@yansigit/opencodex 2.31.2 → 2.32.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 (118) hide show
  1. package/bin/ocx.mjs +99 -70
  2. package/gui/dist/assets/index-BG43zwVe.js +102 -0
  3. package/gui/dist/assets/index-CiSI-jrP.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +6 -8
  7. package/src/adapters/base.ts +2 -0
  8. package/src/adapters/command-code-project-context.ts +377 -0
  9. package/src/adapters/command-code.ts +7 -4
  10. package/src/adapters/cursor/live-models.ts +8 -0
  11. package/src/adapters/cursor/live-transport.ts +21 -0
  12. package/src/adapters/cursor/native-exec-bridge.ts +141 -0
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/tool-definitions.ts +1 -1
  15. package/src/adapters/google-http.ts +12 -2
  16. package/src/adapters/google-wire-compiler.ts +83 -2
  17. package/src/adapters/google.ts +62 -21
  18. package/src/adapters/kiro.ts +0 -3
  19. package/src/adapters/openai-responses.ts +3 -0
  20. package/src/adapters/tool-catalog-nudge.ts +1 -1
  21. package/src/adapters/xai-web-search.ts +7 -2
  22. package/src/bridge.ts +21 -15
  23. package/src/cli/dispatch.ts +50 -2
  24. package/src/cli/doctor.ts +24 -11
  25. package/src/cli/help.ts +4 -3
  26. package/src/cli/index.ts +11 -4
  27. package/src/cli/models.ts +13 -3
  28. package/src/cli/observe.ts +20 -5
  29. package/src/cli/provider.ts +2 -1
  30. package/src/cli/registry.ts +7 -5
  31. package/src/cli/status.ts +2 -1
  32. package/src/cli/system-restart-client.ts +1 -1
  33. package/src/cli/usage-report.ts +134 -0
  34. package/src/codex/app-server-processes.ts +3 -1
  35. package/src/codex/catalog/aggregation.ts +13 -1
  36. package/src/codex/catalog/effort.ts +24 -9
  37. package/src/codex/catalog/model-metadata.ts +566 -0
  38. package/src/codex/catalog/parsing.ts +33 -0
  39. package/src/codex/catalog/provider-fetch.ts +123 -32
  40. package/src/codex/catalog/sync.ts +5 -4
  41. package/src/codex/desktop-app-restart.ts +342 -0
  42. package/src/codex/history-job.ts +32 -3
  43. package/src/codex/history-manifest.ts +112 -0
  44. package/src/codex/history-migration-guardian.ts +5 -5
  45. package/src/codex/history-provider.ts +825 -247
  46. package/src/codex/history-worker.ts +8 -5
  47. package/src/codex/inject.ts +49 -21
  48. package/src/codex/injected-marker.ts +1 -1
  49. package/src/codex/internal/history-writer.ts +4 -3
  50. package/src/codex/native-profile-startup.ts +157 -27
  51. package/src/codex/native-residue.ts +26 -33
  52. package/src/combos/failover.ts +27 -0
  53. package/src/compatibility/index.ts +26 -0
  54. package/src/compatibility/manifest.ts +253 -0
  55. package/src/compatibility/openai-responses.ts +81 -0
  56. package/src/config/atomic-write.ts +219 -0
  57. package/src/config/paths.ts +40 -0
  58. package/src/config/process-state.ts +308 -0
  59. package/src/config/provider-validation.ts +177 -0
  60. package/src/config.ts +77 -812
  61. package/src/generated/compatibility-version.json +162 -94
  62. package/src/images/plan.ts +5 -4
  63. package/src/integrations/ownership-policy.ts +141 -0
  64. package/src/integrations/ownership.ts +10 -0
  65. package/src/integrations/state.ts +44 -5
  66. package/src/integrations/writer.ts +6 -0
  67. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  68. package/src/lib/bounded-body.ts +14 -2
  69. package/src/lib/process-control.ts +2 -1
  70. package/src/lib/state-store-registrations.ts +2 -0
  71. package/src/lib/tool-argument-integers.ts +56 -5
  72. package/src/oauth/health.ts +1 -1
  73. package/src/oauth/index.ts +3 -0
  74. package/src/providers/registry.ts +1 -1
  75. package/src/reasoning-effort.ts +19 -2
  76. package/src/responses/apply-patch-envelope.ts +63 -0
  77. package/src/responses/custom-tool-compat.ts +132 -38
  78. package/src/responses/parser.ts +3 -2
  79. package/src/responses/reasoning-replay-cache.ts +81 -3
  80. package/src/routing/compatibility/behavior.ts +3 -0
  81. package/src/server/auth-cors.ts +9 -7
  82. package/src/server/index.ts +102 -21
  83. package/src/server/local-management-read-client.ts +1 -1
  84. package/src/server/local-provider-reload-client.ts +1 -1
  85. package/src/server/management/agent-settings-routes.ts +1 -1
  86. package/src/server/management/config-routes.ts +4 -1
  87. package/src/server/management/context.ts +1 -1
  88. package/src/server/management/logs-usage-routes.ts +27 -6
  89. package/src/server/management/model-routes.ts +8 -4
  90. package/src/server/management/native-integration-routes.ts +2 -1
  91. package/src/server/management/provider-capability-config.ts +1 -1
  92. package/src/server/management/system-restart.ts +1 -1
  93. package/src/server/port-reclaim.ts +1 -1
  94. package/src/server/proxy-liveness.ts +2 -1
  95. package/src/server/request-log-conversation.ts +30 -0
  96. package/src/server/responses/codex-auth-error.ts +55 -0
  97. package/src/server/responses/combo-stream-preflight.ts +171 -0
  98. package/src/server/responses/compact.ts +6 -21
  99. package/src/server/responses/core.ts +242 -101
  100. package/src/server/responses/fetch-helpers.ts +2 -97
  101. package/src/server/responses-custom-tool-repair.ts +41 -5
  102. package/src/server/responses-undeclared-tool-guard.ts +156 -15
  103. package/src/service.ts +8 -4
  104. package/src/types/provider.ts +7 -0
  105. package/src/types/request.ts +12 -1
  106. package/src/types/tools.ts +87 -11
  107. package/src/types.ts +1 -1
  108. package/src/update/index.ts +5 -4
  109. package/src/update/job.ts +3 -1
  110. package/src/update/transactional-install.mjs +8 -1
  111. package/src/usage/log.ts +16 -8
  112. package/src/usage/summary.ts +201 -8
  113. package/src/vision/describe.ts +18 -13
  114. package/src/web-search/executor.ts +10 -3
  115. package/src/web-search/gemini-executor.ts +35 -13
  116. package/src/web-search/index.ts +85 -1
  117. package/gui/dist/assets/index-BAMgarF9.js +0 -102
  118. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
