@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
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-7jlKgmJd.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-D2sP-biU.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DQsMZzI5.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.27.0",
3
+ "version": "2.28.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -594,6 +594,63 @@ function orphanToolResultText(msg: OcxToolResultMessage): string {
594
594
  return `[tool_result without adjacent tool_use: ${label}]\n${content}`;
595
595
  }
596
596
 
597
+ /**
598
+ * AgentRouter answers 400 `content-blocked` when the first user message is not in English
599
+ * (#2074), while the same request in English returns 200. The gateway is inspecting the opening
600
+ * user content, so an Anthropic `system` string cannot reach it — the framing has to sit in the
601
+ * first user turn.
602
+ */
603
+ const AGENTROUTER_LANGUAGE_PREAMBLE =
604
+ "[Instruction: Process the user request below and respond in the appropriate language.]";
605
+
606
+ /**
607
+ * Exact host match, not a substring.
608
+ *
609
+ * A `hostname.includes("agentrouter")` test also matches `notagentrouter.example` and
610
+ * `agentrouter.org.attacker.example`, which would let an unrelated destination silently
611
+ * receive an injected instruction block. A prompt mutation keyed on a provider's identity
612
+ * must be keyed on that identity exactly.
613
+ */
614
+ function isAgentRouterEndpoint(baseUrl: string): boolean {
615
+ try {
616
+ const { hostname } = new URL(baseUrl);
617
+ return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org");
618
+ } catch {
619
+ return false;
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Prepend the framing as its OWN text block instead of splicing it into the user's string.
625
+ *
626
+ * The distinction matters: rewriting `content` to `${marker}\n\n${original}` edits what the
627
+ * user wrote, and every downstream consumer — logs, retries, an upstream that echoes the turn —
628
+ * then sees a sentence the user never typed as if they had. A separate leading block carries the
629
+ * same signal to the filter while the original text survives byte-for-byte.
630
+ *
631
+ * Only the first user turn is framed, because only the first is what the gateway rejects.
632
+ */
633
+ function applyAgentRouterLanguageFraming(messages: unknown[]): void {
634
+ const firstUser = messages.find(
635
+ (m): m is { role: string; content: unknown } =>
636
+ typeof m === "object" && m !== null && (m as { role?: unknown }).role === "user",
637
+ );
638
+ if (!firstUser) return;
639
+ const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE };
640
+ if (typeof firstUser.content === "string") {
641
+ firstUser.content = firstUser.content === ""
642
+ ? [preamble]
643
+ : [preamble, { type: "text", text: firstUser.content }];
644
+ return;
645
+ }
646
+ if (!Array.isArray(firstUser.content)) return;
647
+ // Idempotence is keyed on the LEADING block being exactly the marker. A substring test would
648
+ // let a user who quotes the marker later in their own prompt suppress the framing entirely.
649
+ const [head] = firstUser.content as { type?: unknown; text?: unknown }[];
650
+ if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return;
651
+ (firstUser.content as unknown[]).unshift(preamble);
652
+ }
653
+
597
654
  function messagesToAnthropicFormat(
598
655
  parsed: OcxParsedRequest,
599
656
  toolNames: { toWire: (name: string) => string },
@@ -833,6 +890,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
833
890
  }
834
891
 
835
892
  const { system, messages } = messagesToAnthropicFormat(parsed, toolNames);
893
+ // Before image normalization, so the framing block is present for every downstream pass.
894
+ if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages);
836
895
  // Primary image layer: resize/re-encode to fit Anthropic limits without dropping
837
896
  // (anthropic-image-normalize.ts); the guard below remains the deterministic backstop.
838
897
  // imageTierBias > 0 = upstream-413 tightened retry (030): start every image one tier lower.
