@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,971 @@
1
+ import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
2
+ import type { PreparedResponsesRequest } from "./request-prepare";
3
+ import type { ResponsesTransport } from "./request-transport";
4
+ import type { ResponsesEffects } from "./response-effects";
5
+ import type { ResponsesSendBudget } from "./request-send-budget";
6
+ import { linkAbortSignal } from "./core-lifetime";
7
+ import type { AdapterRequest } from "../../adapters/base";
8
+ import type { AdapterEvent } from "../../types";
9
+ import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "../../bridge";
10
+ import { trackStreamLifetime } from "../lifecycle";
11
+ import {
12
+ recordAdapterReasoning,
13
+ recordAdapterTier,
14
+ noteAttemptSend,
15
+ sealRequestAttemptIdentity,
16
+ recordAttemptCredentialSource,
17
+ } from "../request-log";
18
+ import { clientCancelledResponse, readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-errors";
19
+ import { redactSecretString } from "../../lib/redact";
20
+ import { waitForProviderRequestSlot } from "../../providers/request-pacing";
21
+ import { providerFetch, fetchWithHeaderTimeout, safeHostLabel } from "./fetch-helpers";
22
+ import {
23
+ transientRetryPolicyFor,
24
+ rateLimitRetryPolicyFor,
25
+ hasKeyPoolFailover,
26
+ rotateProviderTransportOn401,
27
+ rateLimitRetryDelayMs,
28
+ rotateProviderTransportOn429,
29
+ } from "../../providers/key-failover";
30
+ import {
31
+ fetchWithTransientRetry,
32
+ fetchWithResetRetry,
33
+ applyUpstreamRecoveryInit,
34
+ SendBudgetExhaustedError,
35
+ prepareSameTarget429Wait,
36
+ sleepWithAbort,
37
+ } from "../../lib/upstream-retry";
38
+ import { describeUpstreamConnectFailure } from "./upstream-error";
39
+ import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
40
+ import type { AttemptRecoveryKind } from "../../usage/log";
41
+ import type { OAuthAccessSnapshot } from "../../oauth";
42
+ import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
43
+ import { resolveProviderTransport } from "../../providers/xai-transport";
44
+ import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
45
+ import { resolveWireProtocolOverride } from "../adapter-resolve";
46
+ import { bindRouteReasoningReplayScope } from "./core-replay";
47
+ import {
48
+ ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
49
+ rotateAnthropicAccountOn429,
50
+ getAnthropicPoolAccessSnapshot,
51
+ formatAnthropicProviderForLog,
52
+ } from "../../oauth/anthropic-routing";
53
+ import {
54
+ GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
55
+ isGenericOAuthFailoverEnabled,
56
+ rotateGenericOAuthAccountOn429,
57
+ failoverAccountSnapshot,
58
+ } from "../../oauth/generic-account-failover";
59
+ import {
60
+ attemptOpaqueBlobRecovery,
61
+ consoleGoUploadRejectionBody,
62
+ CONSOLE_GO_UPLOAD_RETRY_DELAY_MS,
63
+ reasoningEffortRejectionText,
64
+ } from "./core-opaque-recovery";
65
+ import { shouldAttemptImageTierRetry } from "../image-retry";
66
+ import {
67
+ isTransientConsoleGoUploadRejection,
68
+ enrichOpenCodeZenUpstreamMessage,
69
+ } from "../../providers/opencode-zen-rate-limit";
70
+ import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
71
+ import { consumeComboFailure } from "./core-combo-failure";
72
+ import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
73
+ import { isFixedCodexAccount } from "./core-codex-account";
74
+ import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
75
+ import { isCyberPolicyCode, CYBER_POLICY_FALLBACK_MESSAGE, CYBER_POLICY_ERROR_CODE } from "../../lib/errors";
76
+ import { resolveClientRetryAfter } from "../../lib/retry-after";
77
+ import { cancelBodyOnAbort } from "../../lib/abort";
78
+
79
+ /** One responsibility of the Responses request pipeline; state owners are explicit. */
80
+ export async function prepareAdapterExchange(
81
+ requestContext: Pick<ResponsesRequestContext, "options" | "config" | "logCtx" | "req">,
82
+ admissionState: ResponsesAdmissionState,
83
+ requestState: Pick<
84
+ PreparedResponsesRequest,
85
+ | "parsed"
86
+ | "toolBridgeMaps"
87
+ | "translatorBudget"
88
+ | "selectedForwardHeaders"
89
+ | "route"
90
+ | "inboundWire"
91
+ | "clientRequestedStream"
92
+ | "subagentQuotaFailureModel"
93
+ | "subagentFallbackAccountId"
94
+ >,
95
+ transportState: Pick<
96
+ ResponsesTransport,
97
+ | "activeAdapter"
98
+ | "adapter"
99
+ | "sameTargetRequest"
100
+ | "sameTargetParsed"
101
+ | "sameTargetToken"
102
+ | "transportToken"
103
+ | "oauthDispatch"
104
+ | "imageTierBias"
105
+ | "isOAuth401ReplayProvider"
106
+ | "sentOAuthSnapshot"
107
+ | "refreshResolvedOAuthSelection"
108
+ | "replayOAuthCredentialSnapshot"
109
+ | "invalidateSameTargetRequest"
110
+ | "resolveSelectionAdapter"
111
+ | "anthropicPoolAccountId"
112
+ | "anthropicPoolFailovers"
113
+ | "anthropicSessionKey"
114
+ | "commitResolvedOAuthSelection"
115
+ | "genericFailoverAccountId"
116
+ | "genericFailovers"
117
+ | "applyFailoverSnapshot"
118
+ >,
119
+ responseEffects: Pick<ResponsesEffects, "cancelResponseCompletion" | "notifyResponseComplete" | "refreshRequestToolAliases">,
120
+ sendBudgetState: Pick<
121
+ ResponsesSendBudget,
122
+ | "adapterSendBudget"
123
+ | "noteAdapterPhysicalSend"
124
+ | "remainingTransientSendBudget"
125
+ | "noteTransientSends"
126
+ | "recoverySendAllowance"
127
+ | "recoveryClassFor"
128
+ | "sendBudgetExhausted"
129
+ | "reserveCredentialHop"
130
+ >,
131
+ ) {
132
+ const { options, config, logCtx, req } = requestContext;
133
+ const {
134
+ oauthDispatch,
135
+ isOAuth401ReplayProvider,
136
+ refreshResolvedOAuthSelection,
137
+ invalidateSameTargetRequest,
138
+ resolveSelectionAdapter,
139
+ anthropicSessionKey,
140
+ commitResolvedOAuthSelection,
141
+ applyFailoverSnapshot,
142
+ } = transportState;
143
+ const {
144
+ parsed,
145
+ toolBridgeMaps,
146
+ translatorBudget,
147
+ route,
148
+ inboundWire,
149
+ clientRequestedStream,
150
+ subagentQuotaFailureModel,
151
+ } = requestState;
152
+ const { cancelResponseCompletion, notifyResponseComplete, refreshRequestToolAliases } = responseEffects;
153
+ const {
154
+ adapterSendBudget,
155
+ noteAdapterPhysicalSend,
156
+ remainingTransientSendBudget,
157
+ noteTransientSends,
158
+ recoverySendAllowance,
159
+ recoveryClassFor,
160
+ sendBudgetExhausted,
161
+ reserveCredentialHop,
162
+ } = sendBudgetState;
163
+
164
+
165
+ const upstream = new AbortController();
166
+ const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
167
+ const connectMs = config.connectTimeoutMs ?? 200_000;
168
+ // Bridge stall budget (seconds of silence before upstream_stall_timeout); the retry backoff
169
+ // heartbeat interval is derived from it so the watchdog is always fed during deliberate waits.
170
+ const stallTimeoutMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0
171
+ ? Math.floor(config.stallTimeoutSec * 1000)
172
+ : 300_000;
173
+ transportState.activeAdapter = transportState.adapter;
174
+
175
+ // One immutable, body-safe outbound request per same-target sequence (URL, serialized body,
176
+ // auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the
177
+ // builder runs again only after a key/account/adapter rotation, an oauth refresh, or an
178
+ // image-tier bias change (transportToken bump). `body` is always a serialized string, so
179
+ // reuse is safe, and releaseBodyObservation is idempotent per build.
180
+ let initialRequest: AdapterRequest | undefined;
181
+ let inputTokenEstimate: number | undefined;
182
+ // An adapter may know the turn needs no inference at all — Kiro's replayed history ending in a
183
+ // delivered final answer. Answer it locally: no build (so no token estimate), no send (so
184
+ // sendCount stays 0), and crucially no empty-completion guard, which treats an outputless
185
+ // terminal as a failed turn and re-invokes the identical request. Routing this through the
186
+ // ordinary event path would therefore reinstate the loop it exists to end.
187
+ const localTerminal = transportState.activeAdapter.localTerminal?.(parsed);
188
+ if (localTerminal) {
189
+ logCtx.localTerminalReason = localTerminal.reason;
190
+ // Mark the physical attempt too, not just the parent row. `finishRequestAttempt` finalizes the
191
+ // attempt through the same estimated-provider path, so without this the row reads exact while
192
+ // its own attempt still claims an estimate — the detailed accounting a maintainer actually
193
+ // reads for a zero-send turn.
194
+ if (logCtx.activeAttempt) logCtx.activeAttempt.locallyAnswered = true;
195
+ cleanupUpstreamAbort();
196
+ upstream.abort();
197
+ const terminalEvents: AdapterEvent[] = [{
198
+ type: "done",
199
+ endTurn: true,
200
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
201
+ }];
202
+ if (parsed.stream) {
203
+ const localSse = bridgeToResponsesSSE(
204
+ (async function* () { yield* terminalEvents; })(),
205
+ parsed._responseModelId ?? parsed.modelId,
206
+ toolBridgeMaps.toolNsMap,
207
+ toolBridgeMaps.freeformToolNames,
208
+ toolBridgeMaps.toolSearchToolNames,
209
+ cancelResponseCompletion,
210
+ 2_000,
211
+ {
212
+ translatorBudget,
213
+ onCompletedResponse: notifyResponseComplete,
214
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
215
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
216
+ },
217
+ );
218
+ // Same lifetime tracking as every other streaming return in this function: the turn
219
+ // admission lease is released when the body finishes or the client disconnects. Returning
220
+ // the raw stream would hold a lease for a turn that already has all of its output.
221
+ const localTurnAc = new AbortController();
222
+ return new Response(
223
+ trackStreamLifetime(localSse, localTurnAc, undefined, options.turnAdmissionLease),
224
+ {
225
+ headers: {
226
+ "Content-Type": "text/event-stream",
227
+ "Cache-Control": "no-cache",
228
+ "Connection": "keep-alive",
229
+ "X-Accel-Buffering": "no",
230
+ },
231
+ },
232
+ );
233
+ }
234
+ const json = buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, { translatorBudget });
235
+ notifyResponseComplete(json);
236
+ return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
237
+ }
238
+ try {
239
+ initialRequest = await transportState.activeAdapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, translatorBudget });
240
+ refreshRequestToolAliases(initialRequest);
241
+ recordAdapterReasoning(logCtx, initialRequest);
242
+ recordAdapterTier(logCtx, initialRequest);
243
+ inputTokenEstimate = typeof initialRequest.usageLog?.inputTokens === "number"
244
+ ? initialRequest.usageLog.inputTokens
245
+ : undefined;
246
+ if (inputTokenEstimate !== undefined) logCtx.usageLogInputTokens = inputTokenEstimate;
247
+ } catch (err) {
248
+ // A throwing buildRequest never returned a request; if a post-build step threw, release
249
+ // the serialized-body observation (idempotent) so the translator budget is not leaked.
250
+ // The build runs after linkAbortSignal, so a failure must also tear the link down and
251
+ // abort the upstream controller instead of escaping handleResponses unmapped.
252
+ initialRequest?.releaseBodyObservation?.();
253
+ cleanupUpstreamAbort();
254
+ upstream.abort();
255
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
256
+ const msg = err instanceof Error ? err.message : String(err);
257
+ return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
258
+ }
259
+ // The catch path above always returns, so the request is definitely assigned here.
260
+ // Capture it in a const so the fetch callbacks read a narrowed, immutable value
261
+ // (TypeScript drops narrowing for a `let` captured by a nested function).
262
+ const builtInitialRequest = initialRequest;
263
+ transportState.sameTargetRequest = builtInitialRequest;
264
+ transportState.sameTargetParsed = parsed;
265
+ transportState.sameTargetToken = transportState.transportToken;
266
+ /**
267
+ * Invalidate the same-target request cache. Every credential/adapter/parsed mutation MUST
268
+ * go through here: the cache keys on `parsed` REFERENCE identity, so an in-place mutation
269
+ * is invisible to it and a missed bump would replay a request built with a stale key.
270
+ */
271
+
272
+ let upstreamResponse: Response;
273
+ try {
274
+ if (transportState.activeAdapter.fetchResponse) {
275
+ noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
276
+ await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
277
+ upstreamResponse = await transportState.activeAdapter.fetchResponse(builtInitialRequest, {
278
+ abortSignal: upstream.signal,
279
+ timeoutMs: connectMs,
280
+ sendBudget: adapterSendBudget,
281
+ onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
282
+ stream: parsed.stream,
283
+ executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
284
+ dispatchOverride: oauthDispatch(builtInitialRequest),
285
+ providerName: route.providerName,
286
+ modelId: route.modelId,
287
+ }),
288
+ });
289
+ } else {
290
+ // #1851 scope guard: transient-5xx retry on this generic adapter path is opt-in for
291
+ // direct Google AI Studio only (Vertex/Antigravity use fetchResponse above). Other
292
+ // adapters keep reset-only retry so combo failover still hops on the first 5xx
293
+ // instead of burning ~1.2s of same-target retries per hop.
294
+ // #2643: an opted-in key-auth openai-chat provider also gets transient-5xx retry. The
295
+ // legacy direct-Google exception is preserved exactly; every other adapter still keeps
296
+ // reset-only semantics so combo failover hops on the first 5xx.
297
+ const transientPolicy = transientRetryPolicyFor(route.provider);
298
+ const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy)
299
+ ? fetchWithTransientRetry
300
+ : fetchWithResetRetry;
301
+ upstreamResponse = await fetchWithRetryPolicy(
302
+ recovery => {
303
+ noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
304
+ return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
305
+ method: builtInitialRequest.method,
306
+ headers: builtInitialRequest.headers,
307
+ body: builtInitialRequest.body,
308
+ }, recovery), upstream.signal, connectMs, parsed.stream,
309
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
310
+ dispatchOverride: oauthDispatch(builtInitialRequest),
311
+ providerName: route.providerName,
312
+ modelId: route.modelId,
313
+ }));
314
+ },
315
+ {
316
+ abortSignal: upstream.signal,
317
+ label: safeHostLabel(builtInitialRequest.url),
318
+ ...(transientPolicy
319
+ // Draws the remainder, not the raw policy. A combo child inherits the parent's
320
+ // holder but used to take a fresh full allowance on its own first send, so the
321
+ // shared counter was inherited without ever being read as a limit.
322
+ ? {
323
+ attempts: remainingTransientSendBudget(transientPolicy.attempts),
324
+ onSendsConsumed: noteTransientSends,
325
+ }
326
+ : {}),
327
+ },
328
+ );
329
+ }
330
+ } catch (err) {
331
+ cleanupUpstreamAbort();
332
+ upstream.abort();
333
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
334
+ const msg = describeUpstreamConnectFailure(err, connectMs);
335
+ return formatErrorResponse(502, "upstream_error", msg);
336
+ } finally {
337
+ builtInitialRequest.releaseBodyObservation?.();
338
+ }
339
+
340
+ // Same-target 429 retry budget is per REQUEST: it lives OUTSIDE the recovery loop (so a 413/401
341
+ // replay that comes back 429 cannot silently re-arm a fresh budget) and is SHARED with the
342
+ // terminal-guard continuation below, so the main loop + one continuation can never exceed
343
+ // `attempts` same-key replays in total (bounded per request).
344
+ const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider);
345
+ let rateLimitRetries = 0;
346
+ // Shared with the terminal-guard continuation below: an image-tier reduction that let the
347
+ // main request clear a 413 must not be forgotten on the very next continuation build.
348
+ if (!upstreamResponse.ok) {
349
+ // Recovery loop: multi-key 429 failover + at most ONE opaque-state rebuild and ONE
350
+ // anthropic 413 tightened retry
351
+ // (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves
352
+ // both paths so a 429→413 sequence never rebuilds against a stale pre-rotation
353
+ // adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a
354
+ // 413→429 rotation cannot silently undo the tightening.
355
+ let imageRetryAttempted = false;
356
+ const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false };
357
+ // Console Go answers a transient 400 "Invalid upload request." for bodies it accepts
358
+ // moments later; at most one byte-identical replay is allowed per request.
359
+ const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false };
360
+ let oauth401ReplayAttempted = false;
361
+ // At most one reasoning-effort downgrade per request. This sits outside the recovery loop
362
+ // below for the same reason the two guards above do: a guard declared inside it is reset by
363
+ // every `continue recovery`, which would let one turn walk the whole ladder down.
364
+ const reasoningEffortDowngradeGuard: { attempted: boolean } = { attempted: false };
365
+ /**
366
+ * Rebuild the request from the current parsed input (and any image-tier bias) and refetch
367
+ * it once, tagging the attempt with the given recovery kind. Rebuilds are deterministic
368
+ * for the same parsed request, so same-target replays stay byte-identical.
369
+ */
370
+ const rebuildAndRefetch = async (
371
+ recovery: AttemptRecoveryKind,
372
+ /**
373
+ * Called at the dispatch boundary — after the request is rebuilt and shaped, immediately
374
+ * before the send. A caller holding a reserved hop confirms it here rather than before the
375
+ * rebuild, because a build failure returns `{ failed }` without ever reaching the wire and
376
+ * a permit confirmed earlier would keep the charge for a send that never happened.
377
+ */
378
+ onDispatch?: () => void,
379
+ ): Promise<Response | { failed: Response }> => {
380
+ let retryRequest: AdapterRequest;
381
+ if (transportState.sameTargetRequest !== undefined && transportState.sameTargetParsed === parsed && transportState.sameTargetToken === transportState.transportToken) {
382
+ // Same target (key/adapter/parsed/tier unchanged): replay the exact cached request.
383
+ retryRequest = transportState.sameTargetRequest;
384
+ } else {
385
+ try {
386
+ retryRequest = await transportState.activeAdapter.buildRequest(parsed, {
387
+ headers: requestState.selectedForwardHeaders,
388
+ translatorBudget,
389
+ ...(transportState.imageTierBias > 0 ? { imageTierBias: transportState.imageTierBias } : {}),
390
+ });
391
+ recordAdapterReasoning(logCtx, retryRequest);
392
+ recordAdapterTier(logCtx, retryRequest);
393
+ } catch (err) {
394
+ // A rotated/rebuilt adapter build failure is a request-shaping error, not an
395
+ // upstream connect failure: tear the abort link down and map it as 400 (no 413
396
+ // translator-budget mapping here — that stays with parseRequest/buildToolBridgeMaps).
397
+ cleanupUpstreamAbort();
398
+ upstream.abort();
399
+ if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() };
400
+ const msg = err instanceof Error ? err.message : String(err);
401
+ return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) };
402
+ }
403
+ transportState.sameTargetRequest = retryRequest;
404
+ transportState.sameTargetParsed = parsed;
405
+ transportState.sameTargetToken = transportState.transportToken;
406
+ }
407
+ refreshRequestToolAliases(retryRequest);
408
+ const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number"
409
+ ? retryRequest.usageLog.inputTokens
410
+ : undefined;
411
+ if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
412
+ logCtx.providerAdapter = transportState.activeAdapter.name;
413
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
414
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
415
+ noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
416
+ try {
417
+ try {
418
+ if (transportState.activeAdapter.fetchResponse) {
419
+ await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
420
+ // The dispatch boundary is HERE, not before the pacing wait: that wait can reject for
421
+ // an abort, a saturated queue, an expired slot or a removed provider, and none of
422
+ // those reach the wire. Confirming earlier would hold the charge for a send that the
423
+ // pacer refused.
424
+ onDispatch?.();
425
+ return await transportState.activeAdapter.fetchResponse(retryRequest, {
426
+ abortSignal: upstream.signal,
427
+ timeoutMs: connectMs,
428
+ sendBudget: adapterSendBudget,
429
+ onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send),
430
+ stream: parsed.stream,
431
+ executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
432
+ dispatchOverride: oauthDispatch(retryRequest),
433
+ providerName: route.providerName,
434
+ modelId: route.modelId,
435
+ }),
436
+ });
437
+ }
438
+ // #2643 review: this leg used to call fetchWithHeaderTimeout directly, so an
439
+ // opted-in provider's transient-5xx policy applied to the initial send and to
440
+ // native chat but was silently bypassed here — a 429 that recovered into a
441
+ // retryable 503 got no retry on the Responses path. Route it through the same
442
+ // selection, and pass what is LEFT of the request-scoped budget rather than a
443
+ // fresh one, so a recovery loop cannot multiply total upstream sends.
444
+ const refetchTransientPolicy = transientRetryPolicyFor(route.provider);
445
+ const refetchWithPolicy = (route.provider.adapter === "google" || refetchTransientPolicy)
446
+ ? fetchWithTransientRetry
447
+ : fetchWithResetRetry;
448
+ // Same rule as the passthrough rebuild: spend the base allowance first, then the one
449
+ // shared final-recovery reserve, so a recovery that follows a spent streak still gets
450
+ // its single send instead of dying at three.
451
+ const refetchAllowance = refetchTransientPolicy
452
+ ? recoverySendAllowance(
453
+ refetchTransientPolicy.attempts,
454
+ recoveryClassFor(recovery),
455
+ `${route.providerName}|${route.modelId}|${recovery}`,
456
+ )
457
+ : undefined;
458
+ try {
459
+ return await refetchWithPolicy(
460
+ recoveryKind => {
461
+ if (refetchAllowance?.permit && !refetchAllowance.permit.use()) {
462
+ throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url));
463
+ }
464
+ // Same boundary on the helper path: the thunk is what reaches the wire, and it
465
+ // can be refused above before it does. use() past the first attempt is a no-op.
466
+ onDispatch?.();
467
+ return fetchWithHeaderTimeout(retryRequest.url,
468
+ applyUpstreamRecoveryInit({
469
+ method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
470
+ }, recoveryKind), upstream.signal, connectMs, parsed.stream,
471
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
472
+ dispatchOverride: oauthDispatch(retryRequest),
473
+ providerName: route.providerName,
474
+ modelId: route.modelId,
475
+ }));
476
+ },
477
+ {
478
+ abortSignal: upstream.signal,
479
+ label: safeHostLabel(retryRequest.url),
480
+ ...(refetchAllowance
481
+ ? {
482
+ attempts: refetchAllowance.attempts,
483
+ onSendsConsumed: noteTransientSends,
484
+ }
485
+ : {}),
486
+ },
487
+ );
488
+ } finally {
489
+ // Refunds only a reservation whose send never happened -- an abort settled before
490
+ // the thunk ran. A used or externally settled permit ignores this.
491
+ refetchAllowance?.permit?.release();
492
+ }
493
+ } finally {
494
+ retryRequest.releaseBodyObservation?.();
495
+ }
496
+ } catch (err) {
497
+ cleanupUpstreamAbort();
498
+ upstream.abort();
499
+ if (options.abortSignal?.aborted) {
500
+ return { failed: clientCancelledResponse() };
501
+ }
502
+ const msg = describeUpstreamConnectFailure(err, connectMs);
503
+ return { failed: formatErrorResponse(502, "upstream_error", msg) };
504
+ }
505
+ };
506
+ // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
507
+ recovery: for (;;) {
508
+ if (
509
+ upstreamResponse.status === 401
510
+ && isOAuth401ReplayProvider
511
+ && transportState.sentOAuthSnapshot
512
+ && !oauth401ReplayAttempted
513
+ && !sendBudgetExhausted()
514
+ ) {
515
+ oauth401ReplayAttempted = true;
516
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
517
+ let refreshed: OAuthAccessSnapshot;
518
+ try {
519
+ refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot);
520
+ } catch (err) {
521
+ cleanupUpstreamAbort();
522
+ return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
523
+ }
524
+ if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) {
525
+ cleanupUpstreamAbort();
526
+ return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required")));
527
+ }
528
+ transportState.sentOAuthSnapshot = refreshed;
529
+ transportState.replayOAuthCredentialSnapshot = {
530
+ accountId: refreshed.accountId,
531
+ generation: refreshed.generation,
532
+ };
533
+ if (route.providerName === "kiro") {
534
+ parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
535
+ }
536
+ const refreshedProvider = resolveProviderTransport(
537
+ route.providerName,
538
+ {
539
+ ...route.provider,
540
+ apiKey: refreshed.accessToken,
541
+ ...(refreshed.projectId ? { project: refreshed.projectId } : {}),
542
+ },
543
+ parsed.options.promptCacheKey,
544
+ route.providerName === "github-copilot"
545
+ ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl)
546
+ : undefined,
547
+ );
548
+ route.provider = refreshedProvider;
549
+ invalidateSameTargetRequest();
550
+ transportState.activeAdapter = resolveSelectionAdapter(
551
+ resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
552
+ config.cacheRetention,
553
+ );
554
+ bindRouteReasoningReplayScope({
555
+ parsed,
556
+ providerName: route.providerName,
557
+ provider: refreshedProvider,
558
+ adapterName: transportState.activeAdapter.name,
559
+ oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot,
560
+ });
561
+ const result = await rebuildAndRefetch("oauth-401");
562
+ if ("failed" in result) return result.failed;
563
+ upstreamResponse = result;
564
+ continue recovery;
565
+ }
566
+
567
+ // Static API-key pools can recover a credential-scoped 401 without abandoning the
568
+ // provider: one revoked or mistyped key says nothing about its siblings. OAuth providers
569
+ // refresh above and never enter here — `hasKeyPoolFailover` rejects oauth/forward modes.
570
+ // Runs after the OAuth replay so a refreshable token is never treated as a dead key.
571
+ while (upstreamResponse.status === 401 && hasKeyPoolFailover(route.provider)) {
572
+ const rotated = rotateProviderTransportOn401(config, route.providerName, route.provider, {
573
+ now: Date.now(),
574
+ attemptedKey: route.provider.apiKey,
575
+ promptCacheKey: parsed.options.promptCacheKey,
576
+ });
577
+ if (!rotated) break;
578
+ // Release the failed response's socket before retrying; unread bodies otherwise linger
579
+ // until runtime cleanup (one per rotated key).
580
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
581
+ route.provider = rotated;
582
+ invalidateSameTargetRequest();
583
+ transportState.activeAdapter = resolveSelectionAdapter(
584
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
585
+ config.cacheRetention,
586
+ );
587
+ bindRouteReasoningReplayScope({
588
+ parsed,
589
+ providerName: route.providerName,
590
+ provider: route.provider,
591
+ adapterName: transportState.activeAdapter.name,
592
+ });
593
+ const result = await rebuildAndRefetch("key-401");
594
+ if ("failed" in result) return result.failed;
595
+ upstreamResponse = result;
596
+ }
597
+
598
+ // Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
599
+ // 429 itself (it retries 5xx only), and single-key pools cannot use the failover below,
600
+ // so wait (Retry-After or the fixed interval) and replay the IDENTICAL request on the
601
+ // same key first. Pre-stream only: a 429 arrives before any bytes are relayed, so the
602
+ // replay is lossless. Runs before key failover so "primary-first" setups keep the same
603
+ // key on rate-limit blips; only after the attempts are exhausted does failover run.
604
+ while (
605
+ upstreamResponse.status === 429
606
+ && rateLimitPolicy !== null
607
+ && rateLimitRetries < rateLimitPolicy.attempts
608
+ && !sendBudgetExhausted()
609
+ ) {
610
+ rateLimitRetries += 1;
611
+ // Release unread body + deliberate wait via the shared same-target helper.
612
+ const retryAfterHeader = upstreamResponse.headers.get("retry-after");
613
+ try {
614
+ for await (const _ of prepareSameTarget429Wait({
615
+ body: upstreamResponse.body,
616
+ signal: options.abortSignal,
617
+ delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
618
+ })) {
619
+ // pre-stream: no stall watchdog to feed
620
+ }
621
+ } catch {
622
+ cleanupUpstreamAbort();
623
+ upstream.abort();
624
+ return clientCancelledResponse();
625
+ }
626
+ // Client cancellation wins over any stale timer edge: re-check before dispatching the
627
+ // replay so an adapter never starts work for a request the client already abandoned.
628
+ if (options.abortSignal?.aborted || upstream.signal.aborted) {
629
+ cleanupUpstreamAbort();
630
+ upstream.abort();
631
+ return clientCancelledResponse();
632
+ }
633
+ const result = await rebuildAndRefetch("rate-limit-429");
634
+ if ("failed" in result) return result.failed;
635
+ upstreamResponse = result;
636
+ }
637
+
638
+ // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
639
+ // SAME request once per remaining key. OAuth/forward providers and single-key pools
640
+ // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
641
+ while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
642
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
643
+ retryAfter: upstreamResponse.headers.get("retry-after"),
644
+ now: Date.now(),
645
+ attemptedKey: route.provider.apiKey,
646
+ promptCacheKey: parsed.options.promptCacheKey,
647
+ });
648
+ if (!rotated) break;
649
+ // Release the failed response's socket before retrying; unread bodies otherwise linger
650
+ // until runtime cleanup (one per rotated key under a rate-limit storm).
651
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
652
+ route.provider = rotated;
653
+ invalidateSameTargetRequest();
654
+ transportState.activeAdapter = resolveSelectionAdapter(
655
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
656
+ config.cacheRetention,
657
+ );
658
+ bindRouteReasoningReplayScope({
659
+ parsed,
660
+ providerName: route.providerName,
661
+ provider: route.provider,
662
+ adapterName: transportState.activeAdapter.name,
663
+ });
664
+ const result = await rebuildAndRefetch("key-429");
665
+ if ("failed" in result) return result.failed;
666
+ upstreamResponse = result;
667
+ }
668
+
669
+ // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
670
+ // with another eligible OAuth account (bounded per request). Disabled by default.
671
+ while (
672
+ upstreamResponse.status === 429
673
+ && transportState.anthropicPoolAccountId
674
+ && transportState.anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
675
+ ) {
676
+ const nextAccountId = rotateAnthropicAccountOn429(
677
+ config,
678
+ transportState.anthropicPoolAccountId,
679
+ upstreamResponse.headers.get("retry-after"),
680
+ anthropicSessionKey,
681
+ Date.now(),
682
+ upstreamResponse.headers,
683
+ );
684
+ if (!nextAccountId) break;
685
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
686
+ try {
687
+ const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId));
688
+ if (!admitted) throw new Error("OAuth selection changed during recovery");
689
+ transportState.anthropicPoolAccountId = admitted.accountId;
690
+ transportState.anthropicPoolFailovers += 1;
691
+ route.provider = { ...route.provider, apiKey: admitted.accessToken };
692
+ invalidateSameTargetRequest();
693
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config);
694
+ transportState.activeAdapter = resolveSelectionAdapter(
695
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
696
+ config.cacheRetention,
697
+ );
698
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
699
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
700
+ const result = await rebuildAndRefetch("anthropic-oauth-429");
701
+ if ("failed" in result) return result.failed;
702
+ upstreamResponse = result;
703
+ } catch {
704
+ break;
705
+ }
706
+ }
707
+ // Generic OAuth account failover (#2568) for providers with no pool of their own.
708
+ // Presence is consent since #2568d: rotation is ON by default once two or more eligible
709
+ // accounts are stored for the provider, because a second deliberate login is read as the
710
+ // operator asking for it. A single-account install is still a strict no-op, and an
711
+ // explicit `oauthAccountFailover.enabled: false` (global or per provider) still wins --
712
+ // see isGenericOAuthFailoverEnabled in src/oauth/generic-account-failover.ts. Codex and
713
+ // Anthropic are excluded by isGenericFailoverProvider: their pools own quota scopes,
714
+ // probe leases and affinity that this must not reimplement.
715
+ while (
716
+ upstreamResponse.status === 429
717
+ && transportState.genericFailoverAccountId
718
+ && transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
719
+ && isGenericOAuthFailoverEnabled(config, route.providerName)
720
+ ) {
721
+ // Intersection with the shared request budget. This arm re-sends through
722
+ // rebuildAndRefetch, so the roster cap alone would let one request walk the roster on
723
+ // an allowance the rest of the request cannot see. A refusal ends the ladder with the
724
+ // real 429 already in hand, which is the decided exhaustion contract.
725
+ const hop = reserveCredentialHop(
726
+ "auth-recovery",
727
+ `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
728
+ );
729
+ if (!hop.allowed) break;
730
+ const nextAccountId = rotateGenericOAuthAccountOn429(
731
+ config,
732
+ route.providerName,
733
+ transportState.genericFailoverAccountId,
734
+ upstreamResponse.headers.get("retry-after"),
735
+ );
736
+ if (!nextAccountId) {
737
+ hop.permit?.release();
738
+ break;
739
+ }
740
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
741
+ try {
742
+ // The FULL snapshot, not just the bearer: Antigravity pairs an account-matched
743
+ // projectId with its token and Kiro carries routing metadata, so a token-only swap
744
+ // would mix one account's credential with another's routing data.
745
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
746
+ transportState.genericFailovers += 1;
747
+ if (!await applyFailoverSnapshot(snapshot)) {
748
+ hop.permit?.release();
749
+ break;
750
+ }
751
+ invalidateSameTargetRequest();
752
+ transportState.activeAdapter = resolveSelectionAdapter(
753
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
754
+ config.cacheRetention,
755
+ );
756
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
757
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
758
+ // Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
759
+ // request and return `{ failed }` without reaching the wire, and a permit confirmed
760
+ // before that would hold the charge for a send that never happened.
761
+ const result = await rebuildAndRefetch("oauth-account-429", () => { hop.permit?.use(); });
762
+ if ("failed" in result) {
763
+ // A no-op if the boundary was reached; a refund if the rebuild died before it.
764
+ hop.permit?.release();
765
+ return result.failed;
766
+ }
767
+ upstreamResponse = result;
768
+ } catch {
769
+ // A throw before the send — snapshot fetch, credential application, adapter
770
+ // resolution — must hand the reservation back. Without this the ladder charges the
771
+ // request for a send it never made, and a later recovery in the same request is
772
+ // refused on an allowance nothing spent. release() is idempotent and a no-op once
773
+ // used, so a throw from the rebuild keeps its charge.
774
+ hop.permit?.release();
775
+ break;
776
+ }
777
+ }
778
+ // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry,
779
+ // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is
780
+ // the missing authoritative signal. Rebuild once through the same sanitation path used by a
781
+ // known route switch; invalidating is mandatory because `parsed` mutates in place and the
782
+ // same-target cache would otherwise replay the rejected bytes verbatim.
783
+ const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({
784
+ response: upstreamResponse,
785
+ outboundBody: transportState.sameTargetRequest?.body,
786
+ adapterName: transportState.activeAdapter.name,
787
+ parsed,
788
+ guard: opaqueBlobRecoveryGuard,
789
+ signal: upstream.signal,
790
+ }, recovery => {
791
+ invalidateSameTargetRequest();
792
+ return rebuildAndRefetch(recovery);
793
+ });
794
+ if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response;
795
+ if (opaqueBlobRecovery.kind === "recovered") {
796
+ upstreamResponse = opaqueBlobRecovery.response;
797
+ continue recovery;
798
+ }
799
+ // Anthropic 413 request_too_large: rebuild once with every image one tier lower
800
+ // (spiral guard: single attempt). The biased response re-enters the 429 check above.
801
+ if (shouldAttemptImageTierRetry({
802
+ status: upstreamResponse.status,
803
+ adapterName: transportState.activeAdapter.name,
804
+ parsed,
805
+ alreadyAttempted: imageRetryAttempted,
806
+ })) {
807
+ imageRetryAttempted = true;
808
+ transportState.imageTierBias = 1;
809
+ invalidateSameTargetRequest();
810
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
811
+ const result = await rebuildAndRefetch("image-413");
812
+ if ("failed" in result) return result.failed;
813
+ upstreamResponse = result;
814
+ continue recovery;
815
+ }
816
+ // Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds
817
+ // later with 400 invalid_request_error / "Invalid upload request." Replay the
818
+ // byte-identical request once after the exact gateway rejection.
819
+ if (!consoleGoUploadRetryGuard.attempted) {
820
+ const uploadRejectionBody = await consoleGoUploadRejectionBody(
821
+ upstreamResponse,
822
+ consoleGoUploadRetryGuard.attempted,
823
+ upstream.signal,
824
+ );
825
+ if (uploadRejectionBody !== undefined
826
+ && isTransientConsoleGoUploadRejection({
827
+ status: upstreamResponse.status,
828
+ errorBody: uploadRejectionBody,
829
+ outboundUrl: transportState.sameTargetRequest?.url,
830
+ })) {
831
+ consoleGoUploadRetryGuard.attempted = true;
832
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
833
+ if (!upstream.signal.aborted) {
834
+ try {
835
+ await sleepWithAbort(CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, upstream.signal);
836
+ } catch { cleanupUpstreamAbort(); return clientCancelledResponse(); }
837
+ }
838
+ if (upstream.signal.aborted) { cleanupUpstreamAbort(); return clientCancelledResponse(); }
839
+ const result = await rebuildAndRefetch("console-go-upload-retry");
840
+ if ("failed" in result) return result.failed;
841
+ upstreamResponse = result;
842
+ continue recovery;
843
+ }
844
+ }
845
+ // Reasoning-effort downgrade, mirroring the passthroughRecovery loop above: learn the
846
+ // refused rung, then replay once at the next published one.
847
+ if (!reasoningEffortDowngradeGuard.attempted) {
848
+ const rejectionText = await reasoningEffortRejectionText(
849
+ upstreamResponse,
850
+ reasoningEffortDowngradeGuard.attempted,
851
+ upstream.signal,
852
+ );
853
+ const downgrade = rejectionText === undefined
854
+ ? undefined
855
+ : planReasoningEffortDowngrade({
856
+ provider: route.provider,
857
+ modelId: parsed.modelId,
858
+ requested: parsed.options.reasoning,
859
+ rejectionText,
860
+ });
861
+ if (downgrade) {
862
+ reasoningEffortDowngradeGuard.attempted = true;
863
+ parsed.options.reasoning = downgrade.effort;
864
+ // The same-target cache keys on parsed identity, so a mutated effort needs a token bump.
865
+ invalidateSameTargetRequest();
866
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
867
+ const result = await rebuildAndRefetch("reasoning-effort-downgrade");
868
+ if ("failed" in result) return result.failed;
869
+ upstreamResponse = result;
870
+ continue recovery;
871
+ }
872
+ }
873
+ break;
874
+ }
875
+ if (!upstreamResponse.ok) {
876
+ if (options.comboAttempt) {
877
+ // No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads
878
+ // `response.body` itself with the abort signal threaded through, and the combo
879
+ // contract is that this body's getter is touched exactly once. A guard here would be
880
+ // a second `.body` access for no gain, since the bounded reader owns settlement.
881
+ const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
882
+ .finally(cleanupUpstreamAbort);
883
+ options.onConsumedComboFailure?.(failure);
884
+ return failure.response;
885
+ }
886
+ let errorText: string;
887
+ try {
888
+ errorText = await readDisplaySafeErrorText(
889
+ upstreamResponse,
890
+ upstream.signal,
891
+ "unknown error",
892
+ );
893
+ } finally {
894
+ cleanupUpstreamAbort();
895
+ }
896
+ if (upstreamResponse.status === 413) {
897
+ return clientRequestedStream
898
+ ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget)
899
+ : jsonContextOverflowResponse();
900
+ }
901
+ if (!isFixedCodexAccount(admissionState.authCtx)) {
902
+ recordSubagentQuotaFailureForThreadSpawn(
903
+ req.headers,
904
+ subagentQuotaFailureModel,
905
+ upstreamResponse.status === 429 || upstreamResponse.status === 402
906
+ ? upstreamResponse.status
907
+ : `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
908
+ config,
909
+ requestState.subagentFallbackAccountId,
910
+ );
911
+ }
912
+ // Upstreams occasionally echo request details in error bodies — scrub token-shaped
913
+ // material before it reaches the client-facing error surface.
914
+ const upstreamRetryAfter = upstreamResponse.headers.get("retry-after");
915
+ const normalized = normalizeUpstreamErrorText(errorText, "unknown error");
916
+ const message = normalized.cyberPolicy
917
+ ? normalized.message
918
+ ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText)
919
+ : enrichOpenCodeZenUpstreamMessage(
920
+ `Provider error ${upstreamResponse.status}: ${normalized.safeText}`,
921
+ {
922
+ status: upstreamResponse.status,
923
+ providerName: route.providerName,
924
+ baseUrl: route.provider.baseUrl,
925
+ adapter: route.provider.adapter,
926
+ authMode: route.provider.authMode,
927
+ hasApiKey: Boolean(route.provider.apiKey?.trim()),
928
+ upstreamRetryAfter,
929
+ // This recovery path is the HTTP Responses wire; custom runTurn transports
930
+ // never reach enrichOpenCodeZenUpstreamMessage here.
931
+ supportsHttpSameKeyRetry: true,
932
+ },
933
+ );
934
+ const retryAfter = normalized.cyberPolicy
935
+ ? undefined
936
+ : resolveClientRetryAfter({
937
+ status: upstreamResponse.status,
938
+ message,
939
+ upstreamRetryAfter,
940
+ });
941
+ return formatErrorResponse(
942
+ upstreamResponse.status,
943
+ normalized.cyberPolicy ? (normalized.type ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
944
+ message,
945
+ {
946
+ ...(normalized.cyberPolicy ? { code: CYBER_POLICY_ERROR_CODE } : {}),
947
+ ...(retryAfter !== undefined ? { retryAfter } : {}),
948
+ },
949
+ );
950
+ }
951
+ }
952
+
953
+ cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
954
+
955
+ return {
956
+ upstream,
957
+ cleanupUpstreamAbort,
958
+ connectMs,
959
+ stallTimeoutMs,
960
+ upstreamResponse,
961
+ rateLimitPolicy,
962
+ get rateLimitRetries(): typeof rateLimitRetries {
963
+ return rateLimitRetries;
964
+ },
965
+ set rateLimitRetries(value: typeof rateLimitRetries) {
966
+ rateLimitRetries = value;
967
+ },
968
+ };
969
+ }
970
+
971
+ export type AdapterExchange = Exclude<Awaited<ReturnType<typeof prepareAdapterExchange>>, Response>;