@yansigit/opencodex 2.33.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 (196) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-BjCaHxdz.js +112 -0
  3. package/gui/dist/assets/index-DLkXOXLC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +141 -23
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +17 -1
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  17. package/src/adapters/cursor/native-exec-network.ts +3 -5
  18. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  19. package/src/adapters/cursor/native-exec-shell.ts +116 -31
  20. package/src/adapters/cursor/native-exec.ts +38 -10
  21. package/src/adapters/cursor/protobuf-events.ts +28 -2
  22. package/src/adapters/cursor/protobuf-request.ts +93 -41
  23. package/src/adapters/cursor/request-builder.ts +39 -10
  24. package/src/adapters/cursor/tool-definitions.ts +27 -3
  25. package/src/adapters/cursor/tool-result-normalize.ts +51 -6
  26. package/src/adapters/cursor/types.ts +23 -4
  27. package/src/adapters/cursor.ts +170 -29
  28. package/src/adapters/google-aistudio-parser.ts +49 -0
  29. package/src/adapters/google-antigravity-replay.ts +105 -25
  30. package/src/adapters/google-antigravity-wire.ts +5 -0
  31. package/src/adapters/google-errors.ts +41 -12
  32. package/src/adapters/google-http.ts +12 -11
  33. package/src/adapters/google.ts +219 -36
  34. package/src/adapters/image.ts +1 -1
  35. package/src/adapters/kiro-constants.ts +15 -0
  36. package/src/adapters/kiro-tools.ts +43 -15
  37. package/src/adapters/kiro.ts +54 -9
  38. package/src/adapters/openai-chat.ts +286 -242
  39. package/src/adapters/openai-responses.ts +335 -24
  40. package/src/adapters/run-turn-queue.ts +36 -1
  41. package/src/adapters/tool-catalog-nudge.ts +2 -2
  42. package/src/adapters/xai-tool-schema.ts +436 -0
  43. package/src/bridge.ts +67 -26
  44. package/src/chat/inbound.ts +29 -1
  45. package/src/chat/outbound.ts +15 -7
  46. package/src/claude/agents-inject.ts +8 -1
  47. package/src/claude/outbound.ts +10 -8
  48. package/src/cli/account-api.ts +27 -7
  49. package/src/cli/account-extended.ts +10 -3
  50. package/src/cli/account.ts +29 -5
  51. package/src/cli/alias.ts +66 -0
  52. package/src/cli/claude.ts +26 -1
  53. package/src/cli/dispatch.ts +13 -1
  54. package/src/cli/help.ts +1 -0
  55. package/src/cli/index.ts +6 -1
  56. package/src/cli/init.ts +1 -0
  57. package/src/cli/models-runtime.ts +95 -0
  58. package/src/cli/models.ts +13 -7
  59. package/src/cli/provider-runtime.ts +16 -2
  60. package/src/cli/registry.ts +6 -1
  61. package/src/cli/telemetry-commands.ts +25 -0
  62. package/src/cli/v2.ts +34 -10
  63. package/src/codex/account-pause.ts +2 -1
  64. package/src/codex/account-priority.ts +3 -2
  65. package/src/codex/app-server-processes.ts +80 -6
  66. package/src/codex/auth-api.ts +48 -8
  67. package/src/codex/auth-context.ts +21 -18
  68. package/src/codex/catalog/aggregation.ts +6 -0
  69. package/src/codex/catalog/model-metadata.ts +13 -1
  70. package/src/codex/catalog/native-models.ts +5 -2
  71. package/src/codex/catalog/parsing.ts +16 -0
  72. package/src/codex/catalog/provider-fetch.ts +20 -3
  73. package/src/codex/catalog/sync.ts +127 -2
  74. package/src/codex/catalog.ts +1 -1
  75. package/src/codex/codex-write-lock.ts +3 -1
  76. package/src/codex/convergence-types.ts +1 -1
  77. package/src/codex/convergence.ts +22 -2
  78. package/src/codex/desired-state.ts +2 -2
  79. package/src/codex/desktop-app-restart.ts +18 -5
  80. package/src/codex/inject-coordination.ts +83 -0
  81. package/src/codex/inject.ts +14 -1
  82. package/src/codex/log-guard/inspect.ts +22 -4
  83. package/src/codex/model-entitlements.ts +9 -2
  84. package/src/codex/prompt-layers.ts +371 -25
  85. package/src/codex/prompt-text-probe.ts +238 -0
  86. package/src/codex/quota.ts +123 -18
  87. package/src/codex/routing.ts +9 -0
  88. package/src/codex/subagent-model-fallback.ts +198 -27
  89. package/src/codex/transition-state.ts +107 -8
  90. package/src/combos/types.ts +10 -0
  91. package/src/compatibility/openai-responses.ts +33 -1
  92. package/src/config/autonomous-remediation.ts +21 -0
  93. package/src/config/provider-validation.ts +14 -0
  94. package/src/config/rebase-provenance.ts +68 -0
  95. package/src/config.ts +191 -17
  96. package/src/generated/compatibility-version.json +279 -159
  97. package/src/generated/model-metadata.ts +3 -0
  98. package/src/images/loop.ts +5 -4
  99. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  100. package/src/lab/fabric/producer-child.ts +1 -1
  101. package/src/lib/config-ownership.ts +20 -0
  102. package/src/lib/errors.ts +11 -2
  103. package/src/lib/package-tree-integrity.ts +101 -0
  104. package/src/oauth/aistudio-credentials.ts +65 -0
  105. package/src/oauth/aistudio-native-daemon.ts +116 -0
  106. package/src/oauth/aistudio-session-sync.ts +95 -0
  107. package/src/oauth/generic-account-failover.ts +231 -0
  108. package/src/oauth/google-aistudio-auth.ts +98 -0
  109. package/src/oauth/index.ts +57 -5
  110. package/src/oauth/key-providers.ts +18 -1
  111. package/src/oauth/kiro.ts +45 -0
  112. package/src/oauth/login-cli.ts +65 -1
  113. package/src/oauth/types.ts +15 -0
  114. package/src/providers/codex-capacity.ts +5 -2
  115. package/src/providers/command-code-efforts.ts +38 -6
  116. package/src/providers/context-cap.ts +4 -3
  117. package/src/providers/default-aliases.ts +65 -0
  118. package/src/providers/derive.ts +29 -1
  119. package/src/providers/fastwire.ts +7 -1
  120. package/src/providers/model-presets.ts +119 -0
  121. package/src/providers/new-model-policy.ts +146 -0
  122. package/src/providers/provider-id-rewrite.ts +2 -1
  123. package/src/providers/quota.ts +157 -46
  124. package/src/providers/registry.ts +184 -71
  125. package/src/providers/slug-codec.ts +52 -0
  126. package/src/responses/code-mode-helper-compat.ts +50 -0
  127. package/src/responses/custom-tool-compat.ts +34 -10
  128. package/src/responses/parser.ts +4 -0
  129. package/src/responses/schema.ts +5 -1
  130. package/src/responses/thought-signature-replay.ts +17 -0
  131. package/src/router.ts +43 -2
  132. package/src/routing/account-pool/cooldown.ts +8 -0
  133. package/src/routing/account-pool/index.ts +1 -0
  134. package/src/routing/analytics.ts +1 -0
  135. package/src/routing/quota.ts +10 -0
  136. package/src/server/auth-cors.ts +24 -0
  137. package/src/server/chat-completions.ts +26 -16
  138. package/src/server/chat-native-sse.ts +3 -3
  139. package/src/server/chat-native.ts +30 -11
  140. package/src/server/claude-messages.ts +1 -1
  141. package/src/server/effort-policy.ts +16 -0
  142. package/src/server/index.ts +180 -14
  143. package/src/server/lifecycle.ts +52 -1
  144. package/src/server/management/agent-settings-routes.ts +31 -15
  145. package/src/server/management/codex-prompt-routes.ts +570 -0
  146. package/src/server/management/combo-routes.ts +2 -1
  147. package/src/server/management/config-routes.ts +27 -9
  148. package/src/server/management/context.ts +9 -0
  149. package/src/server/management/logs-usage-routes.ts +11 -5
  150. package/src/server/management/model-routes.ts +266 -0
  151. package/src/server/management/oauth-account-routes.ts +13 -3
  152. package/src/server/management/provider-routes.ts +137 -3
  153. package/src/server/management/routing-profile-routes.ts +2 -2
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/port-reclaim.ts +19 -1
  156. package/src/server/relay-eager.ts +147 -20
  157. package/src/server/relay.ts +251 -19
  158. package/src/server/request-log-conversation.ts +33 -0
  159. package/src/server/request-log.ts +48 -21
  160. package/src/server/responses/collaboration.ts +42 -5
  161. package/src/server/responses/combo-stream-preflight.ts +10 -3
  162. package/src/server/responses/core.ts +575 -140
  163. package/src/server/responses/empty-completion-guard.ts +35 -0
  164. package/src/server/responses/fetch-helpers.ts +14 -6
  165. package/src/server/responses/input-admission.ts +3 -1
  166. package/src/server/responses/passthrough-error.ts +33 -9
  167. package/src/server/responses/policy-fallback.ts +1 -1
  168. package/src/server/responses/responses-field-backfill.ts +105 -13
  169. package/src/server/responses/ws-upstream.ts +35 -5
  170. package/src/server/responses-custom-tool-repair.ts +52 -7
  171. package/src/server/responses-terminal-repair.ts +25 -4
  172. package/src/server/sse-frame-buffer.ts +31 -4
  173. package/src/server/ws-bridge.ts +14 -2
  174. package/src/smoke/fingerprint-cache.ts +133 -0
  175. package/src/smoke/live-scenarios.ts +33 -0
  176. package/src/smoke/runner.ts +119 -0
  177. package/src/telemetry/dispatcher.ts +44 -0
  178. package/src/telemetry/fingerprint.ts +24 -0
  179. package/src/telemetry/hook.ts +43 -0
  180. package/src/telemetry/ledger.ts +54 -0
  181. package/src/telemetry/types.ts +23 -0
  182. package/src/types/config.ts +66 -14
  183. package/src/types/provider.ts +79 -1
  184. package/src/types/request.ts +18 -10
  185. package/src/types/tools.ts +30 -11
  186. package/src/types.ts +1 -0
  187. package/src/usage/command-code-manifest.ts +116 -0
  188. package/src/usage/cost.ts +2 -2
  189. package/src/usage/expected-prices.ts +126 -24
  190. package/src/usage/log.ts +18 -8
  191. package/src/usage/summary.ts +34 -12
  192. package/src/web-search/exa-executor.ts +40 -9
  193. package/src/web-search/index.ts +16 -8
  194. package/src/web-search/loop.ts +5 -4
  195. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
  196. package/gui/dist/assets/index-DrSQdTRd.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
