@bitkyc08/opencodex 2.14.0 → 2.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/gui/dist/assets/index-DWhX3yMp.css +1 -0
- package/gui/dist/assets/index-DuaUVm_d.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +31 -2
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +2 -1
- package/src/adapters/openai-responses-url.ts +14 -0
- package/src/adapters/openai-responses.ts +2 -2
- package/src/codex/auth-api.ts +2 -74
- package/src/codex/catalog/parsing.ts +10 -6
- package/src/codex/catalog/sync.ts +10 -1
- package/src/codex/features.ts +14 -3
- package/src/codex/model-cache.ts +7 -1
- package/src/codex/native-main-claim.ts +13 -2
- package/src/generated/compatibility-version.json +35 -19
- package/src/lab/ledger/store.ts +0 -18
- package/src/lab/subject/installation-salt.ts +13 -2
- package/src/providers/registry.ts +5 -5
- package/src/router.ts +12 -1
- package/src/server/index.ts +1 -1
- package/src/server/management/config-routes.ts +51 -16
- package/src/server/responses/core.ts +0 -1
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/ws-upstream.ts +199 -0
- package/src/vision/index.ts +25 -4
- package/src/vision/timeout-bounds.ts +9 -0
- package/gui/dist/assets/index-BNVYzdn0.css +0 -1
- package/gui/dist/assets/index-Co12XTT-.js +0 -76
|
@@ -1300,10 +1300,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1300
1300
|
modelContextWindows: CLINE_PASS_MODEL_CONTEXT_WINDOWS,
|
|
1301
1301
|
modelInputModalities: CLINE_PASS_MODEL_INPUT_MODALITIES,
|
|
1302
1302
|
noVisionModels: CLINE_PASS_TEXT_ONLY_MODELS,
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
reasoningEfforts: ["low"],
|
|
1303
|
+
// Live-probed 2026-08-13 across every static ClinePass model: the gateway accepts and
|
|
1304
|
+
// validates low/medium/high/xhigh/max, and rejects an invalid sentinel. Preserve the
|
|
1305
|
+
// caller's requested tier and let ClinePass own any backend-specific normalization.
|
|
1306
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
|
|
1307
1307
|
reasoningWireFormat: "gateway-object",
|
|
1308
1308
|
preserveCustomDestination: true,
|
|
1309
1309
|
note: "ClinePass subscription API. Uses a Cline API key and the full cline-pass/<model> upstream slug; quota is shared across the account's rolling 5-hour, weekly, and monthly limits.",
|
|
@@ -1789,7 +1789,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1789
1789
|
liveModels: true,
|
|
1790
1790
|
preserveCustomDestination: true,
|
|
1791
1791
|
// /v1/models is documented as callable authenticated or unauthenticated, so a 2xx catalog
|
|
1792
|
-
// response cannot prove the supplied Bearer key is valid.
|
|
1792
|
+
// response cannot prove that the supplied Bearer key is valid.
|
|
1793
1793
|
apiKeyValidation: "unknown",
|
|
1794
1794
|
// Featherless documents tool calling, but not a provider-wide parallel tool-call contract.
|
|
1795
1795
|
parallelToolCalls: false,
|
package/src/router.ts
CHANGED
|
@@ -267,6 +267,14 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
267
267
|
const modelReasoningEffortMap = mergeNestedRecord(registryEntry.modelReasoningEffortMap, provider.modelReasoningEffortMap);
|
|
268
268
|
const modelReasoningEfforts = mergeStringArrayRecord(registryEntry.modelReasoningEfforts, provider.modelReasoningEfforts);
|
|
269
269
|
const modelDefaultReasoningEfforts = mergeRecordFill(registryEntry.modelDefaultReasoningEfforts, provider.modelDefaultReasoningEfforts);
|
|
270
|
+
// Key-login used to persist this exact low-only ClinePass capability seed. Once the gateway's
|
|
271
|
+
// wider input ladder was live-verified, leaving that generated row untouched would keep old
|
|
272
|
+
// installs clamped forever. This branch is reached only after canonical transport matching, so
|
|
273
|
+
// same-named custom destinations and every other explicit ladder still retain user precedence.
|
|
274
|
+
const repairLegacyClinePassReasoningEfforts = providerName === "cline-pass"
|
|
275
|
+
&& provider.reasoningWireFormat === "gateway-object"
|
|
276
|
+
&& provider.reasoningEfforts?.length === 1
|
|
277
|
+
&& provider.reasoningEfforts[0] === "low";
|
|
270
278
|
const modelContextWindows = providerName === OPENAI_API_PROVIDER_ID
|
|
271
279
|
? mergePositiveNumberCaps(registryEntry.modelContextWindows, provider.modelContextWindows)
|
|
272
280
|
: mergeRecordFill(registryEntry.modelContextWindows, provider.modelContextWindows);
|
|
@@ -342,7 +350,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
342
350
|
...(provider.project === undefined && registryEntry.project !== undefined ? { project: registryEntry.project } : {}),
|
|
343
351
|
...(provider.location === undefined && registryEntry.location !== undefined ? { location: registryEntry.location } : {}),
|
|
344
352
|
...(provider.contextWindow === undefined && registryEntry.contextWindow !== undefined ? { contextWindow: registryEntry.contextWindow } : {}),
|
|
345
|
-
...(provider.reasoningEfforts === undefined
|
|
353
|
+
...((provider.reasoningEfforts === undefined || repairLegacyClinePassReasoningEfforts)
|
|
354
|
+
&& registryEntry.reasoningEfforts !== undefined
|
|
355
|
+
? { reasoningEfforts: [...registryEntry.reasoningEfforts] }
|
|
356
|
+
: {}),
|
|
346
357
|
...(provider.escapeBuiltinToolNames === undefined && registryEntry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: registryEntry.escapeBuiltinToolNames } : {}),
|
|
347
358
|
...(provider.keyOptional === undefined && registryEntry.keyOptional !== undefined ? { keyOptional: registryEntry.keyOptional } : {}),
|
|
348
359
|
...(provider.modelSuffixBracketStrip === undefined && registryEntry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: registryEntry.modelSuffixBracketStrip } : {}),
|
package/src/server/index.ts
CHANGED
|
@@ -485,7 +485,7 @@ export function warnAgentTaskRecoveryStartup(config: {
|
|
|
485
485
|
if (config.agentTaskRecovery?.enabled !== true) return;
|
|
486
486
|
console.warn("⚠️ Experimental encrypted V2 task recovery is enabled.");
|
|
487
487
|
console.warn(" A scoped cache miss may send an additional authenticated request to ChatGPT and may consume quota or add latency; concurrent misses can share one request.");
|
|
488
|
-
console.warn(" Recovered
|
|
488
|
+
console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior.");
|
|
489
489
|
}
|
|
490
490
|
|
|
491
491
|
export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
|
|
@@ -54,7 +54,16 @@ import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
|
54
54
|
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
55
55
|
import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../../reasoning-effort";
|
|
56
56
|
import { normalizeVisionReasoningForModel } from "../../vision/reasoning";
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
findAnthropicVisionProvider,
|
|
59
|
+
isValidVisionTimeoutMs,
|
|
60
|
+
MAX_VISION_TIMEOUT_MS,
|
|
61
|
+
MIN_VISION_TIMEOUT_MS,
|
|
62
|
+
resolveEffectiveVisionModel,
|
|
63
|
+
resolveMaxDescriptionsPerTurn,
|
|
64
|
+
resolveVisionBackend,
|
|
65
|
+
resolveVisionTimeoutMs,
|
|
66
|
+
} from "../../vision";
|
|
58
67
|
import {
|
|
59
68
|
visionCandidateRows,
|
|
60
69
|
visionDescriberIsProvablyBlind,
|
|
@@ -108,6 +117,21 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
|
|
|
108
117
|
return { model, reasoning, models };
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
function publicVisionSidecarSettings(
|
|
121
|
+
config: OcxConfig,
|
|
122
|
+
vision: Awaited<ReturnType<typeof sidecarVisionResponseSettings>>,
|
|
123
|
+
) {
|
|
124
|
+
const vs = config.visionSidecar ?? {};
|
|
125
|
+
return {
|
|
126
|
+
enabled: vs.enabled !== false,
|
|
127
|
+
model: vision.model,
|
|
128
|
+
backend: vs.backend,
|
|
129
|
+
reasoning: vision.reasoning,
|
|
130
|
+
maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(vs.maxDescriptionsPerTurn),
|
|
131
|
+
timeoutMs: resolveVisionTimeoutMs(vs.timeoutMs),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
111
135
|
export async function handleConfigRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
112
136
|
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
113
137
|
if (url.pathname === "/api/config" && req.method === "GET") {
|
|
@@ -407,7 +431,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
407
431
|
|
|
408
432
|
if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
|
|
409
433
|
const ws = config.webSearchSidecar ?? {};
|
|
410
|
-
const vs = config.visionSidecar ?? {};
|
|
411
434
|
const vision = await sidecarVisionResponseSettings(config);
|
|
412
435
|
return jsonResponse({
|
|
413
436
|
webSearch: {
|
|
@@ -415,12 +438,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
415
438
|
backend: ws.backend,
|
|
416
439
|
streamRoutedModelOutput: ws.streamRoutedModelOutput === true,
|
|
417
440
|
},
|
|
418
|
-
vision:
|
|
419
|
-
model: vision.model,
|
|
420
|
-
backend: vs.backend,
|
|
421
|
-
reasoning: vision.reasoning,
|
|
422
|
-
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
|
|
423
|
-
},
|
|
441
|
+
vision: publicVisionSidecarSettings(config, vision),
|
|
424
442
|
visionModels: vision.models,
|
|
425
443
|
});
|
|
426
444
|
}
|
|
@@ -435,7 +453,14 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
435
453
|
if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400);
|
|
436
454
|
const body = raw as {
|
|
437
455
|
webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown; streamRoutedModelOutput?: unknown };
|
|
438
|
-
vision?: {
|
|
456
|
+
vision?: {
|
|
457
|
+
model?: unknown;
|
|
458
|
+
backend?: unknown;
|
|
459
|
+
reasoning?: unknown;
|
|
460
|
+
maxDescriptionsPerTurn?: unknown;
|
|
461
|
+
enabled?: unknown;
|
|
462
|
+
timeoutMs?: unknown;
|
|
463
|
+
};
|
|
439
464
|
};
|
|
440
465
|
if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null
|
|
441
466
|
&& body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") {
|
|
@@ -455,6 +480,14 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
455
480
|
|| body.vision.maxDescriptionsPerTurn <= 0)) {
|
|
456
481
|
return jsonResponse({ error: "vision.maxDescriptionsPerTurn must be a positive integer" }, 400);
|
|
457
482
|
}
|
|
483
|
+
if (body.vision && body.vision.enabled !== undefined && typeof body.vision.enabled !== "boolean") {
|
|
484
|
+
return jsonResponse({ error: "vision.enabled must be a boolean" }, 400);
|
|
485
|
+
}
|
|
486
|
+
if (body.vision && body.vision.timeoutMs !== undefined && !isValidVisionTimeoutMs(body.vision.timeoutMs)) {
|
|
487
|
+
return jsonResponse({
|
|
488
|
+
error: `vision.timeoutMs must be an integer from ${MIN_VISION_TIMEOUT_MS} to ${MAX_VISION_TIMEOUT_MS}`,
|
|
489
|
+
}, 400);
|
|
490
|
+
}
|
|
458
491
|
if (body.vision?.reasoning !== undefined && !isVisionReasoningEffort(body.vision.reasoning)) {
|
|
459
492
|
return jsonResponse({ error: `vision.reasoning must be ${VISION_REASONING_EFFORTS.join(", ")}` }, 400);
|
|
460
493
|
}
|
|
@@ -517,6 +550,14 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
517
550
|
if (typeof body.vision.maxDescriptionsPerTurn === "number") {
|
|
518
551
|
config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn;
|
|
519
552
|
}
|
|
553
|
+
if (typeof body.vision.enabled === "boolean") {
|
|
554
|
+
// `true` is the default — drop the key so disable/re-enable does not rewrite the file.
|
|
555
|
+
if (body.vision.enabled) delete config.visionSidecar.enabled;
|
|
556
|
+
else config.visionSidecar.enabled = false;
|
|
557
|
+
}
|
|
558
|
+
if (typeof body.vision.timeoutMs === "number") {
|
|
559
|
+
config.visionSidecar.timeoutMs = body.vision.timeoutMs;
|
|
560
|
+
}
|
|
520
561
|
if (visionReasoningTouched) {
|
|
521
562
|
if (normalizedVisionReasoning === undefined) delete config.visionSidecar.reasoning;
|
|
522
563
|
else config.visionSidecar.reasoning = normalizedVisionReasoning;
|
|
@@ -524,7 +565,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
524
565
|
}
|
|
525
566
|
saveConfigPreservingClaudeCode(config);
|
|
526
567
|
const ws = config.webSearchSidecar ?? {};
|
|
527
|
-
const vs = config.visionSidecar ?? {};
|
|
528
568
|
const vision = await sidecarVisionResponseSettings(config);
|
|
529
569
|
return jsonResponse({
|
|
530
570
|
ok: true,
|
|
@@ -533,12 +573,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
533
573
|
backend: ws.backend,
|
|
534
574
|
streamRoutedModelOutput: ws.streamRoutedModelOutput === true,
|
|
535
575
|
},
|
|
536
|
-
vision:
|
|
537
|
-
model: vision.model,
|
|
538
|
-
backend: vs.backend,
|
|
539
|
-
reasoning: vision.reasoning,
|
|
540
|
-
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
|
|
541
|
-
},
|
|
576
|
+
vision: publicVisionSidecarSettings(config, vision),
|
|
542
577
|
visionModels: vision.models,
|
|
543
578
|
});
|
|
544
579
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
+
import { codexWsUpstreamFetch, shouldUseCodexWsUpstream } from "./ws-upstream";
|
|
2
3
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
4
|
import {
|
|
4
5
|
getConfigPath,
|
|
@@ -131,7 +132,17 @@ export function safeOriginLabel(url: string): string {
|
|
|
131
132
|
|
|
132
133
|
|
|
133
134
|
export function providerFetch(provider: OcxProviderConfig): typeof globalThis.fetch {
|
|
134
|
-
|
|
135
|
+
const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
|
|
136
|
+
// ChatGPT Codex backend: streaming turns ride the responses_websockets
|
|
137
|
+
// transport (measured ~3s faster TTFT than the SSE POST queue); everything
|
|
138
|
+
// else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
|
|
139
|
+
const wrapped = (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
140
|
+
if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init)) {
|
|
141
|
+
return codexWsUpstreamFetch(input, init, base);
|
|
142
|
+
}
|
|
143
|
+
return base(input, init);
|
|
144
|
+
};
|
|
145
|
+
return wrapped as typeof globalThis.fetch;
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Upstream WebSocket transport for the ChatGPT Codex backend.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: the Codex backend serves the responses_websockets path from
|
|
4
|
+
// a measurably faster queue than the plain SSE POST path. Measured 2026-08-12
|
|
5
|
+
// KST (same account, same payload, strictly sequential): gpt-5.6-luna TTFT p50
|
|
6
|
+
// ~1.0s over WS vs ~3.9s over SSE. Codex CLI itself defaults to the WS
|
|
7
|
+
// transport; opencodex previously always POSTed SSE, which is where its extra
|
|
8
|
+
// 2-3s of TTFT came from.
|
|
9
|
+
//
|
|
10
|
+
// The wrapper only swaps the transport. It dials wss:// with the same headers,
|
|
11
|
+
// sends the JSON body as a single `response.create` frame, and re-encodes the
|
|
12
|
+
// returned event frames as an SSE byte stream, so every downstream consumer
|
|
13
|
+
// (passthrough relay, adapter parsers, usage sniffing) is unchanged.
|
|
14
|
+
|
|
15
|
+
const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
16
|
+
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
|
|
17
|
+
const WS_BETA = "responses_websockets=2026-02-06";
|
|
18
|
+
// If the 101 never arrives (network black hole), give SSE a chance well before
|
|
19
|
+
// the caller's connect timeout (default 200s) would fire.
|
|
20
|
+
const UPGRADE_DEADLINE_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
export function shouldUseCodexWsUpstream(url: string, init?: RequestInit): boolean {
|
|
23
|
+
if (url !== CODEX_RESPONSES_HTTP_URL) return false;
|
|
24
|
+
if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
|
|
25
|
+
const body = init?.body;
|
|
26
|
+
if (typeof body !== "string") return false;
|
|
27
|
+
// Only root-level stream:true selects WS: JSON-mode calls keep the HTTP path
|
|
28
|
+
// because the WS path only speaks the event protocol, and a nested
|
|
29
|
+
// {"metadata":{"stream":true}} must not flip the transport. Parsing (not
|
|
30
|
+
// substring matching) also keeps whitespace-formatted bodies routable.
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(body) as unknown;
|
|
33
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
34
|
+
&& (parsed as Record<string, unknown>).stream === true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function codexWsUpstreamFetch(
|
|
41
|
+
url: string,
|
|
42
|
+
init: RequestInit,
|
|
43
|
+
sseFallback: typeof globalThis.fetch,
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
const signal = init.signal ?? undefined;
|
|
46
|
+
if (signal?.aborted) {
|
|
47
|
+
return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let frameText: string;
|
|
51
|
+
try {
|
|
52
|
+
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
53
|
+
// The WS create frame is implicitly streaming; the backend rejects the
|
|
54
|
+
// HTTP-only `stream` flag inside a frame.
|
|
55
|
+
delete body.stream;
|
|
56
|
+
frameText = JSON.stringify({ ...body, type: "response.create" });
|
|
57
|
+
} catch {
|
|
58
|
+
return sseFallback(url, init);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const headers: Record<string, string> = {};
|
|
62
|
+
new Headers(init.headers ?? {}).forEach((value, key) => {
|
|
63
|
+
// HTTP-body framing headers do not apply to a WS handshake.
|
|
64
|
+
if (key === "content-type" || key === "content-length" || key === "accept" || key === "accept-encoding") return;
|
|
65
|
+
headers[key] = value;
|
|
66
|
+
});
|
|
67
|
+
headers["openai-beta"] = headers["openai-beta"]
|
|
68
|
+
? headers["openai-beta"].includes("responses_websockets")
|
|
69
|
+
? headers["openai-beta"]
|
|
70
|
+
: `${headers["openai-beta"]}, ${WS_BETA}`
|
|
71
|
+
: WS_BETA;
|
|
72
|
+
// A genuine caller `originator` is already in these headers via the forward
|
|
73
|
+
// set. Never fabricate one here: pool/forward traffic must not impersonate
|
|
74
|
+
// Codex CLI, per the metadata-integrity contract. (The backend's fast lane
|
|
75
|
+
// keys on WS + originator, so callers without the tag simply keep their own
|
|
76
|
+
// provenance and scheduling.)
|
|
77
|
+
|
|
78
|
+
return new Promise<Response>((resolve, reject) => {
|
|
79
|
+
let ws: WebSocket;
|
|
80
|
+
try {
|
|
81
|
+
// Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
|
|
82
|
+
ws = new WebSocket(CODEX_RESPONSES_WS_URL, { headers } as unknown as string[]);
|
|
83
|
+
} catch {
|
|
84
|
+
resolve(sseFallback(url, init));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let opened = false;
|
|
89
|
+
let settledPreOpen = false;
|
|
90
|
+
let terminal = false;
|
|
91
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
92
|
+
const encoder = new TextEncoder();
|
|
93
|
+
|
|
94
|
+
const upgradeTimer = setTimeout(() => {
|
|
95
|
+
if (opened || settledPreOpen) return;
|
|
96
|
+
settledPreOpen = true;
|
|
97
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
98
|
+
resolve(sseFallback(url, init));
|
|
99
|
+
}, UPGRADE_DEADLINE_MS);
|
|
100
|
+
|
|
101
|
+
const onAbort = () => {
|
|
102
|
+
if (!opened) {
|
|
103
|
+
if (settledPreOpen) return;
|
|
104
|
+
// Settle BEFORE close(): the close handler treats a pre-open close as
|
|
105
|
+
// an upgrade rejection and would dial the SSE fallback for a request
|
|
106
|
+
// the caller just cancelled.
|
|
107
|
+
settledPreOpen = true;
|
|
108
|
+
clearTimeout(upgradeTimer);
|
|
109
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
110
|
+
reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
114
|
+
if (controller && !terminal) {
|
|
115
|
+
terminal = true;
|
|
116
|
+
// Mirror an aborted fetch: the body read rejects with the abort reason.
|
|
117
|
+
try { controller.error(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); } catch { /* stream already done */ }
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
|
|
122
|
+
ws.addEventListener("open", () => {
|
|
123
|
+
if (settledPreOpen) return;
|
|
124
|
+
clearTimeout(upgradeTimer);
|
|
125
|
+
try {
|
|
126
|
+
ws.send(frameText);
|
|
127
|
+
} catch {
|
|
128
|
+
// send() throwing means the frame never left, so no upstream turn
|
|
129
|
+
// started and the SSE resend cannot double-generate. Falling back
|
|
130
|
+
// (instead of erroring a synthetic 200 body) keeps the pre-stream
|
|
131
|
+
// HTTP error/refresh/failover machinery in charge.
|
|
132
|
+
settledPreOpen = true;
|
|
133
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
134
|
+
resolve(sseFallback(url, init));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
opened = true;
|
|
138
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
139
|
+
start(c) { controller = c; },
|
|
140
|
+
cancel() { try { ws.close(); } catch { /* already closing */ } },
|
|
141
|
+
});
|
|
142
|
+
resolve(new Response(stream, {
|
|
143
|
+
status: 200,
|
|
144
|
+
// The 101 response headers (x-codex-*-reset-at quota hints) are not
|
|
145
|
+
// exposed by Bun's WebSocket; the periodic quota poller covers those.
|
|
146
|
+
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
|
147
|
+
}));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
ws.addEventListener("message", (event) => {
|
|
151
|
+
if (!controller || terminal) return;
|
|
152
|
+
const text = typeof event.data === "string" ? event.data : "";
|
|
153
|
+
if (!text) return;
|
|
154
|
+
let type: unknown;
|
|
155
|
+
try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; }
|
|
156
|
+
if (typeof type !== "string") return;
|
|
157
|
+
// Relay only the event surface the SSE path produces today. WS-only
|
|
158
|
+
// frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
|
|
159
|
+
// so downstream clients see exactly the stream shape they always got.
|
|
160
|
+
if (!type.startsWith("response.") && type !== "error") return;
|
|
161
|
+
try {
|
|
162
|
+
controller.enqueue(encoder.encode(`event: ${type}\ndata: ${text}\n\n`));
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
|
|
167
|
+
terminal = true;
|
|
168
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
169
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
ws.addEventListener("close", () => {
|
|
174
|
+
signal?.removeEventListener("abort", onAbort);
|
|
175
|
+
if (!opened) {
|
|
176
|
+
if (settledPreOpen) return;
|
|
177
|
+
settledPreOpen = true;
|
|
178
|
+
clearTimeout(upgradeTimer);
|
|
179
|
+
// Upgrade rejected (401/403/429/5xx). Retry over plain SSE so the real
|
|
180
|
+
// HTTP status reaches the existing refresh/rotation handlers. No turn
|
|
181
|
+
// started upstream, so the resend cannot double-generate.
|
|
182
|
+
resolve(sseFallback(url, init));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (controller && !terminal) {
|
|
186
|
+
terminal = true;
|
|
187
|
+
// Connection dropped before a Responses terminal event. A clean EOF
|
|
188
|
+
// here would reach clients with no response.completed/failed at all —
|
|
189
|
+
// relaySseWithFailedTail() only synthesizes a failed terminal when the
|
|
190
|
+
// body read THROWS. Error the stream like a reset TCP socket.
|
|
191
|
+
try { controller.error(new Error("codex websocket closed before a Responses terminal event")); } catch { /* stream already done */ }
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
ws.addEventListener("error", () => {
|
|
196
|
+
/* Bun always follows error with close; the close handler settles. */
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
package/src/vision/index.ts
CHANGED
|
@@ -11,6 +11,11 @@ import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
|
|
|
11
11
|
import type { SidecarOutcomeRecorder } from "../web-search/executor";
|
|
12
12
|
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
|
|
13
13
|
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_VISION_TIMEOUT_MS,
|
|
16
|
+
MAX_VISION_TIMEOUT_MS,
|
|
17
|
+
MIN_VISION_TIMEOUT_MS,
|
|
18
|
+
} from "./timeout-bounds";
|
|
14
19
|
|
|
15
20
|
export { describeImage } from "./describe";
|
|
16
21
|
export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
|
|
@@ -23,12 +28,16 @@ export {
|
|
|
23
28
|
visionEligibleModelOptions,
|
|
24
29
|
} from "./eligibility";
|
|
25
30
|
export type { VisionCandidateModel, VisionModelOption, VisionSidecarBackend } from "./eligibility";
|
|
31
|
+
export {
|
|
32
|
+
DEFAULT_VISION_TIMEOUT_MS,
|
|
33
|
+
MAX_VISION_TIMEOUT_MS,
|
|
34
|
+
MIN_VISION_TIMEOUT_MS,
|
|
35
|
+
};
|
|
26
36
|
|
|
27
37
|
const DEFAULT_VISION_MODEL = "gpt-5.4-mini";
|
|
28
38
|
const DEFAULT_ANTHROPIC_VISION_MODEL = "claude-sonnet-5";
|
|
29
|
-
const DEFAULT_TIMEOUT_MS = 45_000;
|
|
30
39
|
const DEFAULT_REASONING: VisionReasoningEffort = "low";
|
|
31
|
-
const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8;
|
|
40
|
+
export const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8;
|
|
32
41
|
const DESCRIPTION_CACHE_MAX_ENTRIES = 256;
|
|
33
42
|
export const VISION_DESCRIPTION_CACHE_MAX_BYTES = 1024 * 1024;
|
|
34
43
|
const descriptionEncoder = new TextEncoder();
|
|
@@ -154,6 +163,18 @@ export function resolveMaxDescriptionsPerTurn(value: unknown): number {
|
|
|
154
163
|
: DEFAULT_MAX_DESCRIPTIONS_PER_TURN;
|
|
155
164
|
}
|
|
156
165
|
|
|
166
|
+
export function isValidVisionTimeoutMs(value: unknown): value is number {
|
|
167
|
+
return typeof value === "number"
|
|
168
|
+
&& Number.isInteger(value)
|
|
169
|
+
&& value >= MIN_VISION_TIMEOUT_MS
|
|
170
|
+
&& value <= MAX_VISION_TIMEOUT_MS;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Runtime config is permissive: malformed or out-of-range values fall back to the default. */
|
|
174
|
+
export function resolveVisionTimeoutMs(value: unknown): number {
|
|
175
|
+
return isValidVisionTimeoutMs(value) ? value : DEFAULT_VISION_TIMEOUT_MS;
|
|
176
|
+
}
|
|
177
|
+
|
|
157
178
|
/** Run `worker` over `items` with bounded concurrency, preserving input order in the result array. */
|
|
158
179
|
async function runBounded<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
|
|
159
180
|
const results = new Array<R>(items.length);
|
|
@@ -271,7 +292,7 @@ export function planVisionSidecar(
|
|
|
271
292
|
settings: {
|
|
272
293
|
model,
|
|
273
294
|
reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING,
|
|
274
|
-
timeoutMs: cfg.timeoutMs
|
|
295
|
+
timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs),
|
|
275
296
|
},
|
|
276
297
|
maxDescriptionsPerTurn,
|
|
277
298
|
};
|
|
@@ -284,7 +305,7 @@ export function planVisionSidecar(
|
|
|
284
305
|
settings: {
|
|
285
306
|
model,
|
|
286
307
|
reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING,
|
|
287
|
-
|
|
308
|
+
timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs),
|
|
288
309
|
},
|
|
289
310
|
maxDescriptionsPerTurn,
|
|
290
311
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inclusive integer bounds for `visionSidecar.timeoutMs`.
|
|
3
|
+
* The ceiling is the 32-bit timer delay used by `signalWithTimeout` / `setTimeout`.
|
|
4
|
+
* This module is the single authority: the runtime, management API, and Dashboard
|
|
5
|
+
* import these numbers rather than restating them.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_VISION_TIMEOUT_MS = 45_000;
|
|
8
|
+
export const MIN_VISION_TIMEOUT_MS = 1;
|
|
9
|
+
export const MAX_VISION_TIMEOUT_MS = 2_147_483_647;
|