@juspay/neurolink 11.1.1 → 11.2.1

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 (74) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/auth/codexOAuth.d.ts +67 -0
  3. package/dist/auth/codexOAuth.js +202 -0
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.js +4 -0
  6. package/dist/browser/neurolink.min.js +401 -401
  7. package/dist/cli/commands/auth.d.ts +27 -8
  8. package/dist/cli/commands/auth.js +425 -6
  9. package/dist/cli/commands/proxy.js +230 -5
  10. package/dist/cli/factories/authCommandFactory.d.ts +8 -0
  11. package/dist/cli/factories/authCommandFactory.js +74 -1
  12. package/dist/lib/auth/codexOAuth.d.ts +67 -0
  13. package/dist/lib/auth/codexOAuth.js +203 -0
  14. package/dist/lib/auth/index.d.ts +1 -0
  15. package/dist/lib/auth/index.js +4 -0
  16. package/dist/lib/providers/configuredOpenAICompat.d.ts +24 -0
  17. package/dist/lib/providers/configuredOpenAICompat.js +60 -0
  18. package/dist/lib/providers/openaiCompatCatalog.d.ts +24 -0
  19. package/dist/lib/providers/openaiCompatCatalog.js +272 -0
  20. package/dist/lib/proxy/accountCooldown.js +35 -2
  21. package/dist/lib/proxy/accountQuota.d.ts +29 -3
  22. package/dist/lib/proxy/accountQuota.js +203 -12
  23. package/dist/lib/proxy/accountUsage.js +15 -2
  24. package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
  25. package/dist/lib/proxy/codexAccountUsage.js +174 -0
  26. package/dist/lib/proxy/proxyAnalysis.js +12 -1
  27. package/dist/lib/proxy/proxyConfig.js +24 -0
  28. package/dist/lib/proxy/routingEvidence.d.ts +12 -1
  29. package/dist/lib/proxy/routingEvidence.js +23 -0
  30. package/dist/lib/proxy/runtimeConfig.js +3 -0
  31. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
  32. package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
  33. package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
  34. package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
  35. package/dist/lib/types/cli.d.ts +7 -1
  36. package/dist/lib/types/codex.d.ts +95 -0
  37. package/dist/lib/types/codex.js +15 -0
  38. package/dist/lib/types/index.d.ts +1 -0
  39. package/dist/lib/types/index.js +1 -0
  40. package/dist/lib/types/providers.d.ts +99 -0
  41. package/dist/lib/types/proxy.d.ts +83 -0
  42. package/dist/lib/types/subscription.d.ts +13 -0
  43. package/dist/lib/utils/providerConfig.d.ts +23 -1
  44. package/dist/lib/utils/providerConfig.js +60 -0
  45. package/dist/providers/configuredOpenAICompat.d.ts +24 -0
  46. package/dist/providers/configuredOpenAICompat.js +59 -0
  47. package/dist/providers/openaiCompatCatalog.d.ts +24 -0
  48. package/dist/providers/openaiCompatCatalog.js +271 -0
  49. package/dist/proxy/accountCooldown.js +35 -2
  50. package/dist/proxy/accountQuota.d.ts +29 -3
  51. package/dist/proxy/accountQuota.js +203 -12
  52. package/dist/proxy/accountUsage.js +15 -2
  53. package/dist/proxy/codexAccountUsage.d.ts +26 -0
  54. package/dist/proxy/codexAccountUsage.js +173 -0
  55. package/dist/proxy/proxyAnalysis.js +12 -1
  56. package/dist/proxy/proxyConfig.js +24 -0
  57. package/dist/proxy/routingEvidence.d.ts +12 -1
  58. package/dist/proxy/routingEvidence.js +23 -0
  59. package/dist/proxy/runtimeConfig.js +3 -0
  60. package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
  61. package/dist/server/routes/claudeProxyRoutes.js +653 -72
  62. package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
  63. package/dist/server/routes/codexProxyRoutes.js +453 -0
  64. package/dist/types/cli.d.ts +7 -1
  65. package/dist/types/codex.d.ts +95 -0
  66. package/dist/types/codex.js +14 -0
  67. package/dist/types/index.d.ts +1 -0
  68. package/dist/types/index.js +1 -0
  69. package/dist/types/providers.d.ts +99 -0
  70. package/dist/types/proxy.d.ts +83 -0
  71. package/dist/types/subscription.d.ts +13 -0
  72. package/dist/utils/providerConfig.d.ts +23 -1
  73. package/dist/utils/providerConfig.js +60 -0
  74. package/package.json +3 -1
