@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -35,6 +35,41 @@ export function emptyCompletionRetryEnabled(
35
35
  /** Surfaced when the single retry was also empty or failed upstream. */
36
36
  export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed";
37
37
 
38
+ /**
39
+ * Observe an event stream for the empty-completion shape WITHOUT changing it (#2472).
40
+ *
41
+ * The guard above is opt-in, so with the default configuration a turn that completes with no
42
+ * output text and no tool call passes through untouched and the client records a silent
43
+ * success. That is the reported symptom: an empty result nobody can explain, with no trace
44
+ * that the proxy saw anything unusual.
45
+ *
46
+ * This is deliberately a passthrough observer, not a second guard. Retrying by default would
47
+ * re-send a turn that may have already had billable side effects; the honest default is to
48
+ * leave the stream alone and make the occurrence visible, so a user can correlate it and
49
+ * decide whether to enable the retry.
50
+ */
51
+ export async function* observeEmptyCompletion(
52
+ events: AsyncIterable<AdapterEvent>,
53
+ onEmptyTurn: () => void,
54
+ ): AsyncGenerator<AdapterEvent> {
55
+ let sawContent = false;
56
+ let sawTerminal = false;
57
+ for await (const event of events) {
58
+ // Reasoning is deliberately NOT content, matching the guard: a reasoning-only stream that
59
+ // ends with nothing is the canonical shape of this failure.
60
+ if (isContentEvent(event)) sawContent = true;
61
+ if (isTerminalEvent(event)) {
62
+ sawTerminal = true;
63
+ // Only a successful terminal is the silent failure. `error` and `incomplete` are already
64
+ // a stated outcome the client can render, so flagging them would be noise.
65
+ if (!sawContent && event.type === "done") onEmptyTurn();
66
+ }
67
+ yield event;
68
+ }
69
+ // A stream that ends before any terminal is the pre-output EOF variant of the same failure.
70
+ if (!sawContent && !sawTerminal) onEmptyTurn();
71
+ }
72
+
38
73
  /**
39
74
  * Terminal stop reasons the bridge renders as a visible `response.incomplete`
40
75
  * (max_tokens / content_filter). Those are already a stated failure, not the
@@ -153,9 +188,10 @@ export interface EmptyCompletionGuardOptions {
153
188
  * Watch an adapter event stream for the empty-completion failure mode. Events
154
189
  * are held until the turn produces content or ends: reasoning and other
155
190
  * pre-content events stay buffered (released in order on first content), the
156
- * terminal is withheld, and an empty terminal triggers one identical-turn
157
- * retry through `continuation`. Usage is merged across attempts so the bridge
158
- * and request log meter the whole turn, not just the attempt that succeeded.
191
+ * terminal is withheld, and an empty terminal or pre-output EOF triggers one
192
+ * identical-turn retry through `continuation`. Usage is merged across attempts
193
+ * so the bridge and request log meter the whole turn, not just the attempt that
194
+ * succeeded.
159
195
  *
160
196
  * Heartbeats always pass through untouched: they feed the bridge's stall
161
197
  * watchdog, so holding them behind the content gate would trip false
@@ -194,7 +230,11 @@ export async function* guardEmptyCompletionEventStream(
194
230
  if (sawContent || passthrough) {
195
231
  // Buffered content is already flowing; everything downstream passes
196
232
  // through. Every terminal carries usage merged across every attempt.
197
- yield isTerminalEvent(event) ? withUsage(event) : event;
233
+ if (isTerminalEvent(event)) {
234
+ yield withUsage(event);
235
+ return;
236
+ }
237
+ yield event;
198
238
  continue;
199
239
  }
200
240
  if (isContentEvent(event)) {
@@ -267,8 +307,25 @@ export async function* guardEmptyCompletionEventStream(
267
307
  if (isReasoningEvent(event)) yield { type: "heartbeat" };
268
308
  }
269
309
  if (!terminalSeen) {
270
- // The source ended without a terminal event (truncated stream). Release
271
- // what was held so the bridge can mark the stream incomplete.
310
+ // A terminal-less EOF before text or a tool call is replay-safe: nothing
311
+ // actionable reached the client. Retry once, then surface a stated error
312
+ // instead of letting the bridge reduce the turn to adapter_eof.
313
+ if (!sawContent && !passthrough && retries < maxRetries) {
314
+ retries += 1;
315
+ try {
316
+ source = await options.continuation();
317
+ } catch {
318
+ yield emptyCompletionRetryFailedEvent(usage, true);
319
+ return;
320
+ }
321
+ continue;
322
+ }
323
+ if (!sawContent && retries > 0) {
324
+ yield emptyCompletionRetryFailedEvent(usage, true);
325
+ return;
326
+ }
327
+ // Post-output EOF remains incomplete; replaying could duplicate text or
328
+ // executable tool calls.
272
329
  yield* releaseHeld();
273
330
  return;
274
331
  }
@@ -5,107 +5,12 @@ import {
5
5
  shouldUseCodexWsUpstream,
6
6
  type BunRuntimeGateInput,
7
7
  } from "./ws-upstream";
8
- import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
9
- import {
10
- getConfigPath,
11
- multiAgentGuidanceEnabled,
12
- resolveEnvValue,
13
- } from "../../config";
14
- import { parseRequest } from "../../responses/parser";
15
- import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
16
- import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
17
- import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
18
- import { routeModel } from "../../router";
19
- import {
20
- advanceComboAfterFailure,
21
- comboDefaultEffort,
22
- comboFailureDecision,
23
- comboIdFromRawBody,
24
- concreteComboRequestBody,
25
- getCombo,
26
- isComboTargetInCooldown,
27
- NoAvailableComboTargetsError,
28
- noteComboSuccess,
29
- parseRetryAfterMs,
30
- pickComboTarget,
31
- targetKey,
32
- } from "../../combos";
33
- import { isInjectionDebugEnabled } from "../../lib/debug-settings";
34
- import { injectionDebugLog } from "../../lib/injection-debug-log";
35
- import { modelInList, namespacedToolName } from "../../types";
36
- import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
37
- import {
38
- forceRefreshOAuthAccessSnapshot,
39
- getOAuthCredentialApiBaseUrl,
40
- getOAuthCredentialProjectId,
41
- getValidAccessTokenSnapshot,
42
- type OAuthAccessSnapshot,
43
- UnsupportedOAuthProviderError,
44
- } from "../../oauth";
45
- import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
46
- import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
47
- import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
48
- import {
49
- applyCodexAuthContextToProvider,
50
- CodexAccountCooldownError,
51
- CodexAuthContextError,
52
- CodexDirectAuthenticationError,
53
- CodexPoolAuthenticationError,
54
- CodexThreadAffinityExpiredError,
55
- headersForCodexAuthContext,
56
- isCodexAuthContextUsable,
57
- resolveCodexAuthContext,
58
- type CodexAuthContext,
59
- } from "../../codex/auth-context";
60
- import {
61
- formatCodexProviderForLog,
62
- recordCodexUpstreamOutcome,
63
- type CodexUpstreamOutcome,
64
- } from "../../codex/routing";
65
- import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../../lib/upstream-retry";
66
- import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
67
- import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
68
- import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
69
- import { slugsEquivalent } from "../../providers/slug-codec";
70
- import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
71
- import { isUsageDebugEnabled } from "../../usage/debug";
72
- import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
73
- import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
74
- import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
75
- import { shouldAttemptImageTierRetry } from "../image-retry";
76
- import { resolveProviderTransport } from "../../providers/xai-transport";
8
+ import type { OcxProviderConfig } from "../../types";
77
9
  import type { WsData } from "../ws-bridge";
78
- import { registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
79
- import { redactSecretString } from "../../lib/redact";
80
- import { readBoundedResponseBody } from "../../lib/bounded-body";
81
- import { supportedLadderFor } from "../effort-policy";
82
- import {
83
- beginRequestAttempt,
84
- catalogModelSupportsServiceTier,
85
- finishRequestAttempt,
86
- inspectResponseLogJson,
87
- noteAttemptSend,
88
- readConfiguredCodexServiceTier,
89
- requestLogSpeedLabel,
90
- sealRequestAttemptIdentity,
91
- usageFromResponsesPayload,
92
- type RequestLogContext,
93
- } from "../request-log";
94
- import type { AttemptRecoveryKind } from "../../usage/log";
95
- import {
96
- consumeForInspection,
97
- consumeForResponseLogMetadata,
98
- markNativePassthroughSseResponse,
99
- relaySseWithFailedTail,
100
- relayWithAbort,
101
- sanitizePassthroughHeaders,
102
- } from "../relay";
103
- import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
104
- import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
105
10
  import { waitForProviderRequestSlot } from "../../providers/request-pacing";
106
11
  import { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
107
12
 
108
- export { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
13
+ export { withUpstreamHttpVersion };
109
14
 
110
15
  export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
111
16
  if (!server) return false;
@@ -159,14 +64,26 @@ export function providerFetch(
159
64
  options: ProviderFetchOptions = {},
160
65
  ): ProviderFetch {
161
66
  const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
67
+ const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
68
+ base.preconnect?.(...args);
69
+ };
70
+ const httpFetch = Object.assign(
71
+ (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
72
+ base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }),
73
+ { preconnect },
74
+ ) as typeof globalThis.fetch;
162
75
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
163
76
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
164
77
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
165
78
  const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
166
79
  if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
167
- return codexWsUpstreamFetch(input, init, base, runtime);
80
+ // The fallback has to be the same HTTP fetch the non-WS branch would have
81
+ // used, protocol pin included: a WS turn that falls back is serving the
82
+ // request over HTTP, and dropping the provider's `upstreamHttpVersion`
83
+ // there would silently negotiate a transport the operator ruled out.
84
+ return codexWsUpstreamFetch(input, init, httpFetch, runtime);
168
85
  }
169
- return base(input, withUpstreamHttpVersion(input, init, provider));
86
+ return httpFetch(input, init);
170
87
  };
171
88
  let pacingSlotAcquired = options.pacingSlotAcquired === true;
172
89
  const waitForPacing = (signal?: AbortSignal) => {
@@ -182,9 +99,6 @@ export function providerFetch(
182
99
  await waitForPacing(init?.signal ?? undefined);
183
100
  return unpaced(input, init);
184
101
  };
185
- const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
186
- base.preconnect?.(...args);
187
- };
188
102
  return Object.assign(wrapped, {
189
103
  preconnect,
190
104
  waitForPacing,
@@ -225,6 +139,7 @@ export async function fetchWithHeaderTimeout(
225
139
  // indistinguishable from a pre-connection failure (#914).
226
140
  ...(manualRedirect ? { redirect: "manual" as const } : {}),
227
141
  signal: AbortSignal.any([abortSignal, timeout.signal]),
142
+ timeout: 0,
228
143
  });
229
144
  } finally {
230
145
  clearTimeout(timer);
@@ -68,7 +68,9 @@ function imageTokens(imageUrl: string): number {
68
68
  }
69
69
 
70
70
  function contentPartTokens(part: OcxContentPart, modelId: string): number {
71
- return part.type === "image" ? imageTokens(part.imageUrl) : estimateTokens(part.text, modelId);
71
+ if (part.type === "image") return imageTokens(part.imageUrl);
72
+ if (part.type === "video") return imageTokens(part.videoUrl);
73
+ return estimateTokens(part.text, modelId);
72
74
  }
73
75
 
74
76
  function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
@@ -1,9 +1,29 @@
1
1
  import { formatErrorResponse } from "../../bridge";
2
+ import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
2
3
  import {
3
4
  resolveClientRetryAfter,
4
5
  validateClientRetryAfterHeader,
5
6
  } from "../../lib/retry-after";
6
7
 
8
+ function isCyberPolicyBody(body: string): boolean {
9
+ if (isCyberPolicyMessage(body)) return true;
10
+ try {
11
+ const parsed = JSON.parse(body) as Record<string, unknown>;
12
+ const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response)
13
+ ? parsed.response as Record<string, unknown>
14
+ : undefined;
15
+ for (const candidate of [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed]) {
16
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
17
+ const record = candidate as Record<string, unknown>;
18
+ if (isCyberPolicyCode(typeof record.code === "string" ? record.code : undefined)) return true;
19
+ if (typeof record.message === "string" && isCyberPolicyMessage(record.message)) return true;
20
+ }
21
+ } catch {
22
+ /* non-JSON body — message detection above is the only safe fallback */
23
+ }
24
+ return false;
25
+ }
26
+
7
27
  /**
8
28
  * Passthrough adapters historically relayed upstream non-2xx bodies verbatim.
9
29
  * Codex maps an *empty* body to the literal client string "Unknown error"
@@ -32,18 +52,22 @@ export function formatPassthroughUpstreamError(
32
52
  const now = options?.now ?? Date.now();
33
53
  const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
34
54
  const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
35
- const resolved = resolveClientRetryAfter({
36
- status,
37
- message: trimmed || `Provider error ${status}: (empty body)`,
38
- upstreamRetryAfter,
39
- now,
40
- });
55
+ const cyberPolicyFailure = isCyberPolicyBody(trimmed);
56
+ const resolved = cyberPolicyFailure
57
+ ? undefined
58
+ : resolveClientRetryAfter({
59
+ status,
60
+ message: trimmed || `Provider error ${status}: (empty body)`,
61
+ upstreamRetryAfter,
62
+ now,
63
+ });
41
64
 
42
65
  if (trimmed) {
43
66
  const needsSet = resolved !== undefined && upstreamRetryAfter !== resolved;
44
- const needsDelete = resolved === undefined
45
- && upstreamRetryAfter !== undefined
46
- && originalValid === undefined;
67
+ const needsDelete = (cyberPolicyFailure && upstreamRetryAfter !== undefined)
68
+ || (resolved === undefined
69
+ && upstreamRetryAfter !== undefined
70
+ && originalValid === undefined);
47
71
 
48
72
  if (!needsSet && !needsDelete) {
49
73
  return new Response(bodyText, {
@@ -91,6 +91,29 @@ function nextSyntheticItemSlot(): ItemIdSlot {
91
91
  return { kind: "fallback", ordinal: syntheticItemOrdinal };
92
92
  }
93
93
 
94
+ /**
95
+ * Backfill `status` on a message output item if missing.
96
+ *
97
+ * The Responses API spec defines `status` as a required field on
98
+ * `OutputMessage`. Some upstream relays omit it, which causes strict
99
+ * deserializers (e.g. grok-build's serde types) to fail with
100
+ * `missing field 'status'`. Only message items carry this field in the
101
+ * Responses schema; reasoning, function_call, and other item types do not.
102
+ *
103
+ * The value is inferred from the event context: `output_item.added` and
104
+ * `response.created` / `response.in_progress` mean the message is still
105
+ * being generated (`in_progress`); `output_item.done` and
106
+ * `response.completed` / `response.incomplete` mean the message is
107
+ * finalized (`completed` / `incomplete` respectively).
108
+ *
109
+ * Returns the same object reference if no change is needed.
110
+ */
111
+ function backfillItemStatus(item: Record<string, unknown>, inferredStatus: string): Record<string, unknown> {
112
+ if (item.type !== "message") return item;
113
+ if ("status" in item) return item;
114
+ return { ...item, status: inferredStatus };
115
+ }
116
+
94
117
  /**
95
118
  * Backfill annotations: [] on an output_text content part if missing.
96
119
  * Returns the same object reference if no change is needed.
@@ -122,10 +145,10 @@ function backfillContentArray(content: unknown): unknown {
122
145
 
123
146
  /**
124
147
  * Walk an output item and backfill output_text parts in its content.
125
- * Also backfills a missing required id on the item itself.
148
+ * Also backfills a missing required id and status on the item itself.
126
149
  * Returns the same object reference if nothing changed.
127
150
  */
