@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,907 @@
1
+ import type { OcxConfig, OcxProviderConfig, OcxParsedRequest } from "../../types";
2
+ import type { CodexAuthContext, CodexAuthPolicyConfig } from "../../codex/auth-context";
3
+ import type { CodexUpstreamOutcome } from "../../codex/routing";
4
+ import {
5
+ recordCodexUpstreamOutcome,
6
+ computeQuotaCooldown,
7
+ formatCodexProviderForLog,
8
+ } from "../../codex/routing";
9
+ import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
10
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
11
+ import { isCodexAccountGenerationLive } from "../../codex/account-store";
12
+ import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota";
13
+ import type { RouteResult } from "../../router";
14
+ import {
15
+ normalizeUpstreamHostCircuitThreshold,
16
+ upstreamHostHealthKey,
17
+ resetUpstreamHostHealth,
18
+ } from "../../codex/upstream-host-health";
19
+ import { safeOriginLabel, fetchWithHeaderTimeout, providerFetch } from "./fetch-helpers";
20
+ import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
21
+ import { formatErrorResponse } from "../../bridge";
22
+ import { readBoundedResponseBody } from "../../lib/bounded-body";
23
+ import { upstreamErrorMessageFromPayload, isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
24
+ import { isNonReplayableResponse, isTransientUpstreamStatus } from "../../lib/upstream-retry";
25
+ import type { RequestLogContext } from "../request-log";
26
+ import type { DataPlaneAdmission } from "../auth-cors";
27
+ import type { InboundWire } from "../../providers/registry";
28
+ import type { BunRuntimeGateInput } from "./ws-upstream";
29
+ import type { TranslatorBudget } from "../../lib/translator-budget";
30
+ import type { AdmissionLease } from "../../lib/admission";
31
+ import {
32
+ resolveCodexModelEntitlements,
33
+ invalidateCodexModelEntitlementsForAccount,
34
+ entitledCodexAccountIdsForModel,
35
+ } from "../../codex/model-entitlements";
36
+ import type { TransientSendBudget } from "../../lib/upstream-retry";
37
+ import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
38
+ import { codexAccountSelectionForTurn } from "../lifecycle";
39
+ import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
40
+ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
41
+ import { slugsEquivalent } from "../../providers/slug-codec";
42
+ import {
43
+ codexProbeLeaseId,
44
+ codexTransientProbeGrant,
45
+ codexProbeQuotaScope,
46
+ releaseCodexAuthContextProbeLease,
47
+ resolveCodexAuthContext,
48
+ CodexPoolAuthenticationError,
49
+ CodexAuthContextError,
50
+ CodexAccountCooldownError,
51
+ CodexMainProfileDrainingError,
52
+ headersForCodexAuthContext,
53
+ applyCodexAuthContextToProvider,
54
+ stripCodexRuntimeProviderFields,
55
+ createCodexReserveDispatchGuard,
56
+ } from "../../codex/auth-context";
57
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
58
+ import { isRequestExecutionBudget } from "../../lib/request-execution-budget";
59
+ import type { SingleUseDispatchPermit } from "../../lib/request-execution-budget";
60
+ import { hasForwardableCodexBearer } from "../auth-cors";
61
+ import { bindRouteReasoningReplayScope } from "./core-replay";
62
+ import {
63
+ conversationStateBindingFromAuth,
64
+ applyAccountChangeConversationStateScrub,
65
+ conversationCarriesUploadedFiles,
66
+ } from "./account-change-state";
67
+ import {
68
+ recordAdapterReasoning,
69
+ recordAdapterTier,
70
+ sealRequestAttemptIdentity,
71
+ recordAttemptCredentialSource,
72
+ noteProviderAttemptSend,
73
+ } from "../request-log";
74
+ import { codexAuthContextLogLabel } from "../../codex/account-label";
75
+ import { chargeWorkflowSends } from "../../lib/workflow-budget";
76
+ import type { ResponsesTerminalStatus } from "../../bridge";
77
+
78
+ export function sidecarOutcomeRecorder(
79
+ config: OcxConfig,
80
+ authCtx: CodexAuthContext,
81
+ ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
82
+ return authCtx.kind === "pool" || authCtx.kind === "main-pool"
83
+ ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
84
+ threadId: authCtx.affinityKey,
85
+ fixedAccount: authCtx.fixedAccount,
86
+ probeLeaseId: authCtx.probeLeaseId,
87
+ probeQuotaScope: authCtx.probeQuotaScope,
88
+ writerGeneration: authCtx.writerGeneration,
89
+ // A vision or web-search sidecar can return 401/403, and that is evidence about the exact
90
+ // stored credential it used. Without the generation it becomes an account-wide quarantine
91
+ // that a replacement inherits (#2892 gap 4). `main-pool` has no stored-record generation, so
92
+ // it keeps the unfenced account-wide semantics.
93
+ ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
94
+ })
95
+ : undefined;
96
+ }
97
+
98
+
99
+
100
+
101
+ export function codexLogAccountId(authCtx: CodexAuthContext): string | null {
102
+ return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
103
+ }
104
+
105
+
106
+ export function isFixedCodexAccount(authCtx: CodexAuthContext): boolean {
107
+ return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
108
+ && authCtx.fixedAccount === true;
109
+ }
110
+
111
+
112
+ export function usesCodexForwardPoolAuth(
113
+ authCtx: CodexAuthContext,
114
+ provider: OcxProviderConfig,
115
+ ): authCtx is Extract<CodexAuthContext, { kind: "pool" | "main-pool" }> {
116
+ return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
117
+ && provider.authMode === "forward" && provider.adapter === "openai-responses";
118
+ }
119
+
120
+
121
+ export function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig, modelId?: string): CodexWsQuotaObserver | undefined {
122
+ if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
123
+ const { accountId, writerGeneration } = authCtx;
124
+ const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined;
125
+ const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined;
126
+ return headers => {
127
+ if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return;
128
+ applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined });
129
+ };
130
+ }
131
+
132
+
133
+ export function preAuthUpstreamHostCircuitKey(
134
+ route: Pick<RouteResult, "provider" | "providerName" | "codexAccountMode" | "codexAccountId">,
135
+ config: OcxConfig,
136
+ options: { requireResponsesAdapter?: boolean } = {},
137
+ ): string | null {
138
+ if (
139
+ normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) === 0
140
+ || route.codexAccountMode !== "pool"
141
+ || route.codexAccountId !== undefined
142
+ || route.provider.authMode !== "forward"
143
+ || (options.requireResponsesAdapter !== false && route.provider.adapter !== "openai-responses")
144
+ ) return null;
145
+ return upstreamHostHealthKey(route.providerName, safeOriginLabel(route.provider.baseUrl ?? ""));
146
+ }
147
+
148
+
149
+ export function upstreamHostCircuitOpenResponse(retryAfterSeconds: number): Response {
150
+ return formatErrorResponse(
151
+ 503,
152
+ "upstream_host_circuit_open",
153
+ "Provider host is temporarily unavailable",
154
+ { retryAfter: String(retryAfterSeconds) },
155
+ );
156
+ }
157
+
158
+
159
+ export function normalizeCodexUnsupportedModelDetail(value: string): string {
160
+ return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
161
+ }
162
+
163
+
164
+ export function isAllowListedCodexAccountModel400(
165
+ status: number,
166
+ bodyText: string,
167
+ modelId: string,
168
+ ): boolean {
169
+ if (status !== 400) return false;
170
+ try {
171
+ const payload = JSON.parse(bodyText) as unknown;
172
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
173
+ const detail = (payload as { detail?: unknown }).detail;
174
+ if (typeof detail !== "string") return false;
175
+ const expected = `The '${modelId}' model is not supported when using Codex with a ChatGPT account.`;
176
+ return normalizeCodexUnsupportedModelDetail(detail)
177
+ === normalizeCodexUnsupportedModelDetail(expected);
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+
184
+ export async function shouldRetryCodexPoolAccountModel400(
185
+ response: Response,
186
+ modelId: string,
187
+ signal?: AbortSignal,
188
+ ): Promise<boolean> {
189
+ if (response.status !== 400) return false;
190
+ try {
191
+ const body = await readBoundedResponseBody(response.clone(), { signal });
192
+ return body.displaySafe
193
+ && !body.truncated
194
+ && isAllowListedCodexAccountModel400(response.status, body.text, modelId);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+
201
+ /** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
202
+ export function codexQuotaFailureMessage(body: string): string | undefined {
203
+ try {
204
+ const payload = JSON.parse(body) as unknown;
205
+ const canonical = upstreamErrorMessageFromPayload(payload);
206
+ if (canonical !== undefined) return canonical;
207
+ if (typeof payload === "string") return payload;
208
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
209
+ const record = payload as Record<string, unknown>;
210
+ if (typeof record.message === "string") return record.message;
211
+ return typeof record.error === "string" ? record.error : undefined;
212
+ } catch {
213
+ // Plain-text gateways remain supported. Valid JSON is inspected only at recognized
214
+ // message fields so echoed request content elsewhere cannot trigger account cooldown.
215
+ return body;
216
+ }
217
+ }
218
+
219
+
220
+ export async function shouldRetryCodexPoolAccountQuota(
221
+ response: Response,
222
+ signal?: AbortSignal,
223
+ ): Promise<boolean> {
224
+ // A post-send WebSocket gateway status must not become a second account's send; the
225
+ // body carries no quota evidence either, but the marker is the contract, not the prose.
226
+ if (isNonReplayableResponse(response)) return false;
227
+ if (response.status === 402 || response.status === 429) {
228
+ // Status alone used to authorize the move, which is right for a limit the ACCOUNT owns and
229
+ // wrong for one it merely belongs to. An organization- or project-scoped exhaustion refuses
230
+ // every credential inside that organization, so the second account meets the same counter
231
+ // and the only thing the rotation buys is a second cold prompt prefix (#4546). Positive
232
+ // evidence is required to withhold it: the helper fails closed, so an unreadable or
233
+ // ambiguous body keeps the broad #584 behaviour unchanged, and `rate_limit_exceeded`,
234
+ // `slow_down` and plan-level exhaustion still rotate exactly as before.
235
+ const { codexScopedExhaustionCode } = await import("../../codex/quota-rejection");
236
+ return await codexScopedExhaustionCode(response, { signal }) === undefined;
237
+ }
238
+ if (response.status < 500 || response.status >= 600) return false;
239
+ try {
240
+ // Reject malformed UTF-8 instead of matching quota words around replacement characters.
241
+ const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
242
+ const message = body.displaySafe && !body.truncated
243
+ ? codexQuotaFailureMessage(body.text)
244
+ : undefined;
245
+ return message !== undefined
246
+ && isRateLimitOrQuotaFailureMessage(message);
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+
253
+ /**
254
+ * A pre-stream upstream 5xx another Codex account may still be able to serve.
255
+ *
256
+ * `server_is_overloaded` is the shape this exists for. The ChatGPT backend refuses in a few
257
+ * hundred milliseconds, the body carries no quota evidence, and nothing in that exchange is
258
+ * account health — so the pool keeps choosing the same account and every request fails on it
259
+ * while the other accounts sit idle. That is what an operator sees as the pool refusing to move.
260
+ *
261
+ * The status stays exactly as upstream sent it. `classifyCodexUpstreamOutcome` maps 5xx to the
262
+ * transient class, so the account earns an ordinary failure streak and `upstreamFailoverThreshold`
263
+ * decides when it is soft-avoided, rather than a quota cooldown it never earned.
264
+ *
265
+ * Deliberately narrow. {@link isNonReplayableResponse} still refuses: a post-send WebSocket
266
+ * gateway status means the body already reached the origin, so sending it from a second account
267
+ * could duplicate a turn the origin may still be running. A 5xx whose body confirms quota is not
268
+ * routed here either — {@link shouldRetryCodexPoolAccountQuota} classifies that one first and
269
+ * carries the cooldown with it.
270
+ */
271
+ export function shouldRetryCodexPoolAccountTransient(response: Response): boolean {
272
+ return !isNonReplayableResponse(response) && isTransientUpstreamStatus(response.status);
273
+ }
274
+
275
+
276
+ export interface CodexPoolAccountRetryArgs {
277
+ /** Sanitized caller input, before any selected Pool credential was materialized. */
278
+ callerAuthHeaders: Headers;
279
+ config: OcxConfig;
280
+ route: { providerName: string; modelId: string; provider: OcxProviderConfig };
281
+ parsed: OcxParsedRequest;
282
+ logCtx: RequestLogContext;
283
+ options: {
284
+ admission?: DataPlaneAdmission;
285
+ codexAuthPolicy?: CodexAuthPolicyConfig;
286
+ visionDescribeTerminal?: boolean;
287
+ abortSignal?: AbortSignal;
288
+ onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
289
+ deferCodexResetDerivedCooldown?: boolean;
290
+ // Narrowed subset of HandleResponsesOptions: the retry rebuilds the adapter, so it
291
+ // needs the inbound scope or the retry could land on a different wire than the
292
+ // first attempt.
293
+ inboundWire?: InboundWire;
294
+ codexWsRuntimeIdentity?: BunRuntimeGateInput;
295
+ translatorBudget: TranslatorBudget;
296
+ turnAdmissionLease?: AdmissionLease;
297
+ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
298
+ /** The logical request's execution budget: the account move is its fourth send. */
299
+ sendBudget?: TransientSendBudget;
300
+ /** Root workflow this turn belongs to, so the move is charged there as well. */
301
+ workflowRootId?: string;
302
+ };
303
+ firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
304
+ firstResponse: Response;
305
+ outcomeStatus: number;
306
+ /**
307
+ * Forbid resolving a DIFFERENT account for this retry.
308
+ *
309
+ * Set when a stored Pool 401 already spent this logical request's account budget on its own
310
+ * refresh and replay. The same-account gated-model retry above stays available, because it
311
+ * sends to the account that was already paying; only the alternate-account resolution below is
312
+ * out of budget.
313
+ */
314
+ sameAccountOnly?: boolean;
315
+ upstream: AbortController;
316
+ connectMs: number;
317
+ passthroughEstimate?: number;
318
+ stream: boolean;
319
+ onResponse?: (
320
+ response: Response,
321
+ authCtx: CodexAuthContext,
322
+ request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>,
323
+ ) => void;
324
+ }
325
+
326
+
327
+ export type CodexPoolAccountRetryResult =
328
+ | {
329
+ kind: "retried";
330
+ authCtx: CodexAuthContext;
331
+ request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
332
+ upstreamResponse: Response;
333
+ selectedForwardHeaders: Headers;
334
+ }
335
+ | { kind: "no-alternate" }
336
+ | {
337
+ kind: "transport";
338
+ error: unknown;
339
+ authCtx: CodexAuthContext;
340
+ };
341
+
342
+
343
+ /** Keep retry-stage entitlement snapshots inside the native-main selection fence. */
344
+ export async function resolveCodexRetryModelEntitlements(
345
+ config: OcxConfig,
346
+ resolver: typeof resolveCodexModelEntitlements,
347
+ turnAdmissionLease?: AdmissionLease,
348
+ ): Promise<Awaited<ReturnType<typeof resolveCodexModelEntitlements>>> {
349
+ // The initial auth selection has already released its admission before the first
350
+ // response arrives. Re-enter for every refresh so profile switching cannot overlap
351
+ // credential discovery, and omit main entirely when a drain or recovery owns it.
352
+ const selectionAdmission = codexAccountSelectionForTurn(turnAdmissionLease)?.();
353
+ const nativeMainReadsForbidden = isNativeMainTrafficBlocked()
354
+ || selectionAdmission?.mainProfileDraining === true;
355
+ try {
356
+ return await resolver(config, {
357
+ excludeAccountIds: nativeMainReadsForbidden
358
+ ? new Set([MAIN_CODEX_ACCOUNT_ID])
359
+ : undefined,
360
+ });
361
+ } finally {
362
+ selectionAdmission?.release();
363
+ }
364
+ }
365
+
366
+
367
+ export const CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS: ReadonlyMap<string, string> = new Map([
368
+ // The authenticated catalog currently advertises Daybreak Blue, while successful responses
369
+ // identify the serving model as gpt-5.6-sol. Sending the selector itself is shard-dependent:
370
+ // live traffic can receive the exact unsupported-model 400 repeatedly from the same entitled
371
+ // account. Keep Daybreak as the admission/catalog identity, but use the stable serving id on
372
+ // the credential-bearing wire after entitlement selection has completed.
373
+ ["gpt-daybreak-blue-latest", "gpt-5.6-sol"],
374
+ ]);
375
+
376
+
377
+ export function codexAccountGatedCanonicalWireModel(modelId: string): string | undefined {
378
+ const exact = CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS.get(modelId);
379
+ if (exact) return exact;
380
+ for (const [selector, wireModel] of CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS) {
381
+ if (slugsEquivalent(modelId, selector)) return wireModel;
382
+ }
383
+ return undefined;
384
+ }
385
+
386
+
387
+ export function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult, logCtx?: RequestLogContext): void {
388
+ if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
389
+ const wireModel = codexAccountGatedCanonicalWireModel(route.modelId);
390
+ if (!wireModel) return;
391
+
392
+ if (logCtx) {
393
+ logCtx.preserveResolvedModelFromRoute = true;
394
+ delete logCtx.resolvedModel;
395
+ }
396
+ parsed.modelId = wireModel;
397
+ if (!parsed._rawBody || typeof parsed._rawBody !== "object") return;
398
+ const raw = parsed._rawBody as Record<string, unknown>;
399
+ raw.model = wireModel;
400
+ // Daybreak's authenticated catalog does not advertise retention support, and the upstream
401
+ // rejects this optional Codex hint before model execution. Removing it preserves request
402
+ // semantics while avoiding an otherwise terminal pre-stream 400.
403
+ delete raw.prompt_cache_retention;
404
+ }
405
+
406
+
407
+ /**
408
+ * Workspace-denial evidence for a 403, read from the upstream body.
409
+ *
410
+ * #1789: a valid K12 credential gets 403 `codex_workspace_access_denied` on a routed prompt.
411
+ * Without this the account is quarantined for reauthentication, which cannot fix a workspace
412
+ * grant and loops forever. Fails closed: an unreadable body keeps the historical handling.
413
+ */
414
+ export async function codexDenialOutcomeMeta(response: Response): Promise<{ denial?: "workspace" | "entitlement" }> {
415
+ if (response.status !== 403) return {};
416
+ const { classifyCodexPreStreamRejection } = await import("../../codex/quota-rejection");
417
+ const rejection = await classifyCodexPreStreamRejection(response);
418
+ return rejection.denial ? { denial: rejection.denial } : {};
419
+ }
420
+
421
+
422
+ export function codexQuotaOutcomeMeta(response: Response): {
423
+ retryAfter: string | null;
424
+ resetAt: string[];
425
+ } {
426
+ return {
427
+ retryAfter: response.headers.get("retry-after"),
428
+ resetAt: [
429
+ response.headers.get("x-codex-primary-reset-at"),
430
+ response.headers.get("x-codex-secondary-reset-at"),
431
+ response.headers.get("x-codex-tertiary-reset-at"),
432
+ ].filter((value): value is string => !!value),
433
+ };
434
+ }
435
+
436
+
437
+ /**
438
+ * A reset timestamp describes a quota window, not an explicit instruction to
439
+ * stop using the whole account. A combo may therefore try a later model in the
440
+ * same request, while Retry-After and headerless quota failures remain blocking.
441
+ */
442
+ export function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean {
443
+ return enabled === true
444
+ && (response.status === 429 || response.status === 402)
445
+ && computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived";
446
+ }
447
+
448
+
449
+ /**
450
+ * One bounded alternate-account retry for Codex pool auth. Used for allow-listed
451
+ * model-400 and for pre-stream 429/402 quota failures (#584).
452
+ */
453
+ export async function retryCodexPoolOnAlternateAccount(
454
+ args: CodexPoolAccountRetryArgs,
455
+ ): Promise<CodexPoolAccountRetryResult> {
456
+ const {
457
+ callerAuthHeaders, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
458
+ outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
459
+ } = args;
460
+ const inboundWire = options.inboundWire ?? "responses";
461
+ const entitlementResolver = options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements;
462
+ let retryAuthCtx: CodexAuthContext | undefined;
463
+ // A transient 5xx must record even when this request cannot move: the ordinary terminal
464
+ // recorder only fires for an OK event-stream body, so a pre-stream refusal would otherwise
465
+ // leave the account looking healthy no matter how many times it refused, and the pool would
466
+ // keep handing it the next request.
467
+ const recordUnmovedTransientOutcome = (): void => {
468
+ if (!isTransientUpstreamStatus(outcomeStatus)) return;
469
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
470
+ threadId: firstAuthCtx.affinityKey,
471
+ fixedAccount: firstAuthCtx.fixedAccount,
472
+ modelId: route.modelId,
473
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
474
+ probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
475
+ transientProbe: codexTransientProbeGrant(firstAuthCtx),
476
+ writerGeneration: firstAuthCtx.writerGeneration,
477
+ });
478
+ };
479
+ if (outcomeStatus === 400 && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)) {
480
+ invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId);
481
+ let refreshed;
482
+ try {
483
+ refreshed = await resolveCodexRetryModelEntitlements(
484
+ config,
485
+ entitlementResolver,
486
+ options.turnAdmissionLease,
487
+ );
488
+ } catch (error) {
489
+ await firstResponse.body?.cancel().catch(() => undefined);
490
+ releaseCodexAuthContextProbeLease(firstAuthCtx);
491
+ throw error;
492
+ }
493
+ if (entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(firstAuthCtx.accountId)) {
494
+ // The authenticated roster still grants this exact model. Retry on the same account:
495
+ // upstream shards can briefly disagree during a gated-model rollout, but a pre-stream 400
496
+ // proves no output was committed and keeps this replay bounded.
497
+ retryAuthCtx = firstAuthCtx;
498
+ }
499
+ }
500
+ // Exact account selectors may retry the same confirmed account above, but must never resolve
501
+ // an alternate. Quota failures and a refreshed entitlement miss remain terminal.
502
+ if (!retryAuthCtx && (firstAuthCtx.fixedAccount || args.sameAccountOnly === true)) {
503
+ recordUnmovedTransientOutcome();
504
+ return { kind: "no-alternate" };
505
+ }
506
+ // An uploaded file is readable only by the account it was sent to, so NO alternate can serve
507
+ // this body. Which account would be chosen does not change that, which is why this asks before
508
+ // the resolution rather than after it: refusing here reserves no send, cancels no response, and
509
+ // leaves the caller holding the first account's rejection to return unchanged (#4710). The
510
+ // initial-dispatch sites answer with a 400 instead, because there is no earlier response there
511
+ // to fall back to. A same-account replay -- the gated-model 400 ladder above -- is unaffected,
512
+ // since it never leaves the issuing account.
513
+ if (!retryAuthCtx && conversationCarriesUploadedFiles(parsed._rawBody)) {
514
+ recordUnmovedTransientOutcome();
515
+ return { kind: "no-alternate" };
516
+ }
517
+ // An account move is the guarded profile's fourth send and draws the single shared
518
+ // final-recovery reserve. Nothing bounded it per request before: `excludeAccountId` excludes
519
+ // only the account that just failed, and the caller's recovery loop can return here after the
520
+ // alternate fails too, so one request could walk the pool an account at a time. The permit is
521
+ // consumed immediately before the physical send, so a resolution that finds no alternate
522
+ // costs nothing.
523
+ const executionBudget = isRequestExecutionBudget(args.options.sendBudget)
524
+ ? args.options.sendBudget
525
+ : undefined;
526
+ let accountMovePermit: SingleUseDispatchPermit | undefined;
527
+ if (!retryAuthCtx && executionBudget) {
528
+ const decision = executionBudget.reserveDispatch({
529
+ sendClass: "account-failover",
530
+ targetKey: `${route.providerName}|${route.modelId}|alternate-account`,
531
+ });
532
+ if (!decision.allowed) {
533
+ recordUnmovedTransientOutcome();
534
+ return { kind: "no-alternate" };
535
+ }
536
+ accountMovePermit = decision.permit;
537
+ }
538
+ try {
539
+ retryAuthCtx ??= await resolveCodexAuthContext(
540
+ callerAuthHeaders,
541
+ config,
542
+ "pool",
543
+ {
544
+ excludeAccountId: firstAuthCtx.accountId,
545
+ admission: options.admission,
546
+ codexAuthPolicy: options.codexAuthPolicy,
547
+ modelId: route.modelId,
548
+ requestScopedMainCredential: hasForwardableCodexBearer(callerAuthHeaders, config),
549
+ beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
550
+ resolveCodexModelEntitlements: entitlementResolver,
551
+ },
552
+ );
553
+ } catch (error) {
554
+ const unexpectedRetryError =
555
+ !(error instanceof CodexPoolAuthenticationError)
556
+ && !(error instanceof CodexAuthContextError)
557
+ && !(error instanceof CodexAccountCooldownError)
558
+ && !(error instanceof CodexMainProfileDrainingError);
559
+ if (unexpectedRetryError) {
560
+ // The reservation is the charge now, so an abandoned move has to hand its send back.
561
+ accountMovePermit?.release();
562
+ await firstResponse.body?.cancel().catch(() => undefined);
563
+ releaseCodexAuthContextProbeLease(firstAuthCtx);
564
+ throw error;
565
+ }
566
+ }
567
+ // A validated request-owned main bearer is a real alternate when the failed credential was a
568
+ // stored Pool account. It has no Pool account id to promote or cool, but it can own this one
569
+ // bounded replay. The resolver already refuses it when main itself is the excluded credential.
570
+ if (
571
+ retryAuthCtx?.kind !== "pool"
572
+ && retryAuthCtx?.kind !== "main-pool"
573
+ && retryAuthCtx?.kind !== "main"
574
+ ) {
575
+ // A body-confirmed quota response may arrive under HTTP 5xx. Without an alternate,
576
+ // the ordinary terminal recorder sees only that wire status and would misclassify it
577
+ // as transient, leaving the exhausted account immediately selectable next turn.
578
+ if (outcomeStatus !== firstResponse.status && (outcomeStatus === 429 || outcomeStatus === 402)) {
579
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
580
+ ...codexQuotaOutcomeMeta(firstResponse),
581
+ threadId: firstAuthCtx.affinityKey,
582
+ modelId: route.modelId,
583
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
584
+ probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
585
+ transientProbe: codexTransientProbeGrant(firstAuthCtx),
586
+ writerGeneration: firstAuthCtx.writerGeneration,
587
+ });
588
+ }
589
+ // No usable alternate was resolved, so the reserved move never becomes a send.
590
+ accountMovePermit?.release();
591
+ recordUnmovedTransientOutcome();
592
+ return { kind: "no-alternate" };
593
+ }
594
+
595
+ const quotaMeta = { ...codexQuotaOutcomeMeta(firstResponse), ...(await codexDenialOutcomeMeta(firstResponse)) };
596
+ if (outcomeStatus === 429 || outcomeStatus === 402) {
597
+ const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
598
+ applyAccountQuotaFromUpstreamHeaders(
599
+ firstAuthCtx.accountId,
600
+ firstResponse.headers,
601
+ firstAuthCtx.writerGeneration,
602
+ firstAuthCtx.kind === "main-pool" ? firstAuthCtx.mainQuotaWriter : undefined,
603
+ { modelId: route.modelId, poolWriter: firstAuthCtx.kind === "pool" ? firstAuthCtx.poolQuotaWriter : undefined },
604
+ );
605
+ }
606
+ const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown(
607
+ firstResponse,
608
+ options.deferCodexResetDerivedCooldown,
609
+ );
610
+ const recordFirstOutcome = (): void => {
611
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
612
+ ...quotaMeta,
613
+ threadId: firstAuthCtx.affinityKey,
614
+ modelId: route.modelId,
615
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
616
+ probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
617
+ transientProbe: codexTransientProbeGrant(firstAuthCtx),
618
+ writerGeneration: firstAuthCtx.writerGeneration,
619
+ // Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
620
+ ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
621
+ });
622
+ };
623
+ // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and
624
+ // ordinary requests must block the first account before the alternate send.
625
+ if (!deferFirstOutcome) recordFirstOutcome();
626
+ const retryHeaders = headersForCodexAuthContext(callerAuthHeaders, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission);
627
+ const retryProvider = applyCodexAuthContextToProvider(
628
+ stripCodexRuntimeProviderFields(route.provider),
629
+ retryAuthCtx,
630
+ "pool",
631
+ );
632
+ const retryAdapter = resolveAdapter(
633
+ resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider, inboundWire),
634
+ config.cacheRetention,
635
+ route.providerName,
636
+ );
637
+ bindRouteReasoningReplayScope({
638
+ parsed,
639
+ providerName: route.providerName,
640
+ provider: retryProvider,
641
+ adapterName: retryAdapter.name,
642
+ codexAuthContext: retryAuthCtx,
643
+ forwardHeaders: retryHeaders,
644
+ });
645
+ {
646
+ const binding = conversationStateBindingFromAuth(
647
+ retryAuthCtx,
648
+ firstAuthCtx.kind === "pool" || firstAuthCtx.kind === "main-pool"
649
+ ? firstAuthCtx.affinityKey
650
+ : undefined,
651
+ );
652
+ if (binding) {
653
+ applyAccountChangeConversationStateScrub({
654
+ body: parsed._rawBody,
655
+ parsed,
656
+ bindingKey: binding.bindingKey,
657
+ servingAccountId: binding.accountId,
658
+ priorAccountId: firstAuthCtx.accountId,
659
+ logCtx,
660
+ });
661
+ }
662
+ }
663
+ const request = await retryAdapter.buildRequest(parsed, {
664
+ headers: retryHeaders,
665
+ translatorBudget: options.translatorBudget,
666
+ });
667
+ recordAdapterReasoning(logCtx, request);
668
+ recordAdapterTier(logCtx, request);
669
+
670
+ await firstResponse.body?.cancel().catch(() => undefined);
671
+ options.onCodexAuthContextResolved?.(retryAuthCtx);
672
+ route.provider = retryProvider;
673
+ logCtx.provider = formatCodexProviderForLog(
674
+ route.providerName,
675
+ retryAuthCtx.accountId,
676
+ config,
677
+ );
678
+ logCtx.accountLogLabel = codexAuthContextLogLabel(retryAuthCtx, config);
679
+ sealRequestAttemptIdentity(
680
+ logCtx.activeAttempt,
681
+ logCtx.provider,
682
+ retryAdapter.name,
683
+ logCtx.accountLogLabel,
684
+ );
685
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name);
686
+
687
+ const retrySameConfirmedAccount = outcomeStatus === 400
688
+ && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)
689
+ && retryAuthCtx.accountId === firstAuthCtx.accountId;
690
+ // Live Daybreak traffic has produced long runs of unsupported-model 400s from different
691
+ // upstream shards even while the authenticated roster continues to grant the model. Permit
692
+ // seven additional same-account sends (eight total including the original), re-checking the
693
+ // exact allow-listed body and fresh entitlement before every later send. Alternate-account and
694
+ // quota recovery retain their historical one-send bound.
695
+ //
696
+ // Two different bounds, and the effective one is the smaller. `maxRetrySends` answers "how
697
+ // many times is it worth re-asking THIS account for a model its roster still grants"; the
698
+ // shared budget answers "how many times may this LOGICAL REQUEST reach upstream in total,
699
+ // across every layer that can re-send". A ladder of eight layered on sends the request had
700
+ // already made is exactly the per-request multiplication #4546 is about, so the ladder is
701
+ // capped at what the request has left. The floor of one keeps the single retry this function
702
+ // was called to make -- the move already paid for itself with its own permit -- and each rung
703
+ // past the first reserves its own send below, so a refusal stops the ladder with the last
704
+ // upstream answer intact.
705
+ // The ladder replays to the SAME account, so it must reserve under the same target key the
706
+ // other legs use. Folding the account id in made every rung read as a target change, which
707
+ // spent the one cross-account slot a real move needs on a same-account replay.
708
+ const ladderTargetKey = `${route.providerName}|${route.modelId}`;
709
+ // The ladder keeps its OWN bound rather than drawing on what the request has left. Clamping it
710
+ // to the shared total looked right and broke a working, pinned path: #2097 fixes this recovery
711
+ // at eight same-account dispatches (tests/server/server-auth.test.ts), and a request that has
712
+ // already spent sends would silently stop short of it. Reconciling an eight-send same-account
713
+ // ladder with a four-send request total is a policy decision, not a clamp to add in passing.
714
+ // What this diff does fix is that the rungs are now CHARGED instead of free.
715
+ const maxRetrySends = retrySameConfirmedAccount ? 7 : 1;
716
+ let retrySendCount = 0;
717
+ let upstreamResponse: Response;
718
+ try {
719
+ while (true) {
720
+ // The same-account gated-model 400 ladder below keeps its own `maxRetrySends` bound and
721
+ // does not take the reserve again; only the move itself does.
722
+ if (accountMovePermit) {
723
+ // The pool-wide recovery window is consulted BEFORE the request-local permit is used.
724
+ // `reserveDispatch` charges at reservation time and `release()` is the only way back, so
725
+ // using the permit first and refusing afterwards would spend a send the request never
726
+ // made. An account move is recovery traffic like any other: one request's own budget
727
+ // cannot see that a thousand other requests are moving at the same moment, which is
728
+ // precisely the amplification this window exists to bound (#4701).
729
+ //
730
+ // A refusal here is not a new failure mode: "no alternate was available" is already the
731
+ // outcome when the pool has nowhere to move this request to, and it is handled.
732
+ if (!classifyPoolRecoveryDispatch("retry").admitted) {
733
+ accountMovePermit.release();
734
+ accountMovePermit = undefined;
735
+ // The alternate context was resolved and will not send. Hand back whatever recovery
736
+ // lease it is holding rather than leaving that account unprobeable.
737
+ releaseCodexAuthContextProbeLease(retryAuthCtx);
738
+ recordUnmovedTransientOutcome();
739
+ return { kind: "no-alternate" };
740
+ }
741
+ const charged = accountMovePermit.use();
742
+ accountMovePermit = undefined;
743
+ if (!charged) {
744
+ releaseCodexAuthContextProbeLease(retryAuthCtx);
745
+ recordUnmovedTransientOutcome();
746
+ return { kind: "no-alternate" };
747
+ }
748
+ // The move is a physical send like any other, so the root workflow is charged too.
749
+ chargeWorkflowSends(args.options.workflowRootId, 1);
750
+ }
751
+ noteProviderAttemptSend(logCtx, route.providerName, route.provider, passthroughEstimate);
752
+ try {
753
+ upstreamResponse = await fetchWithHeaderTimeout(
754
+ request.url,
755
+ {
756
+ method: request.method,
757
+ headers: request.headers,
758
+ body: request.body,
759
+ },
760
+ upstream.signal,
761
+ connectMs,
762
+ stream,
763
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
764
+ providerName: route.providerName,
765
+ modelId: route.modelId,
766
+ onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId),
767
+ beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
768
+ ? createCodexReserveDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
769
+ }),
770
+ // Credential-bearing forward send: never follow a redirect into a
771
+ // dead-host rejection after the credential was seen (#914).
772
+ route.provider.authMode === "forward",
773
+ );
774
+ } catch (error) {
775
+ // Only the forward send is a transport boundary. Entitlement resolver throws below are
776
+ // deliberately outside this catch so programming errors retain their original path.
777
+ return { kind: "transport", error, authCtx: retryAuthCtx };
778
+ }
779
+ retrySendCount += 1;
780
+ args.onResponse?.(upstreamResponse, retryAuthCtx, request);
781
+ if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break;
782
+ // Caller-owned main is an alternate-account replay and can never enter the bounded
783
+ // same-stored-account 400 loop above. Keep that invariant explicit for the account-id reads.
784
+ if (retryAuthCtx.kind === "main") break;
785
+ if (!await shouldRetryCodexPoolAccountModel400(
786
+ upstreamResponse,
787
+ route.modelId,
788
+ options.abortSignal,
789
+ )) break;
790
+ invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId);
791
+ let refreshed: Awaited<ReturnType<typeof resolveCodexModelEntitlements>>;
792
+ try {
793
+ refreshed = await resolveCodexRetryModelEntitlements(
794
+ config,
795
+ entitlementResolver,
796
+ options.turnAdmissionLease,
797
+ );
798
+ } catch (error) {
799
+ await upstreamResponse.body?.cancel().catch(() => undefined);
800
+ await firstResponse.body?.cancel().catch(() => undefined);
801
+ releaseCodexAuthContextProbeLease(firstAuthCtx);
802
+ releaseCodexAuthContextProbeLease(retryAuthCtx);
803
+ throw error;
804
+ }
805
+ if (!entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(retryAuthCtx.accountId)) break;
806
+ // The next rung is another physical send of this logical request: a same-account,
807
+ // same-target replay, charged as an ordinary transient send rather than as a move.
808
+ // Reserved here, immediately before looping back, so a refusal stops the ladder with the
809
+ // last upstream 400 intact instead of spending a send it cannot make.
810
+ // Every rung is CHARGED, and a refusal does not end the ladder. That asymmetry is
811
+ // deliberate and it is the one place the shared cap yields. This is a same-account,
812
+ // same-target replay of a model-gating 400 whose own bound is eight dispatches, pinned by
813
+ // #2097; letting a spent request budget cut it to four would break a recovery that works
814
+ // today, which is precisely the mistake 040_send_budget.md warns a flat ceiling makes.
815
+ // The request total still governs everything that changes target or credential.
816
+ if (executionBudget) {
817
+ const rung = executionBudget.reserveDispatch({
818
+ sendClass: "transient",
819
+ targetKey: ladderTargetKey,
820
+ });
821
+ if (rung.allowed) rung.permit.use();
822
+ chargeWorkflowSends(args.options.workflowRootId, 1);
823
+ }
824
+ await upstreamResponse.body?.cancel().catch(() => undefined);
825
+ }
826
+ } finally {
827
+ request.releaseBodyObservation?.();
828
+ }
829
+ // A real HTTP response proves the host was reached (#914).
830
+ const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url));
831
+ if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) {
832
+ resetUpstreamHostHealth(retryHostKey, null);
833
+ } else {
834
+ resetUpstreamHostHealth(retryHostKey);
835
+ }
836
+ if (deferFirstOutcome && upstreamResponse.ok) {
837
+ // Deferral keeps the first account eligible for a later combo model while an
838
+ // alternate attempt is still fallible. Commit its quota outcome only once the
839
+ // alternate account returns a successful HTTP response; otherwise the combo may
840
+ // still need the first account for its next target.
841
+ recordFirstOutcome();
842
+ }
843
+ return {
844
+ kind: "retried",
845
+ authCtx: retryAuthCtx,
846
+ request,
847
+ upstreamResponse,
848
+ selectedForwardHeaders: retryHeaders,
849
+ };
850
+ }
851
+
852
+
853
+
854
+
855
+ export function codexForwardTerminalOutcomeRecorder(
856
+ config: OcxConfig,
857
+ authCtx: CodexAuthContext,
858
+ provider: OcxProviderConfig,
859
+ modelId?: string,
860
+ logCtx?: RequestLogContext,
861
+ ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
862
+ if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
863
+ return (status, httpStatusOverride) => {
864
+ const quotaStatus = [httpStatusOverride, logCtx?.terminalHttpStatus]
865
+ .find(value => value === 429 || value === 402);
866
+ if (status === "incomplete" && quotaStatus === undefined) {
867
+ // Normal limit/content-filter/stall terminal — the account served the
868
+ // request. Don't penalize account health; record success to clear any
869
+ // prior soft-avoid so a healthy account isn't stuck avoided.
870
+ recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
871
+ threadId: authCtx.affinityKey,
872
+ fixedAccount: authCtx.fixedAccount,
873
+ modelId,
874
+ probeLeaseId: codexProbeLeaseId(authCtx),
875
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
876
+ transientProbe: codexTransientProbeGrant(authCtx),
877
+ writerGeneration: authCtx.writerGeneration,
878
+ });
879
+ return;
880
+ }
881
+ // status === "completed" or "failed": use the semantic HTTP status derived
882
+ // from the terminal SSE error payload (httpStatusFromTerminalError in
883
+ // request-log inspection) instead of collapsing every non-completed terminal
884
+ // to 502. A 400 invalid_request_error must not soft-avoid the account or
885
+ // rebind threads — only genuine transport/5xx failures should trigger
886
+ // transient health recording.
887
+ // httpStatusOverride: the combo WS path inspects SSE payloads into the parent
888
+ // logCtx, but this recorder closes over the child logCtx. The caller passes
889
+ // the parent's terminalHttpStatus so the semantic status is not lost.
890
+ const outcome = status === "completed"
891
+ ? 200
892
+ : (quotaStatus ?? httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
893
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
894
+ threadId: authCtx.affinityKey,
895
+ fixedAccount: authCtx.fixedAccount,
896
+ modelId,
897
+ probeLeaseId: codexProbeLeaseId(authCtx),
898
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
899
+ transientProbe: codexTransientProbeGrant(authCtx),
900
+ writerGeneration: authCtx.writerGeneration,
901
+ // A mid-stream terminal can carry a semantic 401 long after the credential was
902
+ // replaced. It is never replayed — the client already saw output — but it must
903
+ // not retire the replacement either (#2887).
904
+ ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
905
+ });
906
+ };
907
+ }