@bitkyc08/opencodex 2.88.0 → 2.89.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 (205) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
  7. package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  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/coding-agent/protocol.ts +6 -0
  14. package/src/adapters/google-http.ts +3 -2
  15. package/src/adapters/google-vertex-endpoint.ts +67 -0
  16. package/src/adapters/google.ts +35 -29
  17. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  18. package/src/adapters/openai-responses/passthrough.ts +17 -12
  19. package/src/adapters/openai-responses/reasoning.ts +68 -1
  20. package/src/adapters/registry.ts +1 -1
  21. package/src/adapters/upstream-http-error.ts +3 -2
  22. package/src/adapters/zed.ts +7 -4
  23. package/src/bridge/internal.ts +11 -1
  24. package/src/bridge/response-json.ts +2 -2
  25. package/src/bridge/sse.ts +10 -4
  26. package/src/claude/inbound.ts +12 -26
  27. package/src/claude/outbound.ts +5 -0
  28. package/src/cli/account-api.ts +13 -1
  29. package/src/cli/account-extended.ts +17 -5
  30. package/src/cli/account-key-quota.ts +4 -1
  31. package/src/cli/account.ts +1 -1
  32. package/src/cli/capabilities-agents-routing.ts +45 -8
  33. package/src/cli/capabilities-base.ts +5 -5
  34. package/src/cli/capabilities-provider-models.ts +5 -4
  35. package/src/cli/combo-result.ts +8 -2
  36. package/src/cli/combo.ts +17 -0
  37. package/src/cli/help.ts +1 -1
  38. package/src/cli/message-command.ts +4 -1
  39. package/src/cli/message-remote-args.ts +69 -0
  40. package/src/cli/message-remote-command.ts +77 -0
  41. package/src/cli/provider-runtime.ts +69 -1
  42. package/src/cli/registry.ts +3 -3
  43. package/src/codex/account-pause.ts +6 -1
  44. package/src/codex/account-runtime-state.ts +5 -0
  45. package/src/codex/account-store.ts +30 -3
  46. package/src/codex/auth-api/account-list.ts +3 -0
  47. package/src/codex/auth-api/main-account-probe.ts +3 -2
  48. package/src/codex/auth-context.ts +9 -4
  49. package/src/codex/catalog/aggregation.ts +15 -0
  50. package/src/codex/catalog/auto-review.ts +2 -1
  51. package/src/codex/catalog/build-entries.ts +17 -7
  52. package/src/codex/catalog/derive-entry.ts +3 -1
  53. package/src/codex/catalog/metadata.ts +2 -0
  54. package/src/codex/catalog/model-visibility.ts +0 -2
  55. package/src/codex/catalog/ownership.ts +71 -0
  56. package/src/codex/catalog/parsing.ts +12 -3
  57. package/src/codex/catalog/retained-sync.ts +2 -1
  58. package/src/codex/catalog/routed-removal.ts +4 -4
  59. package/src/codex/convergence.ts +2 -1
  60. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  61. package/src/codex/low-quota-protection.ts +57 -31
  62. package/src/codex/main-account-cache.ts +77 -3
  63. package/src/codex/main-account.ts +6 -2
  64. package/src/codex/native-residue.ts +2 -3
  65. package/src/codex/quota.ts +26 -2
  66. package/src/combos/failover.ts +2 -1
  67. package/src/combos/jev-model-backend.ts +30 -7
  68. package/src/combos/jev-model-request.ts +20 -0
  69. package/src/combos/jev-quota-activation.ts +153 -0
  70. package/src/combos/jev-quota-config.ts +62 -0
  71. package/src/combos/jev-quota-route.ts +26 -0
  72. package/src/combos/jev-quota.ts +88 -0
  73. package/src/combos/jev.ts +25 -12
  74. package/src/combos/types.ts +27 -0
  75. package/src/compatibility/index.ts +3 -0
  76. package/src/compatibility/openai-responses.ts +8 -1
  77. package/src/compatibility/opengateway-responses.ts +61 -0
  78. package/src/config/account-auto-pause-revisions.ts +136 -0
  79. package/src/config/diagnostics.ts +13 -0
  80. package/src/config/live-reconcile.ts +9 -0
  81. package/src/config/persist-unlocked.ts +10 -1
  82. package/src/config/removed-opencode-free.ts +135 -0
  83. package/src/config/schema/account-auto-pause.ts +14 -0
  84. package/src/config/schema/config-schema.ts +15 -0
  85. package/src/config/schema/leaf-validators.ts +7 -0
  86. package/src/config.ts +10 -3
  87. package/src/generated/compatibility-version.json +344 -144
  88. package/src/images/loop.ts +32 -9
  89. package/src/lib/account-auto-pause-response.ts +16 -0
  90. package/src/lib/bun-runtime.ts +18 -1
  91. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  92. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  93. package/src/lib/decision-quota-sink.ts +76 -0
  94. package/src/lib/state-store-registrations.ts +25 -0
  95. package/src/link/ssh-argv.ts +3 -0
  96. package/src/messaging/envelope.ts +6 -3
  97. package/src/messaging/remote-auth.ts +83 -0
  98. package/src/messaging/remote-bridge.ts +270 -0
  99. package/src/messaging/remote-contract.ts +108 -0
  100. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  101. package/src/messaging/remote-enrollment.ts +244 -0
  102. package/src/messaging/remote-envelope.ts +40 -0
  103. package/src/messaging/remote-files.ts +117 -0
  104. package/src/messaging/remote-owner.ts +169 -0
  105. package/src/messaging/remote-ports.ts +56 -0
  106. package/src/messaging/remote-process.ts +131 -0
  107. package/src/messaging/remote-replies.ts +98 -0
  108. package/src/messaging/remote-rpc-admission.ts +32 -0
  109. package/src/messaging/remote-send.ts +72 -0
  110. package/src/messaging/remote-store.ts +111 -0
  111. package/src/messaging/remote-tunnels.ts +65 -0
  112. package/src/messaging/rpc.ts +13 -1
  113. package/src/oauth/account-auto-pause-policy.ts +66 -0
  114. package/src/oauth/account-auto-pause.ts +171 -0
  115. package/src/oauth/account-quota-rank.ts +2 -1
  116. package/src/oauth/anthropic-account-refusal.ts +6 -0
  117. package/src/oauth/anthropic-model-family.ts +8 -0
  118. package/src/oauth/anthropic-model-quota.ts +2 -6
  119. package/src/oauth/api-key-pool-preserve.ts +37 -0
  120. package/src/oauth/index.ts +5 -35
  121. package/src/oauth/store.ts +35 -8
  122. package/src/oauth/types.ts +2 -0
  123. package/src/plugins/upstream-hooks.ts +14 -1
  124. package/src/providers/api-key-pause.ts +32 -0
  125. package/src/providers/api-key-resolve.ts +10 -4
  126. package/src/providers/api-key-selection.ts +6 -1
  127. package/src/providers/api-keys.ts +8 -4
  128. package/src/providers/claude-cli-usage.ts +5 -0
  129. package/src/providers/key-failover.ts +11 -11
  130. package/src/providers/model-rename-fields.ts +1 -0
  131. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  132. package/src/providers/quota/account-cache.ts +15 -1
  133. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  134. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  135. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  136. package/src/providers/quota/antigravity.ts +39 -6
  137. package/src/providers/quota/report-cache.ts +14 -0
  138. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  139. package/src/providers/quota-decision-publication.ts +77 -0
  140. package/src/providers/quota-decision-snapshot.ts +145 -0
  141. package/src/providers/quota-routing-cache.ts +3 -0
  142. package/src/providers/quota-types.ts +6 -1
  143. package/src/providers/quota.ts +3 -2
  144. package/src/providers/registry/entries-extended.ts +8 -52
  145. package/src/providers/registry/model-seeds.ts +1 -1
  146. package/src/providers/registry.ts +1 -2
  147. package/src/providers/stale-vision-classification-migration.ts +1 -1
  148. package/src/responses/parser.ts +18 -10
  149. package/src/responses/web-search-replay.ts +138 -0
  150. package/src/routing/compatibility/behavior.ts +4 -1
  151. package/src/server/auth-cors.ts +7 -0
  152. package/src/server/background-lifecycle.ts +2 -1
  153. package/src/server/chat-native.ts +28 -13
  154. package/src/server/claude-messages.ts +2 -1
  155. package/src/server/index/optional-listeners.ts +6 -0
  156. package/src/server/management/combo-routes.ts +17 -2
  157. package/src/server/management/oauth-account-routes.ts +10 -1
  158. package/src/server/management/provider-completion-test.ts +289 -0
  159. package/src/server/management/provider-routes.ts +9 -1
  160. package/src/server/management/route-registry.ts +2 -0
  161. package/src/server/messages-native.ts +18 -4
  162. package/src/server/messages-shared.ts +4 -1
  163. package/src/server/request-log.ts +7 -2
  164. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  165. package/src/server/responses/adapter-dispatch.ts +77 -28
  166. package/src/server/responses/combo-session-recall.ts +25 -0
  167. package/src/server/responses/combo-stream-preflight.ts +3 -1
  168. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  169. package/src/server/responses/compact.ts +8 -5
  170. package/src/server/responses/context-overflow.ts +141 -2
  171. package/src/server/responses/core-codex-account.ts +7 -1
  172. package/src/server/responses/core-combo.ts +31 -5
  173. package/src/server/responses/core.ts +12 -9
  174. package/src/server/responses/empty-completion-guard.ts +2 -0
  175. package/src/server/responses/fetch-helpers.ts +35 -4
  176. package/src/server/responses/jev-model-invoke.ts +13 -14
  177. package/src/server/responses/native-steering-log.ts +2 -2
  178. package/src/server/responses/passthrough-delivery.ts +3 -2
  179. package/src/server/responses/passthrough-dispatch.ts +30 -33
  180. package/src/server/responses/passthrough-observations.ts +38 -0
  181. package/src/server/responses/request-compression.ts +98 -0
  182. package/src/server/responses/request-transport.ts +32 -3
  183. package/src/server/responses/run-turn-execution.ts +2 -0
  184. package/src/server/responses/sidecar-execution.ts +16 -3
  185. package/src/server/responses/terminal-guard.ts +2 -0
  186. package/src/server/startup-action-control.ts +2 -2
  187. package/src/server/startup-health-cache.ts +2 -2
  188. package/src/server/windows-tray-control.ts +2 -2
  189. package/src/service/definition.ts +9 -1
  190. package/src/types/account-auto-pause.ts +15 -0
  191. package/src/types/config.ts +23 -1
  192. package/src/types/provider.ts +9 -5
  193. package/src/types/request.ts +20 -2
  194. package/src/types.ts +1 -0
  195. package/src/usage/cost.ts +17 -1
  196. package/src/usage/expected-prices.ts +3 -1
  197. package/src/usage/jev-stats.ts +4 -0
  198. package/src/usage/log.ts +2 -0
  199. package/src/usage/summary.ts +7 -0
  200. package/src/web-search/loop.ts +37 -11
  201. package/src/web-search/progress-stream.ts +4 -1
  202. package/src/web-search/run-turn-loop.ts +9 -3
  203. package/src/web-search/sidecar-search-replay.ts +96 -0
  204. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  205. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
