@bitkyc08/opencodex 2.49.0 → 2.51.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -102,7 +102,7 @@ import {
102
102
  } from "../../lib/errors";
103
103
  import { injectionDebugLog } from "../../lib/injection-debug-log";
104
104
  import { resolveClientRetryAfter } from "../../lib/retry-after";
105
- import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit";
105
+ import { enrichOpenCodeZenUpstreamMessage } from "../../providers/opencode-zen-rate-limit";
106
106
  import { CODE_MODE_EXEC_TOOL_NAME, modelInList, namespacedToolName } from "../../types";
107
107
  import type {
108
108
  AdapterEvent,
@@ -150,6 +150,11 @@ import {
150
150
  } from "../../oauth/generic-account-failover";
151
151
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
152
152
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
153
+ import {
154
+ createOllamaBridgeExecutor,
155
+ createPassthroughWebSearchBridgeStream,
156
+ planPassthroughWebSearchBridge,
157
+ } from "../../web-search/passthrough-bridge";
153
158
  import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
154
159
  import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
155
160
  import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue";
@@ -243,7 +248,13 @@ import { hasPassiveAccountQuota, recordAnthropicAccountQuotaFromHeaders, recordP
243
248
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
244
249
  import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
245
250
  import { isUsageDebugEnabled } from "../../usage/debug";
246
- import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
251
+ import {
252
+ readJsonRequestBody,
253
+ describeInboundBodyRefusal,
254
+ resolveInboundBodyLimitBytes,
255
+ DecompressedBodyTooLargeError,
256
+ UnsupportedContentEncodingError,
257
+ } from "../request-decompress";
247
258
  import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
248
259
  import {
249
260
  providerModelResponsesTerminalRepair,
@@ -309,6 +320,8 @@ import {
309
320
  } from "../request-log";
310
321
  import {
311
322
  conversationIdFromResponsesRequest,
323
+ getOrAllocateRequestSessionLane,
324
+ linkRequestSessionLane,
312
325
  normalizeLogConversationId,
313
326
  reasoningReplayConversationIdFromResponsesRequest,
314
327
  sessionLaneIdFromRequest,
@@ -424,7 +437,7 @@ import {
424
437
  } from "../responses-undeclared-tool-guard";
425
438
  import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
426
439
  import { responsesJsonToSseStream } from "../responses-json-events";
427
- import { streamingContextOverflowResponse } from "./context-overflow";
440
+ import { jsonContextOverflowResponse, streamingContextOverflowResponse } from "./context-overflow";
428
441
  import { guardTerminalEventStream } from "./terminal-guard";
429
442
  import {
430
443
  emptyCompletionRetryEnabled,
@@ -1012,14 +1025,14 @@ export function usesCodexForwardPoolAuth(
1012
1025
  && provider.authMode === "forward" && provider.adapter === "openai-responses";
1013
1026
  }
1014
1027
 
1015
- function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig): CodexWsQuotaObserver | undefined {
1028
+ function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig, modelId?: string): CodexWsQuotaObserver | undefined {
1016
1029
  if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
1017
1030
  const { accountId, writerGeneration } = authCtx;
1018
1031
  const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined;
1019
1032
  const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined;
1020
1033
  return headers => {
1021
1034
  if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return;
1022
- applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter);
1035
+ applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId });
1023
1036
  };
1024
1037
  }
1025
1038
 
@@ -1382,6 +1395,7 @@ async function retryCodexPoolOnAlternateAccount(
1382
1395
  firstResponse.headers,
1383
1396
  firstAuthCtx.writerGeneration,
1384
1397
  firstAuthCtx.kind === "main-pool" ? firstAuthCtx.mainQuotaWriter : undefined,
1398
+ { modelId: route.modelId },
1385
1399
  );
1386
1400
  }
1387
1401
  const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown(
@@ -1473,7 +1487,7 @@ async function retryCodexPoolOnAlternateAccount(
1473
1487
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1474
1488
  providerName: route.providerName,
1475
1489
  modelId: route.modelId,
1476
- onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider),
1490
+ onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId),
1477
1491
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1478
1492
  ? createCodexReserveDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1479
1493
  }),
@@ -1607,7 +1621,7 @@ export function decodeRequestErrorResponse(err: unknown, label: string): Respons
1607
1621
  return formatErrorResponse(415, "invalid_request_error", err.message);
