@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
|
@@ -16,10 +16,11 @@ import { Agent } from "undici";
|
|
|
16
16
|
import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, parseClaudeCodeUserId, } from "../../auth/anthropicOAuth.js";
|
|
17
17
|
import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
|
|
18
18
|
import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
19
|
-
import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
19
|
+
import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, mergeQuotaSnapshot, modelFamilyToken, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
20
20
|
import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../proxy/accountUsage.js";
|
|
21
21
|
import { AccountQuotaRefreshCoordinator } from "../../proxy/accountQuotaRefreshCoordinator.js";
|
|
22
22
|
import { ProviderTransportCoordinator } from "../../proxy/providerTransportCoordinator.js";
|
|
23
|
+
import { MAX_COOLDOWN_MS_BY_REASON } from "../../proxy/routingEvidence.js";
|
|
23
24
|
import { buildProxyLimitHeaders, summarizePoolHeadroom, } from "../../proxy/quotaHeaders.js";
|
|
24
25
|
import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
|
|
25
26
|
import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
|
|
@@ -487,9 +488,15 @@ function publishLimitHeaders(ctx, args) {
|
|
|
487
488
|
logger.debug(`[proxy] failed to publish limit headers: ${error instanceof Error ? error.message : String(error)}`);
|
|
488
489
|
}
|
|
489
490
|
}
|
|
490
|
-
/**
|
|
491
|
-
|
|
492
|
-
|
|
491
|
+
/**
|
|
492
|
+
* Clamp a cooldown target epoch-ms into [now+MIN, now+MAX], additionally capped
|
|
493
|
+
* by what the reason can plausibly mean — a "session" cooldown describes a
|
|
494
|
+
* 5-hour window, so a stale or malformed reset must not be able to park the
|
|
495
|
+
* account for days under that label.
|
|
496
|
+
*/
|
|
497
|
+
function clampCooldownUntil(untilMs, now, reason) {
|
|
498
|
+
const ceiling = Math.min(MAX_COOLDOWN_MS, (reason && MAX_COOLDOWN_MS_BY_REASON[reason]) ?? MAX_COOLDOWN_MS);
|
|
499
|
+
return Math.min(Math.max(untilMs, now + MIN_COOLDOWN_MS), now + ceiling);
|
|
493
500
|
}
|
|
494
501
|
/**
|
|
495
502
|
* Decide how to cool an account after a genuine (non-anti-abuse) 429.
|
|
@@ -505,24 +512,24 @@ function clampCooldownUntil(untilMs, now) {
|
|
|
505
512
|
* burst / acceleration limit) is transient: honor retry-after as a floor,
|
|
506
513
|
* allow a couple of jittered same-account retries, then a short cooldown.
|
|
507
514
|
*/
|
|
508
|
-
function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.unifiedStatus) {
|
|
515
|
+
function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.unifiedStatus, policy = overagePolicy) {
|
|
509
516
|
// Weekly exhaustion takes precedence — it's the longest, hardest ceiling.
|
|
510
517
|
if (quota && quota.weeklyStatus === "rejected") {
|
|
511
518
|
const reset = resetEpochToMs(quota.weeklyResetAt, now) ??
|
|
512
519
|
(retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
|
|
513
520
|
return {
|
|
514
521
|
reason: "weekly",
|
|
515
|
-
coolingUntil: clampCooldownUntil(reset, now),
|
|
522
|
+
coolingUntil: clampCooldownUntil(reset, now, "weekly"),
|
|
516
523
|
rotateImmediately: true,
|
|
517
524
|
};
|
|
518
525
|
}
|
|
519
|
-
const overageAvailable =
|
|
526
|
+
const overageAvailable = isOverageUsable(quota, policy);
|
|
520
527
|
if (quota && quota.sessionStatus === "rejected" && !overageAvailable) {
|
|
521
528
|
const reset = resetEpochToMs(quota.sessionResetAt, now) ??
|
|
522
529
|
(retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
|
|
523
530
|
return {
|
|
524
531
|
reason: "session",
|
|
525
|
-
coolingUntil: clampCooldownUntil(reset, now),
|
|
532
|
+
coolingUntil: clampCooldownUntil(reset, now, "session"),
|
|
526
533
|
rotateImmediately: true,
|
|
527
534
|
};
|
|
528
535
|
}
|
|
@@ -533,7 +540,7 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
|
|
|
533
540
|
const reset = retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_HARD_COOLDOWN_MS;
|
|
534
541
|
return {
|
|
535
542
|
reason: "unified",
|
|
536
|
-
coolingUntil: clampCooldownUntil(reset, now),
|
|
543
|
+
coolingUntil: clampCooldownUntil(reset, now, "unified"),
|
|
537
544
|
rotateImmediately: true,
|
|
538
545
|
};
|
|
539
546
|
}
|
|
@@ -559,8 +566,8 @@ function minutesUntil(untilMs, now) {
|
|
|
559
566
|
* its reset so the next request skips it instead of discovering the limit via a
|
|
560
567
|
* 429, except when the provider explicitly enables paid overage.
|
|
561
568
|
*/
|
|
562
|
-
function reconcileCooldownFromQuota(state, quota, now) {
|
|
563
|
-
const overageAvailable =
|
|
569
|
+
function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
|
|
570
|
+
const overageAvailable = isOverageUsable(quota, policy);
|
|
564
571
|
let until;
|
|
565
572
|
let reason;
|
|
566
573
|
if (quota.weeklyStatus === "rejected") {
|
|
@@ -592,7 +599,7 @@ function reconcileCooldownFromQuota(state, quota, now) {
|
|
|
592
599
|
if (until === undefined) {
|
|
593
600
|
return null;
|
|
594
601
|
}
|
|
595
|
-
const clamped = clampCooldownUntil(until, now);
|
|
602
|
+
const clamped = clampCooldownUntil(until, now, reason);
|
|
596
603
|
if (!state.coolingUntil || clamped > state.coolingUntil) {
|
|
597
604
|
state.coolingUntil = clamped;
|
|
598
605
|
state.coolingReason = reason;
|
|
@@ -669,7 +676,7 @@ async function applyAccountUsageResult(account, fetchResult, observedAt, prior)
|
|
|
669
676
|
if (quota.lastUpdated < (state.quota?.lastUpdated ?? 0)) {
|
|
670
677
|
return state.quota ?? null;
|
|
671
678
|
}
|
|
672
|
-
state.quota = quota;
|
|
679
|
+
state.quota = mergeQuotaSnapshot(state.quota, quota);
|
|
673
680
|
const cooldownUpdate = reconcileCooldownFromQuota(state, quota, Date.now());
|
|
674
681
|
if (cooldownUpdate?.kind === "cooled") {
|
|
675
682
|
await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
@@ -837,6 +844,46 @@ function getSessionResetToleranceMs() {
|
|
|
837
844
|
const raw = Number(process.env.NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS ?? "");
|
|
838
845
|
return Number.isInteger(raw) && raw > 0 ? raw : 15 * 60 * 1000;
|
|
839
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* Operator policy on spending paid extra usage, refreshed from the runtime
|
|
849
|
+
* config snapshot on every request so a config edit applies mid-flight.
|
|
850
|
+
*
|
|
851
|
+
* Module-level rather than threaded through because every routing and cooldown
|
|
852
|
+
* decision consults it, and the value is uniform for a given proxy generation.
|
|
853
|
+
*/
|
|
854
|
+
let overagePolicy = "auto";
|
|
855
|
+
/**
|
|
856
|
+
* Publish the operator's extra-usage policy for paths that cannot receive it
|
|
857
|
+
* explicitly. Callers that make a routing or cooldown decision take it as a
|
|
858
|
+
* parameter instead — see {@link isOverageUsable} — so a concurrent request or
|
|
859
|
+
* a hot config reload cannot change the answer mid-flight.
|
|
860
|
+
*
|
|
861
|
+
* `undefined` means "no runtime config on this path", which is not a request to
|
|
862
|
+
* clear an operator's setting, so the current value is kept.
|
|
863
|
+
*/
|
|
864
|
+
function setOveragePolicy(policy) {
|
|
865
|
+
if (policy !== undefined) {
|
|
866
|
+
overagePolicy = policy;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/** The policy in force for a request that did not capture one explicitly. */
|
|
870
|
+
function currentOveragePolicy() {
|
|
871
|
+
return overagePolicy;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Whether this account may keep serving on paid extra usage.
|
|
875
|
+
*
|
|
876
|
+
* The provider decides what is possible; the operator decides what is
|
|
877
|
+
* permitted. `never` therefore vetoes an enabled account, while `always` can
|
|
878
|
+
* only confirm a signal Anthropic already gives — nothing here can switch on
|
|
879
|
+
* extra usage the organization has disabled.
|
|
880
|
+
*/
|
|
881
|
+
function isOverageUsable(quota, policy = overagePolicy) {
|
|
882
|
+
if (policy === "never") {
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
return isQuotaOverageAvailable(quota);
|
|
886
|
+
}
|
|
840
887
|
/**
|
|
841
888
|
* Derive the ordering signals for an account from its latest runtime quota.
|
|
842
889
|
*
|
|
@@ -846,7 +893,152 @@ function getSessionResetToleranceMs() {
|
|
|
846
893
|
* window Anthropic has since renewed. The snapshot itself is refreshed from
|
|
847
894
|
* live response headers the next time the account serves a request.
|
|
848
895
|
*/
|
|
849
|
-
|
|
896
|
+
/** Shortest scope token we will match on, so a degenerately broad scope name
|
|
897
|
+
* (e.g. "Claude") cannot silently apply a per-model cap to every model. */
|
|
898
|
+
const MIN_SCOPE_TOKEN_LENGTH = 4;
|
|
899
|
+
function normalizeModelToken(value) {
|
|
900
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* The distinguishing part of a scope display name. The shared vendor prefix is
|
|
904
|
+
* dropped so a window scoped to plain "Claude" collapses to "" and is rejected
|
|
905
|
+
* by the length guard rather than matching every Claude model.
|
|
906
|
+
*/
|
|
907
|
+
function scopeMatchToken(scopeModel) {
|
|
908
|
+
const normalized = normalizeModelToken(scopeModel);
|
|
909
|
+
return normalized.startsWith("claude") ? normalized.slice(6) : normalized;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* How well a window's scope identifies the requested model, or null when it does
|
|
913
|
+
* not apply. Higher wins; an exact wire-id agreement is unambiguous and so
|
|
914
|
+
* outranks every display-name match.
|
|
915
|
+
*/
|
|
916
|
+
function scopeMatchScore(window, normalizedModel, requestedFamily) {
|
|
917
|
+
if (window.scopeModelId &&
|
|
918
|
+
normalizeModelToken(modelFamilyToken(window.scopeModelId)) ===
|
|
919
|
+
requestedFamily) {
|
|
920
|
+
return Number.MAX_SAFE_INTEGER;
|
|
921
|
+
}
|
|
922
|
+
if (!window.scopeModel) {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
const scopeToken = scopeMatchToken(window.scopeModel);
|
|
926
|
+
if (scopeToken.length < MIN_SCOPE_TOKEN_LENGTH ||
|
|
927
|
+
!normalizedModel.includes(scopeToken)) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
return scopeToken.length;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* When a scoped window was last observed. Falls back through the usage-API
|
|
934
|
+
* sweep timestamp to the flat snapshot time.
|
|
935
|
+
*
|
|
936
|
+
* Needed because the flat fields refresh on every response while `windows` only
|
|
937
|
+
* changes when the served model has a scoped cap — so a days-old scoped window
|
|
938
|
+
* rides along on a `lastUpdated` from seconds ago and looks current.
|
|
939
|
+
*/
|
|
940
|
+
function scopedWindowObservedAt(quota, window) {
|
|
941
|
+
return window.updatedAt ?? quota.windowsUpdatedAt ?? quota.lastUpdated;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Find the model-scoped quota window that applies to the requested model.
|
|
945
|
+
*
|
|
946
|
+
* Two sources describe the same cap differently: response headers carry the wire
|
|
947
|
+
* id of the model just served, while the usage API reports a DISPLAY name
|
|
948
|
+
* ("Fable", "Claude Opus 4.6"). So an exact wire-id match is tried first and
|
|
949
|
+
* display-name containment over alphanumeric-normalized forms is the fallback.
|
|
950
|
+
* Among equally specific matches the freshest observation wins, which favours
|
|
951
|
+
* the continuously-updated header window over a manually-refreshed one.
|
|
952
|
+
*
|
|
953
|
+
* Returns null whenever the account reports no applicable, still-fresh scoped
|
|
954
|
+
* cap — the common case.
|
|
955
|
+
*/
|
|
956
|
+
function matchScopedQuotaWindow(quota, requestedModel, now = Date.now()) {
|
|
957
|
+
if (!quota?.windows?.length || !requestedModel) {
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
const normalizedModel = normalizeModelToken(requestedModel);
|
|
961
|
+
if (!normalizedModel) {
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
const requestedFamily = normalizeModelToken(modelFamilyToken(requestedModel));
|
|
965
|
+
let best = null;
|
|
966
|
+
let bestScore = -1;
|
|
967
|
+
let bestObservedAt = -1;
|
|
968
|
+
for (const window of quota.windows) {
|
|
969
|
+
// `isActive` marks which window the provider considers binding right now,
|
|
970
|
+
// not whether the cap applies — a scoped cap reads as inactive until it is
|
|
971
|
+
// the tightest constraint, which is far too late to route around it.
|
|
972
|
+
if (now - scopedWindowObservedAt(quota, window) >
|
|
973
|
+
QUOTA_SNAPSHOT_FRESHNESS_MS) {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const score = scopeMatchScore(window, normalizedModel, requestedFamily);
|
|
977
|
+
if (score === null) {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const observedAt = scopedWindowObservedAt(quota, window);
|
|
981
|
+
if (score > bestScore ||
|
|
982
|
+
(score === bestScore && observedAt > bestObservedAt)) {
|
|
983
|
+
best = window;
|
|
984
|
+
bestScore = score;
|
|
985
|
+
bestObservedAt = observedAt;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return best;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Split a candidate set by whether each account can still serve the requested
|
|
992
|
+
* model under its model-scoped cap.
|
|
993
|
+
*
|
|
994
|
+
* An account is scoped out only on evidence strong enough to act on: a fresh
|
|
995
|
+
* window, a reset that is actually ticking, a rejected/spent reading, and no
|
|
996
|
+
* paid extra usage to absorb the overflow. Anything weaker leaves the account
|
|
997
|
+
* eligible — a stale or mis-parsed window must never be able to empty the pool.
|
|
998
|
+
*
|
|
999
|
+
* `exhaustion` is populated only when every candidate is scoped out AND the
|
|
1000
|
+
* evidence is trustworthy, which is what lets the caller report "switch model"
|
|
1001
|
+
* instead of a generic rate limit.
|
|
1002
|
+
*/
|
|
1003
|
+
function evaluateScopedExhaustion(accounts, requestedModel, now = Date.now(), policy = overagePolicy) {
|
|
1004
|
+
if (!requestedModel || accounts.length === 0) {
|
|
1005
|
+
return { eligible: accounts, exhaustion: null };
|
|
1006
|
+
}
|
|
1007
|
+
const eligible = [];
|
|
1008
|
+
const exhausted = [];
|
|
1009
|
+
let overageDisabledReason;
|
|
1010
|
+
for (const account of accounts) {
|
|
1011
|
+
const quota = accountRuntimeState.get(account.key)?.quota;
|
|
1012
|
+
const window = matchScopedQuotaWindow(quota, requestedModel, now);
|
|
1013
|
+
const reset = window ? resetEpochToMs(window.resetsAt, now) : undefined;
|
|
1014
|
+
const spent = window !== null &&
|
|
1015
|
+
reset !== undefined &&
|
|
1016
|
+
((window.status ?? "").trim().toLowerCase() === "rejected" ||
|
|
1017
|
+
(window.used ?? 0) >= 1) &&
|
|
1018
|
+
!isOverageUsable(quota, policy);
|
|
1019
|
+
if (!spent || !window) {
|
|
1020
|
+
eligible.push(account);
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
exhausted.push({ label: account.label, window });
|
|
1024
|
+
overageDisabledReason ??= quota?.overageDisabledReason;
|
|
1025
|
+
}
|
|
1026
|
+
if (eligible.length > 0 || exhausted.length === 0) {
|
|
1027
|
+
return { eligible, exhaustion: null };
|
|
1028
|
+
}
|
|
1029
|
+
const earliestResetMs = Math.min(...exhausted.map((entry) => resetEpochToMs(entry.window.resetsAt, now) ?? Number.POSITIVE_INFINITY));
|
|
1030
|
+
return {
|
|
1031
|
+
eligible,
|
|
1032
|
+
exhaustion: {
|
|
1033
|
+
model: requestedModel,
|
|
1034
|
+
scopeModel: exhausted[0]?.window.scopeModel ?? modelFamilyToken(requestedModel),
|
|
1035
|
+
earliestResetMs,
|
|
1036
|
+
accounts: exhausted.map((entry) => entry.label),
|
|
1037
|
+
...(overageDisabledReason ? { overageDisabledReason } : {}),
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToleranceMs, requestedModel, policy = overagePolicy) {
|
|
850
1042
|
const st = accountRuntimeState.get(accountKey);
|
|
851
1043
|
const q = st?.quota;
|
|
852
1044
|
const quotaLastUpdated = q && Number.isFinite(q.lastUpdated) ? q.lastUpdated : null;
|
|
@@ -902,7 +1094,9 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
|
|
|
902
1094
|
? (routingQuota.weeklyStatus ?? "unknown")
|
|
903
1095
|
: "allowed"
|
|
904
1096
|
: null;
|
|
905
|
-
|
|
1097
|
+
// isOverageUsable layers the operator's routing.use-overage policy over the
|
|
1098
|
+
// provider signal isQuotaOverageAvailable reads.
|
|
1099
|
+
const overageEligible = isOverageUsable(routingQuota, policy);
|
|
906
1100
|
const hardSaturated = !overageEligible &&
|
|
907
1101
|
(sessionStatus === "rejected" ||
|
|
908
1102
|
sessionStatus === "throttled" ||
|
|
@@ -916,9 +1110,41 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
|
|
|
916
1110
|
sessionTicking &&
|
|
917
1111
|
(sessionUsed ?? 0) >= sessionSoftLimit;
|
|
918
1112
|
const saturated = hardSaturated || softSaturated;
|
|
1113
|
+
// Model-scoped weekly cap for the model this request actually asks for.
|
|
1114
|
+
// Reset-freshened exactly like session/weekly: a window whose reset has
|
|
1115
|
+
// passed reads as renewed, never as spent.
|
|
1116
|
+
const scopedWindow = matchScopedQuotaWindow(routingQuota, requestedModel, now);
|
|
1117
|
+
const scopedReset = scopedWindow
|
|
1118
|
+
? resetEpochToMs(scopedWindow.resetsAt, now)
|
|
1119
|
+
: undefined;
|
|
1120
|
+
const scopedTicking = scopedReset !== undefined;
|
|
1121
|
+
const scopedUsed = scopedWindow
|
|
1122
|
+
? scopedTicking
|
|
1123
|
+
? scopedWindow.used
|
|
1124
|
+
: 0
|
|
1125
|
+
: null;
|
|
1126
|
+
const scopedStatus = scopedWindow
|
|
1127
|
+
? scopedTicking
|
|
1128
|
+
? (scopedWindow.status ?? "unknown")
|
|
1129
|
+
: "allowed"
|
|
1130
|
+
: null;
|
|
1131
|
+
const scopedSaturated = !quotaStale && scopedTicking && (scopedUsed ?? 0) >= sessionSoftLimit;
|
|
919
1132
|
return {
|
|
920
|
-
|
|
1133
|
+
// A rejected model-scoped window means this account cannot serve THIS
|
|
1134
|
+
// model, even though it may be perfectly healthy for others. Excluding it
|
|
1135
|
+
// here only affects ordering — the real gate is evaluateScopedExhaustion,
|
|
1136
|
+
// and neither cools the account, which would wrongly withhold it from
|
|
1137
|
+
// every other model.
|
|
1138
|
+
usable: !coolingActive &&
|
|
1139
|
+
!hardSaturated &&
|
|
1140
|
+
(scopedStatus !== "rejected" || overageEligible),
|
|
921
1141
|
saturated,
|
|
1142
|
+
scopedModel: scopedWindow?.scopeModel ?? null,
|
|
1143
|
+
scopedStatus,
|
|
1144
|
+
scopedUsed,
|
|
1145
|
+
scopedReset: scopedReset ?? Number.POSITIVE_INFINITY,
|
|
1146
|
+
scopedUsedForSort: scopedUsed ?? -1,
|
|
1147
|
+
scopedSaturated,
|
|
922
1148
|
hasQuota: !!q,
|
|
923
1149
|
quotaEvidenceRank: quotaFreshness === "fresh" || quotaFreshness === "stale_known"
|
|
924
1150
|
? 0
|
|
@@ -988,6 +1214,12 @@ function compareAccountRoutingFactors(a, b, metricsByKey, primaryKey) {
|
|
|
988
1214
|
if (ma.saturated !== mb.saturated) {
|
|
989
1215
|
return [ma.saturated ? 1 : -1, "session_headroom"];
|
|
990
1216
|
}
|
|
1217
|
+
// Per-model headroom, after overall session capacity: an account whose cap
|
|
1218
|
+
// for THIS model is nearly spent is demoted even when its 5h/7d are healthy.
|
|
1219
|
+
// No-op when neither account reports a scoped window for the model.
|
|
1220
|
+
if (ma.scopedSaturated !== mb.scopedSaturated) {
|
|
1221
|
+
return [ma.scopedSaturated ? 1 : -1, "scoped_headroom"];
|
|
1222
|
+
}
|
|
991
1223
|
if (ma.saturated && mb.saturated) {
|
|
992
1224
|
if (ma.sessionResetBucket !== mb.sessionResetBucket) {
|
|
993
1225
|
return [ma.sessionResetBucket - mb.sessionResetBucket, "session_reset"];
|
|
@@ -1004,6 +1236,19 @@ function compareAccountRoutingFactors(a, b, metricsByKey, primaryKey) {
|
|
|
1004
1236
|
return [ma.sessionResetBucket - mb.sessionResetBucket, "session_reset"];
|
|
1005
1237
|
}
|
|
1006
1238
|
}
|
|
1239
|
+
// Fill-first within the per-model allowance: finish off the account closest
|
|
1240
|
+
// to spending its cap for this model before opening a fresher one. Ranked
|
|
1241
|
+
// above overall weekly utilization because it is the tighter constraint.
|
|
1242
|
+
// Both sides must actually report a scoped window. Comparing a real
|
|
1243
|
+
// utilization against the "absent" sentinel would rank the account that has a
|
|
1244
|
+
// window above one that does not — and since only the account serving a model
|
|
1245
|
+
// gets that model's window, it would funnel all of a model's traffic onto
|
|
1246
|
+
// whichever account happened to serve it first.
|
|
1247
|
+
if (ma.scopedUsed !== null &&
|
|
1248
|
+
mb.scopedUsed !== null &&
|
|
1249
|
+
ma.scopedUsedForSort !== mb.scopedUsedForSort) {
|
|
1250
|
+
return [mb.scopedUsedForSort - ma.scopedUsedForSort, "scoped_utilization"];
|
|
1251
|
+
}
|
|
1007
1252
|
if (ma.weeklyUsedForSort !== mb.weeklyUsedForSort) {
|
|
1008
1253
|
return [mb.weeklyUsedForSort - ma.weeklyUsedForSort, "weekly_utilization"];
|
|
1009
1254
|
}
|
|
@@ -1012,10 +1257,10 @@ function compareAccountRoutingFactors(a, b, metricsByKey, primaryKey) {
|
|
|
1012
1257
|
}
|
|
1013
1258
|
return [0, "insertion_order"];
|
|
1014
1259
|
}
|
|
1015
|
-
function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs) {
|
|
1260
|
+
function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel) {
|
|
1016
1261
|
const metricsByKey = new Map(accounts.map((account) => [
|
|
1017
1262
|
account.key,
|
|
1018
|
-
accountSortMetrics(account.key, now, sessionSoftLimit, sessionResetToleranceMs),
|
|
1263
|
+
accountSortMetrics(account.key, now, sessionSoftLimit, sessionResetToleranceMs, requestedModel),
|
|
1019
1264
|
]));
|
|
1020
1265
|
return {
|
|
1021
1266
|
orderedAccounts: [...accounts].sort((a, b) => compareAccountRoutingFactors(a, b, metricsByKey, primaryKey)[0]),
|
|
@@ -1046,8 +1291,8 @@ function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftL
|
|
|
1046
1291
|
* Cooling/rejected accounts sort last, soonest-back-to-service first, as
|
|
1047
1292
|
* last resort.
|
|
1048
1293
|
*/
|
|
1049
|
-
function orderAccountsByQuota(accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) {
|
|
1050
|
-
return orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs).orderedAccounts;
|
|
1294
|
+
function orderAccountsByQuota(accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), requestedModel) {
|
|
1295
|
+
return orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel).orderedAccounts;
|
|
1051
1296
|
}
|
|
1052
1297
|
function scheduleAdaptiveQuotaRefreshes(accounts, orderedAccounts, sessionSoftLimit, routingMetrics) {
|
|
1053
1298
|
for (const account of accounts) {
|
|
@@ -1160,6 +1405,12 @@ function buildRoutingDecision(args) {
|
|
|
1160
1405
|
weeklyResetAt: Number.isFinite(metrics.weeklyReset)
|
|
1161
1406
|
? metrics.weeklyReset
|
|
1162
1407
|
: null,
|
|
1408
|
+
scopedModel: metrics.scopedModel,
|
|
1409
|
+
scopedStatus: metrics.scopedStatus,
|
|
1410
|
+
scopedUsed: metrics.scopedUsed,
|
|
1411
|
+
scopedResetAt: Number.isFinite(metrics.scopedReset)
|
|
1412
|
+
? metrics.scopedReset
|
|
1413
|
+
: null,
|
|
1163
1414
|
});
|
|
1164
1415
|
}
|
|
1165
1416
|
const initialAccount = orderedAccounts[0];
|
|
@@ -1202,7 +1453,7 @@ function buildRoutingDecision(args) {
|
|
|
1202
1453
|
};
|
|
1203
1454
|
}
|
|
1204
1455
|
function selectClaudeProxyAccountOrder(args) {
|
|
1205
|
-
const { enabledAccounts, accountStrategy, primaryAccountKey, quotaRoutingEnabled, sessionSoftLimit, sessionResetToleranceMs, setRoutingDecision, } = args;
|
|
1456
|
+
const { enabledAccounts, accountStrategy, primaryAccountKey, quotaRoutingEnabled, sessionSoftLimit, sessionResetToleranceMs, requestedModel, setRoutingDecision, } = args;
|
|
1206
1457
|
let orderedAccounts = [...enabledAccounts];
|
|
1207
1458
|
const evaluatedAt = Date.now();
|
|
1208
1459
|
let metricsByKey;
|
|
@@ -1215,7 +1466,7 @@ function selectClaudeProxyAccountOrder(args) {
|
|
|
1215
1466
|
maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey);
|
|
1216
1467
|
}
|
|
1217
1468
|
if (quotaOrdered) {
|
|
1218
|
-
const quotaOrder = orderAccountsByQuotaWithMetrics(enabledAccounts, evaluatedAt, primaryAccountKey, sessionSoftLimit, sessionResetToleranceMs);
|
|
1469
|
+
const quotaOrder = orderAccountsByQuotaWithMetrics(enabledAccounts, evaluatedAt, primaryAccountKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
|
|
1219
1470
|
orderedAccounts = quotaOrder.orderedAccounts;
|
|
1220
1471
|
metricsByKey = quotaOrder.metricsByKey;
|
|
1221
1472
|
if (logger.shouldLog("debug")) {
|
|
@@ -1243,7 +1494,7 @@ function selectClaudeProxyAccountOrder(args) {
|
|
|
1243
1494
|
}
|
|
1244
1495
|
metricsByKey = new Map(enabledAccounts.map((account) => [
|
|
1245
1496
|
account.key,
|
|
1246
|
-
accountSortMetrics(account.key, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs),
|
|
1497
|
+
accountSortMetrics(account.key, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs, requestedModel),
|
|
1247
1498
|
]));
|
|
1248
1499
|
}
|
|
1249
1500
|
const routingDecision = buildRoutingDecision({
|
|
@@ -1818,7 +2069,9 @@ async function handleClaudePassthroughRequest(args) {
|
|
|
1818
2069
|
// to report — but the upstream quota headers are still the caller's real
|
|
1819
2070
|
// limits. Published before the ok/non-ok split so a 429 carries them too.
|
|
1820
2071
|
{
|
|
1821
|
-
const passthroughQuota = parseQuotaHeaders(response.headers
|
|
2072
|
+
const passthroughQuota = parseQuotaHeaders(response.headers, {
|
|
2073
|
+
model: body.model,
|
|
2074
|
+
});
|
|
1822
2075
|
publishLimitHeaders(ctx, {
|
|
1823
2076
|
upstreamHeaders: response.headers,
|
|
1824
2077
|
quota: passthroughQuota,
|
|
@@ -2216,6 +2469,11 @@ async function loadClaudeProxyAccounts(args) {
|
|
|
2216
2469
|
startupPruneDone = true;
|
|
2217
2470
|
}
|
|
2218
2471
|
const compoundKeys = await tokenStore.listByPrefix("anthropic:");
|
|
2472
|
+
// Tracked so an empty pool can name the real cause: "every account is
|
|
2473
|
+
// entitlement-blocked" is a different problem from "no credentials".
|
|
2474
|
+
const entitlementBlockedLabels = [];
|
|
2475
|
+
let skippedDisabledCount = 0;
|
|
2476
|
+
let skippedForOtherReasons = 0;
|
|
2219
2477
|
for (const key of compoundKeys) {
|
|
2220
2478
|
if (!isAccountAllowed(key, accountAllowlist)) {
|
|
2221
2479
|
logger.debug(`[proxy] skipping account=${key} (not in account allowlist)`);
|
|
@@ -2237,11 +2495,16 @@ async function loadClaudeProxyAccounts(args) {
|
|
|
2237
2495
|
else {
|
|
2238
2496
|
logger.debug(`[proxy] skipping disabled account=${key.split(":")[1] ?? key}`);
|
|
2239
2497
|
existingState.permanentlyDisabled = true;
|
|
2498
|
+
if (disabledReason === "entitlement_blocked") {
|
|
2499
|
+
entitlementBlockedLabels.push(key.split(":")[1] ?? key);
|
|
2500
|
+
}
|
|
2501
|
+
skippedDisabledCount += 1;
|
|
2240
2502
|
continue;
|
|
2241
2503
|
}
|
|
2242
2504
|
}
|
|
2243
2505
|
const tokens = await tokenStore.loadTokens(key);
|
|
2244
2506
|
if (!tokens) {
|
|
2507
|
+
skippedForOtherReasons += 1;
|
|
2245
2508
|
continue;
|
|
2246
2509
|
}
|
|
2247
2510
|
let accessToken = tokens.accessToken;
|
|
@@ -2349,6 +2612,24 @@ async function loadClaudeProxyAccounts(args) {
|
|
|
2349
2612
|
});
|
|
2350
2613
|
}
|
|
2351
2614
|
if (accounts.length === 0) {
|
|
2615
|
+
// Once every account has been disabled on entitlement the loop never runs,
|
|
2616
|
+
// so this is the only place the client can learn why. A flat 401 here would
|
|
2617
|
+
// send the user to re-authenticate, which cannot fix an org policy.
|
|
2618
|
+
if (entitlementBlockedLabels.length > 0 &&
|
|
2619
|
+
entitlementBlockedLabels.length === skippedDisabledCount &&
|
|
2620
|
+
skippedForOtherReasons === 0) {
|
|
2621
|
+
const entitlementMessage = buildEntitlementErrorMessage({
|
|
2622
|
+
status: 403,
|
|
2623
|
+
accounts: entitlementBlockedLabels,
|
|
2624
|
+
message: "OAuth authentication is not allowed for this organization.",
|
|
2625
|
+
errorCode: "oauth_not_allowed_for_organization",
|
|
2626
|
+
});
|
|
2627
|
+
tracer?.setError("permission_error", entitlementMessage);
|
|
2628
|
+
tracer?.end(403, Date.now() - requestStartTime);
|
|
2629
|
+
return {
|
|
2630
|
+
response: buildLoggedClaudeError(403, entitlementMessage, "permission_error"),
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2352
2633
|
const noCredentialsMessage = accountAllowlist
|
|
2353
2634
|
? "No allowed Anthropic credentials are currently available"
|
|
2354
2635
|
: compoundKeys.length > 0
|
|
@@ -2380,6 +2661,7 @@ async function loadClaudeProxyAccounts(args) {
|
|
|
2380
2661
|
quotaRoutingEnabled,
|
|
2381
2662
|
sessionSoftLimit,
|
|
2382
2663
|
sessionResetToleranceMs,
|
|
2664
|
+
requestedModel: typeof body.model === "string" ? body.model : undefined,
|
|
2383
2665
|
setRoutingDecision,
|
|
2384
2666
|
});
|
|
2385
2667
|
if (accountStrategy === "fill-first" &&
|
|
@@ -2730,8 +3012,100 @@ async function tryAutoClaudeFallback(args) {
|
|
|
2730
3012
|
return { response: null, lastErrorMessage: errorMessage };
|
|
2731
3013
|
}
|
|
2732
3014
|
}
|
|
3015
|
+
/** Human-readable list that stays short when the pool is large. */
|
|
3016
|
+
function joinAccountLabels(labels, max = 5) {
|
|
3017
|
+
const unique = [...new Set(labels)];
|
|
3018
|
+
if (unique.length <= max) {
|
|
3019
|
+
return unique.join(", ");
|
|
3020
|
+
}
|
|
3021
|
+
return `${unique.slice(0, max).join(", ")} and ${unique.length - max} more`;
|
|
3022
|
+
}
|
|
3023
|
+
/**
|
|
3024
|
+
* The client-facing text for "every account is blocked by an organization
|
|
3025
|
+
* entitlement policy". Names the accounts and the remedy, because the upstream
|
|
3026
|
+
* message alone ("OAuth authentication is currently not allowed…") gives no
|
|
3027
|
+
* indication that a pool was involved or which credential to fix.
|
|
3028
|
+
*/
|
|
3029
|
+
function buildEntitlementErrorMessage(failure) {
|
|
3030
|
+
const count = new Set(failure.accounts).size;
|
|
3031
|
+
const code = failure.errorCode ? ` (${failure.errorCode})` : "";
|
|
3032
|
+
return (`All ${count} Anthropic account${count === 1 ? "" : "s"} are blocked by an ` +
|
|
3033
|
+
`organization entitlement policy${code}. Accounts: ${joinAccountLabels(failure.accounts)}. Upstream: "${failure.message}". Ask an organization admin to re-enable ` +
|
|
3034
|
+
`Claude Code OAuth access, then run: neurolink auth enable anthropic:<account>.`);
|
|
3035
|
+
}
|
|
3036
|
+
/** Name the subscription window a set of cooldowns is waiting on. */
|
|
3037
|
+
function describeCoolingWindow(states) {
|
|
3038
|
+
const reasons = new Set(states.map((state) => state.coolingReason));
|
|
3039
|
+
if (reasons.size === 1) {
|
|
3040
|
+
switch ([...reasons][0]) {
|
|
3041
|
+
case "session":
|
|
3042
|
+
return "5-hour subscription window";
|
|
3043
|
+
case "weekly":
|
|
3044
|
+
return "7-day subscription window";
|
|
3045
|
+
case "unified":
|
|
3046
|
+
return "subscription limit";
|
|
3047
|
+
case "transient":
|
|
3048
|
+
return "upstream burst limit";
|
|
3049
|
+
default:
|
|
3050
|
+
break;
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
return "upstream rate limits";
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* The provider's stated reason that paid extra usage is unavailable, taken from
|
|
3057
|
+
* the first account that reports one. Anthropic sends this on every response
|
|
3058
|
+
* (e.g. "org_level_disabled"); without it an exhausted pool looks like a proxy
|
|
3059
|
+
* failure rather than a billing policy.
|
|
3060
|
+
*/
|
|
3061
|
+
function findOverageDisabledReason(accounts) {
|
|
3062
|
+
for (const account of accounts) {
|
|
3063
|
+
const quota = accountRuntimeState.get(account.key)?.quota;
|
|
3064
|
+
if (quota?.overageDisabledReason &&
|
|
3065
|
+
quota.overageStatus?.trim().toLowerCase() !== "allowed") {
|
|
3066
|
+
return quota.overageDisabledReason;
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
return undefined;
|
|
3070
|
+
}
|
|
3071
|
+
/** Suffix explaining why paid overage cannot absorb an exhausted window. */
|
|
3072
|
+
function buildOverageUnavailableSuffix(reason) {
|
|
3073
|
+
if (!reason) {
|
|
3074
|
+
return "";
|
|
3075
|
+
}
|
|
3076
|
+
return (` Paid extra usage is unavailable for this organization (${reason}), so ` +
|
|
3077
|
+
`there is no additional capacity before the window resets.`);
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* The client-facing text for "every account has spent its model-scoped cap".
|
|
3081
|
+
* Distinct from a normal rate limit: the same accounts remain healthy for every
|
|
3082
|
+
* other model, so switching model is a real remedy and backing off is not.
|
|
3083
|
+
*/
|
|
3084
|
+
function buildScopedExhaustionMessage(exhaustion) {
|
|
3085
|
+
const count = new Set(exhaustion.accounts).size;
|
|
3086
|
+
return (`All ${count} Anthropic account${count === 1 ? "" : "s"} have exhausted the ` +
|
|
3087
|
+
`model-scoped limit for ${exhaustion.model} (window "${exhaustion.scopeModel}"). ` +
|
|
3088
|
+
`Other models remain available on this pool — switch model, or add an account ` +
|
|
3089
|
+
`with remaining ${exhaustion.scopeModel} capacity. Earliest reset at ` +
|
|
3090
|
+
`${new Date(exhaustion.earliestResetMs).toISOString()}.` +
|
|
3091
|
+
buildOverageUnavailableSuffix(exhaustion.overageDisabledReason));
|
|
3092
|
+
}
|
|
2733
3093
|
function buildClaudeAnthropicFailureResponse(args) {
|
|
2734
|
-
const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, lastTransportErrorCode, lastTransportScope, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
|
|
3094
|
+
const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, entitlementFailure, scopedExhaustion, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, lastTransportErrorCode, lastTransportScope, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
|
|
3095
|
+
// Ranked above the auth rung on purpose: an organization policy block is a
|
|
3096
|
+
// more specific diagnosis than "authentication failed". But only when the
|
|
3097
|
+
// policy explains the *whole* pool — otherwise a single blocked account would
|
|
3098
|
+
// mask four genuine 5xx failures behind a 403, telling the client not to retry
|
|
3099
|
+
// when retrying is exactly right. No retry-after: waiting cannot fix a policy.
|
|
3100
|
+
const entitlementExplainsPool = entitlementFailure !== null &&
|
|
3101
|
+
orderedAccounts.length > 0 &&
|
|
3102
|
+
new Set(entitlementFailure.accounts).size >= orderedAccounts.length;
|
|
3103
|
+
if (entitlementExplainsPool && entitlementFailure && !sawRateLimit) {
|
|
3104
|
+
const message = buildEntitlementErrorMessage(entitlementFailure);
|
|
3105
|
+
tracer?.setError("permission_error", message);
|
|
3106
|
+
tracer?.end(403, Date.now() - requestStartTime);
|
|
3107
|
+
return buildLoggedClaudeError(403, message, "permission_error");
|
|
3108
|
+
}
|
|
2735
3109
|
if (authFailureMessage && !sawRateLimit) {
|
|
2736
3110
|
tracer?.setError("authentication_error", authFailureMessage);
|
|
2737
3111
|
tracer?.end(401, Date.now() - requestStartTime);
|
|
@@ -2786,6 +3160,37 @@ function buildClaudeAnthropicFailureResponse(args) {
|
|
|
2786
3160
|
...(lastTransportScope ? { transportScope: lastTransportScope } : {}),
|
|
2787
3161
|
});
|
|
2788
3162
|
}
|
|
3163
|
+
/** Emit the 429 the client sees, with an honest retry-after. */
|
|
3164
|
+
const respondRateLimited = (message, retryAfterSec) => {
|
|
3165
|
+
const errorBody = buildClaudeError(429, message, "overloaded_error");
|
|
3166
|
+
tracer?.setError("rate_limit_error", message);
|
|
3167
|
+
tracer?.end(429, Date.now() - requestStartTime);
|
|
3168
|
+
logFinalRequest(429, "", "final", "rate_limit_error", message);
|
|
3169
|
+
const errorBodyText = JSON.stringify(errorBody);
|
|
3170
|
+
const headers = {
|
|
3171
|
+
"content-type": "application/json",
|
|
3172
|
+
"retry-after": String(retryAfterSec),
|
|
3173
|
+
};
|
|
3174
|
+
logProxyBody({
|
|
3175
|
+
phase: "client_response",
|
|
3176
|
+
headers,
|
|
3177
|
+
body: errorBodyText,
|
|
3178
|
+
bodySize: Buffer.byteLength(errorBodyText, "utf8"),
|
|
3179
|
+
contentType: "application/json",
|
|
3180
|
+
responseStatus: 429,
|
|
3181
|
+
durationMs: Date.now() - requestStartTime,
|
|
3182
|
+
});
|
|
3183
|
+
return new Response(errorBodyText, { status: 429, headers });
|
|
3184
|
+
};
|
|
3185
|
+
// A model-scoped cap is spent on every account. No upstream call was made, so
|
|
3186
|
+
// sawRateLimit is false and this must be caught before the generic
|
|
3187
|
+
// "all accounts failed" 502 below, which would report the wrong cause and
|
|
3188
|
+
// invite an immediate, guaranteed-to-fail retry.
|
|
3189
|
+
if (scopedExhaustion) {
|
|
3190
|
+
const message = buildScopedExhaustionMessage(scopedExhaustion);
|
|
3191
|
+
logger.always(`[proxy] model-scoped limit exhausted for ${scopedExhaustion.model} on all accounts`);
|
|
3192
|
+
return respondRateLimited(message, Math.max(1, Math.ceil((scopedExhaustion.earliestResetMs - Date.now()) / 1000)));
|
|
3193
|
+
}
|
|
2789
3194
|
if (!sawRateLimit) {
|
|
2790
3195
|
const fallbackSuffix = fallbackFailureMessage
|
|
2791
3196
|
? ` Fallback also failed: ${fallbackFailureMessage}`
|
|
@@ -2809,45 +3214,27 @@ function buildClaudeAnthropicFailureResponse(args) {
|
|
|
2809
3214
|
const retryAfterSec = allAccountsCooling
|
|
2810
3215
|
? Math.max(1, Math.ceil((earliestRetryAt - now) / 1000))
|
|
2811
3216
|
: 1;
|
|
3217
|
+
// Name the window that actually ran out. "rate limits" alone cannot be acted
|
|
3218
|
+
// on: a 5-hour session window and a 7-day weekly window call for very
|
|
3219
|
+
// different responses from the caller.
|
|
3220
|
+
const windowLabel = describeCoolingWindow(activeRateLimitCooldowns);
|
|
3221
|
+
const overageSuffix = buildOverageUnavailableSuffix(findOverageDisabledReason(orderedAccounts));
|
|
2812
3222
|
const errorMessage = allAccountsCooling
|
|
2813
|
-
? `All ${orderedAccounts.length} Anthropic accounts are cooling after
|
|
2814
|
-
: `All ${orderedAccounts.length} accounts rate-limited after per-account retries
|
|
3223
|
+
? `All ${orderedAccounts.length} Anthropic accounts are cooling after the ${windowLabel} was exhausted. Earliest retry at ${new Date(earliestRetryAt).toISOString()}.${overageSuffix}`
|
|
3224
|
+
: `All ${orderedAccounts.length} accounts rate-limited after per-account retries.${overageSuffix}`;
|
|
2815
3225
|
logger.always(`[proxy] all accounts rate-limited, retry in ${retryAfterSec}s`);
|
|
2816
|
-
|
|
2817
|
-
tracer?.setError("rate_limit_error", errorMessage);
|
|
2818
|
-
tracer?.end(429, Date.now() - requestStartTime);
|
|
2819
|
-
logFinalRequest(429, "", "final", "rate_limit_error", errorMessage);
|
|
2820
|
-
const errorBodyText = JSON.stringify(errorBody);
|
|
2821
|
-
logProxyBody({
|
|
2822
|
-
phase: "client_response",
|
|
2823
|
-
headers: {
|
|
2824
|
-
"content-type": "application/json",
|
|
2825
|
-
"retry-after": String(retryAfterSec),
|
|
2826
|
-
},
|
|
2827
|
-
body: errorBodyText,
|
|
2828
|
-
bodySize: Buffer.byteLength(errorBodyText, "utf8"),
|
|
2829
|
-
contentType: "application/json",
|
|
2830
|
-
responseStatus: 429,
|
|
2831
|
-
durationMs: Date.now() - requestStartTime,
|
|
2832
|
-
});
|
|
2833
|
-
return new Response(errorBodyText, {
|
|
2834
|
-
status: 429,
|
|
2835
|
-
headers: {
|
|
2836
|
-
"content-type": "application/json",
|
|
2837
|
-
"retry-after": String(retryAfterSec),
|
|
2838
|
-
},
|
|
2839
|
-
});
|
|
3226
|
+
return respondRateLimited(errorMessage, retryAfterSec);
|
|
2840
3227
|
}
|
|
2841
3228
|
async function handleAnthropicSuccessfulResponse(args) {
|
|
2842
3229
|
const { ctx, body, account, accountState, response, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, poolAccounts, logFinalRequest, } = args;
|
|
2843
3230
|
accountState.consecutiveRefreshFailures = 0;
|
|
2844
3231
|
logger.always(`[proxy] ← ${response.status} account=${account.label}`);
|
|
2845
|
-
const quota = parseQuotaHeaders(response.headers);
|
|
3232
|
+
const quota = parseQuotaHeaders(response.headers, { model: body.model });
|
|
2846
3233
|
if (quota) {
|
|
2847
3234
|
// Stash the latest quota on runtime state so the next request can pick the
|
|
2848
3235
|
// account whose window resets soonest (max-utilization) and proactively
|
|
2849
3236
|
// skip rejected windows unless Anthropic explicitly permits overage.
|
|
2850
|
-
accountState.quota = quota;
|
|
3237
|
+
accountState.quota = mergeQuotaSnapshot(accountState.quota, quota);
|
|
2851
3238
|
const cooldownUpdate = reconcileCooldownFromQuota(accountState, quota, Date.now());
|
|
2852
3239
|
if (cooldownUpdate?.kind === "cooled") {
|
|
2853
3240
|
saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
@@ -2918,7 +3305,7 @@ async function handleAnthropicSuccessfulResponse(args) {
|
|
|
2918
3305
|
});
|
|
2919
3306
|
}
|
|
2920
3307
|
async function handleAnthropicStreamingSuccessResponse(args) {
|
|
2921
|
-
const { ctx, account, accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, } = args;
|
|
3308
|
+
const { ctx, body, account, accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, } = args;
|
|
2922
3309
|
if (!response.body) {
|
|
2923
3310
|
recordAttemptError(account.label, account.type, 502);
|
|
2924
3311
|
logAttempt(502, "stream_error", "No response body from upstream");
|
|
@@ -3015,11 +3402,13 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
3015
3402
|
const bodyText = Buffer.concat(preflight.chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
|
3016
3403
|
const isRateLimit = preflight.errorType === "rate_limit_error";
|
|
3017
3404
|
const logicalStatus = isRateLimit ? 429 : 502;
|
|
3018
|
-
const quota = parseQuotaHeaders(responseHeaders);
|
|
3405
|
+
const quota = parseQuotaHeaders(responseHeaders, { model: body.model });
|
|
3019
3406
|
const now = Date.now();
|
|
3020
3407
|
if (isRateLimit) {
|
|
3021
3408
|
const cooldownPlan = planCooldownFor429(quota, parseRetryAfterMs(responseHeaders["retry-after"] ?? null), now, getUnifiedRateLimitStatus(responseHeaders));
|
|
3022
|
-
accountState.quota = quota
|
|
3409
|
+
accountState.quota = quota
|
|
3410
|
+
? mergeQuotaSnapshot(accountState.quota, quota)
|
|
3411
|
+
: accountState.quota;
|
|
3023
3412
|
const rateLimitKind = cooldownPlan.reason === "transient" ? "transient" : "quota";
|
|
3024
3413
|
if (!accountState.coolingUntil ||
|
|
3025
3414
|
cooldownPlan.coolingUntil > accountState.coolingUntil) {
|
|
@@ -3480,12 +3869,14 @@ async function handleAnthropicJsonSuccessResponse(args) {
|
|
|
3480
3869
|
return { response: responseJson };
|
|
3481
3870
|
}
|
|
3482
3871
|
async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
|
|
3483
|
-
const { account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
|
|
3484
|
-
const retryQuota = parseQuotaHeaders(retryResp.headers
|
|
3872
|
+
const { account, accountState, requestedModel, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
|
|
3873
|
+
const retryQuota = parseQuotaHeaders(retryResp.headers, {
|
|
3874
|
+
model: requestedModel,
|
|
3875
|
+
});
|
|
3485
3876
|
if (retryQuota) {
|
|
3486
3877
|
// Keep the auth-retry success path in parity with the main success path:
|
|
3487
3878
|
// stash quota for proactive selection and reconcile a rejected window.
|
|
3488
|
-
accountState.quota = retryQuota;
|
|
3879
|
+
accountState.quota = mergeQuotaSnapshot(accountState.quota, retryQuota);
|
|
3489
3880
|
const cooldownUpdate = reconcileCooldownFromQuota(accountState, retryQuota, Date.now());
|
|
3490
3881
|
if (cooldownUpdate?.kind === "cooled") {
|
|
3491
3882
|
saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
@@ -3566,13 +3957,14 @@ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
|
|
|
3566
3957
|
return retryJson;
|
|
3567
3958
|
}
|
|
3568
3959
|
async function handleAnthropicAuthRetry(args) {
|
|
3569
|
-
const { ctx, body, account, accountState, headers, buildUpstreamBody, url, enabledAccounts, orderedAccounts, tracer, requestStartTime, allocateAttemptNumber, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, lastError, authFailureMessage, sawRateLimit, sawTransientFailure, sawNetworkError, } = args;
|
|
3960
|
+
const { ctx, body, account, accountState, headers, buildUpstreamBody, url, enabledAccounts, orderedAccounts, tracer, requestStartTime, allocateAttemptNumber, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, lastError, authFailureMessage, entitlementFailure, sawRateLimit, sawTransientFailure, sawNetworkError, } = args;
|
|
3570
3961
|
recordAttemptError(account.label, account.type, 401);
|
|
3571
3962
|
logAttempt(401, "authentication_error", "received 401 from Anthropic", {
|
|
3572
3963
|
retryable: true,
|
|
3573
3964
|
});
|
|
3574
3965
|
let currentLastError = lastError;
|
|
3575
3966
|
let currentAuthFailureMessage = authFailureMessage;
|
|
3967
|
+
let currentEntitlementFailure = entitlementFailure;
|
|
3576
3968
|
let currentSawRateLimit = sawRateLimit;
|
|
3577
3969
|
let currentSawTransientFailure = sawTransientFailure;
|
|
3578
3970
|
let currentSawNetworkError = sawNetworkError;
|
|
@@ -3656,6 +4048,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3656
4048
|
response: await handleAnthropicSuccessfulNonStreamRetryResponse({
|
|
3657
4049
|
account,
|
|
3658
4050
|
accountState,
|
|
4051
|
+
requestedModel: body.model,
|
|
3659
4052
|
retryResp,
|
|
3660
4053
|
tracer,
|
|
3661
4054
|
requestStartTime,
|
|
@@ -3677,6 +4070,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3677
4070
|
: {}),
|
|
3678
4071
|
lastError: failure?.message ?? currentLastError,
|
|
3679
4072
|
authFailureMessage: currentAuthFailureMessage,
|
|
4073
|
+
entitlementFailure: currentEntitlementFailure,
|
|
3680
4074
|
sawRateLimit: currentSawRateLimit || Boolean(failure?.rateLimit),
|
|
3681
4075
|
sawTransientFailure: currentSawTransientFailure ||
|
|
3682
4076
|
Boolean(failure && !failure.rateLimit),
|
|
@@ -3690,6 +4084,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3690
4084
|
continueLoop: false,
|
|
3691
4085
|
lastError: currentLastError,
|
|
3692
4086
|
authFailureMessage: currentAuthFailureMessage,
|
|
4087
|
+
entitlementFailure: currentEntitlementFailure,
|
|
3693
4088
|
sawRateLimit: currentSawRateLimit,
|
|
3694
4089
|
sawTransientFailure: currentSawTransientFailure,
|
|
3695
4090
|
sawNetworkError: currentSawNetworkError,
|
|
@@ -3746,6 +4141,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3746
4141
|
continueLoop: false,
|
|
3747
4142
|
lastError: retryBody,
|
|
3748
4143
|
authFailureMessage: currentAuthFailureMessage,
|
|
4144
|
+
entitlementFailure: currentEntitlementFailure,
|
|
3749
4145
|
sawRateLimit: currentSawRateLimit,
|
|
3750
4146
|
sawTransientFailure: currentSawTransientFailure,
|
|
3751
4147
|
sawNetworkError: currentSawNetworkError,
|
|
@@ -3760,9 +4156,11 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3760
4156
|
// Cool the account per its real reset window before rotating, so a
|
|
3761
4157
|
// session/weekly-exhausted account isn't re-selected next request.
|
|
3762
4158
|
const nowRetry = Date.now();
|
|
3763
|
-
const retryQuota429 = parseQuotaHeaders(retryRespHeaders
|
|
4159
|
+
const retryQuota429 = parseQuotaHeaders(retryRespHeaders, {
|
|
4160
|
+
model: body.model,
|
|
4161
|
+
});
|
|
3764
4162
|
if (retryQuota429) {
|
|
3765
|
-
accountState.quota = retryQuota429;
|
|
4163
|
+
accountState.quota = mergeQuotaSnapshot(accountState.quota, retryQuota429);
|
|
3766
4164
|
}
|
|
3767
4165
|
const retryPlan = planCooldownFor429(retryQuota429, parseRetryAfterMs(retryRespHeaders["retry-after"] ?? null), nowRetry, getUnifiedRateLimitStatus(retryRespHeaders));
|
|
3768
4166
|
const rateLimitKind = retryPlan.reason === "transient" ? "transient" : "quota";
|
|
@@ -3789,6 +4187,36 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3789
4187
|
break;
|
|
3790
4188
|
}
|
|
3791
4189
|
if (retryStatus === 401 || retryStatus === 402 || retryStatus === 403) {
|
|
4190
|
+
// An organization/plan entitlement refusal is not an authentication
|
|
4191
|
+
// problem: the token just minted is valid and refreshing again cannot
|
|
4192
|
+
// change the verdict. Rotate now instead of burning the remaining
|
|
4193
|
+
// refresh cycles and their one-second waits on a certain failure.
|
|
4194
|
+
if (isAccountEntitlementError(retryStatus, retryBody)) {
|
|
4195
|
+
const parsedRetry = parseClaudeErrorBody(retryBody);
|
|
4196
|
+
recordAttemptError(account.label, account.type, retryStatus);
|
|
4197
|
+
retryLogAttempt(retryStatus, "permission_error", summarizeErrorMessage(retryBody));
|
|
4198
|
+
currentEntitlementFailure = {
|
|
4199
|
+
status: retryStatus,
|
|
4200
|
+
accounts: [
|
|
4201
|
+
...(currentEntitlementFailure?.accounts ?? []),
|
|
4202
|
+
account.label,
|
|
4203
|
+
],
|
|
4204
|
+
message: currentEntitlementFailure?.message ??
|
|
4205
|
+
parsedRetry.message ??
|
|
4206
|
+
summarizeErrorMessage(retryBody),
|
|
4207
|
+
...(parsedRetry.errorCode
|
|
4208
|
+
? { errorCode: parsedRetry.errorCode }
|
|
4209
|
+
: {}),
|
|
4210
|
+
};
|
|
4211
|
+
if (account.type === "oauth" &&
|
|
4212
|
+
isDurableEntitlementBlock(retryStatus, retryBody)) {
|
|
4213
|
+
await disableAccountUntilReauth(account, accountState, "entitlement_blocked");
|
|
4214
|
+
}
|
|
4215
|
+
authRetryError = `entitlement blocked for account=${account.label}`;
|
|
4216
|
+
currentLastError = authRetryError;
|
|
4217
|
+
logger.always(`[proxy] ← ${retryStatus} account=${account.label} entitlement blocked after refresh; advancing to next account`);
|
|
4218
|
+
break;
|
|
4219
|
+
}
|
|
3792
4220
|
recordAttemptError(account.label, account.type, retryStatus);
|
|
3793
4221
|
retryLogAttempt(retryStatus, "authentication_error", summarizeErrorMessage(retryBody), { retryable: true });
|
|
3794
4222
|
if (authRetry < MAX_AUTH_RETRIES - 1) {
|
|
@@ -3810,6 +4238,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3810
4238
|
continueLoop: false,
|
|
3811
4239
|
lastError: currentLastError,
|
|
3812
4240
|
authFailureMessage: currentAuthFailureMessage,
|
|
4241
|
+
entitlementFailure: currentEntitlementFailure,
|
|
3813
4242
|
sawRateLimit: currentSawRateLimit,
|
|
3814
4243
|
sawTransientFailure: currentSawTransientFailure,
|
|
3815
4244
|
sawNetworkError: currentSawNetworkError,
|
|
@@ -3823,6 +4252,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3823
4252
|
continueLoop: false,
|
|
3824
4253
|
lastError: currentLastError,
|
|
3825
4254
|
authFailureMessage: currentAuthFailureMessage,
|
|
4255
|
+
entitlementFailure: currentEntitlementFailure,
|
|
3826
4256
|
sawRateLimit: currentSawRateLimit,
|
|
3827
4257
|
sawTransientFailure: currentSawTransientFailure,
|
|
3828
4258
|
sawNetworkError: currentSawNetworkError,
|
|
@@ -3863,6 +4293,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3863
4293
|
continueLoop: false,
|
|
3864
4294
|
lastError: currentLastError,
|
|
3865
4295
|
authFailureMessage: currentAuthFailureMessage,
|
|
4296
|
+
entitlementFailure: currentEntitlementFailure,
|
|
3866
4297
|
sawRateLimit: currentSawRateLimit,
|
|
3867
4298
|
sawTransientFailure: currentSawTransientFailure,
|
|
3868
4299
|
sawNetworkError: currentSawNetworkError,
|
|
@@ -3886,6 +4317,7 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
3886
4317
|
continueLoop: true,
|
|
3887
4318
|
lastError: currentLastError,
|
|
3888
4319
|
authFailureMessage: currentAuthFailureMessage,
|
|
4320
|
+
entitlementFailure: currentEntitlementFailure,
|
|
3889
4321
|
sawRateLimit: currentSawRateLimit,
|
|
3890
4322
|
sawTransientFailure: currentSawTransientFailure,
|
|
3891
4323
|
sawNetworkError: currentSawNetworkError,
|
|
@@ -3969,11 +4401,12 @@ function finalizeAnthropicTerminalTransportError(args) {
|
|
|
3969
4401
|
return clientError;
|
|
3970
4402
|
}
|
|
3971
4403
|
async function handleAnthropicNonOkResponse(args) {
|
|
3972
|
-
const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, } = args;
|
|
4404
|
+
const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, entitlementFailure, } = args;
|
|
3973
4405
|
let currentLastError = lastError;
|
|
3974
4406
|
let currentAuthFailureMessage = authFailureMessage;
|
|
3975
4407
|
let currentSawTransientFailure = sawTransientFailure;
|
|
3976
4408
|
let currentInvalidRequestFailure = invalidRequestFailure;
|
|
4409
|
+
let currentEntitlementFailure = entitlementFailure;
|
|
3977
4410
|
const errBody = await response.text();
|
|
3978
4411
|
const errRespHeaders = {};
|
|
3979
4412
|
response.headers.forEach((value, key) => {
|
|
@@ -4023,6 +4456,7 @@ async function handleAnthropicNonOkResponse(args) {
|
|
|
4023
4456
|
authFailureMessage: currentAuthFailureMessage,
|
|
4024
4457
|
sawTransientFailure: currentSawTransientFailure,
|
|
4025
4458
|
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4459
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4026
4460
|
upstreamSpan: undefined,
|
|
4027
4461
|
};
|
|
4028
4462
|
}
|
|
@@ -4041,6 +4475,53 @@ async function handleAnthropicNonOkResponse(args) {
|
|
|
4041
4475
|
authFailureMessage: currentAuthFailureMessage,
|
|
4042
4476
|
sawTransientFailure: currentSawTransientFailure,
|
|
4043
4477
|
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4478
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4479
|
+
upstreamSpan: undefined,
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
if (account.type === "oauth" &&
|
|
4483
|
+
isAccountEntitlementError(response.status, errBody)) {
|
|
4484
|
+
// Anthropic refuses this credential on organization/plan policy, e.g.
|
|
4485
|
+
// "OAuth authentication is currently not allowed for this organization."
|
|
4486
|
+
// Neither a retry nor a token refresh can fix it, but another account may
|
|
4487
|
+
// not be subject to the same policy — so rotate rather than fail the client.
|
|
4488
|
+
//
|
|
4489
|
+
// Must precede the no-refresh-token branch below: that branch matches the
|
|
4490
|
+
// same 401/402/403 statuses and would disable the account as
|
|
4491
|
+
// `missing_refresh_token`, telling the user to re-login — which cannot help
|
|
4492
|
+
// and burns a working credential.
|
|
4493
|
+
//
|
|
4494
|
+
// Like the beta-rejection branch above, deliberately sets neither
|
|
4495
|
+
// `currentInvalidRequestFailure` (would suppress provider fallback and
|
|
4496
|
+
// outrank a later account's real 429) nor `currentAuthFailureMessage`
|
|
4497
|
+
// (would surface a misleading "re-authenticate" 401).
|
|
4498
|
+
const parsed = parseClaudeErrorBody(errBody);
|
|
4499
|
+
recordAttemptError(account.label, account.type, response.status);
|
|
4500
|
+
currentEntitlementFailure = {
|
|
4501
|
+
status: response.status,
|
|
4502
|
+
accounts: [...(currentEntitlementFailure?.accounts ?? []), account.label],
|
|
4503
|
+
message: currentEntitlementFailure?.message ??
|
|
4504
|
+
parsed.message ??
|
|
4505
|
+
summarizeErrorMessage(errBody),
|
|
4506
|
+
...(parsed.errorCode ? { errorCode: parsed.errorCode } : {}),
|
|
4507
|
+
};
|
|
4508
|
+
logger.always(`[proxy] ← ${response.status} account=${account.label} entitlement blocked${parsed.errorCode ? ` (${parsed.errorCode})` : ""}; advancing to next account`);
|
|
4509
|
+
logAttempt(response.status, "permission_error", summarizeErrorMessage(errBody));
|
|
4510
|
+
tracer?.setError("permission_error", summarizeErrorMessage(errBody));
|
|
4511
|
+
tracer?.recordRetry(account.label, "entitlement_blocked");
|
|
4512
|
+
if (account.type === "oauth" &&
|
|
4513
|
+
isDurableEntitlementBlock(response.status, errBody)) {
|
|
4514
|
+
await disableAccountUntilReauth(account, accountState, "entitlement_blocked");
|
|
4515
|
+
}
|
|
4516
|
+
advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
|
|
4517
|
+
currentLastError = summarizeErrorMessage(errBody);
|
|
4518
|
+
return {
|
|
4519
|
+
continueLoop: true,
|
|
4520
|
+
lastError: currentLastError,
|
|
4521
|
+
authFailureMessage: currentAuthFailureMessage,
|
|
4522
|
+
sawTransientFailure: currentSawTransientFailure,
|
|
4523
|
+
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4524
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4044
4525
|
upstreamSpan: undefined,
|
|
4045
4526
|
};
|
|
4046
4527
|
}
|
|
@@ -4064,6 +4545,7 @@ async function handleAnthropicNonOkResponse(args) {
|
|
|
4064
4545
|
authFailureMessage: currentAuthFailureMessage,
|
|
4065
4546
|
sawTransientFailure: currentSawTransientFailure,
|
|
4066
4547
|
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4548
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4067
4549
|
upstreamSpan: undefined,
|
|
4068
4550
|
};
|
|
4069
4551
|
}
|
|
@@ -4086,6 +4568,7 @@ async function handleAnthropicNonOkResponse(args) {
|
|
|
4086
4568
|
authFailureMessage: currentAuthFailureMessage,
|
|
4087
4569
|
sawTransientFailure: currentSawTransientFailure,
|
|
4088
4570
|
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4571
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4089
4572
|
upstreamSpan: undefined,
|
|
4090
4573
|
};
|
|
4091
4574
|
}
|
|
@@ -4111,6 +4594,7 @@ async function handleAnthropicNonOkResponse(args) {
|
|
|
4111
4594
|
authFailureMessage: currentAuthFailureMessage,
|
|
4112
4595
|
sawTransientFailure: currentSawTransientFailure,
|
|
4113
4596
|
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4597
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4114
4598
|
upstreamSpan: undefined,
|
|
4115
4599
|
};
|
|
4116
4600
|
}
|
|
@@ -4139,6 +4623,7 @@ async function handleAnthropicNonOkResponse(args) {
|
|
|
4139
4623
|
authFailureMessage: currentAuthFailureMessage,
|
|
4140
4624
|
sawTransientFailure: currentSawTransientFailure,
|
|
4141
4625
|
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4626
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4142
4627
|
upstreamSpan: undefined,
|
|
4143
4628
|
};
|
|
4144
4629
|
}
|
|
@@ -4164,6 +4649,7 @@ async function handleAnthropicNonOkResponse(args) {
|
|
|
4164
4649
|
authFailureMessage: currentAuthFailureMessage,
|
|
4165
4650
|
sawTransientFailure: currentSawTransientFailure,
|
|
4166
4651
|
invalidRequestFailure: currentInvalidRequestFailure,
|
|
4652
|
+
entitlementFailure: currentEntitlementFailure,
|
|
4167
4653
|
upstreamSpan: undefined,
|
|
4168
4654
|
};
|
|
4169
4655
|
}
|
|
@@ -4545,7 +5031,7 @@ function buildAnthropicConstructionRejectionTerminalError() {
|
|
|
4545
5031
|
};
|
|
4546
5032
|
}
|
|
4547
5033
|
async function fetchAnthropicAccountResponse(args) {
|
|
4548
|
-
const { url, headers, finalBodyStr, account, accountState: _accountState2, enabledAccounts: _enabledAccounts, orderedAccounts: _orderedAccounts, tracer, logAttempt, logProxyBody, fetchStartMs, attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, upstreamSpan, } = args;
|
|
5034
|
+
const { url, headers, finalBodyStr, requestedModel, account, accountState: _accountState2, enabledAccounts: _enabledAccounts, orderedAccounts: _orderedAccounts, tracer, logAttempt, logProxyBody, fetchStartMs, attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, upstreamSpan, } = args;
|
|
4549
5035
|
let lastError = currentLastError;
|
|
4550
5036
|
let sawRateLimit = currentSawRateLimit;
|
|
4551
5037
|
let sawNetworkError = currentSawNetworkError;
|
|
@@ -4649,7 +5135,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
4649
5135
|
// exhaustion 429 we rotate immediately and park the account until its
|
|
4650
5136
|
// ACTUAL reset, not a 60s hardcap.
|
|
4651
5137
|
const now = Date.now();
|
|
4652
|
-
const quota = parseQuotaHeaders(errRespHeaders);
|
|
5138
|
+
const quota = parseQuotaHeaders(errRespHeaders, { model: requestedModel });
|
|
4653
5139
|
const unifiedStatus = getUnifiedRateLimitStatus(errRespHeaders);
|
|
4654
5140
|
const cooldownPlan = planCooldownFor429(quota, retryAfterMs, now, unifiedStatus);
|
|
4655
5141
|
const rateLimitKind = cooldownPlan.reason === "transient" ? "transient" : "quota";
|
|
@@ -4712,6 +5198,10 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4712
5198
|
return loadedAccounts.response;
|
|
4713
5199
|
}
|
|
4714
5200
|
const { accounts, enabledAccounts, orderedAccounts, bodyStr, requestStart, toolCount, url, clientHeaders, isClaudeClientRequest, } = loadedAccounts;
|
|
5201
|
+
// Snapshot the operator policy once. Reading the module value later would let
|
|
5202
|
+
// a concurrent /limits call or a hot config reload change this request's
|
|
5203
|
+
// answer partway through its own account loop.
|
|
5204
|
+
const requestOveragePolicy = currentOveragePolicy();
|
|
4715
5205
|
const loopState = {
|
|
4716
5206
|
lastError: undefined,
|
|
4717
5207
|
sawRateLimit: false,
|
|
@@ -4720,6 +5210,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4720
5210
|
invalidRequestFailure: null,
|
|
4721
5211
|
authFailureMessage: null,
|
|
4722
5212
|
authCooldownMessage: null,
|
|
5213
|
+
entitlementFailure: null,
|
|
5214
|
+
scopedExhaustion: null,
|
|
4723
5215
|
attemptNumber: 0,
|
|
4724
5216
|
};
|
|
4725
5217
|
const acctSelectionSpan = tracer?.startAccountSelection();
|
|
@@ -4750,6 +5242,25 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4750
5242
|
loopState.authCooldownMessage = `All ${orderedAccounts.length} Anthropic accounts are temporarily unavailable while OAuth refresh is cooling. Earliest retry at ${new Date(earliestRetryAt).toISOString()}.`;
|
|
4751
5243
|
}
|
|
4752
5244
|
}
|
|
5245
|
+
// Second eligibility gate, alongside cooldowns: an account whose model-scoped
|
|
5246
|
+
// cap for THIS model is spent will 429 with certainty. Unlike a cooldown the
|
|
5247
|
+
// condition is per-model, so it must not park the account — it stays fully
|
|
5248
|
+
// available for every other model.
|
|
5249
|
+
const scopedExhaustion = evaluateScopedExhaustion(effectiveAccounts, typeof body.model === "string" ? body.model : undefined, Date.now(), requestOveragePolicy);
|
|
5250
|
+
if (scopedExhaustion.eligible.length > 0) {
|
|
5251
|
+
effectiveAccounts = scopedExhaustion.eligible;
|
|
5252
|
+
}
|
|
5253
|
+
else if (scopedExhaustion.exhaustion) {
|
|
5254
|
+
// Every account is scoped out on evidence we trust. Record it and let the
|
|
5255
|
+
// loop fall through to the post-loop path, so a configured fallback chain
|
|
5256
|
+
// still runs and only the terminal message changes. Returning here instead
|
|
5257
|
+
// would silently drop that fallback.
|
|
5258
|
+
loopState.scopedExhaustion = scopedExhaustion.exhaustion;
|
|
5259
|
+
loopState.lastError = `Model-scoped limit exhausted for ${scopedExhaustion.exhaustion.model}`;
|
|
5260
|
+
effectiveAccounts = [];
|
|
5261
|
+
}
|
|
5262
|
+
// Otherwise the scoped evidence was stale or absent: attempt the request
|
|
5263
|
+
// anyway rather than let a mis-parsed window take the pool down.
|
|
4753
5264
|
const accountAdmissionCapacity = modelRouter?.getMaxInflightPerAccount?.();
|
|
4754
5265
|
// When every eligible account is busy, reserve the first account that frees
|
|
4755
5266
|
// instead of arbitrarily waiting behind the last configured account.
|
|
@@ -4850,6 +5361,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4850
5361
|
url,
|
|
4851
5362
|
headers: preparedAttempt.headers,
|
|
4852
5363
|
finalBodyStr: preparedAttempt.finalBodyStr,
|
|
5364
|
+
requestedModel: body.model,
|
|
4853
5365
|
account,
|
|
4854
5366
|
accountState,
|
|
4855
5367
|
enabledAccounts,
|
|
@@ -4901,7 +5413,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4901
5413
|
const plan = fetchResult.cooldownPlan;
|
|
4902
5414
|
// Refresh the account's quota snapshot for proactive selection.
|
|
4903
5415
|
if (fetchResult.quota) {
|
|
4904
|
-
accountState.quota = fetchResult.quota;
|
|
5416
|
+
accountState.quota = mergeQuotaSnapshot(accountState.quota, fetchResult.quota);
|
|
4905
5417
|
saveAccountQuota(account.label, fetchResult.quota).catch(() => {
|
|
4906
5418
|
// Non-fatal: routing already has the in-memory snapshot.
|
|
4907
5419
|
});
|
|
@@ -4995,12 +5507,14 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4995
5507
|
onStreamTerminal: admissionLease.release,
|
|
4996
5508
|
lastError: loopState.lastError,
|
|
4997
5509
|
authFailureMessage: loopState.authFailureMessage,
|
|
5510
|
+
entitlementFailure: loopState.entitlementFailure,
|
|
4998
5511
|
sawRateLimit: loopState.sawRateLimit,
|
|
4999
5512
|
sawTransientFailure: loopState.sawTransientFailure,
|
|
5000
5513
|
sawNetworkError: loopState.sawNetworkError,
|
|
5001
5514
|
});
|
|
5002
5515
|
loopState.lastError = authRetryResult.lastError;
|
|
5003
5516
|
loopState.authFailureMessage = authRetryResult.authFailureMessage;
|
|
5517
|
+
loopState.entitlementFailure = authRetryResult.entitlementFailure;
|
|
5004
5518
|
loopState.sawRateLimit = authRetryResult.sawRateLimit;
|
|
5005
5519
|
loopState.sawTransientFailure = authRetryResult.sawTransientFailure;
|
|
5006
5520
|
loopState.sawNetworkError = authRetryResult.sawNetworkError;
|
|
@@ -5036,11 +5550,13 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
5036
5550
|
authFailureMessage: loopState.authFailureMessage,
|
|
5037
5551
|
sawTransientFailure: loopState.sawTransientFailure,
|
|
5038
5552
|
invalidRequestFailure: loopState.invalidRequestFailure,
|
|
5553
|
+
entitlementFailure: loopState.entitlementFailure,
|
|
5039
5554
|
});
|
|
5040
5555
|
loopState.lastError = nonOkResult.lastError;
|
|
5041
5556
|
loopState.authFailureMessage = nonOkResult.authFailureMessage;
|
|
5042
5557
|
loopState.sawTransientFailure = nonOkResult.sawTransientFailure;
|
|
5043
5558
|
loopState.invalidRequestFailure = nonOkResult.invalidRequestFailure;
|
|
5559
|
+
loopState.entitlementFailure = nonOkResult.entitlementFailure;
|
|
5044
5560
|
if (nonOkResult.response !== undefined) {
|
|
5045
5561
|
return nonOkResult.response;
|
|
5046
5562
|
}
|
|
@@ -5185,6 +5701,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
5185
5701
|
authFailureMessage: loopState.authFailureMessage,
|
|
5186
5702
|
authCooldownMessage: loopState.authCooldownMessage,
|
|
5187
5703
|
invalidRequestFailure: loopState.invalidRequestFailure,
|
|
5704
|
+
entitlementFailure: loopState.entitlementFailure,
|
|
5705
|
+
scopedExhaustion: loopState.scopedExhaustion,
|
|
5188
5706
|
sawNetworkError: loopState.sawNetworkError,
|
|
5189
5707
|
sawTransientFailure: loopState.sawTransientFailure,
|
|
5190
5708
|
sawRateLimit: loopState.sawRateLimit,
|
|
@@ -5268,7 +5786,9 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
5268
5786
|
quotaRoutingEnabled: isQuotaRoutingEnabled(),
|
|
5269
5787
|
sessionSoftLimit: getSessionSoftLimit(),
|
|
5270
5788
|
sessionResetToleranceMs: getSessionResetToleranceMs(),
|
|
5789
|
+
useOverage: "auto",
|
|
5271
5790
|
};
|
|
5791
|
+
setOveragePolicy(requestRouting.useOverage);
|
|
5272
5792
|
const requestModelRouter = requestRouting.modelRouter;
|
|
5273
5793
|
const body = ctx.body;
|
|
5274
5794
|
// 1. Validate
|
|
@@ -5436,9 +5956,12 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
5436
5956
|
method: "GET",
|
|
5437
5957
|
path: `${basePath}/limits`,
|
|
5438
5958
|
handler: async (ctx) => {
|
|
5439
|
-
const
|
|
5440
|
-
|
|
5441
|
-
|
|
5959
|
+
const limitsRouting = runtimeConfigProvider?.();
|
|
5960
|
+
const effectiveAllowlist = limitsRouting?.accountAllowlist ?? accountAllowlist;
|
|
5961
|
+
// A refresh reconciles cooldowns from the fetched quota, so it makes
|
|
5962
|
+
// the same overage judgement the request path does and needs the same
|
|
5963
|
+
// operator policy in scope.
|
|
5964
|
+
setOveragePolicy(limitsRouting?.useOverage);
|
|
5442
5965
|
const snapshotOnly = ctx.query?.snapshot === "true" || ctx.query?.snapshot === "1";
|
|
5443
5966
|
const accountFilter = ctx.query?.account;
|
|
5444
5967
|
return withSpan({
|
|
@@ -5529,7 +6052,9 @@ async function disableAccountUntilReauth(account, state, reason) {
|
|
|
5529
6052
|
logger.debug(`[proxy] failed to persist disabled state for ${account.label}: ${e instanceof Error ? e.message : String(e)}`);
|
|
5530
6053
|
}
|
|
5531
6054
|
state.permanentlyDisabled = true;
|
|
5532
|
-
logger.always(
|
|
6055
|
+
logger.always(reason === "entitlement_blocked"
|
|
6056
|
+
? `[proxy] account=${account.label} disabled: the organization blocks Claude Code OAuth. Ask an admin to re-enable it, then run: neurolink auth enable ${account.key}`
|
|
6057
|
+
: `[proxy] account=${account.label} disabled until re-authentication. Run: neurolink auth login anthropic --method oauth`);
|
|
5533
6058
|
return true;
|
|
5534
6059
|
}
|
|
5535
6060
|
async function coolAccountAfterTransientRefreshFailure(account, state) {
|
|
@@ -5667,11 +6192,13 @@ export function parseClaudeErrorBody(errBody) {
|
|
|
5667
6192
|
parsed.type === "error" &&
|
|
5668
6193
|
parsed.error &&
|
|
5669
6194
|
typeof parsed.error === "object") {
|
|
6195
|
+
const errorCode = parsed.error.details?.error_code;
|
|
5670
6196
|
return {
|
|
5671
6197
|
errorType: typeof parsed.error.type === "string" ? parsed.error.type : undefined,
|
|
5672
6198
|
message: typeof parsed.error.message === "string"
|
|
5673
6199
|
? parsed.error.message
|
|
5674
6200
|
: undefined,
|
|
6201
|
+
...(typeof errorCode === "string" ? { errorCode } : {}),
|
|
5675
6202
|
};
|
|
5676
6203
|
}
|
|
5677
6204
|
}
|
|
@@ -5714,6 +6241,55 @@ export function isSubscriptionBetaRejection(status, errBody) {
|
|
|
5714
6241
|
message.includes("subscription") &&
|
|
5715
6242
|
message.includes("available"));
|
|
5716
6243
|
}
|
|
6244
|
+
/**
|
|
6245
|
+
* Organization/plan entitlement codes Anthropic reports in `error.details`.
|
|
6246
|
+
* Membership here is what promotes a rejection from "rotate" to "remember".
|
|
6247
|
+
*/
|
|
6248
|
+
const ENTITLEMENT_ERROR_CODES = new Set([
|
|
6249
|
+
"oauth_not_allowed_for_organization",
|
|
6250
|
+
"oauth_not_allowed",
|
|
6251
|
+
"organization_disabled",
|
|
6252
|
+
]);
|
|
6253
|
+
/**
|
|
6254
|
+
* An entitlement rejection: Anthropic refuses THIS credential on organization
|
|
6255
|
+
* or plan policy, e.g. `403 permission_error` /
|
|
6256
|
+
* "OAuth authentication is currently not allowed for this organization."
|
|
6257
|
+
*
|
|
6258
|
+
* The taxonomy makes this safe to rotate on: `permission_error` is reserved for
|
|
6259
|
+
* credential/organization permission, distinct from `invalid_request_error`
|
|
6260
|
+
* (request shape) and `not_found_error`, both already terminal above. No
|
|
6261
|
+
* `permission_error` is caused by the request body, so the identical request
|
|
6262
|
+
* can succeed on a different account.
|
|
6263
|
+
*
|
|
6264
|
+
* Deliberately broad — rotation is cheap and reversible. Persisting the block
|
|
6265
|
+
* is gated on the narrower {@link isDurableEntitlementBlock}.
|
|
6266
|
+
*/
|
|
6267
|
+
export function isAccountEntitlementError(status, errBody) {
|
|
6268
|
+
if (status !== 401 && status !== 402 && status !== 403) {
|
|
6269
|
+
return false;
|
|
6270
|
+
}
|
|
6271
|
+
const parsed = parseClaudeErrorBody(errBody);
|
|
6272
|
+
if (parsed.errorType === "permission_error") {
|
|
6273
|
+
return true;
|
|
6274
|
+
}
|
|
6275
|
+
return (parsed.errorCode !== undefined &&
|
|
6276
|
+
ENTITLEMENT_ERROR_CODES.has(parsed.errorCode));
|
|
6277
|
+
}
|
|
6278
|
+
/**
|
|
6279
|
+
* Whether an entitlement rejection is specific enough to disable the account
|
|
6280
|
+
* until someone re-enables it. Narrower than {@link isAccountEntitlementError}
|
|
6281
|
+
* because disabling is sticky and user-visible: an unrecognised
|
|
6282
|
+
* `permission_error` should rotate through the pool and surface a 403, never
|
|
6283
|
+
* durably remove a credential on a guess.
|
|
6284
|
+
*/
|
|
6285
|
+
export function isDurableEntitlementBlock(status, errBody) {
|
|
6286
|
+
if (!isAccountEntitlementError(status, errBody)) {
|
|
6287
|
+
return false;
|
|
6288
|
+
}
|
|
6289
|
+
const parsed = parseClaudeErrorBody(errBody);
|
|
6290
|
+
return (parsed.errorCode !== undefined &&
|
|
6291
|
+
ENTITLEMENT_ERROR_CODES.has(parsed.errorCode));
|
|
6292
|
+
}
|
|
5717
6293
|
function normalizeClaudeRequestForAnthropic(body) {
|
|
5718
6294
|
return {
|
|
5719
6295
|
...body,
|
|
@@ -5797,8 +6373,8 @@ export const __testHooks = {
|
|
|
5797
6373
|
scheduleAdaptiveQuotaRefreshes,
|
|
5798
6374
|
scheduleHandoffQuotaRefresh,
|
|
5799
6375
|
getQuotaRefreshState: (key) => accountQuotaRefreshCoordinator.getState(key),
|
|
5800
|
-
buildQuotaRoutingDecision: (accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) => {
|
|
5801
|
-
const order = orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs);
|
|
6376
|
+
buildQuotaRoutingDecision: (accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), requestedModel) => {
|
|
6377
|
+
const order = orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
|
|
5802
6378
|
return buildRoutingDecision({
|
|
5803
6379
|
accounts,
|
|
5804
6380
|
orderedAccounts: order.orderedAccounts,
|
|
@@ -5870,5 +6446,10 @@ export const __testHooks = {
|
|
|
5870
6446
|
shouldAttemptClaudeFallback,
|
|
5871
6447
|
executeClaudeFallbackWithRetry,
|
|
5872
6448
|
buildClaudeAnthropicFailureResponse,
|
|
6449
|
+
isAccountEntitlementError,
|
|
6450
|
+
isDurableEntitlementBlock,
|
|
6451
|
+
evaluateScopedExhaustion,
|
|
6452
|
+
matchScopedQuotaWindow,
|
|
6453
|
+
setOveragePolicy,
|
|
5873
6454
|
};
|
|
5874
6455
|
//# sourceMappingURL=claudeProxyRoutes.js.map
|