@@ -22,8 +22,10 @@ import {
22
22
  durableReplayDestinationIdentity,
23
23
  durableReplayCredentialIdentity,
24
24
  reasoningReplayKeyCredentialIdentity,
25
+ reasoningReplayOpaqueBlobRejectionMemoized,
25
26
  reasoningReplayOAuthCredentialIdentity,
26
27
  reasoningReplayServingIdentityChanged,
28
+ rememberReasoningReplayOpaqueBlobRejection,
27
29
  } from "../../responses/reasoning-replay-cache";
28
30
  import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
29
31
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
@@ -125,7 +127,7 @@ import {
125
127
  rotateCursorAccountOnAuth,
126
128
  } from "../../oauth/cursor-routing";
127
129
  import { getAccountCredential } from "../../oauth/store";
128
- import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
130
+ import { buildWebSearchTool, mediaBridgeWillRun, planWebSearch, resolveCcaInTurnGrounding, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
129
131
  import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
130
132
  import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
131
133
  import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue";
@@ -133,16 +135,12 @@ import {
133
135
  applyCodexAuthContextToProvider,
134
136
  codexPoolAffinityKey,
135
137
  CodexAccountCooldownError,
136
- codexMainProfileDrainingResponse,
137
- cooldownErrorResponse,
138
138
  CodexAuthContextError,
139
- CodexDirectAuthenticationError,
140
139
  CodexMainProfileDrainingError,
141
140
  CodexPoolAuthenticationError,
142
141
  CodexThreadAffinityExpiredError,
143
142
  headersForCodexAuthContext,
144
143
  materializeCodexUpstreamAuth,
145
- CodexMainSubstitutionUnavailableError,
146
144
  isCodexAuthContextUsable,
147
145
  resolveCodexAuthContext,
148
146
  codexProbeLeaseId,
@@ -245,6 +243,7 @@ import {
245
243
  import {
246
244
  conversationIdFromResponsesRequest,
247
245
  normalizeLogConversationId,
246
+ reasoningReplayConversationIdFromResponsesRequest,
248
247
  sessionIdHeaderFromRequest,
249
248
  } from "../request-log-conversation";
250
249
  import type { AttemptRecoveryKind } from "../../usage/log";
@@ -291,6 +290,7 @@ import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from ".
291
290
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
292
291
 
293
292
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
293
+ import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
294
294
  import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
295
295
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
296
296
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
@@ -326,8 +326,11 @@ import {
326
326
  type RoutedNamespaceToolAliases,
327
327
  } from "../../responses/namespace-tool-compat";
328
328
  import {
329
+ collectDeclaredNamelessClientCallTypes,
329
330
  collectDeclaredWireToolNames,
330
331
  createUndeclaredToolCallGuardBlockRewrite,
332
+ currentTurnWireToolCatalogBody,
333
+ hasExplicitWireToolCatalog,
331
334
  undeclaredToolCallMessage,
332
335
  undeclaredToolCallName,
333
336
  undeclaredToolCallNameInResponse,
@@ -339,6 +342,12 @@ import {
339
342
  emptyCompletionRetryEnabled,
340
343
  guardEmptyCompletionEventStream,
341
344
  } from "./empty-completion-guard";
345
+ import { preflightComboStreamResponse } from "./combo-stream-preflight";
346
+
347
+ // runTurn adapters own an event queue and perform their combo preflight before
348
+ // bridging. A second byte-stream reader would reinterpret that transport's
349
+ // already-committed event boundary and can replay custom adapter work.
350
+ const runTurnAdapterSseResponses = new WeakSet<Response>();
342
351
 
343
352
  /**
344
353
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -535,6 +544,9 @@ function bindRouteReasoningReplayScope(args: {
535
544
  if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
536
545
  parsed._stripReasoningEncryptedContent = true;
537
546
  }
547
+ if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
548
+ parsed._stripReasoningEncryptedContent = true;
549
+ }
538
550
  bindProviderContinuationForRoute(parsed, continuationOwner);
539
551
  }
540
552
 
@@ -648,6 +660,26 @@ async function opaqueBlobRejectionBodyForRecovery(
648
660
  }
649
661
  }
650
662
 
663
+ /**
664
+ * Materialize an upstream error body only when the bounded reader observed a complete,
665
+ * display-safe payload. Partial timeout and over-limit prefixes are attacker-controlled,
666
+ * so callers keep their existing status-only fallback instead.
667
+ */
668
+ export async function readDisplaySafeErrorText(
669
+ response: Response,
670
+ signal: AbortSignal,
671
+ fallback: string,
672
+ ): Promise<string> {
673
+ try {
674
+ const body = await readBoundedResponseBody(response, { signal });
675
+ return body.displaySafe ? body.text : fallback;
676
+ } catch {
677
+ // Preserve the former Response.text().catch(fallback) contract. Request-abort
678
+ // classification remains owned by the surrounding response pipeline.
679
+ return fallback;
680
+ }
681
+ }
682
+
651
683
  function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void {
652
684
  parsed._stripReasoningEncryptedContent = true;
653
685
  }
@@ -688,9 +720,20 @@ async function attemptOpaqueBlobRecovery(
688
720
  }
689
721
 
690
722
  args.guard.attempted = true;
723
+ const rejectedScope = args.parsed._reasoningReplayScope
724
+ ? {
725
+ clientThreadId: args.parsed._reasoningReplayScope.clientThreadId,
726
+ ...(args.parsed._reasoningReplayScope.current
727
+ ? { current: { ...args.parsed._reasoningReplayScope.current } }
728
+ : {}),
729
+ }
730
+ : undefined;
691
731
  prepareOpaqueBlobRecovery(args.parsed);
692
732
  try { void args.response.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
693
733
  const result = await rebuild("opaque-blob-rejection");
734
+ if (!("failed" in result) && result.ok) {
735
+ rememberReasoningReplayOpaqueBlobRejection(rejectedScope);
736
+ }
694
737
  return "failed" in result
695
738
  ? { kind: "failed", response: result.failed }
696
739
  : { kind: "recovered", response: result };
@@ -1517,44 +1560,20 @@ async function resolveResponsesCodexAuth(
1517
1560
  substituteMainCredential,
1518
1561
  };
1519
1562
  } catch (err) {
1520
- if (err instanceof CodexAccountCooldownError) {
1521
- return { ok: false, response: cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace) };
1522
- }
1523
- if (err instanceof CodexMainProfileDrainingError) {
1524
- return { ok: false, response: codexMainProfileDrainingResponse() };
1525
- }
1526
- if (err instanceof CodexThreadAffinityExpiredError) {
1527
- return {
1528
- ok: false,
1529
- response: formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"),
1530
- };
1531
- }
1532
1563
  if (err instanceof CodexAuthContextError) {
1533
1564
  const safeAccountLabel = route.codexAccountNamespace
1534
1565
  ? `${route.providerName}-${route.codexAccountNamespace}`
1535
1566
  : formatCodexProviderForLog(route.providerName, err.accountId, config);
1536
1567
  console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
1537
- return {
1538
- ok: false,
1539
- response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
1540
- };
1541
- }
1542
- if (err instanceof CodexPoolAuthenticationError) {
1543
- return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1544
- }
1545
- if (err instanceof CodexDirectAuthenticationError) {
1546
- return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1547
1568
  }
1548
1569
  if (err instanceof ForwardAdmissionCredentialError) {
1549
1570
  return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1550
1571
  }
1551
- if (err instanceof CodexMainSubstitutionUnavailableError) {
1552
- // Fail BEFORE any upstream I/O. The alternative is forwarding the admission secret.
1553
- return {
1554
- ok: false,
1555
- response: formatErrorResponse(401, "authentication_error", "No usable Codex main credential to serve this request"),
1556
- };
1557
- }
1572
+ const response = mapCodexAuthContextErrorToResponse(err, {
1573
+ accountSelector: route.codexAccountNamespace,
1574
+ now: Date.now(),
1575
+ });
1576
+ if (response) return { ok: false, response };
1558
1577
  throw err;
1559
1578
  }
1560
1579
  }
@@ -1932,6 +1951,31 @@ export async function handleComboResponses(
1932
1951
  return clientCancelledResponse();
1933
1952
  }
1934
1953
 
1954
+ if (response.ok && !runTurnAdapterSseResponses.has(response)) {
1955
+ const nativePassthrough = isNativePassthroughSseResponse(response);
1956
+ const eagerRelay = isEagerRelaySseResponse(response);
1957
+ let preflight;
1958
+ try {
1959
+ preflight = await preflightComboStreamResponse(response, childLog);
1960
+ } catch (error) {
1961
+ callbackGate.discard();
1962
+ if (options.abortSignal?.aborted) {
1963
+ retainCancelledAttempt();
1964
+ return clientCancelledResponse();
1965
+ }
1966
+ throw error;
1967
+ }
1968
+ if (preflight.kind === "failed") {
1969
+ callbackGate.discard();
1970
+ terminalRecorder?.("failed", preflight.response.status);
1971
+ response = preflight.response;
1972
+ } else {
1973
+ response = preflight.response;
1974
+ if (nativePassthrough) markNativePassthroughSseResponse(response);
1975
+ if (eagerRelay) markEagerRelaySseResponse(response);
1976
+ }
1977
+ }
1978
+
1935
1979
  if (response.ok) {
1936
1980
  if (!attempt) return response;
1937
1981
  sealRequestAttemptIdentity(
@@ -1989,7 +2033,7 @@ export async function handleComboResponses(
1989
2033
  );
1990
2034
  finishRequestAttempt(
1991
2035
  attempt,
1992
- response.status,
2036
+ failure.response.status,
1993
2037
  Date.now() - (started ?? Date.now()),
1994
2038
  failure.usage,
1995
2039
  );
@@ -2004,7 +2048,7 @@ export async function handleComboResponses(
2004
2048
  return lastFailure;
2005
2049
  }
2006
2050
  console.warn(
2007
- `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${started === undefined ? 0 : Date.now() - started}ms`,
2051
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${started === undefined ? 0 : Date.now() - started}ms`,
2008
2052
  );
2009
2053
  const nextPick = advanceComboAfterFailure(config, pick, {
2010
2054
  retryAfter: failure.retryAfter,
@@ -2220,7 +2264,6 @@ async function handleResponsesInner(
2220
2264
  if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate;
2221
2265
  if (inboundClientThreadId) {
2222
2266
  parsed._clientThreadId = inboundClientThreadId;
2223
- parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId };
2224
2267
  } else if (
2225
2268
  options.inboundWire === "anthropic"
2226
2269
  && options.promptCacheKeyIsSharedCohort !== true
@@ -2256,15 +2299,31 @@ async function handleResponsesInner(
2256
2299
  ...(force ? { force: true } : {}),
2257
2300
  ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}),
2258
2301
  });
2259
- // Prefer a pre-populated id (routed Claude) over Responses headers that may be
2260
- // absent or synthetically injected (session_id from prompt_cache_key).
2261
- if (!logCtx.conversationId) {
2262
- logCtx.conversationId = conversationIdFromResponsesRequest({
2302
+ const resolvedConversationId = conversationIdFromResponsesRequest({
2303
+ clientThreadId: parsed._clientThreadId,
2304
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2305
+ threadIdHeader: req.headers.get("thread-id"),
2306
+ cursorConversationId: parsed._cursorConversationId,
2307
+ });
2308
+ // _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses
2309
+ // a dedicated raw conversation namespace so mixed headers that carry the same identity still
2310
+ // match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations.
2311
+ // Keep an Anthropic prompt_cache_key scope already bound above (#1735/#1926).
2312
+ if (!parsed._reasoningReplayScope) {
2313
+ const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({
2263
2314
  clientThreadId: parsed._clientThreadId,
2264
- sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2265
2315
  threadIdHeader: req.headers.get("thread-id"),
2266
2316
  cursorConversationId: parsed._cursorConversationId,
2317
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2267
2318
  });
2319
+ if (reasoningReplayConversationId) {
2320
+ parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId };
2321
+ }
2322
+ }
2323
+ // Prefer a pre-populated id (routed Claude) over Responses headers that may be
2324
+ // absent or synthetically injected (session_id from prompt_cache_key).
2325
+ if (!logCtx.conversationId) {
2326
+ logCtx.conversationId = resolvedConversationId;
2268
2327
  }
2269
2328
  logCtx.requestedModel = parsed.modelId;
2270
2329
  logCtx.requestedEffort = parsed.options.reasoning;
@@ -2976,6 +3035,7 @@ async function handleResponsesInner(
2976
3035
  ? new Map<string, { namespace: string; name: string }>()
2977
3036
  : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
2978
3037
  const routedCustomToolNames = new Set<string>();
3038
+ const routedCustomToolRepairNames = new Set<string>();
2979
3039
  const routedToolSearchNames = new Set<string>();
2980
3040
  // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
2981
3041
  // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
@@ -2998,6 +3058,18 @@ async function handleResponsesInner(
2998
3058
  + `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
2999
3059
  );
3000
3060
  }
3061
+ // Preserve the caller's readable catalog boundary before provider-specific normalization can
3062
+ // remove an unsupported final entry (for example xAI cached-only web search).
3063
+ const replayedInputPrefixLength = parsed._replayPrefixLen ?? 0;
3064
+ const clientToolAuthorizationBody = currentTurnWireToolCatalogBody(
3065
+ parsed._rawBody,
3066
+ replayedInputPrefixLength,
3067
+ );
3068
+ const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody);
3069
+ const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody);
3070
+ const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
3071
+ clientToolAuthorizationBody,
3072
+ );
3001
3073
  let request: Awaited<ReturnType<typeof adapter.buildRequest>>;