@@ -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,4 +1,7 @@
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
7
  import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
@@ -41,6 +44,7 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null {
41
44
  return { label, scope: "model", percent, ...(resetAt !== undefined ? { resetAt } : {}) };
42
45
  }
43
46
 
47
+ /** 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
48
  export async function readAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
45
49
  const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
46
50
  headers: {
@@ -87,6 +91,7 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
87
91
  ...(customWindows.length > 0 ? { customWindows } : {}),
88
92
  updatedAt: Date.now(),
89
93
  };
94
+ if (decisionQuotaSinkActive()) bindRawDecisionWindows(quota, rawAnthropicUsageWindows(body, quota.updatedAt));
90
95
  // Empty / schema-changed payloads must not cache as "success with no bars".
91
96
  return hasQuotaRows(quota) ? markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
92
97
  const kind = asRecord(raw)?.kind;
@@ -493,9 +493,9 @@ export function selectProactiveApiKey(
493
493
 
494
494
  const activeEntry = pool.find(entry => entry.key === provider.apiKey);
495
495
  // 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;
496
+ if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
497
497
 
498
- const eligible = pool.filter(entry => !isKeyInCooldown(providerName, entry.id, now));
498
+ const eligible = pool.filter(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now));
499
499
  if (eligible.length === 0) return null;
500
500
 
501
501
  let chosen = eligible[0]!;
@@ -504,7 +504,7 @@ export function selectProactiveApiKey(
504
504
  const lastIndex = lastId ? pool.findIndex(entry => entry.id === lastId) : -1;
505
505
  for (let offset = 1; offset <= pool.length; offset += 1) {
506
506
  const candidate = pool[(lastIndex + offset) % pool.length]!;
507
- if (isKeyInCooldown(providerName, candidate.id, now)) continue;
507
+ if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
508
508
  chosen = candidate;
509
509
  break;
510
510
  }
@@ -518,11 +518,11 @@ export function selectProactiveApiKey(
518
518
  const outcome = commitProviderApiKeySelection<string | null>(config, providerName, freshProvider => {
519
519
  const freshPool = freshProvider.apiKeyPool ?? [];
520
520
  const target = freshPool.find(entry => entry.id === chosen.id);
521
- if (!target) return { changed: false, value: null };
521
+ if (!target || target.disabled) return { changed: false, value: null };
522
522
  if (freshProvider.apiKey === target.key) return { changed: false, value: null };
523
523
  const freshActive = freshPool.find(entry => entry.key === freshProvider.apiKey);
524
524
  // Re-check under the lock: a concurrent manual selection may have landed a healthy key.
525
- if (freshActive && !isKeyInCooldown(providerName, freshActive.id, now)) {
525
+ if (freshActive && !freshActive.disabled && !isKeyInCooldown(providerName, freshActive.id, now)) {
526
526
  return { changed: false, value: null };
527
527
  }
528
528
  freshProvider.apiKey = target.key;
@@ -564,7 +564,7 @@ export function selectProactiveApiKeyTransport(
564
564
 
565
565
  const dispatchKeyCursor = new Map<string, string>();
566
566
  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);
567
+ const pool = (provider.apiKeyPool ?? []).filter(entry => !entry.disabled).filter((entry, index, all) => all.findIndex(other => other.key === entry.key) === index);
568
568
  const previous = dispatchKeyCursor.get(providerName);
569
569
  const start = pool.findIndex(entry => entry.id === previous);
570
570
  for (let offset = 1; offset <= pool.length; offset++) {
@@ -575,7 +575,7 @@ function allocateDistributedKey(providerName: string, provider: OcxProviderConfi
575
575
  entryId: entry.id, reference: entry.key, revision: provider.apiKeySelectionRevision, distributed: true,
576
576
  } };
577
577
  }
578
- const next = Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000));
578
+ const next = pool.length ? Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000)) : now + 1000;
579
579
  throw new RequestPacingQueueOverloadError(providerName, "keys_exhausted", Math.max(1, Math.ceil((next - now) / 1000)));
580
580
  }
581
581
 
@@ -711,7 +711,7 @@ export function recordDistributedKeyFailure(
711
711
  const provider = config.providers[providerName];
712
712
  if (!selection?.distributed || !provider || provider.authMode === "oauth" || provider.authMode === "forward") return false;
713
713
  const failed = provider.apiKeyPool?.find(entry => entry.id === selection.entryId && entry.key === selection.reference);
714
- if (!failed || provider.apiKeySelectionRevision !== selection.revision) return false;
714
+ if (!failed || failed.disabled || provider.apiKeySelectionRevision !== selection.revision) return false;
715
715
  const delay = status === "billing" ? ANTHROPIC_BILLING_COOLDOWN_MS : status === 401 ? MAX_COOLDOWN_MS : quotaResetAt !== undefined
716
716
  ? Math.max(1, quotaResetAt - now) : parseRetryAfterMs(retryAfter, now) ?? DEFAULT_COOLDOWN_MS;
717
717
  for (const entry of provider.apiKeyPool ?? []) if (entry.key === failed.key) {
@@ -739,7 +739,7 @@ function rotateKeyAfterFailure(
739
739
  if (attemptedSelection?.distributed) {
740
740
  if (acceptsFreshProvider && !acceptsFreshProvider(provider)) return null;
741
741
  if (!recordDistributedKeyFailure(config, providerName, attemptedSelection, failureStatus, now, retryAfterHeader, quotaResetAt)) return null;
742
- if (!(provider.apiKeyPool ?? []).some(entry => !isKeyInCooldown(providerName, entry.id, now))) return null;
742
+ if (!(provider.apiKeyPool ?? []).some(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now))) return null;
743
743
  return allocateDistributedKey(providerName, provider, now);
744
744
  }
745
745
 
@@ -764,7 +764,7 @@ function rotateKeyAfterFailure(
764
764
 
765
765
  if (freshProvider.apiKey !== failedKey) {
766
766
  const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
767
- if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
767
+ if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) {
768
768
  return {
769
769
  changed: false,
770
770
  value: { failedId: failedEntry?.id },
@@ -776,7 +776,7 @@ function rotateKeyAfterFailure(
776
776
  const candidateCount = failedEntry ? pool.length - 1 : pool.length;
777
777
  for (let offset = 1; offset <= candidateCount; offset += 1) {
778
778
  const candidate = pool[(currentIndex + offset) % pool.length]!;
779
- if (isKeyInCooldown(providerName, candidate.id, now)) continue;
779
+ if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
780
780
  freshProvider.apiKey = candidate.key;
781
781
  return {
782
782
  changed: true,
@@ -51,6 +51,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = {
51
51
  apiKeyTransport: "none",
52
52
  apiKeyPool: "none",
53
53
  apiKeyPoolStrategy: "none",
54
+ accountAutoPause: "none",
54
55
  apiKeySelectionRevision: "none",
55
56
  _apiKeyAttempt: "none",
56
57
  defaultModel: "scalar",
@@ -1,22 +1,16 @@
1
1
  /**
2
2
  * OpenCode Zen short-window rate-limit guidance (#1145 / OCX-56).
3
3
  *
4
- * OpenCode's keyed and keyless Zen chat endpoints share `https://opencode.ai/zen/v1`.
4
+ * OpenCode's keyed Zen chat endpoint uses `https://opencode.ai/zen/v1`.
5
5
  * Free-model traffic can hit a short-window burst ceiling around 15–20 RPM
6
6
  * (community-measured). Zen often answers with opaque `429 Rate limit exceeded`
7
7
  * bodies and may omit `Retry-After` / `X-RateLimit-*`; when those headers are
8
- * present they still take precedence. Distinct from the keyless desktop
9
- * ~200 requests / 5h quota documented on `opencode-free`.
10
- *
11
- * The same module also owns the keyless free-tier admission explanation (#4121):
12
- * Zen rejects a request that carries no `x-opencode-session` header with
13
- * `MissingSessionID` / "OpenCode's free tier can only be used in OpenCode".
14
- * opencodex does not synthesize that header — see {@link enrichOpenCodeZenFreeTierMessage}.
8
+ * present they still take precedence.
15
9
  */
