@bitkyc08/opencodex 2.26.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-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +60 -1
- package/src/adapters/base.ts +16 -2
- package/src/adapters/command-code.ts +4 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-antigravity-replay.ts +16 -8
- package/src/adapters/google.ts +22 -5
- package/src/adapters/openai-chat.ts +189 -60
- package/src/adapters/openai-responses.ts +37 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/bridge.ts +11 -5
- package/src/cli/doctor.ts +76 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/models.ts +13 -6
- package/src/codex/account-usability.ts +3 -0
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-api.ts +22 -5
- package/src/codex/auth-context.ts +108 -3
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/metadata.ts +17 -3
- package/src/codex/catalog/native-models.ts +22 -14
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +63 -15
- package/src/codex/convergence.ts +61 -13
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/quota.ts +28 -3
- package/src/codex/routing.ts +14 -8
- package/src/codex/user-identity.ts +21 -1
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +11 -24
- package/src/generated/compatibility-version.json +110 -70
- package/src/images/loop.ts +11 -4
- package/src/lib/destination-policy.ts +47 -0
- package/src/lib/shadow-call.ts +15 -0
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/oauth/index.ts +33 -5
- package/src/oauth/store.ts +11 -5
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/fastwire.ts +39 -8
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +120 -6
- package/src/providers/service-tier.ts +50 -15
- package/src/responses/parser.ts +59 -11
- package/src/responses/state.ts +162 -5
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/router.ts +17 -3
- package/src/routing/capability.ts +26 -9
- package/src/routing/compatibility/behavior.ts +44 -6
- package/src/routing/profile.ts +1 -1
- package/src/server/chat-native.ts +11 -2
- package/src/server/index.ts +59 -8
- package/src/server/management/agent-settings-routes.ts +16 -2
- package/src/server/management/shared.ts +3 -1
- package/src/server/request-log.ts +31 -0
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/compact.ts +54 -7
- package/src/server/responses/core.ts +259 -43
- package/src/server/responses/input-admission.ts +7 -2
- 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/service-manager-probe.ts +99 -0
- package/src/service.ts +86 -6
- package/src/tray/windows.ts +25 -5
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +818 -0
- package/src/types/provider.ts +521 -0
- package/src/types/request.ts +358 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +103 -1883
- package/src/usage/cost.ts +37 -1
- package/src/usage/log.ts +4 -0
- package/src/web-search/loop.ts +11 -4
|
@@ -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
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -1238,6 +1331,7 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
1238
1331
|
route.modelId,
|
|
1239
1332
|
route.providerName,
|
|
1240
1333
|
inboundWire,
|
|
1334
|
+
config.providers[route.providerName],
|
|
1241
1335
|
);
|
|
1242
1336
|
const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy);
|
|
1243
1337
|
const callerTier = parsed.options.serviceTier;
|
|
@@ -1837,7 +1931,14 @@ async function handleResponsesInner(
|
|
|
1837
1931
|
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1838
1932
|
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
|
|
1839
1933
|
}
|
|
1840
|
-
|
|
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
|
+
);
|
|
1841
1942
|
// Helpers must not resume/append into the parent thread's Cursor conversation.
|
|
1842
1943
|
parsed._cursorIsolateConversation = true;
|
|
1843
1944
|
}
|
|
@@ -2124,6 +2225,7 @@ async function handleResponsesInner(
|
|
|
2124
2225
|
}
|
|
2125
2226
|
|
|
2126
2227
|
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
2228
|
+
applyCodexAccountGatedWireNormalization(parsed, route);
|
|
2127
2229
|
logCtx.provider = route.codexAccountNamespace
|
|
2128
2230
|
? `${route.providerName}-${route.codexAccountNamespace}`
|
|
2129
2231
|
: formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
@@ -2398,6 +2500,7 @@ async function handleResponsesInner(
|
|
|
2398
2500
|
? new Map<string, { namespace: string; name: string }>()
|
|
2399
2501
|
: imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
|
|
2400
2502
|
const routedCustomToolNames = new Set<string>();
|
|
2503
|
+
const routedToolSearchNames = new Set<string>();
|
|
2401
2504
|
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
2402
2505
|
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
2403
2506
|
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
@@ -2431,6 +2534,12 @@ async function handleResponsesInner(
|
|
|
2431
2534
|
if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name);
|
|
2432
2535
|
}
|
|
2433
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
|
+
}
|
|
2434
2543
|
// #1700: the bridged paths refuse a call to a tool the request never declared
|
|
2435
2544
|
// (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed
|
|
2436
2545
|
// provider's top-level `apply_patch` — which under Codex code mode exists only as a nested
|
|
@@ -2621,6 +2730,98 @@ async function handleResponsesInner(
|
|
|
2621
2730
|
request.releaseBodyObservation?.();
|
|
2622
2731
|
}
|
|
2623
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
|
+
|
|
2624
2825
|
// Same-target 429 wait-and-retry (opt-in `retryOn429`) for key-auth providers on the
|
|
2625
2826
|
// passthrough wire. This branch returns before the recovery loop below, so Responses-shaped
|
|
2626
2827
|
// key-auth gateways (e.g. the built-in DeepSeek preset) would otherwise surface 429
|
|
@@ -2683,7 +2884,7 @@ async function handleResponsesInner(
|
|
|
2683
2884
|
}
|
|
2684
2885
|
}
|
|
2685
2886
|
|
|
2686
|
-
if (usesCodexForwardPoolAuth(authCtx, route.provider)
|
|
2887
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
2687
2888
|
let poolRetryOutcome: number | undefined;
|
|
2688
2889
|
if (await shouldRetryCodexPoolAccountModel400(
|
|
2689
2890
|
upstreamResponse,
|
|
@@ -2691,7 +2892,7 @@ async function handleResponsesInner(
|
|
|
2691
2892
|
options.abortSignal,
|
|
2692
2893
|
)) {
|
|
2693
2894
|
poolRetryOutcome = 400;
|
|
2694
|
-
} else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
2895
|
+
} else if (!authCtx.fixedAccount && shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
2695
2896
|
// Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
|
|
2696
2897
|
poolRetryOutcome = upstreamResponse.status;
|
|
2697
2898
|
}
|
|
@@ -2893,6 +3094,9 @@ async function handleResponsesInner(
|
|
|
2893
3094
|
routedCustomToolNames.size > 0
|
|
2894
3095
|
? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget)
|
|
2895
3096
|
: undefined,
|
|
3097
|
+
routedToolSearchNames.size > 0
|
|
3098
|
+
? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget)
|
|
3099
|
+
: undefined,
|
|
2896
3100
|
githubCopilotRepairEnabled
|
|
2897
3101
|
? createGithubCopilotResponsesBlockRewrite(translatorBudget)
|
|
2898
3102
|
: undefined,
|
|
@@ -3090,9 +3294,13 @@ async function handleResponsesInner(
|
|
|
3090
3294
|
restoreImageGenCallsInJson(text, imageGenCallAliases),
|
|
3091
3295
|
routedCustomToolNames,
|
|
3092
3296
|
);
|
|
3297
|
+
const restoredToolSearch = restoreRoutedToolSearchCallsInJson(
|
|
3298
|
+
restored,
|
|
3299
|
+
routedToolSearchNames,
|
|
3300
|
+
);
|
|
3093
3301
|
const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)
|
|
3094
|
-
? repairResponsesSnapshotJson(
|
|
3095
|
-
:
|
|
3302
|
+
? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody)
|
|
3303
|
+
: restoredToolSearch;
|
|
3096
3304
|
const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId
|
|
3097
3305
|
? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId)
|
|
3098
3306
|
: backfillResponsesFieldsJson(repaired);
|
|
@@ -4348,9 +4556,9 @@ async function handleResponsesInner(
|
|
|
4348
4556
|
const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
4349
4557
|
try {
|
|
4350
4558
|
if (nextParsed.stream) {
|
|
4351
|
-
yield* activeAdapter.parseStream(response, translatorBudget);
|
|
4559
|
+
yield* activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata);
|
|
4352
4560
|
} else if (activeAdapter.parseResponse) {
|
|
4353
|
-
yield* await activeAdapter.parseResponse(response, translatorBudget);
|
|
4561
|
+
yield* await activeAdapter.parseResponse(response, translatorBudget, logCtx.activeTierMetadata);
|
|
4354
4562
|
} else {
|
|
4355
4563
|
yield { type: "error", message: "Provider continuation does not support response parsing" };
|
|
4356
4564
|
}
|
|
@@ -4380,7 +4588,11 @@ async function handleResponsesInner(
|
|
|
4380
4588
|
};
|
|
4381
4589
|
|
|
4382
4590
|
if (parsed.stream) {
|
|
4383
|
-
const initialEventStream = activeAdapter.parseStream(
|
|
4591
|
+
const initialEventStream = activeAdapter.parseStream(
|
|
4592
|
+
upstreamResponse,
|
|
4593
|
+
translatorBudget,
|
|
4594
|
+
logCtx.activeTierMetadata,
|
|
4595
|
+
);
|
|
4384
4596
|
const eventStream = terminalGuardEnabled
|
|
4385
4597
|
? guardTerminalEventStream({
|
|
4386
4598
|
parsed,
|
|
@@ -4448,7 +4660,11 @@ async function handleResponsesInner(
|
|
|
4448
4660
|
if (activeAdapter.parseResponse) {
|
|
4449
4661
|
let events: AdapterEvent[];
|
|
4450
4662
|
try {
|
|
4451
|
-
const initialEvents = await activeAdapter.parseResponse(
|
|
4663
|
+
const initialEvents = await activeAdapter.parseResponse(
|
|
4664
|
+
upstreamResponse,
|
|
4665
|
+
translatorBudget,
|
|
4666
|
+
logCtx.activeTierMetadata,
|
|
4667
|
+
);
|
|
4452
4668
|
let guardedEvents: AdapterEvent[];
|
|
4453
4669
|
if (terminalGuardEnabled) {
|
|
4454
4670
|
guardedEvents = [];
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "../../codex/catalog/metadata";
|
|
14
14
|
import { estimateTokens } from "../../lib/token-estimate";
|
|
15
15
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
16
|
+
import { modelRecordValue } from "../../reasoning-effort";
|
|
16
17
|
import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -133,7 +134,11 @@ export function resolveInputCeiling(
|
|
|
133
134
|
// config here so this stays pure: no filesystem, no catalog, no registry scan.
|
|
134
135
|
nativeContextCap?: NativeContextLimitsInput,
|
|
135
136
|
): number | null {
|
|
136
|
-
|
|
137
|
+
// `modelRecordValue`, not a bare lookup: the catalog resolves these same two maps that
|
|
138
|
+
// way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw here made the gate fall
|
|
139
|
+
// back to the provider-wide window and refuse turns the model can plainly hold.
|
|
140
|
+
const configured = positive(modelRecordValue(provider.modelContextWindows, modelId))
|
|
141
|
+
?? positive(provider.contextWindow);
|
|
137
142
|
|
|
138
143
|
// The canonical `openai` registry entry declares no context fields, so without this the
|
|
139
144
|
// gate would be inert on the default Codex route. All three clauses are load-bearing: a
|
|
@@ -156,7 +161,7 @@ export function resolveInputCeiling(
|
|
|
156
161
|
|
|
157
162
|
const window = canonicalNativeBare ? native : configured;
|
|
158
163
|
// modelMaxInputTokens is an input-only cap, so it can only tighten the window.
|
|
159
|
-
const configuredMaxInput = positive(provider.modelMaxInputTokens
|
|
164
|
+
const configuredMaxInput = positive(modelRecordValue(provider.modelMaxInputTokens, modelId));
|
|
160
165
|
const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null);
|
|
161
166
|
return limits.length === 0 ? null : Math.min(...limits);
|
|
162
167
|
}
|
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
|
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")
|