@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.
- package/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +59 -0
- package/src/adapters/base.ts +2 -0
- package/src/adapters/google-antigravity-replay.ts +16 -8
- package/src/adapters/google.ts +21 -4
- package/src/adapters/openai-chat.ts +151 -54
- package/src/adapters/openai-responses.ts +37 -0
- package/src/cli/index.ts +19 -6
- package/src/codex/account-usability.ts +3 -0
- package/src/codex/auth-api.ts +22 -5
- package/src/codex/auth-context.ts +55 -2
- package/src/codex/catalog/metadata.ts +17 -3
- package/src/codex/catalog/native-models.ts +22 -14
- package/src/codex/catalog/sync.ts +57 -11
- package/src/codex/convergence.ts +61 -13
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/quota.ts +28 -3
- package/src/codex/routing.ts +14 -8
- package/src/generated/compatibility-version.json +51 -39
- package/src/lib/destination-policy.ts +47 -0
- package/src/lib/shadow-call.ts +15 -0
- package/src/oauth/index.ts +33 -5
- package/src/oauth/store.ts +11 -5
- package/src/providers/fastwire.ts +39 -8
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +74 -5
- package/src/providers/service-tier.ts +16 -8
- package/src/responses/parser.ts +3 -9
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/router.ts +7 -0
- package/src/routing/capability.ts +26 -9
- package/src/routing/compatibility/behavior.ts +41 -3
- package/src/server/chat-native.ts +11 -2
- package/src/server/index.ts +54 -8
- package/src/server/management/agent-settings-routes.ts +16 -2
- package/src/server/request-log.ts +31 -0
- package/src/server/responses/compact.ts +54 -7
- package/src/server/responses/core.ts +246 -39
- package/src/server/responses/responses-field-backfill.ts +88 -6
- package/src/server/responses/terminal-guard.ts +10 -0
- package/src/server/responses-tool-search-repair.ts +217 -0
- package/src/server/system-env.ts +74 -5
- package/src/usage/log.ts +4 -0
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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
|
@@ -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.
|
package/src/adapters/base.ts
CHANGED
|
@@ -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
|
|
603
|
-
* array
|
|
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(
|
|
607
|
-
|
|
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
|
/**
|
package/src/adapters/google.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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 {
|
|
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 (
|
|
1671
|
+
for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
|
|
1672
|
+
const rawToolCall: unknown = rawToolCalls[callIndex];
|
|
1617
1673
|
if (!isRecord(rawToolCall)) {
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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 &&
|
|
1712
|
+
if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta);
|
|
1657
1713
|
if (!call) {
|
|
1658
|
-
call = {
|
|
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
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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(
|
|
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 +=
|
|
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
|
},
|