@@ -3,6 +3,7 @@ import {
3
3
  codexWsUpstreamFetch,
4
4
  currentBunRuntimeIdentity,
5
5
  shouldUseCodexWsUpstream,
6
+ type CodexWsUpstreamOptions,
6
7
  type BunRuntimeGateInput,
7
8
  } from "./ws-upstream";
8
9
  import type { OcxProviderConfig } from "../../types";
@@ -73,19 +74,25 @@ export function providerFetch(
73
74
  : base;
74
75
  const httpFetch = Object.assign(
75
76
  (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
76
- transport(input, withUpstreamHttpVersion(input, init, provider)),
77
+ transport(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }),
77
78
  { preconnect },
78
79
  ) as typeof globalThis.fetch;
79
- // ChatGPT Codex backend: streaming turns ride the responses_websockets
80
- // transport (measured ~3s faster TTFT than the SSE POST queue); everything
81
- // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
80
+ // ChatGPT Codex backend: eligible streaming turns stay on HTTP/SSE by
81
+ // default. `wsUpstream: true`, or (when that option is omitted)
82
+ // OCX_CODEX_WS_UPSTREAM=true/1, opts into the responses_websockets transport;
83
+ // everything else keeps the provider's HTTP fetch. See ws-upstream.ts for
84
+ // the details.
85
+ const wsOptions: CodexWsUpstreamOptions = {
86
+ wsUpstream: provider.wsUpstream,
87
+ maxWsFrameBytes: provider.maxWsFrameBytes,
88
+ };
82
89
  const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
83
- if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
90
+ if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, wsOptions)) {
84
91
  // The fallback has to be the same HTTP fetch the non-WS branch would have
85
92
  // used, protocol pin included: a WS turn that falls back is serving the
86
93
  // request over HTTP, and dropping the provider's `upstreamHttpVersion`
87
94
  // there would silently negotiate a transport the operator ruled out.
88
- return codexWsUpstreamFetch(input, init, httpFetch, runtime);
95
+ return codexWsUpstreamFetch(input, init, httpFetch, runtime, wsOptions);
89
96
  }
