@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused } from "../oauth/account-auto-pause";
1
2
  import { createPhysicalSendReporter } from "../lib/request-execution-budget";
2
3
  import { createInferenceSendBudget } from "./inference/context";
3
4
  /**
@@ -120,7 +121,7 @@ import {
120
121
  recordKeyWireAttemptUsage,
121
122
  type RequestLogContext,
122
123
  } from "./request-log";
123
- import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
124
+ import { fetchWithHeaderTimeout, physicalSendObserverInit, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
124
125
  import { anthropicBillingPhysicalFetch } from "../providers/anthropic-billing-ownership";
125
126
  import { rotateAnthropicBillingKeyOnResponse } from "../providers/anthropic-billing-recovery";
126
127
  import { linkAbortSignal } from "./responses/core-lifetime";
@@ -545,6 +546,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
545
546
  pendingDistributedFailure = undefined;
546
547
  record?.();
547
548
  };
549
+ /** Send the native Messages request under the transient retry policy, re-validating the selected OAuth account or API key at the dispatch boundary. */
548
550
  const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
549
551
  const remaining = remainingTransientSends();
550
552
  if (requestTransientPolicy && remaining <= 0) {
@@ -616,6 +618,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
616
618
  }
617
619
  const releaseFamily = snapshot && oauthBinding ? anthropicModelQuotaFor(oauthBinding.instance).claimAnthropicFamilyRevalidation(snapshot.accountId, route.modelId) : () => {};
618
620
  if (!releaseFamily) throw new AnthropicAccountCooldownError(1);
621
+ const observeAutoPause = captureAccountAutoPauseAttempt(route.providerName, activeProvider, { config, oauth: sendingBinding?.snapshot }, headers);
619
622
  const selection = activeProvider._apiKeyAttempt;
620
623
  let dispatched: Response;
621
624
  const spendReport = createPhysicalSendReporter(physicalBudget, () => ({
@@ -641,7 +644,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
641
644
  anthropicBillingPhysicalFetch((activeProvider as OcxProviderTransport).fetch ?? execute, route.providerName, activeProvider),
642
645
  route.providerName, activeProvider, wire.url, activeProvider.apiKeyTransport === "bearer" ? "authorization" : "x-api-key"),
643
646
  wire.url,
644
- applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery),
647
+ applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body, ...physicalSendObserverInit(observeAutoPause, wire.url) }, transportRecovery),
645
648
  { providerName: route.providerName, provider: activeProvider },
646
649
  );
647
650
  } finally {
@@ -659,7 +662,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
659
662
  && credentialGeneration(current.credential) === snapshot.generation
660
663
  && current.credential.accountId === providerAccountUuid) {
661
664
  bindAnthropicRefusalCredentialForSend(dispatched, sendOwner, providerAccountUuid);
662
- recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId);
665
+ recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId, snapshot.generation);
663
666
  }
664
667
  } catch { /* Passive observation must not fail the response. */ }
665
668
  }
@@ -672,7 +675,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
672
675
  ? () => { recordDistributedResponseFailure(dispatched, config, route.providerName, req.signal); }
673
676
  : undefined;
674
677
  if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
675
- return dispatched;
678
+ return observeAutoPause ? observeAutoPause(dispatched) : dispatched;
676
679
  },
677
680
  }),
678
681
  ),
@@ -700,9 +703,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
700
703
  let billingRecoveryActive = false;
701
704
  const keySendAvailable = () => transientSendAvailable()
702
705
  && (!billingRecoveryActive || Math.max(physicalSendsUsed, physicalBudget.used) < physicalBudget.policy.maxTotalModelSends);
706
+ let autoPauseMoves = 0;
703
707
  const recoveryPasses = activeProvider._apiKeyAttempt?.distributed ? activeProvider.apiKeyPool?.length ?? 1 : 1;
704
708
  for (let pass = 0; ; pass++) {
705
709
  const priorResponse = response;
710
+ for (; wasAccountAutoPaused(response) && !oauthBinding && config.providers[route.providerName]?.apiKey && autoPauseMoves < 3 && keySendAvailable(); autoPauseMoves++) {
711
+ const fresh = resolveCurrentProviderApiKeyTransport(config, route.providerName, { ...activeProvider, _apiKeyAttempt: undefined });
712
+ const next = fresh && (selectProactiveApiKeyTransport(config, route.providerName, fresh) ?? fresh);
713
+ if (!next) break;
714
+ try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ }
715
+ rebuildFor(next);
716
+ response = await send("key-401");
717
+ }
718
+
706
719
  // A credential-scoped 401 on a static key pool says nothing about the sibling keys.
