@juspay/neurolink 10.8.9 → 10.8.11
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/browser/neurolink.min.js +389 -389
- package/dist/lib/proxy/accountQuota.d.ts +8 -0
- package/dist/lib/proxy/accountQuota.js +23 -0
- package/dist/lib/proxy/proxyAnalysis.js +10 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +20 -4
- package/dist/lib/server/routes/claudeProxyRoutes.js +165 -62
- package/dist/lib/types/proxy.d.ts +18 -0
- package/dist/proxy/accountQuota.d.ts +8 -0
- package/dist/proxy/accountQuota.js +23 -0
- package/dist/proxy/proxyAnalysis.js +10 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +20 -4
- package/dist/server/routes/claudeProxyRoutes.js +165 -62
- package/dist/types/proxy.d.ts +18 -0
- package/package.json +1 -1
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
import type { AccountQuota } from "../types/index.js";
|
|
13
13
|
/** Read and normalize Anthropic's authoritative top-level unified status. */
|
|
14
14
|
export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Whether Anthropic explicitly permits a request to use overage after a
|
|
17
|
+
* subscription window is exhausted. Fresh responses require all three
|
|
18
|
+
* provider signals. Older persisted snapshots predate the raw fallback and
|
|
19
|
+
* upgrade-path fields, but retain a positive fallback percentage together with
|
|
20
|
+
* an allowed overage status, which is the equivalent provider state.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "upgradePaths"> | null | undefined): boolean;
|
|
15
23
|
/**
|
|
16
24
|
* Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
|
|
17
25
|
* Returns `null` when key headers are absent.
|
|
@@ -39,6 +39,27 @@ export function getUnifiedRateLimitStatus(headers) {
|
|
|
39
39
|
const normalized = value?.trim().toLowerCase();
|
|
40
40
|
return normalized || undefined;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether Anthropic explicitly permits a request to use overage after a
|
|
44
|
+
* subscription window is exhausted. Fresh responses require all three
|
|
45
|
+
* provider signals. Older persisted snapshots predate the raw fallback and
|
|
46
|
+
* upgrade-path fields, but retain a positive fallback percentage together with
|
|
47
|
+
* an allowed overage status, which is the equivalent provider state.
|
|
48
|
+
*/
|
|
49
|
+
export function isQuotaOverageAvailable(quota) {
|
|
50
|
+
if (quota?.overageStatus?.trim().toLowerCase() !== "allowed") {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const explicitFallback = quota.fallbackStatus?.trim().toLowerCase();
|
|
54
|
+
const hasExplicitOveragePath = (quota.upgradePaths ?? "")
|
|
55
|
+
.split(",")
|
|
56
|
+
.map((path) => path.trim().toLowerCase())
|
|
57
|
+
.includes("overage");
|
|
58
|
+
if (explicitFallback === "available" && hasExplicitOveragePath) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return explicitFallback === undefined && (quota.fallbackPercentage ?? 0) > 0;
|
|
62
|
+
}
|
|
42
63
|
/**
|
|
43
64
|
* Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
|
|
44
65
|
* Returns `null` when key headers are absent.
|
|
@@ -69,6 +90,8 @@ export function parseQuotaHeaders(headers) {
|
|
|
69
90
|
weeklyStatus: getHeader(headers, `${P}unified-7d-status`) ?? "unknown",
|
|
70
91
|
weeklyResetAt: weeklyResetRaw ? parseInt(weeklyResetRaw, 10) || 0 : 0,
|
|
71
92
|
fallbackPercentage: fallbackRaw ? parseFloat(fallbackRaw) || 0 : 0,
|
|
93
|
+
fallbackStatus: getHeader(headers, `${P}unified-fallback`) ?? "unknown",
|
|
94
|
+
upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
|
|
72
95
|
overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
|
|
73
96
|
lastUpdated: Date.now(),
|
|
74
97
|
};
|
|
@@ -87,6 +87,7 @@ function routingCandidateValue(value) {
|
|
|
87
87
|
"sessionStatus",
|
|
88
88
|
"weeklyStatus",
|
|
89
89
|
];
|
|
90
|
+
const optionalNullableStringFields = ["fallbackStatus", "upgradePaths"];
|
|
90
91
|
if (!stringValue(candidate.account) ||
|
|
91
92
|
typeof candidate.accountType !== "string" ||
|
|
92
93
|
!ROUTING_ACCOUNT_TYPES.has(candidate.accountType) ||
|
|
@@ -97,6 +98,12 @@ function routingCandidateValue(value) {
|
|
|
97
98
|
requiredBooleanFields.some((field) => typeof candidate[field] !== "boolean") ||
|
|
98
99
|
requiredNullableNumberFields.some((field) => !isNullableFiniteNumber(candidate[field])) ||
|
|
99
100
|
requiredNullableStringFields.some((field) => !isNullableString(candidate[field])) ||
|
|
101
|
+
optionalNullableStringFields.some((field) => field in candidate &&
|
|
102
|
+
candidate[field] !== undefined &&
|
|
103
|
+
!isNullableString(candidate[field])) ||
|
|
104
|
+
("overageEligible" in candidate &&
|
|
105
|
+
candidate.overageEligible !== undefined &&
|
|
106
|
+
typeof candidate.overageEligible !== "boolean") ||
|
|
100
107
|
!(candidate.coolingReason === null ||
|
|
101
108
|
(typeof candidate.coolingReason === "string" &&
|
|
102
109
|
COOLING_REASONS.has(candidate.coolingReason)))) {
|
|
@@ -117,6 +124,9 @@ function routingCandidateValue(value) {
|
|
|
117
124
|
coolingReason: candidate.coolingReason,
|
|
118
125
|
coolingUntil: candidate.coolingUntil,
|
|
119
126
|
unifiedStatus: candidate.unifiedStatus,
|
|
127
|
+
fallbackStatus: candidate.fallbackStatus,
|
|
128
|
+
upgradePaths: candidate.upgradePaths,
|
|
129
|
+
overageEligible: candidate.overageEligible,
|
|
120
130
|
overageStatus: candidate.overageStatus,
|
|
121
131
|
sessionStatus: candidate.sessionStatus,
|
|
122
132
|
sessionUsed: candidate.sessionUsed,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
|
|
13
13
|
import { ProxyTracer } from "../../proxy/proxyTracer.js";
|
|
14
14
|
import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
|
|
15
|
-
import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
15
|
+
import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyQuotaCooldownUpdate, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
16
16
|
declare function tryAcquireAccountAdmission(accountKey: string, capacity: number): AccountAdmissionLease | undefined;
|
|
17
17
|
declare function acquireAccountAdmission(accountKey: string, capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<AccountAdmissionLease | undefined>;
|
|
18
18
|
declare function acquireFirstAvailableAccountAdmission(accountKeys: string[], capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<{
|
|
@@ -45,15 +45,23 @@ declare function resetEpochToMs(resetEpoch: number | undefined, now: number): nu
|
|
|
45
45
|
* The unified subscription limits expose per-window status + reset:
|
|
46
46
|
* - weekly (7d) "rejected" → hard cap for the week; cool until the 7d reset.
|
|
47
47
|
* - session (5h) "rejected" → paced out for this session; cool until the 5h reset.
|
|
48
|
-
* Both mean "retrying this account is futile until its window resets"
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Both mean "retrying this account is futile until its window resets" unless
|
|
49
|
+
* the provider explicitly enables overage. In that case, the subscription
|
|
50
|
+
* window is exhausted but the account remains usable for paid fallback.
|
|
51
51
|
*
|
|
52
52
|
* Anything else (window still "allowed" but momentarily 429'd — a per-minute
|
|
53
53
|
* burst / acceleration limit) is transient: honor retry-after as a floor,
|
|
54
54
|
* allow a couple of jittered same-account retries, then a short cooldown.
|
|
55
55
|
*/
|
|
56
56
|
declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number, unifiedStatus?: string | undefined): AccountCooldownPlan;
|
|
57
|
+
/**
|
|
58
|
+
* Proactively cool an account when a SUCCESS response reveals a window has just
|
|
59
|
+
* flipped to "rejected" (the boundary request that spends the last of the quota
|
|
60
|
+
* still returns 200 but reports rejected/next-reset). Parks the account until
|
|
61
|
+
* its reset so the next request skips it instead of discovering the limit via a
|
|
62
|
+
* 429, except when the provider explicitly enables paid overage.
|
|
63
|
+
*/
|
|
64
|
+
declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: AccountQuota, now: number): ProxyQuotaCooldownUpdate;
|
|
57
65
|
/**
|
|
58
66
|
* Seed each account's runtime quota from the persisted snapshots in
|
|
59
67
|
* ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
|
|
@@ -289,6 +297,12 @@ export declare function createClaudeProxyRoutes(modelRouter?: ModelRouterInterfa
|
|
|
289
297
|
declare function reconcileEligibleAccountRuntimeState(account: ProxyPassthroughAccount): void;
|
|
290
298
|
export declare function getTransientSameAccountRetryDelayMs(retryNumber: number): number;
|
|
291
299
|
declare function describeTransportError(error: unknown): string;
|
|
300
|
+
/**
|
|
301
|
+
* Determine whether a POST can be retried without risking duplicate provider
|
|
302
|
+
* work. Only failures that prove connection establishment did not complete are
|
|
303
|
+
* safe; a reset, socket error, or response timeout can happen after dispatch.
|
|
304
|
+
*/
|
|
305
|
+
declare function isRetryableNetworkError(error: unknown): boolean;
|
|
292
306
|
/**
|
|
293
307
|
* Parse a Claude error payload when available.
|
|
294
308
|
*/
|
|
@@ -329,6 +343,8 @@ export declare const __testHooks: {
|
|
|
329
343
|
resolveHomeIndex: typeof resolveHomeIndex;
|
|
330
344
|
maybeResetPrimaryToHome: typeof maybeResetPrimaryToHome;
|
|
331
345
|
planCooldownFor429: typeof planCooldownFor429;
|
|
346
|
+
reconcileCooldownFromQuota: typeof reconcileCooldownFromQuota;
|
|
347
|
+
isRetryableNetworkError: typeof isRetryableNetworkError;
|
|
332
348
|
isPermanentRefreshFailure: typeof isPermanentRefreshFailure;
|
|
333
349
|
getStreamFailureDetails: typeof getStreamFailureDetails;
|
|
334
350
|
trackUpstreamReadableStream: typeof trackUpstreamReadableStream;
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
import { access, readFile } from "node:fs/promises";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import { Agent } from "undici";
|
|
15
16
|
import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, parseClaudeCodeUserId, } from "../../auth/anthropicOAuth.js";
|
|
16
17
|
import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
|
|
17
18
|
import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
18
|
-
import { getUnifiedRateLimitStatus, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
19
|
+
import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
19
20
|
import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
|
|
20
21
|
import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
|
|
21
22
|
import { tracers } from "../../telemetry/tracers.js";
|
|
@@ -91,6 +92,21 @@ const AUTH_REFRESH_MAX_COOLDOWN_MS = 5 * 60 * 1000;
|
|
|
91
92
|
* to cover the full lifecycle of streaming responses, including extended
|
|
92
93
|
* thinking from Opus models (which can exceed 5 minutes for large contexts). */
|
|
93
94
|
const UPSTREAM_FETCH_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
|
95
|
+
let anthropicUpstreamDispatcher;
|
|
96
|
+
function fetchAnthropicUpstream(url, init) {
|
|
97
|
+
// Node's global fetch applies Undici's 300s default headers timeout before
|
|
98
|
+
// the route's 15-minute abort signal. Keep both transport deadlines aligned
|
|
99
|
+
// with the proxy contract and instantiate lazily so importing routes has no
|
|
100
|
+
// open transport handles.
|
|
101
|
+
anthropicUpstreamDispatcher ??= new Agent({
|
|
102
|
+
headersTimeout: UPSTREAM_FETCH_TIMEOUT_MS,
|
|
103
|
+
bodyTimeout: UPSTREAM_FETCH_TIMEOUT_MS,
|
|
104
|
+
});
|
|
105
|
+
return fetch(url, {
|
|
106
|
+
...init,
|
|
107
|
+
dispatcher: anthropicUpstreamDispatcher,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
94
110
|
const accountRuntimeState = new Map();
|
|
95
111
|
/** Shared across requests so a concurrent burst gets at most two retries for
|
|
96
112
|
* the account/window, rather than every request starting its own retry chain. */
|
|
@@ -411,9 +427,9 @@ function clampCooldownUntil(untilMs, now) {
|
|
|
411
427
|
* The unified subscription limits expose per-window status + reset:
|
|
412
428
|
* - weekly (7d) "rejected" → hard cap for the week; cool until the 7d reset.
|
|
413
429
|
* - session (5h) "rejected" → paced out for this session; cool until the 5h reset.
|
|
414
|
-
* Both mean "retrying this account is futile until its window resets"
|
|
415
|
-
*
|
|
416
|
-
*
|
|
430
|
+
* Both mean "retrying this account is futile until its window resets" unless
|
|
431
|
+
* the provider explicitly enables overage. In that case, the subscription
|
|
432
|
+
* window is exhausted but the account remains usable for paid fallback.
|
|
417
433
|
*
|
|
418
434
|
* Anything else (window still "allowed" but momentarily 429'd — a per-minute
|
|
419
435
|
* burst / acceleration limit) is transient: honor retry-after as a floor,
|
|
@@ -430,7 +446,8 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
|
|
|
430
446
|
rotateImmediately: true,
|
|
431
447
|
};
|
|
432
448
|
}
|
|
433
|
-
|
|
449
|
+
const overageAvailable = isQuotaOverageAvailable(quota);
|
|
450
|
+
if (quota && quota.sessionStatus === "rejected" && !overageAvailable) {
|
|
434
451
|
const reset = resetEpochToMs(quota.sessionResetAt, now) ??
|
|
435
452
|
(retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
|
|
436
453
|
return {
|
|
@@ -442,7 +459,7 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
|
|
|
442
459
|
// Anthropic may reject the authoritative top-level unified limit while both
|
|
443
460
|
// 5h and 7d sub-window statuses still say "allowed". Treating this as a
|
|
444
461
|
// transient burst retries a known-exhausted account and delays failover.
|
|
445
|
-
if (unifiedStatus?.trim().toLowerCase() === "rejected") {
|
|
462
|
+
if (unifiedStatus?.trim().toLowerCase() === "rejected" && !overageAvailable) {
|
|
446
463
|
const reset = retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_HARD_COOLDOWN_MS;
|
|
447
464
|
return {
|
|
448
465
|
reason: "unified",
|
|
@@ -470,34 +487,53 @@ function minutesUntil(untilMs, now) {
|
|
|
470
487
|
* flipped to "rejected" (the boundary request that spends the last of the quota
|
|
471
488
|
* still returns 200 but reports rejected/next-reset). Parks the account until
|
|
472
489
|
* its reset so the next request skips it instead of discovering the limit via a
|
|
473
|
-
* 429
|
|
490
|
+
* 429, except when the provider explicitly enables paid overage.
|
|
474
491
|
*/
|
|
475
|
-
function
|
|
492
|
+
function reconcileCooldownFromQuota(state, quota, now) {
|
|
493
|
+
const overageAvailable = isQuotaOverageAvailable(quota);
|
|
476
494
|
let until;
|
|
477
495
|
let reason;
|
|
478
496
|
if (quota.weeklyStatus === "rejected") {
|
|
479
497
|
until = resetEpochToMs(quota.weeklyResetAt, now);
|
|
480
498
|
reason = "weekly";
|
|
481
499
|
}
|
|
482
|
-
|
|
500
|
+
if (until === undefined &&
|
|
501
|
+
overageAvailable &&
|
|
502
|
+
state.coolingUntil &&
|
|
503
|
+
(state.coolingReason === "session" || state.coolingReason === "unified")) {
|
|
504
|
+
const previousCoolingUntil = state.coolingUntil;
|
|
505
|
+
state.coolingUntil = undefined;
|
|
506
|
+
state.coolingReason = undefined;
|
|
507
|
+
logger.always("[proxy] clearing subscription cooldown because Anthropic explicitly permits overage");
|
|
508
|
+
return { kind: "cleared", coolingUntil: previousCoolingUntil };
|
|
509
|
+
}
|
|
510
|
+
if (until === undefined &&
|
|
511
|
+
quota.sessionStatus === "rejected" &&
|
|
512
|
+
!overageAvailable) {
|
|
483
513
|
until = resetEpochToMs(quota.sessionResetAt, now);
|
|
484
514
|
reason = "session";
|
|
485
515
|
}
|
|
486
|
-
else if (
|
|
516
|
+
else if (until === undefined &&
|
|
517
|
+
quota.unifiedStatus === "rejected" &&
|
|
518
|
+
!overageAvailable) {
|
|
487
519
|
until = now + DEFAULT_HARD_COOLDOWN_MS;
|
|
488
520
|
reason = "unified";
|
|
489
521
|
}
|
|
490
522
|
if (until === undefined) {
|
|
491
|
-
return
|
|
523
|
+
return null;
|
|
492
524
|
}
|
|
493
525
|
const clamped = clampCooldownUntil(until, now);
|
|
494
526
|
if (!state.coolingUntil || clamped > state.coolingUntil) {
|
|
495
527
|
state.coolingUntil = clamped;
|
|
496
528
|
state.coolingReason = reason;
|
|
497
529
|
logger.always(`[proxy] proactively cooling account (${reason}) ~${minutesUntil(clamped, now)}m from success-response quota (status rejected)`);
|
|
498
|
-
return
|
|
530
|
+
return {
|
|
531
|
+
kind: "cooled",
|
|
532
|
+
coolingUntil: clamped,
|
|
533
|
+
coolingReason: reason ?? "unified",
|
|
534
|
+
};
|
|
499
535
|
}
|
|
500
|
-
return
|
|
536
|
+
return null;
|
|
501
537
|
}
|
|
502
538
|
/**
|
|
503
539
|
* Seed each account's runtime quota from the persisted snapshots in
|
|
@@ -528,6 +564,15 @@ async function seedRuntimeQuotasFromDisk(accounts) {
|
|
|
528
564
|
state.coolingUntil = persistedCooldown.coolingUntil;
|
|
529
565
|
state.coolingReason = persistedCooldown.reason;
|
|
530
566
|
}
|
|
567
|
+
if (state.quota) {
|
|
568
|
+
const cooldownUpdate = reconcileCooldownFromQuota(state, state.quota, now);
|
|
569
|
+
if (cooldownUpdate?.kind === "cooled") {
|
|
570
|
+
await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason);
|
|
571
|
+
}
|
|
572
|
+
else if (cooldownUpdate?.kind === "cleared") {
|
|
573
|
+
await clearAccountCooldown(account.key, cooldownUpdate.coolingUntil);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
531
576
|
}
|
|
532
577
|
}
|
|
533
578
|
catch {
|
|
@@ -594,13 +639,16 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
|
|
|
594
639
|
? (q.weeklyStatus ?? "unknown")
|
|
595
640
|
: "allowed"
|
|
596
641
|
: null;
|
|
642
|
+
const overageEligible = isQuotaOverageAvailable(q);
|
|
597
643
|
const saturated = sessionStatus === "throttled" ||
|
|
598
644
|
(sessionTicking && (sessionUsed ?? 0) >= sessionSoftLimit);
|
|
599
645
|
const quotaLastUpdated = q && Number.isFinite(q.lastUpdated) ? q.lastUpdated : null;
|
|
600
646
|
return {
|
|
601
647
|
usable: !coolingActive &&
|
|
602
648
|
weeklyStatus !== "rejected" &&
|
|
603
|
-
sessionStatus !== "rejected"
|
|
649
|
+
(sessionStatus !== "rejected" || overageEligible) &&
|
|
650
|
+
(q?.unifiedStatus?.trim().toLowerCase() !== "rejected" ||
|
|
651
|
+
overageEligible),
|
|
604
652
|
saturated,
|
|
605
653
|
hasQuota: !!q,
|
|
606
654
|
quotaLastUpdated,
|
|
@@ -609,6 +657,9 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
|
|
|
609
657
|
coolingReason: st?.coolingReason ?? null,
|
|
610
658
|
coolingUntil: st?.coolingUntil ?? 0,
|
|
611
659
|
unifiedStatus: q?.unifiedStatus ?? null,
|
|
660
|
+
fallbackStatus: q?.fallbackStatus ?? null,
|
|
661
|
+
upgradePaths: q?.upgradePaths ?? null,
|
|
662
|
+
overageEligible,
|
|
612
663
|
overageStatus: q?.overageStatus ?? null,
|
|
613
664
|
sessionStatus,
|
|
614
665
|
sessionUsed,
|
|
@@ -735,6 +786,9 @@ function buildRoutingDecision(args) {
|
|
|
735
786
|
? metrics.coolingUntil
|
|
736
787
|
: null,
|
|
737
788
|
unifiedStatus: metrics.unifiedStatus,
|
|
789
|
+
fallbackStatus: metrics.fallbackStatus,
|
|
790
|
+
upgradePaths: metrics.upgradePaths,
|
|
791
|
+
overageEligible: metrics.overageEligible,
|
|
738
792
|
overageStatus: metrics.overageStatus,
|
|
739
793
|
sessionStatus: metrics.sessionStatus,
|
|
740
794
|
sessionUsed: metrics.sessionUsed,
|
|
@@ -1368,7 +1422,7 @@ async function handleClaudePassthroughRequest(args) {
|
|
|
1368
1422
|
recordAttempt("passthrough", "passthrough");
|
|
1369
1423
|
let response;
|
|
1370
1424
|
try {
|
|
1371
|
-
response = await
|
|
1425
|
+
response = await fetchAnthropicUpstream("https://api.anthropic.com/v1/messages?beta=true", {
|
|
1372
1426
|
method: "POST",
|
|
1373
1427
|
headers: upstreamHeaders,
|
|
1374
1428
|
body: bodyStr,
|
|
@@ -2385,15 +2439,18 @@ async function handleAnthropicSuccessfulResponse(args) {
|
|
|
2385
2439
|
if (quota) {
|
|
2386
2440
|
// Stash the latest quota on runtime state so the next request can pick the
|
|
2387
2441
|
// account whose window resets soonest (max-utilization) and proactively
|
|
2388
|
-
// skip
|
|
2442
|
+
// skip rejected windows unless Anthropic explicitly permits overage.
|
|
2389
2443
|
accountState.quota = quota;
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2444
|
+
const cooldownUpdate = reconcileCooldownFromQuota(accountState, quota, Date.now());
|
|
2445
|
+
if (cooldownUpdate?.kind === "cooled") {
|
|
2446
|
+
saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
2447
|
+
// Non-fatal: cooldown is already active in memory.
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
else if (cooldownUpdate?.kind === "cleared") {
|
|
2451
|
+
clearAccountCooldown(account.key, cooldownUpdate.coolingUntil).catch(() => {
|
|
2452
|
+
// Non-fatal: the next successful response will reconcile again.
|
|
2453
|
+
});
|
|
2397
2454
|
}
|
|
2398
2455
|
saveAccountQuota(account.label, quota).catch(() => {
|
|
2399
2456
|
// Non-fatal: quota persistence is best-effort
|
|
@@ -2469,10 +2526,11 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
2469
2526
|
if (preflight.kind === "transport_error") {
|
|
2470
2527
|
const message = describeTransportError(preflight.error);
|
|
2471
2528
|
const partialBody = Buffer.concat(preflight.chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
|
2472
|
-
|
|
2529
|
+
// The POST has already returned a response. The upstream may have started
|
|
2530
|
+
// processing it, so replaying it on another account could duplicate work.
|
|
2531
|
+
logger.always(`[proxy] stream failed before first chunk account=${account.label}: ${message}; returning terminal error to avoid replaying an ambiguous request`);
|
|
2473
2532
|
recordAttemptError(account.label, account.type, 502);
|
|
2474
|
-
logAttempt(502, "stream_error", message, { retryable:
|
|
2475
|
-
tracer?.recordRetry(account.label, "stream_before_first_chunk");
|
|
2533
|
+
logAttempt(502, "stream_error", message, { retryable: false });
|
|
2476
2534
|
upstreamSpan?.end();
|
|
2477
2535
|
logProxyBody({
|
|
2478
2536
|
phase: "upstream_response",
|
|
@@ -2488,8 +2546,16 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
2488
2546
|
metadata: { logicalStatus: 502, transportError: message },
|
|
2489
2547
|
});
|
|
2490
2548
|
return {
|
|
2491
|
-
|
|
2492
|
-
|
|
2549
|
+
response: finalizeAnthropicTerminalTransportError({
|
|
2550
|
+
account,
|
|
2551
|
+
tracer,
|
|
2552
|
+
requestStartTime,
|
|
2553
|
+
attemptNumber,
|
|
2554
|
+
logProxyBody,
|
|
2555
|
+
logFinalRequest,
|
|
2556
|
+
errorType: "stream_error",
|
|
2557
|
+
message,
|
|
2558
|
+
}),
|
|
2493
2559
|
};
|
|
2494
2560
|
}
|
|
2495
2561
|
if (preflight.kind === "empty") {
|
|
@@ -2995,16 +3061,18 @@ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
|
|
|
2995
3061
|
const retryQuota = parseQuotaHeaders(retryResp.headers);
|
|
2996
3062
|
if (retryQuota) {
|
|
2997
3063
|
// Keep the auth-retry success path in parity with the main success path:
|
|
2998
|
-
// stash quota for proactive selection and
|
|
2999
|
-
// response reveals the window flipped to "rejected".
|
|
3064
|
+
// stash quota for proactive selection and reconcile a rejected window.
|
|
3000
3065
|
accountState.quota = retryQuota;
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3066
|
+
const cooldownUpdate = reconcileCooldownFromQuota(accountState, retryQuota, Date.now());
|
|
3067
|
+
if (cooldownUpdate?.kind === "cooled") {
|
|
3068
|
+
saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
3069
|
+
// Non-fatal: cooldown is already active in memory.
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
else if (cooldownUpdate?.kind === "cleared") {
|
|
3073
|
+
clearAccountCooldown(account.key, cooldownUpdate.coolingUntil).catch(() => {
|
|
3074
|
+
// Non-fatal: the next successful response will reconcile again.
|
|
3075
|
+
});
|
|
3008
3076
|
}
|
|
3009
3077
|
saveAccountQuota(account.label, retryQuota).catch((error) => {
|
|
3010
3078
|
logger.debug("[proxy] Failed to persist account quota after auth retry", {
|
|
@@ -3133,7 +3201,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3133
3201
|
metadata: { upstreamMethod: "POST", upstreamUrl: url },
|
|
3134
3202
|
});
|
|
3135
3203
|
try {
|
|
3136
|
-
const retryResp = await
|
|
3204
|
+
const retryResp = await fetchAnthropicUpstream(url, {
|
|
3137
3205
|
method: "POST",
|
|
3138
3206
|
headers,
|
|
3139
3207
|
body: retryBodyStr,
|
|
@@ -3344,11 +3412,37 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3344
3412
|
: String(retryFetchErr);
|
|
3345
3413
|
authRetryError = `network error on retry ${authRetry + 1}: ${message}`;
|
|
3346
3414
|
currentLastError = authRetryError;
|
|
3415
|
+
const retryable = isRetryableNetworkError(retryFetchErr);
|
|
3347
3416
|
retryLogAttempt(502, "network_error", message, {
|
|
3348
|
-
retryable
|
|
3417
|
+
retryable,
|
|
3349
3418
|
errorCode: getErrorCode(retryFetchErr) ?? "unknown",
|
|
3350
3419
|
});
|
|
3351
3420
|
logger.debug(`[proxy] ${authRetryError}`);
|
|
3421
|
+
if (!retryable) {
|
|
3422
|
+
// Once a POST has left this process, a reset/timeout or unknown fetch
|
|
3423
|
+
// failure is ambiguous: retrying it on another account can duplicate
|
|
3424
|
+
// the request. Only connection-establishment failures are replay-safe.
|
|
3425
|
+
currentUpstreamSpan?.end();
|
|
3426
|
+
return {
|
|
3427
|
+
response: finalizeAnthropicTerminalTransportError({
|
|
3428
|
+
account,
|
|
3429
|
+
tracer,
|
|
3430
|
+
requestStartTime,
|
|
3431
|
+
attemptNumber: retryAttemptNumber,
|
|
3432
|
+
logProxyBody,
|
|
3433
|
+
logFinalRequest,
|
|
3434
|
+
errorType: "network_error",
|
|
3435
|
+
message,
|
|
3436
|
+
}),
|
|
3437
|
+
continueLoop: false,
|
|
3438
|
+
lastError: currentLastError,
|
|
3439
|
+
authFailureMessage: currentAuthFailureMessage,
|
|
3440
|
+
sawRateLimit: currentSawRateLimit,
|
|
3441
|
+
sawTransientFailure: currentSawTransientFailure,
|
|
3442
|
+
sawNetworkError: currentSawNetworkError,
|
|
3443
|
+
upstreamSpan: undefined,
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3352
3446
|
break;
|
|
3353
3447
|
}
|
|
3354
3448
|
}
|
|
@@ -3427,6 +3521,27 @@ function finalizeAnthropicTerminalFetchError(args) {
|
|
|
3427
3521
|
errorType: terminalError.errorType,
|
|
3428
3522
|
});
|
|
3429
3523
|
}
|
|
3524
|
+
function finalizeAnthropicTerminalTransportError(args) {
|
|
3525
|
+
const { account, tracer, requestStartTime, attemptNumber, logProxyBody, logFinalRequest, errorType, message, } = args;
|
|
3526
|
+
tracer?.setError(errorType, message);
|
|
3527
|
+
tracer?.end(502, Date.now() - requestStartTime);
|
|
3528
|
+
logFinalRequest(502, account.label, account.type, errorType, message);
|
|
3529
|
+
const clientError = buildClaudeError(502, message);
|
|
3530
|
+
const clientErrorBody = JSON.stringify(clientError);
|
|
3531
|
+
logProxyBody({
|
|
3532
|
+
phase: "client_response",
|
|
3533
|
+
headers: { "content-type": "application/json" },
|
|
3534
|
+
body: clientErrorBody,
|
|
3535
|
+
bodySize: Buffer.byteLength(clientErrorBody, "utf8"),
|
|
3536
|
+
contentType: "application/json",
|
|
3537
|
+
account: account.label,
|
|
3538
|
+
accountType: account.type,
|
|
3539
|
+
attempt: attemptNumber,
|
|
3540
|
+
responseStatus: 502,
|
|
3541
|
+
durationMs: Date.now() - requestStartTime,
|
|
3542
|
+
});
|
|
3543
|
+
return clientError;
|
|
3544
|
+
}
|
|
3430
3545
|
async function handleAnthropicNonOkResponse(args) {
|
|
3431
3546
|
const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, } = args;
|
|
3432
3547
|
let currentLastError = lastError;
|
|
@@ -4000,7 +4115,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
4000
4115
|
const currentUpstreamSpan = upstreamSpan;
|
|
4001
4116
|
let response;
|
|
4002
4117
|
try {
|
|
4003
|
-
response = await
|
|
4118
|
+
response = await fetchAnthropicUpstream(url, {
|
|
4004
4119
|
method: "POST",
|
|
4005
4120
|
headers,
|
|
4006
4121
|
body: finalBodyStr,
|
|
@@ -4461,8 +4576,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4461
4576
|
// Clear cooling on success — but only if the stored cooldown has already
|
|
4462
4577
|
// expired, so an older in-flight success can't wipe an active exhaustion
|
|
4463
4578
|
// cooldown just set by a concurrent 429. The success handler re-applies a
|
|
4464
|
-
// cooldown via
|
|
4465
|
-
//
|
|
4579
|
+
// cooldown via reconcileCooldownFromQuota when fresh quota headers
|
|
4580
|
+
// report a rejected window without explicit overage availability.
|
|
4466
4581
|
if (accountState.coolingUntil &&
|
|
4467
4582
|
Date.now() >= accountState.coolingUntil) {
|
|
4468
4583
|
const expiredCooldown = accountState.coolingUntil;
|
|
@@ -4955,37 +5070,23 @@ function describeTransportError(error) {
|
|
|
4955
5070
|
return detail ? `${message} (${detail})` : message;
|
|
4956
5071
|
}
|
|
4957
5072
|
/**
|
|
4958
|
-
* Determine whether a
|
|
5073
|
+
* Determine whether a POST can be retried without risking duplicate provider
|
|
5074
|
+
* work. Only failures that prove connection establishment did not complete are
|
|
5075
|
+
* safe; a reset, socket error, or response timeout can happen after dispatch.
|
|
4959
5076
|
*/
|
|
4960
5077
|
function isRetryableNetworkError(error) {
|
|
4961
5078
|
const code = getErrorCode(error);
|
|
4962
|
-
|
|
5079
|
+
return (code !== undefined &&
|
|
4963
5080
|
[
|
|
4964
5081
|
"ECONNREFUSED",
|
|
4965
|
-
"
|
|
5082
|
+
"EADDRNOTAVAIL",
|
|
4966
5083
|
// The Anthropic host is fixed, so ENOTFOUND can be a transient resolver
|
|
4967
5084
|
// outage. Keep it inside the existing bounded same-account retry budget.
|
|
4968
5085
|
"ENOTFOUND",
|
|
4969
|
-
"ETIMEDOUT",
|
|
4970
5086
|
"EHOSTUNREACH",
|
|
4971
5087
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
4972
5088
|
"UND_ERR_CONNECT",
|
|
4973
|
-
|
|
4974
|
-
"UND_ERR_HEADERS_TIMEOUT",
|
|
4975
|
-
].includes(code)) {
|
|
4976
|
-
return true;
|
|
4977
|
-
}
|
|
4978
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4979
|
-
const normalized = message.toLowerCase();
|
|
4980
|
-
return (normalized.includes("econnrefused") ||
|
|
4981
|
-
normalized.includes("econnreset") ||
|
|
4982
|
-
normalized.includes("enotfound") ||
|
|
4983
|
-
normalized.includes("etimedout") ||
|
|
4984
|
-
normalized.includes("timed out") ||
|
|
4985
|
-
normalized.includes("connection error") ||
|
|
4986
|
-
normalized.includes("connect error") ||
|
|
4987
|
-
normalized.includes("fetch failed") ||
|
|
4988
|
-
normalized.includes("socket hang up"));
|
|
5089
|
+
].includes(code));
|
|
4989
5090
|
}
|
|
4990
5091
|
const TRANSIENT_HTTP_STATUSES = new Set([
|
|
4991
5092
|
408, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 529,
|
|
@@ -5114,6 +5215,8 @@ export const __testHooks = {
|
|
|
5114
5215
|
resolveHomeIndex,
|
|
5115
5216
|
maybeResetPrimaryToHome,
|
|
5116
5217
|
planCooldownFor429,
|
|
5218
|
+
reconcileCooldownFromQuota,
|
|
5219
|
+
isRetryableNetworkError,
|
|
5117
5220
|
isPermanentRefreshFailure,
|
|
5118
5221
|
getStreamFailureDetails,
|
|
5119
5222
|
trackUpstreamReadableStream,
|
|
@@ -445,6 +445,9 @@ export type ProxyAccountRoutingCandidate = {
|
|
|
445
445
|
coolingReason: AccountCoolingReason | null;
|
|
446
446
|
coolingUntil: number | null;
|
|
447
447
|
unifiedStatus: string | null;
|
|
448
|
+
fallbackStatus?: string | null;
|
|
449
|
+
upgradePaths?: string | null;
|
|
450
|
+
overageEligible?: boolean;
|
|
448
451
|
overageStatus: string | null;
|
|
449
452
|
sessionStatus: string | null;
|
|
450
453
|
sessionUsed: number | null;
|
|
@@ -480,6 +483,9 @@ export type ProxyAccountSortMetrics = {
|
|
|
480
483
|
coolingReason: AccountCoolingReason | null;
|
|
481
484
|
coolingUntil: number;
|
|
482
485
|
unifiedStatus: string | null;
|
|
486
|
+
fallbackStatus?: string | null;
|
|
487
|
+
upgradePaths?: string | null;
|
|
488
|
+
overageEligible?: boolean;
|
|
483
489
|
overageStatus: string | null;
|
|
484
490
|
sessionStatus: string | null;
|
|
485
491
|
sessionUsed: number | null;
|
|
@@ -737,6 +743,14 @@ export type AccountCooldownPlan = {
|
|
|
737
743
|
* burst), a small number of jittered same-account retries is allowed first. */
|
|
738
744
|
rotateImmediately: boolean;
|
|
739
745
|
};
|
|
746
|
+
export type ProxyQuotaCooldownUpdate = {
|
|
747
|
+
kind: "cooled";
|
|
748
|
+
coolingUntil: number;
|
|
749
|
+
coolingReason: AccountCoolingReason;
|
|
750
|
+
} | {
|
|
751
|
+
kind: "cleared";
|
|
752
|
+
coolingUntil: number;
|
|
753
|
+
} | null;
|
|
740
754
|
export type TransientRateLimitRetryBudget = {
|
|
741
755
|
coolingUntil: number;
|
|
742
756
|
retriesClaimed: number;
|
|
@@ -921,6 +935,10 @@ export type AccountQuota = {
|
|
|
921
935
|
weeklyResetAt: number;
|
|
922
936
|
/** 0.0-1.0 (from fallback-percentage) */
|
|
923
937
|
fallbackPercentage: number;
|
|
938
|
+
/** Provider fallback availability, for example "available". */
|
|
939
|
+
fallbackStatus?: string;
|
|
940
|
+
/** Comma-separated provider upgrade paths, for example "overage". */
|
|
941
|
+
upgradePaths?: string;
|
|
924
942
|
/** "allowed" | "rejected" */
|
|
925
943
|
overageStatus: string;
|
|
926
944
|
/** Epoch ms when we last captured this data */
|
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
import type { AccountQuota } from "../types/index.js";
|
|
13
13
|
/** Read and normalize Anthropic's authoritative top-level unified status. */
|
|
14
14
|
export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Whether Anthropic explicitly permits a request to use overage after a
|
|
17
|
+
* subscription window is exhausted. Fresh responses require all three
|
|
18
|
+
* provider signals. Older persisted snapshots predate the raw fallback and
|
|
19
|
+
* upgrade-path fields, but retain a positive fallback percentage together with
|
|
20
|
+
* an allowed overage status, which is the equivalent provider state.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "upgradePaths"> | null | undefined): boolean;
|
|
15
23
|
/**
|
|
16
24
|
* Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
|
|
17
25
|
* Returns `null` when key headers are absent.
|