90
97
  return httpFetch(input, init);
91
98
  };
@@ -143,6 +150,7 @@ export async function fetchWithHeaderTimeout(
143
150
  // indistinguishable from a pre-connection failure (#914).
144
151
  ...(manualRedirect ? { redirect: "manual" as const } : {}),
145
152
  signal: AbortSignal.any([abortSignal, timeout.signal]),
153
+ timeout: 0,
146
154
  });
147
155
  } finally {
148
156
  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, {
@@ -89,7 +89,7 @@ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): v
89
89
  const attempt = logCtx.activeAttempt;
90
90
  if (attempt) {
91
91
  const startedAt = logCtx.activeAttemptStartedAt ?? Date.now();
92
- finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage);
92
+ finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage, logCtx.upstreamError);
93
93
  }
94
94
  delete logCtx.activeAttempt;
95
95
  delete logCtx.activeAttemptStartedAt;
@@ -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
  }
@@ -4,8 +4,8 @@
4
4
  // a measurably faster queue than the plain SSE POST path. Measured 2026-08-12
5
5
  // KST (same account, same payload, strictly sequential): gpt-5.6-luna TTFT p50
6
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.
7
+ // transport; opencodex keeps HTTP/SSE as its reliable default and allows
8
+ // operators to opt into WS when the lower latency is worth the risk.
9
9
  //