1608
1622
  }
1609
1623
  if (err instanceof DecompressedBodyTooLargeError) {
1610
- return formatErrorResponse(413, "invalid_request_error", err.message);
1624
+ return formatErrorResponse(413, "inbound_body_too_large", describeInboundBodyRefusal(err));
1611
1625
  }
1612
1626
  console.warn(`[${label}] request body decode/parse failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
1613
1627
  return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
@@ -2248,6 +2262,50 @@ function isTerminalPoolRefreshFailure(error: unknown): boolean {
2248
2262
  return error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired");
2249
2263
  }
2250
2264
 
2265
+ /**
2266
+ * The refusal an operator meets when a stored pool credential's forced refresh does not complete.
2267
+ *
2268
+ * A bare "retry this request" reads as a transient fault in the proxy, which is how #4212's
2269
+ * reporter spent an afternoon concluding OpenCodex had broken while one of their own accounts was
2270
+ * the thing that needed them. It stays a retryable 503 and stays non-quarantining, because the
2271
+ * refresh genuinely may succeed and a token-endpoint 5xx must not retire a healthy account
2272
+ * (#2887). What it adds is the account and the exit: when retrying stops helping, that account
2273
+ * has to be signed in again.
2274
+ *
2275
+ * The label is a public account selector when the request carried one, otherwise the durable
2276
+ * `p`-prefixed log label — never the raw pool id and never the email. Those are the identifiers
2277
+ * `responses-compaction-routing.test.ts` and `codex-auth-context.test.ts` already assert must not
2278
+ * reach an operator-facing surface, and an error body travels further than a log line, not less.
2279
+ * When neither is resolvable the sentence degrades to "the selected Codex pool account" rather
2280
+ * than naming something opaque, because a wrong name is worse than no name.
2281
+ *
2282
+ * The wording says "sign in to that account again" and deliberately does NOT say
2283
+ * "reauthentication". `classifyError` runs `isAuthenticationMessage` before it reaches the
2284
+ * `status === 503` arm, and that check is status-blind on the bare substring "authentication",
2285
+ * which "reauthentication" contains. A body carrying that word is reclassified to
2286
+ * `authentication_error` / `invalid_api_key` even though the HTTP status stays 503 — and Codex
2287
+ * applies retry-after backoff only for `server_is_overloaded`, so the friendlier sentence would
2288
+ * have quietly disabled the retry this refusal exists to ask for. `options.code` cannot buy the
2289
+ * classification back; only the wording can.
2290
+ */
2291
+ export function poolCredentialRefreshIncompleteResponse(args: {
2292
+ authCtx: CodexAuthContext;
2293
+ config: Pick<OcxConfig, "codexAccounts">;
2294
+ accountSelector?: string;
2295
+ }): Response {
2296
+ const label = args.accountSelector ?? codexAuthContextLogLabel(args.authCtx, args.config);
2297
+ const account = label ? `Codex pool account ${label}` : "the selected Codex pool account";
2298
+ const response = formatErrorResponse(
2299
+ 503,
2300
+ "server_busy",
2301
+ `Codex credential refresh did not complete for ${account}; retry this request. `
2302
+ + "If it keeps failing, sign in to that account again.",
2303
+ );
2304
+ const headers = new Headers(response.headers);
2305
+ headers.set("Retry-After", "1");
2306
+ return new Response(response.body, { status: response.status, headers });
2307
+ }
2308
+
2251
2309
  /**
2252
2310
  * One forced refresh and one same-account rebuild for a stored pool credential that
2253
2311
  * upstream rejected with a pre-stream 401. `quarantine` distinguishes a dead grant,
@@ -2318,14 +2376,15 @@ async function refreshPoolForwardAuth(args: {
2318
2376
  response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
2319
2377
  };
2320
2378
  }
2321
- const response = formatErrorResponse(
2322
- 503,
2323
- "server_busy",
2324
- "Codex credential refresh did not complete; retry this request",
2325
- );
2326
- const headers = new Headers(response.headers);
2327
- headers.set("Retry-After", "1");
2328
- return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) };
2379
+ return {
2380
+ ok: false,
2381
+ quarantine: false,
2382
+ response: poolCredentialRefreshIncompleteResponse({
2383
+ authCtx,
2384
+ config,
2385
+ accountSelector: route.codexAccountNamespace,
2386
+ }),
2387
+ };
2329
2388
  }
2330
2389
  }
2331
2390
 
@@ -2439,8 +2498,7 @@ async function applyFinalRouteRequestNormalization(args: {
2439
2498
 
2440
2499
  // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
2441
2500
  // this request will actually use (#404).
2442
- route.provider = resolveOpenCodeGoTransport(route.provider,
2443
- sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session")));
2501
+ route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req));
2444
2502
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
2445
2503
  if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
2446
2504
  logCtx.model = route.modelId;
@@ -2779,6 +2837,10 @@ export async function handleComboResponses(
2779
2837
  logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
2780
2838
 
2781
2839
  let lastFailure: Response | null = null;
2840
+ // The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode`
2841
+ // is gone, so carry the loop's own classification decision instead of re-deriving a
2842
+ // weaker one from the status alone (#4149).
2843
+ let lastFailureClassifiesOverflow = false;
2782
2844
  while (pick) {
2783
2845
  if (options.abortSignal?.aborted) return clientCancelledResponse();
2784
2846
  const childLog: RequestLogContext = {
@@ -2800,6 +2862,7 @@ export async function handleComboResponses(
2800
2862
  headers: childHeaders,
2801
2863
  body: JSON.stringify(childBody),
2802
2864
  });
2865
+ linkRequestSessionLane(req, childRequest);
2803
2866
  let resolvedAuth: CodexAuthContext | undefined;
2804
2867
  let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
2805
2868
  const started = Date.now();
@@ -2986,6 +3049,12 @@ export async function handleComboResponses(
2986
3049
  const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, {
2987
3050
  code: failure.upstreamCode,
2988
3051
  });
3052
+ const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
3053
+ // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
3054
+ const classifyOverflow = failure.response.status === 413
3055
+ && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large"
3056
+ && failure.upstreamCode !== "translation_buffer_limit"));
3057
+ lastFailureClassifiesOverflow = classifyOverflow;
2989
3058
  if (storedPool401ReplayDispatched) {
2990
3059
  if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) {
2991
3060
  const recoveredTarget = await pickWithWait({
@@ -3010,15 +3079,19 @@ export async function handleComboResponses(
3010
3079
  // Keep the spent Pool budget sticky even after a recovered routed child:
3011
3080
  // no later failure may reopen ordinary combo/native account hopping.
3012
3081
  adoptFailedChildLog(childLog);
3082
+ if (classifyOverflow && failureDecision === "stop") {
3083
+ return wantsStream
3084
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
3085
+ : jsonContextOverflowResponse();
3086
+ }
3013
3087
  return lastFailure;
3014
3088
  }
3015
3089
  if (failureDecision === "stop") {
3016
3090
  adoptFailedChildLog(childLog);
3017
- if (
3018
- failure.response.status === 413
3019
- && (rawBody as { stream?: unknown } | null)?.stream === true
3020
- ) {
3021
- return streamingContextOverflowResponse(requestedModel, options.translatorBudget);
3091
+ if (classifyOverflow) {
3092
+ return wantsStream
3093
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
3094
+ : jsonContextOverflowResponse();
3022
3095
  }
3023
3096
  return lastFailure;
3024
3097
  }
@@ -3068,9 +3141,11 @@ export async function handleComboResponses(
3068
3141
  }
3069
3142
  if (
3070
3143
  lastFailure?.status === 413
3071
- && (rawBody as { stream?: unknown } | null)?.stream === true
3144
+ && lastFailureClassifiesOverflow
3072
3145
  ) {
3073
- return streamingContextOverflowResponse(requestedModel, options.translatorBudget);
3146
+ return (rawBody as { stream?: unknown } | null)?.stream === true
3147
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
3148
+ : jsonContextOverflowResponse();
3074
3149
  }
3075
3150
  return lastFailure!;
3076
3151
  }
@@ -3222,7 +3297,7 @@ async function handleResponsesInner(
3222
3297
  const agentTaskRecovery = agentTaskRecoveryConfig(config);
3223
3298
  let body: unknown;
3224
3299
  try {
3225
- body = await readJsonRequestBody(req, translatorBudget);
3300
+ body = await readJsonRequestBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
3226
3301
  } catch (err) {
3227
3302
  if (options.abortSignal?.aborted || req.signal.aborted) {
3228
3303
  return clientCancelledResponse();
@@ -3275,6 +3350,30 @@ async function handleResponsesInner(
3275
3350
  }
3276
3351
  }
3277
3352
  }
3353
+ // A shadow-call replacement that names a COMBO is routing policy, not the identity of any
3354
+ // one pick. The late intercept site below resolves it through routeModel/tryPickComboModel,
3355
+ // which collapses the table to a single target while still tagging `routeKind: "combo"`, so
3356
+ // the combo gate on the next line never fires, handleComboResponses never runs, and 429/5xx
3357
+ // hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
3358
+ // here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
3359
+ // LOOKUP so the check can never observe a one-candidate collapse.
3360
+ if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) {
3361
+ const shadowIntercept = config.shadowCallIntercept;
3362
+ const rawShadowModel = (body as { model?: unknown }).model;
3363
+ if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
3364
+ && isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) {
3365
+ const shadowComboId = resolveComboId(config, shadowIntercept.model);
3366
+ if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) {
3367
+ (body as Record<string, unknown>).model = shadowIntercept.model;
3368
+ // Same rule as the late intercept site: record the operator-configured prefix that
3369
+ // matched, never the caller's raw model string. Matching is by prefix, so the raw
3370
+ // value is caller-controlled and reaches usage.jsonl and /api/logs.
3371
+ logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
3372
+ shadowSourceModelPrefix(rawShadowModel, shadowIntercept.sourceModels),
3373
+ );
3374
+ }
3375
+ }
3376
+ }
3278
3377
  const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
