@bitkyc08/opencodex 2.87.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 (246) 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-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.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-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -16,6 +16,7 @@
16
16
  * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per
17
17
  * account; nothing ever rewrites plaintext into config or its backups.
18
18
  */
19
+ import { invalidateDecisionKeyQuotas, observeDecisionKeyCredential } from "../lib/decision-quota-sink";
19
20
  import { resolveEnvValue } from "../config/proxy-env";
20
21
  import { loadKeyringBinding } from "../lib/keyring-native";
21
22
  import { isGcpCredentialMaterial } from "../lib/gcp-credential-input";
@@ -52,6 +53,7 @@ export function providerKeyResolutionGeneration(): number {
52
53
  /** Test seam: swap the OS entry for an in-memory one and drop caches. */
53
54
  export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
54
55
  entryFactory = factory ?? defaultEntryFactory;
56
+ invalidateDecisionKeyQuotas();
55
57
  resolvedCache.clear();
56
58
  resolutionGeneration++;
57
59
  warnedAccounts.clear();
@@ -59,6 +61,7 @@ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychai
59
61
 
60
62
  /** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
61
63
  export function invalidateResolvedProviderKeyCache(): void {
64
+ invalidateDecisionKeyQuotas();
62
65
  resolvedCache.clear();
63
66
  resolutionGeneration++;
64
67
  warnedAccounts.clear();
@@ -114,15 +117,18 @@ function readKeychain(account: string, quiet = false): string | undefined {
114
117
  */
115
118
  export function resolveProviderApiKey(value: string | undefined): string | undefined {
116
119
  if (!value) return undefined;
117
- if (isKeychainReference(value)) return readKeychain(keychainAccount(value));
118
- return resolveEnvValue(value);
120
+ const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value)) : resolveEnvValue(value);
121
+ observeDecisionKeyCredential(value, resolved);
122
+ return resolved;
119
123
  }
120
124
 
121
125
  /** The same resolution without the unreadable-reference warning, for observe-only callers. */
122
126
  export function resolveProviderApiKeyQuietly(value: string | undefined): string | undefined {
123
127
  if (!value) return undefined;
124
- if (isKeychainReference(value)) return readKeychain(keychainAccount(value), true);
125
- return resolveEnvValue(value);
128
+ const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value), true) : resolveEnvValue(value);
129
+ // A quiet read still sees a replaced secret; advisory key evidence published under the old one must not outlive it.
130
+ observeDecisionKeyCredential(value, resolved);
131
+ return resolved;
126
132
  }
127
133
 
128
134
  export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
@@ -13,6 +13,9 @@ import { captureProviderApiKeySelection } from "./api-key-selection-capture";
13
13
  export { captureProviderApiKeySelection } from "./api-key-selection-capture";
14
14
 