10
10
  // The wrapper only swaps the transport. It dials wss:// with the same headers,
11
11
  // sends the JSON body as a single `response.create` frame, and re-encodes the
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
16
16
  import { compareBunVersions } from "../../lib/bun-stream-caps";
17
+ import { interceptRuntimeFailure } from "../../telemetry/hook";
17
18
 
18
19
  const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
19
20
  const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
@@ -55,6 +56,29 @@ export type BunRuntimeIdentity = {
55
56
 
56
57
  export type BunRuntimeGateInput = string | BunRuntimeIdentity;
57
58
 
59
+ export interface CodexWsUpstreamOptions {
60
+ wsUpstream?: boolean;
61
+ maxWsFrameBytes?: number;
62
+ }
63
+
64
+ export function isCodexWsUpstreamDisabled(options?: CodexWsUpstreamOptions): boolean {
65
+ if (options?.wsUpstream !== undefined) return options.wsUpstream !== true;
66
+ const env = process.env.OCX_CODEX_WS_UPSTREAM;
67
+ return env !== "true" && env !== "1";
68
+ }
69
+
70
+ export function resolveCodexWsMaxFrameBytes(options?: CodexWsUpstreamOptions): number {
71
+ if (typeof options?.maxWsFrameBytes === "number" && Number.isFinite(options.maxWsFrameBytes) && options.maxWsFrameBytes > 0) {
72
+ return Math.min(options.maxWsFrameBytes, CODEX_WS_CREATE_FRAME_LIMIT_BYTES);
73
+ }
74
+ const envVal = process.env.OCX_CODEX_WS_MAX_FRAME_BYTES;
75
+ if (envVal) {
76
+ const parsed = Number.parseInt(envVal, 10);
77
+ if (Number.isFinite(parsed) && parsed > 0) return Math.min(parsed, CODEX_WS_CREATE_FRAME_LIMIT_BYTES);
78
+ }
79
+ return CODEX_WS_CREATE_FRAME_LIMIT_BYTES;
80
+ }
81
+
58
82
  const codexWsUpstreamResponses = new WeakSet<Response>();
59
83
 
60
84
  /** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */
@@ -102,7 +126,9 @@ export function shouldUseCodexWsUpstream(
102
126
  url: string,
103
127
  init?: RequestInit,
104
128
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
129
+ options?: CodexWsUpstreamOptions,
105
130
  ): boolean {
131
+ if (isCodexWsUpstreamDisabled(options)) return false;
106
132
  if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
107
133
  if (url !== CODEX_RESPONSES_HTTP_URL) return false;
108
134
  if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
@@ -172,8 +198,9 @@ export function codexWsUpstreamFetch(
172
198
  init: RequestInit,
173
199
  sseFallback: typeof globalThis.fetch,
174
200
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
201
+ options?: CodexWsUpstreamOptions,
175
202
  ): Promise<Response> {
176
- if (!bunSupportsBoundedCodexWsRelay(runtime)) {
203
+ if (isCodexWsUpstreamDisabled(options) || !bunSupportsBoundedCodexWsRelay(runtime)) {
177
204
  return sseFallback(url, init);
178
205
  }
179
206
  const signal = init.signal ?? undefined;
@@ -196,7 +223,8 @@ export function codexWsUpstreamFetch(
196
223
  // streaming Response, so the oversized close can only be surfaced as a stream
197
224
  // error — and a resend at that point could double-generate. Measuring the
198
225
  // frame we are about to send keeps the whole failure mode unreachable.
199
- if (codexWsCreateFrameExceedsLimit(frameText)) {
226
+ const maxFrameBytes = resolveCodexWsMaxFrameBytes(options);
227
+ if (codexWsCreateFrameExceedsLimit(frameText, maxFrameBytes)) {
200
228
  return sseFallback(url, init);
201
229
  }
202
230
 
@@ -370,7 +398,9 @@ export function codexWsUpstreamFetch(
370
398
  // here would reach clients with no response.completed/failed at all —
371
399
  // relaySseWithFailedTail() only synthesizes a failed terminal when the
372
400
  // body read THROWS. Error the stream like a reset TCP socket.
373
- try { controller.error(new Error(closedBeforeTerminalMessage(event))); } catch { /* stream already done */ }
401
+ const error = new Error(closedBeforeTerminalMessage(event));
402
+ if ((event as { code?: unknown } | null)?.code === 1006) interceptRuntimeFailure(error, { category: "websocket_1006" });
403
+ try { controller.error(error); } catch { /* stream already done */ }
374
404
  }
375
405
  });
376
406
 
@@ -1,8 +1,10 @@
1
1
  import type { TranslatorBudget } from "../lib/translator-budget";
2
2
  import { normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
3
+ import { compileCodeModeHelperInput } from "../responses/code-mode-helper-compat";
3
4
  import {
4
5
  customToolItemId,
5
6
  restoreRoutedCustomCalls,
7
+ routedCustomToolTargetName,
6
8
  routedCustomToolWireName,
7
9
  unwrapRoutedCustomToolArguments,
8
10
  } from "../responses/custom-tool-compat";
@@ -87,8 +89,10 @@ export function createRoutedCustomToolRestoreBlockRewrite(
87
89
  names: ReadonlySet<string>,
88
90
  budget?: TranslatorBudget,
89
91
  repairNames: ReadonlySet<string> = new Set(),
92
+ declaredNames?: ReadonlySet<string>,
90
93
  ): SseBlockRewrite {
91
- const itemNames = new Map<string, { name: string; namespace?: string }>();
94
+ const itemNames = new Map<string, { name: string; aliased: boolean; namespace?: string }>();
95
+ const customAliasItemNames = new Map<string, string>();
92
96
  const repairItemNames = new Map<string, string>();
93
97
  const ordinaryItemIds = new Set<string>();
94
98
  const openCalls = new Map<string, OpenCustomCall>();
@@ -114,6 +118,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
114
118
  }
115
119
  pendingArguments = [];
116
120
  itemNames.clear();
121
+ customAliasItemNames.clear();
117
122
  repairItemNames.clear();
118
123
  ordinaryItemIds.clear();
119
124
  };
@@ -187,11 +192,20 @@ export function createRoutedCustomToolRestoreBlockRewrite(
187
192
  ) {
188
193
  const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
189
194
  const wireName = routedCustomToolWireName(parsed.item);
195
+ const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames);
196
+ const aliased = targetName !== undefined && targetName !== wireName;
197
+ if (upstreamItemId && aliased) {
198
+ customAliasItemNames.set(upstreamItemId, parsed.item.name);
199
+ if (type === "response.output_item.added") {
200
+ openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 });
201
+ }
202
+ }
190
203
  const repairable = wireName !== undefined && repairNames.has(wireName);
191
204
  if (upstreamItemId && repairable) repairItemNames.set(upstreamItemId, parsed.item.name);
192
- const restored = repairable
193
- ? restoreRoutedCustomCalls(parsed, names, repairNames)
205
+ const restored = repairable || aliased
206
+ ? restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames)
194
207
  : { value: parsed, changed: false };
208
+ if (type === "response.output_item.done" && upstreamItemId) releaseCall(upstreamItemId);
195
209
  return restored.changed
196
210
  ? [replaceSseDataPayload(block, JSON.stringify(restored.value))]
197
211
  : [block];
@@ -203,12 +217,14 @@ export function createRoutedCustomToolRestoreBlockRewrite(
203
217
  && typeof parsed.item.name === "string"
204
218
  ) {
205
219
  const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
220
+ const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames);
221
+ const routed = targetName !== undefined;
206
222
  const wireName = routedCustomToolWireName(parsed.item);
207
- const routed = wireName !== undefined && names.has(wireName);
208
223
  if (upstreamItemId) {
209
224
  if (routed) {
210
225
  itemNames.set(upstreamItemId, {
211
226
  name: parsed.item.name,
227
+ aliased: targetName !== wireName,
212
228
  ...(typeof parsed.item.namespace === "string" ? { namespace: parsed.item.namespace } : {}),
213
229
  });
214
230
  ordinaryItemIds.delete(upstreamItemId);
@@ -227,7 +243,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
227
243
  if (upstreamItemId && pending.length > 0 && !openCalls.has(upstreamItemId)) {
228
244
  openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 });
229
245
  }
230
- const restored = restoreRoutedCustomCalls(parsed, names, repairNames);
246
+ const restored = restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames);
231
247
  const restoredBlock = restored.changed