3002
3074
  try {
3003
3075
  request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
@@ -3019,6 +3091,12 @@ async function handleResponsesInner(
3019
3091
  || toolBridgeMaps.toolNsMap.get(name)?.freeform === true
3020
3092
  ) routedCustomToolNames.add(name);
3021
3093
  }
3094
+ for (const name of request.routedCustomToolRepairNames ?? []) {
3095
+ if (
3096
+ toolBridgeMaps.freeformToolNames.has(name)
3097
+ || toolBridgeMaps.toolNsMap.get(name)?.freeform === true
3098
+ ) routedCustomToolRepairNames.add(name);
3099
+ }
3022
3100
  }
3023
3101
  for (const name of request.convertedRoutedToolSearchNames ?? []) {
3024
3102
  // The adapter already keeps this set empty when tool_choice forbids the private search.
@@ -3034,13 +3112,13 @@ async function handleResponsesInner(
3034
3112
  // call it cannot execute, and the turn showed a bare `aborted` with the file untouched.
3035
3113
  // Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a
3036
3114
  // routed provider, so it keeps passing through unguarded, as it does for the rewrites above.
3037
- // The guard needs a catalog to compare against, so it stands down when the request carries
3038
- // none. That is not the same claim as "no tools means no tool may be called": a passthrough
3039
- // request can legitimately omit `tools` entirely and still receive a tool call the client
3040
- // understands — `tests/github-copilot-stream-contract.test.ts` sends `{model, input, stream}`
3041
- // with no tools and Copilot answers with a `custom_tool_call` for `apply_patch`. Policing an
3042
- // empty catalog truncates that turn. An unreadable body lands here too, since it yields no
3043
- // names either.
3115
+ // The guard needs a catalog to compare against, so it stands down when the request omits one.
3116
+ // An explicit empty catalog is still authoritative: it declares that no client tools may be
3117
+ // called. A passthrough request can legitimately omit `tools` entirely and still receive a call
3118
+ // the client understands — `tests/github-copilot-stream-contract.test.ts` sends
3119
+ // `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
3120
+ // `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
3121
+ // too because the proxy cannot establish the caller's declared authorization boundary.
3044
3122
  const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
3045
3123
  try {
3046
3124
  const body = JSON.parse(bodyText) as unknown;
@@ -3051,16 +3129,46 @@ async function handleResponsesInner(
3051
3129
  return undefined;
3052
3130
  }
3053
3131
  };
3054
- let outboundRequestBody = parseOutboundRequestBody(request.body);
3055
- const declaredWireToolNames = collectDeclaredWireToolNames(outboundRequestBody);
3056
- // Union with the caller's own catalog, because the outbound body is not always a complete
3057
- // record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a
3058
- // request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets
3059
- // the client's name back. Widening only ever makes the guard fire less; a name declared in
3060
- // neither place — #1700's `apply_patch` — is still refused.
3061
- for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
3062
- const undeclaredToolGuardActive = declaredWireToolNames.size > 0
3063
- && route.provider.authMode !== "forward";
3132
+ let outboundRequestBody: Record<string, unknown> | undefined;
3133
+ const declaredWireToolNames = new Set<string>();
3134
+ const declaredNamelessClientCallTypes = new Set<string>();
3135
+ let undeclaredToolGuardActive = false;
3136
+ const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => {
3137
+ outboundRequestBody = parseOutboundRequestBody(builtRequest.body);
3138
+ declaredWireToolNames.clear();
3139
+ // With no replay prefix the full outbound body belongs to this turn and its normalized
3140
+ // aliases are authoritative. A continuation's outbound body still contains historical
3141
+ // catalogs (and may promote historical tool-search definitions), so it can never widen the
3142
+ // current caller snapshot captured above.
3143
+ if (replayedInputPrefixLength === 0) {
3144
+ for (const name of collectDeclaredWireToolNames(outboundRequestBody)) {
3145
+ declaredWireToolNames.add(name);
3146
+ }
3147
+ }
3148
+ for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name);
3149
+ declaredNamelessClientCallTypes.clear();
3150
+ if (replayedInputPrefixLength === 0) {
3151
+ for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) {
3152
+ declaredNamelessClientCallTypes.add(callType);
3153
+ }
3154
+ }
3155
+ for (const callType of clientDeclaredNamelessCallTypes) {
3156
+ declaredNamelessClientCallTypes.add(callType);
3157
+ }
3158
+ // On an ordinary request these maps capture caller-catalog identities that normalization may
3159
+ // replace on the outbound wire (for example a client image tool becoming hosted). On replay,
3160
+ // however, the parsed maps also contain historical catalog entries, so only the bounded
3161
+ // current-turn wire snapshot above may authorize a call.
3162
+ if (replayedInputPrefixLength === 0) {
3163
+ for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
3164
+ }
3165
+ undeclaredToolGuardActive = (
3166
+ declaredWireToolNames.size > 0
3167
+ || clientDeclaredNamelessCallTypes.size > 0
3168
+ || clientExplicitWireToolCatalog
3169
+ ) && route.provider.authMode !== "forward";
3170
+ };
3171
+ refreshUndeclaredToolGuard(request);
3064
3172
  // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the
