@bitkyc08/opencodex 2.55.0 → 2.57.0

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