@@ -70,6 +70,8 @@ export interface AdapterRequest {
70
70
  body: string;
71
71
  /** Custom-tool names actually lowered to upstream function calls while building this request. */
72
72
  convertedRoutedCustomToolNames?: ReadonlySet<string>;
73
+ /** Client tool-search names actually lowered to upstream function calls for this request. */
74
+ convertedRoutedToolSearchNames?: ReadonlySet<string>;
73
75
  /** Releases observation of a serialized request body after its final fetch attempt settles. */
74
76
  releaseBodyObservation?: () => void;
75
77
  /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */
@@ -599,12 +599,20 @@ export function antigravityUsesReplayCache(model: string): boolean {
599
599
  * Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
600
600
  * the functionCall identity (name + args). Accumulates across the whole session so a sequential
601
601
  * multi-step tool loop keeps EVERY prior call's signature, not just the latest part-index slot.
602
- * A signature on a standalone thought part is paired with the next functionCall in the same
603
- * array (#897); a call's own signature takes precedence and an unpaired one is dropped.
602
+ * A signature on a standalone thought part applies to the functionCall parts that follow it in
603
+ * the same array AND to later arrays of the same turn: streaming splits a thought part and its
604
+ * calls across SSE chunks, so `carriedThoughtSig` threads the still-unpaired signature from the
605
+ * previous chunk and the return value hands the remainder to the next one (#897, #2125). A call's
606
+ * own signature always takes precedence over a carried one.
604
607
  * `parts` is the already-unwrapped `response.candidates[0].content.parts`.
605
608
  */
606
- export function observeAntigravityReplay(model: string, sessionId: string, parts: unknown[]): void {
607
- if (!antigravityUsesReplayCache(model) || !Array.isArray(parts) || parts.length === 0) return;
609
+ export function observeAntigravityReplay(
610
+ model: string,
611
+ sessionId: string,
612
+ parts: unknown[],
613
+ carriedThoughtSig?: string,
614
+ ): string | undefined {
615
+ if (!antigravityUsesReplayCache(model) || !Array.isArray(parts) || parts.length === 0) return carriedThoughtSig;
608
616
  ensureReplaySnapshotLoaded();
609
617
  const now = Date.now();
610
618
  deleteExpiredReplaySessionsThrottled(now);
@@ -618,7 +626,7 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
618
626
  lastActiveAtMs: 0,
619
627
  };
620
628
  let inserted = false;
621
- let pendingThoughtSig: string | undefined;
629
+ let pendingThoughtSig: string | undefined = carriedThoughtSig;
622
630
  for (const raw of parts) {
623
631
  if (!raw || typeof raw !== "object") continue;
624
632
  const part = raw as Record<string, unknown>;
@@ -632,7 +640,6 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
632
640
  continue;
633
641
  }
634
642
  const callSig = sig ?? pendingThoughtSig; // a signature on the call part itself wins
635
- pendingThoughtSig = undefined;
636
643
  if (!callSig) continue;
637
644
  const ck = functionCallKey(fc.name, fc.args);
638
645
  if (!ck) continue; // only function-call signatures are replayable by identity
@@ -645,7 +652,7 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
645
652
  replayBytes += sizeBytes;
646
653
  inserted = true;
647
654
  }
648
- if (!inserted) return;
655
+ if (!inserted) return pendingThoughtSig;
649
656
  // Charge the fixed outer key only when the session is actually stored.
650
657
  if (!existing) replayBytes += REPLAY_SESSION_KEY_BYTES;
651
658
  evictInnerCalls(entry);
@@ -659,7 +666,7 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
659
666
  } else {
660
667
  replayBytes -= REPLAY_SESSION_KEY_BYTES;
661
668
  }
662
- return;
669
+ return pendingThoughtSig;
663
670
  }
664
671
  entry.expiresAtMs = now + REPLAY_TTL_MS;
665
672
  entry.lastActiveAtMs = now;
@@ -668,6 +675,7 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
668
675
  evictIfNeeded();
669
676
  enforceAppOwnedMemoryBudget();
670
677
  markReplayDirty();
678
+ return pendingThoughtSig;
671
679
  }
672
680
 
673
681
  /**
@@ -342,6 +342,7 @@ interface GoogleResponsePart {
342
342
  text?: string;
343
343
  thought?: boolean;
344
344
  thoughtSignature?: string;
345
+ thought_signature?: string;
345
346
  functionCall?: { name: string; args: unknown };
346
347
  }
347
348
 
@@ -352,8 +353,9 @@ interface GoogleResponsePart {
352
353
  */
353
354
  function googleToolCallMetadataFromPart(
354
355
  part: GoogleResponsePart,
356
+ fallbackSignature?: string,
355
357
  ): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined {
356
- const signature = part.thoughtSignature;
358
+ const signature = part.thoughtSignature ?? part.thought_signature ?? fallbackSignature;
357
359
  if (!isLikelyRealThoughtSignature(signature)) return undefined;
358
360
  return { providerMetadata: { google: { thoughtSignature: signature } } };
359
361
  }
@@ -601,6 +603,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
601
603
  let lastFinishReason: string | undefined;
602
604
  let sawAnyFrame = false;
603
605
  let sawTerminalSignal = false;
606
+ let pendingStreamThoughtSig: string | undefined;
604
607
 
605
608
  const handleDataLine = async function* (line: string): AsyncGenerator<AdapterEvent, "continue" | "content" | "terminate"> {
606
609
  const payload = line.slice(5).trim();
@@ -697,10 +700,19 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
697
700
  const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
698
701
  if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
699
702
  && parts && replayModel && replaySession) {
700
- observeAntigravityReplay(replayModel, replaySession, parts as unknown[]);
703
+ pendingStreamThoughtSig = observeAntigravityReplay(
704
+ replayModel,
705
+ replaySession,
706
+ parts as unknown[],
707
+ pendingStreamThoughtSig,
708
+ );
701
709
  }
702
710
  if (parts) {
703
711
  for (const part of parts) {
712
+ const sig = part.thoughtSignature ?? part.thought_signature;
713
+ if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
714
+ pendingStreamThoughtSig = sig;
715
+ }
704
716
  const textEvent = googlePartTextEvent(part);
705
717
  if (textEvent) {
706
718
  emittedContentEvent = true;
@@ -729,7 +741,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
729
741
  type: "tool_call_start",
730
742
  id,
731
743
  name: restoreGoogleToolName(part.functionCall.name),
732
- ...googleToolCallMetadataFromPart(part),
744
+ ...googleToolCallMetadataFromPart(part, pendingStreamThoughtSig),
733
745
  };
734
746
  yield { type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) };
735
747
  yield { type: "tool_call_end" };
@@ -926,7 +938,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
926
938
  && replayModel && replaySession) {
927
939
  observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]);
