@bitkyc08/opencodex 2.27.0 → 2.28.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 (45) hide show
  1. package/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +59 -0
  5. package/src/adapters/base.ts +2 -0
  6. package/src/adapters/google-antigravity-replay.ts +16 -8
  7. package/src/adapters/google.ts +21 -4
  8. package/src/adapters/openai-chat.ts +151 -54
  9. package/src/adapters/openai-responses.ts +37 -0
  10. package/src/cli/index.ts +19 -6
  11. package/src/codex/account-usability.ts +3 -0
  12. package/src/codex/auth-api.ts +22 -5
  13. package/src/codex/auth-context.ts +55 -2
  14. package/src/codex/catalog/metadata.ts +17 -3
  15. package/src/codex/catalog/native-models.ts +22 -14
  16. package/src/codex/catalog/sync.ts +57 -11
  17. package/src/codex/convergence.ts +61 -13
  18. package/src/codex/model-entitlements.ts +353 -0
  19. package/src/codex/quota.ts +28 -3
  20. package/src/codex/routing.ts +14 -8
  21. package/src/generated/compatibility-version.json +51 -39
  22. package/src/lib/destination-policy.ts +47 -0
  23. package/src/lib/shadow-call.ts +15 -0
  24. package/src/oauth/index.ts +33 -5
  25. package/src/oauth/store.ts +11 -5
  26. package/src/providers/fastwire.ts +39 -8
  27. package/src/providers/quota.ts +9 -2
  28. package/src/providers/registry.ts +74 -5
  29. package/src/providers/service-tier.ts +16 -8
  30. package/src/responses/parser.ts +3 -9
  31. package/src/responses/tool-search-compat.ts +301 -0
  32. package/src/router.ts +7 -0
  33. package/src/routing/capability.ts +26 -9
  34. package/src/routing/compatibility/behavior.ts +41 -3
  35. package/src/server/chat-native.ts +11 -2
  36. package/src/server/index.ts +54 -8
  37. package/src/server/management/agent-settings-routes.ts +16 -2
  38. package/src/server/request-log.ts +31 -0
  39. package/src/server/responses/compact.ts +54 -7
  40. package/src/server/responses/core.ts +246 -39
  41. package/src/server/responses/responses-field-backfill.ts +88 -6
  42. package/src/server/responses/terminal-guard.ts +10 -0
  43. package/src/server/responses-tool-search-repair.ts +217 -0
  44. package/src/server/system-env.ts +74 -5
  45. package/src/usage/log.ts +4 -0
@@ -109,6 +109,12 @@ import {
109
109
  stripCodexRuntimeProviderFields,
110
110
  type CodexAuthContext,
111
111
  } from "../../codex/auth-context";