15
15
  function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean {
16
+ if (expected.distributed) return provider.apiKeySelectionRevision === expected.revision
17
+ && provider.requestPacing?.enabled === true && provider.requestPacing.keySelection === "round-robin"
18
+ && !!provider.apiKeyPool?.some(entry => !entry.disabled && entry.id === expected.entryId && entry.key === expected.reference);
16
19
  const current = captureProviderApiKeySelection(provider);
17
20
  return current.entryId === expected.entryId && current.reference === expected.reference
18
21
  && current.revision === expected.revision;
@@ -24,10 +27,12 @@ function currentKeyProvider(config: OcxConfig, name: string, selection?: Provide
24
27
  let row = configured;
25
28
  if (selection?.distributed) {
26
29
  const entry = configured.apiKeyPool?.find(key => key.id === selection.entryId && key.key === selection.reference);
27
- if (!entry || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
30
+ if (!entry || entry.disabled || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
28
31
  || !configured.requestPacing?.enabled || configured.requestPacing.keySelection !== "round-robin") return null;
29
32
  row = { ...configured, apiKey: entry.key, _apiKeyAttempt: selection };
30
33
  }
34
+ if (row.apiKeyPool?.some(entry => entry.disabled && entry.key === row.apiKey)
35
+ || row.apiKeyPool?.length && row.apiKeyPool.every(entry => entry.disabled)) return null;
31
36
  const current = routedProviderConfig(name, { ...row, _apiKeyAttempt: selection?.distributed ? selection : undefined });
32
37
  if (current.authMode === "oauth" || current.authMode === "forward") return null;
33
38
  if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null;
@@ -21,6 +21,8 @@ export interface ProviderApiKeyInfo extends AccountQuotaFields {
21
21
  /** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
22
22
  masked: string;
23
23
  active: boolean;
24
+ disabled?: boolean;
25
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
24
26
  addedAt?: number;
25
27
  }
26
28
 
@@ -217,7 +219,7 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
217
219
  const pool = provider.apiKeyPool?.length
218
220
  ? provider.apiKeyPool
219
221
  : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
220
- const activeId = (pool.find(entry => entry.key === provider.apiKey) ?? pool[0])?.id ?? null;
222
+ const activeId = pool.find(entry => entry.key === provider.apiKey)?.id ?? null;
221
223
  return {
222
224
  activeId,
223
225
  keys: pool.map(entry => ({
@@ -225,6 +227,8 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
225
227
  ...(entry.label ? { label: entry.label } : {}),
226
228
  masked: maskApiKey(entry.key),
227
229
  active: entry.id === activeId,
230
+ ...(entry.disabled ? { disabled: true } : {}),
231
+ ...(entry.pauseDetails ? { pauseDetails: entry.pauseDetails } : {}),
228
232
  ...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
229
233
  })),
230
234
  };
@@ -300,7 +304,7 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
300
304
  } else {
301
305
  pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
302
306
  }
303
- fresh.apiKey = trimmed;
307
+ if (!existing?.disabled) fresh.apiKey = trimmed;
304
308
  return { changed: true, selectionChanged: true, value: id };
305
309
  });
306
310
  if (committed.status !== "committed") return { error: "provider selection unavailable" };
@@ -313,7 +317,7 @@ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: str
313
317
  const entry = provider.apiKeyPool?.find(e => e.id === id)
314
318
  ?? (!provider.apiKeyPool?.length && provider.apiKey && apiKeyPoolEntryId(provider.apiKey) === id
315
319
  ? { id, key: provider.apiKey } : undefined);
316
- if (!entry) return { changed: false, value: false };
320
+ if (!entry || entry.disabled) return { changed: false, value: false };
317
321
  ensurePool(provider);
318
322
  provider.apiKey = entry.key;
319
323
  return { changed: true, selectionChanged: true, value: true };
@@ -344,7 +348,7 @@ export function removeProviderApiKey(config: OcxConfig, name: string, id: string
344
348
  removed = entry.key;
345
349
  provider.apiKeyPool = pool.filter(e => e.id !== id);
346
350
  if (provider.apiKey === entry.key) {
347
- const next = provider.apiKeyPool[0];
351
+ const next = provider.apiKeyPool.find(candidate => !candidate.disabled);
348
352
  if (next) provider.apiKey = next.key;
349
353
  else delete provider.apiKey;
350
354
  }
@@ -1,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;
@@ -70,6 +64,9 @@ export function enrichOpenCodeZenRateLimitMessage(
70
64
  message: string,
71
65
  opts: {
72
66
  status: number;
67
+ /** Parsed upstream error type, when available. */
68
+ upstreamErrorType?: string | null;
69
+ upstreamErrorCode?: string | null;
73
70
  providerName?: string;
74
71
  baseUrl?: string;
75
72
  adapter?: string;
@@ -84,7 +81,9 @@ export function enrichOpenCodeZenRateLimitMessage(
84
81
  ): string {
85
82
  if (opts.status !== 429) return message;
86
83
  if (!isOpenCodeZenRateLimitProvider(opts)) return message;
87
- if (!/rate\s*limit/i.test(message)) return message;
84
+ const errorText = `${opts.upstreamErrorType ?? ""} ${opts.upstreamErrorCode ?? ""} ${message}`.replace(/[_-]/g, " ");
85
+ if (/\b(?:quota|billing)\b|\busage\s+limit\b|\binsufficient\s+(?:credits?|balance|funds)\b|\b(?:credits?|balance)\s+(?:exhausted|depleted)\b/i.test(errorText)) return message;
86
+ if (opts.upstreamErrorType !== "rate_limit_error" && !/rate\s*limit/i.test(message)) return message;
88
87
  if (message.includes(ENRICHMENT_MARKER)) return message;
89
88
 
90
89
  const upstreamRetry = validateClientRetryAfterHeader(
@@ -107,73 +106,13 @@ export function enrichOpenCodeZenRateLimitMessage(
107
106
  }
108
107
 
109
108
  /**
110
- * Zen's keyless free tier admits only OpenCode's own client. A request without an
111
- * `x-opencode-session` header is refused with error type `MissingSessionID` and the
112
- * message "OpenCode's free tier can only be used in OpenCode" (#4121).
113
- *
114
- * Presence of the header is the whole gate — any value clears it — so opencodex could
115
- * pass by minting one. It does not. Fabricating a session identifier and a versioned
116
- * `opencode/<version>` User-Agent is a claim to *be* the OpenCode client, and no upstream
117
- * contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a
118
- * bypassed admission check, not permission. Until OpenCode publishes a third-party
119
- * integration path for this exact keyless tier, the supported route is the keyed
120
- * `opencode-zen` provider.
121
- *
122
- * Two markers are matched because the two request surfaces expose different parts of the
123
- * upstream envelope: the Responses path forwards the bounded raw body (which carries the
124
- * `MissingSessionID` type), while the native Chat path forwards only the parsed message.
125
- */
126
- const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i;
127
-
128
- /** Idempotence marker — the appended guidance must not stack across enrichment layers. */
129
- const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header";
130
-
131
- /** True when an upstream error body is Zen's keyless free-tier admission refusal. */
132
- export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean {
133
- if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true;
134
- return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message);
135
- }
136
-
137
- /**
138
- * Replace a raw `MissingSessionID` passthrough with an explanation of the upstream
139
- * restriction and the supported alternative. No-op for every other provider and every
140
- * other error, and idempotent so layered enrichment cannot append it twice.
141
- */
142
- export function enrichOpenCodeZenFreeTierMessage(
143
- message: string,
144
- opts: {
145
- providerName?: string;
146
- baseUrl?: string;
147
- adapter?: string;
148
- /** Upstream `error.type`, when the caller parsed one out of the envelope. */
149
- upstreamErrorType?: string | null;
150
- },
151
- ): string {
152
- if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message;
153
- if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message;
154
- if (!isOpenCodeZenRateLimitProvider(opts)) return message;
155
- return (
156
- `${message}`
157
- + " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any"
158
- + " request that arrives without an x-opencode-session header."
159
- + ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode`
160
- + " client is a claim no upstream contract supports."
161
- + " Use the keyed opencode-zen provider with an OpenCode Zen API key"
162
- + " (https://opencode.ai/auth), or route this model through another provider."
163
- + " Upstream terms: https://opencode.ai/docs/zen/."
164
- );
165
- }
166
-
167
- /**
168
- * Single entry point for Zen upstream-error guidance on the Responses wire: short-window
169
- * rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
170
- * outside its own case, so the composition is safe for every other upstream failure.
109
+ * Zen upstream-error guidance on Responses and native Messages.
171
110
  */
172
111
  export function enrichOpenCodeZenUpstreamMessage(
173
112
  message: string,
174
113
  opts: Parameters<typeof enrichOpenCodeZenRateLimitMessage>[1] & { upstreamErrorType?: string | null },
175
114
  ): string {
176
- return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts);
115
+ return enrichOpenCodeZenRateLimitMessage(message, opts);
177
116
  }
178
117
 
179
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
+ }