232
248
  ? replaceSseDataPayload(block, JSON.stringify(restored.value))
233
249
  : block;
@@ -239,6 +255,33 @@ export function createRoutedCustomToolRestoreBlockRewrite(
239
255
  }
240
256
 
241
257
  const upstreamItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
258
+ if (
259
+ type === "response.custom_tool_call_input.delta"
260
+ && upstreamItemId
261
+ && customAliasItemNames.has(upstreamItemId)
262
+ ) {
263
+ const open = openCalls.get(upstreamItemId) ?? { argumentsText: "", emittedInput: "", retainedBytes: 0 };
264
+ const delta = typeof parsed.delta === "string" ? parsed.delta : "";
265
+ const deltaBytes = Buffer.byteLength(delta, "utf8");
266
+ if (deltaBytes > 0) budget?.chargeRetained(deltaBytes, { kind: "retained_collectors" });
267
+ open.argumentsText += delta;
268
+ open.retainedBytes += deltaBytes;
269
+ openCalls.set(upstreamItemId, open);
270
+ return [];
271
+ }
272
+ if (
273
+ type === "response.custom_tool_call_input.done"
274
+ && upstreamItemId
275
+ && customAliasItemNames.has(upstreamItemId)
276
+ ) {
277
+ const source = typeof parsed.input === "string"
278
+ ? parsed.input
279
+ : openCalls.get(upstreamItemId)?.argumentsText ?? "";
280
+ return [replaceSseDataPayload(block, JSON.stringify({
281
+ ...parsed,
282
+ input: compileCodeModeHelperInput(source, customAliasItemNames.get(upstreamItemId)!),
283
+ }))];
284
+ }
242
285
  if (
243
286
  type === "response.custom_tool_call_input.done"
244
287
  && upstreamItemId
@@ -301,12 +344,14 @@ export function createRoutedCustomToolRestoreBlockRewrite(
301
344
  ...rest,
302
345
  type: nextType,
303
346
  item_id: customToolItemId(upstreamItemId),
304
- input: unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace),
347
+ input: itemName?.aliased
348
+ ? compileCodeModeHelperInput(source, itemName.name)
349
+ : unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace),
305
350
  };