3065
3173
  // untouched upstream stream, so it can still observe a `response.completed` the client never
3066
3174
  // received; checking the payload itself rather than a flag shared with the client relay keeps
@@ -3077,7 +3185,11 @@ async function handleResponsesInner(
3077
3185
  // provider) every name looks undeclared, and flipping this would stop recording continuation
3078
3186
  // state for exactly the passthrough traffic the guard deliberately stands down for.
3079
3187
  if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return;
3080
- if (undeclaredToolCallName(payload, declaredWireToolNames) !== undefined) {
3188
+ if (undeclaredToolCallName(
3189
+ payload,
3190
+ declaredWireToolNames,
3191
+ declaredNamelessClientCallTypes,
3192
+ ) !== undefined) {
3081
3193
  inspectionSawUndeclaredTool = true;
3082
3194
  }
3083
3195
  };
@@ -3086,7 +3198,11 @@ async function handleResponsesInner(
3086
3198
  if (inspectionSawUndeclaredTool) return;
3087
3199
  if (
3088
3200
  undeclaredToolGuardActive
3089
- && undeclaredToolCallNameInResponse(response, declaredWireToolNames) !== undefined
3201
+ && undeclaredToolCallNameInResponse(
3202
+ response,
3203
+ declaredWireToolNames,
3204
+ declaredNamelessClientCallTypes,
3205
+ ) !== undefined
3090
3206
  ) {
3091
3207
  return;
3092
3208
  }
@@ -3253,7 +3369,7 @@ async function handleResponsesInner(
3253
3369
  ? request.usageLog.inputTokens
3254
3370
  : undefined;
3255
3371
  if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate;
3256
- outboundRequestBody = parseOutboundRequestBody(request.body);
3372
+ refreshUndeclaredToolGuard(request);
3257
3373
  logCtx.providerAdapter = retryAdapter.name;
3258
3374
  sealRequestAttemptIdentity(
3259
3375
  logCtx.activeAttempt,
@@ -3363,6 +3479,7 @@ async function handleResponsesInner(
3363
3479
  const msg = err instanceof Error ? err.message : String(err);
3364
3480
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
3365
3481
  }
3482
+ refreshUndeclaredToolGuard(request);
3366
3483
  try {
3367
3484
  upstreamResponse = await fetchWithTransientRetry(
3368
3485
  recovery => {
@@ -3514,6 +3631,7 @@ async function handleResponsesInner(
3514
3631
  authCtx = retry.authCtx;
3515
3632
  request = retry.request;
3516
3633
  refreshRoutedNamespaceToolAliases(request);
3634
+ refreshUndeclaredToolGuard(request);
3517
3635
  upstreamResponse = retry.upstreamResponse;
3518
3636
  selectedForwardHeaders = retry.selectedForwardHeaders;
3519
3637
  // Keep subagent quota-failure health keyed to the account that actually served.
@@ -3552,13 +3670,21 @@ async function handleResponsesInner(
3552
3670
  const passthroughCt = headers.get("content-type")?.toLowerCase();
3553
3671
  const isEventStream = passthroughCt?.includes("text/event-stream")
3554
3672
  || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
3555
- const terminalRecorder = codexForwardTerminalOutcomeRecorder(
3673
+ const recordTerminalOutcome = codexForwardTerminalOutcomeRecorder(
3556
3674
  config,
3557
3675
  authCtx,
3558
3676
  route.provider,
3559
3677
  route.modelId,
3560
3678
  logCtx,
3561
3679
  );
3680
+ let terminalOutcomeRecorded = false;
3681
+ const terminalRecorder = recordTerminalOutcome
3682
+ ? (status: ResponsesTerminalStatus, httpStatusOverride?: number): void => {
3683
+ if (terminalOutcomeRecorded) return;
3684
+ terminalOutcomeRecorded = true;
3685
+ recordTerminalOutcome(status, httpStatusOverride);
3686
+ }
3687
+ : undefined;
3562
3688
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
3563
3689
  // Capture quota from upstream response for multi-account tracking
3564
3690
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
@@ -3635,19 +3761,13 @@ async function handleResponsesInner(
3635
3761
  options.onConsumedComboFailure?.(failure);
3636
3762
  return failure.response;
3637
3763
  }
3638
- // The plain passthrough error path has no bounded reader of its own: `.text()` attaches
3639
- // the reader only when it runs, so an abort landing between fetch resolution and that
3640
- // call orphans Bun's internal read rejection (src/lib/abort.ts).
3641
- const detachPassthroughErrorGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
3642
- try {
3643
- const errorText = await upstreamResponse.text().catch(() => "");
3644
- return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
3645
- statusText: upstreamResponse.statusText,
3646
- headers,
3647
- });
3648
- } finally {
3649
- detachPassthroughErrorGuard();
3650
- }
3764
+ // The bounded reader owns the original body, deadline, abort settlement, and lock.
3765
+ // Unsafe partial data falls back to #452's non-empty status-only JSON.
3766
+ const errorText = await readDisplaySafeErrorText(upstreamResponse, upstream.signal, "");
3767
+ return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
3768
+ statusText: upstreamResponse.statusText,
3769
+ headers,
3770
+ });
3651
3771
  }
3652
3772
 
3653
3773
  // Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
@@ -3710,8 +3830,12 @@ async function handleResponsesInner(
3710
3830
  payloadRewrites.length > 0
3711
3831
  ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites))
3712
3832
  : undefined,
3713
- routedCustomToolNames.size > 0
3714
- ? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget)
3833
+ routedCustomToolNames.size > 0 || routedCustomToolRepairNames.size > 0
3834
+ ? createRoutedCustomToolRestoreBlockRewrite(
3835
+ routedCustomToolNames,
3836
+ translatorBudget,
3837
+ routedCustomToolRepairNames,
3838
+ )
3715
3839
  : undefined,
3716
3840
  routedToolSearchNames.size > 0
3717
3841
  ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget)
@@ -3726,7 +3850,10 @@ async function handleResponsesInner(
3726
3850
  // Last: every rewrite above can still rename or reshape a call item, so the guard must
3727
3851
  // compare the names the client will actually receive against the declared catalog.
3728
3852
  undeclaredToolGuardActive
3729
- ? createUndeclaredToolCallGuardBlockRewrite(declaredWireToolNames)
3853
+ ? createUndeclaredToolCallGuardBlockRewrite(
3854
+ declaredWireToolNames,
3855
+ declaredNamelessClientCallTypes,
3856
+ )
3730
3857
  : undefined,
3731
3858
  ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
3732
3859
  const clientBlockRewrite = blockRewrites.length > 0
@@ -3916,6 +4043,7 @@ async function handleResponsesInner(
3916
4043
  const restored = restoreRoutedCustomCallsInJson(
3917
4044
  restoredNamespace,
3918
4045
  routedCustomToolNames,
4046
+ routedCustomToolRepairNames,
3919
4047
  );
3920
4048
  const restoredToolSearch = restoreRoutedToolSearchCallsInJson(
3921
4049
  restored,
@@ -3942,7 +4070,11 @@ async function handleResponsesInner(
3942
4070
  if (undeclaredToolGuardActive) {
3943
4071
  const undeclared = (() => {
3944
4072
  try {
3945
- return undeclaredToolCallNameInResponse(JSON.parse(clientJson), declaredWireToolNames);
4073
+ return undeclaredToolCallNameInResponse(
4074
+ JSON.parse(clientJson),
4075
+ declaredWireToolNames,
4076
+ declaredNamelessClientCallTypes,
4077
+ );
3946
4078
  } catch {
3947
4079
  return undefined;
3948
4080
  }
@@ -4061,13 +4193,23 @@ async function handleResponsesInner(
4061
4193
  // - non-runTurn: web-search wins over image when both eligible (documented priority)
4062
4194
  // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
4063
4195
  // can proceed for web-search-only turns
4064
- const wsPlan = !routedCompaction
4065
- ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
4066
- : undefined;
4067
4196
  const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
4068
4197
  const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
4069
- const canRunWebSearch = !!wsPlan && !adapter.runTurn;
4070
- if ((imgPlan || vidPlan) && canRunWebSearch) {
4198
+ const hasMediaPlan = !!(imgPlan || vidPlan);
4199
+ // A media plan is not enough to suppress Gemini 2.x grounding: both bridges inject tools only
4200
+ // on streaming turns. This is the potential-injection value used to resolve sidecar precedence.
4201
+ const mediaMayInject = mediaBridgeWillRun(hasMediaPlan, false, !!adapter.runTurn, parsed.stream);
4202
+ const wsPlan = !routedCompaction
4203
+ ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, mediaMayInject)
4204
+ : undefined;
4205
+ const webSearchWinsMedia = !!wsPlan && !adapter.runTurn;
4206
+ const mediaWillRun = mediaBridgeWillRun(hasMediaPlan, !!wsPlan, !!adapter.runTurn, parsed.stream);
4207
+ const ccaInTurnGrounding = !routedCompaction
4208
+ ? resolveCcaInTurnGrounding(config, parsed, false, route.provider, route.modelId, mediaWillRun)
4209
+ : undefined;
4210
+ if (ccaInTurnGrounding) parsed._ccaInTurnGrounding = ccaInTurnGrounding;
4211
+ const canRunWebSearch = webSearchWinsMedia && !ccaInTurnGrounding;
4212
+ if (hasMediaPlan && webSearchWinsMedia) {
4071
4213
  // Web search takes priority when both are active — the media bridge cannot run
4072
4214
  // alongside runWithWebSearch. Surface a runtime signal so the user knows their
4073
4215
  // configured video/image bridge was skipped for this turn, rather than silently
@@ -4075,7 +4217,7 @@ async function handleResponsesInner(
4075
4217
  if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
4076
4218
  if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
4077
4219
  }
4078
- if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
4220
+ if (mediaWillRun || (imgPlan && !parsed.stream && !webSearchWinsMedia)) {
4079
4221
  // The image bridge detects a hosted image_generation tool and requires streaming.
4080
4222
  // The video bridge activates from config and injects a tool — it also needs streaming
4081
4223
  // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
@@ -4448,9 +4590,11 @@ async function handleResponsesInner(
4448
4590
  );
4449
4591
  const bridgeTurnAc = new AbortController();
4450
4592
  const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, undefined, options.turnAdmissionLease);
4451
- return new Response(trackedSse, {
4593
+ const response = new Response(trackedSse, {
4452
4594
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
4453
4595
  });
4596
+ runTurnAdapterSseResponses.add(response);
4597
+ return response;
4454
4598
  }
4455
4599
 
4456
4600
  await runTurn();
@@ -5083,14 +5227,16 @@ async function handleResponsesInner(
5083
5227
  options.onConsumedComboFailure?.(failure);
5084
5228
  return failure.response;
5085
5229
  }
5086
- // The plain error path has no bounded reader of its own: `.text()` attaches the reader
5087
- // only when it runs, so an abort landing between fetch resolution and that call orphans
5088
- // Bun's internal read rejection — the uncatchable teardown `cancelBodyOnAbort` absorbs
5089
- // (src/lib/abort.ts). Found while investigating #1419; not a fix for the native trap.
5090
- const detachErrorBodyGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
5091
- const errorText = await upstreamResponse.text().catch(() => "unknown error");
5092
- detachErrorBodyGuard();
5093
- cleanupUpstreamAbort();
5230
+ let errorText: string;
5231
+ try {
5232
+ errorText = await readDisplaySafeErrorText(
5233
+ upstreamResponse,
5234
+ upstream.signal,
5235
+ "unknown error",
5236
+ );
5237
+ } finally {
5238
+ cleanupUpstreamAbort();
5239
+ }
5094
5240
  if (!isFixedCodexAccount(authCtx)) {
5095
5241
  recordSubagentQuotaFailureForThreadSpawn(
5096
5242
  req.headers,
@@ -5489,12 +5635,7 @@ async function handleResponsesInner(
5489
5635
  }
5490
5636
 
5491
5637
  if (!response.ok) {
5492
- // Same pre-read guard as the initial response's error branch: a non-2xx continuation body
5493
- // is still a Bun fetch body, and an abort landing before `.text()` attaches its reader
5494
- // orphans the internal rejection.
5495
- const detachContinuationErrorGuard = cancelBodyOnAbort(response.body, upstream.signal);
5496
- const errorText = await response.text().catch(() => "unknown error");
5497
- detachContinuationErrorGuard();
5638
+ const errorText = await readDisplaySafeErrorText(response, upstream.signal, "unknown error");
5498
5639
  yield {
5499
5640
  type: "error",
5500
5641
  status: response.status,