@@ -0,0 +1,271 @@
1
+ import { AIProviderName } from "../constants/enums.js";
2
+ import { CloudflareModels, FireworksModels, GroqModels, MistralModels, PerplexityModels, TogetherAIModels, XaiModels, } from "../constants/enums.js";
3
+ import { AuthenticationError, InvalidModelError, ProviderError, } from "../types/index.js";
4
+ import { DEFAULT_ERROR_RULES } from "../utils/errorClassifier.js";
5
+ import { createCloudflareConfig, createFireworksConfig, createGroqConfig, createMistralConfig, createPerplexityConfig, createTogetherAIConfig, createXaiConfig, } from "../utils/providerConfig.js";
6
+ function buildCloudflareBaseURL(accountId) {
7
+ return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`;
8
+ }
9
+ /**
10
+ * Config-driven catalog of the 7 zero-quirk OpenAI-compatible providers.
11
+ * Each entry fully replaces what used to be a hand-written
12
+ * OpenAIChatCompletionsProvider subclass — see ConfiguredOpenAICompatProvider
13
+ * for the class that reads these entries, and providerRegistry.ts for the
14
+ * registration loop that consumes this array.
15
+ *
16
+ * `errorRules` mirrors each provider's LIVE `formatProviderError` rule array
17
+ * (post plan-07/wave-2 migration), not the original hand-rolled ladder these
18
+ * providers had when plan 05 was first drafted: every provider below now
19
+ * keeps only its bespoke rule(s) — auth, plus Groq's model_decommissioned and
20
+ * xAI's insufficient_quota — before spreading the SAME exported
21
+ * `DEFAULT_ERROR_RULES` constant that the live subclasses spread (never an
22
+ * inlined copy, so this catalog cannot drift from that table independently).
23
+ * See plan-05/progress.md Ruling R4 for the full rationale.
24
+ *
25
+ * To add a new zero-quirk OpenAI-compatible provider: add one entry here.
26
+ * Do NOT add a provider here if it needs any hook override beyond the 3
27
+ * mandatory ones (getProviderName/getDefaultModel/formatProviderError) —
28
+ * write a dedicated subclass instead (see deepseek.ts, azureOpenai.ts, and
29
+ * Task 14's docs task for the deciding criteria).
30
+ */
31
+ export const OPENAI_COMPAT_CATALOG = [
32
+ {
33
+ providerName: AIProviderName.GROQ,
34
+ aliases: ["groq"],
35
+ apiKeyEnvVar: "GROQ_API_KEY",
36
+ baseURLEnvVar: "GROQ_BASE_URL",
37
+ defaultBaseURL: "https://api.groq.com/openai/v1",
38
+ configOptions: createGroqConfig(),
39
+ modelEnvVar: "GROQ_MODEL",
40
+ defaultModel: GroqModels.LLAMA_3_3_70B_VERSATILE,
41
+ registryDefaultModel: GroqModels.LLAMA_3_3_70B_VERSATILE,
42
+ registryDefaultModelChecksEnvVar: true,
43
+ fallbackModelName: GroqModels.LLAMA_3_1_8B_INSTANT,
44
+ fallbackModels: [
45
+ GroqModels.LLAMA_3_3_70B_VERSATILE,
46
+ GroqModels.LLAMA_3_1_8B_INSTANT,
47
+ GroqModels.GEMMA_2_9B_IT,
48
+ GroqModels.MIXTRAL_8X7B_32768,
49
+ GroqModels.LLAMA_3_2_90B_VISION_PREVIEW,
50
+ GroqModels.LLAMA_3_2_11B_VISION_PREVIEW,
51
+ ],
52
+ // KNOWN GAP (not reproducible by this data-only array — flagged for PR C,
53
+ // see plan-05/task-A-report.md): live GroqProvider.formatProviderError()
54
+ // intercepts TimeoutError and returns a plain ProviderError BEFORE ever
55
+ // calling classifyProviderError, overriding that function's own
56
+ // (non-overridable) rule that TimeoutError always maps to NetworkError.
57
+ // ConfiguredOpenAICompatProvider.formatProviderError() has no such
58
+ // pre-check hook, so migrating Groq onto it as-is would silently
59
+ // reclassify Groq timeouts as NetworkError. This entry's errorRules
60
+ // still mirrors Groq's live rule array faithfully for every other error
61
+ // shape; the TimeoutError special case is a structural gap in
62
+ // ConfiguredOpenAICompatProvider, not a data error here.
63
+ errorRules: [
64
+ {
65
+ match: (ctx) => ctx.statusCode === 401 ||
66
+ /Invalid API key|Authentication|invalid_api_key/i.test(ctx.message),
67
+ errorClass: AuthenticationError,
68
+ message: "Invalid Groq API key. Check GROQ_API_KEY. Get one at https://console.groq.com/keys",
69
+ },
70
+ {
71
+ match: (ctx) => /model_decommissioned/i.test(ctx.message),
72
+ errorClass: InvalidModelError,
73
+ message: (ctx) => `Groq model '${ctx.modelName}' was decommissioned. Pick a current model from https://console.groq.com/docs/models.`,
74
+ },
75
+ ...DEFAULT_ERROR_RULES,
76
+ ],
77
+ },
78
+ {
79
+ providerName: AIProviderName.XAI,
80
+ aliases: ["xai", "grok"],
81
+ apiKeyEnvVar: "XAI_API_KEY",
82
+ baseURLEnvVar: "XAI_BASE_URL",
83
+ defaultBaseURL: "https://api.x.ai/v1",
84
+ configOptions: createXaiConfig(),
85
+ modelEnvVar: "XAI_MODEL",
86
+ defaultModel: XaiModels.GROK_3,
87
+ registryDefaultModel: XaiModels.GROK_3,
88
+ registryDefaultModelChecksEnvVar: true,
89
+ fallbackModelName: XaiModels.GROK_3_MINI,
90
+ fallbackModels: [
91
+ XaiModels.GROK_3,
92
+ XaiModels.GROK_3_MINI,
93
+ XaiModels.GROK_2_LATEST,
94
+ XaiModels.GROK_2_VISION_LATEST,
95
+ XaiModels.GROK_BETA,
96
+ ],
97
+ errorRules: [
98
+ {
99
+ match: (ctx) => ctx.statusCode === 401 ||
100
+ /Invalid API key|Authentication|invalid_api_key/i.test(ctx.message),
101
+ errorClass: AuthenticationError,
102
+ message: "Invalid xAI API key. Please check your XAI_API_KEY environment variable. Get one at https://console.x.ai/",
103
+ },
104
+ {
105
+ match: (ctx) => /insufficient_quota|quota exceeded/i.test(ctx.message),
106
+ errorClass: ProviderError,
107
+ message: "xAI account has insufficient quota. Top up at https://console.x.ai/",
108
+ },
109
+ ...DEFAULT_ERROR_RULES,
110
+ ],
111
+ },
112
+ {
113
+ providerName: AIProviderName.TOGETHER_AI,
114
+ aliases: ["together-ai", "together"],
115
+ apiKeyEnvVar: "TOGETHER_API_KEY",
116
+ baseURLEnvVar: "TOGETHER_BASE_URL",
117
+ defaultBaseURL: "https://api.together.xyz/v1",
118
+ configOptions: createTogetherAIConfig(),
119
+ modelEnvVar: "TOGETHER_MODEL",
120
+ defaultModel: TogetherAIModels.LLAMA_3_3_70B_INSTRUCT_TURBO,
121
+ registryDefaultModel: TogetherAIModels.LLAMA_3_3_70B_INSTRUCT_TURBO,
122
+ registryDefaultModelChecksEnvVar: true,
123
+ fallbackModelName: TogetherAIModels.LLAMA_3_1_8B_INSTRUCT_TURBO,
124
+ fallbackModels: [
125
+ TogetherAIModels.LLAMA_3_3_70B_INSTRUCT_TURBO,
126
+ TogetherAIModels.LLAMA_3_1_405B_INSTRUCT_TURBO,
127
+ TogetherAIModels.LLAMA_3_1_70B_INSTRUCT_TURBO,
128
+ TogetherAIModels.LLAMA_3_1_8B_INSTRUCT_TURBO,
129
+ TogetherAIModels.MIXTRAL_8X22B_INSTRUCT,
130
+ TogetherAIModels.QWEN_2_5_72B_INSTRUCT_TURBO,
131
+ TogetherAIModels.DEEPSEEK_R1,
132
+ TogetherAIModels.DEEPSEEK_V3,
133
+ ],
134
+ errorRules: [
135
+ {
136
+ match: (ctx) => ctx.statusCode === 401 ||
137
+ /Invalid API key|Authentication/i.test(ctx.message),
138
+ errorClass: AuthenticationError,
139
+ message: "Invalid Together AI API key. Get one at https://api.together.xyz/settings/api-keys",
140
+ },
141
+ ...DEFAULT_ERROR_RULES,
142
+ ],
143
+ },
144
+ {
145
+ providerName: AIProviderName.FIREWORKS,
146
+ aliases: ["fireworks"],
147
+ apiKeyEnvVar: "FIREWORKS_API_KEY",
148
+ baseURLEnvVar: "FIREWORKS_BASE_URL",
149
+ defaultBaseURL: "https://api.fireworks.ai/inference/v1",
150
+ configOptions: createFireworksConfig(),
151
+ modelEnvVar: "FIREWORKS_MODEL",
152
+ defaultModel: FireworksModels.DEEPSEEK_V4_PRO,
153
+ registryDefaultModel: FireworksModels.DEEPSEEK_V4_PRO,
154
+ registryDefaultModelChecksEnvVar: true,
155
+ fallbackModelName: FireworksModels.DEEPSEEK_V4_PRO,
156
+ fallbackModels: [
157
+ FireworksModels.DEEPSEEK_V4_PRO,
158
+ FireworksModels.GLM_5P1,
159
+ FireworksModels.GLM_5,
160
+ FireworksModels.KIMI_K2P6,
161
+ FireworksModels.KIMI_K2P5,
162
+ FireworksModels.GPT_OSS_120B,
163
+ ],
164
+ errorRules: [
165
+ {
166
+ match: (ctx) => ctx.statusCode === 401 ||
167
+ /Invalid API key|Authentication/i.test(ctx.message),
168
+ errorClass: AuthenticationError,
169
+ message: "Invalid Fireworks API key. Get one at https://fireworks.ai/account/api-keys",
170
+ },
171
+ ...DEFAULT_ERROR_RULES,
172
+ ],
173
+ },
174
+ {
175
+ providerName: AIProviderName.PERPLEXITY,
176
+ aliases: ["perplexity", "pplx"],
177
+ apiKeyEnvVar: "PERPLEXITY_API_KEY",
178
+ baseURLEnvVar: "PERPLEXITY_BASE_URL",
179
+ defaultBaseURL: "https://api.perplexity.ai",
180
+ configOptions: createPerplexityConfig(),
181
+ modelEnvVar: "PERPLEXITY_MODEL",
182
+ defaultModel: PerplexityModels.SONAR,
183
+ registryDefaultModel: PerplexityModels.SONAR,
184
+ registryDefaultModelChecksEnvVar: true,
185
+ // Perplexity's live class does NOT override getFallbackModelName() — it
186
+ // inherits the base class default "gpt-3.5-turbo". Preserved here
187
+ // verbatim, not "fixed" to a Perplexity model — that's a real,
188
+ // pre-existing quirk this plan is not authorized to change.
189
+ fallbackModelName: "gpt-3.5-turbo",
190
+ fallbackModels: [
191
+ PerplexityModels.SONAR,
192
+ PerplexityModels.SONAR_PRO,
193
+ PerplexityModels.SONAR_REASONING,
194
+ PerplexityModels.SONAR_REASONING_PRO,
195
+ PerplexityModels.SONAR_DEEP_RESEARCH,
196
+ ],
197
+ errorRules: [
198
+ {
199
+ match: (ctx) => ctx.statusCode === 401 ||
200
+ /Invalid API key|Authentication/i.test(ctx.message),
201
+ errorClass: AuthenticationError,
202
+ message: "Invalid Perplexity API key. Get one at https://www.perplexity.ai/settings/api",
203
+ },
204
+ ...DEFAULT_ERROR_RULES,
205
+ ],
206
+ },
207
+ {
208
+ providerName: AIProviderName.MISTRAL,
209
+ aliases: ["mistral"],
210
+ apiKeyEnvVar: "MISTRAL_API_KEY",
211
+ baseURLEnvVar: "MISTRAL_BASE_URL",
212
+ defaultBaseURL: "https://api.mistral.ai/v1",
213
+ configOptions: createMistralConfig(),
214
+ modelEnvVar: "MISTRAL_MODEL",
215
+ defaultModel: MistralModels.MISTRAL_SMALL_2506,
216
+ // The one documented registry-vs-class default-model quirk (see this
217
+ // plan's "Design reference" section): the registry passes the bare
218
+ // literal MISTRAL_LARGE_LATEST with no env-var check, while
219
+ // MistralProvider.getDefaultModel() checks MISTRAL_MODEL and defaults to
220
+ // MISTRAL_SMALL_2506. Preserved exactly, not reconciled.
221
+ registryDefaultModel: MistralModels.MISTRAL_LARGE_LATEST,
222
+ registryDefaultModelChecksEnvVar: false,
223
+ fallbackModelName: MistralModels.MISTRAL_SMALL_2506,
224
+ fallbackModels: [
225
+ MistralModels.MISTRAL_SMALL_2506,
226
+ MistralModels.MISTRAL_LARGE_LATEST,
227
+ ],
228
+ errorRules: [
229
+ {
230
+ match: (ctx) => ctx.statusCode === 401 ||
231
+ /API_KEY_INVALID|Invalid API key|Unauthorized/i.test(ctx.message),
232
+ errorClass: AuthenticationError,
233
+ message: "Invalid Mistral API key. Please check your MISTRAL_API_KEY environment variable.",
234
+ },
235
+ ...DEFAULT_ERROR_RULES,
236
+ ],
237
+ },
238
+ {
239
+ providerName: AIProviderName.CLOUDFLARE,
240
+ aliases: ["cloudflare", "workers-ai", "cf-ai"],
241
+ apiKeyEnvVar: "CLOUDFLARE_API_KEY",
242
+ computedBaseURL: {
243
+ envVar: "CLOUDFLARE_ACCOUNT_ID",
244
+ missingValueMessage: "CLOUDFLARE_ACCOUNT_ID is required (or pass credentials.cloudflare.accountId). Get the account id from https://dash.cloudflare.com/",
245
+ build: buildCloudflareBaseURL,
246
+ },
247
+ configOptions: createCloudflareConfig(),
248
+ modelEnvVar: "CLOUDFLARE_MODEL",
249
+ defaultModel: CloudflareModels.LLAMA_3_3_70B_FAST,
250
+ registryDefaultModel: CloudflareModels.LLAMA_3_3_70B_FAST,
251
+ registryDefaultModelChecksEnvVar: true,
252
+ fallbackModelName: CloudflareModels.LLAMA_3_1_8B_FAST,
253
+ fallbackModels: [
254
+ CloudflareModels.LLAMA_3_3_70B_FAST,
255
+ CloudflareModels.LLAMA_3_1_70B_INSTRUCT,
256
+ CloudflareModels.LLAMA_3_1_8B_FAST,
257
+ CloudflareModels.LLAMA_3_2_11B_VISION,
258
+ CloudflareModels.MISTRAL_7B_INSTRUCT_V0_2,
259
+ CloudflareModels.QWEN_1P5_14B_CHAT_AWQ,
260
+ ],
261
+ errorRules: [
262
+ {
263
+ match: (ctx) => ctx.statusCode === 401 ||
264
+ /Invalid API key|Authentication/i.test(ctx.message),
265
+ errorClass: AuthenticationError,
266
+ message: "Invalid Cloudflare API key. Use a token with Workers AI Read+Write scope. Get one at https://dash.cloudflare.com/profile/api-tokens",
267
+ },
268
+ ...DEFAULT_ERROR_RULES,
269
+ ],
270
+ },
271
+ ];
@@ -2,7 +2,8 @@ import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { AsyncMutex } from "../utils/asyncMutex.js";
5
- import { ACCOUNT_COOLING_REASONS } from "./routingEvidence.js";
5
+ import { logger } from "../utils/logger.js";
6
+ import { ACCOUNT_COOLING_REASONS, MAX_COOLDOWN_MS_BY_REASON, } from "./routingEvidence.js";
6
7
  import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