128
- function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
151
+ function backfillOutputItem(item: unknown, slot: ItemIdSlot, inferredStatus: string): unknown {
129
152
  if (!isPlainObject(item)) return item;
130
153
  // The compact wire family is the `/v1/responses/compact` format, not a Responses output item.
131
154
  // Those items have no `id` in that contract, so synthesizing one changes a response body the
@@ -136,34 +159,92 @@ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
136
159
  const content = item.content;
137
160
  const repaired = backfillContentArray(content);
138
161
  const withId = backfillItemId(item, slot);
139
- if (repaired === content && withId === item) return item;
140
- return { ...withId, ...(repaired === content ? {} : { content: repaired }) };
162
+ const withStatus = backfillItemStatus(withId, inferredStatus);
163
+ if (repaired === content && withStatus === item) return item;
164
+ return { ...withStatus, ...(repaired === content ? {} : { content: repaired }) };
141
165
  }
142
166
 
143
167
  /**
144
168
  * Walk a response object's output[] and backfill output_text parts.
169
+ *
170
+ * `inferredItemStatus` is the status to backfill on message items that lack
171
+ * one — derived from the event type so `output_item.added` / `response.created`
172
+ * gets `in_progress` while `output_item.done` / `response.completed` gets
173
+ * `completed`.
174
+ *
175
+ * Deliberately does NOT backfill `created_at`. #2639 proposed it for the same
176
+ * strict-decoder reason as `status`, but the proxy also relays some upstream
177
+ * responses verbatim, and `tests/server-combo-failover-e2e.test.ts` asserts a
178
+ * combo backup response is returned byte-exact. Injecting a field the upstream
179
+ * never sent breaks that contract. Both cannot hold for the same body, so the
180
+ * `created_at` half needs its own decision about which contract yields; it is
181
+ * not a detail to slip in beside `status`.
182
+ *
145
183
  * Returns the same object reference if nothing changed.
146
184
  */