928
940
  }
941
+ let pendingThoughtSig: string | undefined;
929
942
  for (const part of candidates[0].content.parts) {
943
+ const sig = part.thoughtSignature ?? part.thought_signature;
944
+ if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
945
+ pendingThoughtSig = sig;
946
+ }
930
947
  const textEvent = googlePartTextEvent(part);
931
948
  if (textEvent) events.push(textEvent);
932
949
  const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData;
@@ -950,7 +967,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
950
967
  type: "tool_call_start",
951
968
  id,
952
969
  name: restoreGoogleToolName(part.functionCall.name),
953
- ...googleToolCallMetadataFromPart(part),
970
+ ...googleToolCallMetadataFromPart(part, pendingThoughtSig),
954
971
  });
955
972
  events.push({ type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) });
956
973
  events.push({ type: "tool_call_end" });
@@ -14,12 +14,15 @@ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalo
14
14
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
15
15
  import {
16
16
  canForwardForeignServiceTierForChatModel,
17
+ fastPolicyForModel,
17
18
  supportsServiceTierForModel,
18
19
  } from "../providers/service-tier";
19
20
  import {
20
21
  canonicalFastTierMarker,
21
22
  createAdapterTierMetadata,
23
+ decideTier,
22
24
  type AdapterTierMetadata,
25
+ type ResolvedFastPolicy,
23
26
  } from "../providers/fastwire";
24
27
  import { openaiChatCompletionsUrl } from "./openai-chat-url";
25
28
  import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