3279
3378
  if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
3280
3379
  options.onRequestBodyRead?.();
@@ -3612,10 +3711,19 @@ async function handleResponsesInner(
3612
3711
  let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined;
3613
3712
  // Native fallback and explicitly trusted direct Responses routes can consume ciphertext,
3614
3713
  // so recover only after final route selection.
3714
+ //
3715
+ // Deliberately NOT gated on `threadSpawn` (#4089). Switching a live thread from a native
3716
+ // ChatGPT model to a routed provider replays a backend-minted encrypted agent message on every
3717
+ // later turn, and a model switch is not a spawn, so the spawn requirement failed the thread
3718
+ // closed permanently without ever attempting recovery. The trust boundary is
3719
+ // `recoveryAdmission()` in ./agent-task-recovery -- Codex originator, live native ChatGPT
3720
+ // bearer, matching chatgpt-account-id, no inbound API key, no proxy-admission secret -- which
3721
+ // admits only the owner of the session that would be spent. `threadSpawn` narrowed which of
3722
+ // that owner's own requests could use their own session; it kept nobody else out. The combo
3723
+ // gate above keeps its spawn requirement: that path has its own native-target filtering and
3724
+ // per-attempt failover, and the reported defect is on this path.
3615
3725
  if (
3616
3726
  inboundWire === "responses"
3617
- &&
3618
- threadSpawn
3619
3727
  && agentTaskRecovery
3620
3728
  && !isCanonicalOpenAiForwardProvider(route.provider)
3621
3729
  && !options.comboAttempt
@@ -5050,7 +5158,7 @@ async function handleResponsesInner(
5050
5158
  dispatchOverride: oauthDispatch(request),
5051
5159
  providerName: route.providerName,
5052
5160
  modelId: route.modelId,
5053
- onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider),
5161
+ onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider, route.modelId),
5054
5162
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
5055
5163
  ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
5056
5164
  }),
