@bitkyc08/opencodex 2.55.0-preview.20260914 → 2.56.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-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
materializeCodexUpstreamAuthAsync,
|
|
52
52
|
isCodexAuthContextUsable,
|
|
53
53
|
resolveCodexAuthContext,
|
|
54
|
+
codexPoolAffinityKey,
|
|
54
55
|
codexProbeLeaseId,
|
|
55
56
|
codexProbeQuotaScope,
|
|
56
57
|
releaseCodexAuthContextProbeLease,
|
|
@@ -67,6 +68,11 @@ import {
|
|
|
67
68
|
recordCodexUpstreamOutcome,
|
|
68
69
|
type CodexUpstreamOutcome,
|
|
69
70
|
} from "../../codex/routing";
|
|
71
|
+
import {
|
|
72
|
+
applyAccountChangeConversationStateScrub,
|
|
73
|
+
conversationStateBindingFromAuth,
|
|
74
|
+
rememberServingConversationStateIssuer,
|
|
75
|
+
} from "./account-change-state";
|
|
70
76
|
import {
|
|
71
77
|
TokenRefreshError,
|
|
72
78
|
forceRefreshCodexPoolToken,
|
|
@@ -682,6 +688,13 @@ export async function handleResponsesCompact(
|
|
|
682
688
|
// Combo-resolved targets skip native compact so failover can advance through the
|
|
683
689
|
// combo target list when the picked model returns 429/5xx — the routed path below
|
|
684
690
|
// dispatches through handleResponses → handleComboResponses with full failover.
|
|
691
|
+
//
|
|
692
|
+
// One holder for the WHOLE logical compact, declared above the native branch because the
|
|
693
|
+
// routed fallback below is not a different request: a native attempt that 404s, or a quota
|
|
694
|
+
// failure that hands off, continues here. The routed turn used to call handleResponses with
|
|
695
|
+
// no budget at all, so `handleResponsesInner` minted a fresh four after the native attempt
|
|
696
|
+
// had already spent some of the first one.
|
|
697
|
+
const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget();
|
|
685
698
|
if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo) {
|
|
686
699
|
if (req.signal.aborted) {
|
|
687
700
|
return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
@@ -773,10 +786,24 @@ export async function handleResponsesCompact(
|
|
|
773
786
|
// buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here
|
|
774
787
|
// so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
|
|
775
788
|
const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
|
|
789
|
+
{
|
|
790
|
+
const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers));
|
|
791
|
+
if (binding) {
|
|
792
|
+
applyAccountChangeConversationStateScrub({
|
|
793
|
+
body: raw,
|
|
794
|
+
bindingKey: binding.bindingKey,
|
|
795
|
+
servingAccountId: binding.accountId,
|
|
796
|
+
logCtx,
|
|
797
|
+
});
|
|
798
|
+
applyAccountChangeConversationStateScrub({
|
|
799
|
+
body: compactBody,
|
|
800
|
+
bindingKey: binding.bindingKey,
|
|
801
|
+
servingAccountId: binding.accountId,
|
|
802
|
+
logCtx,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
776
806
|
const compactUrl = `${base}/responses/compact`;
|
|
777
|
-
// One holder for this logical compact, inherited by the handoff child so a second model
|
|
778
|
-
// does not start over with a fresh four.
|
|
779
|
-
const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget();
|
|
780
807
|
const compactTargetKey = `${route.providerName}|${route.modelId}|compact`;
|
|
781
808
|
const actualCompactHostKey = upstreamHostHealthKey(
|
|
782
809
|
route.providerName,
|
|
@@ -1096,6 +1123,30 @@ export async function handleResponsesCompact(
|
|
|
1096
1123
|
await upstream.body?.cancel().catch(() => undefined);
|
|
1097
1124
|
outcomeCtx = alternate.authCtx;
|
|
1098
1125
|
logCtx.accountLogLabel = codexAuthContextLogLabel(alternate.authCtx, config);
|
|
1126
|
+
{
|
|
1127
|
+
const binding = conversationStateBindingFromAuth(
|
|
1128
|
+
alternate.authCtx,
|
|
1129
|
+
(authCtx.kind === "pool" || authCtx.kind === "main-pool")
|
|
1130
|
+
? authCtx.affinityKey
|
|
1131
|
+
: codexPoolAffinityKey(req.headers),
|
|
1132
|
+
);
|
|
1133
|
+
if (binding) {
|
|
1134
|
+
applyAccountChangeConversationStateScrub({
|
|
1135
|
+
body: raw,
|
|
1136
|
+
bindingKey: binding.bindingKey,
|
|
1137
|
+
servingAccountId: binding.accountId,
|
|
1138
|
+
priorAccountId: authCtx.accountId,
|
|
1139
|
+
logCtx,
|
|
1140
|
+
});
|
|
1141
|
+
applyAccountChangeConversationStateScrub({
|
|
1142
|
+
body: compactBody,
|
|
1143
|
+
bindingKey: binding.bindingKey,
|
|
1144
|
+
servingAccountId: binding.accountId,
|
|
1145
|
+
priorAccountId: authCtx.accountId,
|
|
1146
|
+
logCtx,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1099
1150
|
try {
|
|
1100
1151
|
upstream = await sendCompactAttempt(alternate.provider, alternate.headers, "single", alternate.authCtx);
|
|
1101
1152
|
} catch (err) {
|
|
@@ -1163,6 +1214,7 @@ export async function handleResponsesCompact(
|
|
|
1163
1214
|
if (buffered.ok) {
|
|
1164
1215
|
inspectResponseLogJson(logCtx, await buffered.clone().text());
|
|
1165
1216
|
forgetCompactHandoffRoute(req);
|
|
1217
|
+
rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers));
|
|
1166
1218
|
} else if (quotaFailure && !storedPool401ReplayAttempted) {
|
|
1167
1219
|
const fallbackModel = compactHandoffRoute(req, raw.model);
|
|
1168
1220
|
if (fallbackModel && !req.signal.aborted) {
|
|
@@ -1223,7 +1275,10 @@ export async function handleResponsesCompact(
|
|
|
1223
1275
|
body: JSON.stringify(internalBody),
|
|
1224
1276
|
});
|
|
1225
1277
|
linkRequestSessionLane(req, internalReq);
|
|
1226
|
-
|
|
1278
|
+
// The routed compaction turn is a handoff inside the same logical request, so it draws the
|
|
1279
|
+
// REMAINDER. Minting here is what let a native attempt spend three sends and the routed
|
|
1280
|
+
// fallback spend four more.
|
|
1281
|
+
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, ...(admission ? { admission } : {}) });
|
|
1227
1282
|
if (!response.ok) return response;
|
|
1228
1283
|
let json: { output?: unknown[]; status?: unknown; error?: unknown };
|
|
1229
1284
|
if (response.headers.get("content-type")?.includes("text/event-stream")) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ResponsesRequestContext } from "./core-options";
|
|
2
|
+
import type { ResponsesSidecarAuth } from "./request-sidecar-auth";
|
|
3
|
+
import { emptyCompletionRetryEnabled } from "./empty-completion-guard";
|
|
4
|
+
|
|
5
|
+
/** One responsibility of the Responses request pipeline; state owners are explicit. */
|
|
6
|
+
export function createResponsesCompletionPolicy(
|
|
7
|
+
requestContext: Pick<ResponsesRequestContext, "config" | "options">,
|
|
8
|
+
sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
|
|
9
|
+
) {
|
|
10
|
+
const { config, options } = requestContext;
|
|
11
|
+
const { routedCompaction } = sidecarState;
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
// Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output
|
|
15
|
+
// text and no tool call is a failure the client cannot see — it silently records the turn as
|
|
16
|
+
// done. The guard holds pre-content adapter events, suppresses the terminal of an empty
|
|
17
|
+
// turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is
|
|
18
|
+
// empty or fails. This is a top-level config opt-in; OCX_EMPTY_COMPLETION_RETRY=0 is a
|
|
19
|
+
// disable-only emergency override. Compaction turns and combo attempts keep their own
|
|
20
|
+
// machinery (the combo preflight already handles empty streams). Native Chat-to-Chat
|
|
21
|
+
// requests return from handleChatCompletions before entering Responses core, so they are
|
|
22
|
+
// intentionally outside this guard and retain their existing one-send wire behavior.
|
|
23
|
+
const emptyCompletionGuardEnabled =
|
|
24
|
+
emptyCompletionRetryEnabled(config)
|
|
25
|
+
&& !options.comboAttempt
|
|
26
|
+
&& !routedCompaction;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
emptyCompletionGuardEnabled,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ResponsesCompletionPolicy = Exclude<ReturnType<typeof createResponsesCompletionPolicy>, Response>;
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import type { OcxProviderConfig, OcxConfig } from "../../types";
|
|
2
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
3
|
+
import type { CodexAuthContext } from "../../codex/auth-context";
|
|
4
|
+
import type { RouteResult } from "../../router";
|
|
5
|
+
import type { HandleResponsesOptions } from "./core-options";
|
|
6
|
+
import {
|
|
7
|
+
hasForwardableCodexBearer,
|
|
8
|
+
validateForwardAdmissionCredential,
|
|
9
|
+
isProxyAdmissionSecret,
|
|
10
|
+
ForwardAdmissionCredentialError,
|
|
11
|
+
} from "../auth-cors";
|
|
12
|
+
import {
|
|
13
|
+
providerConsumesCallerAuthorization,
|
|
14
|
+
captureCallerDirectAuth,
|
|
15
|
+
} from "../../providers/caller-authorization";
|
|
16
|
+
import { inspectChatGptDomainClaim } from "../../oauth/chatgpt";
|
|
17
|
+
import {
|
|
18
|
+
resolveCodexAuthContext,
|
|
19
|
+
CodexMainProfileDrainingError,
|
|
20
|
+
materializeCodexUpstreamAuthAsync,
|
|
21
|
+
headersForCodexAuthContext,
|
|
22
|
+
isCodexAuthContextUsable,
|
|
23
|
+
releaseCodexAuthContextProbeLease,
|
|
24
|
+
CodexAuthContextError,
|
|
25
|
+
applyCodexAuthContextToProvider,
|
|
26
|
+
stripCodexRuntimeProviderFields,
|
|
27
|
+
} from "../../codex/auth-context";
|
|
28
|
+
import { codexAccountSelectionForTurn, tryClaimNativeMainProfileForTurn } from "../lifecycle";
|
|
29
|
+
import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
|
|
30
|
+
import { formatErrorResponse } from "../../bridge";
|
|
31
|
+
import { clientCancelledResponse } from "./core-errors";
|
|
32
|
+
import { formatCodexProviderForLog, handOffThreadAffinityGeneration } from "../../codex/routing";
|
|
33
|
+
import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
|
|
34
|
+
import {
|
|
35
|
+
isTerminalCodexPoolRefreshFailure,
|
|
36
|
+
forceRefreshCodexPoolToken,
|
|
37
|
+
capturePoolQuotaWriter,
|
|
38
|
+
} from "../../codex/account-store";
|
|
39
|
+
import type { RequestLogContext } from "../request-log";
|
|
40
|
+
import { markLocalRequestLogRefusal } from "../request-log";
|
|
41
|
+
import { CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON } from "../../codex/pool-refresh-backoff";
|
|
42
|
+
import { codexAuthContextLogLabel } from "../../codex/account-label";
|
|
43
|
+
import { forceRefreshMainAccountToken } from "../../codex/main-account";
|
|
44
|
+
|
|
45
|
+
/** Keep synthesized Claude identity out of request headers reused by policy/combo fallback. */
|
|
46
|
+
export function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers {
|
|
47
|
+
if (!sessionId || !isCanonicalOpenAiForwardProvider(provider)
|
|
48
|
+
|| headers.has("session_id") || headers.has("session-id") || headers.has("thread-id")) return headers;
|
|
49
|
+
const forwarded = new Headers(headers);
|
|
50
|
+
forwarded.set("session_id", sessionId);
|
|
51
|
+
return forwarded;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
export type ResponsesAuthResolution =
|
|
56
|
+
| { ok: true; authCtx: CodexAuthContext; headers: Headers; callerAuthHeaders: Headers; substituteMainCredential: boolean }
|
|
57
|
+
| { ok: false; response: Response };
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The caller credential the final Codex auth resolution will be given, as far as the ROUTE
|
|
62
|
+
* decides it: a route change that may cross a credential domain drops the raw caller credential,
|
|
63
|
+
* and a trusted Claude-main handoff replaces it.
|
|
64
|
+
*
|
|
65
|
+
* Shared with the lineage preview in `handleResponsesInner`, which has to read a conversation's
|
|
66
|
+
* family under the same authenticated scope the resolution will record it under -- that scope is
|
|
67
|
+
* an HMAC of exactly this Authorization header. Two copies of this rule would put preview and
|
|
68
|
+
* final auth in different scopes the first time one of them changed.
|
|
69
|
+
*/
|
|
70
|
+
export function codexRouteCredentialDomainHeaders(
|
|
71
|
+
req: Request,
|
|
72
|
+
route: RouteResult,
|
|
73
|
+
options: HandleResponsesOptions,
|
|
74
|
+
credentialDomainWasRewritten: boolean,
|
|
75
|
+
): Headers {
|
|
76
|
+
const trustedClaudeMainForFinalRoute = options.stripClaudeMainAuthForNoncanonicalForward === true
|
|
77
|
+
&& isCanonicalOpenAiForwardProvider(route.provider)
|
|
78
|
+
? options.trustedClaudeMainAuth : undefined;
|
|
79
|
+
if (trustedClaudeMainForFinalRoute) {
|
|
80
|
+
const claudeMainHeaders = new Headers(req.headers);
|
|
81
|
+
claudeMainHeaders.set("authorization", trustedClaudeMainForFinalRoute.authorization);
|
|
82
|
+
if (trustedClaudeMainForFinalRoute.chatgptAccountId) {
|
|
83
|
+
claudeMainHeaders.set("chatgpt-account-id", trustedClaudeMainForFinalRoute.chatgptAccountId);
|
|
84
|
+
} else {
|
|
85
|
+
claudeMainHeaders.delete("chatgpt-account-id");
|
|
86
|
+
}
|
|
87
|
+
return claudeMainHeaders;
|
|
88
|
+
}
|
|
89
|
+
// Route-changing recursion retains typed admission, never an unscoped raw
|
|
90
|
+
// caller credential. Bearer admission is substituted or stripped below.
|
|
91
|
+
const routeMayChangeCredentialDomain = options.comboAttempt === true
|
|
92
|
+
|| route.routeKind === "policy"
|
|
93
|
+
|| credentialDomainWasRewritten;
|
|
94
|
+
if (routeMayChangeCredentialDomain && options.admission?.source !== "bearer") {
|
|
95
|
+
const scoped = new Headers(req.headers);
|
|
96
|
+
scoped.delete("authorization");
|
|
97
|
+
scoped.delete("chatgpt-account-id");
|
|
98
|
+
return scoped;
|
|
99
|
+
}
|
|
100
|
+
return req.headers;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Does this route substitute OUR stored main credential, and does the caller own the credential
|
|
106
|
+
* this request will authenticate with?
|
|
107
|
+
*
|
|
108
|
+
* Both answers are needed twice: by the resolution below, and by the lineage preview, which must
|
|
109
|
+
* not follow a Pool family binding for a request whose credential never enters Pool state. One
|
|
110
|
+
* implementation, because two copies of this predicate disagreeing is the divergence the preview
|
|
111
|
+
* gate exists to prevent. The reasoning behind the substitution test itself is at its use site
|
|
112
|
+
* below (#1686, #2132).
|
|
113
|
+
*/
|
|
114
|
+
export function codexRouteCredentialOwnership(
|
|
115
|
+
authInputHeaders: Headers,
|
|
116
|
+
config: OcxConfig,
|
|
117
|
+
route: RouteResult,
|
|
118
|
+
options: HandleResponsesOptions,
|
|
119
|
+
): { substituteMainCredential: boolean; requestScopedMainCredential: boolean } {
|
|
120
|
+
const substituteMainCredential = options.admission?.source === "bearer"
|
|
121
|
+
&& (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider));
|
|
122
|
+
return {
|
|
123
|
+
substituteMainCredential,
|
|
124
|
+
requestScopedMainCredential: route.codexAccountMode !== undefined
|
|
125
|
+
&& !substituteMainCredential
|
|
126
|
+
&& hasForwardableCodexBearer(authInputHeaders, config),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve Codex auth for a route. On unusable contexts, releases any probe lease
|
|
133
|
+
* before returning the 401 (nothing reaches upstream).
|
|
134
|
+
*/
|
|
135
|
+
export async function resolveResponsesCodexAuth(
|
|
136
|
+
req: Request,
|
|
137
|
+
config: OcxConfig,
|
|
138
|
+
route: RouteResult,
|
|
139
|
+
options: HandleResponsesOptions,
|
|
140
|
+
credentialDomainWasRewritten = false,
|
|
141
|
+
): Promise<ResponsesAuthResolution> {
|
|
142
|
+
try {
|
|
143
|
+
let authInputHeaders = codexRouteCredentialDomainHeaders(
|
|
144
|
+
req,
|
|
145
|
+
route,
|
|
146
|
+
options,
|
|
147
|
+
credentialDomainWasRewritten,
|
|
148
|
+
);
|
|
149
|
+
// A caller-auth transport that is not canonical OpenAI (keyless Cursor) consumes the
|
|
150
|
+
// caller's Authorization as its own upstream token. Keep that contract only for a clean
|
|
151
|
+
// single bearer with NO ChatGPT-domain marker. A bearer marked for the ChatGPT domain —
|
|
152
|
+
// whether its marker is valid or malformed/conflicting — a combined/malformed value, or
|
|
153
|
+
// the captured explicit OpenAI pair is never a Cursor token; a foreign JWT carrying only
|
|
154
|
+
// a generic organizations claim is not ChatGPT-marked and keeps the legacy contract.
|
|
155
|
+
// chatgpt-account-id has no meaning outside the ChatGPT domain.
|
|
156
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider)
|
|
157
|
+
&& providerConsumesCallerAuthorization(route.provider)) {
|
|
158
|
+
const rawAuth = authInputHeaders.get("authorization")?.trim();
|
|
159
|
+
const singleBearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(rawAuth ?? "")?.[1];
|
|
160
|
+
const domainClaim = singleBearer ? inspectChatGptDomainClaim(singleBearer) : { kind: "absent" as const };
|
|
161
|
+
const dropBearer = options.nativeCallerAuth != null || domainClaim.kind !== "absent"
|
|
162
|
+
|| (rawAuth !== undefined && singleBearer === undefined);
|
|
163
|
+
if (dropBearer || authInputHeaders.has("chatgpt-account-id")) {
|
|
164
|
+
const scoped = new Headers(authInputHeaders);
|
|
165
|
+
if (dropBearer) scoped.delete("authorization");
|
|
166
|
+
scoped.delete("chatgpt-account-id");
|
|
167
|
+
authInputHeaders = scoped;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// The caller's own Direct credential may cross an internal route change only to the
|
|
171
|
+
// canonical OpenAI transport, under a predicate deliberately STRICTER than plain
|
|
172
|
+
// unchanged-route Direct forwarding: a clean non-proxy bearer whose ChatGPT-domain
|
|
173
|
+
// marker is valid, with any explicit account header matching that marker. Unchanged
|
|
174
|
+
// routes keep their legacy rules; sidecar enrichment grants no primary authority.
|
|
175
|
+
if (options.callerDirectAuth && isCanonicalOpenAiForwardProvider(route.provider)) {
|
|
176
|
+
const directHeaders = new Headers({
|
|
177
|
+
authorization: options.callerDirectAuth.authorization,
|
|
178
|
+
...(options.callerDirectAuth.chatgptAccountId
|
|
179
|
+
? { "chatgpt-account-id": options.callerDirectAuth.chatgptAccountId } : {}),
|
|
180
|
+
});
|
|
181
|
+
if (captureCallerDirectAuth(directHeaders, config)) {
|
|
182
|
+
authInputHeaders = new Headers(authInputHeaders);
|
|
183
|
+
authInputHeaders.set("authorization", options.callerDirectAuth.authorization);
|
|
184
|
+
if (options.callerDirectAuth.chatgptAccountId) {
|
|
185
|
+
authInputHeaders.set("chatgpt-account-id", options.callerDirectAuth.chatgptAccountId);
|
|
186
|
+
} else {
|
|
187
|
+
authInputHeaders.delete("chatgpt-account-id");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// #1686: a caller that proved admission with a BEARER presented one of our own secrets.
|
|
192
|
+
// Refusing it here is what made the codex-cli `env_key` contract unusable against Direct.
|
|
193
|
+
// Admitting it is only safe because the stored main credential is substituted below, so
|
|
194
|
+
// the admission secret still never leaves this process.
|
|
195
|
+
//
|
|
196
|
+
// #2132: substitution answers "does THIS ROUTE need our stored ChatGPT credential", not
|
|
197
|
+
// "how did the caller authenticate". Only a native Codex route reaches the ChatGPT backend
|
|
198
|
+
// and can consume that credential; a key-authenticated routed provider carries its own and
|
|
199
|
+
// never touches it. Keying on the caller alone made an install that deliberately never
|
|
200
|
+
// logged into ChatGPT fail every routed request with "No usable Codex main credential".
|
|
201
|
+
//
|
|
202
|
+
// But ask that question the way the ADAPTER asks it. `codexAccountMode` is derived from the
|
|
203
|
+
// provider NAME (`providerCodexAccountMode`), while the passthrough adapter decides whether
|
|
204
|
+
// to forward caller credentials from the TRANSPORT — adapter, auth mode, and base URL
|
|
205
|
+
// (`isCanonicalOpenAiForwardProvider`). A row the operator named anything other than
|
|
206
|
+
// `openai`, pointed at the canonical ChatGPT backend with `authMode: "forward"`, satisfies
|
|
207
|
+
// the adapter's test and fails this one, so substitution was skipped and the adapter then
|
|
208
|
+
// forwarded our own admission secret upstream. Two predicates answering one question is the
|
|
209
|
+
// bug; the transport is the authority, because the transport is what actually carries the
|
|
210
|
+
// header. A key-authenticated routed provider is still not canonical-forward, so #2132's
|
|
211
|
+
// no-ChatGPT-login install keeps working.
|
|
212
|
+
const { substituteMainCredential, requestScopedMainCredential } = codexRouteCredentialOwnership(
|
|
213
|
+
authInputHeaders,
|
|
214
|
+
config,
|
|
215
|
+
route,
|
|
216
|
+
options,
|
|
217
|
+
);
|
|
218
|
+
const stripAuthorization = options.admission?.source === "bearer" && !substituteMainCredential;
|
|
219
|
+
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
|
|
220
|
+
validateForwardAdmissionCredential(authInputHeaders, config);
|
|
221
|
+
}
|
|
222
|
+
let authCtx: CodexAuthContext;
|
|
223
|
+
if (route.codexAccountMode) {
|
|
224
|
+
authCtx = await resolveCodexAuthContext(authInputHeaders, config, route.codexAccountMode, {
|
|
225
|
+
admission: options.admission,
|
|
226
|
+
codexAuthPolicy: options.codexAuthPolicy,
|
|
227
|
+
accountId: route.codexAccountId,
|
|
228
|
+
modelId: route.modelId,
|
|
229
|
+
substituteMainCredentialForDirect: substituteMainCredential,
|
|
230
|
+
requestScopedMainCredential,
|
|
231
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
232
|
+
resolveCodexModelEntitlements: options.resolveCodexModelEntitlements,
|
|
233
|
+
signal: options.abortSignal,
|
|
234
|
+
nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
|
|
235
|
+
});
|
|
236
|
+
options.onCodexAuthContextResolved?.(authCtx);
|
|
237
|
+
} else {
|
|
238
|
+
// A custom-named canonical-forward provider has no Codex account mode, but an
|
|
239
|
+
// admission bearer still substitutes the stored main credential below. Claim the
|
|
240
|
+
// same physical profile before synthesizing the main context so transport-based
|
|
241
|
+
// substitution cannot bypass a switch drain.
|
|
242
|
+
if (
|
|
243
|
+
substituteMainCredential
|
|
244
|
+
&& (
|
|
245
|
+
isNativeMainTrafficBlocked()
|
|
246
|
+
|| !tryClaimNativeMainProfileForTurn(options.turnAdmissionLease)
|
|
247
|
+
|| isNativeMainTrafficBlocked()
|
|
248
|
+
)
|
|
249
|
+
) {
|
|
250
|
+
throw new CodexMainProfileDrainingError();
|
|
251
|
+
}
|
|
252
|
+
authCtx = { kind: "main", accountId: null };
|
|
253
|
+
options.onCodexAuthContextResolved?.(undefined);
|
|
254
|
+
}
|
|
255
|
+
// This resolver also builds a synthetic main context for unrelated keyed routes. Only
|
|
256
|
+
// the actual Codex-forward transport consumes main quota; provider names are not proof
|
|
257
|
+
// (custom-named canonical-forward providers must retain the same protection).
|
|
258
|
+
const mainPolicyConfig = isCanonicalOpenAiForwardProvider(route.provider)
|
|
259
|
+
? options.codexAuthPolicy ?? config : undefined;
|
|
260
|
+
const headers = await materializeCodexUpstreamAuthAsync(authInputHeaders, authCtx, {
|
|
261
|
+
admission: options.admission,
|
|
262
|
+
config: mainPolicyConfig,
|
|
263
|
+
modelId: route.modelId,
|
|
264
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
265
|
+
substituteMainCredential,
|
|
266
|
+
signal: options.abortSignal,
|
|
267
|
+
nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
|
|
268
|
+
});
|
|
269
|
+
// Awaiting even a cached materialization yields. Preserve the policy error if the live
|
|
270
|
+
// quota/config changed during that yield, before usability could mislabel it as reauth.
|
|
271
|
+
headersForCodexAuthContext(headers, authCtx, mainPolicyConfig, route.modelId, options.admission);
|
|
272
|
+
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
273
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (stripAuthorization) {
|
|
280
|
+
headers.delete("authorization");
|
|
281
|
+
headers.delete("chatgpt-account-id");
|
|
282
|
+
}
|
|
283
|
+
if (providerConsumesCallerAuthorization(route.provider) && options.admission?.source !== undefined
|
|
284
|
+
&& options.admission.source !== "loopback") {
|
|
285
|
+
validateForwardAdmissionCredential(headers, config);
|
|
286
|
+
} else {
|
|
287
|
+
// Even adapters that ignore caller auth must not retain a proxy secret for
|
|
288
|
+
// a later internal hop or a future transport change.
|
|
289
|
+
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
290
|
+
if (bearer && isProxyAdmissionSecret(bearer, config)) {
|
|
291
|
+
headers.delete("authorization");
|
|
292
|
+
headers.delete("chatgpt-account-id");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
authCtx,
|
|
298
|
+
headers,
|
|
299
|
+
callerAuthHeaders: new Headers(authInputHeaders),
|
|
300
|
+
substituteMainCredential,
|
|
301
|
+
};
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (options.abortSignal?.aborted || req.signal.aborted) {
|
|
304
|
+
return { ok: false, response: clientCancelledResponse() };
|
|
305
|
+
}
|
|
306
|
+
if (err instanceof CodexAuthContextError) {
|
|
307
|
+
const safeAccountLabel = route.codexAccountNamespace
|
|
308
|
+
? `${route.providerName}-${route.codexAccountNamespace}`
|
|
309
|
+
: formatCodexProviderForLog(route.providerName, err.accountId, config);
|
|
310
|
+
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
311
|
+
}
|
|
312
|
+
if (err instanceof ForwardAdmissionCredentialError) {
|
|
313
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
314
|
+
}
|
|
315
|
+
const response = mapCodexAuthContextErrorToResponse(err, {
|
|
316
|
+
accountSelector: route.codexAccountNamespace,
|
|
317
|
+
now: Date.now(),
|
|
318
|
+
});
|
|
319
|
+
if (response) return { ok: false, response };
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Terminal means the grant itself is dead and no retry can help. Everything else —
|
|
327
|
+
* an untyped network failure, a token-endpoint 5xx surfacing as `unknown`, an abort,
|
|
328
|
+
* refresh capacity, lock contention, a superseded flight — is transient, and treating
|
|
329
|
+
* it as terminal would quarantine a healthy account on an upstream blip, which is the
|
|
330
|
+
* defect this path exists to fix (#2887).
|
|
331
|
+
*/
|
|
332
|
+
export function isTerminalPoolRefreshFailure(error: unknown): boolean {
|
|
333
|
+
// Delegated so "terminal" has ONE definition. A missing record or a missing refresh-grant
|
|
334
|
+
// fingerprint is permanent -- retrying cannot conjure a credential -- and used to be a bare
|
|
335
|
+
// Error, which fell through to the retryable 503 and told the operator to keep retrying a
|
|
336
|
+
// request that could never succeed.
|
|
337
|
+
return isTerminalCodexPoolRefreshFailure(error);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The refusal an operator meets when a stored pool credential's forced refresh does not complete.
|
|
343
|
+
*
|
|
344
|
+
* A bare "retry this request" reads as a transient fault in the proxy, which is how #4212's
|
|
345
|
+
* reporter spent an afternoon concluding OpenCodex had broken while one of their own accounts was
|
|
346
|
+
* the thing that needed them. It stays a retryable 503 and stays non-quarantining, because the
|
|
347
|
+
* refresh genuinely may succeed and a token-endpoint 5xx must not retire a healthy account
|
|
348
|
+
* (#2887). What it adds is the account and the exit: when retrying stops helping, that account
|
|
349
|
+
* has to be signed in again.
|
|
350
|
+
*
|
|
351
|
+
* The label is a public account selector when the request carried one, otherwise the durable
|
|
352
|
+
* `p`-prefixed log label — never the raw pool id and never the email. Those are the identifiers
|
|
353
|
+
* `responses-compaction-routing.test.ts` and `codex-auth-context.test.ts` already assert must not
|
|
354
|
+
* reach an operator-facing surface, and an error body travels further than a log line, not less.
|
|
355
|
+
* When neither is resolvable the sentence degrades to "the selected Codex pool account" rather
|
|
356
|
+
* than naming something opaque, because a wrong name is worse than no name.
|
|
357
|
+
*
|
|
358
|
+
* The wording says "sign in to that account again" and deliberately does NOT say
|
|
359
|
+
* "reauthentication". `classifyError` runs `isAuthenticationMessage` before it reaches the
|
|
360
|
+
* `status === 503` arm, and that check is status-blind on the bare substring "authentication",
|
|
361
|
+
* which "reauthentication" contains. A body carrying that word is reclassified to
|
|
362
|
+
* `authentication_error` / `invalid_api_key` even though the HTTP status stays 503 — and Codex
|
|
363
|
+
* applies retry-after backoff only for `server_is_overloaded`, so the friendlier sentence would
|
|
364
|
+
* have quietly disabled the retry this refusal exists to ask for. `options.code` cannot buy the
|
|
365
|
+
* classification back; only the wording can.
|
|
366
|
+
*/
|
|
367
|
+
export function poolCredentialRefreshIncompleteResponse(args: {
|
|
368
|
+
authCtx: CodexAuthContext;
|
|
369
|
+
config: Pick<OcxConfig, "codexAccounts">;
|
|
370
|
+
accountSelector?: string;
|
|
371
|
+
logCtx?: RequestLogContext;
|
|
372
|
+
}): Response {
|
|
373
|
+
// The wire contract below is unchanged on purpose, so the record has to carry the origin
|
|
374
|
+
// instead. Without it an operator reads this sentence under a field named "Upstream reason"
|
|
375
|
+
// and goes looking at the provider's status page for a refusal that never left this process.
|
|
376
|
+
if (args.logCtx) markLocalRequestLogRefusal(args.logCtx, CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON);
|
|
377
|
+
const label = args.accountSelector ?? codexAuthContextLogLabel(args.authCtx, args.config);
|
|
378
|
+
const account = label ? `Codex pool account ${label}` : "the selected Codex pool account";
|
|
379
|
+
const response = formatErrorResponse(
|
|
380
|
+
503,
|
|
381
|
+
"server_busy",
|
|
382
|
+
`Codex credential refresh did not complete for ${account}; retry this request. `
|
|
383
|
+
+ "If it keeps failing, sign in to that account again.",
|
|
384
|
+
);
|
|
385
|
+
const headers = new Headers(response.headers);
|
|
386
|
+
headers.set("Retry-After", "1");
|
|
387
|
+
return new Response(response.body, { status: response.status, headers });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* One forced refresh and one same-account rebuild for a stored pool credential that
|
|
393
|
+
* upstream rejected with a pre-stream 401. `quarantine` distinguishes a dead grant,
|
|
394
|
+
* which must retire the account, from a transient failure, which must not.
|
|
395
|
+
*/
|
|
396
|
+
export async function refreshPoolForwardAuth(args: {
|
|
397
|
+
logCtx?: RequestLogContext;
|
|
398
|
+
req: Request;
|
|
399
|
+
config: OcxConfig;
|
|
400
|
+
route: RouteResult;
|
|
401
|
+
authCtx: CodexAuthContext & { kind: "pool" };
|
|
402
|
+
substituteMainCredential: boolean;
|
|
403
|
+
options: HandleResponsesOptions;
|
|
404
|
+
}): Promise<
|
|
405
|
+
| { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers }
|
|
406
|
+
| { ok: false; response: Response; quarantine: boolean; quarantineGeneration?: number }
|
|
407
|
+
> {
|
|
408
|
+
const { req, config, route, authCtx, substituteMainCredential, options } = args;
|
|
409
|
+
try {
|
|
410
|
+
const refreshed = await forceRefreshCodexPoolToken(authCtx.accountId, {
|
|
411
|
+
rejectedGeneration: authCtx.generation,
|
|
412
|
+
rejectedAccessToken: authCtx.accessToken,
|
|
413
|
+
signal: options.abortSignal,
|
|
414
|
+
});
|
|
415
|
+
if (!refreshed.rotated) {
|
|
416
|
+
// The store resolved to the same bearer upstream just rejected. Replaying it
|
|
417
|
+
// would spend another upstream call to earn the identical 401. Upstream can do
|
|
418
|
+
// this on a SUCCESSFUL response by rotating only the refresh grant, so the
|
|
419
|
+
// credential generation may already have moved — quarantine has to be fenced on
|
|
420
|
+
// where the credential actually is, not on the generation we started from.
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
quarantine: true,
|
|
424
|
+
quarantineGeneration: refreshed.generation,
|
|
425
|
+
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// Only a CAS this request performed itself proves the new credential descends from
|
|
429
|
+
// the rejected one. Somebody else's replacement may be a different identity, and
|
|
430
|
+
// its affinity must be retired rather than inherited.
|
|
431
|
+
if (refreshed.selfRefreshed) {
|
|
432
|
+
handOffThreadAffinityGeneration(authCtx.accountId, authCtx.generation, refreshed.generation);
|
|
433
|
+
}
|
|
434
|
+
const refreshedAuthCtx: CodexAuthContext = {
|
|
435
|
+
...authCtx,
|
|
436
|
+
accessToken: refreshed.accessToken,
|
|
437
|
+
chatgptAccountId: refreshed.chatgptAccountId,
|
|
438
|
+
generation: refreshed.generation,
|
|
439
|
+
poolQuotaWriter: capturePoolQuotaWriter(authCtx.accountId, refreshed),
|
|
440
|
+
};
|
|
441
|
+
const provider = applyCodexAuthContextToProvider(
|
|
442
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
443
|
+
refreshedAuthCtx,
|
|
444
|
+
route.codexAccountMode,
|
|
445
|
+
);
|
|
446
|
+
const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, {
|
|
447
|
+
admission: options.admission,
|
|
448
|
+
config: options.codexAuthPolicy ?? config,
|
|
449
|
+
modelId: route.modelId,
|
|
450
|
+
substituteMainCredential,
|
|
451
|
+
signal: options.abortSignal,
|
|
452
|
+
nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
|
|
453
|
+
});
|
|
454
|
+
return { ok: true, authCtx: refreshedAuthCtx, provider, headers };
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (isTerminalPoolRefreshFailure(error)) {
|
|
457
|
+
return {
|
|
458
|
+
ok: false,
|
|
459
|
+
quarantine: true,
|
|
460
|
+
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
quarantine: false,
|
|
466
|
+
response: poolCredentialRefreshIncompleteResponse({
|
|
467
|
+
authCtx,
|
|
468
|
+
config,
|
|
469
|
+
accountSelector: route.codexAccountNamespace,
|
|
470
|
+
logCtx: args.logCtx,
|
|
471
|
+
}),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
export async function refreshNativeMainForwardAuth(args: {
|
|
478
|
+
req: Request;
|
|
479
|
+
config: OcxConfig;
|
|
480
|
+
route: RouteResult;
|
|
481
|
+
authCtx: CodexAuthContext;
|
|
482
|
+
substituteMainCredential: boolean;
|
|
483
|
+
options: HandleResponsesOptions;
|
|
484
|
+
}): Promise<
|
|
485
|
+
| { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers }
|
|
486
|
+
| { ok: false; response: Response }
|
|
487
|
+
> {
|
|
488
|
+
const { req, config, route, authCtx, substituteMainCredential, options } = args;
|
|
489
|
+
if (authCtx.kind !== "main-pool") {
|
|
490
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", "No native main credential to refresh") };
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const refreshed = await forceRefreshMainAccountToken(authCtx.accessToken, {
|
|
494
|
+
signal: options.abortSignal,
|
|
495
|
+
...(options.nativeMainRefreshDependencies ?? {}),
|
|
496
|
+
});
|
|
497
|
+
if (!refreshed) {
|
|
498
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication") };
|
|
499
|
+
}
|
|
500
|
+
const refreshedAuthCtx: CodexAuthContext = {
|
|
501
|
+
...authCtx,
|
|
502
|
+
accessToken: refreshed.accessToken,
|
|
503
|
+
chatgptAccountId: refreshed.chatgptAccountId,
|
|
504
|
+
};
|
|
505
|
+
const provider = applyCodexAuthContextToProvider(
|
|
506
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
507
|
+
refreshedAuthCtx,
|
|
508
|
+
route.codexAccountMode,
|
|
509
|
+
);
|
|
510
|
+
const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, {
|
|
511
|
+
admission: options.admission,
|
|
512
|
+
config: options.codexAuthPolicy ?? config,
|
|
513
|
+
modelId: route.modelId,
|
|
514
|
+
substituteMainCredential,
|
|
515
|
+
signal: options.abortSignal,
|
|
516
|
+
nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
|
|
517
|
+
});
|
|
518
|
+
return { ok: true, authCtx: refreshedAuthCtx, provider, headers };
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (options.abortSignal?.aborted || req.signal.aborted) {
|
|
521
|
+
return { ok: false, response: clientCancelledResponse() };
|
|
522
|
+
}
|
|
523
|
+
return { ok: false, response: mapCodexAuthContextErrorToResponse(error, {
|
|
524
|
+
now: Date.now(), accountSelector: route.codexAccountNamespace,
|
|
525
|
+
}) ?? nativeMainRefreshFailureResponse(error) };
|
|
526
|
+
}
|
|
527
|
+
}
|