@bitkyc08/opencodex 2.42.0 → 2.43.0

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.
Files changed (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -71,6 +71,8 @@ export type EagerRelayHooks = {
71
71
  };
72
72
 
73
73
  export type EagerRelayOptions = {
74
+ /** Caller cancellation, independent of the turn/shutdown controller. */
75
+ clientGoneSignal?: AbortSignal;
74
76
  /** Bounded client queue in bytes; producer pauses above it. Default 8 MiB. */
75
77
  maxQueueBytes?: number;
76
78
  /** Transient-budget owner for the inline-rewrite frame buffer. */
@@ -103,6 +105,7 @@ export function relaySseEagerBounded(
103
105
  const drainMs = opts?.postCancelDrainMs ?? DEFAULT_DRAIN_MS;
104
106
  const drainBytes = opts?.postCancelDrainBytes ?? DEFAULT_DRAIN_BYTES;
105
107
  const now = opts?.now ?? Date.now;
108
+ const clientGoneSignal = opts?.clientGoneSignal;
106
109
 
107
110
  const reader = body.getReader();
108
111
  const terminalEncoder = new TextEncoder();
@@ -179,6 +182,8 @@ export function relaySseEagerBounded(
179
182
  let queuedBytes = 0;
180
183
  let cancelled = false;
181
184
  let done = false;
185
+ let drainedBytes = 0;
186
+ let drainDeadline = Number.POSITIVE_INFINITY;
182
187
  // Pause gate: resolved by client pull, client cancel, or upstream abort so a
183
188
  // paused producer ALWAYS resumes (audit blocker 2 — no deadlock; onDone and
184
189
  // turn unregistration stay reachable, drainAndShutdown never hangs).
@@ -202,6 +207,7 @@ export function relaySseEagerBounded(
202
207
  const fireDone = () => {
203
208
  if (doneFired) return;
204
209
  doneFired = true;
210
+ clientGoneSignal?.removeEventListener("abort", markClientGone);
205
211
  if (drainTimer) { clearTimeout(drainTimer); drainTimer = null; }
206
212
  try { hooks.onDone(); } catch { /* lifecycle callbacks must not break teardown */ }
207
213
  };
@@ -217,6 +223,20 @@ export function relaySseEagerBounded(
217
223
  (drainTimer as { unref?: () => void }).unref?.();
218
224
  };
219
225
 
226
+ const markClientGone = () => {
227
+ if (cancelled || doneFired) return;
228
+ cancelled = true;
229
+ drainDeadline = now() + drainMs;
230
+ armDrainTimer();
231
+ wakeUp();
232
+ };
233
+ const canDeliver = () => {
234
+ // A rejected fetch read can settle before every abort listener is dispatched.
235
+ // Also re-check after error serialization, which can re-enter caller abort.
236
+ if (clientGoneSignal?.aborted) markClientGone();
237
+ return !cancelled && !upstream.signal.aborted;
238
+ };
239
+
220
240
  const producer = async () => {
221
241
  let syntheticKind: "incomplete" | "failed" | null = null;
222
242
  let deliveryFallbackSent = false;
@@ -237,6 +257,7 @@ export function relaySseEagerBounded(
237
257
  for (;;) {
238
258
  const result = await reader.read();
239
259
  const { done: upstreamDone, value } = result;
260
+ if (clientGoneSignal?.aborted) markClientGone();
240
261
  // A chunk that already settled is INSPECTED before abort is honored. A read
241
262
  // can settle with a real chunk in the same tick the signal fires (post-cancel
242
263
  // drain: the terminal frame arrives, then the drain timer aborts upstream).
@@ -262,7 +283,7 @@ export function relaySseEagerBounded(
262
283
  }
263
284
  if (rewriteFailed) {
264
285
  const safeTail = encodeFailedTail(rewriteError);
265
- if (safeTail && !cancelled && !upstream.signal.aborted) {
286
+ if (safeTail && canDeliver()) {
266
287
  if (!hooks.sawTerminal()) syntheticKind = "failed";
267
288
  queuedBytes += safeTail.byteLength;
268
289
  try { controllerRef?.enqueue(safeTail); } catch { /* client already torn down */ }
@@ -279,7 +300,7 @@ export function relaySseEagerBounded(
279
300
  queuedBytes += terminalSentinel.byteLength;
280
301
  try { controllerRef?.enqueue(terminalSentinel); } catch { /* client already gone */ }
281
302
  }
282
- } else if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
303
+ } else if (!hooks.sawTerminal() && canDeliver()) {
283
304
  // A clean 200 EOF without a Responses terminal must be visible to
284
305
  // Codex as one incomplete turn, followed by the normal sentinel.
285
306
  queuedBytes += adapterEofFrame.byteLength + terminalSentinel.byteLength;
@@ -322,9 +343,7 @@ export function relaySseEagerBounded(
322
343
  controllerRef?.enqueue(outbound);
323
344
  } catch {
324
345
  // Controller already torn down (client went away without cancel()).
325
- cancelled = true;
326
- drainDeadline = now() + drainMs;
327
- armDrainTimer();
346
+ markClientGone();
328
347
  continue;
329
348
  }
330
349
  }
@@ -339,11 +358,12 @@ export function relaySseEagerBounded(
339
358
  reader.cancel("Responses terminal event received").catch(() => {});
340
359
  break;
341
360
  }
342
- while (queuedBytes > maxQueueBytes && !cancelled && !upstream.signal.aborted) {
361
+ while (queuedBytes > maxQueueBytes && canDeliver()) {
343
362
  await paused();
344
363
  }
345
364
  }
346
365
  } catch (err) {
366
+ if (clientGoneSignal?.aborted) markClientGone();
347
367
  // Upstream read failure. Distinguish genuine mid-stream reset from
348
368
  // abort-driven teardown (shutdown/cancel-expiry) — audit M3.
349
369
  // A read can fail after delivering an unterminated terminal block. Flush
@@ -379,36 +399,36 @@ export function relaySseEagerBounded(
379
399
  clientTail = new Uint8Array(0);
380
400
  }
381
401
  }
382
- if (clientTail.byteLength > 0 && !cancelled && !upstream.signal.aborted) {
402
+ if (clientTail.byteLength > 0 && canDeliver()) {
383
403
  queuedBytes += clientTail.byteLength;
384
404
  try { controllerRef?.enqueue(clientTail); } catch { /* client already torn down */ }
385
405
  }
386
- if (rewriteFailed && !cancelled && !upstream.signal.aborted) {
406
+ if (rewriteFailed && canDeliver()) {
387
407
  // Never bypass a client rewrite after it fails: boundedTail can contain
388
408
  // provider metadata or content that the active rewrite was required to
389
409
  // remove. Emit one safe failed envelope instead. When inspection has
390
410
  // already reported the real upstream terminal, this is a delivery
391
411
  // fallback only and must not create a second accounting outcome.
392
412
  const safeTail = encodeFailedTail(rewriteError ?? err);
393
- if (safeTail && !cancelled && !upstream.signal.aborted) {
413
+ if (safeTail && canDeliver()) {
394
414
  if (!hooks.sawTerminal()) syntheticKind = "failed";
395
415
  deliveryFallbackSent = true;
396
416
  queuedBytes += safeTail.byteLength;
397
417
  try { controllerRef?.enqueue(safeTail); } catch { /* client already torn down */ }
398
418
  try { controllerRef?.close(); } catch { /* client already gone */ }
399
419
  }
400
- } else if (tailTerminal && !cancelled && !upstream.signal.aborted) {
420
+ } else if (tailTerminal && canDeliver()) {
401
421
  if (!tailDone) {
402
422
  queuedBytes += terminalSentinel.byteLength;
403
423
  try { controllerRef?.enqueue(terminalSentinel); } catch { /* client already gone */ }
404
424
  }
405
- } else if (!tailTerminal && !cancelled && !upstream.signal.aborted) {
425
+ } else if (!tailTerminal && canDeliver()) {
406
426
  // Serializing `err` can run user-defined accessors (Error.message
407
427
  // getters, toString) that re-entrantly cancel the client or abort the
408
428
  // upstream. Build the tail FIRST, then re-check eligibility before
409
429
  // committing to the synthetic terminal (adversarial review blocker).
410
430
  const tail = encodeFailedTail(err);
411
- if (tail && !cancelled && !upstream.signal.aborted) {
431
+ if (tail && canDeliver()) {
412
432
  // Inspection and client framing have separate bounded parsers. If
413
433
  // inspection resynchronized after an oversized frame and observed a
414
434
  // later real terminal, it still must not suppress a terminal delivery
@@ -429,7 +449,7 @@ export function relaySseEagerBounded(
429
449
  frameBufferBytes = 0;
430
450
  }
431
451
  terminalBoundary.dispose();
432
- if (syntheticKind) hooks.onSynthetic(syntheticKind);
452
+ if (syntheticKind && canDeliver()) hooks.onSynthetic(syntheticKind);
433
453
  if (cancelled && !hooks.sawTerminal()) {
434
454
  hooks.onClientCancel();
435
455
  }
@@ -437,21 +457,19 @@ export function relaySseEagerBounded(
437
457
  upstream.abort();
438
458
  reader.cancel().catch(() => {});
439
459
  }
440
- if (!cancelled) {
441
- try { controllerRef?.close(); } catch { /* already closed/errored */ }
442
- }
460
+ // Signal-driven cancellation need not have invoked the body's cancel hook.
461
+ try { controllerRef?.close(); } catch { /* already closed/errored */ }
443
462
  try { hooks.disposeInspection?.(); } catch { /* inspection teardown must not block lifecycle cleanup */ }
444
463
  try { activeRewrite?.dispose?.(); } catch { /* rewrite teardown must not block lifecycle cleanup */ }
445
464
  fireDone();
446
465
  }
447
466
  };
448
467
 
449
- let drainedBytes = 0;
450
- let drainDeadline = Number.POSITIVE_INFINITY;
451
-
452
468
  return new ReadableStream<Uint8Array>({
453
469
  start(controller) {
454
470
  controllerRef = controller;
471
+ clientGoneSignal?.addEventListener("abort", markClientGone, { once: true });
472
+ if (clientGoneSignal?.aborted) markClientGone();
455
473
  void producer();
456
474
  },
457
475
  pull() {
@@ -462,10 +480,7 @@ export function relaySseEagerBounded(
462
480
  wakeUp();
463
481
  },
464
482
  cancel() {
465
- cancelled = true;
466
- drainDeadline = now() + drainMs;
467
- armDrainTimer();
468
- wakeUp();
483
+ markClientGone();
469
484
  },
470
485
  });
471
486
  }
@@ -1275,6 +1275,7 @@ function startBoundedInspectionPump(options: InspectionPumpOptions): void {
1275
1275
  try {
1276
1276
  for (;;) {
1277
1277
  const { done, value } = await reader.read();
1278
+ if (clientGoneSignal?.aborted) markClientGone();
1278
1279
  if (drainStopped) {
1279
1280
  // stopDrain() cancelled the reader; the settled read is the wake-up.
1280
1281
  clientGoneWithoutTerminal = !inspector.terminalSeen();
@@ -1312,6 +1313,9 @@ function startBoundedInspectionPump(options: InspectionPumpOptions): void {
1312
1313
  }
1313
1314
  }
1314
1315
  } catch {
1316
+ // Bun can settle a fetch body read before dispatching all abort listeners.
1317
+ // Observe the signal itself before classifying that rejection as upstream.
1318
+ if (clientGoneSignal?.aborted) markClientGone();
1315
1319
  // A read error can follow a final SSE block without its blank-line
1316
1320
  // delimiter. Flush that candidate before classifying the transport as a
1317
1321
  // synthetic reset; otherwise a real completed/failed/policy terminal is
@@ -594,6 +594,12 @@ export function requestLogErrorCode(
594
594
  export function requestLogSpeedLabel(serviceTier: string | undefined): string | undefined {
595
595
  const normalized = serviceTier?.trim().toLowerCase();
596
596
  if (normalized === "priority" || normalized === "fast") return "fast";
597
+ // Ultra Fast is labelled even though nothing in the shipped catalog advertises it: the
598
+ // reporter on #3429 reached it by hand-editing their own catalog, the request completed,
599
+ // and the Logs column stayed empty because this returned undefined. A tier the proxy
600
+ // forwarded but refuses to name is an observability hole, not a feature gate — the flag
601
+ // decides whether the tier survives, not whether we admit to carrying it.
602
+ if (normalized === "ultrafast") return "ultrafast";
597
603
  return undefined;
598
604
  }
599
605
 
@@ -0,0 +1,65 @@
1
+ export const CODEX_WS_ID_MAX_BYTES = 4096;
2
+ export const CODEX_WS_MAX_TRACKED_ITEMS = 10_000;
3
+ const MAX_TRACKED_ID_BYTES = 1024 * 1024;
4
+
5
+ function record(value: unknown): value is Record<string, unknown> {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ function id(value: unknown): value is string {
9
+ return typeof value === "string" && value.length > 0 && !/[\u0000-\u001f\u007f]/.test(value)
10
+ && Buffer.byteLength(value) <= CODEX_WS_ID_MAX_BYTES;
11
+ }
12
+
13
+ /** A cold incompatible response may finish one-shot; a reused socket cannot mix owners. */
14
+ export class CodexWsCorrelation {
15
+ private responseId: string | null = null;
16
+ private reusable = true;
17
+ private readonly items = new Set<string>();
18
+ private itemBytes = 0;
19
+
20
+ constructor(private readonly strict: boolean, private readonly previouslyCompleted: (id: string) => boolean) {}
21
+
22
+ private mismatch(): void {
23
+ this.reusable = false;
24
+ if (this.strict) throw new Error("codex websocket response identity mismatch");
25
+ }
26
+
27
+ accept(event: Record<string, unknown>): void {
28
+ if (event.stream_id !== undefined) { this.mismatch(); return; }
29
+ if (event.type === "error") return;
30
+ const response = record(event.response) ? event.response : undefined;
31
+ if (event.type === "response.created") {
32
+ const next = response?.id;
33
+ if (!id(next) || this.responseId !== null || this.previouslyCompleted(next)) {
34
+ this.mismatch();
35
+ return;
36
+ }
37
+ this.responseId = next;
38
+ return;
39
+ }
40
+ if (!this.responseId) { this.mismatch(); return; }
41
+ if ((response && response.id !== this.responseId)
42
+ || (event.response_id !== undefined && event.response_id !== this.responseId)) this.mismatch();
43
+ const item = record(event.item) ? event.item : undefined;
44
+ if (event.type === "response.output_item.added") {
45
+ if (!id(item?.id) || this.items.has(item.id)) { this.mismatch(); return; }
46
+ this.itemBytes += Buffer.byteLength(item.id);
47
+ if (this.items.size >= CODEX_WS_MAX_TRACKED_ITEMS || this.itemBytes > MAX_TRACKED_ID_BYTES) {
48
+ throw new Error("codex websocket correlation exceeds its bounded item budget");
49
+ }
50
+ this.items.add(item.id);
51
+ return;
52
+ }
53
+ const itemId = event.item_id ?? item?.id;
54
+ if (itemId !== undefined && (!id(itemId) || !this.items.has(itemId))) this.mismatch();
55
+ if (typeof event.type === "string" && event.type.endsWith(".delta") && itemId === undefined) this.mismatch();
56
+ }
57
+
58
+ completed(event: Record<string, unknown>): string | null {
59
+ const response = record(event.response) ? event.response : undefined;
60
+ return this.reusable && event.type === "response.completed" && response?.status === "completed"
61
+ && response.id === this.responseId ? this.responseId : null;
62
+ }
63
+
64
+ finish(): void { this.items.clear(); this.itemBytes = 0; }
65
+ }
@@ -0,0 +1,261 @@
1
+ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
2
+ import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata";
3
+ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request";
4
+ import { CodexWsCorrelation } from "./codex-ws-correlation";
5
+ import type { CodexWsSession } from "./codex-ws-session";
6
+ import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
7
+ MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage } from "./codex-ws-wire";
8
+
9
+ interface ExchangeOptions {
10
+ session: CodexWsSession;
11
+ url: string;
12
+ init: RequestInit;
13
+ prepared: PreparedCodexWsRequest;
14
+ sseFallback: typeof globalThis.fetch;
15
+ onQuota?: CodexWsQuotaObserver;
16
+ beforeDispatch?: (headers: Headers) => void;
17
+ }
18
+
19
+ /** The sole SSE exchange state machine for both one-shot and retained sockets. */
20
+ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
21
+ const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch } = options;
22
+ const { frameText, headers } = prepared;
23
+ const signal = init.signal ?? undefined;
24
+ return new Promise<Response>((resolve, reject) => {
25
+ const ws = session.socket;
26
+
27
+ let opened = session.opened;
28
+ let settledPreOpen = false;
29
+ let sent = false;
30
+ let received = false;
31
+ let responseCommitted = false;
32
+ let terminal = false;
33
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
34
+ const encoder = new TextEncoder();
35
+ const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
36
+ const correlation = session.retainable ? new CodexWsCorrelation(session.reused, id => session.hasCompleted(id)) : null;
37
+ let detachOwner = () => {};
38
+ let preludeTimer: ReturnType<typeof setTimeout> | undefined;
39
+ const stream = new ReadableStream<Uint8Array>({
40
+ start(c) { controller = c; },
41
+ cancel() {
42
+ if (terminal) return;
43
+ terminal = true;
44
+ cleanup();
45
+ session.dispose();
46
+ },
47
+ }, new ByteLengthQueuingStrategy({ highWaterMark: MAX_CODEX_WS_QUEUE_BYTES }));
48
+
49
+ const cleanup = () => {
50
+ clearTimeout(upgradeTimer);
51
+ clearTimeout(preludeTimer);
52
+ signal?.removeEventListener("abort", onAbort);
53
+ metadata?.finish();
54
+ correlation?.finish();
55
+ detachOwner();
56
+ ws.removeEventListener("open", onOpen);
57
+ ws.removeEventListener("message", onMessage);
58
+ ws.removeEventListener("close", onClose);
59
+ ws.removeEventListener("error", onError);
60
+ };
61
+
62
+ const commitResponse = () => {
63
+ if (responseCommitted) return;
64
+ responseCommitted = true;
65
+ clearTimeout(preludeTimer);
66
+ const responseHeaders = metadata?.snapshot() ?? new Headers();
67
+ responseHeaders.set("content-type", "text/event-stream; charset=utf-8");
68
+ const response = new Response(stream, { status: 200, headers: responseHeaders });
69
+ metadata?.commit();
70
+ markCodexWsResponse(response, Boolean(metadata && onQuota));
71
+ resolve(response);
72
+ };
73
+
74
+ const failStream = (error: unknown) => {
75
+ if (terminal) return;
76
+ terminal = true;
77
+ // A frame may already be executing upstream. Settle as a body failure,
78
+ // never a fetch rejection/5xx that the pre-stream wrapper could resend.
79
+ if (sent) commitResponse();
80
+ cleanup();
81
+ try { controller?.error(typeof error === "string" ? new Error(error) : error); } catch { /* stream already done */ }
82
+ session.dispose();
83
+ };
84
+
85
+ const upgradeTimer = setTimeout(() => {
86
+ if (opened || settledPreOpen) return;
87
+ settledPreOpen = true;
88
+ cleanup();
89
+ session.dispose();
90
+ resolve(sseFallback(url, init));
91
+ }, UPGRADE_DEADLINE_MS);
92
+
93
+ const cancelExchange = (reason: unknown) => {
94
+ if (terminal || settledPreOpen) return;
95
+ if (!sent) {
96
+ settledPreOpen = true;
97
+ terminal = true;
98
+ cleanup();
99
+ session.dispose();
100
+ reject(reason);
101
+ return;
102
+ }
103
+ failStream(reason);
104
+ };
105
+ const onAbort = () => cancelExchange(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
106
+ signal?.addEventListener("abort", onAbort, { once: true });
107
+
108
+ const onOpen = () => {
109
+ if (settledPreOpen) return;
110
+ clearTimeout(upgradeTimer);
111
+ opened = true;
112
+ try {
113
+ beforeDispatch?.(new Headers(headers));
114
+ } catch (error) {
115
+ // Settle and detach before close: a synchronous close event must not resend over SSE.
116
+ settledPreOpen = true;
117
+ terminal = true;
118
+ cleanup();
119
+ ws.removeEventListener("open", onOpen);
120
+ ws.removeEventListener("message", onMessage);
121
+ ws.removeEventListener("close", onClose);
122
+ ws.removeEventListener("error", onError);
123
+ session.dispose();
124
+ reject(error);
125
+ return;
126
+ }
127
+ if (terminal || settledPreOpen || signal?.aborted) return;
128
+ sent = true;
129
+ try {
130
+ ws.send(frameText);
131
+ } catch {
132
+ if (received || responseCommitted) {
133
+ if (terminal) session.dispose();
134
+ failStream("codex websocket send failed after response activity");
135
+ return;
136
+ }
137
+ // send() throwing means the frame never left, so no upstream turn
138
+ // started and the SSE resend cannot double-generate. Falling back
139
+ // (instead of erroring a synthetic 200 body) keeps the pre-stream
140
+ // HTTP error/refresh/failover machinery in charge.
141
+ settledPreOpen = true;
142
+ sent = false;
143
+ cleanup();
144
+ session.dispose();
145
+ resolve(sseFallback(url, init));
146
+ return;
147
+ }
148
+ if (!metadata) commitResponse();
149
+ else if (!responseCommitted && !terminal) {
150
+ preludeTimer = setTimeout(() => failStream("codex websocket response prelude timed out"), CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS);
151
+ }
152
+ };
153
+
154
+ const onMessage = (event: MessageEvent) => {
155
+ if (!controller || terminal) return;
156
+ received = true;
157
+ const text = typeof event.data === "string" ? event.data : "";
158
+ if (!text) return;
159
+ // UTF-8 byte length is always at least the JS string length. Reject this
160
+ // cheap lower bound before parsing so an obviously oversized frame does
161
+ // not create another large object graph.
162
+ if (text.length > MAX_CODEX_WS_FRAME_BYTES) {
163
+ failStream("codex websocket frame exceeds the response size limit");
164
+ return;
165
+ }
166
+ const rawEncodedText = encoder.encode(text);
167
+ if (rawEncodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
168
+ failStream("codex websocket frame exceeds the response size limit");
169
+ return;
170
+ }
171
+ const normalized = normalizeResponsesWsRelayEvent(text);
172
+ if (!normalized) return;
173
+ const { type } = normalized;
174
+ let relayText = normalized.text;
175
+ let controlFrame = false;
176
+ if (metadata) {
177
+ try {
178
+ const sanitized = metadata.consume(normalized.payload, rawEncodedText.byteLength);
179
+ if (sanitized !== null) {
180
+ relayText = sanitized;
181
+ controlFrame = true;
182
+ }
183
+ } catch (error) {
184
+ failStream(error);
185
+ return;
186
+ }
187
+ }
188
+ const encodedText = relayText === text ? rawEncodedText : encoder.encode(relayText);
189
+ if (encodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
190
+ failStream("codex websocket frame exceeds the response size limit");
191
+ return;
192
+ }
193
+ if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
194
+ if (!controlFrame) {
195
+ try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; }
196
+ commitResponse();
197
+ }
198
+ const prefix = encoder.encode(`event: ${type}\ndata: `);
199
+ const suffix = encoder.encode("\n\n");
200
+ const frameBytes = prefix.byteLength + encodedText.byteLength + suffix.byteLength;
201
+ if (frameBytes > MAX_CLIENT_SSE_FRAME_BYTES) {
202
+ failStream("codex websocket frame exceeds the response size limit");
203
+ return;
204
+ }
205
+ const availableBytes = controller.desiredSize ?? 0;
206
+ if (frameBytes > availableBytes) {
207
+ failStream("codex websocket response exceeded the buffered queue limit");
208
+ return;
209
+ }
210
+ const sseFrame = new Uint8Array(frameBytes);
211
+ sseFrame.set(prefix);
212
+ sseFrame.set(encodedText, prefix.byteLength);
213
+ sseFrame.set(suffix, prefix.byteLength + encodedText.byteLength);
214
+ try {
215
+ controller.enqueue(sseFrame);
216
+ } catch {
217
+ failStream("codex websocket response stream closed while enqueueing");
218
+ return;
219
+ }
220
+ if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
221
+ const completedId = correlation?.completed(normalized.payload) ?? null;
222
+ terminal = true;
223
+ cleanup();
224
+ try { controller.close(); } catch { /* already closed */ }
225
+ session.release(completedId);
226
+ }
227
+ };
228
+
229
+ const onClose = (event: unknown) => {
230
+ cleanup();
231
+ if (!opened) {
232
+ if (settledPreOpen) return;
233
+ settledPreOpen = true;
234
+ // Upgrade rejected (401/403/429/5xx). Retry over plain SSE so the real
235
+ // HTTP status reaches the existing refresh/rotation handlers. No turn
236
+ // started upstream, so the resend cannot double-generate.
237
+ resolve(sseFallback(url, init));
238
+ return;
239
+ }
240
+ if (sent && !terminal) failStream(closedBeforeTerminalMessage(event));
241
+ };
242
+
243
+ const onError = () => {
244
+ if (terminal || settledPreOpen) return;
245
+ if (!opened && !sent) {
246
+ settledPreOpen = true;
247
+ terminal = true;
248
+ cleanup();
249
+ session.dispose();
250
+ resolve(sseFallback(url, init));
251
+ } else failStream("codex websocket transport error");
252
+ };
253
+ detachOwner = session.bindOwner(reason => cancelExchange(reason));
254
+ ws.addEventListener("open", onOpen);
255
+ ws.addEventListener("message", onMessage);
256
+ ws.addEventListener("close", onClose);
257
+ ws.addEventListener("error", onError);
258
+ if (signal?.aborted) onAbort();
259
+ else if (session.opened) onOpen();
260
+ });
261
+ }