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