147
- function backfillResponseOutput(response: unknown): unknown {
185
+ function backfillResponseOutput(response: unknown, inferredItemStatus: string): unknown {
148
186
  if (!isPlainObject(response)) return response;
149
187
  const output = response.output;
150
188
  if (!Array.isArray(output)) return response;
151
189
  let changed = false;
152
190
  const repaired = output.map((item, idx) => {
153
191
  if (!isPlainObject(item)) return item;
154
- const next = backfillOutputItem(item, { kind: "index", index: idx });
192
+ const next = backfillOutputItem(item, { kind: "index", index: idx }, inferredItemStatus);
155
193
  if (next !== item) changed = true;
156
194
  return next;
157
195
  });
158
196
  return changed ? { ...response, output: repaired } : response;
159
197
  }
160
198
 
199
+ /**
200
+ * Infer the status to backfill on a message item from the event type.
201
+ *
202
+ * `output_item.added` means the item is still being generated (`in_progress`);
203
+ * `output_item.done` means it is finalized (`completed`). Response-level events
204
+ * infer from the response's own status field — which is authoritative when present.
205
+ * If the response status is also absent, the event type itself determines the phase:
206
+ * `response.created` / `response.in_progress` → `in_progress`,
207
+ * `response.completed` → `completed`, `response.incomplete` → `incomplete`.
208
+ */
209
+ function inferredStatusForEventType(eventType: string): string {
210
+ if (eventType === "response.output_item.added") return "in_progress";
211
+ if (eventType === "response.output_item.done") return "completed";
212
+ if (eventType === "response.created" || eventType === "response.in_progress") return "in_progress";
213
+ // `queued` is a real Responses lifecycle status: the response exists but has not
214
+ // started generating. Without this row it falls through to the `completed`
215
+ // default below, which would mark an unstarted message as finished.
216
+ if (eventType === "response.queued") return "in_progress";
217
+ if (eventType === "response.incomplete" || eventType === "response.failed") return "incomplete";
218
+ return "completed";
219
+ }
220
+
221
+ /**
222
+ * Map a response-level lifecycle status to a valid OutputMessage status.
223
+ *
224
+ * `OutputMessage.status` accepts only `in_progress`, `completed`, or
225
+ * `incomplete`. Response-level statuses like `failed` or `cancelled` have no
226
+ * direct message-level equivalent, but `incomplete` is the correct semantic
227
+ * mapping: the message did not finish generating. Writing `completed` would
228
+ * assert something the upstream never claimed — a client branching on
229
+ * `status === "completed"` would treat a truncated message as whole.
230
+ */
231
+ function messageStatusFromResponseStatus(status: string): string | null {
232
+ if (status === "in_progress" || status === "completed" || status === "incomplete") return status;
233
+ // A queued response has not begun generating, so its message items are
234
+ // in_progress — never completed. Returning null here would fall back to the
235
+ // event-type inference, whose default is `completed`.
236
+ if (status === "queued") return "in_progress";
237
+ if (status === "failed" || status === "cancelled") return "incomplete";
238
+ return null;
239
+ }
240
+
161
241
  /**
162
242
  * Statelessly rewrite one SSE event: backfill annotations
163
243
  * on any output_text content part found in the event payload.
164
244
  */
165
245
  function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
166
246
  const type = typeof event.type === "string" ? event.type : "";
247
+ const inferredItemStatus = inferredStatusForEventType(type);
167
248
  let next = event;
168
249
  let changed = false;
169
250
 
@@ -177,8 +258,8 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
177
258
  // is not recoverable in that case, but a unique id is what strict decoders require, and a
178
259
  // well-formed stream still gets the stable index-derived id.
179
260
  const item = typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0
180
- ? backfillOutputItem(event.item, { kind: "index", index: rawIndex })
181
- : backfillOutputItem(event.item, nextSyntheticItemSlot());
261
+ ? backfillOutputItem(event.item, { kind: "index", index: rawIndex }, inferredItemStatus)
262
+ : backfillOutputItem(event.item, nextSyntheticItemSlot(), inferredItemStatus);
182
263
  if (item !== event.item) {
183
264
  next = { ...next, item };
184
265
  changed = true;
@@ -198,7 +279,13 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
198
279
  // response.created / in_progress / completed / incomplete / failed:
199
280
  // response.output[].content[] -> output_text parts
200
281
  if (isPlainObject(event.response)) {
201
- const response = backfillResponseOutput(event.response);
282
+ // For response-level events, prefer the response's own status when it is a valid
283
+ // OutputMessage status. Response lifecycle statuses like "failed" or "cancelled"
284
+ // have no message-level equivalent — fall back to the event-type inference instead.
285
+ const responseStatus = typeof event.response.status === "string"
286
+ ? messageStatusFromResponseStatus(event.response.status) ?? inferredItemStatus
287
+ : inferredItemStatus;
288
+ const response = backfillResponseOutput(event.response, responseStatus);
202
289
  if (response !== event.response) {
203
290
  next = { ...next, response };
204
291
  changed = true;
@@ -210,9 +297,9 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
210
297
 
211
298
  /**
212
299
  * Create a stateless SSE block rewrite that backfills annotations and
213
- * on output_text content parts. Unconditional: the field is a required
214
- * canonical Responses field, so adding it when absent is safe for all
215
- * clients.
300
+ * message status on output_text content parts and message items.
301
+ * Unconditional: both are required canonical Responses fields, so adding
302
+ * them when absent is safe for all clients.
216
303
  */
217
304
  export function createResponsesFieldBackfillBlockRewrite(): SseBlockRewrite {
218
305
  const rewrite: SseBlockRewrite = (block: string): readonly string[] => {
@@ -245,7 +332,12 @@ export function backfillResponsesFieldsJson(payload: string): string {
245
332
  return payload;
246
333
  }
247
334
  if (!isPlainObject(response)) return payload;
248
- const repaired = backfillResponseOutput(response);
335
+ // For a non-streaming response, derive the item status from the response's own
336
+ // status field when it is a valid OutputMessage status; fall back to "completed".
337
+ const inferredItemStatus = typeof response.status === "string"
338
+ ? messageStatusFromResponseStatus(response.status) ?? "completed"
339
+ : "completed";
340
+ const repaired = backfillResponseOutput(response, inferredItemStatus);
249
341
  if (repaired === response) return payload;
250
342
  return JSON.stringify(repaired);
251
343
  }
@@ -28,6 +28,25 @@ const UPGRADE_DEADLINE_MS = 10_000;
28
28
  export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
29
29
  export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
30
30
  export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
31
+ // The backend drops any inbound message of 16 MiB or more: it closes the socket
32
+ // (1009) without a Responses terminal event, which reaches clients as a bare
33
+ // 502 upstream_server_error. Measured against the live endpoint 2026-08-23:
34
+ // 16,777,000 B completed, 16,777,300 B closed in ~1s, every time. The same
35
+ // request body succeeds over HTTP SSE, so the ceiling belongs to this transport
36
+ // alone (see #2426). A full-replay thread reaches it with ~11 pasted
37
+ // screenshots, and then never recovers, because each retry resends the frame.
38
+ export const MAX_CODEX_WS_CREATE_FRAME_BYTES = 16 * 1024 * 1024;
39
+ // Bun frames the payload it is handed, so the send-side budget is the JSON text
40
+ // itself, and nothing is appended between the check and the send. The margin is
41
+ // a conservative cushion, not a computed requirement: it covers RFC 6455 frame
42
+ // overhead in case the backend counts it (14 bytes at this payload size — an
43
+ // 8-byte extended length plus a 4-byte client mask, leaving ~65.5 KiB spare),
44
+ // and it leaves room for a future caller that appends to the frame.
45
+ const CODEX_WS_CREATE_FRAME_MARGIN_BYTES = 64 * 1024;
46
+ export const CODEX_WS_CREATE_FRAME_LIMIT_BYTES =
47
+ MAX_CODEX_WS_CREATE_FRAME_BYTES - CODEX_WS_CREATE_FRAME_MARGIN_BYTES;
48
+ /** Close code the backend uses for an oversized message (RFC 6455 "message too big"). */
49
+ const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
31
50
 
32
51
  export type BunRuntimeIdentity = {
33
52
  version: string;
@@ -102,6 +121,52 @@ export function shouldUseCodexWsUpstream(
102
121
  }
103
122
  }
104
123
 
124
+ const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
125
+
126
+ /**
127
+ * The close code is the only thing that separates "the backend refused this
128
+ * payload" from "the network dropped", and both used to reach the caller as the
129
+ * same bare 502. Naming the oversized case here puts that distinction in the
130
+ * message the client receives.
131
+ *
132
+ * It does NOT reach the request log as a typed code. The eager relay turns any
133
+ * stream error into a generic `upstream_reset` synthetic terminal
134
+ * (`relay.ts`, `relay-eager.ts`) without feeding that frame back through the
135
+ * inspector, so `/api/logs` keeps neither this message nor a specific code —
136
+ * only `streamAborted`. Machine-readable typing would mean changing the error
137
+ * taxonomy, which is deliberately out of scope for this transport fix.
138
+ */
139
+ function closedBeforeTerminalMessage(event: unknown): string {
140
+ const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
141
+ const code = typeof detail?.code === "number" ? detail.code : null;
142
+ const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
143
+ if (code === null) return CLOSED_BEFORE_TERMINAL;
144
+ const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
145
+ if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
146
+ return `codex websocket rejected the request frame as too large (close${suffix});`
147
+ + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`;
148
+ }
149
+ return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
150
+ }
151
+
152
+ /**
153
+ * True when the `response.create` frame is at or above the backend's inbound
154
+ * message ceiling, so this turn must take the HTTP SSE path instead.
155
+ *
156
+ * Sizing a 16 MiB string should not cost a 16 MiB copy. UTF-8 never encodes
157
+ * below one byte per UTF-16 code unit and never above three, so both tails are
158
+ * settled from the string length alone; only the narrow band between them pays
159
+ * for a real byte count, and `Buffer.byteLength` measures without allocating.
160
+ */
161
+ export function codexWsCreateFrameExceedsLimit(
162
+ frameText: string,
163
+ limitBytes: number = CODEX_WS_CREATE_FRAME_LIMIT_BYTES,
164
+ ): boolean {
165
+ if (frameText.length >= limitBytes) return true;
166
+ if (frameText.length * 3 < limitBytes) return false;
167
+ return Buffer.byteLength(frameText, "utf8") >= limitBytes;
168
+ }
169
+
105
170
  export function codexWsUpstreamFetch(
106
171
  url: string,
107
172
  init: RequestInit,
@@ -127,6 +192,14 @@ export function codexWsUpstreamFetch(
127
192
  return sseFallback(url, init);
128
193
  }
129
194
 
195
+ // Decide before dialing. Once the socket is open the caller already holds a
196
+ // streaming Response, so the oversized close can only be surfaced as a stream
197
+ // error — and a resend at that point could double-generate. Measuring the
198
+ // frame we are about to send keeps the whole failure mode unreachable.
199
+ if (codexWsCreateFrameExceedsLimit(frameText)) {
200
+ return sseFallback(url, init);
201
+ }
202
+
130
203
  const headers: Record<string, string> = {};
131
204
  new Headers(init.headers ?? {}).forEach((value, key) => {
132
205
  // HTTP-body framing headers do not apply to a WS handshake.
@@ -279,7 +352,7 @@ export function codexWsUpstreamFetch(
279
352
  }
280
353
  });
281
354
 
282
- ws.addEventListener("close", () => {
355
+ ws.addEventListener("close", (event: unknown) => {
283
356
  signal?.removeEventListener("abort", onAbort);
284
357
  if (!opened) {
285
358
  if (settledPreOpen) return;
@@ -297,7 +370,7 @@ export function codexWsUpstreamFetch(
297
370
  // here would reach clients with no response.completed/failed at all —
298
371
  // relaySseWithFailedTail() only synthesizes a failed terminal when the
299
372
  // body read THROWS. Error the stream like a reset TCP socket.
300
- try { controller.error(new Error("codex websocket closed before a Responses terminal event")); } catch { /* stream already done */ }
373
+ try { controller.error(new Error(closedBeforeTerminalMessage(event))); } catch { /* stream already done */ }
301
374
  }
302
375
  });
303
376