707
720
  while (response.status === 401 && distributedResponseOwnsKey && hasKeyPoolFailover(activeProvider) && keySendAvailable()) {
708
721
  const rotated = rotateProviderTransportOn401(config, route.providerName, activeProvider, {
@@ -762,6 +775,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
762
775
  if (response === priorResponse || isNonReplayableResponse(response) || !keySendAvailable()) break;
763
776
  // Billing class transitions share the physical-send ceiling; distributed-only
764
777
  // transitions additionally retain their original bounded pool-pass allowance.
778
+ if (wasAccountAutoPaused(response) && autoPauseMoves < 3) continue;
765
779
  if (distributedResponseOwnsKey && (
766
780
  (billingRecoveryActive && [401, 429].includes(response.status))
767
781
  || (activeProvider.adapter === "anthropic" && hasKeyPoolFailover(activeProvider) && [400, 402].includes(response.status))
@@ -835,6 +849,17 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
835
849
  }
836
850
  releaseRetainedRequest();
837
851
 
852
+ // The last pool account refused on spent quota and rotation found no replacement: answer the
853
+ // usage limit with its reset (#6991), as the bridge lane does, not the upstream "try again later".
854
+ const poolUsageLimit = response.status === 429 && oauthBinding
855
+ ? anthropicRoutingFor(nativeInstance!).anthropicPoolUsageLimitAfterRefusal(Date.now(), oauthBinding.routeDecision, route.modelId)
856
+ : null;
857
+ if (poolUsageLimit) {
858
+ discard(response);
859
+ cleanupAbort();
860
+ logCtx.errorCode = "usage_limit_reached";
861
+ return retainUpstreamMessagesRequestId(localOAuthFailure(poolUsageLimit)!, response.headers);
862
+ }
838
863
  if (!response.ok) {
839
864
  let bodyText = "";
840
865
  try {
@@ -13,6 +13,7 @@
13
13
  * other.
14
14
  */
15
15
  import { sniffImageDimensions } from "../adapters/anthropic-image-guard";
16
+ import { anthropicCacheWrite1hTokens } from "../adapters/anthropic/cache-retention";
16
17
  import { createToolCallIdAllocator } from "../adapters/tool-call-id";
17
18
  import { idleDeadline } from "../lib/abort";
18
19
  import {
@@ -29,12 +30,13 @@ import type { RequestLogContext } from "./request-log";
29
30
  import { recordGenerationEvent } from "./request-log-generation-window";
30
31
  import type { OcxConfig } from "../types";
31
32
 
32
- export function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined {
33
+ export function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number; cacheCreation1hInputTokens?: number } | undefined {
33
34
  if (!usage) return undefined;
34
35
  const num = (v: unknown) => typeof v === "number" ? v : 0;
35
36
  const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined;
36
37
  const read = num(usage.cache_read_input_tokens);
37
38
  const write = num(usage.cache_creation_input_tokens);
39
+ const write1h = anthropicCacheWrite1hTokens(usage, write);
38
40
  // Anthropic input_tokens excludes cache read/write; normalize to the canonical
39
41
  // inclusive convention (types.ts OcxUsage / devlog 070). cached = READS only.
40
42
  return {
@@ -44,6 +46,7 @@ export function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: numb
44
46
  cachedInputTokens: read,
45
47
  cacheReadInputTokens: read,
46
48
  cacheCreationInputTokens: write,
49
+ ...(write1h !== undefined ? { cacheCreation1hInputTokens: write1h } : {}),
47
50
  } : {}),
48
51
  };
49
52
  }
@@ -982,7 +982,7 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
982
982
  const raw = usage as {
983
983
  input_tokens?: unknown;
984
984
  output_tokens?: unknown;
985
- input_tokens_details?: { cached_tokens?: unknown; cache_write_tokens?: unknown };
985
+ input_tokens_details?: { cached_tokens?: unknown; cache_write_tokens?: unknown; cache_write_1h_tokens?: unknown };
986
986
  output_tokens_details?: { reasoning_tokens?: unknown };
987
987
  total_tokens?: unknown;
988
988
  prompt_tokens?: unknown;
@@ -1004,6 +1004,9 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
1004
1004
  ...(typeof raw.input_tokens_details?.cache_write_tokens === "number"
1005
1005
  ? { cacheCreationInputTokens: raw.input_tokens_details.cache_write_tokens }
1006
1006
  : {}),
1007
+ ...(typeof raw.input_tokens_details?.cache_write_1h_tokens === "number"
1008
+ ? { cacheCreation1hInputTokens: raw.input_tokens_details.cache_write_1h_tokens }
1009
+ : {}),
1007
1010
  ...(typeof raw.output_tokens_details?.reasoning_tokens === "number"
1008
1011
  ? { reasoningOutputTokens: raw.output_tokens_details.reasoning_tokens }
1009
1012
  : {}),
@@ -1717,8 +1720,10 @@ export function noteProviderAttemptSend(
1717
1720
  if (attempt && attempt.sendCount > 0 && attemptAccountChanged(previous, next, attempt.provider, logCtx.provider)) {
1718
1721
  // An input estimate is not evidence that a failed send used that many tokens.
1719
1722
  delete attempt.inputTokenEstimate;
1723
+ // An account-scoped recovery kind names the status the sealed account answered.
1720
1724
  finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
1721
- : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502,
1725
+ : recovery === "key-401" || recovery === "oauth-401" ? 401 : recovery === "oauth-account-403" ? 403
1726
+ : recovery === "oauth-account-402" ? 402 : recovery?.includes("429") ? 429 : 502,
1722
1727
  Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage);
1723
1728
  // This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind
1724
1729
  // is direct evidence here rather than an inference from history. Without this the sealed
@@ -1847,16 +1852,20 @@ export function finishRequestAttempt(
1847
1852
  export function aggregateAttemptUsage(
1848
1853
  attempts: readonly PersistedUsageAttempt[],
1849
1854
  ): FinalizedUsageResult {
1850
- const status: UsageStatus = attempts.length > 0
1851
- && attempts.every(attempt => attempt.usageStatus === "unsupported")
1855
+ // A usage-less attempt that never reached upstream spent nothing (summary.ts `spentNothing`),
1856
+ // so it cannot leave the row's usage unknown.
1857
+ const measured = attempts.filter(attempt => attempt.usage
1858
+ || (attempt.sendCount !== 0 && attempt.locallyAnswered !== true));
1859
+ const status: UsageStatus = measured.length > 0
1860
+ && measured.every(attempt => attempt.usageStatus === "unsupported")
1852
1861
  ? "unsupported"
1853
- : attempts.some(attempt => (
1862
+ : measured.some(attempt => (
1854
1863
  attempt.usageStatus === "unreported" || attempt.usageStatus === "unsupported"
1855
1864
  ))
1856
1865
  ? "unreported"
1857
- : attempts.some(attempt => attempt.usageStatus === "estimated")
1866
+ : measured.some(attempt => attempt.usageStatus === "estimated")
1858
1867
  ? "estimated"
1859
- : attempts.length > 0
1868
+ : measured.length > 0
1860
1869
  ? "reported"
1861
1870
  : "unreported";
1862
1871
 
@@ -1864,7 +1873,7 @@ export function aggregateAttemptUsage(
1864
1873
  if (usages.length === 0) return { status };
1865
1874
 
1866
1875
  const sumOptional = (
1867
- key: "cachedInputTokens" | "cacheReadInputTokens" | "cacheCreationInputTokens"
1876
+ key: "cachedInputTokens" | "cacheReadInputTokens" | "cacheCreationInputTokens" | "cacheCreation1hInputTokens"
1868
1877
  | "reasoningOutputTokens" | "providerCredits",
1869
1878
  ): number | undefined => {
1870
1879
  const present = usages.flatMap(usage => (
@@ -1873,8 +1882,12 @@ export function aggregateAttemptUsage(
1873
1882
  return present.length > 0 ? present.reduce((sum, value) => sum + value, 0) : undefined;
1874
1883
  };
1875
1884
  const cachedInputTokens = sumOptional("cachedInputTokens");
1876
- const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
1885
+ // Readers take `cacheReadInputTokens ?? cachedInputTokens` (usage/cost.ts), so once any attempt
1886
+ // reports the read field, an attempt that only reports `cachedInputTokens` must count in it too.
1887
+ const cacheReadInputTokens = sumOptional("cacheReadInputTokens") === undefined ? undefined
1888
+ : usages.reduce((sum, usage) => sum + (usage.cacheReadInputTokens ?? usage.cachedInputTokens ?? 0), 0);
1877
1889
  const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
1890
+ const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
1878
1891
  const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
1879
1892
  const providerCredits = sumOptional("providerCredits");
1880
1893
  const totalTokens = usages.reduce(
@@ -1888,6 +1901,7 @@ export function aggregateAttemptUsage(
1888
1901
  ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
1889
1902
  ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
1890
1903
  ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
1904
+ ...(cacheCreation1hInputTokens !== undefined ? { cacheCreation1hInputTokens } : {}),
1891
1905
  ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
1892
1906
  ...(providerCredits !== undefined ? { providerCredits } : {}),
1893
1907
  ...(status === "estimated" ? { estimated: true } : {}),
@@ -0,0 +1,26 @@
1
+ import type { ResponsesSendBudget } from "./request-send-budget";
2
+
3
+ const moves = new WeakMap<object, number>();
4
+ /** One same-provider credential move, funded by the existing recovery owner. */
5
+ export async function recoverAutoPausedAccount<T>(options: {
6
+ requestKey: object;
7
+ remaining?: number;
8
+ reserve: ResponsesSendBudget["reserveCredentialHop"];
9
+ refresh: () => Promise<boolean>;
10
+ refetch: () => Promise<T>;
11
+ setPermit: (permit: ResponsesSendBudget["pendingHopPermit"]) => void;
12
+ }): Promise<T | undefined> {
13
+ const count = moves.get(options.requestKey) ?? 0;
14
+ if (count >= 3 || options.remaining !== undefined && options.remaining <= 0) return;
15
+ const hop = options.reserve("auth-recovery", "account-auto-pause", true);
16
+ if (!hop.allowed) return;
17
+ try {
18
+ if (!await options.refresh()) return;
19
+ moves.set(options.requestKey, count + 1);
20
+ options.setPermit(hop.permit);
21
+ return await options.refetch();
22
+ } finally {
23
+ options.setPermit(undefined);
24
+ hop.permit?.release();
25
+ }
26
+ }
@@ -28,7 +28,7 @@ import {
28
28
  fetchWithTransientRetry,
29
29
  fetchWithResetRetry,
30
30
  applyUpstreamRecoveryInit,
31
- isNonReplayableResponse,
31
+ isNonReplayableResponse, isSameTargetRetryHold,
32
32
  prepareSameTarget429Wait,
33
33
  SendBudgetExhaustedError,
34
34
  } from "../../lib/upstream-retry";
@@ -349,7 +349,7 @@ export function createAdapterContinuations(
349
349
  && !kiroReplaySetupFailed
350
350
  // A synthesized replay refusal is not a rate limit; replaying the continuation on
351
351
  // it would re-send a turn whose first send may already have been processed.
352
- && !isNonReplayableResponse(response)
352
+ && !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
353
353
  && rateLimitPolicy !== null
354
354
  && adapterExchange.rateLimitRetries < rateLimitPolicy.attempts
355
355
  // The main recovery loop and the passthrough ladder both consult the shared remainder
@@ -1,12 +1,15 @@
1
+ import { recoverAutoPausedAccount } from "./account-auto-pause-recovery";
2
+ import { wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
1
3
  import { bindAnthropicRejectionResponse } from "../../adapters/anthropic-rejection-memo";
2
4
  import { deferPhysicalSendReporter } from "./deferred-send-reporter";
5
+ import { usesApiKeyAccount } from "../../providers/label";
3
6
  import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
4
7
  import { classifyAnthropic429 } from "../../oauth/anthropic-rate-limit-policy";
5
8
  import { rotateAnthropicAccountOnResponseForInstance } from "../../oauth/anthropic-account-refusal";
6
9
  import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
7
10
  import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay";
8
11
  import { transientSendCapFor } from "./request-send-budget";
9
- import { isNonReplayableResponse } from "../../lib/upstream-retry";
12
+ import { isNonReplayableResponse, isSameTargetRetryHold } from "../../lib/upstream-retry";
10
13
  import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
11
14
  import { isLocalUpstream } from "../../lib/local-upstream";
12
15
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
@@ -141,6 +144,7 @@ export async function prepareAdapterExchange(
141
144
  | "sameTargetToken"
142
145
  | "transportToken"
143
146
  | "oauthDispatch"
147
+ | "recoverAutoPausedSelection"
144
148
  | "imageTierBias"
145
149
  | "oauth401ReplayAttempted"
146
150
  | "isOAuth401ReplayProvider"
@@ -394,11 +398,12 @@ export async function prepareAdapterExchange(
394
398
  upstreamResponse = new Response(text, { status: original.status, statusText: original.statusText, headers });
395
399
  return upstreamResponse;
396
400
  };
401
+ const autoPauseEnabled = config.providers[route.providerName]?.accountAutoPause?.enabled === true;
397
402
  const billingPoolRequest = isAnthropicBillingKeyPool(route.provider);
398
403
  let billingRecoveryActive = false;
399
- const genericRefetchCountsSends = () => sendBudgetState.adapterSendBudget?.spendEnforced === true
404
+ const genericRefetchCountsSends = () => options.comboAttempt === true || sendBudgetState.adapterSendBudget?.spendEnforced === true
400
405
  || transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null
401
- || billingPoolRequest || billingRecoveryActive;
406
+ || billingPoolRequest || billingRecoveryActive || autoPauseEnabled;
402
407
  let handoffPermit: ResponsesSendBudget["pendingHopPermit"];
403
408
  try {
404
409
  if (transportState.activeAdapter.fetchResponse) {
@@ -520,10 +525,10 @@ export async function prepareAdapterExchange(
520
525
  label: safeHostLabel(builtInitialRequest.url),
521
526
  claimAmbiguousResend: claimPreHeaderResend,
522
527
  ...(sendBudgetState.adapterSendBudget?.spendEnforced || options.comboDispatchPermit || transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt
523
- || billingPoolRequest
528
+ || billingPoolRequest || autoPauseEnabled
524
529
  ? {
525
530
  // A pending compaction permit already booked this leg's first physical send.
526
- ...(transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt
531
+ ...(transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt || autoPauseEnabled
527
532
  ? {
528
533
  attempts: Math.min(initialSendCap,
529
534
  sendBudgetState.initialSendAllowance(initialSendCap) + (compactPrepaid ? 1 : 0)),
@@ -758,7 +763,9 @@ export async function prepareAdapterExchange(
758
763
  : undefined;
759
764
  let refetchPermitUsed = false;
760
765
  const refetchReporter = refetchAllowance ? transientSendReporter(refetchAllowance.permit) : undefined;
761
- const deferredRefetch = billingPoolRequest && refetchReporter ? deferPhysicalSendReporter(refetchReporter) : undefined;
766
+ const deferredRefetch = (billingPoolRequest || options.comboAttempt) && refetchReporter
767
+ ? deferPhysicalSendReporter(refetchReporter) : undefined;
768
+ const pendingKeyMetadata = deferredRefetch && usesApiKeyAccount(route.provider);
762
769
  try {
763
770
  return await refetchWithPolicy(
764
771
  recoveryKind => {
@@ -767,14 +774,14 @@ export async function prepareAdapterExchange(
767
774
  });
768
775
  // Prepare key metadata before the guarded transport commits its attempt;
769
776
  // counters and permits still wait for the physical executor after pacing.
770
- if (deferredRefetch) transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
777
+ if (pendingKeyMetadata) transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
771
778
  const confirmDispatch = () => {
772
779
  if (refetchAllowance?.permit && !refetchPermitUsed) {
773
780
  if (!refetchAllowance.permit.use()) throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url));
774
781
  refetchPermitUsed = true;
775
782
  }
776
- if (!deferredRefetch) transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
777
- // Eligible pools confirm after pacing at the physical executor; legacy
783
+ if (!pendingKeyMetadata) transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
784
+ // Counted Combo/pool sends confirm at the physical executor; legacy
778
785
  // helper paths retain their existing thunk admission boundary.
779
786
  onDispatch?.();
780
787
  if (preserveFailureResponse) replacementAdmitted = true;
@@ -887,7 +894,7 @@ export async function prepareAdapterExchange(
887
894
  const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
888
895
  const hop = reserveCredentialHop("auth-recovery",
889
896
  `${route.providerName}|${route.modelId}|antigravity-auth`,
890
- !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null));
897
+ !adapterOwnsDispatch && genericRefetchCountsSends());
891
898
  if (!hop.allowed) return null;
892
899
  try {
893
900
  const snapshot = await failoverAccountSnapshot(route.providerName, nextId);
@@ -923,6 +930,15 @@ export async function prepareAdapterExchange(
923
930
  };
924
931
  // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
925
932
  recovery: for (;;) {
933
+ if (wasAccountAutoPaused(upstreamResponse) && route.provider.authMode !== "forward") {
934
+ const recovered = await recoverAutoPausedAccount({ requestKey: transportState, reserve: reserveCredentialHop,
935
+ remaining: transientSendCapFor(transientRetryPolicyFor(route.provider)?.attempts, sendBudgetState.targetSendsUsed),
936
+ refresh: () => transportState.recoverAutoPausedSelection(parsed), refetch: () => rebuildAndRefetch("oauth-account-429"), setPermit: permit => { sendBudgetState.pendingHopPermit = permit; } });
937
+ if (!recovered) break;
938
+ if ("failed" in recovered) return recovered.failed;
939
+ upstreamResponse = recovered; continue recovery;
940
+ }
941
+
926
942
  // Preserve the terminal verdict through adapter and combo error formatting.
927
943
  // This also covers a reset reached by a 401/429/413 recovery refetch.
928
944
  if (isNonReplayableResponse(upstreamResponse)) {
@@ -942,7 +958,7 @@ export async function prepareAdapterExchange(
942
958
  continue recovery;
943
959
  }
944
960
  if (
945
- upstreamResponse.status === 401
961
+ upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
946
962
  && isOAuth401ReplayProvider
947
963
  && transportState.sentOAuthSnapshot
948
964
  && !oauth401ReplayAttempted
@@ -978,7 +994,7 @@ export async function prepareAdapterExchange(
978
994
  const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
979
995
  const hop = reserveCredentialHop("auth-recovery",
980
996
  `${route.providerName}|${route.modelId}|terminal-refresh-account`,
981
- !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null));
997
+ !adapterOwnsDispatch && genericRefetchCountsSends());
982
998
  if (hop.allowed) {
983
999
  try {
984
1000
  const admitted = await applyFailoverSnapshot(alternate);
@@ -1062,7 +1078,7 @@ export async function prepareAdapterExchange(
1062
1078
  continue recovery;
1063
1079
  }
1064
1080
 
1065
- if (route.providerName === "google-antigravity" && upstreamResponse.status === 401
1081
+ if (route.providerName === "google-antigravity" && upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
1066
1082
  && oauth401ReplayAttempted && !antigravityAuthRotationAttempted
1067
1083
  && !isNonReplayableResponse(upstreamResponse)) {
1068
1084
  antigravityAuthRotationAttempted = true;
@@ -1223,7 +1239,7 @@ export async function prepareAdapterExchange(
1223
1239
  // replay is lossless. Runs before key failover so "primary-first" setups keep the same
1224
1240
  // key on rate-limit blips; only after the attempts are exhausted does failover run.
1225
1241
  while (
1226
- upstreamResponse.status === 429
1242
+ upstreamResponse.status === 429 && !isSameTargetRetryHold(upstreamResponse)
1227
1243
  && rateLimitPolicy !== null
1228
1244
  && rateLimitRetries < rateLimitPolicy.attempts
1229
1245
  && !sendBudgetExhausted()
@@ -1348,12 +1364,31 @@ export async function prepareAdapterExchange(
1348
1364
  && anthropicInstance
1349
1365
  && transportState.anthropicPoolAccountId
1350
1366
  ) {
1351
- const nextAccountId = await rotateAnthropicAccountOnResponseForInstance(anthropicInstance, upstreamResponse, {
1352
- config, accountId: transportState.anthropicPoolAccountId, sessionKey: anthropicSessionKey,
1353
- model: route.modelId, requestKey: transportState, decision: transportState.anthropicRouteDecision, currentDecision: currentAnthropicRouteDecision, signal: upstream.signal,
1354
- canRetry: !sendBudgetExhausted() && transportState.anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
1355
- });
1356
- if (!nextAccountId) break;
1367
+ // Same admission as the other rotation ladders: the roster cap cannot see the shared
1368
+ // send budget. A canRetry term, not a break -- the call also records the refusal.
1369
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
1370
+ const hop = reserveCredentialHop(
1371
+ "auth-recovery",
1372
+ `${route.providerName}|${route.modelId}|anthropic-pool-rotation`,
1373
+ !adapterOwnsDispatch && genericRefetchCountsSends(),
1374
+ );
1375
+ let nextAccountId: string | null;
1376
+ try {
1377
+ nextAccountId = await rotateAnthropicAccountOnResponseForInstance(anthropicInstance, upstreamResponse, {
1378
+ config, accountId: transportState.anthropicPoolAccountId, sessionKey: anthropicSessionKey,
1379
+ model: route.modelId, requestKey: transportState, decision: transportState.anthropicRouteDecision, currentDecision: currentAnthropicRouteDecision, signal: upstream.signal,
1380
+ // A denied reservation must not rotate on base headroom its bounds cannot see.
1381
+ canRetry: hop.allowed && (hop.permit !== undefined || !sendBudgetExhausted())
1382
+ && transportState.anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
1383
+ });
1384
+ } catch (rotationError) {
1385
+ hop.permit?.release();
1386
+ throw rotationError;
1387
+ }
1388
+ if (!nextAccountId) {
1389
+ hop.permit?.release();
1390
+ break;
1391
+ }
1357
1392
  try {
1358
1393
  const admitted = await commitResolvedOAuthSelection(await anthropicRoutingFor(anthropicInstance).getAnthropicPoolAccessSnapshot(nextAccountId));
1359
1394
  if (!admitted) throw new Error("OAuth selection changed during recovery");
@@ -1376,12 +1411,26 @@ export async function prepareAdapterExchange(
1376
1411
  });
1377
1412
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
1378
1413
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
1379
- const result = await rebuildAndRefetch(upstreamResponse.status === 401 ? "oauth-401"
1380
- : upstreamResponse.status === 403 ? "oauth-account-403" : "anthropic-oauth-429");
1381
- if ("failed" in result) return result.failed;
1382
- upstreamResponse = result;
1414
+ // The replay IS this hop's send; the dispatching layer settles the reservation.
1415
+ sendBudgetState.pendingHopPermit = hop.permit;
1416
+ let result: Response | { failed: Response };
1417
+ try {
1418
+ result = await rebuildAndRefetch(upstreamResponse.status === 401 ? "oauth-401"
1419
+ : upstreamResponse.status === 403 ? "oauth-account-403" : "anthropic-oauth-429",
1420
+ () => { if (!adapterOwnsDispatch) hop.permit?.use(); });
1421
+ } finally {
1422
+ sendBudgetState.pendingHopPermit = undefined;
1423
+ }
1424
+ if ("failed" in result) {
1425
+ // A no-op once the boundary was reached; a refund when the rebuild died first.
1426
+ hop.permit?.release();
1427
+ return result.failed;
1428
+ }
1429
+ upstreamResponse = result;
1383
1430
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
1384
- } catch {
1431
+ } catch {
1432
+ // release() refunds a pre-send hop and is a no-op once the send settled it.
1433
+ hop.permit?.release();
1385
1434
  break;
1386
1435
  }
1387
1436
  }
@@ -1421,9 +1470,9 @@ export async function prepareAdapterExchange(
1421
1470
  const hop = reserveCredentialHop(
1422
1471
  "auth-recovery",
1423
1472
  `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
1424
- // Transient and opted-in reset helpers report physical sends; an adapter-owned
1473
+ // Counted generic helpers report physical sends; an adapter-owned
1425
1474
  // ladder settles its own booking.
1426
- !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null),
1475
+ !adapterOwnsDispatch && genericRefetchCountsSends(),
1427
1476
  );
1428
1477
  if (!hop.allowed) break;
1429
1478
  const nextAccountId = rotateGenericOAuthAccountOnRefusal(
@@ -1525,9 +1574,9 @@ export async function prepareAdapterExchange(
1525
1574
  const hop = reserveCredentialHop(
1526
1575
  "auth-recovery",
1527
1576
  `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
1528
- // Transient and opted-in reset helpers report physical sends; an adapter-owned
1577
+ // Counted generic helpers report physical sends; an adapter-owned
1529
1578
  // ladder settles its own booking.
1530
- !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null),
1579
+ !adapterOwnsDispatch && genericRefetchCountsSends(),
1531
1580
  );
1532
1581
  if (!hop.allowed) break;
1533
1582
  const nextAccountId = rotateGenericOAuthAccountOn429(
@@ -1646,7 +1695,7 @@ export async function prepareAdapterExchange(
1646
1695
  const hop = reserveCredentialHop(
1647
1696
  "auth-recovery",
1648
1697
  `${route.providerName}|${route.modelId}|adapter-recovery-oauth-verify`,
1649
- !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null),
1698
+ !adapterOwnsDispatch && genericRefetchCountsSends(),
1650
1699
  );
1651
1700
  if (!hop.allowed) break;
1652
1701
  // Auth-refusal rotation, not the rate-limit one: a verification refusal
@@ -458,6 +458,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
458
458
  return;
459
459
  }
460
460
  if (terminal || settledPreOpen || signal?.aborted) return;
461
+ // New sockets have just validated their upgrade; retained sockets carry the
462
+ // same connection signal into a fresh exchange, never its predecessor's frames.
463
+ metadata?.seed(session.upgradeHeaders());
461
464
  sent = true;
462
465
  try {
463
466
  if (nativeControl) {
@@ -6,11 +6,48 @@ export const CODEX_WS_METADATA_MAX_HEADERS = 128;
6
6
  export const CODEX_WS_METADATA_MAX_VALUE_BYTES = 4096;
7
7
 
8
8
  export type CodexWsQuotaObserver = (headers: Headers) => void;
9
+ const REASONING_INCLUDED_HEADER = "x-reasoning-included";
10
+ const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
9
11
 
10
12
  function record(value: unknown): value is Record<string, unknown> {
11
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
12
14
  }
13
15
 
16
+ /** Bun emits this payload before validating the upgrade; the session must wait for open. */
17
+ export function readCodexWsUpgradeHeaders(payload: unknown): Headers {
18
+ try {
19
+ if (!record(payload) || payload.statusCode !== 101 || !Array.isArray(payload.rawHeaders)
20
+ || payload.rawHeaders.length % 2 !== 0 || payload.rawHeaders.length > CODEX_WS_METADATA_MAX_HEADERS * 2) return new Headers();
21
+ const headers = new Headers();
22
+ let bytes = 0;
23
+ let connectionNominated = false;
24
+ for (let i = 0; i < payload.rawHeaders.length; i += 2) {
25
+ const name = payload.rawHeaders[i];
26
+ const value = payload.rawHeaders[i + 1];
27
+ if (typeof name !== "string" || typeof value !== "string"
28
+ || name.length + value.length > CODEX_WS_METADATA_MAX_BYTES - bytes || !HTTP_HEADER_TOKEN.test(name)
29
+ || /[\r\n\0]/.test(value)) return new Headers();
30
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
31
+ if (bytes > CODEX_WS_METADATA_MAX_BYTES) return new Headers();
32
+ const lower = name.toLowerCase();
33
+ if (lower === "connection" && value.split(",").some(token => token.trim().toLowerCase() === REASONING_INCLUDED_HEADER)) {
34
+ connectionNominated = true;
35
+ }
36
+ // Other allowlisted response headers (quota, request id, turn state, model)
37
+ // describe a response, not a reusable connection. Never retain them here.
38
+ if (lower === REASONING_INCLUDED_HEADER) {
39
+ if (Buffer.byteLength(value) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
40
+ headers.append(lower, value);
41
+ if (Buffer.byteLength(headers.get(lower)!) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
42
+ }
43
+ }
44
+ return connectionNominated ? new Headers() : headers;
45
+ } catch {
46
+ // Missing/unsupported or malformed optional metadata cannot authorize a retry.
47
+ return new Headers();
48
+ }
49
+ }
50
+
14
51
  function finiteNonnegative(value: unknown): value is number {
15
52
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
16
53
  }
@@ -91,6 +128,13 @@ export class CodexWsMetadata {
91
128
 
92
129
  snapshot(): Headers { return new Headers(this.headers); }
93
130
 
131
+ /** Seed only the connection-level inclusion signal; per-response frames still win. */
132
+ seed(headers: Headers): void {
133
+ if (this.ended || this.committed || this.headers.has(REASONING_INCLUDED_HEADER)) return;
134
+ const value = headers.get(REASONING_INCLUDED_HEADER);
135
+ if (value !== null) setMetadataHeader(this.headers, REASONING_INCLUDED_HEADER, value);
136
+ }
137
+
94
138
  private publishQuota(headers: Headers): void {
95
139
  // Observation is auxiliary bookkeeping; a consumer exception cannot turn
96
140
  // a valid provider frame into a retryable transport failure.
@@ -1,3 +1,5 @@
1
+ import { readCodexWsUpgradeHeaders } from "./codex-ws-metadata";
2
+
1
3
  export const MAX_CODEX_WS_SESSION_EXCHANGES = 32;
2
4
 
3
5
  /** Owns one physical socket; request listeners belong to the exchange, not this object. */
@@ -8,10 +10,13 @@ export class CodexWsSession {
8
10
  busy = false;
9
11
  private owner?: (reason: Error) => void;
10
12
  private readonly completedIds = new Set<string>();
13
+ private pendingUpgradeHeaders?: Headers;
14
+ private connectionHeaders?: Headers;
11
15
 
12
16
  constructor(url: string, headers: Record<string, string>, readonly retainable = false,
13
17
  private readonly changed: () => void = () => {}, proxy?: string) {
14
18
  this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
19
+ this.socket.addEventListener("handshake", this.onHandshake);
15
20
  this.socket.addEventListener("open", this.onOpen);
16
21
  this.socket.addEventListener("message", this.onIdleMessage);
17
22
  this.socket.addEventListener("close", this.onClose);
@@ -20,6 +25,7 @@ export class CodexWsSession {
20
25
 
21
26
  get reused(): boolean { return this.completedIds.size > 0; }
22
27
  hasCompleted(id: string): boolean { return this.completedIds.has(id); }
28
+ upgradeHeaders(): Headers { return new Headers(this.opened && !this.closed ? this.connectionHeaders : undefined); }
23
29
 
24
30
  reserve(): boolean {
25
31
  if (this.closed || this.busy || (this.opened && this.socket.readyState !== undefined && this.socket.readyState !== 1)) return false;
@@ -71,7 +77,18 @@ export class CodexWsSession {
71
77
  }
72
78
  }
73
79
 
74
- private onOpen = (): void => { this.opened = true; };
80
+ private onHandshake = (event: Event): void => {
81
+ if (this.opened || this.closed) return;
82
+ this.pendingUpgradeHeaders = readCodexWsUpgradeHeaders((event as MessageEvent<unknown>).data);
83
+ };
84
+ private onOpen = (): void => {
85
+ if (this.closed) return;
86
+ // A 101 payload alone is not proof that Bun accepted the handshake.
87
+ this.connectionHeaders = this.pendingUpgradeHeaders;
88
+ this.pendingUpgradeHeaders = undefined;
89
+ this.socket.removeEventListener("handshake", this.onHandshake);
90
+ this.opened = true;
91
+ };
75
92
  private onIdleMessage = (): void => {
76
93
  if (!this.busy) this.dispose(new Error("codex websocket received unsolicited idle data"));
77
94
  };
@@ -85,6 +102,9 @@ export class CodexWsSession {
85
102
  // The active exchange's close listener retains pre-send fallback semantics.
86
103
  };
87
104
  private detach(): void {
105
+ this.pendingUpgradeHeaders = undefined;
106
+ this.connectionHeaders = undefined;
107
+ this.socket.removeEventListener("handshake", this.onHandshake);
88
108
  this.socket.removeEventListener("open", this.onOpen);
89
109
  this.socket.removeEventListener("message", this.onIdleMessage);
90
110
  this.socket.removeEventListener("close", this.onClose);