@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.
@@ -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
- // Only low and the `reasoning: { enabled, effort }` request shape have been accepted by a live
1304
- // ClinePass request. Neither wire detail is currently documented, so clamp higher Codex
1305
- // requests to the verified tier until the gateway documents or is live-probed more broadly.
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 && registryEntry.reasoningEfforts !== undefined ? { reasoningEfforts: registryEntry.reasoningEfforts } : {}),
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 } : {}),
@@ -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 model output is retained only in a bounded in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior.");
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 { findAnthropicVisionProvider, resolveEffectiveVisionModel, resolveVisionBackend } from "../../vision";
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?: { model?: unknown; backend?: unknown; reasoning?: unknown; maxDescriptionsPerTurn?: unknown };
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
  }
@@ -1779,7 +1779,6 @@ async function handleResponsesInner(
1779
1779
  );
1780
1780
  }
1781
1781
  }
1782
- toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
1783
1782
  } catch {
1784
1783
  unreadableEncryptedAgentTask = true;
1785
1784
  }
@@ -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
- return (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
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
+ }
@@ -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 ?? DEFAULT_TIMEOUT_MS,
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
- timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS,
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;