@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
@@ -19,6 +19,8 @@
19
19
  * distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` —
20
20
  * both append distinct identified accounts under multiauth.
21
21
  */
22
+ import { notifyAuthStoreLoaded } from "../lib/decision-quota-sink";
23
+ import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
22
24
  import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan";
23
25
  import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity";
24
26
  import { findKiroAccountForLogin, withRetainedKiroUser } from "./kiro-account-identity";
@@ -354,17 +356,23 @@ export function credentialGeneration(cred: OAuthCredentials): string {
354
356
  return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
355
357
  }
356
358
 
359
+ /** Load and normalize the OAuth auth store with hardened permissions, and hand the result to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
357
360
  function loadAuthStoreInternal(): { store: AuthStore; hadLegacy: boolean } {
358
361
  const path = getAuthStorePath();
359
362
  hardenConfigDir();
360
363
  hardenExistingSecret(path);
361
- if (!existsSync(path)) return { store: {}, hadLegacy: false };
364
+ if (!existsSync(path)) { notifyAuthStoreLoaded({}); return { store: {}, hadLegacy: false }; }
365
+ let result: { store: AuthStore; hadLegacy: boolean };
362
366
  try {
363
- return normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8")));
367
+ result = normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8")));
364
368
  } catch {
365
369
  backupInvalidConfig(path);
370
+ notifyAuthStoreLoaded({});
366
371
  return { store: {}, hadLegacy: false };
367
372
  }
373
+ // Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
374
+ notifyAuthStoreLoaded(result.store);
375
+ return result;
368
376
  }
369
377
 
370
378
  export function loadAuthStore(): AuthStore {
@@ -403,6 +411,7 @@ export function peekAuthStore(): AuthStore {
403
411
  return snapshot.kind === "ready" ? snapshot.store : {};
404
412
  }
405
413
 
414
+ /** Atomically write the auth store with owner-only permissions, then hand it to the opt-in decision-quota slot. */
406
415
  function persist(store: AuthStore): void {
407
416
  const dir = getConfigDir();
408
417
  assertNotRealHomeUnderTest(dir);
@@ -413,6 +422,7 @@ function persist(store: AuthStore): void {
413
422
  }
414
423
  hardenConfigDir();
415
424
  atomicWriteFile(getAuthStorePath(), JSON.stringify(store, null, 2) + "\n");
425
+ notifyAuthStoreLoaded(store);
416
426
  }
417
427
 
418
428
  export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } }
@@ -638,7 +648,12 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
638
648
  if (candidate.refreshAttentionGeneration === credentialGeneration(credential)) {
639
649
  account.refreshAttentionGeneration = candidate.refreshAttentionGeneration;
640
650
  }
641
- if (candidate.paused === true) account.paused = true;
651
+ if (candidate.paused === true) {
652
+ account.paused = true;
653
+ const details = accountPauseDetailsSchema.safeParse(candidate.pauseDetails);
654
+ if (details.success) account.pauseDetails = details.data;
655
+ }
656
+ if (typeof candidate.pauseRevision === "string") account.pauseRevision = candidate.pauseRevision;
642
657
  const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride);
643
658
  if (threshold !== null) account.autoSwitchThresholdOverride = threshold;
644
659
  if (candidate.needsReauth === true && candidate.needsReauthReason === "verify_account") {
@@ -801,7 +816,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
801
816
  drainOAuthMutations();
802
817
  return result;
803
818
  }
804
- export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
819
+ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void; withConfigLock?: boolean }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
805
820
  const { store, hadLegacy } = loadAuthStoreInternal();
806
821
  if (hadLegacy) backupLegacyOnce();
807
822
  const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
@@ -814,7 +829,7 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
814
829
  // Only providers whose credentials this mutation actually removed leave the downgrade
815
830
  // backup; a no-op removal names none. The result decides, so it is read here.
816
831
  const scrubbedProviders = options?.scrubLegacyBackup?.(result) ?? [];
817
- options?.assertBeforePersist?.();
832
+ if (!options?.withConfigLock) options?.assertBeforePersist?.();
818
833
  const changedProviders: string[] = [];
819
834
  for (const provider of new Set([...selections.keys(), ...Object.keys(store)])) {
820
835
  const before = selections.get(provider);
@@ -837,7 +852,8 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
837
852
  // Receipt-producing mutations need the revision assigned by the bookkeeping above, not the
838
853
  // provisional value visible inside their callback. Finalization cannot await or mutate disk.
839
854
  options?.finalizeResult?.(result, store);
840
- persist(store);
855
+ const publish = () => { if (options?.withConfigLock) options.assertBeforePersist?.(); persist(store); };
856
+ if (options?.withConfigLock) withConfigMutationLockSync(publish); else publish();
841
857
  if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
842
858
  // A committed observer may establish ordering before the generic selection
843
859
  // publication, but its failure can never turn a durable write into a reported failure.
@@ -1388,17 +1404,23 @@ export async function setAccountPaused(
1388
1404
  provider: string,
1389
1405
  accountId: string,
1390
1406
  paused: boolean,
1407
+ automatic?: { generation: string; pauseRevision?: string; details: import("../types/account-auto-pause").AccountPauseDetails; isCurrent?: () => boolean; assertPolicyBeforePersist?: () => void },
1391
1408
  ): Promise<SetAccountPausedResult> {
1392
1409
  const result = await mutateStore(store => {
1393
1410
  const set = store[provider];
1394
1411
  const account = set?.accounts.find(candidate => candidate.id === accountId);
1395
1412
  if (!set || !account) return { status: "not-found" } as const;
1396
- if ((account.paused === true) === paused) {
1413
+ if (automatic && (automatic.isCurrent?.() === false || credentialGeneration(account.credential) !== automatic.generation
1414
+ || account.pauseRevision !== automatic.pauseRevision)) return { status: "not-found" } as const;
1415
+ if (paused && account.paused === true) {
1397
1416
  return { status: "unchanged", activeAccountId: set.activeAccountId, activeAccountChanged: false } as const;
1398
1417
  }
1399
1418
 
1400
1419
  if (paused) account.paused = true;
1401
1420
  else delete account.paused;
1421
+ account.pauseRevision = randomUUID();
1422
+ if (automatic) account.pauseDetails = automatic.details;
1423
+ else delete account.pauseDetails;
1402
1424
  // Pause is part of selection eligibility even when the operator changes a non-active
1403
1425
  // account. Bump the provider selection revision so observers receive the existing
1404
1426
  // post-persistence roster invalidation and stale automatic proposals cannot commit.
@@ -1432,7 +1454,10 @@ export async function setAccountPaused(
1432
1454
  activeAccountId: set.activeAccountId,
1433
1455
  activeAccountChanged,
1434
1456
  } as const;
1435
- }, [provider, accountId, paused]);
1457
+ }, [provider, accountId, paused], { withConfigLock: !!automatic, assertBeforePersist: () => {
1458
+ automatic?.assertPolicyBeforePersist?.();
1459
+ if (automatic?.isCurrent?.() === false) throw new Error("Account auto-pause policy changed before persistence");
1460
+ } });
1436
1461
  if (result.status === "updated") publishOAuthAccountPauseChange(provider);
1437
1462
  return result;
1438
1463
  }
@@ -1485,6 +1510,8 @@ export async function replaceProviderAccountSet(
1485
1510
  ...(account.needsReauth ? { needsReauth: true } : {}),
1486
1511
  ...(account.refreshAttentionGeneration ? { refreshAttentionGeneration: account.refreshAttentionGeneration } : {}),
1487
1512
  ...(account.paused ? { paused: true } : {}),
1513
+ ...(account.pauseDetails ? { pauseDetails: account.pauseDetails } : {}),
1514
+ ...(account.pauseRevision ? { pauseRevision: account.pauseRevision } : {}),
1488
1515
  ...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}),
1489
1516
  ...(account.needsReauthReason === "verify_account" ? { needsReauthReason: account.needsReauthReason } : {}),
1490
1517
  ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
@@ -96,6 +96,8 @@ export interface ProviderAccount {
96
96
  refreshAttentionGeneration?: string;
97
97
  /** Operator exclusion from generic OAuth account selection until explicitly resumed. */
98
98
  paused?: boolean;
99
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
100
+ pauseRevision?: string;
99
101
  /** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */
100
102
  autoSwitchThresholdOverride?: number;
101
103
  /**
@@ -31,13 +31,20 @@ interface Registration {
31
31
  }
32
32
 
33
33
  const registrations: Registration[] = [];
34
+ // Changes on every registration and removal, so an observer can tell that no rewriter could
35
+ // have touched a send between two points even when the slot is empty at both.
36
+ let registrationEpoch = 0;
34
37
 
35
38
  export function registerUpstreamRewriter(name: string, rewrite: UpstreamRewriter): () => void {
36
39
  const registration: Registration = { name, rewrite, disabled: false };
37
40
  registrations.push(registration);
41
+ registrationEpoch += 1;
38
42
  return () => {
39
43
  const index = registrations.indexOf(registration);
40
- if (index >= 0) registrations.splice(index, 1);
44
+ if (index >= 0) {
45
+ registrations.splice(index, 1);
46
+ registrationEpoch += 1;
47
+ }
41
48
  };
42
49
  }
43
50
 
@@ -45,6 +52,11 @@ export function hasUpstreamRewriters(): boolean {
45
52
  return registrations.length > 0;
46
53
  }
47
54
 
55
+ /** Monotonic registration epoch; equal values bracket a window with no registration change. */
56
+ export function upstreamRewriterEpoch(): number {
57
+ return registrationEpoch;
58
+ }
59
+
48
60
  /**
49
61
  * Run every active rewriter over one send. Returns the input untouched (same objects) when
50
62
  * no rewriter is registered, so the common path allocates nothing.
@@ -116,4 +128,5 @@ export function rewriteWebSocketDial(
116
128
 
117
129
  export function resetUpstreamRewritersForTests(): void {
118
130
  registrations.length = 0;
131
+ registrationEpoch += 1;
119
132
  }
@@ -0,0 +1,32 @@
1
+ import { accountAutoPauseFenceMatchesDisk, type AccountAutoPauseFence } from "../config/account-auto-pause-revisions";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { OcxConfig } from "../types";
4
+ import type { ProviderApiKeySelection } from "../types/provider";
5
+ import type { AccountPauseDetails } from "../types/account-auto-pause";
6
+ import { apiKeyPoolEntryId } from "./api-keys";
7
+ import { resolveProviderApiKey } from "./api-key-resolve";
8
+ import { commitProviderApiKeySelection } from "./api-key-selection";
9
+
10
+ export function setProviderApiKeyPaused(config: OcxConfig, name: string, id: string, paused: boolean,
11
+ automatic?: { fence: AccountAutoPauseFence; selection: ProviderApiKeySelection; pauseRevision?: string; resolvedKey?: string; details: AccountPauseDetails }): boolean {
12
+ const result = commitProviderApiKeySelection(config, name, provider => {
13
+ if (automatic && !accountAutoPauseFenceMatchesDisk(name, automatic.fence)) return { changed: false, value: false };
14
+ const pool = provider.apiKeyPool?.length ? provider.apiKeyPool
15
+ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
16
+ const entry = pool.find(e => e.id === id);
17
+ if (!entry || automatic && (entry.disabled || entry.key !== automatic.selection.reference || entry.pauseRevision !== automatic.pauseRevision
18
+ || provider.accountAutoPause?.enabled !== true || resolveProviderApiKey(entry.key) !== automatic.resolvedKey)) return { changed: false, value: false };
19
+ provider.apiKeyPool = pool;
20
+ entry.disabled = paused;
21
+ entry.pauseRevision = randomUUID();
22
+ if (automatic) entry.pauseDetails = automatic.details;
23
+ else delete entry.pauseDetails;
24
+ if (paused && provider.apiKey === entry.key) {
25
+ const next = provider.apiKeyPool?.find(e => !e.disabled);
26
+ if (next) provider.apiKey = next.key;
27
+ else delete provider.apiKey;
28
+ } else if (!paused && !provider.apiKey) provider.apiKey = entry.key;
29
+ return { changed: true, selectionChanged: true, value: true };
30
+ }, automatic?.selection);
31
+ return result.status === "committed" && result.value;
32
+ }
@@ -16,6 +16,7 @@
16
16
  * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per
17
17
  * account; nothing ever rewrites plaintext into config or its backups.
18
18
  */
19
+ import { invalidateDecisionKeyQuotas, observeDecisionKeyCredential } from "../lib/decision-quota-sink";
19
20
  import { resolveEnvValue } from "../config/proxy-env";
20
21
  import { loadKeyringBinding } from "../lib/keyring-native";
21
22
  import { isGcpCredentialMaterial } from "../lib/gcp-credential-input";
@@ -52,6 +53,7 @@ export function providerKeyResolutionGeneration(): number {
52
53
  /** Test seam: swap the OS entry for an in-memory one and drop caches. */
53
54
  export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
54
55
  entryFactory = factory ?? defaultEntryFactory;
56
+ invalidateDecisionKeyQuotas();
55
57
  resolvedCache.clear();
56
58
  resolutionGeneration++;
57
59
  warnedAccounts.clear();
@@ -59,6 +61,7 @@ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychai
59
61
 
60
62
  /** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
61
63
  export function invalidateResolvedProviderKeyCache(): void {
64
+ invalidateDecisionKeyQuotas();
62
65
  resolvedCache.clear();
63
66
  resolutionGeneration++;
64
67
  warnedAccounts.clear();
@@ -114,15 +117,18 @@ function readKeychain(account: string, quiet = false): string | undefined {
114
117
  */
115
118
  export function resolveProviderApiKey(value: string | undefined): string | undefined {
116
119
  if (!value) return undefined;
117
- if (isKeychainReference(value)) return readKeychain(keychainAccount(value));
118
- return resolveEnvValue(value);
120
+ const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value)) : resolveEnvValue(value);
121
+ observeDecisionKeyCredential(value, resolved);
122
+ return resolved;
119
123
  }
120
124
 
121
125
  /** The same resolution without the unreadable-reference warning, for observe-only callers. */
122
126
  export function resolveProviderApiKeyQuietly(value: string | undefined): string | undefined {
123
127
  if (!value) return undefined;
124
- if (isKeychainReference(value)) return readKeychain(keychainAccount(value), true);
125
- return resolveEnvValue(value);
128
+ const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value), true) : resolveEnvValue(value);
129
+ // A quiet read still sees a replaced secret; advisory key evidence published under the old one must not outlive it.
130
+ observeDecisionKeyCredential(value, resolved);
131
+ return resolved;
126
132
  }
127
133
 
128
134
  export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
@@ -13,6 +13,9 @@ import { captureProviderApiKeySelection } from "./api-key-selection-capture";
13
13
  export { captureProviderApiKeySelection } from "./api-key-selection-capture";
14
14
 
15
15
  function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean {
16
+ if (expected.distributed) return provider.apiKeySelectionRevision === expected.revision
17
+ && provider.requestPacing?.enabled === true && provider.requestPacing.keySelection === "round-robin"
18
+ && !!provider.apiKeyPool?.some(entry => !entry.disabled && entry.id === expected.entryId && entry.key === expected.reference);
16
19
  const current = captureProviderApiKeySelection(provider);
17
20
  return current.entryId === expected.entryId && current.reference === expected.reference
18
21
  && current.revision === expected.revision;
@@ -24,10 +27,12 @@ function currentKeyProvider(config: OcxConfig, name: string, selection?: Provide
24
27
  let row = configured;
25
28
  if (selection?.distributed) {
26
29
  const entry = configured.apiKeyPool?.find(key => key.id === selection.entryId && key.key === selection.reference);
27
- if (!entry || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
30
+ if (!entry || entry.disabled || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
28
31
  || !configured.requestPacing?.enabled || configured.requestPacing.keySelection !== "round-robin") return null;
29
32
  row = { ...configured, apiKey: entry.key, _apiKeyAttempt: selection };
30
33
  }
34
+ if (row.apiKeyPool?.some(entry => entry.disabled && entry.key === row.apiKey)
35
+ || row.apiKeyPool?.length && row.apiKeyPool.every(entry => entry.disabled)) return null;
31
36
  const current = routedProviderConfig(name, { ...row, _apiKeyAttempt: selection?.distributed ? selection : undefined });
32
37
  if (current.authMode === "oauth" || current.authMode === "forward") return null;
33
38
  if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null;
@@ -21,6 +21,8 @@ export interface ProviderApiKeyInfo extends AccountQuotaFields {
21
21
  /** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
22
22
  masked: string;
23
23
  active: boolean;
24
+ disabled?: boolean;
25
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
24
26
  addedAt?: number;
25
27
  }
26
28
 
@@ -217,7 +219,7 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
217
219
  const pool = provider.apiKeyPool?.length
218
220
  ? provider.apiKeyPool
219
221
  : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
220
- const activeId = (pool.find(entry => entry.key === provider.apiKey) ?? pool[0])?.id ?? null;
222
+ const activeId = pool.find(entry => entry.key === provider.apiKey)?.id ?? null;
221
223
  return {
222
224
  activeId,
223
225
  keys: pool.map(entry => ({
@@ -225,6 +227,8 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
225
227
  ...(entry.label ? { label: entry.label } : {}),
226
228
  masked: maskApiKey(entry.key),
227
229
  active: entry.id === activeId,
230
+ ...(entry.disabled ? { disabled: true } : {}),
231
+ ...(entry.pauseDetails ? { pauseDetails: entry.pauseDetails } : {}),
228
232
  ...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
229
233
  })),
230
234
  };
@@ -300,7 +304,7 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
300
304
  } else {
301
305
  pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
302
306
  }
303
- fresh.apiKey = trimmed;
307
+ if (!existing?.disabled) fresh.apiKey = trimmed;
304
308
  return { changed: true, selectionChanged: true, value: id };
305
309
  });
306
310
  if (committed.status !== "committed") return { error: "provider selection unavailable" };
@@ -313,7 +317,7 @@ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: str
313
317
  const entry = provider.apiKeyPool?.find(e => e.id === id)
314
318
  ?? (!provider.apiKeyPool?.length && provider.apiKey && apiKeyPoolEntryId(provider.apiKey) === id
315
319
  ? { id, key: provider.apiKey } : undefined);
316
- if (!entry) return { changed: false, value: false };
320
+ if (!entry || entry.disabled) return { changed: false, value: false };
317
321
  ensurePool(provider);
318
322
  provider.apiKey = entry.key;
319
323
  return { changed: true, selectionChanged: true, value: true };
@@ -344,7 +348,7 @@ export function removeProviderApiKey(config: OcxConfig, name: string, id: string
344
348
  removed = entry.key;
345
349
  provider.apiKeyPool = pool.filter(e => e.id !== id);
346
350
  if (provider.apiKey === entry.key) {
347
- const next = provider.apiKeyPool[0];
351
+ const next = provider.apiKeyPool.find(candidate => !candidate.disabled);
348
352
  if (next) provider.apiKey = next.key;
349
353
  else delete provider.apiKey;
350
354
  }
@@ -1,7 +1,11 @@
1
1
  import { markAnthropicFamilyEnumeration } from "./quota/anthropic-family-headers";
2
+ import { rawAnthropicUsageWindows } from "./quota/anthropic-decision-windows";
3
+ import { bindRawDecisionWindows } from "./quota-decision-publication";
4
+ import { decisionQuotaSinkActive } from "../lib/decision-quota-sink";
2
5
  import { CLAUDE_CLI_USER_AGENT } from "./claude-cli-identity";
3
6
  import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS } from "./quota-wire";
4
- import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
7
+ import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "./quota-types";
8
+ import { httpQuotaFailure } from "./quota/failure-codes";
5
9
  const hasQuotaRows = (q: ProviderQuota) => q.fiveHourPercent !== undefined || q.weeklyPercent !== undefined || Boolean(q.customWindows?.length);
6
10
 
7
11
  function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
@@ -41,7 +45,18 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null {
41
45
  return { label, scope: "model", percent, ...(resetAt !== undefined ? { resetAt } : {}) };
42
46
  }
43
47
 
48
+ /** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */
44
49
  export async function readAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
50
+ return (await probeAnthropicUsageQuota(accessToken)).quota;
51
+ }
52
+
53
+ /** A usage read plus, when it yields no quota, the closed reason why. A thrown fetch still throws. */
54
+ export interface AnthropicUsageProbe {
55
+ quota: ProviderQuota | null;
56
+ failure?: QuotaFailureCode;
57
+ }
58
+
59
+ export async function probeAnthropicUsageQuota(accessToken: string): Promise<AnthropicUsageProbe> {
45
60
  const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
46
61
  headers: {
47
62
  Accept: "application/json, text/plain, */*",
@@ -52,9 +67,12 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
52
67
  },
53
68
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
54
69
  });
55
- if (!response.ok) return null;
70
+ if (!response.ok) {
71
+ try { await response.body?.cancel(); } catch { /* Best-effort release; the status is the diagnosis. */ }
72
+ return { quota: null, failure: httpQuotaFailure(response.status) };
73
+ }
56
74
  const body = asRecord(await readQuotaJson(response));
57
- if (!body) return null;
75
+ if (!body) return { quota: null, failure: "response_unusable" };
58
76
  const fiveHour = parseClaudeBucket(body.five_hour);
59
77
  const sevenDay = parseClaudeBucket(body.seven_day);
60
78
  const fable = parseClaudeBucket(body.seven_day_fable);
@@ -87,10 +105,12 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
87
105
  ...(customWindows.length > 0 ? { customWindows } : {}),
88
106
  updatedAt: Date.now(),
89
107
  };
108
+ if (decisionQuotaSinkActive()) bindRawDecisionWindows(quota, rawAnthropicUsageWindows(body, quota.updatedAt));
90
109
  // Empty / schema-changed payloads must not cache as "success with no bars".
91
- return hasQuotaRows(quota) ? markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
110
+ if (!hasQuotaRows(quota)) return { quota: null, failure: "response_unusable" };
111
+ return { quota: markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
92
112
  const kind = asRecord(raw)?.kind;
93
113
  return kind === "session" || kind === "weekly_all" || kind === "weekly_scoped" && parseClaudeLimit(raw) !== null;
94
- })) : null;
114
+ })) };
95
115
  }
96
116
 
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry";
4
4
  import { routedSlug, slugEquivalenceKey } from "./slug-codec";
5
5
  import { comboDisabledModelSelectors } from "../combos/types";
6
- import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store";
6
+ import { providerUsesKeyAuthOverride } from "./key-store";
7
7
 
8
8
  export const INITIAL_MODEL_SELECTION_THRESHOLD = 20;
9
9
  type Selection = NonNullable<OcxProviderConfig["initialModelSelection"]>;
@@ -31,7 +31,7 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean {
31
31
  if (entry && providerMatchesRegistryTransport(name, provider)) {
32
32
  if (entry.authKind === "forward") return true;
33
33
  if (entry.authKind === "oauth") {
34
- return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey));
34
+ return !providerUsesKeyAuthOverride(entry, provider);
35
35
  }
36
36
  }
37
37
  return provider.authMode === "oauth" || provider.authMode === "forward";
@@ -12,6 +12,7 @@ import { isKeyInCooldown, getKeyCooldownUntil, setKeyCooldown } from "./api-key-
12
12
  export { clearKeyCooldowns, getKeyCooldownUntil, sweepExpiredApiKeyCooldowns } from "./api-key-cooldowns";
13
13
  import { commitProviderApiKeySelection, providerApiKeySelectionIsCurrent } from "./api-key-selection";
14
14
  import { RequestPacingQueueOverloadError } from "./request-pacing";
15
+ import { isCanonicalMistralChat } from "./mistral-transport";
15
16
  import type { ProviderApiKeySelection } from "../types/provider";
16
17
  import { routedProviderConfig } from "./routed-config";
17
18
  import { getProviderRegistryEntry } from "./registry";
@@ -493,9 +494,9 @@ export function selectProactiveApiKey(
493
494
 
494
495
  const activeEntry = pool.find(entry => entry.key === provider.apiKey);
495
496
  // A healthy committed key wins, whether the operator chose it or a previous rotation did.
496
- if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
497
+ if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
497
498
 
498
- const eligible = pool.filter(entry => !isKeyInCooldown(providerName, entry.id, now));
499
+ const eligible = pool.filter(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now));
499
500
  if (eligible.length === 0) return null;
500
501
 
501
502
  let chosen = eligible[0]!;
@@ -504,7 +505,7 @@ export function selectProactiveApiKey(
504
505
  const lastIndex = lastId ? pool.findIndex(entry => entry.id === lastId) : -1;
505
506
  for (let offset = 1; offset <= pool.length; offset += 1) {
506
507
  const candidate = pool[(lastIndex + offset) % pool.length]!;
507
- if (isKeyInCooldown(providerName, candidate.id, now)) continue;
508
+ if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
508
509
  chosen = candidate;
509
510
  break;
510
511
  }
@@ -518,11 +519,11 @@ export function selectProactiveApiKey(
518
519
  const outcome = commitProviderApiKeySelection<string | null>(config, providerName, freshProvider => {
519
520
  const freshPool = freshProvider.apiKeyPool ?? [];
520
521
  const target = freshPool.find(entry => entry.id === chosen.id);
521
- if (!target) return { changed: false, value: null };
522
+ if (!target || target.disabled) return { changed: false, value: null };
522
523
  if (freshProvider.apiKey === target.key) return { changed: false, value: null };
523
524
  const freshActive = freshPool.find(entry => entry.key === freshProvider.apiKey);
524
525
  // Re-check under the lock: a concurrent manual selection may have landed a healthy key.
525
- if (freshActive && !isKeyInCooldown(providerName, freshActive.id, now)) {
526
+ if (freshActive && !freshActive.disabled && !isKeyInCooldown(providerName, freshActive.id, now)) {
526
527
  return { changed: false, value: null };
527
528
  }
528
529
  freshProvider.apiKey = target.key;
@@ -564,7 +565,7 @@ export function selectProactiveApiKeyTransport(
564
565
 
565
566
  const dispatchKeyCursor = new Map<string, string>();
566
567
  function allocateDistributedKey(providerName: string, provider: OcxProviderConfig, now: number): OcxProviderConfig {
567
- const pool = (provider.apiKeyPool ?? []).filter((entry, index, all) => all.findIndex(other => other.key === entry.key) === index);
568
+ const pool = (provider.apiKeyPool ?? []).filter(entry => !entry.disabled).filter((entry, index, all) => all.findIndex(other => other.key === entry.key) === index);
568
569
  const previous = dispatchKeyCursor.get(providerName);
569
570
  const start = pool.findIndex(entry => entry.id === previous);
570
571
  for (let offset = 1; offset <= pool.length; offset++) {
@@ -575,7 +576,7 @@ function allocateDistributedKey(providerName: string, provider: OcxProviderConfi
575
576
  entryId: entry.id, reference: entry.key, revision: provider.apiKeySelectionRevision, distributed: true,
576
577
  } };
577
578
  }
578
- const next = Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000));
579
+ const next = pool.length ? Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000)) : now + 1000;
579
580
  throw new RequestPacingQueueOverloadError(providerName, "keys_exhausted", Math.max(1, Math.ceil((next - now) / 1000)));
580
581
  }
581
582
 
@@ -585,7 +586,7 @@ function allocateDistributedKey(providerName: string, provider: OcxProviderConfi
585
586
  * (subscription traffic such as Muse Spark) falls back to a patient same-key policy so a
586
587
  * burst 429 waits and replays instead of surfacing to the client and aborting a long
587
588
  * session; key-auth Command Code at its canonical endpoints gets the same fallback (#5180), and
588
- * every other provider without the knob keeps today's fail-fast behavior.
589
+ * canonical Mistral Chat uses a slower fallback; other destinations stay fail-fast.
589
590
  * OAuth/forward/local credentials are never replayed on the same token. The returned
590
591
  * policy is fully defaulted so callers never re-check fields.
591
592
  */
@@ -609,8 +610,15 @@ export function rateLimitRetryPolicyFor(
609
610
  respectRetryAfter: policy.respectRetryAfter ?? DEFAULT_RATE_LIMIT_RETRY.respectRetryAfter,
610
611
  };
611
612
  }
612
- // No explicit knob: patient fallback for OpenCode Go and canonical Command Code only.
613
+ // No explicit knob: destination-specific fallbacks, never OAuth or local credentials.
613
614
  if (provider.authMode !== undefined && provider.authMode !== "key") return null;
615
+ // Mistral's token-throughput and temporary capacity refusals both use HTTP 429.
616
+ // Keep relays and other wire adapters opt-in, and reuse the existing send budget.
617
+ if (typeof provider.baseUrl === "string" && typeof provider.adapter === "string" && isCanonicalMistralChat({
618
+ adapter: provider.adapter, authMode: provider.authMode, baseUrl: provider.baseUrl,
619
+ })) {
620
+ return { ...DEFAULT_RATE_LIMIT_RETRY, attempts: 6, intervalMs: 10_000 };
621
+ }
614
622
  if (isOpenCodeGoDestination(provider)) return { ...OPENCODE_GO_RATE_LIMIT_RETRY };
615
623
  // Command Code's Provider API rate-limits long muse-spark turns the same way (#5180): a single
616
624
  // key cannot fail over, and the Codex client does not retry a 429, so the turn aborts. The key
@@ -711,7 +719,7 @@ export function recordDistributedKeyFailure(
711
719
  const provider = config.providers[providerName];
712
720
  if (!selection?.distributed || !provider || provider.authMode === "oauth" || provider.authMode === "forward") return false;
713
721
  const failed = provider.apiKeyPool?.find(entry => entry.id === selection.entryId && entry.key === selection.reference);
714
- if (!failed || provider.apiKeySelectionRevision !== selection.revision) return false;
722
+ if (!failed || failed.disabled || provider.apiKeySelectionRevision !== selection.revision) return false;
715
723
  const delay = status === "billing" ? ANTHROPIC_BILLING_COOLDOWN_MS : status === 401 ? MAX_COOLDOWN_MS : quotaResetAt !== undefined
716
724
  ? Math.max(1, quotaResetAt - now) : parseRetryAfterMs(retryAfter, now) ?? DEFAULT_COOLDOWN_MS;
717
725
  for (const entry of provider.apiKeyPool ?? []) if (entry.key === failed.key) {
@@ -739,7 +747,7 @@ function rotateKeyAfterFailure(
739
747
  if (attemptedSelection?.distributed) {
740
748
  if (acceptsFreshProvider && !acceptsFreshProvider(provider)) return null;
741
749
  if (!recordDistributedKeyFailure(config, providerName, attemptedSelection, failureStatus, now, retryAfterHeader, quotaResetAt)) return null;
742
- if (!(provider.apiKeyPool ?? []).some(entry => !isKeyInCooldown(providerName, entry.id, now))) return null;
750
+ if (!(provider.apiKeyPool ?? []).some(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now))) return null;
743
751
  return allocateDistributedKey(providerName, provider, now);
744
752
  }
745
753
 
@@ -764,7 +772,7 @@ function rotateKeyAfterFailure(
764
772
 
765
773
  if (freshProvider.apiKey !== failedKey) {
766
774
  const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
767
- if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
775
+ if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) {
768
776
  return {
769
777
  changed: false,
770
778
  value: { failedId: failedEntry?.id },
@@ -776,7 +784,7 @@ function rotateKeyAfterFailure(
776
784
  const candidateCount = failedEntry ? pool.length - 1 : pool.length;
777
785
  for (let offset = 1; offset <= candidateCount; offset += 1) {
778
786
  const candidate = pool[(currentIndex + offset) % pool.length]!;
779
- if (isKeyInCooldown(providerName, candidate.id, now)) continue;
787
+ if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
780
788
  freshProvider.apiKey = candidate.key;
781
789
  return {
782
790
  changed: true,
@@ -32,14 +32,19 @@ export type {
32
32
  ProviderKeyStoreKind,
33
33
  } from "./api-key-resolve";
34
34
 
35
- /** Shared with routing: a key-mode override is effective only while its key resolves. */
35
+ /**
36
+ * Shared with routing: a key-mode override is operator intent, not a credential probe.
37
+ * The configured authMode alone decides the lane; a key that cannot be resolved right now
38
+ * (paused last key, missing env var, disabled pool) fails closed at the transport and must
39
+ * never implicitly reclassify the provider to the registry's OAuth lane, where a stored
40
+ * OAuth account would be billed instead of the operator's key.
41
+ */
36
42
  export function providerUsesKeyAuthOverride(
37
43
  entry: Pick<ProviderRegistryEntry, "authKind" | "allowKeyAuthOverride">,
38
44
  provider: Pick<OcxProviderConfig, "authMode">,
39
- resolvedKey: string | undefined,
40
45
  ): boolean {
41
46
  return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true
42
- && provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0;
47
+ && provider.authMode === "key";
43
48
  }
44
49
 
45
50
  /**