@juspay/neurolink 10.12.7 → 10.12.8
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 +6 -0
- package/dist/browser/neurolink.min.js +451 -451
- package/dist/cli/commands/proxyAnalyze.js +7 -0
- package/dist/lib/proxy/accountQuotaRefreshCoordinator.d.ts +19 -0
- package/dist/lib/proxy/accountQuotaRefreshCoordinator.js +105 -0
- package/dist/lib/proxy/accountUsage.d.ts +5 -5
- package/dist/lib/proxy/accountUsage.js +5 -5
- package/dist/lib/proxy/providerTransportCoordinator.d.ts +17 -0
- package/dist/lib/proxy/providerTransportCoordinator.js +156 -0
- package/dist/lib/proxy/proxyAnalysis.js +59 -0
- package/dist/lib/proxy/routingEvidence.d.ts +1 -1
- package/dist/lib/proxy/routingEvidence.js +3 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +12 -4
- package/dist/lib/server/routes/claudeProxyRoutes.js +319 -69
- package/dist/lib/types/proxy.d.ts +79 -0
- package/dist/proxy/accountQuotaRefreshCoordinator.d.ts +19 -0
- package/dist/proxy/accountQuotaRefreshCoordinator.js +104 -0
- package/dist/proxy/accountUsage.d.ts +5 -5
- package/dist/proxy/accountUsage.js +5 -5
- package/dist/proxy/providerTransportCoordinator.d.ts +17 -0
- package/dist/proxy/providerTransportCoordinator.js +155 -0
- package/dist/proxy/proxyAnalysis.js +59 -0
- package/dist/proxy/routingEvidence.d.ts +1 -1
- package/dist/proxy/routingEvidence.js +3 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +12 -4
- package/dist/server/routes/claudeProxyRoutes.js +319 -69
- package/dist/types/proxy.d.ts +79 -0
- package/package.json +1 -1
|
@@ -18,6 +18,8 @@ import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from
|
|
|
18
18
|
import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
19
19
|
import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
20
20
|
import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../proxy/accountUsage.js";
|
|
21
|
+
import { AccountQuotaRefreshCoordinator } from "../../proxy/accountQuotaRefreshCoordinator.js";
|
|
22
|
+
import { ProviderTransportCoordinator } from "../../proxy/providerTransportCoordinator.js";
|
|
21
23
|
import { buildProxyLimitHeaders, summarizePoolHeadroom, } from "../../proxy/quotaHeaders.js";
|
|
22
24
|
import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
|
|
23
25
|
import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
|
|
@@ -111,8 +113,11 @@ function fetchAnthropicUpstream(url, init) {
|
|
|
111
113
|
});
|
|
112
114
|
}
|
|
113
115
|
const accountRuntimeState = new Map();
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
const accountQuotaRefreshCoordinator = new AccountQuotaRefreshCoordinator();
|
|
117
|
+
const providerTransportCoordinator = new ProviderTransportCoordinator();
|
|
118
|
+
/** Snapshot age is diagnostic and controls lightweight refresh eligibility. It
|
|
119
|
+
* must never discard known healthy routing evidence or trigger a user request
|
|
120
|
+
* on another account solely to rediscover quota. */
|
|
116
121
|
const QUOTA_SNAPSHOT_FRESHNESS_MS = 15 * 60 * 1000;
|
|
117
122
|
/** Shared across requests so a concurrent burst gets at most two retries for
|
|
118
123
|
* the account/window, rather than every request starting its own retry chain. */
|
|
@@ -646,6 +651,65 @@ const MIN_USAGE_REFETCH_INTERVAL_MS = 15_000;
|
|
|
646
651
|
const lastUsageFetchAt = new Map();
|
|
647
652
|
let limitsRefreshInFlight = null;
|
|
648
653
|
const USAGE_REFRESH_CONCURRENCY = 4;
|
|
654
|
+
async function applyAccountUsageResult(account, fetchResult, observedAt, prior) {
|
|
655
|
+
if (fetchResult.ok === false) {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
const state = getOrCreateRuntimeState(account.key);
|
|
659
|
+
const quota = usageToQuota(fetchResult.usage, {
|
|
660
|
+
now: observedAt,
|
|
661
|
+
prior: state.quota ?? prior ?? null,
|
|
662
|
+
});
|
|
663
|
+
if (!quota) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
// The usage payload cannot be newer than the request that fetched it. A
|
|
667
|
+
// passive response captured after that request started wins, including its
|
|
668
|
+
// cooldown decision and persisted snapshot.
|
|
669
|
+
if (quota.lastUpdated < (state.quota?.lastUpdated ?? 0)) {
|
|
670
|
+
return state.quota ?? null;
|
|
671
|
+
}
|
|
672
|
+
state.quota = quota;
|
|
673
|
+
const cooldownUpdate = reconcileCooldownFromQuota(state, quota, Date.now());
|
|
674
|
+
if (cooldownUpdate?.kind === "cooled") {
|
|
675
|
+
await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
676
|
+
// Non-fatal: the cooldown is already active in memory.
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
else if (cooldownUpdate?.kind === "cleared") {
|
|
680
|
+
await clearAccountCooldown(account.key, cooldownUpdate.coolingUntil).catch(() => {
|
|
681
|
+
// Non-fatal: the next successful response will reconcile again.
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
await saveAccountQuota(account.label, quota).catch(() => {
|
|
685
|
+
// Non-fatal: quota persistence is best-effort.
|
|
686
|
+
});
|
|
687
|
+
return quota;
|
|
688
|
+
}
|
|
689
|
+
async function fetchValidatedAccountUsage(account) {
|
|
690
|
+
const result = await fetchAccountUsage(account);
|
|
691
|
+
if (result.ok === false) {
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
const recognizable = usageToQuota(result.usage, {
|
|
695
|
+
now: Date.now(),
|
|
696
|
+
prior: accountRuntimeState.get(account.key)?.quota ?? null,
|
|
697
|
+
});
|
|
698
|
+
return recognizable
|
|
699
|
+
? result
|
|
700
|
+
: {
|
|
701
|
+
ok: false,
|
|
702
|
+
reason: "parse",
|
|
703
|
+
error: "usage payload had no recognizable limit windows",
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
async function refreshAccountQuotaInBackground(account, trigger) {
|
|
707
|
+
const refresh = await accountQuotaRefreshCoordinator.run(account, trigger, fetchValidatedAccountUsage);
|
|
708
|
+
if (refresh.kind !== "completed" || refresh.result.ok === false) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
await applyAccountUsageResult(account, refresh.result, refresh.startedAt);
|
|
712
|
+
}
|
|
649
713
|
/**
|
|
650
714
|
* Fetch fresh limits from Anthropic's usage endpoint for every eligible OAuth
|
|
651
715
|
* account and write them through the exact same chain the passive header
|
|
@@ -686,6 +750,7 @@ async function refreshAccountLimits(options = {}) {
|
|
|
686
750
|
fetchedAt,
|
|
687
751
|
snapshot: true,
|
|
688
752
|
results: accounts.map((account) => buildResult(account, "snapshot", null)),
|
|
753
|
+
refreshMetrics: accountQuotaRefreshCoordinator.getMetrics(),
|
|
689
754
|
};
|
|
690
755
|
}
|
|
691
756
|
const results = new Array(accounts.length);
|
|
@@ -710,7 +775,12 @@ async function refreshAccountLimits(options = {}) {
|
|
|
710
775
|
// Isolate failures per account: an unexpected rejection must not abort
|
|
711
776
|
// the Promise.all sweep and turn the whole /limits response into a 502.
|
|
712
777
|
try {
|
|
713
|
-
const
|
|
778
|
+
const refresh = await accountQuotaRefreshCoordinator.run(account, `manual:${account.key}`, fetchValidatedAccountUsage, { force: true });
|
|
779
|
+
if (refresh.kind !== "completed") {
|
|
780
|
+
results[index] = buildResult(account, "throttled", null);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
const fetchResult = refresh.result;
|
|
714
784
|
// `=== false` (not `!ok`) — the react-hooks sub-build compiles this
|
|
715
785
|
// file without strictNullChecks, where negated boolean-discriminant
|
|
716
786
|
// narrowing does not apply.
|
|
@@ -718,35 +788,11 @@ async function refreshAccountLimits(options = {}) {
|
|
|
718
788
|
results[index] = buildResult(account, "error", null, fetchResult.error);
|
|
719
789
|
continue;
|
|
720
790
|
}
|
|
721
|
-
const
|
|
722
|
-
const capturedAt = Date.now();
|
|
723
|
-
const quota = usageToQuota(fetchResult.usage, {
|
|
724
|
-
now: capturedAt,
|
|
725
|
-
prior: state.quota ?? persisted[account.label] ?? null,
|
|
726
|
-
});
|
|
791
|
+
const quota = await applyAccountUsageResult(account, fetchResult, refresh.startedAt, persisted[account.label] ?? null);
|
|
727
792
|
if (!quota) {
|
|
728
793
|
results[index] = buildResult(account, "error", null, "usage payload had no recognizable limit windows");
|
|
729
794
|
continue;
|
|
730
795
|
}
|
|
731
|
-
// Guard against a passive header capture that landed mid-fetch: never
|
|
732
|
-
// replace a fresher runtime snapshot with an older reading.
|
|
733
|
-
if (quota.lastUpdated >= (state.quota?.lastUpdated ?? 0)) {
|
|
734
|
-
state.quota = quota;
|
|
735
|
-
}
|
|
736
|
-
const cooldownUpdate = reconcileCooldownFromQuota(state, quota, capturedAt);
|
|
737
|
-
if (cooldownUpdate?.kind === "cooled") {
|
|
738
|
-
await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
739
|
-
// Non-fatal: cooldown is already active in memory.
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
else if (cooldownUpdate?.kind === "cleared") {
|
|
743
|
-
await clearAccountCooldown(account.key, cooldownUpdate.coolingUntil).catch(() => {
|
|
744
|
-
// Non-fatal: the next successful response will reconcile again.
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
await saveAccountQuota(account.label, quota).catch(() => {
|
|
748
|
-
// Non-fatal: quota persistence is best-effort
|
|
749
|
-
});
|
|
750
796
|
results[index] = buildResult(account, "refreshed", quota);
|
|
751
797
|
}
|
|
752
798
|
catch (err) {
|
|
@@ -755,7 +801,12 @@ async function refreshAccountLimits(options = {}) {
|
|
|
755
801
|
}
|
|
756
802
|
};
|
|
757
803
|
await Promise.all(Array.from({ length: Math.min(USAGE_REFRESH_CONCURRENCY, accounts.length || 1) }, () => worker()));
|
|
758
|
-
return {
|
|
804
|
+
return {
|
|
805
|
+
fetchedAt,
|
|
806
|
+
snapshot: false,
|
|
807
|
+
results,
|
|
808
|
+
refreshMetrics: accountQuotaRefreshCoordinator.getMetrics(),
|
|
809
|
+
};
|
|
759
810
|
}
|
|
760
811
|
/** Quota-aware selection is on by default; disable with
|
|
761
812
|
* NEUROLINK_PROXY_QUOTA_ROUTING=off|false|0. Only affects the fill-first
|
|
@@ -800,8 +851,30 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
|
|
|
800
851
|
const q = st?.quota;
|
|
801
852
|
const quotaLastUpdated = q && Number.isFinite(q.lastUpdated) ? q.lastUpdated : null;
|
|
802
853
|
const quotaAgeMs = quotaLastUpdated === null ? null : Math.max(0, now - quotaLastUpdated);
|
|
854
|
+
const refreshState = accountQuotaRefreshCoordinator.getState(accountKey);
|
|
803
855
|
const quotaStale = quotaAgeMs !== null && quotaAgeMs > QUOTA_SNAPSHOT_FRESHNESS_MS;
|
|
804
|
-
const
|
|
856
|
+
const normalizeStatus = (status) => status.trim().toLowerCase();
|
|
857
|
+
const rawOverageEligible = isQuotaOverageAvailable(q);
|
|
858
|
+
const observedStatuses = [
|
|
859
|
+
q?.unifiedStatus,
|
|
860
|
+
q?.sessionStatus,
|
|
861
|
+
q?.weeklyStatus,
|
|
862
|
+
].filter((status) => !!status?.trim());
|
|
863
|
+
const staleHardOrAmbiguous = quotaStale &&
|
|
864
|
+
!!q &&
|
|
865
|
+
!rawOverageEligible &&
|
|
866
|
+
observedStatuses.some((status) => normalizeStatus(status) !== "allowed");
|
|
867
|
+
const quotaFreshness = !q
|
|
868
|
+
? "unknown"
|
|
869
|
+
: !quotaStale
|
|
870
|
+
? "fresh"
|
|
871
|
+
: staleHardOrAmbiguous
|
|
872
|
+
? "refresh_due"
|
|
873
|
+
: "stale_known";
|
|
874
|
+
// A stale rejection is advisory after restart and cannot quarantine an
|
|
875
|
+
// account. Keep it as evidence, rank it behind known-healthy accounts, and
|
|
876
|
+
// refresh it through the usage endpoint before relying on the old status.
|
|
877
|
+
const routingQuota = staleHardOrAmbiguous ? undefined : q;
|
|
805
878
|
const coolingActive = !!st?.coolingUntil && now < st.coolingUntil;
|
|
806
879
|
// resetEpochToMs returns undefined for absent OR passed resets, so a
|
|
807
880
|
// ticking window is exactly "reset !== undefined".
|
|
@@ -830,17 +903,46 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
|
|
|
830
903
|
: "allowed"
|
|
831
904
|
: null;
|
|
832
905
|
const overageEligible = isQuotaOverageAvailable(routingQuota);
|
|
833
|
-
const
|
|
834
|
-
(
|
|
906
|
+
const hardSaturated = !overageEligible &&
|
|
907
|
+
(sessionStatus === "rejected" ||
|
|
908
|
+
sessionStatus === "throttled" ||
|
|
909
|
+
weeklyStatus === "rejected" ||
|
|
910
|
+
weeklyStatus === "throttled" ||
|
|
911
|
+
routingQuota?.unifiedStatus?.trim().toLowerCase() === "rejected" ||
|
|
912
|
+
(sessionTicking && (sessionUsed ?? 0) >= 1) ||
|
|
913
|
+
(weeklyTicking && (weeklyUsed ?? 0) >= 1));
|
|
914
|
+
const softSaturated = !hardSaturated &&
|
|
915
|
+
!overageEligible &&
|
|
916
|
+
sessionTicking &&
|
|
917
|
+
(sessionUsed ?? 0) >= sessionSoftLimit;
|
|
918
|
+
const saturated = hardSaturated || softSaturated;
|
|
835
919
|
return {
|
|
836
|
-
usable: !coolingActive &&
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
920
|
+
usable: !coolingActive && !hardSaturated,
|
|
921
|
+
saturated,
|
|
922
|
+
hasQuota: !!q,
|
|
923
|
+
quotaEvidenceRank: quotaFreshness === "fresh" || quotaFreshness === "stale_known"
|
|
924
|
+
? 0
|
|
925
|
+
: quotaFreshness === "refresh_due"
|
|
926
|
+
? 1
|
|
927
|
+
: 2,
|
|
843
928
|
quotaStale,
|
|
929
|
+
quotaFreshness,
|
|
930
|
+
refreshNeeded: quotaFreshness === "unknown" || quotaFreshness === "refresh_due",
|
|
931
|
+
refreshReason: quotaFreshness === "unknown"
|
|
932
|
+
? "startup_unknown"
|
|
933
|
+
: quotaFreshness === "refresh_due"
|
|
934
|
+
? "ambiguous_snapshot"
|
|
935
|
+
: null,
|
|
936
|
+
refreshInFlight: refreshState.inFlight,
|
|
937
|
+
lastRefreshAttemptAt: refreshState.lastAttemptAt ?? null,
|
|
938
|
+
lastRefreshSuccessAt: refreshState.lastSuccessAt ?? null,
|
|
939
|
+
nextRefreshEligibleAt: refreshState.nextEligibleAt ?? null,
|
|
940
|
+
saturationKind: hardSaturated ? "hard" : softSaturated ? "soft" : "none",
|
|
941
|
+
softLimitOverrideReason: overageEligible &&
|
|
942
|
+
sessionTicking &&
|
|
943
|
+
(sessionUsed ?? 0) >= sessionSoftLimit
|
|
944
|
+
? "overage"
|
|
945
|
+
: null,
|
|
844
946
|
quotaLastUpdated,
|
|
845
947
|
quotaAgeMs,
|
|
846
948
|
coolingActive,
|
|
@@ -880,8 +982,8 @@ function compareAccountRoutingFactors(a, b, metricsByKey, primaryKey) {
|
|
|
880
982
|
au === bu ? "insertion_order" : "cooldown_recovery",
|
|
881
983
|
];
|
|
882
984
|
}
|
|
883
|
-
if (ma.
|
|
884
|
-
return [ma.
|
|
985
|
+
if (ma.quotaEvidenceRank !== mb.quotaEvidenceRank) {
|
|
986
|
+
return [ma.quotaEvidenceRank - mb.quotaEvidenceRank, "quota_evidence"];
|
|
885
987
|
}
|
|
886
988
|
if (ma.saturated !== mb.saturated) {
|
|
887
989
|
return [ma.saturated ? 1 : -1, "session_headroom"];
|
|
@@ -928,9 +1030,8 @@ function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftL
|
|
|
928
1030
|
* temporarily demoted until that session resets.
|
|
929
1031
|
*
|
|
930
1032
|
* Priority among usable accounts:
|
|
931
|
-
* 1.
|
|
932
|
-
*
|
|
933
|
-
* forever: never picked → never observed → never comparable.)
|
|
1033
|
+
* 1. known healthy quota before unknown or ambiguous stale evidence. Quota
|
|
1034
|
+
* discovery is handled by a lightweight usage GET, never a user request.
|
|
934
1035
|
* 2. session headroom before session-saturated (>= soft limit or
|
|
935
1036
|
* "throttled") — do not re-hammer an urgent weekly account while its 5h
|
|
936
1037
|
* capacity is temporarily unavailable.
|
|
@@ -948,6 +1049,62 @@ function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftL
|
|
|
948
1049
|
function orderAccountsByQuota(accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) {
|
|
949
1050
|
return orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs).orderedAccounts;
|
|
950
1051
|
}
|
|
1052
|
+
function scheduleAdaptiveQuotaRefreshes(accounts, orderedAccounts, sessionSoftLimit, routingMetrics) {
|
|
1053
|
+
for (const account of accounts) {
|
|
1054
|
+
if (account.type !== "oauth") {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const metrics = routingMetrics?.get(account.key) ??
|
|
1058
|
+
accountSortMetrics(account.key, Date.now(), sessionSoftLimit, getSessionResetToleranceMs());
|
|
1059
|
+
if (metrics.quotaFreshness === "unknown") {
|
|
1060
|
+
void refreshAccountQuotaInBackground(account, `startup-unknown:${account.key}`).catch((error) => {
|
|
1061
|
+
logger.debug(`[proxy] background quota discovery failed account=${account.label}: ${describeTransportError(error)}`);
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
else if (metrics.quotaFreshness === "refresh_due") {
|
|
1065
|
+
void refreshAccountQuotaInBackground(account, `ambiguous:${metrics.quotaLastUpdated ?? 0}`).catch((error) => {
|
|
1066
|
+
logger.debug(`[proxy] ambiguous quota refresh failed account=${account.label}: ${describeTransportError(error)}`);
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const active = orderedAccounts[0];
|
|
1071
|
+
const candidate = orderedAccounts[1];
|
|
1072
|
+
if (!active || !candidate || candidate.type !== "oauth") {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const activeQuota = accountRuntimeState.get(active.key)?.quota;
|
|
1076
|
+
if (!activeQuota) {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const sessionPrewarmAt = Math.max(0, sessionSoftLimit - 0.05);
|
|
1080
|
+
const needsPrewarm = (activeQuota.sessionUsed ?? 0) >= sessionPrewarmAt ||
|
|
1081
|
+
(activeQuota.weeklyUsed ?? 0) >= 0.9;
|
|
1082
|
+
if (!needsPrewarm) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const trigger = [
|
|
1086
|
+
"handoff",
|
|
1087
|
+
active.key,
|
|
1088
|
+
activeQuota.sessionResetAt ?? 0,
|
|
1089
|
+
activeQuota.weeklyResetAt ?? 0,
|
|
1090
|
+
candidate.key,
|
|
1091
|
+
].join(":");
|
|
1092
|
+
void refreshAccountQuotaInBackground(candidate, trigger).catch((error) => {
|
|
1093
|
+
logger.debug(`[proxy] quota handoff prewarm failed account=${candidate.label}: ${describeTransportError(error)}`);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
function scheduleHandoffQuotaRefresh(current, candidate, handoffEpoch, sessionSoftLimit) {
|
|
1097
|
+
if (!candidate || candidate.type !== "oauth") {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const metrics = accountSortMetrics(candidate.key, Date.now(), sessionSoftLimit, getSessionResetToleranceMs());
|
|
1101
|
+
if (metrics.quotaFreshness === "fresh") {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
void refreshAccountQuotaInBackground(candidate, `hard-handoff:${current.key}:${handoffEpoch}:${candidate.key}`).catch((error) => {
|
|
1105
|
+
logger.debug(`[proxy] hard-handoff quota refresh failed account=${candidate.label}: ${describeTransportError(error)}`);
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
951
1108
|
function buildRoutingDecision(args) {
|
|
952
1109
|
const { accounts, orderedAccounts, metricsByKey, evaluatedAt, strategy, primaryKey, quotaRoutingEnabled, quotaOrdered, sessionSoftLimit, sessionResetToleranceMs, rotationOffset, } = args;
|
|
953
1110
|
const sourceIndexes = new Map(accounts.map((account, index) => [account.key, index]));
|
|
@@ -969,6 +1126,15 @@ function buildRoutingDecision(args) {
|
|
|
969
1126
|
saturated: metrics.saturated,
|
|
970
1127
|
quotaObserved: metrics.hasQuota,
|
|
971
1128
|
quotaStale: metrics.quotaStale,
|
|
1129
|
+
quotaFreshness: metrics.quotaFreshness,
|
|
1130
|
+
refreshNeeded: metrics.refreshNeeded,
|
|
1131
|
+
refreshReason: metrics.refreshReason,
|
|
1132
|
+
refreshInFlight: metrics.refreshInFlight,
|
|
1133
|
+
lastRefreshAttemptAt: metrics.lastRefreshAttemptAt,
|
|
1134
|
+
lastRefreshSuccessAt: metrics.lastRefreshSuccessAt,
|
|
1135
|
+
nextRefreshEligibleAt: metrics.nextRefreshEligibleAt,
|
|
1136
|
+
saturationKind: metrics.saturationKind,
|
|
1137
|
+
softLimitOverrideReason: metrics.softLimitOverrideReason,
|
|
972
1138
|
quotaLastUpdated: metrics.quotaLastUpdated,
|
|
973
1139
|
quotaAgeMs: metrics.quotaAgeMs,
|
|
974
1140
|
coolingActive: metrics.coolingActive,
|
|
@@ -1096,7 +1262,7 @@ function selectClaudeProxyAccountOrder(args) {
|
|
|
1096
1262
|
if (routingDecision) {
|
|
1097
1263
|
setRoutingDecision(routingDecision);
|
|
1098
1264
|
}
|
|
1099
|
-
return orderedAccounts;
|
|
1265
|
+
return { orderedAccounts, metricsByKey };
|
|
1100
1266
|
}
|
|
1101
1267
|
// ---------------------------------------------------------------------------
|
|
1102
1268
|
// OAuth polyfill helpers (extracted to reduce block nesting)
|
|
@@ -2207,7 +2373,7 @@ async function loadClaudeProxyAccounts(args) {
|
|
|
2207
2373
|
tracer?.end(401, Date.now() - requestStartTime);
|
|
2208
2374
|
return { response: buildLoggedClaudeError(401, reauthMsg) };
|
|
2209
2375
|
}
|
|
2210
|
-
const orderedAccounts = selectClaudeProxyAccountOrder({
|
|
2376
|
+
const { orderedAccounts, metricsByKey } = selectClaudeProxyAccountOrder({
|
|
2211
2377
|
enabledAccounts,
|
|
2212
2378
|
accountStrategy,
|
|
2213
2379
|
primaryAccountKey,
|
|
@@ -2216,6 +2382,11 @@ async function loadClaudeProxyAccounts(args) {
|
|
|
2216
2382
|
sessionResetToleranceMs,
|
|
2217
2383
|
setRoutingDecision,
|
|
2218
2384
|
});
|
|
2385
|
+
if (accountStrategy === "fill-first" &&
|
|
2386
|
+
quotaRoutingEnabled &&
|
|
2387
|
+
enabledAccounts.length > 1) {
|
|
2388
|
+
scheduleAdaptiveQuotaRefreshes(enabledAccounts, orderedAccounts, sessionSoftLimit, metricsByKey);
|
|
2389
|
+
}
|
|
2219
2390
|
const normalizedAnthropicBody = normalizeClaudeRequestForAnthropic(body);
|
|
2220
2391
|
const bodyStr = JSON.stringify(normalizedAnthropicBody);
|
|
2221
2392
|
const requestStart = Date.now();
|
|
@@ -2560,7 +2731,7 @@ async function tryAutoClaudeFallback(args) {
|
|
|
2560
2731
|
}
|
|
2561
2732
|
}
|
|
2562
2733
|
function buildClaudeAnthropicFailureResponse(args) {
|
|
2563
|
-
const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
|
|
2734
|
+
const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, lastTransportErrorCode, lastTransportScope, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
|
|
2564
2735
|
if (authFailureMessage && !sawRateLimit) {
|
|
2565
2736
|
tracer?.setError("authentication_error", authFailureMessage);
|
|
2566
2737
|
tracer?.end(401, Date.now() - requestStartTime);
|
|
@@ -2598,12 +2769,22 @@ function buildClaudeAnthropicFailureResponse(args) {
|
|
|
2598
2769
|
const fallbackSuffix = fallbackFailureMessage
|
|
2599
2770
|
? ` Fallback also failed: ${fallbackFailureMessage}`
|
|
2600
2771
|
: "";
|
|
2601
|
-
const
|
|
2772
|
+
const transportDescription = lastTransportScope === "shared_provider_transport"
|
|
2773
|
+
? "shared Anthropic network"
|
|
2774
|
+
: lastTransportScope === "connection_transport"
|
|
2775
|
+
? "Anthropic connection"
|
|
2776
|
+
: null;
|
|
2777
|
+
const msg = `${transportDescription ? `${transportDescription} failure prevented safe cross-account rotation` : "All Anthropic accounts failed due to transient upstream/network errors"}. Last error${lastTransportErrorCode ? ` (${lastTransportErrorCode})` : ""}: ${lastError instanceof Error
|
|
2602
2778
|
? lastError.message
|
|
2603
2779
|
: String(lastError ?? "unknown")}.${fallbackSuffix}`;
|
|
2604
2780
|
tracer?.setError("transient_error", msg.slice(0, 500));
|
|
2605
2781
|
tracer?.end(502, Date.now() - requestStartTime);
|
|
2606
|
-
return buildLoggedClaudeError(502, msg, fallbackFailureMessage ? "fallback_exhausted" : "transient_error"
|
|
2782
|
+
return buildLoggedClaudeError(502, msg, fallbackFailureMessage ? "fallback_exhausted" : "transient_error", {
|
|
2783
|
+
...(lastTransportErrorCode
|
|
2784
|
+
? { errorCode: lastTransportErrorCode }
|
|
2785
|
+
: {}),
|
|
2786
|
+
...(lastTransportScope ? { transportScope: lastTransportScope } : {}),
|
|
2787
|
+
});
|
|
2607
2788
|
}
|
|
2608
2789
|
if (!sawRateLimit) {
|
|
2609
2790
|
const fallbackSuffix = fallbackFailureMessage
|
|
@@ -4063,6 +4244,7 @@ function createClaudeRequestRuntimeContext(args) {
|
|
|
4063
4244
|
? "stream_error"
|
|
4064
4245
|
: "handler_error",
|
|
4065
4246
|
message: errorMessage,
|
|
4247
|
+
errorCode: extra?.errorCode,
|
|
4066
4248
|
});
|
|
4067
4249
|
}
|
|
4068
4250
|
else {
|
|
@@ -4083,6 +4265,10 @@ function createClaudeRequestRuntimeContext(args) {
|
|
|
4083
4265
|
responseTimeMs: Date.now() - requestStartTime,
|
|
4084
4266
|
...(errorType ? { errorType } : {}),
|
|
4085
4267
|
...(errorMessage ? { errorMessage } : {}),
|
|
4268
|
+
...(extra?.errorCode ? { errorCode: extra.errorCode } : {}),
|
|
4269
|
+
...(extra?.transportScope
|
|
4270
|
+
? { transportScope: extra.transportScope }
|
|
4271
|
+
: {}),
|
|
4086
4272
|
...(extra?.inputTokens !== undefined
|
|
4087
4273
|
? { inputTokens: extra.inputTokens }
|
|
4088
4274
|
: {}),
|
|
@@ -4104,7 +4290,7 @@ function createClaudeRequestRuntimeContext(args) {
|
|
|
4104
4290
|
const buildLoggedClaudeError = (status, message, errorType, extra) => {
|
|
4105
4291
|
const errorBody = buildClaudeError(status, message, errorType);
|
|
4106
4292
|
const errorBodyText = JSON.stringify(errorBody);
|
|
4107
|
-
logFinalRequest(status, extra?.account ?? "", extra?.accountType ?? "final", errorType, message);
|
|
4293
|
+
logFinalRequest(status, extra?.account ?? "", extra?.accountType ?? "final", errorType, message, extra);
|
|
4108
4294
|
logProxyBody({
|
|
4109
4295
|
phase: "client_response",
|
|
4110
4296
|
headers: { "content-type": "application/json" },
|
|
@@ -4156,6 +4342,9 @@ function createAnthropicAttemptLogger(args) {
|
|
|
4156
4342
|
...(errorType ? { errorType } : {}),
|
|
4157
4343
|
...(errorMessage ? { errorMessage } : {}),
|
|
4158
4344
|
...(extra?.errorCode ? { errorCode: extra.errorCode } : {}),
|
|
4345
|
+
...(extra?.transportScope
|
|
4346
|
+
? { transportScope: extra.transportScope }
|
|
4347
|
+
: {}),
|
|
4159
4348
|
...(extra?.inputTokens !== undefined
|
|
4160
4349
|
? { inputTokens: extra.inputTokens }
|
|
4161
4350
|
: {}),
|
|
@@ -4372,6 +4561,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
4372
4561
|
}
|
|
4373
4562
|
catch (fetchErr) {
|
|
4374
4563
|
const retryable = isRetryableNetworkError(fetchErr);
|
|
4564
|
+
const transportScope = classifyNetworkTransportScope(fetchErr);
|
|
4375
4565
|
// Every dispatched upstream request is an attempt, including terminal
|
|
4376
4566
|
// transport failures. Record it once before preserving the throw behavior.
|
|
4377
4567
|
sawNetworkError = true;
|
|
@@ -4383,6 +4573,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
4383
4573
|
logAttempt(502, "network_error", errorMessage, {
|
|
4384
4574
|
retryable,
|
|
4385
4575
|
errorCode,
|
|
4576
|
+
transportScope,
|
|
4386
4577
|
});
|
|
4387
4578
|
tracer?.setError("network_error", errorMessage);
|
|
4388
4579
|
if (retryable) {
|
|
@@ -4395,6 +4586,8 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
4395
4586
|
return {
|
|
4396
4587
|
continueLoop: true,
|
|
4397
4588
|
retrySameAccount: true,
|
|
4589
|
+
transportScope,
|
|
4590
|
+
errorCode,
|
|
4398
4591
|
lastError,
|
|
4399
4592
|
sawRateLimit,
|
|
4400
4593
|
sawNetworkError,
|
|
@@ -4572,6 +4765,15 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4572
4765
|
let transientSameAccountRetries = 0;
|
|
4573
4766
|
let rateLimitSameAccountRetries = 0;
|
|
4574
4767
|
while (true) {
|
|
4768
|
+
const transportPermit = await providerTransportCoordinator.acquire(ctx.abortSignal);
|
|
4769
|
+
if (transportPermit.allowed === false) {
|
|
4770
|
+
loopState.sawNetworkError = true;
|
|
4771
|
+
loopState.lastTransportErrorCode =
|
|
4772
|
+
transportPermit.errorCode ?? undefined;
|
|
4773
|
+
loopState.lastTransportScope = transportPermit.transportScope;
|
|
4774
|
+
loopState.lastError = `Anthropic transport recovery probe failed (${transportPermit.errorCode ?? "unknown"})`;
|
|
4775
|
+
break accountLoop;
|
|
4776
|
+
}
|
|
4575
4777
|
loopState.attemptNumber += 1;
|
|
4576
4778
|
if (tracer && loopState.attemptNumber === 1 && acctSelectionSpan) {
|
|
4577
4779
|
tracer.setAccountSelection({
|
|
@@ -4613,6 +4815,9 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4613
4815
|
!preparedAttempt.buildUpstreamBody ||
|
|
4614
4816
|
!preparedAttempt.finalBodyStr ||
|
|
4615
4817
|
preparedAttempt.fetchStartMs === undefined) {
|
|
4818
|
+
if (transportPermit.probe) {
|
|
4819
|
+
providerTransportCoordinator.reportProbeAbandoned(transportPermit);
|
|
4820
|
+
}
|
|
4616
4821
|
continue accountLoop;
|
|
4617
4822
|
}
|
|
4618
4823
|
let admissionLease;
|
|
@@ -4632,31 +4837,53 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4632
4837
|
if (!admissionLease) {
|
|
4633
4838
|
// A later account may be immediately available. Preserve the routing
|
|
4634
4839
|
// order but do not leave a request queued behind a busy first choice.
|
|
4840
|
+
if (transportPermit.probe) {
|
|
4841
|
+
providerTransportCoordinator.reportProbeAbandoned(transportPermit);
|
|
4842
|
+
}
|
|
4635
4843
|
continue accountLoop;
|
|
4636
4844
|
}
|
|
4637
4845
|
let admissionTransferredToStream = false;
|
|
4638
4846
|
try {
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4847
|
+
let fetchResult;
|
|
4848
|
+
try {
|
|
4849
|
+
fetchResult = await fetchAnthropicAccountResponse({
|
|
4850
|
+
url,
|
|
4851
|
+
headers: preparedAttempt.headers,
|
|
4852
|
+
finalBodyStr: preparedAttempt.finalBodyStr,
|
|
4853
|
+
account,
|
|
4854
|
+
accountState,
|
|
4855
|
+
enabledAccounts,
|
|
4856
|
+
orderedAccounts,
|
|
4857
|
+
tracer,
|
|
4858
|
+
logAttempt,
|
|
4859
|
+
logProxyBody,
|
|
4860
|
+
fetchStartMs: preparedAttempt.fetchStartMs,
|
|
4861
|
+
attemptNumber: loopState.attemptNumber,
|
|
4862
|
+
currentLastError: loopState.lastError,
|
|
4863
|
+
currentSawRateLimit: loopState.sawRateLimit,
|
|
4864
|
+
currentSawNetworkError: loopState.sawNetworkError,
|
|
4865
|
+
upstreamSpan: preparedAttempt.upstreamSpan,
|
|
4866
|
+
});
|
|
4867
|
+
}
|
|
4868
|
+
catch (error) {
|
|
4869
|
+
if (transportPermit.probe) {
|
|
4870
|
+
providerTransportCoordinator.reportProbeAbandoned(transportPermit);
|
|
4871
|
+
}
|
|
4872
|
+
throw error;
|
|
4873
|
+
}
|
|
4874
|
+
if (fetchResult.transportScope) {
|
|
4875
|
+
providerTransportCoordinator.reportTransportFailure(fetchResult.errorCode, fetchResult.transportScope, transportPermit);
|
|
4876
|
+
}
|
|
4877
|
+
else {
|
|
4878
|
+
providerTransportCoordinator.reportSuccess(transportPermit);
|
|
4879
|
+
}
|
|
4657
4880
|
loopState.lastError = fetchResult.lastError;
|
|
4658
4881
|
loopState.sawRateLimit = fetchResult.sawRateLimit;
|
|
4659
4882
|
loopState.sawNetworkError = fetchResult.sawNetworkError;
|
|
4883
|
+
if (fetchResult.transportScope) {
|
|
4884
|
+
loopState.lastTransportScope = fetchResult.transportScope;
|
|
4885
|
+
loopState.lastTransportErrorCode = fetchResult.errorCode;
|
|
4886
|
+
}
|
|
4660
4887
|
if (fetchResult.terminalError) {
|
|
4661
4888
|
return finalizeAnthropicTerminalFetchError({
|
|
4662
4889
|
terminalError: fetchResult.terminalError,
|
|
@@ -4717,6 +4944,9 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4717
4944
|
// rotate while the already-published cooldown remains active.
|
|
4718
4945
|
advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
|
|
4719
4946
|
logger.always(`[proxy] account=${account.label} rate-limited (${plan.reason}); cooling ~${minutesUntil(plan.coolingUntil, Date.now())}m until ${new Date(plan.coolingUntil).toISOString()}, rotating`);
|
|
4947
|
+
if (plan.rotateImmediately) {
|
|
4948
|
+
scheduleHandoffQuotaRefresh(account, effectiveAccounts[accountIndex + 1], plan.coolingUntil, sessionSoftLimit);
|
|
4949
|
+
}
|
|
4720
4950
|
continue accountLoop;
|
|
4721
4951
|
}
|
|
4722
4952
|
// Transient error retry (network errors, 529 overloaded)
|
|
@@ -4728,9 +4958,13 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4728
4958
|
await sleep(delayMs);
|
|
4729
4959
|
continue;
|
|
4730
4960
|
}
|
|
4731
|
-
if (fetchResult.retrySameAccount) {
|
|
4961
|
+
if (fetchResult.retrySameAccount && !fetchResult.transportScope) {
|
|
4732
4962
|
logger.always(`[proxy] exhausted transient same-account retries for account=${account.label}; rotating`);
|
|
4733
4963
|
}
|
|
4964
|
+
if (fetchResult.transportScope) {
|
|
4965
|
+
logger.always(`[proxy] Anthropic ${fetchResult.transportScope} failure code=${fetchResult.errorCode ?? "unknown"}; suppressing cross-account rotation after ${transientSameAccountRetries + 1} attempts`);
|
|
4966
|
+
break accountLoop;
|
|
4967
|
+
}
|
|
4734
4968
|
continue accountLoop;
|
|
4735
4969
|
}
|
|
4736
4970
|
let upstreamSpan = fetchResult.upstreamSpan;
|
|
@@ -4955,6 +5189,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4955
5189
|
sawTransientFailure: loopState.sawTransientFailure,
|
|
4956
5190
|
sawRateLimit: loopState.sawRateLimit,
|
|
4957
5191
|
lastError: loopState.lastError,
|
|
5192
|
+
lastTransportErrorCode: loopState.lastTransportErrorCode,
|
|
5193
|
+
lastTransportScope: loopState.lastTransportScope,
|
|
4958
5194
|
fallbackFailureMessage: loopState.fallbackFailureMessage,
|
|
4959
5195
|
orderedAccounts,
|
|
4960
5196
|
buildLoggedClaudeError,
|
|
@@ -5406,11 +5642,18 @@ function isRetryableNetworkError(error) {
|
|
|
5406
5642
|
// The Anthropic host is fixed, so ENOTFOUND can be a transient resolver
|
|
5407
5643
|
// outage. Keep it inside the existing bounded same-account retry budget.
|
|
5408
5644
|
"ENOTFOUND",
|
|
5645
|
+
"EAI_AGAIN",
|
|
5409
5646
|
"EHOSTUNREACH",
|
|
5410
5647
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
5411
5648
|
"UND_ERR_CONNECT",
|
|
5412
5649
|
].includes(code));
|
|
5413
5650
|
}
|
|
5651
|
+
function classifyNetworkTransportScope(error) {
|
|
5652
|
+
const code = getErrorCode(error);
|
|
5653
|
+
return code === "ENOTFOUND" || code === "EAI_AGAIN"
|
|
5654
|
+
? "shared_provider_transport"
|
|
5655
|
+
: "connection_transport";
|
|
5656
|
+
}
|
|
5414
5657
|
const TRANSIENT_HTTP_STATUSES = new Set([
|
|
5415
5658
|
408, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 529,
|
|
5416
5659
|
]);
|
|
@@ -5540,15 +5783,20 @@ export const __testHooks = {
|
|
|
5540
5783
|
planCooldownFor429,
|
|
5541
5784
|
reconcileCooldownFromQuota,
|
|
5542
5785
|
refreshAccountLimits,
|
|
5786
|
+
applyAccountUsageResult,
|
|
5543
5787
|
clearLimitsRefreshStateForTests: () => {
|
|
5544
5788
|
lastUsageFetchAt.clear();
|
|
5545
5789
|
limitsRefreshInFlight = null;
|
|
5790
|
+
accountQuotaRefreshCoordinator.clear();
|
|
5546
5791
|
},
|
|
5547
5792
|
isRetryableNetworkError,
|
|
5548
5793
|
isPermanentRefreshFailure,
|
|
5549
5794
|
getStreamFailureDetails,
|
|
5550
5795
|
trackUpstreamReadableStream,
|
|
5551
5796
|
orderAccountsByQuota,
|
|
5797
|
+
scheduleAdaptiveQuotaRefreshes,
|
|
5798
|
+
scheduleHandoffQuotaRefresh,
|
|
5799
|
+
getQuotaRefreshState: (key) => accountQuotaRefreshCoordinator.getState(key),
|
|
5552
5800
|
buildQuotaRoutingDecision: (accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) => {
|
|
5553
5801
|
const order = orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs);
|
|
5554
5802
|
return buildRoutingDecision({
|
|
@@ -5587,6 +5835,8 @@ export const __testHooks = {
|
|
|
5587
5835
|
transientRateLimitRetryBudgets.clear();
|
|
5588
5836
|
transientCooldownAdmissionSchedules.clear();
|
|
5589
5837
|
accountAdmissionStates.clear();
|
|
5838
|
+
accountQuotaRefreshCoordinator.clear();
|
|
5839
|
+
providerTransportCoordinator.clear();
|
|
5590
5840
|
primaryAccountIndex = 0;
|
|
5591
5841
|
lastKnownAccountCount = 0;
|
|
5592
5842
|
},
|