@juspay/neurolink 11.2.0 → 11.2.2
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.
- package/CHANGELOG.md +12 -0
- package/dist/auth/codexOAuth.d.ts +67 -0
- package/dist/auth/codexOAuth.js +202 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +4 -0
- package/dist/browser/neurolink.min.js +419 -419
- package/dist/cli/commands/auth.d.ts +27 -8
- package/dist/cli/commands/auth.js +425 -6
- package/dist/cli/commands/proxy.js +230 -5
- package/dist/cli/factories/authCommandFactory.d.ts +8 -0
- package/dist/cli/factories/authCommandFactory.js +74 -1
- package/dist/lib/auth/codexOAuth.d.ts +67 -0
- package/dist/lib/auth/codexOAuth.js +203 -0
- package/dist/lib/auth/index.d.ts +1 -0
- package/dist/lib/auth/index.js +4 -0
- package/dist/lib/providers/openaiChatCompletionsBase.js +26 -14
- package/dist/lib/proxy/accountCooldown.js +35 -2
- package/dist/lib/proxy/accountQuota.d.ts +29 -3
- package/dist/lib/proxy/accountQuota.js +203 -12
- package/dist/lib/proxy/accountUsage.js +15 -2
- package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/lib/proxy/codexAccountUsage.js +174 -0
- package/dist/lib/proxy/proxyAnalysis.js +12 -1
- package/dist/lib/proxy/proxyConfig.js +24 -0
- package/dist/lib/proxy/routingEvidence.d.ts +12 -1
- package/dist/lib/proxy/routingEvidence.js +23 -0
- package/dist/lib/proxy/runtimeConfig.js +3 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
- package/dist/lib/types/cli.d.ts +7 -1
- package/dist/lib/types/codex.d.ts +95 -0
- package/dist/lib/types/codex.js +15 -0
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/proxy.d.ts +83 -0
- package/dist/lib/types/subscription.d.ts +13 -0
- package/dist/providers/openaiChatCompletionsBase.js +26 -14
- package/dist/proxy/accountCooldown.js +35 -2
- package/dist/proxy/accountQuota.d.ts +29 -3
- package/dist/proxy/accountQuota.js +203 -12
- package/dist/proxy/accountUsage.js +15 -2
- package/dist/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/proxy/codexAccountUsage.js +173 -0
- package/dist/proxy/proxyAnalysis.js +12 -1
- package/dist/proxy/proxyConfig.js +24 -0
- package/dist/proxy/routingEvidence.d.ts +12 -1
- package/dist/proxy/routingEvidence.js +23 -0
- package/dist/proxy/runtimeConfig.js +3 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/server/routes/codexProxyRoutes.js +453 -0
- package/dist/types/cli.d.ts +7 -1
- package/dist/types/codex.d.ts +95 -0
- package/dist/types/codex.js +14 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/proxy.d.ts +83 -0
- package/dist/types/subscription.d.ts +13 -0
- package/package.json +3 -1
|
@@ -481,6 +481,13 @@ export type ProxyAccountRoutingCandidate = {
|
|
|
481
481
|
weeklyStatus: string | null;
|
|
482
482
|
weeklyUsed: number | null;
|
|
483
483
|
weeklyResetAt: number | null;
|
|
484
|
+
/** Display name of the model-scoped window that matched the requested model
|
|
485
|
+
* (e.g. "Fable"), or null when the account reports no scoped cap for it.
|
|
486
|
+
* Optional so schema-v1 readers of older records stay valid. */
|
|
487
|
+
scopedModel?: string | null;
|
|
488
|
+
scopedStatus?: string | null;
|
|
489
|
+
scopedUsed?: number | null;
|
|
490
|
+
scopedResetAt?: number | null;
|
|
484
491
|
};
|
|
485
492
|
export type ProxyAccountRoutingDecision = {
|
|
486
493
|
schemaVersion: 1;
|
|
@@ -531,6 +538,15 @@ export type ProxyAccountSortMetrics = {
|
|
|
531
538
|
weeklyReset: number;
|
|
532
539
|
weeklyUsed: number | null;
|
|
533
540
|
weeklyUsedForSort: number;
|
|
541
|
+
/** Model-scoped weekly window matching the requested model. All null/false
|
|
542
|
+
* when the account reports no scoped cap for it (the common case), which
|
|
543
|
+
* makes every scoped comparator rung a no-op for unscoped traffic. */
|
|
544
|
+
scopedModel: string | null;
|
|
545
|
+
scopedStatus: string | null;
|
|
546
|
+
scopedUsed: number | null;
|
|
547
|
+
scopedReset: number;
|
|
548
|
+
scopedUsedForSort: number;
|
|
549
|
+
scopedSaturated: boolean;
|
|
534
550
|
};
|
|
535
551
|
export type RequestLogEntry = {
|
|
536
552
|
timestamp: string;
|
|
@@ -665,6 +681,8 @@ export type AnthropicLoopState = {
|
|
|
665
681
|
} | null;
|
|
666
682
|
authFailureMessage: string | null;
|
|
667
683
|
authCooldownMessage: string | null;
|
|
684
|
+
entitlementFailure: AnthropicEntitlementFailure | null;
|
|
685
|
+
scopedExhaustion: AnthropicScopedExhaustion | null;
|
|
668
686
|
fallbackFailureMessage?: string;
|
|
669
687
|
attemptNumber: number;
|
|
670
688
|
lastTransportErrorCode?: string;
|
|
@@ -752,6 +770,7 @@ export type AnthropicAuthRetryResult = {
|
|
|
752
770
|
retryDelayMs?: number;
|
|
753
771
|
lastError: unknown;
|
|
754
772
|
authFailureMessage: string | null;
|
|
773
|
+
entitlementFailure: AnthropicEntitlementFailure | null;
|
|
755
774
|
sawRateLimit: boolean;
|
|
756
775
|
sawTransientFailure: boolean;
|
|
757
776
|
sawNetworkError: boolean;
|
|
@@ -771,6 +790,7 @@ export type AnthropicNonOkResult = {
|
|
|
771
790
|
body: string;
|
|
772
791
|
contentType?: string;
|
|
773
792
|
} | null;
|
|
793
|
+
entitlementFailure: AnthropicEntitlementFailure | null;
|
|
774
794
|
upstreamSpan?: Span;
|
|
775
795
|
};
|
|
776
796
|
export type PreparedAnthropicAccountAttempt = {
|
|
@@ -997,6 +1017,17 @@ export type AccountQuota = {
|
|
|
997
1017
|
overageStatus: string;
|
|
998
1018
|
/** Whether Anthropic reports that paid overage is actively serving traffic. */
|
|
999
1019
|
overageInUse?: boolean;
|
|
1020
|
+
/** Why overage is unavailable, verbatim from
|
|
1021
|
+
* anthropic-ratelimit-unified-overage-disabled-reason (e.g.
|
|
1022
|
+
* "org_level_disabled"). Present only when the provider states one. */
|
|
1023
|
+
overageDisabledReason?: string;
|
|
1024
|
+
/** Authoritative extra-usage switch from the usage API's
|
|
1025
|
+
* `extra_usage.is_enabled`. Unlike the header trio this is reported even for
|
|
1026
|
+
* an account that has never served a request. */
|
|
1027
|
+
overageEnabled?: boolean;
|
|
1028
|
+
/** Which window Anthropic considers binding right now, verbatim from
|
|
1029
|
+
* anthropic-ratelimit-unified-representative-claim (e.g. "five_hour"). */
|
|
1030
|
+
representativeClaim?: string;
|
|
1000
1031
|
/** Epoch ms when we last captured this data */
|
|
1001
1032
|
lastUpdated: number;
|
|
1002
1033
|
/** Dynamic per-plan limit buckets from the usage API `limits[]` array
|
|
@@ -1033,8 +1064,21 @@ export type AccountQuotaWindow = {
|
|
|
1033
1064
|
isActive?: boolean;
|
|
1034
1065
|
/** Model display name for model-scoped windows (e.g. "Fable"). */
|
|
1035
1066
|
scopeModel?: string;
|
|
1067
|
+
/** Wire model id for the scope when the provider reports one
|
|
1068
|
+
* (`scope.model.id`), which matches a request's `model` exactly and so beats
|
|
1069
|
+
* display-name matching. Often null in practice. */
|
|
1070
|
+
scopeModelId?: string;
|
|
1036
1071
|
/** Surface scope when the provider reports one. */
|
|
1037
1072
|
scopeSurface?: string;
|
|
1073
|
+
/** Epoch ms this individual window was observed. Lets a header-derived window
|
|
1074
|
+
* and a usage-API window on the same account age independently — the flat
|
|
1075
|
+
* `lastUpdated` refreshes on every response and would otherwise make a
|
|
1076
|
+
* days-old scoped window look current. */
|
|
1077
|
+
updatedAt?: number;
|
|
1078
|
+
/** Provenance of this window, mirroring AccountQuotaSource. */
|
|
1079
|
+
source?: AccountQuotaSource;
|
|
1080
|
+
/** Raw unified header token for header-derived windows, e.g. "7d_oi". */
|
|
1081
|
+
headerWindow?: string;
|
|
1038
1082
|
};
|
|
1039
1083
|
/** One utilization window from the OAuth usage endpoint (wire shape, loose). */
|
|
1040
1084
|
export type AnthropicUsageWindow = {
|
|
@@ -2316,6 +2360,40 @@ export type ClaudeSnapshot = {
|
|
|
2316
2360
|
export type ParsedClaudeError = {
|
|
2317
2361
|
errorType?: string;
|
|
2318
2362
|
message?: string;
|
|
2363
|
+
/** `error.details.error_code`, e.g. "oauth_not_allowed_for_organization".
|
|
2364
|
+
* Absent on payloads that carry no details object. */
|
|
2365
|
+
errorCode?: string;
|
|
2366
|
+
};
|
|
2367
|
+
/**
|
|
2368
|
+
* Accounts rejected by an organization/plan entitlement policy during a single
|
|
2369
|
+
* request. Anthropic answers such an account with a `permission_error` that no
|
|
2370
|
+
* amount of retrying or token refreshing can fix, but which a *different*
|
|
2371
|
+
* account may not hit at all — so it drives rotation, and is reported to the
|
|
2372
|
+
* client only once every account has been tried.
|
|
2373
|
+
*/
|
|
2374
|
+
export type AnthropicEntitlementFailure = {
|
|
2375
|
+
status: number;
|
|
2376
|
+
/** Labels of every account that rejected this request on entitlement. */
|
|
2377
|
+
accounts: string[];
|
|
2378
|
+
/** Upstream message from the first such rejection. */
|
|
2379
|
+
message: string;
|
|
2380
|
+
errorCode?: string;
|
|
2381
|
+
};
|
|
2382
|
+
/**
|
|
2383
|
+
* Every account's model-scoped window for the requested model is spent. Unlike
|
|
2384
|
+
* a cooldown this is per-model: the same accounts stay healthy for every other
|
|
2385
|
+
* model, so the client is told to switch model rather than to back off.
|
|
2386
|
+
*/
|
|
2387
|
+
export type AnthropicScopedExhaustion = {
|
|
2388
|
+
/** Wire model id from the request. */
|
|
2389
|
+
model: string;
|
|
2390
|
+
/** Display name of the exhausted window, e.g. "Fable". */
|
|
2391
|
+
scopeModel: string;
|
|
2392
|
+
/** Epoch ms of the soonest reset across the exhausted accounts. */
|
|
2393
|
+
earliestResetMs: number;
|
|
2394
|
+
accounts: string[];
|
|
2395
|
+
/** Provider reason overage is unavailable, e.g. "org_level_disabled". */
|
|
2396
|
+
overageDisabledReason?: string;
|
|
2319
2397
|
};
|
|
2320
2398
|
/** ora spinner instance held by proxy CLI commands, nullable when --quiet. */
|
|
2321
2399
|
export type ProxySpinner = Ora | null;
|
|
@@ -2340,7 +2418,12 @@ export type ProxyRequestRoutingSnapshot = {
|
|
|
2340
2418
|
quotaRoutingEnabled: boolean;
|
|
2341
2419
|
sessionSoftLimit: number;
|
|
2342
2420
|
sessionResetToleranceMs: number;
|
|
2421
|
+
/** Operator policy on spending paid extra usage once a subscription window is
|
|
2422
|
+
* spent. Only "never" can override the provider's own signal. */
|
|
2423
|
+
useOverage: ProxyOveragePolicy;
|
|
2343
2424
|
};
|
|
2425
|
+
/** Operator policy for paid extra usage. */
|
|
2426
|
+
export type ProxyOveragePolicy = "auto" | "always" | "never";
|
|
2344
2427
|
/** Immutable last-known-good proxy configuration published at runtime. */
|
|
2345
2428
|
export type ProxyRuntimeConfigSnapshot = ProxyRequestRoutingSnapshot & {
|
|
2346
2429
|
loadedAt: string;
|
|
@@ -998,6 +998,19 @@ export type ProxyRoutingConfig = {
|
|
|
998
998
|
passthroughModels?: string[];
|
|
999
999
|
/** Enable quota-aware fill-first account ordering. Defaults to true. */
|
|
1000
1000
|
quotaRouting?: boolean;
|
|
1001
|
+
/**
|
|
1002
|
+
* Whether an account may keep serving on paid extra usage once its
|
|
1003
|
+
* subscription window is spent.
|
|
1004
|
+
*
|
|
1005
|
+
* - `auto` (default): follow whatever Anthropic reports for the account.
|
|
1006
|
+
* - `never`: park the account at the subscription limit even when extra usage
|
|
1007
|
+
* is enabled, so the pool can never spend credits.
|
|
1008
|
+
* - `always`: keep serving whenever the provider permits extra usage.
|
|
1009
|
+
*
|
|
1010
|
+
* Only `never` can override the provider — nothing here can enable extra usage
|
|
1011
|
+
* that Anthropic has disabled (e.g. `org_level_disabled`).
|
|
1012
|
+
*/
|
|
1013
|
+
useOverage?: "auto" | "always" | "never";
|
|
1001
1014
|
/** Session utilization threshold used to proactively demote an account. */
|
|
1002
1015
|
sessionSoftLimit?: number;
|
|
1003
1016
|
/** Reset-time bucket width used when ordering quota windows. */
|
|
@@ -464,15 +464,22 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
464
464
|
const apiErr = await buildAPIError(url, body, res);
|
|
465
465
|
// One-shot 400 retry. The overflow corrector runs FIRST (it can
|
|
466
466
|
// re-fit max_tokens from the provider's own numbers and also
|
|
467
|
-
// self-heals the runtime window registry);
|
|
468
|
-
// may strip a rejected field
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
467
|
+
// self-heals the runtime window registry); its output then feeds
|
|
468
|
+
// a subclass hook that may strip a rejected field (e.g. NIM's
|
|
469
|
+
// chat_template / reasoning_budget), so a body that needs BOTH
|
|
470
|
+
// fixes gets both — a plain `??` between the two would let
|
|
471
|
+
// whichever ran first silently win and drop the other's fix. The
|
|
472
|
+
// retry runs under the SAME timeout controller as the first
|
|
473
|
+
// attempt, so the configured timeout caps the overall call —
|
|
474
|
+
// matching the streaming path, which reuses its composed signal
|
|
475
|
+
// for the retry.
|
|
473
476
|
const retryBody = res.status === 400
|
|
474
|
-
? (
|
|
475
|
-
|
|
477
|
+
? (() => {
|
|
478
|
+
const typedErr = apiErr;
|
|
479
|
+
const overflowCorrected = correctBodyAfterContextOverflow(body, typedErr);
|
|
480
|
+
return (adjustBodyAfter400(overflowCorrected ?? body, typedErr) ??
|
|
481
|
+
overflowCorrected);
|
|
482
|
+
})()
|
|
476
483
|
: undefined;
|
|
477
484
|
if (!retryBody) {
|
|
478
485
|
throw apiErr;
|
|
@@ -967,13 +974,18 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
967
974
|
// consumed inside doFetch's closure, either returned on success or
|
|
968
975
|
// discarded after buildAPIError read its body on failure.
|
|
969
976
|
const apiErr = err;
|
|
970
|
-
// Overflow corrector first (re-fits max_tokens from the provider's
|
|
971
|
-
//
|
|
972
|
-
// hook (e.g. NIM strips chat_template / reasoning_budget when
|
|
973
|
-
// rejects them)
|
|
977
|
+
// Overflow corrector first (re-fits max_tokens from the provider's own
|
|
978
|
+
// numbers + self-heals the window registry); its output then feeds the
|
|
979
|
+
// subclass hook (e.g. NIM strips chat_template / reasoning_budget when
|
|
980
|
+
// a model rejects them), so a body needing BOTH fixes gets both — a
|
|
981
|
+
// plain `??` between the two would let whichever ran first silently
|
|
982
|
+
// win and drop the other's fix.
|
|
974
983
|
const retryBody = apiErr.statusCode === 400
|
|
975
|
-
? (
|
|
976
|
-
this.
|
|
984
|
+
? (() => {
|
|
985
|
+
const overflowCorrected = this.correctBodyAfterContextOverflow(body, apiErr);
|
|
986
|
+
return (this.adjustBodyAfter400(overflowCorrected ?? body, apiErr) ??
|
|
987
|
+
overflowCorrected);
|
|
988
|
+
})()
|
|
977
989
|
: undefined;
|
|
978
990
|
if (!retryBody) {
|
|
979
991
|
throw apiErr;
|
|
@@ -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 {
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
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
|
});
|
|
@@ -199,12 +199,14 @@ function toFraction(percent) {
|
|
|
199
199
|
? percent / 100
|
|
200
200
|
: undefined;
|
|
201
201
|
}
|
|
202
|
-
function mapUsageLimit(entry) {
|
|
202
|
+
function mapUsageLimit(entry, now) {
|
|
203
203
|
const window = {
|
|
204
204
|
kind: entry.kind ?? "unknown",
|
|
205
205
|
used: toFraction(entry.percent) ?? 0,
|
|
206
206
|
status: deriveWindowStatus(entry.percent, entry.severity),
|
|
207
207
|
resetsAt: isoToEpochSeconds(entry.resets_at),
|
|
208
|
+
source: "usage-api",
|
|
209
|
+
updatedAt: now,
|
|
208
210
|
};
|
|
209
211
|
if (entry.group !== undefined) {
|
|
210
212
|
window.group = entry.group;
|
|
@@ -219,6 +221,13 @@ function mapUsageLimit(entry) {
|
|
|
219
221
|
if (scopeModel) {
|
|
220
222
|
window.scopeModel = scopeModel;
|
|
221
223
|
}
|
|
224
|
+
// The wire id matches a request's `model` exactly, so keeping it lets routing
|
|
225
|
+
// skip the fuzzy display-name match ("Fable" vs "claude-fable-5-20260115").
|
|
226
|
+
// Often null in practice, which is why the display-name path still exists.
|
|
227
|
+
const scopeModelId = entry.scope?.model?.id;
|
|
228
|
+
if (scopeModelId) {
|
|
229
|
+
window.scopeModelId = scopeModelId;
|
|
230
|
+
}
|
|
222
231
|
const scopeSurface = entry.scope?.surface;
|
|
223
232
|
if (scopeSurface) {
|
|
224
233
|
window.scopeSurface = scopeSurface;
|
|
@@ -242,7 +251,7 @@ export function usageToQuota(usage, opts) {
|
|
|
242
251
|
return null;
|
|
243
252
|
}
|
|
244
253
|
const { now, prior } = opts;
|
|
245
|
-
const windows = limits.map(mapUsageLimit);
|
|
254
|
+
const windows = limits.map((entry) => mapUsageLimit(entry, now));
|
|
246
255
|
const sessionLimit = limits.find((entry) => entry.kind === "session");
|
|
247
256
|
const weeklyLimit = limits.find((entry) => entry.kind === "weekly_all");
|
|
248
257
|
const sessionPct = usage.five_hour?.utilization ?? sessionLimit?.percent ?? undefined;
|
|
@@ -273,6 +282,10 @@ export function usageToQuota(usage, opts) {
|
|
|
273
282
|
weeklyResetAt,
|
|
274
283
|
fallbackPercentage: prior?.fallbackPercentage ?? 0,
|
|
275
284
|
overageStatus,
|
|
285
|
+
// Authoritative: the header trio the legacy overage checks rely on is only
|
|
286
|
+
// ever sent on a served response, so an account refreshed but not yet used
|
|
287
|
+
// would otherwise look overage-ineligible even with extra usage switched on.
|
|
288
|
+
...(typeof overageEnabled === "boolean" ? { overageEnabled } : {}),
|
|
276
289
|
lastUpdated: now,
|
|
277
290
|
windows,
|
|
278
291
|
windowsUpdatedAt: now,
|