@@ -5128,7 +5236,7 @@ async function handleResponsesInner(
5128
5236
  dispatchOverride: oauthDispatch(request),
5129
5237
  providerName: route.providerName,
5130
5238
  modelId: route.modelId,
5131
- onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider),
5239
+ onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider, route.modelId),
5132
5240
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
5133
5241
  ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
5134
5242
  }),
@@ -5234,7 +5342,7 @@ async function handleResponsesInner(
5234
5342
  dispatchOverride: oauthDispatch(request),
5235
5343
  providerName: route.providerName,
5236
5344
  modelId: route.modelId,
5237
- onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider),
5345
+ onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider, route.modelId),
5238
5346
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
5239
5347
  ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
5240
5348
  }),
@@ -5354,7 +5462,7 @@ async function handleResponsesInner(
5354
5462
  dispatchOverride: oauthDispatch(request),
5355
5463
  providerName: route.providerName,
5356
5464
  modelId: route.modelId,
5357
- onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider),
5465
+ onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider, route.modelId),
5358
5466
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
5359
5467
  ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
5360
5468
  }),
@@ -5454,7 +5562,7 @@ async function handleResponsesInner(
5454
5562
  dispatchOverride: oauthDispatch(request),
5455
5563
  providerName: route.providerName,
5456
5564
  modelId: route.modelId,
5457
- onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider),
5565
+ onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider, route.modelId),
5458
5566
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
5459
5567
  ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