7
8
  const COOLDOWN_FILE = "account-cooldowns.json";
8
9
  const VALID_REASONS = new Set(ACCOUNT_COOLING_REASONS);
@@ -32,13 +33,45 @@ function isPersistedCooldown(value) {
32
33
  typeof candidate.reason === "string" &&
33
34
  VALID_REASONS.has(candidate.reason));
34
35
  }
36
+ /**
37
+ * Cap a persisted cooldown at what its reason can plausibly mean, measured from
38
+ * when it was written.
39
+ *
40
+ * Entries written before per-reason ceilings existed can hold a wildly
41
+ * out-of-range wait — a "session" cooldown running for days, from a single stale
42
+ * reset timestamp. Clamping on load heals those without operator action.
43
+ * Clamping rather than dropping keeps a legitimate long weekly cooldown intact.
44
+ */
45
+ function sanitizePersistedCooldown(accountKey, entry) {
46
+ const ceiling = MAX_COOLDOWN_MS_BY_REASON[entry.reason];
47
+ if (ceiling === undefined) {
48
+ return entry;
49
+ }
50
+ const latest = entry.updatedAt + ceiling;
51
+ if (entry.coolingUntil <= latest) {
52
+ return entry;
53
+ }
54
+ // Announce it: an account silently parked far beyond what its reason can mean
55
+ // is exactly the condition that is hard to diagnose from the outside, and this
56
+ // runs once per process so it cannot become noise.
57
+ const hours = (ms) => (ms / 3_600_000).toFixed(1);
58
+ logger.always(`[proxy] cooldown clamp: ${accountKey} ${entry.reason} entry healed from ` +
59
+ `${hours(entry.coolingUntil - entry.updatedAt)}h to ` +
60
+ `${hours(ceiling)}h — the stored wait exceeded what "${entry.reason}" can mean`);
61
+ return { ...entry, coolingUntil: latest };
62
+ }
35
63
  async function ensureAccountCooldownsLoaded() {
36
64
  if (!cacheLoaded) {
37
65
  if (!cacheLoadPromise) {
38
66
  cacheLoadPromise = (async () => {
39
67
  try {
40
68
  const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
41
- memoryCache = Object.fromEntries(Object.entries(parsed).filter((entry) => isPersistedCooldown(entry[1])));
69
+ memoryCache = Object.fromEntries(Object.entries(parsed)
70
+ .filter((entry) => isPersistedCooldown(entry[1]))
71
+ .map(([key, entry]) => [
72
+ key,
73
+ sanitizePersistedCooldown(key, entry),
74
+ ]));
42
75
  }
43
76
  catch {
44
77
  memoryCache = {};
@@ -9,7 +9,14 @@
9
9
  * updates an in-memory cache and debounces disk writes so the request/response
10
10
  * path is never blocked by file I/O.
11
11
  */
12
- import type { AccountQuota } from "../types/index.js";
12
+ import type { AccountQuota, AccountQuotaWindow } from "../types/index.js";
13
+ /**
14
+ * Collapse a wire model id to its family by dropping the snapshot date, so
15
+ * `claude-fable-5-20260115` and `claude-fable-5-20260320` both tag the same
16
+ * scoped window. Without this a window would stop matching the day Anthropic
17
+ * ships a new snapshot.
18
+ */
19
+ export declare function modelFamilyToken(model: string): string;
13
20
  /** Read and normalize Anthropic's authoritative top-level unified status. */
14
21
  export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
15
22
  /**
@@ -20,13 +27,32 @@ export declare function getUnifiedRateLimitStatus(headers: Headers | Record<stri
20
27
  * fallback percentage together with an allowed overage status, which is the
21
28
  * equivalent provider state.
22
29
  */
23
- export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "overageInUse" | "upgradePaths"> | null | undefined): boolean;
30
+ export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "overageInUse" | "overageEnabled" | "overageDisabledReason" | "upgradePaths"> | null | undefined): boolean;
24
31
  /**
25
32
  * Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
26
33
  * Returns `null` when key headers are absent.
27
34
  * Pure computation — no I/O, no blocking.
28
35
  */
29
- export declare function parseQuotaHeaders(headers: Headers | Record<string, string>): AccountQuota | null;
36
+ export declare function parseQuotaHeaders(headers: Headers | Record<string, string>, opts?: {
37
+ model?: string;
38
+ now?: number;
39
+ }): AccountQuota | null;
40
+ /**
41
+ * Merge dynamic limit windows across snapshots from different sources.
42
+ *
43
+ * The two sources see different things and neither is a superset: the usage API
44
+ * reports every plan bucket but only when explicitly refreshed, while response
45
+ * headers report only the window(s) touched by the request just served — but do
46
+ * so continuously. A plain overwrite in either direction loses real data, which
47
+ * is why a header capture used to erase the model-scoped windows a `/limits`
48
+ * refresh had just fetched.
49
+ */
50
+ export declare function mergeQuotaWindows(existing: AccountQuotaWindow[] | undefined, incoming: AccountQuotaWindow[] | undefined): AccountQuotaWindow[] | undefined;
51
+ /**
52
+ * Fold a freshly observed snapshot onto the previous one for the same account,
53
+ * preserving dynamic windows the new snapshot does not carry.
54
+ */
55
+ export declare function mergeQuotaSnapshot(previous: AccountQuota | undefined, incoming: AccountQuota): AccountQuota;
30
56
  /**
31
57
  * Initialise the quota module with a custom file path.
32
58
  * When set, all reads/writes go to this path instead of the default
@@ -33,6 +33,82 @@ function getHeader(headers, name) {
33
33
  }
34
34
  return undefined;
35
35
  }
36
+ /** Enumerate header names, working for both `Headers` and a plain record. */
37
+ function forEachHeaderName(headers, visit) {
38
+ if (typeof headers.forEach === "function") {
39
+ headers.forEach((_value, name) => visit(name));
40
+ return;
41
+ }
42
+ for (const name of Object.keys(headers)) {
43
+ visit(name);
44
+ }
45
+ }
46
+ /**
47
+ * Collapse a wire model id to its family by dropping the snapshot date, so
48
+ * `claude-fable-5-20260115` and `claude-fable-5-20260320` both tag the same
49
+ * scoped window. Without this a window would stop matching the day Anthropic
50
+ * ships a new snapshot.
51
+ */
52
+ export function modelFamilyToken(model) {
53
+ return model
54
+ .trim()
55
+ .replace(/[-_](latest)$/i, "")
56
+ .replace(/-\d{6,8}$/, "");
57
+ }
58
+ /** Unified header window tokens that map to the flat session/weekly fields. */
59
+ const FLAT_UNIFIED_WINDOW_TOKENS = new Set(["5h", "7d"]);
60
+ const UNIFIED_UTILIZATION_HEADER = /^anthropic-ratelimit-unified-([a-z0-9_]+)-utilization$/;
61
+ /**
62
+ * Discover model-scoped rate-limit windows from response headers.
63
+ *
64
+ * Anthropic reports a per-model weekly cap as its own header family — today
65
+ * `anthropic-ratelimit-unified-7d_oi-*`, sent only on responses for the model
66
+ * that cap applies to. The token is matched generically rather than hardcoded
67
+ * so a future `7d_xx` is captured without a code change, mirroring how
68
+ * `mapUsageLimit` preserves the provider's vocabulary verbatim.
69
+ *
70
+ * Requires the request's model: the header states a limit but never says which
71
+ * model it scopes, and an untagged window cannot be matched to a later request.
72
+ */
73
+ function parseScopedQuotaWindows(headers, model, now) {
74
+ if (!model) {
75
+ return [];
76
+ }
77
+ const scopeModel = modelFamilyToken(model);
78
+ if (!scopeModel) {
79
+ return [];
80
+ }
81
+ const tokens = [];
82
+ forEachHeaderName(headers, (name) => {
83
+ const match = UNIFIED_UTILIZATION_HEADER.exec(name.toLowerCase());
84
+ if (match?.[1] && !FLAT_UNIFIED_WINDOW_TOKENS.has(match[1])) {
85
+ tokens.push(match[1]);
86
+ }
87
+ });
88
+ const windows = [];
89
+ for (const token of tokens) {
90
+ const P = `anthropic-ratelimit-unified-${token}-`;
91
+ const used = parseFloat(getHeader(headers, `${P}utilization`) ?? "");
92
+ if (Number.isNaN(used)) {
93
+ continue;
94
+ }
95
+ const resetRaw = getHeader(headers, `${P}reset`);
96
+ const status = getHeader(headers, `${P}status`)?.trim().toLowerCase();
97
+ windows.push({
98
+ kind: token.startsWith("7d") ? "weekly_scoped" : "session_scoped",
99
+ group: token.startsWith("7d") ? "weekly" : "session",
100
+ used,
101
+ status: status ?? "unknown",
102
+ resetsAt: resetRaw ? parseInt(resetRaw, 10) || 0 : 0,
103
+ scopeModel,
104
+ scopeModelId: model,
105
+ headerWindow: token,
106
+ source: "headers",
107
+ updatedAt: now,
108
+ });
109
+ }
110
+ return windows;
111
+ }
36
112
  /** Read and normalize Anthropic's authoritative top-level unified status. */
37
113
  export function getUnifiedRateLimitStatus(headers) {
38
114
  const value = getHeader(headers, "anthropic-ratelimit-unified-status");
@@ -48,7 +124,25 @@ export function getUnifiedRateLimitStatus(headers) {
48
124
  * equivalent provider state.
49
125
  */
50
126
  export function isQuotaOverageAvailable(quota) {
51
- if (quota?.overageStatus?.trim().toLowerCase() !== "allowed") {
127
+ // `extra_usage.is_enabled` from the usage API is the account's own setting and
128
+ // is reported even for an account that has never served a request, which the
129
+ // header signals below cannot cover. Positive only: it is refreshed far less
130
+ // often than headers are, so a stale `false` must not veto live evidence that
131
+ // overage is actually serving.
132
+ //
133
+ // It is also sticky — the merge carries it forward whenever a payload omits
134
+ // `extra_usage` — so a live header saying overage is switched off must be
135
+ // able to veto it. Without that veto an org disabling extra usage would leave
136
+ // the flag true forever, suppressing every cooldown and sending request after
137
+ // request that is certain to 429.
138
+ const overageStatus = quota?.overageStatus?.trim().toLowerCase();
139
+ const providerDisabledOverage = overageStatus === "rejected" || quota?.overageDisabledReason !== undefined;
140
+ if (quota?.overageEnabled === true && !providerDisabledOverage) {
141
+ return true;
142
+ }
143
+ // Explicit null check: overageStatus is now read before this point, so the
144
+ // optional-chain no longer narrows `quota` for the accesses below.
145
+ if (!quota || overageStatus !== "allowed") {
52
146
  return false;
53
147
  }
54
148
  if (quota.overageInUse === true) {
@@ -69,7 +163,7 @@ export function isQuotaOverageAvailable(quota) {
69
163
  * Returns `null` when key headers are absent.
70
164
  * Pure computation — no I/O, no blocking.
71
165
  */
72
- export function parseQuotaHeaders(headers) {
166
+ export function parseQuotaHeaders(headers, opts) {
73
167
  // Anthropic prefixes all quota headers with "anthropic-ratelimit-"
74
168
  const P = "anthropic-ratelimit-";
75
169
  const sessionUtilRaw = getHeader(headers, `${P}unified-5h-utilization`);
@@ -85,6 +179,10 @@ export function parseQuotaHeaders(headers) {
85
179
  const sessionResetRaw = getHeader(headers, `${P}unified-5h-reset`);
86
180
  const weeklyResetRaw = getHeader(headers, `${P}unified-7d-reset`);
87
181
  const fallbackRaw = getHeader(headers, `${P}unified-fallback-percentage`);
182
+ const now = opts?.now ?? Date.now();
183
+ const scopedWindows = parseScopedQuotaWindows(headers, opts?.model, now);
184
+ const overageDisabledReason = getHeader(headers, `${P}unified-overage-disabled-reason`);
185
+ const representativeClaim = getHeader(headers, `${P}unified-representative-claim`);
88
186
  return {
89
187
  unifiedStatus: getUnifiedRateLimitStatus(headers),
90
188
  sessionUsed,
@@ -94,15 +192,114 @@ export function parseQuotaHeaders(headers) {
94
192
  weeklyStatus: getHeader(headers, `${P}unified-7d-status`) ?? "unknown",
95
193
  weeklyResetAt: weeklyResetRaw ? parseInt(weeklyResetRaw, 10) || 0 : 0,
96
194
  fallbackPercentage: fallbackRaw ? parseFloat(fallbackRaw) || 0 : 0,
195
+ // Anthropic does not send `unified-fallback` on the current wire, so this is
196
+ // always "unknown" in practice, which keeps the legacy back-compat branch of
197
+ // isQuotaOverageAvailable inert. Left as-is deliberately: making that branch
198
+ // reachable would stop cooling accounts that today park correctly, and the
199
+ // authoritative extra-usage signal now comes from `overageEnabled` instead.
97
200
  fallbackStatus: getHeader(headers, `${P}unified-fallback`) ?? "unknown",
98
201
  upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
99
202
  overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
100
203
  overageInUse: getHeader(headers, `${P}unified-overage-in-use`)?.trim().toLowerCase() ===
101
204
  "true",
102
- lastUpdated: Date.now(),
205
+ ...(overageDisabledReason ? { overageDisabledReason } : {}),
206
+ ...(representativeClaim ? { representativeClaim } : {}),
207
+ lastUpdated: now,
103
208
  source: "headers",
209
+ ...(scopedWindows.length > 0 ? { windows: scopedWindows } : {}),
104
210
  };
105
211
  }
212
+ /**
213
+ * Identity of a window across refreshes.
214
+ *
215
+ * Scope identity uses `scopeModel` — the model *family* on header-derived
216
+ * windows — ahead of the dated wire id, so a new model snapshot updates the
217
+ * existing window instead of appending a second one for the same cap and
218
+ * growing the array on every release. `source` keeps the two providers' views
219
+ * of the same cap distinct, since they name it differently and are reconciled
220
+ * by freshness rather than merged.
221
+ */
222
+ function quotaWindowKey(window) {
223
+ return [
224
+ window.kind,
225
+ window.source ?? "usage-api",
226
+ window.headerWindow ?? "",
227
+ window.scopeModel ?? window.scopeModelId ?? "",
228
+ window.scopeSurface ?? "",
229
+ ].join("|");
230
+ }
231
+ /**
232
+ * Merge dynamic limit windows across snapshots from different sources.
233
+ *
234
+ * The two sources see different things and neither is a superset: the usage API
235
+ * reports every plan bucket but only when explicitly refreshed, while response
236
+ * headers report only the window(s) touched by the request just served — but do
237
+ * so continuously. A plain overwrite in either direction loses real data, which
238
+ * is why a header capture used to erase the model-scoped windows a `/limits`
239
+ * refresh had just fetched.
240
+ */
241
+ export function mergeQuotaWindows(existing, incoming) {
242
+ if (!incoming?.length) {
243
+ return existing;
244
+ }
245
+ if (!existing?.length) {
246
+ return incoming;
247
+ }
248
+ const merged = new Map();
249
+ const incomingFromUsageApi = incoming.some((window) => (window.source ?? "usage-api") === "usage-api");
250
+ for (const window of existing) {
251
+ // A usage-API sweep is authoritative for every bucket it reports, but it
252
+ // never reports the header-only scoped windows — so those are carried over.
253
+ if (incomingFromUsageApi && (window.source ?? "usage-api") !== "headers") {
254
+ continue;
255
+ }
256
+ merged.set(quotaWindowKey(window), window);
257
+ }
258
+ for (const window of incoming) {
259
+ merged.set(quotaWindowKey(window), window);
260
+ }
261
+ return [...merged.values()];
262
+ }
263
+ /**
264
+ * Fold a freshly observed snapshot onto the previous one for the same account,
265
+ * preserving dynamic windows the new snapshot does not carry.
266
+ */
267
+ export function mergeQuotaSnapshot(previous, incoming) {
268
+ if (!previous) {
269
+ return incoming;
270
+ }
271
+ const windows = mergeQuotaWindows(previous.windows, incoming.windows);
272
+ const next = { ...incoming };
273
+ if (windows !== undefined) {
274
+ next.windows = windows;
275
+ }
276
+ // Account configuration, not per-response state: each source reports only
277
+ // some of these, so a plain overwrite makes the value flicker in and out
278
+ // depending on which source wrote last. `overageEnabled` comes only from the
279
+ // usage API and `overageDisabledReason` only from response headers, so
280
+ // whichever wrote last would otherwise erase the other's field.
281
+ if (next.overageEnabled === undefined &&
282
+ previous.overageEnabled !== undefined) {
283
+ next.overageEnabled = previous.overageEnabled;
284
+ }
285
+ if (next.overageDisabledReason === undefined &&
286
+ previous.overageDisabledReason !== undefined) {
287
+ next.overageDisabledReason = previous.overageDisabledReason;
288
+ }
289
+ if (next.representativeClaim === undefined &&
290
+ previous.representativeClaim !== undefined) {
291
+ next.representativeClaim = previous.representativeClaim;
292
+ }
293
+ // windowsUpdatedAt tracks the last full usage-API sweep; a header capture
294
+ // adds one window and must not claim to have refreshed all of them.
295
+ const windowsUpdatedAt = incoming.source === "usage-api"
296
+ ? incoming.windowsUpdatedAt
297
+ : (incoming.windowsUpdatedAt ?? previous.windowsUpdatedAt);
298
+ if (windowsUpdatedAt !== undefined) {
299
+ next.windowsUpdatedAt = windowsUpdatedAt;
300
+ }
301
+ return next;
302
+ }
106
303
  // ---------------------------------------------------------------------------
107
304
  // In-memory cache + debounced async persistence
108
305
  // ---------------------------------------------------------------------------
@@ -236,15 +433,9 @@ export async function loadAccountQuota(accountKey) {
236
433
  export async function saveAccountQuota(accountKey, quota) {
237
434
  await stateMutex.runExclusive(async () => {
238
435
  await ensureAccountQuotasLoaded();
239
- const next = { ...quota };
240
- // Header-sourced saves carry no dynamic windows; a passive capture right
241
- // after a usage-API refresh must not erase the refreshed buckets.
242
- const existing = memoryCache[accountKey];
243
- if (next.windows === undefined && existing?.windows !== undefined) {
244
- next.windows = existing.windows;
245
- next.windowsUpdatedAt = existing.windowsUpdatedAt;
246
- }
247
- memoryCache[accountKey] = next;
436
+ // A header capture reports only the windows the served request touched, so
437
+ // it must fold onto the existing snapshot rather than replace it.
438
+ memoryCache[accountKey] = mergeQuotaSnapshot(memoryCache[accountKey], quota);
248
439
  dirty = true;
249
440
  cacheVersion += 1;
250
441
  });