@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -21,11 +21,47 @@
21
21
 
22
22
  import {
23
23
  sharedSpendLedger,
24
+ spendCeilingsConfigured,
24
25
  type SpendReservationLedger,
25
26
  type SpendScope,
26
27
  type SpendUsage,
27
28
  } from "./spend-reservation-ledger";
28
29
 
30
+ /**
31
+ * What a token-ceiling refusal has to be able to say.
32
+ *
33
+ * "Budget exhausted" on its own is the failure this repository keeps re-learning: a policy
34
+ * rejection wearing another error's clothing sends an operator to look at the provider. The
35
+ * scope says WHICH ceiling fired -- one task, one account, or the whole pool -- and the limit
36
+ * is the number they would otherwise have to read the journal to recover. The scope ID is
37
+ * deliberately not here: root ids are client thread headers and identity ids are credentials,
38
+ * and the ledger's rule is that neither is written down in the clear.
39
+ */
40
+ export interface WorkflowSpendDenialDetail {
41
+ readonly scope: SpendScope;
42
+ readonly limit: number;
43
+ /** Tokens the refused reservation would have taken the scope to, where that is known. */
44
+ readonly projected?: number;
45
+ }
46
+
47
+ /** Operator-facing name for each scope. What an operator calls it, not what the type calls it. */
48
+ const SPEND_SCOPE_LABEL: Record<SpendScope, string> = {
49
+ root: "task",
50
+ identity: "account",
51
+ pool: "provider pool",
52
+ };
53
+
54
+ /**
55
+ * Thousands separators, done here rather than by `toLocaleString`.
56
+ *
57
+ * A ceiling is an eight- or nine-digit number and an unseparated one is genuinely hard to read
58
+ * against the figure beside it. `toLocaleString` would do this too, but its output depends on
59
+ * the ICU data the runtime happens to carry, and a message a test pins must not differ between
60
+ * a developer's machine and a CI image.
61
+ */
62
+ const formatTokenCount = (tokens: number): string =>
63
+ Math.trunc(tokens).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
64
+
29
65
  export interface WorkflowBudgetPolicy {
30
66
  /** Children admitted concurrently under one root. */
31
67
  readonly maxConcurrentChildren: number;
@@ -172,7 +208,10 @@ export type WorkflowDenial =
172
208
  * therefore says which ceiling fired AND that no provider was contacted, because that is the
173
209
  * first thing an operator needs and the only place left to put it.
174
210
  */
175
- export function workflowDenialSummary(reason: WorkflowDenial): { code: string; message: string } {
211
+ export function workflowDenialSummary(
212
+ reason: WorkflowDenial,
213
+ spend?: WorkflowSpendDenialDetail,
214
+ ): { code: string; message: string } {
176
215
  switch (reason) {
177
216
  case "workflow-sends-exhausted":
178
217
  return {
@@ -197,8 +236,21 @@ export function workflowDenialSummary(reason: WorkflowDenial): { code: string; m
197
236
  case "workflow-spend-exhausted":
198
237
  return {
199
238
  code: "workflow_spend_exhausted",
200
- message: "This proxy refused the request locally: the task reached a configured token"
201
- + " ceiling, so no provider was contacted.",
239
+ // With the denial in hand the sentence names the ceiling that fired and its number,
240
+ // because the alternative is an operator who can see that something refused and has
241
+ // no way to find out what. Without one -- a caller that knows only the reason -- the
242
+ // original sentence is kept unchanged.
243
+ message: spend
244
+ ? "This proxy refused the request locally: the configured " + SPEND_SCOPE_LABEL[spend.scope]
245
+ + " token ceiling of " + formatTokenCount(spend.limit) + " is spent"
246
+ + (spend.projected !== undefined
247
+ ? " (this send would have taken it to " + formatTokenCount(spend.projected) + ")"
248
+ : "")
249
+ + ", so no provider was contacted. Spend is durable, so it does not roll forward"
250
+ + " with the send window: raise or remove spend." + spend.scope
251
+ + ".maxTokens in config.json to grant more."
252
+ : "This proxy refused the request locally: the task reached a configured token"
253
+ + " ceiling, so no provider was contacted.",
202
254
  };
203
255
  case "workflow-tracking-exhausted":
204
256
  return {
@@ -241,6 +293,10 @@ export interface WorkflowBudgetEvent {
241
293
  readonly rootId: string;
242
294
  /** The ceiling that fired. Present for `refused`, absent for `cleared`. */
243
295
  readonly reason?: WorkflowDenial;
296
+ /** Which token scope refused, on a spend denial. Absent on every count denial. */
297
+ readonly spendScope?: SpendScope;
298
+ /** That scope's ceiling, so the event is readable without the config open beside it. */
299
+ readonly spendLimit?: number;
244
300
  /** Windowed sends at the moment of the event. */
245
301
  readonly sends: number;
246
302
  /** Windowed distinct children at the moment of the event. */
@@ -289,6 +345,7 @@ export function recordWorkflowRefusalEvent(
289
345
  rootId: string | undefined,
290
346
  reason: WorkflowDenial,
291
347
  now: number = Date.now(),
348
+ spend?: WorkflowSpendDenialDetail,
292
349
  ): void {
293
350
  if (!rootId) return;
294
351
  const state = roots.get(rootId);
@@ -297,6 +354,7 @@ export function recordWorkflowRefusalEvent(
297
354
  kind: "refused",
298
355
  rootId,
299
356
  reason,
357
+ ...(spend ? { spendScope: spend.scope, spendLimit: spend.limit } : {}),
300
358
  sends: state ? windowedSends(state, now) : 0,
301
359
  children: state ? windowedChildren(state, now) : 0,
302
360
  });
@@ -323,6 +381,10 @@ export type WorkflowDecision =
323
381
  rootId: string;
324
382
  /** Which spend scope refused, when the denial came from the token ledger. */
325
383
  spendScope?: SpendScope;
384
+ /** That scope's configured ceiling, so a caller can say what it was. */
385
+ spendLimit?: number;
386
+ /** Tokens the refused reservation would have taken the scope to, where known. */
387
+ spendProjected?: number;
326
388
  };
327
389
 
328
390
  /**
@@ -424,24 +486,40 @@ export function admitWorkflowTurn(
424
486
  ): WorkflowDecision | undefined {
425
487
  if (!rootId) return undefined;
426
488
  // An explicit ledger is consulted even without a spend request, so root eviction can
427
- // still see spend-exhausted entries. With neither, no token tracking is in play.
428
- const ledger = spendLedger ?? (spend ? sharedSpendLedger() : undefined);
489
+ // still see spend-exhausted entries. The shared one is resolved whenever a ceiling is
490
+ // CONFIGURED, which is what lets admission refuse an already-spent scope before a body is
491
+ // parsed. An install that configured nothing resolves no ledger, opens no journal, and runs
492
+ // this function exactly as it did before -- the unconfigured path has to stay byte-identical
493
+ // because the ledger is on and journalling by default.
494
+ const ledger = spendLedger ?? (spend || spendCeilingsConfigured() ? sharedSpendLedger() : undefined);
429
495
  let state = roots.get(rootId);
430
496
  // Every refusal below goes on the record through this one seam. Recording at each return
431
497
  // site instead of at the HTTP caller is what makes the record complete: the spend denials
432
498
  // are decided inside the ledger branch and never surface as a distinct reason to the caller
433
499
  // that formats the response.
434
- const refuse = (reason: WorkflowDenial, spendScope?: SpendScope): WorkflowDecision => {
500
+ const refuse = (reason: WorkflowDenial, denial?: WorkflowSpendDenialDetail): WorkflowDecision => {
435
501
  const current = roots.get(rootId);
436
502
  recordBudgetEvent({
437
503
  at: now,
438
504
  kind: "refused",
439
505
  rootId,
440
506
  reason,
507
+ ...(denial ? { spendScope: denial.scope, spendLimit: denial.limit } : {}),
441
508
  sends: current ? windowedSends(current, now) : 0,
442
509
  children: current ? windowedChildren(current, now) : 0,
443
510
  });
444
- return { admitted: false, reason, rootId, ...(spendScope ? { spendScope } : {}) };
511
+ return {
512
+ admitted: false,
513
+ reason,
514
+ rootId,
515
+ ...(denial
516
+ ? {
517
+ spendScope: denial.scope,
518
+ spendLimit: denial.limit,
519
+ ...(denial.projected !== undefined ? { spendProjected: denial.projected } : {}),
520
+ }
521
+ : {}),
522
+ };
445
523
  };
446
524
  if (!state) {
447
525
  if (roots.size >= policy.maxTrackedRoots && !evictOneRoot(policy, ledger, now)) {
@@ -469,6 +547,26 @@ export function admitWorkflowTurn(
469
547
  return refuse("workflow-concurrency-exhausted");
470
548
  }
471
549
 
550
+ // The counts are checked first and the token ceiling second, and the order is deliberate
551
+ // rather than emergent. A count check reads two integers this process already holds; a token
552
+ // check may have to build the ledger and replay its journal. Checking the cheap bound first
553
+ // means the expensive one is never reached for a request the cheap one already refused.
554
+ //
555
+ // The two therefore CAN disagree, and the intersection is what is enforced: a request passes
556
+ // only when every count cap and every token ceiling admits it. A token denial happens before
557
+ // any count is charged, and a count denial happens before any reservation is booked, so
558
+ // neither leaves the other's accounting to unwind. Whichever refuses first is reported as
559
+ // itself -- one refusal is never relabelled as the other, because "sends exhausted" and
560
+ // "spend exhausted" send an operator to two different remedies.
561
+ //
562
+ // A scope whose ceiling is ALREADY spent is refused here rather than at the reservation. The
563
+ // reservation needs a token count, which is not known until the body is parsed and a route
564
+ // resolved; an exhausted scope needs neither and is the cheapest refusal available.
565
+ if (!spend && ledger) {
566
+ const reached = spentRootCeiling(rootId, ledger);
567
+ if (reached) return refuse("workflow-spend-exhausted", reached);
568
+ }
569
+
472
570
  if (spend && ledger) {
473
571
  const decision = ledger.reserve({
474
572
  sendId: spend.sendId,
@@ -491,7 +589,9 @@ export function admitWorkflowTurn(
491
589
  : "workflow-spend-exhausted";
492
590
  return refuse(
493
591
  reason,
494
- denial.reason === "spend-limit-exceeded" ? denial.scope : undefined,
592
+ denial.reason === "spend-limit-exceeded"
593
+ ? { scope: denial.scope, limit: denial.limit, projected: denial.projected }
594
+ : undefined,
495
595
  );
496
596
  }
497
597
  }
@@ -600,6 +700,43 @@ export function workflowSendCeilingReached(
600
700
  return state !== undefined && windowedSends(state, now) >= policy.maxPhysicalSends;
601
701
  }
602
702
 
703
+ /**
704
+ * The root scope's ceiling when that scope is already spent, or undefined.
705
+ *
706
+ * Root only: identity and pool are not known until routing has picked an account, so those two
707
+ * refuse at the reservation itself. `exhausted` sums settled spend, open reservations and
708
+ * unresolved spend, which is the same total the reservation compares, so this answers the same
709
+ * question the reservation would -- just without needing the request's token count.
710
+ */
711
+ function spentRootCeiling(
712
+ rootId: string,
713
+ ledger: SpendReservationLedger,
714
+ ): WorkflowSpendDenialDetail | undefined {
715
+ const limit = ledger.policy.root.maxTokens;
716
+ if (limit === undefined) return undefined;
717
+ return ledger.exhausted("root", rootId) ? { scope: "root", limit } : undefined;
718
+ }
719
+
720
+ /**
721
+ * The token ceiling a root has already spent, or undefined when it has room or has none.
722
+ *
723
+ * The count-side twin of {@link workflowSendCeilingReached}, and the responses path calls both
724
+ * at the same seam for the same reason: a refusal decided before dispatch can be reported as
725
+ * ITSELF -- a named ceiling, a synthetic log row, a machine-readable header -- instead of
726
+ * surfacing later as a generic send-budget error from whichever leg happened to run out first.
727
+ *
728
+ * Returns undefined when no ceiling is configured, without resolving a ledger, so an install
729
+ * that never opted in neither pays for this check nor opens a journal because of it.
730
+ */
731
+ export function workflowSpendCeilingReached(
732
+ rootId: string | undefined,
733
+ spendLedger?: SpendReservationLedger,
734
+ ): WorkflowSpendDenialDetail | undefined {
735
+ if (!rootId) return undefined;
736
+ const ledger = spendLedger ?? (spendCeilingsConfigured() ? sharedSpendLedger() : undefined);
737
+ return ledger ? spentRootCeiling(rootId, ledger) : undefined;
738
+ }
739
+
603
740
  export interface WorkflowBudgetSnapshot {
604
741
  active: number;
605
742
  /** Sends inside the window. This is the number the ceiling compares. */
@@ -13,6 +13,38 @@
13
13
  import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../providers/quota";
14
14
  import { getKiroAccountExhaustion } from "../providers/kiro-usage";
15
15
 
16
+ /** Antigravity hosts Gemini and Claude windows on one account; ranking must not mix them. */
17
+ export type QuotaModelFamily = "gem" | "cla";
18
+
19
+ export function classifyModelFamilyForQuota(
20
+ provider: string,
21
+ modelId?: string | null,
22
+ ): QuotaModelFamily | undefined {
23
+ if (provider !== "google-antigravity" || typeof modelId !== "string" || !modelId.trim()) {
24
+ return undefined;
25
+ }
26
+ const id = modelId.toLowerCase();
27
+ // Gemma is not Gemini: a substring/prefix match would poison Gemini ranking.
28
+ if (/(?:^|[^a-z])gemma(?:[^a-z]|$)/.test(id)) return undefined;
29
+ // Catalog ids are gemini-*, never a bare gem- token. Window labels still match Gem via
30
+ // windowMatchesFamily; this classifier is only for request model ids.
31
+ if (/(?:^|[^a-z])gemini(?:[^a-z]|$)/.test(id)) return "gem";
32
+ if (
33
+ /(?:^|[^a-z])claude(?:[^a-z]|$)/.test(id)
34
+ || /(?:^|[^a-z])opus(?:[^a-z]|$)/.test(id)
35
+ || /(?:^|[^a-z])sonnet(?:[^a-z]|$)/.test(id)
36
+ || /(?:^|[^a-z])haiku(?:[^a-z]|$)/.test(id)
37
+ || /(?:^|[^a-z])gpt[-_]oss(?:[^a-z]|$)/.test(id)
38
+ ) return "cla";
39
+ return undefined;
40
+ }
41
+
42
+ function windowMatchesFamily(label: string, family: QuotaModelFamily): boolean {
43
+ const token = label.trim().split(/[\s(/]+/)[0] ?? "";
44
+ if (family === "gem") return /^gem(?:ini)?$/i.test(token);
45
+ return /^cla(?:ude)?$/i.test(token);
46
+ }
47
+
16
48
  /** Lower sorts earlier. Unknown sits between measured-healthy and measured-empty. */
17
49
  const RANK_HEALTHY = 0;
18
50
  const RANK_UNKNOWN = 1;
@@ -48,15 +80,24 @@ const PASSIVE_HEADROOM_MAX_AGE_MS = 60 * 60_000;
48
80
  /**
49
81
  * Remaining headroom across every window the provider reports.
50
82
  *
51
- * The minimum wins: an account at 5% of its five-hour window is unusable right now even if
52
- * its monthly allowance is barely touched.
53
- */
54
- function headroomOf(provider: string, accountId: string): number | null {
83
+ * The minimum wins: an account at 5% of its five-hour window is unusable right now even if
84
+ * its monthly allowance is barely touched.
85
+ */
86
+ function headroomOf(provider: string, accountId: string, requestedModelId?: string | null): number | null {
55
87
  const quota = getCachedProviderAccountQuota(provider, accountId);
56
88
  if (!quota) return null;
57
89
  // Null, not a low rank: this must reproduce "no evidence" so a stale roster degrades to
58
90
  // the unranked ring rather than to a differently wrong answer.
59
91
  if (hasPassiveAccountQuota(provider) && Date.now() - quota.updatedAt > PASSIVE_HEADROOM_MAX_AGE_MS) return null;
92
+ const family = classifyModelFamilyForQuota(provider, requestedModelId);
93
+ if (family) {
94
+ const percents = (quota.customWindows ?? [])
95
+ .filter(window => windowMatchesFamily(window.label, family))
96
+ .map(window => window.percent)
97
+ .filter((value): value is number => typeof value === "number");
98
+ if (percents.length === 0) return null;
99
+ return 100 - Math.max(...percents);
100
+ }
60
101
  const percents = [
61
102
  quota.fiveHourPercent,
62
103
  quota.weeklyPercent,
@@ -74,15 +115,23 @@ function headroomOf(provider: string, accountId: string): number | null {
74
115
  * than an ordering. Null stays null all the way out: a caller must decide what "unmeasured"
75
116
  * means for its own rule instead of being handed a fabricated 0 or 100.
76
117
  */
77
- export function accountHeadroomPercent(provider: string, accountId: string): number | null {
78
- return headroomOf(provider, accountId);
118
+ export function accountHeadroomPercent(
119
+ provider: string,
120
+ accountId: string,
121
+ requestedModelId?: string | null,
122
+ ): number | null {
123
+ return headroomOf(provider, accountId, requestedModelId);
79
124
  }
80
125
 
81
126
  /** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */
82
- export function isAccountQuotaExhausted(provider: string, accountId: string): boolean {
127
+ export function isAccountQuotaExhausted(
128
+ provider: string,
129
+ accountId: string,
130
+ requestedModelId?: string | null,
131
+ ): boolean {
83
132
  const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null;
84
133
  if (exhaustion !== null) return exhaustion.exhausted;
85
- const headroom = headroomOf(provider, accountId);
134
+ const headroom = headroomOf(provider, accountId, requestedModelId);
86
135
  return headroom !== null && headroom <= 0;
87
136
  }
88
137
 
@@ -92,24 +141,28 @@ export function isAccountQuotaExhausted(provider: string, accountId: string): bo
92
141
  * Returns the input untouched when no candidate has quota evidence, which keeps every
93
142
  * provider without per-account quota on exactly the behaviour it has today.
94
143
  */
95
- export function rankAccountsByHeadroom(provider: string, ring: readonly string[]): string[] {
144
+ export function rankAccountsByHeadroom(
145
+ provider: string,
146
+ ring: readonly string[],
147
+ requestedModelId?: string | null,
148
+ ): string[] {
96
149
  if (ring.length < 2) return [...ring];
97
150
 
98
151
  let sawEvidence = false;
99
152
  // Same rule as hasHeadroomEvidence: a passive provider's partial roster must not rank
100
153
  // at all. The failover path calls this directly (selectFailoverAccount), so the guard
101
154
  // cannot live only in the pre-dispatch predicate.
102
- if (hasPassiveAccountQuota(provider) && !ring.every(id => headroomOf(provider, id) !== null)) {
155
+ if (hasPassiveAccountQuota(provider) && !ring.every(id => headroomOf(provider, id, requestedModelId) !== null)) {
103
156
  return [...ring];
104
157
  }
105
158
  const ranked: Ranked[] = ring.map((id, index) => {
106
159
  // A provider-declared exhaustion verdict outranks the percentage: an account may sit at
107
160
  // 100% and still be servable when overage is enabled, and the verdict knows that.
108
161
  const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${id}`) : null;
109
- const headroom = headroomOf(provider, id);
162
+ const headroom = headroomOf(provider, id, requestedModelId);
110
163
  if (exhaustion !== null || headroom !== null) sawEvidence = true;
111
164
 
112
- if (isAccountQuotaExhausted(provider, id)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
165
+ if (isAccountQuotaExhausted(provider, id, requestedModelId)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
113
166
  if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
114
167
  return { id, bucket: RANK_HEALTHY, headroom, index };
115
168
  });
@@ -129,7 +182,11 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
129
182
  * told "ranked" when nothing was measured. Pre-dispatch selection asks this first so it
130
183
  * can decline to act on a roster it knows nothing about.
131
184
  */
132
- export function hasHeadroomEvidence(provider: string, ids: readonly string[]): boolean {
185
+ export function hasHeadroomEvidence(
186
+ provider: string,
187
+ ids: readonly string[],
188
+ requestedModelId?: string | null,
189
+ ): boolean {
133
190
  // A PASSIVE provider needs evidence for EVERY candidate, not any one of them.
134
191
  //
135
192
  // A probe fills the whole roster in one pass (fetchProviderAccountQuotas), so "any"
@@ -140,10 +197,10 @@ export function hasHeadroomEvidence(provider: string, ids: readonly string[]): b
140
197
  // AWAY from an unmeasured account and TOWARD the one account known to be spent, which
141
198
  // is the exact inversion of what ranking is for.
142
199
  if (hasPassiveAccountQuota(provider)) {
143
- return ids.length > 0 && ids.every(id => headroomOf(provider, id) !== null);
200
+ return ids.length > 0 && ids.every(id => headroomOf(provider, id, requestedModelId) !== null);
144
201
  }
145
202
  return ids.some(id =>
146
- headroomOf(provider, id) !== null
203
+ headroomOf(provider, id, requestedModelId) !== null
147
204
  || (provider === "kiro" && getKiroAccountExhaustion(`${provider}\u0000${id}`) !== null));
148
205
  }
149
206
  /**
@@ -22,6 +22,8 @@ import {
22
22
  hasHeadroomEvidence,
23
23
  isAccountQuotaExhausted,
24
24
  rankAccountsByHeadroom,
25
+ classifyModelFamilyForQuota,
26
+ type QuotaModelFamily,
25
27
  } from "./account-quota-rank";
26
28
  import {
27
29
  genericPoolKey,
@@ -81,13 +83,14 @@ const health = new Map<string, AccountHealth>();
81
83
  /** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
82
84
  const presence = new Map<string, PresenceEntry>();
83
85
 
84
- const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
86
+ const healthKey = (provider: string, accountId: string, family?: QuotaModelFamily) =>
87
+ family ? `${provider}\u0000${accountId}\u0000${family}` : `${provider}\u0000${accountId}`;
85
88
 
86
- function isCooled(provider: string, accountId: string, now: number): boolean {
87
- const entry = health.get(healthKey(provider, accountId));
89
+ function isCooled(provider: string, accountId: string, now: number, family?: QuotaModelFamily): boolean {
90
+ const entry = health.get(healthKey(provider, accountId, family));
88
91
  if (!entry) return false;
89
92
  if (entry.cooldownUntil <= now) {
90
- health.delete(healthKey(provider, accountId));
93
+ health.delete(healthKey(provider, accountId, family));
91
94
  return false;
92
95
  }
93
96
  return true;
@@ -175,11 +178,11 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n
175
178
  }
176
179
 
177
180
  /** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
178
- export function eligibleFailoverAccounts(providerName: string, now = Date.now()): string[] {
181
+ export function eligibleFailoverAccounts(providerName: string, now = Date.now(), family?: QuotaModelFamily): string[] {
179
182
  const set = getAccountSet(providerName);
180
183
  if (!set) return [];
181
184
  return set.accounts
182
- .filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now))
185
+ .filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
183
186
  .map(account => account.id);
184
187
  }
185
188
 
@@ -230,8 +233,8 @@ function stableGenericRoster(providerName: string): string[] {
230
233
  * statement about observed usage, and treating "no observation" as "spent" would evacuate every
231
234
  * quota-less provider off its active account on the very first request.
232
235
  */
233
- function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number): boolean {
234
- const headroom = accountHeadroomPercent(providerName, accountId);
236
+ function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
237
+ const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
235
238
  if (headroom === null) return false;
236
239
  return 100 - headroom >= threshold;
237
240
  }
@@ -245,15 +248,17 @@ function pickFillFirstGenericAccount(
245
248
  providerName: string,
246
249
  activeId: string | undefined,
247
250
  now: number,
251
+ requestedModelId?: string | null,
248
252
  ): string | null {
249
253
  const stableAll = stableGenericRoster(providerName);
250
254
  if (stableAll.length < 2) return null;
251
- const eligible = new Set(eligibleFailoverAccounts(providerName, now));
255
+ const family = classifyModelFamilyForQuota(providerName, requestedModelId);
256
+ const eligible = new Set(eligibleFailoverAccounts(providerName, now, family));
252
257
  const stored = config.providers?.[providerName]?.oauthAccountFailover?.autoSwitchThreshold;
253
258
  const threshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100
254
259
  ? stored
255
260
  : DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD;
256
- if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold)) {
261
+ if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold, requestedModelId)) {
257
262
  return null;
258
263
  }
259
264
  const start = activeId ? stableAll.indexOf(activeId) : -1;
@@ -277,11 +282,17 @@ function pickFillFirstGenericAccount(
277
282
  * advance and round-robin would propose the same account forever. This is the same shape
278
283
  * `commitAnthropicSelectionRouting` already commits with.
279
284
  */
280
- export function noteGenericPoolSelection(config: OcxConfig, providerName: string, accountId: string): void {
285
+ export function noteGenericPoolSelection(
286
+ config: OcxConfig,
287
+ providerName: string,
288
+ accountId: string,
289
+ requestedModelId?: string | null,
290
+ ): void {
281
291
  if (activeGenericStrategy(config, providerName) !== "round-robin") return;
282
292
  const poolKey = genericPoolKey(providerName);
283
293
  const limit = genericStickyLimit(config, providerName);
284
- const picked = pickRoundRobinAccount(poolKey, eligibleFailoverAccounts(providerName), limit);
294
+ const family = classifyModelFamilyForQuota(providerName, requestedModelId);
295
+ const picked = pickRoundRobinAccount(poolKey, eligibleFailoverAccounts(providerName, Date.now(), family), limit);
285
296
  // The resolver may have admitted a different account than the ring proposed: a removal, a
286
297
  // reauth verdict or a manual selection can land during credential resolution. Realign the
287
298
  // cursor onto what actually served rather than leaving it on a road not taken.
@@ -302,6 +313,7 @@ export function rotateGenericOAuthAccountOn429(
302
313
  failedAccountId: string,
303
314
  retryAfterHeader: string | null | undefined,
304
315
  now = Date.now(),
316
+ requestedModelId?: string | null,
305
317
  ): string | null {
306
318
  if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
307
319
  const set = getAccountSet(providerName);
@@ -314,13 +326,14 @@ export function rotateGenericOAuthAccountOn429(
314
326
  // A Retry-After from upstream still wins — it is the server's own instruction.
315
327
  const exhausted = parsed === undefined ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
316
328
  const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
317
- health.set(healthKey(providerName, failedAccountId), {
329
+ const family = classifyModelFamilyForQuota(providerName, requestedModelId);
330
+ health.set(healthKey(providerName, failedAccountId, family), {
318
331
  cooldownUntil: now + cooldownMs,
319
332
  cooldownSource: parsed ? "retry-after" : "default",
320
333
  });
321
334
  sweepExpiredOnWrite(now);
322
335
 
323
- const eligible = eligibleFailoverAccounts(providerName, now).filter(id => id !== failedAccountId);
336
+ const eligible = eligibleFailoverAccounts(providerName, now, family).filter(id => id !== failedAccountId);
324
337
  if (eligible.length === 0) return null;
325
338
  // A rotation means the roster in use just changed; do not answer the next activation question
326
339
  // from a count read before the failure.
@@ -359,7 +372,7 @@ export function rotateGenericOAuthAccountOn429(
359
372
  }
360
373
  // With no quota evidence this returns the ring untouched, so providers without
361
374
  // per-account quota keep exactly the traversal they have today.
362
- return rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
375
+ return rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
363
376
  }
364
377
 
365
378
  /**
@@ -392,6 +405,7 @@ export function preferredInitialAccount(
392
405
  config: OcxConfig,
393
406
  providerName: string,
394
407
  now = Date.now(),
408
+ requestedModelId?: string | null,
395
409
  ): string | null {
396
410
  // The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
397
411
  // refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
@@ -411,7 +425,8 @@ export function preferredInitialAccount(
411
425
  // would never reach its own test. Cooldowns and reauth are still honoured inside each pick.
412
426
  const strategy = activeGenericStrategy(config, providerName);
413
427
  if (strategy === "round-robin") {
414
- const eligibleNow = eligibleFailoverAccounts(providerName, now);
428
+ const family = classifyModelFamilyForQuota(providerName, requestedModelId);
429
+ const eligibleNow = eligibleFailoverAccounts(providerName, now, family);
415
430
  if (eligibleNow.length === 0) return null;
416
431
  // PEEK, not pick: this proposal is discardable, and advancing the ring for an account the
417
432
  // resolver then rejects would skip a turn for nothing. noteGenericPoolSelection commits.
@@ -423,26 +438,26 @@ export function preferredInitialAccount(
423
438
  return picked && picked !== active ? picked : null;
424
439
  }
425
440
  if (strategy === "fill-first") {
426
- const picked = pickFillFirstGenericAccount(config, providerName, active, now);
441
+ const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId);
427
442
  return picked && picked !== active ? picked : null;
428
443
  }
429
444
 
430
445
  const activeRow = selected.accounts.find(account => account.id === active);
431
446
  if (activeRow && activeRow.needsReauth !== true
432
- && !isCooled(providerName, activeRow.id, now)
433
- && !isAccountQuotaExhausted(providerName, activeRow.id)) return null;
447
+ && !isCooled(providerName, activeRow.id, now, classifyModelFamilyForQuota(providerName, requestedModelId))
448
+ && !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId)) return null;
434
449
 
435
450
  // Evidence is required BEFORE eligibility narrows the field. Without this, a provider
436
451
  // with no quota data at all could still be redirected: cool the active account with a
437
452
  // 429 and the eligible list collapses to one candidate, which any ranking returns
438
453
  // unchanged — an answer that looks ranked but was never measured. The no-op guarantee
439
454
  // for quota-less providers has to be checked on the full roster.
440
- if (!hasHeadroomEvidence(providerName, order)) return null;
455
+ if (!hasHeadroomEvidence(providerName, order, requestedModelId)) return null;
441
456
 
442
457
  // Cooldowns are respected here, unlike in the presence count: this picks the account to
443
458
  // send to right now, and one inside its 429 window is the single candidate we hold
444
459
  // positive evidence against.
445
- const eligible = order.filter(id => !isCooled(providerName, id, now));
460
+ const eligible = order.filter(id => !isCooled(providerName, id, now, classifyModelFamilyForQuota(providerName, requestedModelId)));
446
461
  if (eligible.length === 0) return null;
447
462
 
448
463
  // Start the ring at the active account so an unranked outcome reproduces today's choice.
@@ -451,7 +466,7 @@ export function preferredInitialAccount(
451
466
  const candidates = ring.filter(id => eligible.includes(id));
452
467
  if (candidates.length === 0) return null;
453
468
 
454
- const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
469
+ const best = rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
455
470
  // Nothing to do when the ranking agrees with the account we would have used anyway.
456
471
  //
457
472
  // A proposal still needs guarded selection commit after credential resolution: a
@@ -461,12 +476,10 @@ export function preferredInitialAccount(
461
476
 
462
477
  /** Earliest remaining cooldown, for a client-facing Retry-After when every account is cooled. */
463
478
  export function genericFailoverRetryAfterSeconds(providerName: string, now = Date.now()): number | null {
464
- const set = getAccountSet(providerName);
465
- if (!set) return null;
479
+ const prefix = `${providerName}\u0000`;
466
480
  let earliest: number | null = null;
467
- for (const account of set.accounts) {
468
- const entry = health.get(healthKey(providerName, account.id));
469
- if (!entry || entry.cooldownUntil <= now) continue;
481
+ for (const [key, entry] of health) {
482
+ if (!key.startsWith(prefix) || entry.cooldownUntil <= now) continue;
470
483
  if (earliest === null || entry.cooldownUntil < earliest) earliest = entry.cooldownUntil;
471
484
  }
472
485
  return earliest === null ? null : Math.max(1, Math.ceil((earliest - now) / 1000));
@@ -2,6 +2,7 @@
2
2
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
3
3
  import { generatePKCE } from "./pkce";
4
4
  import type { OAuthController, OAuthCredentials } from "./types";
5
+ import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
5
6
 
6
7
  export const ORCAROUTER_DEFAULT_API_BASE_URL = "https://api.orcarouter.ai";
7
8
  export const ORCAROUTER_DEFAULT_AUTH_BASE_URL = "https://www.orcarouter.ai";
@@ -147,6 +148,7 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
147
148
 
148
149
  async exchangeToken(code: string, _state: string, _redirectUri: string): Promise<OAuthCredentials> {
149
150
  if (!this.#verifier) throw new Error("OrcaRouter PKCE verifier was not initialized");
151
+ const signal = requestSignal(this.ctrl.signal);
150
152
  let response: Response;
151
153
  try {
152
154
  response = await fetch(new URL("/api/v1/auth/keys", this.#authBaseUrl), {
@@ -158,7 +160,7 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
158
160
  code_challenge_method: "S256",
159
161
  }),
160
162
  redirect: "error",
161
- signal: requestSignal(this.ctrl.signal),
163
+ signal,
162
164
  });
163
165
  } catch (error) {
164
166
  if (this.ctrl.signal?.aborted) {
@@ -171,9 +173,20 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
171
173
  // never turn a code, verifier, or accidentally returned key into console output.
172
174
  throw new Error(`OrcaRouter key exchange failed with HTTP ${response.status}`);
173
175
  }
176
+ const { bytes, oversized } = await readBoundedResponseBytes(response, {
177
+ maxBytes: BOUNDED_BODY_MAX_BYTES,
178
+ signal,
179
+ }).catch(() => {
180
+ if (signal.aborted) throw signal.reason;
181
+ // Preserve the existing non-reflective error for response-body failures.
182
+ throw new Error("OrcaRouter key exchange returned invalid JSON");
183
+ });
184
+ if (oversized) {
185
+ throw new Error(`OrcaRouter key exchange response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`);
186
+ }
174
187
  let payload: unknown;
175
188
  try {
176
- payload = await response.json();
189
+ payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
177
190
  } catch {
178
191
  throw new Error("OrcaRouter key exchange returned invalid JSON");
179
192
  }
@@ -437,6 +437,14 @@ function backupLegacyOnce(): void {
437
437
  try {
438
438
  copyFileSync(path, backup);
439
439
  try { chmodSync(backup, 0o600); } catch { /* best-effort */ }
440
+ try {
441
+ // Register only the copy we just created. An unowned home still needs downgrade recovery.
442
+ if (!recordOwnedConfigPath(getConfigDir(), backup)) {
443
+ console.warn("[oauth] Recovery backup created, but uninstall ownership registration failed.");
444
+ }
445
+ } catch {
446
+ console.warn("[oauth] Recovery backup created, but uninstall ownership registration failed.");
447
+ }
440
448
  } catch { /* best-effort */ }
441
449
  }
442
450