5460
5568
  }),
@@ -5658,7 +5766,8 @@ async function handleResponsesInner(
5658
5766
  const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
5659
5767
  if (!isCodexWsQuotaObservedResponse(upstreamResponse)) {
5660
5768
  applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers,
5661
- authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined);
5769
+ authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined,
5770
+ { modelId: route.modelId });
5662
5771
  }
5663
5772
  if (terminalBodyWillRecord) {
5664
5773
  options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
@@ -5699,7 +5808,8 @@ async function handleResponsesInner(
5699
5808
 
5700
5809
  // Non-2xx passthrough failures must never reach Codex as an empty body —
5701
5810
  // Codex renders that as the opaque "Unknown error" (#452). Combo attempts
5702
- // keep their typed failure envelope. Non-empty bodies are relayed verbatim
5811
+ // keep their typed failure envelope. Except for the classified 413 below,
5812
+ // non-empty bodies are relayed verbatim
5703
5813
  // (headers included) so pool-retry Activation B/D and client diagnostics stay intact.
5704
5814
  // Manual-redirect policy (#914): a 3xx is relayed as-is (Location preserved
5705
5815
  // through sanitizePassthroughHeaders) so a redirect to a dead host can never
@@ -5727,11 +5837,10 @@ async function handleResponsesInner(
5727
5837
  // The bounded reader owns the original body, deadline, abort settlement, and lock.
5728
5838
  // Unsafe partial data falls back to #452's non-empty status-only JSON.
5729
5839
  const errorText = await readDisplaySafeErrorText(upstreamResponse, upstream.signal, "");
5730
- if (upstreamResponse.status === 413 && clientRequestedStream) {
5731
- return streamingContextOverflowResponse(
5732
- parsed._responseModelId ?? parsed.modelId,
5733
- translatorBudget,
5734
- );
5840
+ if (upstreamResponse.status === 413) {
5841
+ return clientRequestedStream
5842
+ ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget)
5843
+ : jsonContextOverflowResponse();
5735
5844
  }
5736
5845
  return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
5737
5846
  statusText: upstreamResponse.statusText,
@@ -5760,15 +5869,60 @@ async function handleResponsesInner(
5760
5869
  route.provider,
5761
5870
  route.modelId,
5762
5871
  );
5872
+ // #3761: opt-in hosted-web-search bridge. Codex always declares the hosted web_search tool,
5873
+ // and this branch relays that declaration on the assumption the destination executes it.
5874
+ // A KEY-auth gateway that does not (Ollama Cloud GLM) answers with a function_call named
5875
+ // web_search that nothing runs, and the undeclared-tool guard below ends the turn. When the
5876
+ // provider opts in, the bridge intercepts that one call, runs the search, continues the
5877
+ // conversation upstream, and hands back ordinary Responses SSE — so every rewrite below,
5878
+ // including the guard itself, still inspects the client-facing stream. Default OFF: without
5879
+ // the opt-in this is one planner call and the relay is byte-identical to before.
5880
+ const webSearchBridgePlan = planPassthroughWebSearchBridge(parsed, route.provider, {
5881
+ isPassthrough: true,
5882
+ stream: parsed.stream === true,
5883
+ });
5884
+ // The bridge wraps the RAW upstream body, so terminal repair below still owns the single
5885
+ // client-facing terminal — the bridge drops the terminal of every intercepted leg.
5886
+ const upstreamSseBody = webSearchBridgePlan
5887
+ ? createPassthroughWebSearchBridgeStream({
5888
+ plan: webSearchBridgePlan,
5889
+ firstLeg: upstreamResponse.body,
5890
+ requestBody: request.body,
5891
+ // Continuation legs replay the same built request with the executed search appended.
5892
+ // The first leg already passed the recovery ladder, the outbound size ceiling, and the
5893
+ // host circuit; a KEY-auth destination has no OAuth refresh to replay on a later leg.
5894
+ send: (continuationBody: string) => fetchWithHeaderTimeout(
5895
+ request.url,
5896
+ { method: request.method, headers: request.headers, body: continuationBody },
5897
+ upstream.signal,
5898
+ connectMs,
5899
+ true,
5900
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
5901
+ dispatchOverride: oauthDispatch(request),
5902
+ providerName: route.providerName,
5903
+ modelId: route.modelId,
5904
+ }),
5905
+ false,
5906
+ ),
5907
+ execute: createOllamaBridgeExecutor(webSearchBridgePlan, route.provider.apiKey ?? ""),
5908
+ // Appending a search result can push the continuation past the ceiling the first leg
5909
+ // was admitted under, so the same limit is re-applied before every later send.
5910
+ checkOutboundBody: (continuationBody: string) => {
5911
+ const result = checkOutboundBodySize(continuationBody, config.maxUpstreamBodyBytes);
5912
+ return result.admitted ? undefined : describeOutboundBodyRefusal(result);
5913
+ },
5914
+ signal: upstream.signal,
5915
+ })
5916
+ : upstreamResponse.body;
5763
5917
  const passthroughSseBody = terminalRepairPolicy
5764
5918
  ? relayResponsesSseWithTerminalRepair(
5765
- upstreamResponse.body,
5919
+ upstreamSseBody,
5766
5920
  upstream,
5767
5921
  terminalRepairPolicy,
5768
5922
  translatorBudget,
5769
5923
  options.responsesTerminalRepairScheduler,
5770
5924
  )
5771
- : upstreamResponse.body;
5925
+ : upstreamSseBody;
5772
5926
  const repairConfig = route.provider.responsesItemIdRepair;
5773
5927
  // Grok Build renders deltas live but reconstructs its durable assistant
5774
5928
  // turn from the completed response snapshot. Native Responses streams
@@ -7463,11 +7617,10 @@ async function handleResponsesInner(
7463
7617
  } finally {
7464
7618
  cleanupUpstreamAbort();
7465
7619
  }
7466
- if (upstreamResponse.status === 413 && clientRequestedStream && !options.comboAttempt) {
7467
- return streamingContextOverflowResponse(
7468
- parsed._responseModelId ?? parsed.modelId,
7469
- translatorBudget,
7470
- );
7620
+ if (upstreamResponse.status === 413) {
7621
+ return clientRequestedStream
7622
+ ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget)
7623
+ : jsonContextOverflowResponse();
7471
7624
  }
7472
7625
  if (!isFixedCodexAccount(authCtx)) {
7473
7626
  recordSubagentQuotaFailureForThreadSpawn(
@@ -7487,7 +7640,7 @@ async function handleResponsesInner(
7487
7640
  const message = normalized.cyberPolicy
7488
7641
  ? normalized.message
7489
7642
  ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText)
7490
- : enrichOpenCodeZenRateLimitMessage(
7643
+ : enrichOpenCodeZenUpstreamMessage(
7491
7644
  `Provider error ${upstreamResponse.status}: ${normalized.safeText}`,
7492
7645
  {
7493
7646
  status: upstreamResponse.status,
@@ -7498,7 +7651,7 @@ async function handleResponsesInner(
7498
7651
  hasApiKey: Boolean(route.provider.apiKey?.trim()),
7499
7652
  upstreamRetryAfter,
7500
7653
  // This recovery path is the HTTP Responses wire; custom runTurn transports
7501
- // never reach enrichOpenCodeZenRateLimitMessage here.
7654
+ // never reach enrichOpenCodeZenUpstreamMessage here.
7502
7655
  supportsHttpSameKeyRetry: true,
7503
7656
  },
7504
7657
  );
@@ -1,7 +1,8 @@
1
1
  import { comboFailureDecision } from "../../combos/failover";
2
2
  import { readBoundedResponseBody } from "../../lib/bounded-body";
3
- import { readJsonRequestBody } from "../request-decompress";
3
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress";
4
4
  import { finishRequestAttempt, type RequestLogContext } from "../request-log";
5
+ import { linkRequestSessionLane } from "../request-log-conversation";
5
6
  import type { OcxConfig } from "../../types";
6
7
  import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
7
8
  import { handleResponses as handleResponsesCore } from "./core";
@@ -56,12 +57,17 @@ function requestWithCandidate(
56
57
  headers.delete("content-encoding");
57
58
  headers.delete("content-length");
58
59
  headers.set("content-type", "application/json");
59
- return new Request(req.url, {
60
+ const retryRequest = new Request(req.url, {
60
61
  method: req.method,
61
62
  headers,
62
63
  body: JSON.stringify({ ...rawBody, model: `${candidate.provider}/${candidate.model}` }),
63
64
  signal: req.signal,
64
65
  });
66
+ // A sessionless request keeps the lane it was already allocated. Without this the second
67
+ // candidate reaches OpenCode Go under a different x-opencode-session than the first attempt,
68
+ // which is the same conversation split the header exists to prevent.
69
+ linkRequestSessionLane(req, retryRequest);
70
+ return retryRequest;
65
71
  }
66
72
 
67
73
  function errorCodeFromText(text: string): string | undefined {
@@ -145,7 +151,11 @@ export async function handleResponsesWithPolicyFallback(
145
151
  };
146
152
  let rawBody: Record<string, unknown> | null = null;
147
153
  try {
148
- const parsed = await readJsonRequestBody(req.clone());
154
+ const parsed = await readJsonRequestBody(
155
+ req.clone(),
156
+ undefined,
157
+ resolveInboundBodyLimitBytes(config.maxInboundBodyBytes),
158
+ );
149
159
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) rawBody = parsed as Record<string, unknown>;
150
160
  } catch {
151
161
  // Core owns the client-facing parse/decompression error.
@@ -33,7 +33,7 @@ import {
33
33
  type ExactOpenAiSidecarAccount,
34
34
  } from "../providers/openai-sidecar";
35
35
  import { routeModel } from "../router";
36
- import { readJsonRequestBody } from "./request-decompress";
36
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
37
37
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
38
38
  import type { RequestLogContext } from "./request-log";
39
39
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
@@ -64,7 +64,7 @@ export async function handleSearch(
64
64
  }
65
65
  let body: unknown;
66
66
  try {
67
- body = await readJsonRequestBody(req);
67
+ body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
68
68
  } catch (err) {
69
69
  return decodeRequestErrorResponse(err, "search");
70
70
  }
@@ -7,6 +7,7 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode";
7
7
  import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context";
8
8
  import type { OcxConfig } from "../types";
9
9
  import { recordOwnedConfigPath } from "../lib/config-ownership";
10
+ import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
10
11
 
11
12
  /**
12
13
  * Does the opencodex dummy marker belong in the system environment?
@@ -79,9 +80,14 @@ export function writeShellEnvFile(
79
80
  auto?: AutoContextMode,
80
81
  deps: SystemEnvDeps = {},
81
82
  ): void {
83
+ // Same local destination the launchd domain gets, resolved through the same resolver rather
84
+ // than re-derived: the unauthenticated loopback listener when one is enabled, otherwise the
85
+ // bind address (#4236). The two files must not disagree, or a new shell and a launchd-started
86
+ // `claude` would dial different sockets.
87
+ const destination = localInferenceDestination(config, port);
82
88
  const lines = [
83
89
  `# Generated by opencodex — do not edit manually`,
84
- `export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
90
+ `export ANTHROPIC_BASE_URL=${shellValue(destination.origin)}`,
85
91
  `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`,
86
92
  ];
87
93
  // New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
@@ -89,7 +95,13 @@ export function writeShellEnvFile(
89
95
  const conditional = (name: string, value: string) =>
90
96
  `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
91
97
  if (systemEnvMarkerMode(config, deps) === "proxy") {
92
- if (config.apiKeys?.length) {
98
+ // On a bind that demands data-plane admission the credential may live only in
99
+ // `OPENCODEX_API_AUTH_TOKEN` or the hardened service token file, so the same ladder the
100
+ // launchd injection uses applies here. Never the admin token (reviewer constraint on #4236).
101
+ const hostAdmissionToken = destination.requiresAdmissionToken ? localAdmissionToken(config) : undefined;
102
+ if (hostAdmissionToken) {
103
+ lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(hostAdmissionToken)}`);
104
+ } else if (config.apiKeys?.length) {
93
105
  lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
94
106
  } else {
95
107
  lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));