@yansigit/opencodex 2.31.3 → 2.33.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 (176) hide show
  1. package/README.md +2 -2
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-DKLr4LTE.js +102 -0
  4. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +6 -5
  7. package/src/adapters/anthropic.ts +25 -13
  8. package/src/adapters/azure.ts +20 -4
  9. package/src/adapters/base.ts +5 -1
  10. package/src/adapters/command-code.ts +42 -10
  11. package/src/adapters/cursor/live-models.ts +8 -0
  12. package/src/adapters/cursor/live-transport.ts +1 -1
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/protobuf-events.ts +158 -7
  15. package/src/adapters/cursor/protobuf-request.ts +33 -15
  16. package/src/adapters/cursor/request-builder.ts +4 -3
  17. package/src/adapters/cursor/tool-definitions.ts +27 -1
  18. package/src/adapters/cursor/types.ts +4 -3
  19. package/src/adapters/cursor.ts +9 -0
  20. package/src/adapters/google-antigravity-replay.ts +2 -2
  21. package/src/adapters/google-antigravity-wire.ts +7 -0
  22. package/src/adapters/google-errors.ts +6 -2
  23. package/src/adapters/google-http.ts +30 -7
  24. package/src/adapters/google-truncation.ts +5 -0
  25. package/src/adapters/google-wire-compiler.ts +38 -6
  26. package/src/adapters/google.ts +154 -33
  27. package/src/adapters/kiro-tools.ts +20 -9
  28. package/src/adapters/kiro.ts +0 -3
  29. package/src/adapters/openai-chat.ts +9 -0
  30. package/src/adapters/openai-responses.ts +4 -1
  31. package/src/adapters/tool-catalog-nudge.ts +1 -1
  32. package/src/adapters/xai-web-search.ts +7 -2
  33. package/src/bridge.ts +133 -24
  34. package/src/claude/context-windows.ts +16 -9
  35. package/src/cli/dispatch.ts +50 -2
  36. package/src/cli/doctor.ts +26 -13
  37. package/src/cli/help.ts +4 -3
  38. package/src/cli/index.ts +20 -6
  39. package/src/cli/models.ts +13 -3
  40. package/src/cli/observe.ts +20 -5
  41. package/src/cli/provider.ts +8 -1
  42. package/src/cli/registry.ts +7 -5
  43. package/src/cli/status.ts +25 -1
  44. package/src/cli/system-restart-client.ts +1 -1
  45. package/src/cli/usage-report.ts +134 -0
  46. package/src/codex/app-server-processes.ts +3 -1
  47. package/src/codex/auth-api.ts +4 -2
  48. package/src/codex/autostart-health.ts +16 -0
  49. package/src/codex/catalog/aggregation.ts +12 -0
  50. package/src/codex/catalog/effort.ts +42 -12
  51. package/src/codex/catalog/metadata.ts +27 -1
  52. package/src/codex/catalog/model-metadata.ts +593 -0
  53. package/src/codex/catalog/parsing.ts +71 -27
  54. package/src/codex/catalog/provider-fetch.ts +189 -33
  55. package/src/codex/catalog/sync.ts +6 -5
  56. package/src/codex/convergence.ts +5 -0
  57. package/src/codex/desktop-app-restart.ts +342 -0
  58. package/src/codex/history-job.ts +32 -3
  59. package/src/codex/history-manifest.ts +112 -0
  60. package/src/codex/history-migration-guardian.ts +5 -5
  61. package/src/codex/history-provider.ts +825 -247
  62. package/src/codex/history-worker.ts +8 -5
  63. package/src/codex/inject.ts +49 -21
  64. package/src/codex/injected-marker.ts +1 -1
  65. package/src/codex/internal/history-writer.ts +4 -3
  66. package/src/codex/native-profile-startup.ts +157 -27
  67. package/src/codex/native-residue.ts +26 -33
  68. package/src/codex/shim.ts +56 -3
  69. package/src/combos/failover.ts +27 -0
  70. package/src/compatibility/index.ts +26 -0
  71. package/src/compatibility/manifest.ts +253 -0
  72. package/src/compatibility/openai-responses.ts +81 -0
  73. package/src/config/atomic-write.ts +219 -0
  74. package/src/config/paths.ts +40 -0
  75. package/src/config/process-state.ts +308 -0
  76. package/src/config/provider-validation.ts +214 -0
  77. package/src/config.ts +153 -814
  78. package/src/generated/compatibility-version.json +232 -148
  79. package/src/images/loop.ts +37 -6
  80. package/src/images/plan.ts +5 -4
  81. package/src/integrations/ownership-policy.ts +141 -0
  82. package/src/integrations/ownership.ts +10 -0
  83. package/src/integrations/state.ts +44 -5
  84. package/src/integrations/writer.ts +6 -0
  85. package/src/lib/azure-identity.ts +154 -0
  86. package/src/lib/bounded-body.ts +14 -2
  87. package/src/lib/debug.ts +42 -0
  88. package/src/lib/errors.ts +14 -0
  89. package/src/lib/process-control.ts +2 -1
  90. package/src/lib/provider-outbound.ts +45 -33
  91. package/src/lib/provider-tls-profile.ts +309 -0
  92. package/src/lib/proxy-env.ts +49 -0
  93. package/src/lib/redact.ts +10 -1
  94. package/src/lib/state-store-registrations.ts +2 -0
  95. package/src/lib/tool-argument-integers.ts +56 -5
  96. package/src/oauth/antigravity-routing.ts +282 -236
  97. package/src/oauth/callback-server.ts +22 -2
  98. package/src/oauth/command-code.ts +5 -16
  99. package/src/oauth/google-antigravity.ts +42 -5
  100. package/src/oauth/health.ts +1 -1
  101. package/src/oauth/index.ts +15 -3
  102. package/src/oauth/kimi.ts +9 -1
  103. package/src/oauth/open-browser-choice.ts +26 -0
  104. package/src/oauth/store.ts +6 -0
  105. package/src/providers/antigravity-quota.ts +3 -1
  106. package/src/providers/api-keys.ts +2 -1
  107. package/src/providers/auto-compact-budget.ts +65 -0
  108. package/src/providers/derive.ts +4 -0
  109. package/src/providers/key-failover.ts +5 -1
  110. package/src/providers/openai-tiers.ts +5 -0
  111. package/src/providers/provider-id-rewrite.ts +1 -0
  112. package/src/providers/quota.ts +59 -13
  113. package/src/providers/registry.ts +4 -2
  114. package/src/providers/request-pacing.ts +33 -6
  115. package/src/providers/xai-transport.ts +21 -0
  116. package/src/reasoning-effort.ts +19 -2
  117. package/src/responses/apply-patch-envelope.ts +63 -0
  118. package/src/responses/custom-tool-compat.ts +132 -38
  119. package/src/responses/google-provider-options.ts +36 -0
  120. package/src/responses/namespace-tool-compat.ts +84 -4
  121. package/src/responses/parser.ts +14 -2
  122. package/src/responses/provider-opaque-metadata.ts +3 -3
  123. package/src/responses/reasoning-replay-cache.ts +81 -3
  124. package/src/responses/schema.ts +37 -0
  125. package/src/responses/state.ts +94 -4
  126. package/src/router.ts +8 -2
  127. package/src/server/auth-cors.ts +37 -7
  128. package/src/server/images.ts +19 -35
  129. package/src/server/index.ts +102 -21
  130. package/src/server/local-management-read-client.ts +1 -1
  131. package/src/server/local-provider-reload-client.ts +1 -1
  132. package/src/server/management/agent-settings-routes.ts +206 -16
  133. package/src/server/management/combo-routes.ts +6 -0
  134. package/src/server/management/config-routes.ts +35 -6
  135. package/src/server/management/context.ts +1 -1
  136. package/src/server/management/logs-usage-routes.ts +27 -6
  137. package/src/server/management/model-routes.ts +8 -4
  138. package/src/server/management/model-rows.ts +4 -0
  139. package/src/server/management/native-integration-routes.ts +2 -1
  140. package/src/server/management/oauth-account-routes.ts +25 -4
  141. package/src/server/management/provider-capability-config.ts +1 -1
  142. package/src/server/management/provider-routes.ts +113 -15
  143. package/src/server/management/routing-profile-routes.ts +3 -0
  144. package/src/server/management/system-restart.ts +1 -1
  145. package/src/server/port-reclaim.ts +1 -1
  146. package/src/server/proxy-liveness.ts +2 -1
  147. package/src/server/request-log-conversation.ts +30 -0
  148. package/src/server/request-log.ts +21 -0
  149. package/src/server/responses/agent-task-recovery.ts +1 -1
  150. package/src/server/responses/codex-auth-error.ts +55 -0
  151. package/src/server/responses/combo-stream-preflight.ts +171 -0
  152. package/src/server/responses/compact.ts +36 -22
  153. package/src/server/responses/core.ts +584 -247
  154. package/src/server/responses/empty-completion-guard.ts +35 -6
  155. package/src/server/responses/fetch-helpers.ts +20 -102
  156. package/src/server/responses/v2-native-parent-override.ts +59 -0
  157. package/src/server/responses/ws-upstream.ts +75 -2
  158. package/src/server/responses-custom-tool-repair.ts +41 -5
  159. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  160. package/src/service.ts +9 -5
  161. package/src/types/config.ts +16 -1
  162. package/src/types/provider.ts +16 -0
  163. package/src/types/request.ts +34 -1
  164. package/src/types/tools.ts +114 -11
  165. package/src/types.ts +7 -1
  166. package/src/update/index.ts +5 -4
  167. package/src/update/job.ts +3 -1
  168. package/src/update/transactional-install.mjs +8 -1
  169. package/src/usage/log.ts +16 -8
  170. package/src/usage/summary.ts +201 -8
  171. package/src/vision/describe.ts +18 -13
  172. package/src/web-search/executor.ts +10 -3
  173. package/src/web-search/gemini-executor.ts +6 -4
  174. package/src/web-search/loop.ts +42 -6
  175. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
  176. package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
