@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.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. 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
- const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
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
+ }