306
351
  return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))];
307
352
  }
308
353
 
309
- const restored = restoreRoutedCustomCalls(parsed, names, repairNames);
354
+ const restored = restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames);
310
355
  const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
311
356
  if (terminal) releaseAll();
312
357
  return restored.changed
@@ -22,6 +22,22 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
22
22
  return typeof value === "object" && value !== null && !Array.isArray(value);
23
23
  }
24
24
 
25
+ function isUnframedTerminalLikeSuffix(block: string): boolean {
26
+ const payload = sseDataPayload(block);
27
+ if (payload === "[DONE]") return true;
28
+ if (!payload) return false;
29
+ try {
30
+ const parsed = JSON.parse(payload);
31
+ if (!isPlainRecord(parsed)) return false;
32
+ return parsed.type === "response.completed"
33
+ || parsed.type === "response.failed"
34
+ || parsed.type === "response.incomplete"
35
+ || parsed.type === "error";
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
25
41
  function outputIndex(value: unknown): number | null {
26
42
  return Number.isInteger(value) && (value as number) >= 0 ? value as number : null;
27
43
  }
@@ -292,11 +308,16 @@ export function relayResponsesSseWithTerminalRepair(
292
308
  if (done) {
293
309
  appendBuffer(decoder.decode());
294
310
  if (buffer.length > 0) {
295
- // A delimiter-less suffix is not a complete SSE event. Preserve the
296
- // upstream bytes for passthrough compatibility, but never let a
297
- // truncated lifecycle frame establish synthetic success.
311
+ // A delimiter-less suffix is not a complete SSE event. Preserve an
312
+ // ordinary suffix for passthrough compatibility, but never promote
313
+ // a terminal-like suffix by adding the delimiter it did not receive
314
+ // upstream. The latter must stay tainted and fail closed through the
315
+ // synthetic incomplete terminal below.
298
316
  tainted = true;
299
- controller.enqueue(encoder.encode(buffer));
317
+ if (!isUnframedTerminalLikeSuffix(buffer)) {
318
+ controller.enqueue(encoder.encode(buffer));
319
+ controller.enqueue(encoder.encode(buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n"));
320
+ }
300
321
  }
301
322
  if (!realTerminalSeen) {
302
323
  emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller);