@bitkyc08/opencodex 2.88.0 → 2.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -0,0 +1,207 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxProviderConfig } from "../types";
3
+ import { isEgressTransparentExecutor, markEgressTransparentExecutor } from "../lib/provider-egress";
4
+ import { configuredOutboundFetch } from "../lib/proxy-env";
5
+ import { waitOutsideProviderHeaderDeadline } from "../lib/provider-admission-wait";
6
+ import { markSameTargetRetryHold, sleepWithAbort } from "../lib/upstream-retry";
7
+ import { RequestPacingQueueOverloadError } from "./request-pacing";
8
+ import type { OcxProviderTransport } from "./xai-transport";
9
+
10
+ const MAX_IDENTITIES = 128;
11
+ const DEFAULT_MAX_WAIT_MS = 60_000; // Existing retry wait budget, not an inferred rate window.
12
+ const MINUTE_MS = 60 * 1000; // Unit named by the observed headers, never an account quota.
13
+ type RecoveryState = { generation: number; probe?: { done: Promise<void>; finish(): void } };
14
+
15
+ /** Match the supported key-auth Chat destination, including one optional trailing slash. */
16
+ export function isCanonicalMistralChat(
17
+ provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "baseUrl">,
18
+ ): boolean {
19
+ if (provider.adapter !== "openai-chat" || (provider.authMode !== undefined && provider.authMode !== "key")) return false;
20
+ try {
21
+ const url = new URL(provider.baseUrl);
22
+ return url.origin === "https://api.mistral.ai" && /^\/v1\/?$/.test(url.pathname)
23
+ && !url.username && !url.password && !url.search && !url.hash;
24
+ } catch { return false; }
25
+ }
26
+
27
+ /** Read a nonnegative integer counter; reject missing or malformed values. */
28
+ function count(headers: Headers, name: string): number | undefined {
29
+ const raw = headers.get(name)?.trim();
30
+ if (!raw || !/^\d+$/.test(raw)) return undefined;
31
+ const value = Number(raw);
32
+ return Number.isSafeInteger(value) ? value : undefined;
33
+ }
34
+
35
+ /** Read the server delay as seconds or an HTTP date; ignore invalid hints. */
36
+ function retryDelay(headers: Headers, now: number): number | undefined {
37
+ const raw = headers.get("retry-after")?.trim();
38
+ if (!raw) return undefined;
39
+ const value = /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - now;
40
+ return Number.isFinite(value) ? Math.max(0, value) : undefined;
41
+ }
42
+
43
+ /** Classify measured refusal evidence only; never predict a future cache hit or token charge. */
44
+ export function mistralMinuteRefusal(headers: Headers): "temporary" | "charge-above-allowance" | "unknown" {
45
+ const month = count(headers, "x-ratelimit-limit-tokens-month");
46
+ if (month !== undefined && count(headers, "x-ratelimit-remaining-tokens-month") === 0) return "unknown";
47
+ const limit = count(headers, "x-ratelimit-limit-tokens-minute");
48
+ const remaining = count(headers, "x-ratelimit-remaining-tokens-minute");
49
+ const cost = count(headers, "x-ratelimit-tokens-query-cost");
50
+ if (limit !== undefined && limit > 0 && cost !== undefined && cost > limit) return "charge-above-allowance";
51
+ if (limit !== undefined && limit > 0 && remaining !== undefined && remaining <= limit
52
+ && (remaining === 0 || (cost !== undefined && cost > remaining))) return "temporary";
53
+ const requests = count(headers, "x-ratelimit-limit-req-minute");
54
+ if (requests !== undefined && requests > 0 && count(headers, "x-ratelimit-remaining-req-minute") === 0) return "temporary";
55
+ return "unknown";
56
+ }
57
+
58
+ /** Process-local observations, never a fixed account/plan quota or a persisted credential. */
59
+ export class MistralBudgetPacer {
60
+ private readonly deadlines = new Map<string, number>();
61
+ private readonly recoveries = new Map<string, RecoveryState>();
62
+ /** Latest live deadline evicted at capacity; unknown identities wait it out instead of sending early. */
63
+ private evictedUntil = 0;
64
+ private waiting = 0;
65
+
66
+ constructor(
67
+ private readonly now: () => number = Date.now,
68
+ private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void> = sleepWithAbort,
69
+ ) {}
70
+
71
+ /** Record server delays and confirmed minute pressure for this credential and model. */
72
+ observe(identity: string, headers: Headers, status = 200): void {
73
+ const tokens = count(headers, "x-ratelimit-remaining-tokens-minute");
74
+ const requests = count(headers, "x-ratelimit-remaining-req-minute");
75
+ // Healthy remaining counters never predict the next request's cache/charge.
76
+ if (status !== 429 && tokens !== 0 && requests !== 0) return;
77
+ // With no explicit time, activate a conservative minute hold only AFTER a
78
+ // refusal establishes pressure. Healthy responses never predict the next cost.
79
+ const delay = retryDelay(headers, this.now())
80
+ ?? (status === 429 && mistralMinuteRefusal(headers) === "temporary" ? MINUTE_MS : undefined);
81
+ if (delay === undefined || delay === 0) return;
82
+ for (const [key, deadline] of this.deadlines) {
83
+ if (deadline <= this.now()) this.deadlines.delete(key);
84
+ }
85
+ if (!this.deadlines.has(identity) && this.deadlines.size >= MAX_IDENTITIES) {
86
+ const [evicted, until] = this.deadlines.entries().next().value!;
87
+ this.evictedUntil = Math.max(this.evictedUntil, until);
88
+ this.deadlines.delete(evicted);
89
+ }
90
+ this.deadlines.set(identity, Math.max(this.deadlines.get(identity) ?? 0, this.now() + delay));
91
+ if (status === 429) {
92
+ if (!this.recoveries.has(identity) && this.recoveries.size >= MAX_IDENTITIES) {
93
+ const idle = [...this.recoveries].find(([, state]) => !state.probe);
94
+ if (!idle) return;
95
+ this.recoveries.delete(idle[0]);
96
+ }
97
+ const state = this.recoveries.get(identity) ?? { generation: 0 };
98
+ state.generation++;
99
+ this.recoveries.set(identity, state);
100
+ }
101
+ }
102
+
103
+ /** Wait within the admission budget and return a release callback for one recovery probe. */
104
+ async acquire(identity: string, signal?: AbortSignal, maxWaitMs = DEFAULT_MAX_WAIT_MS): Promise<(success?: boolean) => void> {
105
+ signal?.throwIfAborted();
106
+ if (this.waiting >= MAX_IDENTITIES) throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60);
107
+ this.waiting++;
108
+ const deadline = this.now() + maxWaitMs;
109
+ try {
110
+ for (;;) {
111
+ signal?.throwIfAborted();
112
+ // An evicted identity may have re-entered with a shorter delay; the evicted one still binds.
113
+ const live = this.evictedUntil > this.now() ? this.evictedUntil : undefined;
114
+ const own = this.deadlines.get(identity);
115
+ const readyAt = own === undefined ? live : live === undefined ? own : Math.max(own, live);
116
+ if (readyAt !== undefined && readyAt > deadline) {
117
+ const delay = readyAt - this.now();
118
+ throw new RequestPacingQueueOverloadError("mistral", "queue_expired", Math.ceil(delay / 1000));
119
+ }
120
+ if (readyAt !== undefined && readyAt > this.now()) {
121
+ await this.sleep(readyAt - this.now(), signal);
122
+ continue;
123
+ }
124
+ const state = this.recoveries.get(identity);
125
+ if (!state) { this.deadlines.delete(identity); return () => {}; }
126
+ if (state.probe) {
127
+ const remaining = deadline - this.now();
128
+ if (remaining <= 0) throw new RequestPacingQueueOverloadError("mistral", "queue_expired", 1);
129
+ const local = new AbortController();
130
+ try {
131
+ await Promise.race([state.probe.done, this.sleep(remaining, AbortSignal.any([local.signal, ...(signal ? [signal] : [])]))]);
132
+ } finally { local.abort(); }
133
+ continue;
134
+ }
135
+ let finish!: () => void;
136
+ const probe = { done: new Promise<void>(resolve => { finish = resolve; }), finish: () => finish() };
137
+ state.probe = probe;
138
+ const generation = state.generation;
139
+ let released = false;
140
+ return success => {
141
+ if (released) return;
142
+ released = true;
143
+ if (state.probe === probe) delete state.probe;
144
+ // Only this probe can settle recovery; an older concurrent success must
145
+ // not erase a more recent refusal or the server's minimum wait.
146
+ if (success && state.generation === generation) {
147
+ if (this.recoveries.get(identity) === state) this.recoveries.delete(identity);
148
+ if ((this.deadlines.get(identity) ?? 0) <= this.now()) this.deadlines.delete(identity);
149
+ }
150
+ probe.finish();
151
+ };
152
+ }
153
+ } finally { this.waiting--; }
154
+ }
155
+ }
156
+
157
+ const pacer = new MistralBudgetPacer();
158
+ const wrapped = new WeakSet<typeof globalThis.fetch>();
159
+
160
+ /** Wrap canonical Mistral Chat sends with bounded admission and server-delay protection. */
161
+ export function resolveMistralTransport(
162
+ provider: OcxProviderTransport,
163
+ budgetPacer = pacer,
164
+ ): OcxProviderTransport {
165
+ if (!isCanonicalMistralChat(provider) || (provider.fetch && wrapped.has(provider.fetch))) return provider;
166
+ const baseFetch = provider.fetch
167
+ ?? markEgressTransparentExecutor(((input, init) => configuredOutboundFetch(input, init)) as typeof globalThis.fetch);
168
+ const attemptFetch = (async (input, init) => {
169
+ const url = new URL(input instanceof Request ? input.url : String(input));
170
+ if (url.origin !== "https://api.mistral.ai" || url.pathname !== "/v1/chat/completions"
171
+ || url.search || url.hash || init?.method?.toUpperCase() !== "POST" || typeof init.body !== "string") {
172
+ return baseFetch(input, init);
173
+ }
174
+ // The Chat adapter serializes model first. Read only that bounded prefix rather
175
+ // than parsing/copying a potentially million-token conversation a second time.
176
+ const encodedModel = /^\s*\{\s*"model"\s*:\s*("(?:[^"\\]|\\.)*")\s*[,}]/.exec(init.body.slice(0, 4096))?.[1];
177
+ let model: unknown;
178
+ try { model = encodedModel === undefined ? undefined : JSON.parse(encodedModel); } catch { return baseFetch(input, init); }
179
+ const authorization = new Headers(init.headers).get("authorization");
180
+ if (!authorization || typeof model !== "string" || !model || model.length > 256) return baseFetch(input, init);
181
+ // Bind to the actual send credential, including after pool rotation; keep no raw key.
182
+ const identity = createHash("sha256").update(JSON.stringify([authorization, model])).digest("hex");
183
+ const pacing = provider.requestPacing?.enabled !== false && provider.retryOn429?.respectRetryAfter !== false;
184
+ const release = pacing ? await waitOutsideProviderHeaderDeadline(init,
185
+ signal => budgetPacer.acquire(identity, signal, provider.retryOn429?.maxIntervalMs)) : undefined;
186
+ let response: Response;
187
+ try {
188
+ init.signal?.throwIfAborted();
189
+ response = await baseFetch(input, init);
190
+ if (pacing) budgetPacer.observe(identity, response.headers, response.status);
191
+ } catch (error) { release?.(); throw error; }
192
+ release?.(response.ok);
193
+ const retry = provider.retryOn429;
194
+ if (response.status === 429 && retry?.respectRetryAfter !== false) {
195
+ const delay = retryDelay(response.headers, Date.now());
196
+ if (delay !== undefined && delay > (retry?.maxIntervalMs ?? DEFAULT_MAX_WAIT_MS)) {
197
+ // The shared retry helper caps waits. Stop same-key replays sooner than Mistral permits;
198
+ // another credential may still serve, and the original status/body/Retry-After reach the caller.
199
+ markSameTargetRetryHold(response);
200
+ }
201
+ }
202
+ return response;
203
+ }) as typeof globalThis.fetch;
204
+ if (isEgressTransparentExecutor(baseFetch)) markEgressTransparentExecutor(attemptFetch);
205
+ wrapped.add(attemptFetch);
206
+ return { ...provider, fetch: attemptFetch };
207
+ }
@@ -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",
@@ -12,6 +12,7 @@ import {
12
12
  type CodexAuthPolicyConfig,
13
13
  } from "../codex/auth-context";
14
14
  import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome } from "../codex/routing";
15
+ import { codexFailureWindowOutcomeMeta } from "../codex/routing/failure-window";
15
16
  import { inspectChatGptDomainClaim } from "../oauth/chatgpt";
16
17
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "../server/auth-cors";
17
18
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
@@ -205,6 +206,7 @@ export async function resolveFirstUsableOpenAiSidecar(
205
206
  releaseCodexAuthContextProbeLease(authContext);
206
207
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
207
208
  }
209
+ const windowMeta = codexFailureWindowOutcomeMeta(authContext);
208
210
  return {
209
211
  ...candidate,
210
212
  authContext,
@@ -220,6 +222,7 @@ export async function resolveFirstUsableOpenAiSidecar(
220
222
  probeLeaseId: authContext.probeLeaseId,
221
223
  probeQuotaScope: authContext.probeQuotaScope,
222
224
  writerGeneration: authContext.writerGeneration,
225
+ ...windowMeta,
223
226
  // 401/403 here is evidence about this exact stored credential; without the generation a
224
227
  // replacement inherits the quarantine (#2892 gap 4).
225
228
  ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
@@ -256,6 +259,7 @@ export async function resolveFirstUsableOpenAiSidecar(
256
259
  releaseCodexAuthContextProbeLease(authContext);
257
260
  continue;
258
261
  }
262
+ const windowMeta = codexFailureWindowOutcomeMeta(authContext);
259
263
  return {
260
264
  ...candidate,
261
265
  authContext,
@@ -271,6 +275,7 @@ export async function resolveFirstUsableOpenAiSidecar(
271
275
  threadId: authContext.affinityKey,
272
276
  probeLeaseId: authContext.probeLeaseId,
273
277
  writerGeneration: authContext.writerGeneration,
278
+ ...windowMeta,
274
279
  // Same fence as the exact-account recorder above (#2892 gap 4).
275
280
  ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
276
281
  },
@@ -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);
@@ -4,7 +4,9 @@ import { getAccountSet } from "../../oauth/store";
4
4
  import { captureConfigGeneration, sweepExpiredOnWrite } from "../../lib/state-store-sweeper";
5
5
  import { configuredAnthropicInstance, type AnthropicInstanceId } from "../anthropic-instance";
6
6
  import { ACCOUNT_QUOTA_TTL_MS } from "../quota-wire";
7
- import { fetchAnthropicUsageQuotaForInstance } from "./vendor-probes-oauth";
7
+ import { fetchAnthropicUsageProbeForInstance } from "./vendor-probes-oauth";
8
+ import { thrownQuotaFailure } from "./failure-codes";
9
+ import type { QuotaFailureCode } from "../quota-types";
8
10
  import { AnthropicQuotaProbeOwnershipError, anthropicCooldownRecoveryFor, type AnthropicQuotaRecoveryResult } from "./anthropic-cooldown-recovery";
9
11
  import {
10
12
  accountCacheKey, accountQuotaCache, accountQuotaInflight, captureProviderAccountQuotaEpoch,
@@ -49,7 +51,10 @@ export async function fetchAnthropicAccountQuota(
49
51
  }
50
52
  let token: string;
51
53
  try { token = await getTokenForAccountQuotaProbe(instance, accountId); }
52
- catch { return unavailable(); }
54
+ catch {
55
+ // Missing, signed-out or unrefreshable credential: a reauthentication matter, never a quota fact.
56
+ return { ...unavailable(), quotaFailure: "account_unavailable", quotaFailureIsCurrent: ownerCurrent };
57
+ }
53
58
  if (!ownerCurrent()) return unavailable();
54
59
  const flightKey = recovery.anthropicCredentialQuotaFlightKey(key, accountId);
55
60
  const flightCurrent = () => ownerCurrent()
@@ -64,30 +69,38 @@ export async function fetchAnthropicAccountQuota(
64
69
  // and a thrown read (network failure) both record the unavailable row over the retained
65
70
  // in-flight observations, as the pre-instance A path did, so joined callers and later cached
66
71
  // reads agree and windows that expired during the shared probe are normalized away.
67
- const publish = (result: AnthropicQuotaRecoveryResult | null): AccountQuotaCacheEntry => {
72
+ /** 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. */
73
+ const publish = (result: AnthropicQuotaRecoveryResult | null, failure?: QuotaFailureCode): AccountQuotaCacheEntry => {
68
74
  const previous = accountQuotaCache.get(key);
69
75
  const retained = previous?.isCurrent?.() === false ? undefined : previous;
70
76
  const isCurrent = result?.isCurrent ?? flightCurrent;
71
77
  const entry: AccountQuotaCacheEntry = {
72
78
  ts: Date.now(), quota: normalizeAnthropicQuota(result?.quota ?? retained?.quota, Date.now()),
73
79
  ...(!result ? { unavailable: true as const } : {}), isCurrent,
80
+ ...(!result && failure ? { quotaFailure: failure, quotaFailureIsCurrent: isCurrent } : {}),
74
81
  };
75
- if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); sweepExpiredOnWrite(entry.ts); }
82
+ if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); result?.publishDecisionQuota(); sweepExpiredOnWrite(entry.ts); }
76
83
  return entry;
77
84
  };
78
85
  const probe = (async (): Promise<AccountQuotaCacheEntry> => {
86
+ // The closed reason of the last usage read in this flight: an HTTP status class, an unusable
87
+ // body, or a thrown dispatch. Ownership and cooldown refusals never set it.
88
+ let failure: QuotaFailureCode | undefined;
79
89
  try {
80
90
  const result = await recovery.probeAnthropicQuotaWithRecovery(accountId, token, fresh => {
81
91
  if (!flightCurrent()) throw new AnthropicQuotaProbeOwnershipError("quota flight changed");
82
92
  recovery.assertAnthropicQuotaSendAllowed(accountId, token);
83
- return fetchAnthropicUsageQuotaForInstance(instance, token, fresh || freshDispatch);
93
+ return fetchAnthropicUsageProbeForInstance(instance, token, fresh || freshDispatch).then(
94
+ read => { failure = read.failure; return read.quota; },
95
+ error => { failure = thrownQuotaFailure(error); throw error; },
96
+ );
84
97
  }, () => ownerCurrent() && mayCommitAccountQuotaKey(key, writerGeneration));
85
98
  if (result && !result.isCurrent()) return unavailable();
86
99
  if (!result && !flightCurrent()) return unavailable();
87
- return publish(result);
100
+ return publish(result, failure);
88
101
  } catch (error) {
89
102
  if (error instanceof AnthropicQuotaProbeOwnershipError || !flightCurrent()) return unavailable();
90
- return publish(null);
103
+ return publish(null, failure);
91
104
  }
92
105
  })().finally(() => { if (accountQuotaInflight.get(flightKey) === probe) accountQuotaInflight.delete(flightKey); });
93
106
  accountQuotaInflight.set(flightKey, probe);
@@ -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
+ }