@@ -153,9 +153,10 @@ export interface EmptyCompletionGuardOptions {
153
153
  * Watch an adapter event stream for the empty-completion failure mode. Events
154
154
  * are held until the turn produces content or ends: reasoning and other
155
155
  * 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.
156
+ * terminal is withheld, and an empty terminal or pre-output EOF triggers one
157
+ * identical-turn retry through `continuation`. Usage is merged across attempts
158
+ * so the bridge and request log meter the whole turn, not just the attempt that
159
+ * succeeded.
159
160
  *
160
161
  * Heartbeats always pass through untouched: they feed the bridge's stall
161
162
  * watchdog, so holding them behind the content gate would trip false
@@ -194,7 +195,11 @@ export async function* guardEmptyCompletionEventStream(
194
195
  if (sawContent || passthrough) {
195
196
  // Buffered content is already flowing; everything downstream passes
196
197
  // through. Every terminal carries usage merged across every attempt.
197
- yield isTerminalEvent(event) ? withUsage(event) : event;
198
+ if (isTerminalEvent(event)) {
199
+ yield withUsage(event);
200
+ return;
201
+ }
202
+ yield event;
198
203
  continue;
199
204
  }
200
205
  if (isContentEvent(event)) {
@@ -231,6 +236,13 @@ export async function* guardEmptyCompletionEventStream(
231
236
  return;
232
237
  }
233
238
  if (event.type === "error") {
239
+ // A local route/request validation failure from a continuation is already a complete,
240
+ // typed client error. Do not collapse it into the generic empty-completion retry failure.
241
+ if (event.status === 400 && event.errorType === "invalid_request_error") {
242
+ yield* releaseHeld();
243
+ yield withUsage(event);
244
+ return;
245
+ }
234
246
  if (retries > 0 && event.status !== 499) {
235
247
  // The retry failed upstream. Its body cannot reach the client (the
236
248
  // 200 head went out with the first attempt), so state the failure in
@@ -267,8 +279,25 @@ export async function* guardEmptyCompletionEventStream(
267
279
  if (isReasoningEvent(event)) yield { type: "heartbeat" };
268
280
  }
269
281
  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.
282
+ // A terminal-less EOF before text or a tool call is replay-safe: nothing
283
+ // actionable reached the client. Retry once, then surface a stated error
284
+ // instead of letting the bridge reduce the turn to adapter_eof.
285
+ if (!sawContent && !passthrough && retries < maxRetries) {
286
+ retries += 1;
287
+ try {
288
+ source = await options.continuation();
289
+ } catch {
290
+ yield emptyCompletionRetryFailedEvent(usage, true);
291
+ return;
292
+ }
293
+ continue;
294
+ }
295
+ if (!sawContent && retries > 0) {
296
+ yield emptyCompletionRetryFailedEvent(usage, true);
297
+ return;
298
+ }
299
+ // Post-output EOF remains incomplete; replaying could duplicate text or
300
+ // executable tool calls.
272
301
  yield* releaseHeld();
273
302
  return;
274
303
  }
@@ -5,107 +5,13 @@ 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";
12
+ import { providerTlsFetch } from "../../lib/provider-tls-profile";
107
13
 
108
- export { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
14
+ export { withUpstreamHttpVersion };
109
15
 
110
16
  export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
111
17
  if (!server) return false;
@@ -159,14 +65,29 @@ export function providerFetch(
159
65
  options: ProviderFetchOptions = {},
160
66
  ): ProviderFetch {
161
67
  const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
68
+ const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
69
+ base.preconnect?.(...args);
70
+ };
71
+ const transport = options.providerName
72
+ ? providerTlsFetch(options.providerName, provider, base)
73
+ : base;
74
+ const httpFetch = Object.assign(
75
+ (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
76
+ transport(input, withUpstreamHttpVersion(input, init, provider)),
77
+ { preconnect },
78
+ ) as typeof globalThis.fetch;
162
79
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
163
80
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
164
81
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
165
82
  const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
166
83
  if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
167
- return codexWsUpstreamFetch(input, init, base, runtime);
84
+ // The fallback has to be the same HTTP fetch the non-WS branch would have
85
+ // used, protocol pin included: a WS turn that falls back is serving the
86
+ // request over HTTP, and dropping the provider's `upstreamHttpVersion`
87
+ // there would silently negotiate a transport the operator ruled out.
88
+ return codexWsUpstreamFetch(input, init, httpFetch, runtime);
168
89
  }
169
- return base(input, withUpstreamHttpVersion(input, init, provider));
90
+ return httpFetch(input, init);
170
91
  };
171
92
  let pacingSlotAcquired = options.pacingSlotAcquired === true;
172
93
  const waitForPacing = (signal?: AbortSignal) => {
@@ -182,9 +103,6 @@ export function providerFetch(
182
103
  await waitForPacing(init?.signal ?? undefined);
183
104
  return unpaced(input, init);
184
105
  };
185
- const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
186
- base.preconnect?.(...args);
187
- };
188
106
  return Object.assign(wrapped, {
189
107
  preconnect,
190
108
  waitForPacing,
@@ -0,0 +1,59 @@
1
+ import type { OcxConfig, OcxParsedRequest } from "../../types";
2
+ import { routeModel, type RouteResult } from "../../router";
3
+ import type { PolicyRequestEvidence } from "../../routing/evaluator";
4
+ import { isMultiAgentV2Enabled } from "../../codex/features";
5
+ import { collabSurface } from "./collaboration";
6
+ import { isThreadSpawnRequest } from "../effort-policy";
7
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
8
+
9
+ export type V2NativeParentOverrideDecision =
10
+ | { kind: "skip" }
11
+ | { kind: "reject"; message: string; trace?: unknown }
12
+ | { kind: "override"; route: RouteResult };
13
+
14
+ export function decideV2NativeParentOverride(args: {
15
+ kind: "responses" | "compact";
16
+ config: OcxConfig;
17
+ headers: Headers;
18
+ sourceRoute: RouteResult;
19
+ parsed?: OcxParsedRequest;
20
+ comboAttempt?: boolean;
21
+ targetEvidence?: PolicyRequestEvidence;
22
+ }): V2NativeParentOverrideDecision {
23
+ const { config, headers, sourceRoute } = args;
24
+ if (
25
+ args.comboAttempt
26
+ || config.v2NativeParentOverride?.enabled !== true
27
+ || config.multiAgentMode !== "v2"
28
+ || config.keepNativeChatGptOnV1 === true
29
+ || !isMultiAgentV2Enabled()
30
+ ) return { kind: "skip" };
31
+ if (args.kind === "responses" && (
32
+ !args.parsed
33
+ || (collabSurface(args.parsed) !== "v2"
34
+ && !(args.parsed._compactionRequest === true && config.multiAgentMode === "v2"))
35
+ )) {
36
+ return { kind: "skip" };
37
+ }
38
+ if (isThreadSpawnRequest(headers) || headers.has("x-openai-subagent")) return { kind: "skip" };
39
+ if (!isCanonicalOpenAiForwardProvider(sourceRoute.provider)) return { kind: "skip" };
40
+
41
+ const targetModel = config.v2NativeParentOverride.model?.trim();
42
+ if (!targetModel) {
43
+ return { kind: "reject", message: "v2NativeParentOverride requires a configured model" };
44
+ }
45
+ let route: RouteResult;
46
+ try {
47
+ route = routeModel(config, targetModel, args.targetEvidence);
48
+ } catch (error) {
49
+ return {
50
+ kind: "reject",
51
+ message: error instanceof Error ? error.message : String(error),
52
+ trace: (error as { trace?: unknown }).trace,
53
+ };
54
+ }
55
+ if (isCanonicalOpenAiForwardProvider(route.provider)) {
56
+ return { kind: "reject", message: "v2NativeParentOverride target must resolve to a noncanonical provider" };
57
+ }
58
+ return { kind: "override", route };
59
+ }
@@ -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
 
@@ -1,4 +1,5 @@
1
1
  import type { TranslatorBudget } from "../lib/translator-budget";
2
+ import { normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
2
3
  import {
3
4
  customToolItemId,
4
5
  restoreRoutedCustomCalls,
@@ -85,8 +86,10 @@ type PendingArgumentBlock = {
85
86
  export function createRoutedCustomToolRestoreBlockRewrite(
86
87
  names: ReadonlySet<string>,
87
88
  budget?: TranslatorBudget,
89
+ repairNames: ReadonlySet<string> = new Set(),
88
90
  ): SseBlockRewrite {
89
- const itemNames = new Map<string, string>();
91
+ const itemNames = new Map<string, { name: string; namespace?: string }>();
92
+ const repairItemNames = new Map<string, string>();
90
93
  const ordinaryItemIds = new Set<string>();
91
94
  const openCalls = new Map<string, OpenCustomCall>();
92
95
  let pendingArguments: PendingArgumentBlock[] = [];
@@ -111,6 +114,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
111
114
  }
112
115
  pendingArguments = [];
113
116
  itemNames.clear();
117
+ repairItemNames.clear();
114
118
  ordinaryItemIds.clear();
115
119
  };
116
120
 
@@ -175,6 +179,23 @@ export function createRoutedCustomToolRestoreBlockRewrite(
175
179
  && parsed.output_index >= 0
176
180
  ? parsed.output_index
177
181
  : undefined;
182
+ if (
183
+ (type === "response.output_item.added" || type === "response.output_item.done")
184
+ && isPlainObject(parsed.item)
185
+ && parsed.item.type === "custom_tool_call"
186
+ && typeof parsed.item.name === "string"
187
+ ) {
188
+ const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
189
+ const wireName = routedCustomToolWireName(parsed.item);
190
+ const repairable = wireName !== undefined && repairNames.has(wireName);
191
+ if (upstreamItemId && repairable) repairItemNames.set(upstreamItemId, parsed.item.name);
192
+ const restored = repairable
193
+ ? restoreRoutedCustomCalls(parsed, names, repairNames)
194
+ : { value: parsed, changed: false };
195
+ return restored.changed
196
+ ? [replaceSseDataPayload(block, JSON.stringify(restored.value))]
197
+ : [block];
198
+ }
178
199
  if (
179
200
  (type === "response.output_item.added" || type === "response.output_item.done")
180
201
  && isPlainObject(parsed.item)
@@ -186,7 +207,10 @@ export function createRoutedCustomToolRestoreBlockRewrite(
186
207
  const routed = wireName !== undefined && names.has(wireName);
187
208
  if (upstreamItemId) {
188
209
  if (routed) {
189
- itemNames.set(upstreamItemId, parsed.item.name);
210
+ itemNames.set(upstreamItemId, {
211
+ name: parsed.item.name,
212
+ ...(typeof parsed.item.namespace === "string" ? { namespace: parsed.item.namespace } : {}),
213
+ });
190
214
  ordinaryItemIds.delete(upstreamItemId);
191
215
  } else {
192
216
  ordinaryItemIds.add(upstreamItemId);
@@ -203,7 +227,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
203
227
  if (upstreamItemId && pending.length > 0 && !openCalls.has(upstreamItemId)) {
204
228
  openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 });
205
229
  }
206
- const restored = restoreRoutedCustomCalls(parsed, names);
230
+ const restored = restoreRoutedCustomCalls(parsed, names, repairNames);
207
231
  const restoredBlock = restored.changed
208
232
  ? replaceSseDataPayload(block, JSON.stringify(restored.value))
209
233
  : block;
@@ -215,6 +239,17 @@ export function createRoutedCustomToolRestoreBlockRewrite(
215
239
  }
216
240
 
217
241
  const upstreamItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
242
+ if (
243
+ type === "response.custom_tool_call_input.done"
244
+ && upstreamItemId
245
+ && repairItemNames.has(upstreamItemId)
246
+ && typeof parsed.input === "string"
247
+ ) {
248
+ const input = normalizeApplyPatchDelimiters(parsed.input);
249
+ if (input !== parsed.input) {
250
+ return [replaceSseDataPayload(block, JSON.stringify({ ...parsed, input }))];
251
+ }
252
+ }
218
253
  const argumentEvent = type === "response.function_call_arguments.delta"
219
254
  || type === "response.function_call_arguments.done";
220
255
  if (argumentEvent && (!upstreamItemId || (!itemNames.has(upstreamItemId) && !ordinaryItemIds.has(upstreamItemId)))) {
@@ -261,16 +296,17 @@ export function createRoutedCustomToolRestoreBlockRewrite(
261
296
  ? parsed.arguments
262
297
  : openCalls.get(upstreamItemId)?.argumentsText ?? "";
263
298
  const { arguments: _arguments, ...rest } = parsed;
299
+ const itemName = itemNames.get(upstreamItemId);
264
300
  const next = {
265
301
  ...rest,
266
302
  type: nextType,
267
303
  item_id: customToolItemId(upstreamItemId),
268
- input: unwrapRoutedCustomToolArguments(source),
304
+ input: unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace),
269
305
  };
270
306
  return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))];
271
307
  }
272
308
 
273
- const restored = restoreRoutedCustomCalls(parsed, names);
309
+ const restored = restoreRoutedCustomCalls(parsed, names, repairNames);
274
310
  const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
275
311
  if (terminal) releaseAll();
276
312
  return restored.changed