112
+ import {
113
+ entitledCodexAccountIdsForModel,
114
+ invalidateCodexModelEntitlementsForAccount,
115
+ resolveCodexModelEntitlements,
116
+ } from "../../codex/model-entitlements";
117
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
112
118
  import {
113
119
  computeQuotaCooldown,
114
120
  formatCodexProviderForLog,
@@ -267,6 +273,8 @@ import {
267
273
  } from "../sse-payload-rewrite";
268
274
  import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
269
275
  import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair";
276
+ import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat";
277
+ import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair";
270
278
  import {
271
279
  collectDeclaredWireToolNames,
272
280
  createUndeclaredToolCallGuardBlockRewrite,
@@ -307,7 +315,7 @@ export function sidecarOutcomeRecorder(
307
315
 
308
316
 
309
317
 
310
- import { isShadowSourceModel, shouldInterceptShadowCall } from "../../lib/shadow-call";
318
+ import { isShadowSourceModel, shadowSourceModelPrefix, shouldInterceptShadowCall } from "../../lib/shadow-call";
311
319
 
312
320
  export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
313
321
 
@@ -530,6 +538,39 @@ type CodexPoolAccountRetryResult =
530
538
  authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
531
539
  };
532
540
 
541
+ const CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS: ReadonlyMap<string, string> = new Map([
542
+ // The authenticated catalog currently advertises Daybreak Blue, while successful responses
543
+ // identify the serving model as gpt-5.6-sol. Sending the selector itself is shard-dependent:
544
+ // live traffic can receive the exact unsupported-model 400 repeatedly from the same entitled
545
+ // account. Keep Daybreak as the admission/catalog identity, but use the stable serving id on
546
+ // the credential-bearing wire after entitlement selection has completed.
547
+ ["gpt-daybreak-blue-latest", "gpt-5.6-sol"],
548
+ ]);
549
+
550
+ export function codexAccountGatedCanonicalWireModel(modelId: string): string | undefined {
551
+ const exact = CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS.get(modelId);
552
+ if (exact) return exact;
553
+ for (const [selector, wireModel] of CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS) {
554
+ if (slugsEquivalent(modelId, selector)) return wireModel;
555
+ }
556
+ return undefined;
557
+ }
558
+
559
+ function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult): void {
560
+ if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
561
+ const wireModel = codexAccountGatedCanonicalWireModel(route.modelId);
562
+ if (!wireModel) return;
563
+
564
+ parsed.modelId = wireModel;
565
+ if (!parsed._rawBody || typeof parsed._rawBody !== "object") return;
566
+ const raw = parsed._rawBody as Record<string, unknown>;
567
+ raw.model = wireModel;
568
+ // Daybreak's authenticated catalog does not advertise retention support, and the upstream
569
+ // rejects this optional Codex hint before model execution. Removing it preserves request
570
+ // semantics while avoiding an otherwise terminal pre-stream 400.
571
+ delete raw.prompt_cache_retention;
572
+ }
573
+
533
574
  /**
534
575
  * Workspace-denial evidence for a 403, read from the upstream body.
535
576
  *
@@ -580,22 +621,32 @@ async function retryCodexPoolOnAlternateAccount(
580
621
  req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
581
622
  outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
582
623
  } = args;
583
- // Defense in depth: exact account selectors must never reach alternate-account resolution,
584
- // even if a future caller forgets to guard this helper.
585
- if (firstAuthCtx.fixedAccount) return { kind: "no-alternate" };
586
624
  const inboundWire = options.inboundWire ?? "responses";
587
625
  let retryAuthCtx: CodexAuthContext | undefined;
626
+ if (outcomeStatus === 400 && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)) {
627
+ invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId);
628
+ const refreshed = await resolveCodexModelEntitlements(config);
629
+ if (entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(firstAuthCtx.accountId)) {
630
+ // The authenticated roster still grants this exact model. Retry on the same account:
631
+ // upstream shards can briefly disagree during a gated-model rollout, but a pre-stream 400
632
+ // proves no output was committed and keeps this replay bounded.
633
+ retryAuthCtx = firstAuthCtx;
634
+ }
635
+ }
636
+ // Exact account selectors may retry the same confirmed account above, but must never resolve
637
+ // an alternate. Quota failures and a refreshed entitlement miss remain terminal.
638
+ if (!retryAuthCtx && firstAuthCtx.fixedAccount) return { kind: "no-alternate" };
588
639
  try {
589
- retryAuthCtx = await resolveCodexAuthContext(
590
- req.headers,
591
- config,
592
- "pool",
593
- {
594
- excludeAccountId: firstAuthCtx.accountId,
595
- modelId: route.modelId,
596
- beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
597
- },
598
- );
640
+ retryAuthCtx ??= await resolveCodexAuthContext(
641
+ req.headers,
642
+ config,
643
+ "pool",
644
+ {
645
+ excludeAccountId: firstAuthCtx.accountId,
646
+ modelId: route.modelId,
647
+ beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
648
+ },
649
+ );
599
650
  } catch (error) {
600
651
  if (
601
652
  !(error instanceof CodexPoolAuthenticationError)
@@ -677,27 +728,50 @@ async function retryCodexPoolOnAlternateAccount(
677
728
  logCtx.accountLogLabel,
678
729
  );
679
730
 
680
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
731
+ const retrySameConfirmedAccount = outcomeStatus === 400
732
+ && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)
733
+ && retryAuthCtx.accountId === firstAuthCtx.accountId;
734
+ // Live Daybreak traffic has produced long runs of unsupported-model 400s from different
735
+ // upstream shards even while the authenticated roster continues to grant the model. Permit
736
+ // seven additional same-account sends (eight total including the original), re-checking the
737
+ // exact allow-listed body and fresh entitlement before every later send. Alternate-account and
738
+ // quota recovery retain their historical one-send bound.
739
+ const maxRetrySends = retrySameConfirmedAccount ? 7 : 1;
740
+ let retrySendCount = 0;
681
741
  let upstreamResponse: Response;
682
742
  try {
683
- upstreamResponse = await fetchWithHeaderTimeout(
684
- request.url,
685
- {
686
- method: request.method,
687
- headers: request.headers,
688
- body: request.body,
689
- },
690
- upstream.signal,
691
- connectMs,
692
- stream,
693
- providerFetch(route.provider, options.codexWsRuntimeIdentity, {
694
- providerName: route.providerName,
695
- modelId: route.modelId,
696
- }),
697
- // Credential-bearing forward send: never follow a redirect into a
698
- // dead-host rejection after the credential was seen (#914).
699
- route.provider.authMode === "forward",
700
- );
743
+ while (true) {
744
+ noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
745
+ upstreamResponse = await fetchWithHeaderTimeout(
746
+ request.url,
747
+ {
748
+ method: request.method,
749
+ headers: request.headers,
750
+ body: request.body,
751
+ },
752
+ upstream.signal,
753
+ connectMs,
754
+ stream,
755
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
756
+ providerName: route.providerName,
757
+ modelId: route.modelId,
758
+ }),
759
+ // Credential-bearing forward send: never follow a redirect into a
760
+ // dead-host rejection after the credential was seen (#914).
761
+ route.provider.authMode === "forward",
762
+ );
763
+ retrySendCount += 1;
764
+ if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break;
765
+ if (!await shouldRetryCodexPoolAccountModel400(
766
+ upstreamResponse,
767
+ route.modelId,
768
+ options.abortSignal,
769
+ )) break;
770
+ invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId);
771
+ const refreshed = await resolveCodexModelEntitlements(config);
772
+ if (!entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(retryAuthCtx.accountId)) break;
773
+ await upstreamResponse.body?.cancel().catch(() => undefined);
774
+ }
701
775
  } catch (error) {
702
776
  // Attribute the transport failure to the alternate account (already selected).
703
777
  return { kind: "transport", error, authCtx: retryAuthCtx };
@@ -1085,7 +1159,25 @@ async function resolveResponsesCodexAuth(
1085
1159
  // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct.
1086
1160
  // Admitting it is only safe because the stored main credential is substituted below, so
1087
1161
  // the admission secret still never leaves this process.
1088
- const substituteMainCredential = options.admission?.source === "bearer";
1162
+ //
1163
+ // #2132: substitution answers "does THIS ROUTE need our stored ChatGPT credential", not
1164
+ // "how did the caller authenticate". Only a native Codex route reaches the ChatGPT backend
1165
+ // and can consume that credential; a key-authenticated routed provider carries its own and
1166
+ // never touches it. Keying on the caller alone made an install that deliberately never
1167
+ // logged into ChatGPT fail every routed request with "No usable Codex main credential".
1168
+ //
1169
+ // But ask that question the way the ADAPTER asks it. `codexAccountMode` is derived from the
1170
+ // provider NAME (`providerCodexAccountMode`), while the passthrough adapter decides whether
1171
+ // to forward caller credentials from the TRANSPORT — adapter, auth mode, and base URL
1172
+ // (`isCanonicalOpenAiForwardProvider`). A row the operator named anything other than
1173
+ // `openai`, pointed at the canonical ChatGPT backend with `authMode: "forward"`, satisfies
1174
+ // the adapter's test and fails this one, so substitution was skipped and the adapter then
1175
+ // forwarded our own admission secret upstream. Two predicates answering one question is the
1176
+ // bug; the transport is the authority, because the transport is what actually carries the
1177
+ // header. A key-authenticated routed provider is still not canonical-forward, so #2132's
1178
+ // no-ChatGPT-login install keeps working.
1179
+ const substituteMainCredential = options.admission?.source === "bearer"
1180
+ && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider));
1089
1181
  if (route.codexAccountMode === "direct" && !substituteMainCredential) {
1090
1182
  validateForwardAdmissionCredential(req.headers, config);
1091
1183
  }
@@ -1094,6 +1186,7 @@ async function resolveResponsesCodexAuth(
1094
1186
  authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
1095
1187
  accountId: route.codexAccountId,
1096
1188
  modelId: route.modelId,
1189
+ substituteMainCredentialForDirect: substituteMainCredential,
1097
1190
  beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
1098
1191
  });
1099
1192
  options.onCodexAuthContextResolved?.(authCtx);
@@ -1838,7 +1931,14 @@ async function handleResponsesInner(
1838
1931
  if (parsed._rawBody && typeof parsed._rawBody === "object") {
1839
1932
  (parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
1840
1933
  }
1841
- (logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
1934
+ // Record the operator-configured prefix that matched, NOT the caller's raw model string.
1935
+ // Matching is by prefix, so a caller can append arbitrary text and still intercept; that
1936
+ // raw value would then land in usage.jsonl and /api/logs behind a pattern-based redactor
1937
+ // that does not recognize every credential family. The prefix is a value the operator
1938
+ // configured, so no caller-controlled string is persisted.
1939
+ logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
1940
+ shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels),
1941
+ );
1842
1942
  // Helpers must not resume/append into the parent thread's Cursor conversation.
1843
1943
  parsed._cursorIsolateConversation = true;
1844
1944
  }
@@ -2125,6 +2225,7 @@ async function handleResponsesInner(
2125
2225
  }
2126
2226
 
2127
2227
  route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
2228
+ applyCodexAccountGatedWireNormalization(parsed, route);
2128
2229
  logCtx.provider = route.codexAccountNamespace
2129
2230
  ? `${route.providerName}-${route.codexAccountNamespace}`
2130
2231
  : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
@@ -2399,6 +2500,7 @@ async function handleResponsesInner(
2399
2500
  ? new Map<string, { namespace: string; name: string }>()
2400
2501
  : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
2401
2502
  const routedCustomToolNames = new Set<string>();
2503
+ const routedToolSearchNames = new Set<string>();
2402
2504
  // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
2403
2505
  // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
2404
2506
  // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
@@ -2432,6 +2534,12 @@ async function handleResponsesInner(
2432
2534
  if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name);
2433
2535
  }
2434
2536
  }
2537
+ for (const name of request.convertedRoutedToolSearchNames ?? []) {
2538
+ // The adapter already keeps this set empty when tool_choice forbids the private search.
2539
+ // Its wire name may be collision-aliased, so comparing it to the caller-facing name here
2540
+ // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes.
2541
+ routedToolSearchNames.add(name);
2542
+ }
2435
2543
  // #1700: the bridged paths refuse a call to a tool the request never declared
2436
2544
  // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed
2437
2545
  // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested
@@ -2622,6 +2730,98 @@ async function handleResponsesInner(
2622
2730
  request.releaseBodyObservation?.();
2623
2731
  }
2624
2732
 
2733
+ // Native Responses providers return before the generic adapter recovery loop below. Keep
2734
+ // their OAuth contract identical: one pre-stream 401 forces a credential refresh and one
2735
+ // rebuilt replay. xAI's current subscription models use this branch now that their official
2736
+ // Grok CLI catalog declares the Responses backend.
2737
+ if (upstreamResponse.status === 401 && isOAuth401ReplayProvider && sentOAuthSnapshot) {
2738
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
2739
+ let refreshed: OAuthAccessSnapshot;
2740
+ try {
2741
+ refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
2742
+ } catch (err) {
2743
+ upstream.abort();
2744
+ releaseCodexAuthContextProbeLease(authCtx);
2745
+ return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
2746
+ }
2747
+ sentOAuthSnapshot = refreshed;
2748
+ replayOAuthCredentialSnapshot = {
2749
+ accountId: refreshed.accountId,
2750
+ generation: refreshed.generation,
2751
+ };
2752
+ if (route.providerName === "kiro") {
2753
+ parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
2754
+ }
2755
+ const refreshedProvider = resolveProviderTransport(
2756
+ route.providerName,
2757
+ { ...route.provider, apiKey: refreshed.accessToken },
2758
+ parsed.options.promptCacheKey,
2759
+ route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
2760
+ );
2761
+ route.provider = refreshedProvider;
2762
+ const refreshedAdapter = resolveAdapter(
2763
+ resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
2764
+ config.cacheRetention,
2765
+ );
2766
+ if (!("passthrough" in refreshedAdapter) || !refreshedAdapter.passthrough) {
2767
+ upstream.abort();
2768
+ return formatErrorResponse(502, "upstream_error", "OAuth refresh changed the provider wire unexpectedly");
2769
+ }
2770
+ bindRouteReasoningReplayScope({
2771
+ parsed,
2772
+ providerName: route.providerName,
2773
+ provider: refreshedProvider,
2774
+ adapterName: refreshedAdapter.name,
2775
+ oauthCredentialSnapshot: replayOAuthCredentialSnapshot,
2776
+ });
2777
+ logCtx.providerAdapter = refreshedAdapter.name;
2778
+ sealRequestAttemptIdentity(
2779
+ logCtx.activeAttempt,
2780
+ logCtx.provider,
2781
+ refreshedAdapter.name,
2782
+ logCtx.accountLogLabel,
2783
+ );
2784
+ try {
2785
+ request = await refreshedAdapter.buildRequest(parsed, {
2786
+ headers: selectedForwardHeaders,
2787
+ translatorBudget,
2788
+ });
2789
+ recordAdapterReasoning(logCtx, request);
2790
+ recordAdapterTier(logCtx, request);
2791
+ } catch (err) {
2792
+ upstream.abort();
2793
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
2794
+ const msg = err instanceof Error ? err.message : String(err);
2795
+ return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
2796
+ }
2797
+ try {
2798
+ upstreamResponse = await fetchWithTransientRetry(
2799
+ recovery => {
2800
+ noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401");
2801
+ return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
2802
+ method: request.method,
2803
+ headers: request.headers,
2804
+ body: request.body,
2805
+ }, recovery), upstream.signal, connectMs, parsed.stream,
2806
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
2807
+ providerName: route.providerName,
2808
+ modelId: route.modelId,
2809
+ }),
2810
+ route.provider.authMode === "forward")
2811
+ .then(res => {
2812
+ settleObservedHostResponse();
2813
+ return res;
2814
+ });
2815
+ },
2816
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
2817
+ );
2818
+ } catch (err) {
2819
+ return transportFailureResponse(err);
2820
+ } finally {
2821
+ request.releaseBodyObservation?.();
2822
+ }
2823
+ }
2824
+
2625
2825
  // Same-target 429 wait-and-retry (opt-in `retryOn429`) for key-auth providers on the
2626
2826
  // passthrough wire. This branch returns before the recovery loop below, so Responses-shaped
2627
2827
  // key-auth gateways (e.g. the built-in DeepSeek preset) would otherwise surface 429
@@ -2684,7 +2884,7 @@ async function handleResponsesInner(
2684
2884
  }
2685
2885
  }
2686
2886
 
2687
- if (usesCodexForwardPoolAuth(authCtx, route.provider) && !authCtx.fixedAccount) {
2887
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
2688
2888
  let poolRetryOutcome: number | undefined;
2689
2889
  if (await shouldRetryCodexPoolAccountModel400(
2690
2890
  upstreamResponse,
@@ -2692,7 +2892,7 @@ async function handleResponsesInner(
2692
2892
  options.abortSignal,
2693
2893
  )) {
2694
2894
  poolRetryOutcome = 400;
2695
- } else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
2895
+ } else if (!authCtx.fixedAccount && shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
2696
2896
  // Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
2697
2897
  poolRetryOutcome = upstreamResponse.status;
2698
2898
  }
@@ -2894,6 +3094,9 @@ async function handleResponsesInner(
2894
3094
  routedCustomToolNames.size > 0
2895
3095
  ? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget)
2896
3096
  : undefined,
3097
+ routedToolSearchNames.size > 0
3098
+ ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget)
3099
+ : undefined,
2897
3100
  githubCopilotRepairEnabled
2898
3101
  ? createGithubCopilotResponsesBlockRewrite(translatorBudget)
2899
3102
  : undefined,
@@ -3091,9 +3294,13 @@ async function handleResponsesInner(
3091
3294
  restoreImageGenCallsInJson(text, imageGenCallAliases),
3092
3295
  routedCustomToolNames,
3093
3296
  );
3297
+ const restoredToolSearch = restoreRoutedToolSearchCallsInJson(
3298
+ restored,
3299
+ routedToolSearchNames,
3300
+ );
3094
3301
  const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)
3095
- ? repairResponsesSnapshotJson(restored, outboundRequestBody)
3096
- : restored;
3302
+ ? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody)
3303
+ : restoredToolSearch;
3097
3304
  const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId
3098
3305
  ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId)
3099
3306
  : backfillResponsesFieldsJson(repaired);
@@ -29,6 +29,67 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
29
29
  return !!value && typeof value === "object" && !Array.isArray(value);
30
30
  }
31
31
 
32
+ /** Wire prefixes for Responses output item ids, matching OpenAI's id shapes. */
33
+ const ITEM_ID_PREFIXES: Readonly<Record<string, string>> = {
34
+ message: "msg_",
35
+ reasoning: "rs_",
36
+ function_call: "fc_",
37
+ custom_tool_call: "ctc_",
38
+ // A routed tool_search lowering is restored to `tool_search_call` without an id, so this
39
+ // backfill is what names it. The generic `item_` fallback is not merely cosmetic here:
40
+ // `stripInvalidItemIds` in the Responses adapter deletes any id whose prefix does not match
41
+ // the type, so an `item_`-named tool_search_call silently loses its id on the NEXT turn and
42
+ // the client sees an item it cannot correlate. The prefixes here must stay a superset of the
43
+ // ones that serializer enforces.
44
+ tool_search_call: "tsc_",
45
+ web_search_call: "ws_",
46
+ file_search_call: "fs_",
47
+ code_interpreter_call: "ci_",
48
+ computer_call: "cc_",
49
+ // The Responses wire type is `image_generation_call`; `image_gen_call` is kept only so a
50
+ // relay that emits the short spelling is not silently demoted to the generic `item_`.
51
+ image_generation_call: "ig_",
52
+ image_gen_call: "ig_",
53
+ };
54
+
55
+ /**
56
+ * Backfill a required id on an output item when absent. Strict Responses
57
+ * decoders (e.g. grok-build serde types) fail with "missing field id" when a
58
+ * message or reasoning item has no id, which some upstream relays omit. The
59
+ * generated id is deterministic per (type, output index) so it stays stable
60
+ * across streaming events that reference the same item.
61
+ */
62
+ function backfillItemId(item: Record<string, unknown>, slot: ItemIdSlot): Record<string, unknown> {
63
+ if (typeof item.id === "string" && item.id.length > 0) return item;
64
+ const type = typeof item.type === "string" ? item.type : "";
65
+ const prefix = Object.prototype.hasOwnProperty.call(ITEM_ID_PREFIXES, type) ? ITEM_ID_PREFIXES[type] : "item_";
66
+ return { ...item, id: prefix + "ocx_" + (slot.kind === "index" ? String(slot.index) : "fallback_" + slot.ordinal) };
67
+ }
68
+
69
+ /**
70
+ * Which namespace a synthesized id comes from.
71
+ *
72
+ * Keeping the fallback counter in the SAME numeric namespace as real output indexes only
73
+ * pushed the collision out of reach rather than removing it: a response whose real index
74
+ * happened to be 1_000_001 would produce the same id as the first malformed-index fallback,
75
+ * and a duplicate id is exactly what this backfill exists to prevent. The namespaces are now
76
+ * lexically disjoint, so no index value can ever collide with a fallback.
77
+ */
78
+ type ItemIdSlot = { kind: "index"; index: number } | { kind: "fallback"; ordinal: number };
79
+
80
+ /**
81
+ * Monotonic ordinal for an event whose `output_index` is absent or malformed.
82
+ *
83
+ * Process-global rather than per-response because this module is stateless by design and the
84
+ * value only has to be unique, not meaningful. It carries its own `fallback_` namespace, so
85
+ * uniqueness no longer depends on a real index never reaching some arbitrary ceiling.
86
+ */
87
+ let syntheticItemOrdinal = 0;
88
+ function nextSyntheticItemSlot(): ItemIdSlot {
89
+ syntheticItemOrdinal += 1;
90
+ return { kind: "fallback", ordinal: syntheticItemOrdinal };
91
+ }
92
+
32
93
  /**
33
94
  * Backfill annotations: [] on an output_text content part if missing.
34
95
  * Returns the same object reference if no change is needed.
@@ -58,16 +119,29 @@ function backfillContentArray(content: unknown): unknown {
58
119
  return changed ? repaired : content;
59
120
  }
60
121
 
122
+ /**
123
+ * Item types that are NOT Responses output items and must be returned byte-for-byte.
124
+ *
125
+ * `compaction` is the `/v1/responses/compact` wire format, not a Responses output item. It has
126
+ * no `id` in that contract, so synthesizing one changes a response body the client compares
127
+ * exactly. The backfill exists to satisfy strict Responses decoders; a shape those decoders
128
+ * never see is outside its remit.
129
+ */
130
+ const NON_RESPONSES_ITEM_TYPES: ReadonlySet<string> = new Set(["compaction"]);
131
+
61
132
  /**
62
133
  * Walk an output item and backfill output_text parts in its content.
134
+ * Also backfills a missing required id on the item itself.
63
135
  * Returns the same object reference if nothing changed.
64
136
  */
65
- function backfillOutputItem(item: unknown): unknown {
137
+ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
66
138
  if (!isPlainObject(item)) return item;
139
+ if (typeof item.type === "string" && NON_RESPONSES_ITEM_TYPES.has(item.type)) return item;
67
140
  const content = item.content;
68
141
  const repaired = backfillContentArray(content);
69
- if (repaired === content) return item;
70
- return { ...item, content: repaired };
142
+ const withId = backfillItemId(item, slot);
143
+ if (repaired === content && withId === item) return item;
144
+ return { ...withId, ...(repaired === content ? {} : { content: repaired }) };
71
145
  }
72
146
 
73
147
  /**
@@ -79,9 +153,9 @@ function backfillResponseOutput(response: unknown): unknown {
79
153
  const output = response.output;
80
154
  if (!Array.isArray(output)) return response;
81
155
  let changed = false;
82
- const repaired = output.map((item) => {
156
+ const repaired = output.map((item, idx) => {
83
157
  if (!isPlainObject(item)) return item;
84
- const next = backfillOutputItem(item);
158
+ const next = backfillOutputItem(item, { kind: "index", index: idx });
85
159
  if (next !== item) changed = true;
86
160
  return next;
87
161
  });
@@ -100,7 +174,15 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
100
174
  // output_item.added / output_item.done: item.content[] -> output_text parts
101
175
  if ((type === "response.output_item.added" || type === "response.output_item.done")
102
176
  && isPlainObject(event.item)) {
103
- const item = backfillOutputItem(event.item);
177
+ const rawIndex = event.output_index;
178
+ // A malformed or absent `output_index` must not collapse to 0: two such events would then
179
+ // both synthesize `msg_ocx_0`, and duplicate ids are the very thing this backfill exists to
180
+ // prevent. Fall back to a per-process counter so the synthesized id stays unique. Position
181
+ // is not recoverable in that case, but a unique id is what strict decoders require, and a
182
+ // well-formed stream still gets the stable index-derived id.
183
+ const item = typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0
184
+ ? backfillOutputItem(event.item, { kind: "index", index: rawIndex })
185
+ : backfillOutputItem(event.item, nextSyntheticItemSlot());
104
186
  if (item !== event.item) {
105
187
  next = { ...next, item };
106
188
  changed = true;
@@ -193,6 +193,16 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
193
193
  const seen: AdapterEvent[] = [];
194
194
  let terminalSeen = false;
195
195
  for await (const event of source) {
196
+ // A heartbeat is adapter liveness, not turn content: it exists so the bridge watchdog
197
+ // can tell a buffering adapter from a hung one. Retaining it here would put an
198
+ // unbounded number of empty markers into `seen`, which feeds both the continuation
199
+ // analysis and the rebuilt request — and the openai-chat adapter now emits one per
200
+ // tool-call delta, so a long argument payload alone could grow this array without
201
+ // limit. The empty-completion guard already passes them through unretained; match it.
202
+ if (event.type === "heartbeat") {
203
+ yield event;
204
+ continue;
205
+ }
196
206
  if (event.type === "done") {
197
207
  terminalSeen = true;
198
208
  const analysis = (options.adapterName === "anthropic" || options.adapterName === "openai-chat")