16
10
  import { validateClientRetryAfterHeader } from "../lib/retry-after";
17
11
  import { registryEntryForProviderDestination } from "./registry";
18
12
 
19
- const OPENCODE_ZEN_PROVIDER_IDS = new Set(["opencode-zen", "opencode-free"]);
13
+ const OPENCODE_ZEN_PROVIDER_IDS = new Set(["opencode-zen"]);
20
14
 
21
15
  /** Observed free-model burst ceiling on Zen (not an official OpenCode figure). */
22
16
  export const OPENCODE_ZEN_OBSERVED_RPM_HINT = "roughly 15-20 requests per minute";
@@ -49,7 +43,7 @@ export function isOpenCodeZenRateLimitProvider(opts: {
49
43
 
50
44
  /**
51
45
  * Same-key `retryOn429` only applies on key-authenticated HTTP paths — not
52
- * keyless `opencode-free` traffic and not custom `runTurn` transports.
46
+ * keyless traffic and not custom `runTurn` transports.
53
47
  */
54
48
  export function supportsOpenCodeZenRetryOn429Guidance(opts: {
55
49
  authMode?: string;
@@ -112,73 +106,13 @@ export function enrichOpenCodeZenRateLimitMessage(
112
106
  }
113
107
 
114
108
  /**
115
- * Zen's keyless free tier admits only OpenCode's own client. A request without an
116
- * `x-opencode-session` header is refused with error type `MissingSessionID` and the
117
- * message "OpenCode's free tier can only be used in OpenCode" (#4121).
118
- *
119
- * Presence of the header is the whole gate — any value clears it — so opencodex could
120
- * pass by minting one. It does not. Fabricating a session identifier and a versioned
121
- * `opencode/<version>` User-Agent is a claim to *be* the OpenCode client, and no upstream
122
- * contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a
123
- * bypassed admission check, not permission. Until OpenCode publishes a third-party
124
- * integration path for this exact keyless tier, the supported route is the keyed
125
- * `opencode-zen` provider.
126
- *
127
- * Two markers are matched because the two request surfaces expose different parts of the
128
- * upstream envelope: the Responses path forwards the bounded raw body (which carries the
129
- * `MissingSessionID` type), while the native Chat path forwards only the parsed message.
130
- */
131
- const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i;
132
-
133
- /** Idempotence marker — the appended guidance must not stack across enrichment layers. */
134
- const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header";
135
-
136
- /** True when an upstream error body is Zen's keyless free-tier admission refusal. */
137
- export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean {
138
- if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true;
139
- return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message);
140
- }
141
-
142
- /**
143
- * Replace a raw `MissingSessionID` passthrough with an explanation of the upstream
144
- * restriction and the supported alternative. No-op for every other provider and every
145
- * other error, and idempotent so layered enrichment cannot append it twice.
146
- */
147
- export function enrichOpenCodeZenFreeTierMessage(
148
- message: string,
149
- opts: {
150
- providerName?: string;
151
- baseUrl?: string;
152
- adapter?: string;
153
- /** Upstream `error.type`, when the caller parsed one out of the envelope. */
154
- upstreamErrorType?: string | null;
155
- },
156
- ): string {
157
- if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message;
158
- if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message;
159
- if (!isOpenCodeZenRateLimitProvider(opts)) return message;
160
- return (
161
- `${message}`
162
- + " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any"
163
- + " request that arrives without an x-opencode-session header."
164
- + ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode`
165
- + " client is a claim no upstream contract supports."
166
- + " Use the keyed opencode-zen provider with an OpenCode Zen API key"
167
- + " (https://opencode.ai/auth), or route this model through another provider."
168
- + " Upstream terms: https://opencode.ai/docs/zen/."
169
- );
170
- }
171
-
172
- /**
173
- * Single entry point for Zen upstream-error guidance on Responses and native Messages: short-window
174
- * rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
175
- * outside its own case, so the composition is safe for every other upstream failure.
109
+ * Zen upstream-error guidance on Responses and native Messages.
176
110
  */
177
111
  export function enrichOpenCodeZenUpstreamMessage(
178
112
  message: string,
179
113
  opts: Parameters<typeof enrichOpenCodeZenRateLimitMessage>[1] & { upstreamErrorType?: string | null },
180
114
  ): string {
181
- return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts);
115
+ return enrichOpenCodeZenRateLimitMessage(message, opts);
182
116
  }
183
117
 
184
118
  /** The effective HTTP endpoint, never the configured row name, identifies Console. */
@@ -1,3 +1,6 @@
1
+ import { rawAnthropicHeaderWindows } from "./anthropic-decision-windows";
2
+ import { clearDecisionAccountQuotas, decisionQuotaSinkActive } from "../../lib/decision-quota-sink";
3
+ import { bindRawDecisionWindows, publishAnthropicDecisionQuota } from "../quota-decision-publication";
1
4
  import { parseAnthropicFamilyHeaders, mergeAnthropicFamilyWindows } from "./anthropic-family-headers";
2
5
  import { anthropicModelQuotaFor, ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS } from "../../oauth/anthropic-model-quota";
3
6
  import { createHash } from "node:crypto";
@@ -289,6 +292,7 @@ export function recordAnthropicAccountQuotaFromHeadersForInstance(
289
292
  writerGeneration: number,
290
293
  status?: number,
291
294
  model?: string,
295
+ decisionCredentialGeneration?: string,
292
296
  ): void {
293
297
  if (!accountId) return;
294
298
  const key = accountCacheKey(instance, accountId);
@@ -318,14 +322,20 @@ export function recordAnthropicAccountQuotaFromHeadersForInstance(
318
322
  ...(observed.customWindows ? { customWindows: mergeAnthropicFamilyWindows(previous?.quota?.customWindows, observed.customWindows) } : {}),
319
323
  }, observed.updatedAt),
320
324
  });
325
+ // Advisory evidence is published for the primary pool only; Pool 2 stays unknown to decisions.
326
+ if (instance === "anthropic" && generation && generation === decisionCredentialGeneration && decisionQuotaSinkActive()) {
327
+ bindRawDecisionWindows(observed, rawAnthropicHeaderWindows(headers, observed.updatedAt));
328
+ publishAnthropicDecisionQuota(accountId, generation, observed, true);
329
+ }
321
330
  persistAccountQuotaCache();
322
331
  }
323
332
 
324
333
  /** Legacy recorder: config/roster generation is numeric and distinct from bearer ownership. */
325
334
  export function recordAnthropicAccountQuotaFromHeaders(
326
335
  accountId: string, headers: Headers, writerGeneration: number, status?: number, model?: string,
336
+ decisionCredentialGeneration?: string,
327
337
  ): void {
328
- recordAnthropicAccountQuotaFromHeadersForInstance("anthropic", accountId, headers, writerGeneration, status, model);
338
+ recordAnthropicAccountQuotaFromHeadersForInstance("anthropic", accountId, headers, writerGeneration, status, model, decisionCredentialGeneration);
329
339
  }
330
340
 
331
341
  /**
@@ -446,6 +456,7 @@ export function resetProviderQuotaReconcileStateForTests(): void {
446
456
 
447
457
  /** Drop cached per-account rows (all, or just one provider's). */
448
458
  export function clearAccountQuotaCache(provider?: string): void {
459
+ clearDecisionAccountQuotas(provider);
449
460
  // Anthropic readers use provider-owned epochs; B cannot revoke an A flight.
450
461
  if (provider !== "anthropic2") explicitAccountEpoch += 1;
451
462
  if (!provider) {
@@ -475,6 +486,9 @@ export function clearAccountQuotaCache(provider?: string): void {
475
486
 
476
487
  /** Drop one account's cached bars and any joined probe so a reset is not ranked from the old window. */
477
488
  export function forgetCachedProviderAccountQuota(provider: string, accountId: string): void {
489
+ // Advisory decision evidence of the same account is a copy of the dropped bars; it must not
490
+ // keep ranking the account from the pre-reset window while the reread is pending or fails.
491
+ clearDecisionAccountQuotas(provider, accountId);
478
492
  hydrateAccountQuotaCache();
479
493
  const key = accountCacheKey(provider, accountId);
480
494
  accountQuotaCache.delete(key);
@@ -64,6 +64,7 @@ export async function fetchAnthropicAccountQuota(
64
64
  // and a thrown read (network failure) both record the unavailable row over the retained
65
65
  // in-flight observations, as the pre-instance A path did, so joined callers and later cached
66
66
  // reads agree and windows that expired during the shared probe are normalized away.
67
+ /** Cache the settled probe result for an account and publish its decision evidence only while the flight is still current for its credential and generation. */
67
68
  const publish = (result: AnthropicQuotaRecoveryResult | null): AccountQuotaCacheEntry => {
68
69
  const previous = accountQuotaCache.get(key);
69
70
  const retained = previous?.isCurrent?.() === false ? undefined : previous;
@@ -72,7 +73,7 @@ export async function fetchAnthropicAccountQuota(
72
73
  ts: Date.now(), quota: normalizeAnthropicQuota(result?.quota ?? retained?.quota, Date.now()),
73
74
  ...(!result ? { unavailable: true as const } : {}), isCurrent,
74
75
  };
75
- if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); sweepExpiredOnWrite(entry.ts); }
76
+ if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); result?.publishDecisionQuota(); sweepExpiredOnWrite(entry.ts); }
76
77
  return entry;
77
78
  };
78
79
  const probe = (async (): Promise<AccountQuotaCacheEntry> => {
@@ -1,3 +1,5 @@
1
+ import { boundDecisionWindows } from "../quota-decision-publication";
2
+ import { decisionQuotaSinkActive, publishDecisionAccountQuota } from "../../lib/decision-quota-sink";
1
3
  import { mergeAnthropicFamilyWindows, hasAnthropicFamilyEnumeration } from "./anthropic-family-headers";
2
4
  import { getCachedProviderAccountQuota, captureProviderAccountQuotaEpoch } from "./account-cache";
3
5
  import { anthropicModelQuotaFor } from "../../oauth/anthropic-model-quota";
@@ -10,6 +12,7 @@ export class AnthropicQuotaProbeOwnershipError extends Error {}
10
12
 
11
13
  export type AnthropicCooldownRecoveryProbe = Readonly<{
12
14
  instance: AnthropicInstanceId;
15
+ generation: string;
13
16
  requiresFreshDispatch: boolean;
14
17
  isCurrentCredential(): boolean;
15
18
  isCurrentFamily(): boolean;
@@ -20,10 +23,13 @@ export type AnthropicCooldownRecoveryProbe = Readonly<{
20
23
  export type AnthropicQuotaRecoveryResult = Readonly<{
21
24
  instance: AnthropicInstanceId;
22
25
  quota: ProviderQuota;
26
+ /** Publishes secret-free advisory evidence for the primary pool only; a no-op for Pool 2. */
27
+ publishDecisionQuota(): void;
23
28
  /** Must be checked synchronously at each publication boundary. */
24
29
  isCurrent(): boolean;
25
30
  }>;
26
31
 
32
+ /** Create the cooldown-recovery probe owner for one Anthropic instance; its results are credential- and generation-bound before they may publish. */
27
33
  function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
28
34
  const familyGeneration = (accountId: string) => anthropicModelQuotaFor(instance).anthropicFamilyQuotaGeneration(accountId);
29
35
 
@@ -129,6 +135,7 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
129
135
  };
130
136
  return {
131
137
  instance,
138
+ generation,
132
139
  requiresFreshDispatch: claim !== null,
133
140
  isCurrentCredential,
134
141
  isCurrentFamily: () => familyGeneration(accountId) === capturedFamilyGeneration,
@@ -165,6 +172,7 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
165
172
  }
166
173
  // Clearing the claimed cooldown intentionally advances the fence. Adopt that exact new
167
174
  // generation; any later observation/429 then invalidates publication before a cache write.
175
+ const rawQuota = quota;
168
176
  const ownsFamily = probe.isCurrentFamily();
169
177
  const authoritative = ownsFamily && hasAnthropicFamilyEnumeration(quota);
170
178
  if (ownsFamily) anthropicModelQuotaFor(instance).observeAnthropicFamilyQuota(accountId, quota.customWindows ?? [], quota.updatedAt, authoritative);
@@ -177,14 +185,24 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
177
185
  }
178
186
  const publicationGeneration = anthropicCooldownGeneration(accountId);
179
187
  const publicationFamilyGeneration = familyGeneration(accountId);
188
+ /** True while the probed credential, cooldown generation and family generation are unchanged and publication is still permitted. */
189
+ const isCurrent = () => probe.isCurrentCredential()
190
+ && anthropicCooldownGeneration(accountId) === publicationGeneration
191
+ && familyGeneration(accountId) === publicationFamilyGeneration
192
+ && mayPublish();
180
193
  afterSettlementForTests?.();
181
194
  return {
182
195
  instance,
183
196
  quota,
184
- isCurrent: () => probe.isCurrentCredential()
185
- && anthropicCooldownGeneration(accountId) === publicationGeneration
186
- && familyGeneration(accountId) === publicationFamilyGeneration
187
- && mayPublish(),
197
+ isCurrent,
198
+ publishDecisionQuota: () => {
199
+ // Activation first: ownership validation reads the auth store and must not run for signals-off installs.
200
+ if (instance !== "anthropic" || !decisionQuotaSinkActive() || !isCurrent()) return;
201
+ // An unbound probe result was parsed while inactive: unknown, never display fallback.
202
+ const windows = boundDecisionWindows(rawQuota);
203
+ if (windows) publishDecisionAccountQuota("anthropic", accountId, probe.generation,
204
+ windows.filter(row => ownsFamily || ["5h", "weekly", "monthly"].includes(row.window)), !authoritative);
205
+ },
188
206
  };
189
207
  }
190
208
 
@@ -0,0 +1,47 @@
1
+ /** Raw advisory projection; compatibility display clamping/rounding remains unchanged. */
2
+ import type { DecisionQuotaWindow } from "../quota-decision-snapshot";
3
+ import { normalizeResetAt, toFiniteNumber } from "../quota-wire";
4
+ /** Return a plain object value, or undefined for non-objects and arrays. */
5
+ const record = (value: unknown): Record<string, unknown> | undefined =>
6
+ value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
7
+ /** Normalize a reset value; an explicit invalid reset becomes NaN so it cannot read as an undated valid measurement. */
8
+ function reset(value: unknown): number | undefined {
9
+ // Explicit invalid reset must not become an apparently undated valid measurement.
10
+ return value === undefined || value === null ? undefined : normalizeResetAt(value) ?? NaN;
11
+ }
12
+ /** Extract raw decision windows (5h, weekly and per-model families) from an Anthropic usage body, keeping invalid resets distinct from display parsing. */
13
+ export function rawAnthropicUsageWindows(body: Record<string, unknown>, observedAt: number): DecisionQuotaWindow[] {
14
+ const rows: DecisionQuotaWindow[] = [];
15
+ /** Append one window when its percentage is a finite number, carrying its normalized reset. */
16
+ const add = (window: DecisionQuotaWindow["window"], raw: unknown, percentField: string) => {
17
+ const row = record(raw);
18
+ const percent = toFiniteNumber(row?.[percentField]);
19
+ if (percent === undefined) return;
20
+ const resetAt = reset(row?.resets_at);
21
+ rows.push({ window, percent, observedAt, ...(resetAt !== undefined ? { resetAt } : {}) });
22
+ };
23
+ for (const [window, field] of [["5h", "five_hour"], ["weekly", "seven_day"], ["fable", "seven_day_fable"], ["opus", "seven_day_opus"], ["sonnet", "seven_day_sonnet"]] as const) add(window, body[field], "utilization");
24
+ for (const limit of Array.isArray(body.limits) ? body.limits : []) {
25
+ const row = record(limit);
26
+ if (row?.kind !== "weekly_scoped") continue;
27
+ const display = record(record(row.scope)?.model)?.display_name;
28
+ if (typeof display !== "string") continue;
29
+ const label = display.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "").toLowerCase();
30
+ const family = (["fable", "opus", "sonnet"] as const).find(family => label.includes(family));
31
+ if (family) add(family, row, "percent");
32
+ }
33
+ return rows;
34
+ }
35
+ /** Extract decision windows from Anthropic unified rate-limit response headers, converting utilization fractions to percentages. */
36
+ export function rawAnthropicHeaderWindows(headers: Headers, observedAt: number): DecisionQuotaWindow[] {
37
+ const rows: DecisionQuotaWindow[] = [];
38
+ for (const [window, wire] of [["5h", "5h"], ["weekly", "7d"], ["fable", "7d_oi"]] as const) {
39
+ const fraction = toFiniteNumber(headers.get(`anthropic-ratelimit-unified-${wire}-utilization`));
40
+ if (fraction === undefined || fraction < 0 || fraction > 1) continue;
41
+ const rawReset = headers.get(`anthropic-ratelimit-unified-${wire}-reset`);
42
+ const seconds = toFiniteNumber(rawReset);
43
+ const resetAt = rawReset === null ? undefined : seconds !== undefined && seconds > 0 ? seconds * 1000 : NaN;
44
+ rows.push({ window, percent: fraction * 100, observedAt, ...(resetAt !== undefined ? { resetAt } : {}) });
45
+ }
46
+ return rows;
47
+ }
@@ -45,8 +45,31 @@ function quotaInfoEntries(modelInfo: Record<string, unknown>): Record<string, un
45
45
  return entries;
46
46
  }
47
47
 
48
+ /**
49
+ * Gemini 4 Argon has its own quota pool. Its names also contain "gemini", so every family
50
+ * decision below checks Argon first; otherwise Argon readings fill or replace the Gem family.
51
+ */
52
+ const ARGON_TOKEN = /(?:^|[^a-z0-9])argon(?:[^a-z0-9]|$)/i;
53
+ const GEMINI_TOKEN = /(?:^|[^a-z0-9])gemini(?:[^a-z0-9]|$)/i;
54
+ const CLAUDE_FAMILY_TOKEN = /(?:^|[^a-z0-9])(?:claude|3p|gpt|opus|sonnet)(?:[^a-z0-9]|$)/i;
55
+ /** Canonical model of the Argon summary pool; tagging keeps the window out of routing. */
56
+ const ARGON_POOL_MODEL_ID = "gemini-4-argon";
57
+
58
+ type AntigravitySummaryFamily = "argon" | "gem" | "cla";
59
+
60
+ /** A bucket's own id and name decide its family when they name one; the group decides otherwise. */
61
+ function antigravitySummaryBucketFamily(bucket: Record<string, unknown>): AntigravitySummaryFamily | undefined {
62
+ const identity = `${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`;
63
+ if (ARGON_TOKEN.test(identity)) return "argon";
64
+ if (GEMINI_TOKEN.test(identity)) return "gem";
65
+ if (CLAUDE_FAMILY_TOKEN.test(identity)) return "cla";
66
+ return undefined;
67
+ }
68
+
48
69
  function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, unknown>, quotaInfo: Record<string, unknown>): "Gem" | "Cla" | null {
49
70
  const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : "";
71
+ // Argon models keep their exact model rows but never seed the shared Gem family bar.
72
+ if (ARGON_TOKEN.test(`${modelId} ${displayName}`)) return null;
50
73
  const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : "";
51
74
  const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase();
52
75
  if (haystack.includes("gemini")) return "Gem";
@@ -128,9 +151,13 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
128
151
  for (const rawGroup of groups) {
129
152
  const group = asRecord(rawGroup);
130
153
  if (!group) continue;
131
- const groupName = `${typeof group.displayName === "string" ? group.displayName : ""} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase();
132
- const isGemini = groupName.includes("gemini");
133
- const isClaude = groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt");
154
+ const groupDisplayName = typeof group.displayName === "string" ? group.displayName : "";
155
+ const groupName = `${groupDisplayName} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase();
156
+ // A description that merely mentions Argon must not move a whole Gemini group.
157
+ const groupFamily: AntigravitySummaryFamily | undefined = ARGON_TOKEN.test(groupDisplayName) ? "argon"
158
+ : groupName.includes("gemini") ? "gem"
159
+ : groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt") ? "cla"
160
+ : undefined;
134
161
 
135
162
  const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : [];
136
163
  for (const rawBucket of buckets) {
@@ -143,13 +170,19 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
143
170
 
144
171
  const isWeekly = windowStr.includes("week");
145
172
  const is5h = windowStr.includes("5h") || windowStr.includes("five");
173
+ const family = antigravitySummaryBucketFamily(bucket) ?? groupFamily;
146
174
 
147
- if (isGemini) {
175
+ if (family === "argon") {
176
+ const label = is5h ? "Argon" : isWeekly ? "Argon (Weekly)" : "";
177
+ if (label && !customWindowsMap.has(label)) {
178
+ customWindowsMap.set(label, { label, modelId: ARGON_POOL_MODEL_ID, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
179
+ }
180
+ } else if (family === "gem") {
148
181
  const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : "";
149
182
  if (label && !customWindowsMap.has(label)) {
150
183
  customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
151
184
  }
152
- } else if (isClaude) {
185
+ } else if (family === "cla") {
153
186
  const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : "";
154
187
  if (label && !customWindowsMap.has(label)) {
155
188
  customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
@@ -164,7 +197,7 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
164
197
  }
165
198
  }
166
199
 
167
- const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"];
200
+ const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)", "Argon", "Argon (Weekly)"];
168
201
  const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => {
169
202
  const ia = PREFERRED_ORDER.indexOf(a.label);
170
203
  const ib = PREFERRED_ORDER.indexOf(b.label);
@@ -1,3 +1,5 @@
1
+ import { decisionQuotaSinkActive, invalidateDecisionKeyQuotas, publishDecisionKeyQuota } from "../../lib/decision-quota-sink";
2
+ import { providerDecisionWindows } from "../quota-decision-publication";
1
3
  import { createHash } from "node:crypto";
2
4
  import { effectiveCodexAuthAccountId, listCodexAuthAccountsSnapshot } from "../../codex/auth-api";
3
5
  import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota";
@@ -345,6 +347,18 @@ export function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolea
345
347
  && (accountReportCurrent.get(value)?.() ?? true);
346
348
  }
347
349
 
350
+ /** Called only by an accepted collector/cache publication, never the JEV read path. */
351
+ export function publishCollectedDecisionQuota(reports: ProviderQuotaReport[], config: OcxConfig): void {
352
+ if (!decisionQuotaSinkActive()) return;
353
+ invalidateDecisionKeyQuotas();
354
+ for (const report of reports) {
355
+ const provider = config.providers[report.provider];
356
+ const evidence = routingEvidence.get(report);
357
+ if (provider && evidence && evidence.binding === providerQuotaRoutingBinding(report.provider, provider))
358
+ publishDecisionKeyQuota(report.provider, provider, providerDecisionWindows(evidence.quota));
359
+ }
360
+ }
361
+
348
362
  /**
349
363
  * The reports this process already holds for the configured providers, for a passive read (#6959):
350
364
  * no probe, token refresh, account snapshot or publication into routing evidence. The Home's own
@@ -300,7 +300,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
300
300
  if (!initialRow || !currentRow || initialRow.loginId !== currentRow.loginId || initialRow.addedAt !== currentRow.addedAt
301
301
  || epoch !== captureProviderAccountQuotaEpoch(instance) || recovery.anthropicAccountIncarnation(probedAccountId) !== incarnation) return null;
302
302
  let quota: ProviderQuota | null;
303
- let anthropicCurrent: (() => boolean) | undefined;
303
+ let anthropicCurrent: (() => boolean) | undefined; let anthropicPublish: (() => void) | undefined;
304
304
  try {
305
305
  const result = await recovery.probeAnthropicQuotaWithRecovery(probedAccountId, accessToken,
306
306
  fresh => {
@@ -311,7 +311,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
311
311
  () => targetCurrent() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration));
312
312
  if (result && !result.isCurrent()) return null;
313
313
  quota = result?.quota ?? null;
314
- anthropicCurrent = result?.isCurrent;
314
+ anthropicCurrent = result?.isCurrent; anthropicPublish = result?.publishDecisionQuota;
315
315
  } catch (error) {
316
316
  if (error instanceof AnthropicQuotaProbeOwnershipError) return null;
317
317
  throw error;
@@ -322,7 +322,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
322
322
  if (probedAccountId && probedAccountKey) {
323
323
  const stillOwnsToken = getAccountCredential(instance, probedAccountId)?.access === accessToken;
324
324
  if (stillOwnsToken && anthropicCurrent?.() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
325
- accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent });
325
+ accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent }); anthropicPublish?.();
326
326
  }
327
327
  }
328
328
  const quotaReport = report(provider, "anthropic:oauth-usage", quota);