@@ -96,6 +99,8 @@ export function buildOpenAIChatPassthroughRequest(
96
99
  rawBody: Record<string, unknown>,
97
100
  modelId: string,
98
101
  stream: boolean,
102
+ fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"),
103
+ fastMode?: boolean,
99
104
  ): AdapterRequest {
100
105
  const { url, headers, hasCredential } = openAIChatTransport(provider);
101
106
 
@@ -123,7 +128,16 @@ export function buildOpenAIChatPassthroughRequest(
123
128
  // `<listed>:<tag>` siblings the operator never opted out, silently returning prose.
124
129
  if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;
125
130
 
126
- if (provider.chatServiceTier && rawBody.service_tier !== undefined) {
131
+ // Run the same complete Fast policy as the translated Chat path, including explicit
132
+ // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still
133
+ // retains the caller's exact spelling; forced Fast uses the policy-owned wire value.
134
+ const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined;
135
+ const tierDecision = decideTier(fastPolicy, fastMode, callerTier);
136
+ if (tierDecision.kind === "set") {
137
+ body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined
138
+ ? callerTier
139
+ : tierDecision.value;
140
+ } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) {
127
141
  body.service_tier = rawBody.service_tier;
128
142
  }
129
143
  if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
@@ -308,8 +322,13 @@ function invalidToolCallsEvent(
308
322
  rawToolCalls: unknown,
309
323
  mode: "stream" | "response",
310
324
  usage?: OcxUsage,
325
+ diagnosticOverride?: InvalidToolCallDiagnostic,
311
326
  ): Extract<AdapterEvent, { type: "error" }> {
312
- const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
327
+ // The streamed accumulator knows things a rescan cannot: which field on which pending call
328
+ // was actually rejected. Without the override, a stream carrying accepted padding on call 0
329
+ // and a real defect on call 1 blames call 0, because the stateless scan stops at the first
330
+ // structurally odd value it sees.
331
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
313
332
  const detail = diagnostic
314
333
  ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
315
334
  : "";
@@ -527,9 +546,13 @@ function diagnoseInvalidToolCalls(
527
546
  return undefined;
528
547
  }
529
548
 
530
- function logInvalidToolCalls(mode: "stream" | "response", rawToolCalls: unknown): void {
549
+ function logInvalidToolCalls(
550
+ mode: "stream" | "response",
551
+ rawToolCalls: unknown,
552
+ diagnosticOverride?: InvalidToolCallDiagnostic,
553
+ ): void {
531
554
  if (!isDebugEnabled()) return;
532
- const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
555
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
533
556
  if (!diagnostic) return;
534
557
  const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic));
535
558
  debugProviderDiagnostic("openai-chat", "invalid-tool-calls", {
@@ -1290,6 +1313,26 @@ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: stri
1290
1313
  return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction));
1291
1314
  }
1292
1315
 
1316
+ function canSerializeOpenAIChatServiceTier(
1317
+ provider: OcxProviderConfig,
1318
+ modelId: string,
1319
+ serviceTier: unknown,
1320
+ tierDecision?: OcxParsedRequest["options"]["tierDecision"],
1321
+ ): boolean {
1322
+ if (serviceTier === undefined) return false;
1323
+ if (tierDecision !== undefined) {
1324
+ return tierDecision.kind === "set" || tierDecision.kind === "forward-caller";
1325
+ }
1326
+ // No decision from the router means this call did not go through the tier state machine, so
1327
+ // ask that machine rather than re-deriving a looser answer beside it. The previous fallback
1328
+ // returned true whenever foreign forwarding was allowed at all, which let a caller tier
1329
+ // reach the wire in cases `decideTier` would have dropped — the two paths disagreeing is
1330
+ // precisely the bug, so there is now only one authority.
1331
+ const callerTier = typeof serviceTier === "string" ? serviceTier : undefined;
1332
+ const decision = decideTier(fastPolicyForModel(provider, modelId, undefined, "chat"), undefined, callerTier);
1333
+ return decision.kind === "set" || decision.kind === "forward-caller";
1334
+ }
1335
+
1293
1336
  export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
1294
1337
  return {
1295
1338
  name: "openai-chat",
@@ -1312,13 +1355,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1312
1355
  // unclassified Chat routes remain behind the caller-forwarding opt-in.
1313
1356
  const serviceTier = parsed.options.serviceTier;
1314
1357
  const tierDecision = parsed.options.tierDecision;
1315
- const callerCanonicalFast = canonicalFastTierMarker(serviceTier) !== undefined;
1316
- const callerTierForwardAllowed = canForwardForeignServiceTierForChatModel(provider, parsed.modelId);
1317
- const canonicalFastCapability = callerCanonicalFast
1318
- && supportsServiceTierForModel(provider, parsed.modelId) === true;
1319
- const canSerializeServiceTier = tierDecision?.kind === "set"
1320
- || tierDecision?.kind === "forward-caller"
1321
- || (tierDecision === undefined && (callerTierForwardAllowed || canonicalFastCapability));
1358
+ const canSerializeServiceTier = canSerializeOpenAIChatServiceTier(
1359
+ provider,
1360
+ parsed.modelId,
1361
+ serviceTier,
1362
+ tierDecision,
1363
+ );
1322
1364
  if (canSerializeServiceTier && serviceTier !== undefined) {
1323
1365
  body.service_tier = serviceTier;
1324
1366
  }
@@ -1499,7 +1541,20 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1499
1541
  const budgetEncoder = new TextEncoder();
1500
1542
  let buffer = "";
1501
1543
  let bufferBytes = 0;
1502
- interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
1544
+ interface PendingToolCall {
1545
+ key: string;
1546
+ id: string;
1547
+ name: string;
1548
+ args: string;
1549
+ argsBytes: number;
1550
+ /**
1551
+ * Whether this call has ever received `arguments` as an actual string, empty included.
1552
+ * An empty string still counts: it proves the upstream sent the field with the right
1553
+ * wire type, which is what a later malformed repeat of that field would be padding for.
1554
+ * A canonical NAME is not evidence about the ARGUMENTS field and must not stand in.
1555
+ */
1556
+ sawArgumentsString: boolean;
1557
+ }
1503
1558
  const pendingToolCalls: PendingToolCall[] = [];
1504
1559
  let toolCallSeq = 0;
1505
1560
  const closeToolCalls = (): PendingToolCall[] => {
@@ -1613,61 +1668,103 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1613
1668
  logInvalidToolCalls("stream", rawToolCalls);
1614
1669
  return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1615
1670
  }
1616
- for (const rawToolCall of rawToolCalls) {
1671
+ for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
1672
+ const rawToolCall: unknown = rawToolCalls[callIndex];
1617
1673
  if (!isRecord(rawToolCall)) {
1618
- logInvalidToolCalls("stream", rawToolCalls);
1619
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1620
- }
1621
- const tc = rawToolCall as {
1622
- index?: number;
1623
- id?: string;
1624
- function?: { name?: string; arguments?: string };
1625
- };
1626
- // That cast is a TypeScript convenience, not a runtime guarantee: this is
1627
- // upstream JSON. Validate the fields before they are stored, so a non-string
1628
- // name or arguments value fails closed through the #1325 channel here rather
1629
- // than escaping later as a TypeError from string handling at flush time.
1630
- const rawFunction = (rawToolCall as { function?: unknown }).function;
1631
- if (rawFunction !== undefined && rawFunction !== null) {
1632
- if (!isRecord(rawFunction)) {
1633
- logInvalidToolCalls("stream", rawToolCalls);
1634
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1635
- }
1636
- const rawName = rawFunction.name;
1637
- const rawArguments = rawFunction.arguments;
1638
- // Some OpenAI-compatible streamers repeat already-sent fields as null on
1639
- // continuation deltas. Treat only null/undefined as absent; every other
1640
- // non-string value still fails closed before entering the accumulator.
1641
- if (isInvalidStreamStringField(rawName) || isInvalidStreamStringField(rawArguments)) {
1642
- logInvalidToolCalls("stream", rawToolCalls);
1643
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1644
- }
1674
+ const diagnostic: InvalidToolCallDiagnostic = {
1675
+ reason: "tool_call_not_object",
1676
+ callIndex,
1677
+ valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
1678
+ };
1679
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1680
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1645
1681
  }
1646
- if (isInvalidStreamStringField(tc.id)) {
1647
- logInvalidToolCalls("stream", rawToolCalls);
1648
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1682
+ // This is upstream JSON, so every field is validated before it is stored: a
1683
+ // malformed value must fail closed through the #1325 channel here rather than
1684
+ // escaping later as a TypeError from string handling at flush time.
1685
+ const rawFunction = rawToolCall.function;
1686
+ if (rawFunction !== undefined && rawFunction !== null && !isRecord(rawFunction)) {
1687
+ const diagnostic: InvalidToolCallDiagnostic = {
1688
+ reason: "tool_call_function_not_object",
1689
+ callIndex,
1690
+ valueType: Array.isArray(rawFunction) ? "array" : typeof rawFunction,
1691
+ };
1692
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1693
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1649
1694
  }
1650
- const key = typeof tc.index === "number"
1651
- ? `i:${tc.index}`
1652
- : tc.id
1653
- ? `id:${tc.id}`
1695
+ const fnRecord = isRecord(rawFunction) ? rawFunction : undefined;
1696
+ const rawName = fnRecord?.name;
1697
+ const rawArguments = fnRecord?.arguments;
1698
+ const rawId = rawToolCall.id;
1699
+ const idDelta = typeof rawId === "string" ? rawId : "";
1700
+ const rawIndex = rawToolCall.index;
1701
+
1702
+ // Resolve the pending call BEFORE judging the fields. Some OpenAI-compatible
1703
+ // streamers repeat an already-sent field as a non-string placeholder on a
1704
+ // continuation delta; judging first meant the whole stream died with a 502 even
1705
+ // though the value being repeated was already held in canonical form.
1706
+ const key = typeof rawIndex === "number"
1707
+ ? `i:${rawIndex}`
1708
+ : idDelta
1709
+ ? `id:${idDelta}`
1654
1710
  : pendingToolCalls[pendingToolCalls.length - 1]?.key;
1655
1711
  let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
1656
- if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
1712
+ if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta);
1657
1713
  if (!call) {
1658
- call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
1714
+ call = {
1715
+ key: key ?? `seq:${pendingToolCalls.length}`,
1716
+ id: "",
1717
+ name: "",
1718
+ args: "",
1719
+ argsBytes: 0,
1720
+ sawArgumentsString: false,
1721
+ };
1659
1722
  pendingToolCalls.push(call);
1660
1723
  budget.openCall(call.key);
1661
1724
  }
1662
- if (tc.id && !call.id) call.id = tc.id;
1663
- if (tc.function?.name && !call.name) call.name = tc.function.name;
1664
- if (tc.function?.arguments) {
1725
+
1726
+ // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name
1727
+ // says nothing about whether `arguments` was ever sent as a string, so it cannot
1728
+ // authorize a malformed arguments value — that would silently drop a real
1729
+ // argument payload the model intended to send.
1730
+ const rejection: InvalidToolCallDiagnostic | undefined =
1731
+ isInvalidStreamStringField(rawName) && call.name.trim() === ""
1732
+ ? { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawName }
1733
+ : isInvalidStreamStringField(rawArguments) && !call.sawArgumentsString
1734
+ ? { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawArguments }
1735
+ : isInvalidStreamStringField(rawId) && call.id === ""
1736
+ ? { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawId }
1737
+ : undefined;
1738
+ if (rejection) {
1739
+ logInvalidToolCalls("stream", rawToolCalls, rejection);
1740
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, rejection));
1741
+ }
1742
+
1743
+ if (idDelta && !call.id) call.id = idDelta;
1744
+ if (typeof rawName === "string" && rawName && !call.name) call.name = rawName;
1745
+ if (typeof rawArguments === "string") call.sawArgumentsString = true;
1746
+ // Tool-call deltas are BUFFERED until a terminal signal, so this adapter can
1747
+ // consume upstream frames for a long time while yielding nothing. The Responses
1748
+ // bridge reads adapter activity, not socket activity, so a model that streams a
1749
+ // large argument payload looks identical to a hung upstream and the stall
1750
+ // watchdog can abort a turn that was progressing normally.
1751
+ //
1752
+ // Found while investigating #2156, but it is NOT that bug: a stall abort emits
1753
+ // `response.incomplete` with `upstream_stall_timeout` from the bridge, whereas
1754
+ // that report shows the adapter's own end-of-stream error after `reader.read()`
1755
+ // returned EOF with tool calls still pending. Different path, different frame.
1756
+ //
1757
+ // A heartbeat is invisible downstream — the bridge consumes it to re-arm the
1758
+ // watchdog and emits nothing — which is the same remedy the Cursor, Anthropic,
1759
+ // Google, and Kiro adapters already use for their own silent phases.
1760
+ yield { type: "heartbeat" };
1761
+ if (typeof rawArguments === "string" && rawArguments) {
1665
1762
  const previousBytes = call.argsBytes;
1666
- const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
1763
+ const nextBytes = previousBytes + budgetEncoder.encode(rawArguments).byteLength;
1667
1764
  const scope = { kind: "tool_args" as const, callId: call.key };
1668
1765
  const reservation = budget.reserveTransient(nextBytes, scope);
1669
1766
  try {
1670
- call.args += tc.function.arguments;
1767
+ call.args += rawArguments;
1671
1768
  reservation.commitRetained();
1672
1769
  budget.releaseRetained(previousBytes, scope);
1673
1770
  call.argsBytes = nextBytes;
@@ -11,6 +11,7 @@ import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
11
11
  import { modelRecordValue } from "../reasoning-effort";
12
12
  import type { TranslatorBudget } from "../lib/translator-budget";
13
13
  import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
14
+ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
14
15
  import { openaiResponsesUrl } from "./openai-responses-url";
15
16
  import {
16
17
  createAdapterTierMetadata,
@@ -185,6 +186,28 @@ function stripUnsupportedReasoningParams(body: unknown): unknown {
185
186
  return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined };
186
187
  }
187
188
 
189
+ /**
190
+ * GPT-5.6 replaced the legacy 24-hour retention field with `prompt_cache_options.ttl`, and the
191
+ * ChatGPT backend 400s the whole request when the retired field is present (issue #2092).
192
+ *
193
+ * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract,
194
+ * and implicit caching still applies when the caller sent no replacement options. Inventing a
195
+ * value here would silently change a caching decision the caller never made.
196
+ *
197
+ * Deliberately narrow on both axes, because a wider strip is a behavior change rather than a fix:
198
+ * only the gpt-5.6 family (an older model may still honor the field), and only on the canonical
199
+ * ChatGPT backend, which is the deployment that rejects it. Matching is exact-or-dashed-prefix so
200
+ * a future `gpt-5.60` is not swept up by a bare `startsWith`.
201
+ */
202
+ function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): unknown {
203
+ if (!isPlainObject(body)) return body;
204
+ if (typeof modelId !== "string") return body;
205
+ if (modelId !== "gpt-5.6" && !modelId.startsWith("gpt-5.6-")) return body;
206
+ if (!Object.hasOwn(body, "prompt_cache_retention")) return body;
207
+ const { prompt_cache_retention: _retention, ...rest } = body;
208
+ return rest;
209
+ }
210
+
188
211
  /**
189
212
  * A false model capability prevents Codex from emitting summary fields after the catalog refresh.
190
213
  * Strip them here as well so an already-running client with a stale catalog cannot keep sending an
@@ -1468,6 +1491,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1468
1491
 
1469
1492
  const forward = provider.authMode === "forward";
1470
1493
  let convertedRoutedCustomToolNames: Set<string> | undefined;
1494
+ let convertedRoutedToolSearchNames: Set<string> | undefined;
1471
1495
  const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
1472
1496
  let outBody = stripPreviousResponseId(
1473
1497
  parsed._rawBody,
@@ -1491,6 +1515,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1491
1515
  }
1492
1516
  if (forward) {
1493
1517
  outBody = stripUnsupportedForwardParams(outBody);
1518
+ // Only the canonical ChatGPT backend rejects the retired field; a self-hosted or
1519
+ // third-party forward gateway may still accept it, so this must not be widened.
1520
+ if (isCanonicalOpenAiForwardProvider(provider)) {
1521
+ outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
1522
+ }
1494
1523
  } else {
1495
1524
  outBody = preferConfiguredHostedTools(
1496
1525
  outBody,
@@ -1522,6 +1551,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1522
1551
  outBody = rewritten.body;
1523
1552
  convertedRoutedCustomToolNames = rewritten.names;
1524
1553
  }
1554
+ if (!isCanonicalOpenAiForwardProvider(provider)) {
1555
+ // Run after custom-tool lowering so the search compatibility layer can choose a
1556
+ // collision-free public function name against the final routed function catalog.
1557
+ const rewritten = rewriteRoutedToolSearchForUpstream(outBody);
1558
+ outBody = rewritten.body;
1559
+ convertedRoutedToolSearchNames = rewritten.names;
1560
+ }
1525
1561
  const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
1526
1562
  const finalBody = stripDisabledReasoningSummaries(
1527
1563
  normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
@@ -1549,6 +1585,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1549
1585
  body,
1550
1586
  releaseBodyObservation,
1551
1587
  ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
1588
+ ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}),
1552
1589
  ...(tierLog ? { tierLog } : {}),
1553
1590
  };
1554
1591
  },