@juspay/neurolink 12.9.2 → 12.9.4
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 +2 -2
- package/dist/browser/neurolink.min.js +391 -391
- package/dist/providers/catalog/cloudflare.json +3 -3
- package/dist/proxy/accountLedger.d.ts +6 -3
- package/dist/proxy/accountLedger.js +37 -10
- package/dist/proxy/providerTransportCoordinator.d.ts +4 -1
- package/dist/proxy/providerTransportCoordinator.js +8 -1
- package/dist/server/routes/claudeProxyRoutes.d.ts +19 -0
- package/dist/server/routes/claudeProxyRoutes.js +231 -49
- package/dist/types/proxy.d.ts +30 -1
- package/dist/types/proxyClient.d.ts +20 -2
- package/package.json +2 -1
|
@@ -18,6 +18,8 @@ import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from
|
|
|
18
18
|
import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
19
19
|
import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, mergeQuotaSnapshot, modelFamilyToken, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
20
20
|
import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../proxy/accountUsage.js";
|
|
21
|
+
import { tokenStore } from "../../auth/tokenStore.js";
|
|
22
|
+
import { fetchCodexAccountUsage, listCodexAccountsForUsage, resolveProxyStatusAccountIdentity, } from "../../proxy/codexAccountUsage.js";
|
|
21
23
|
import { AccountQuotaRefreshCoordinator } from "../../proxy/accountQuotaRefreshCoordinator.js";
|
|
22
24
|
import { ProviderTransportCoordinator } from "../../proxy/providerTransportCoordinator.js";
|
|
23
25
|
import { MAX_COOLDOWN_MS_BY_REASON } from "../../proxy/routingEvidence.js";
|
|
@@ -107,6 +109,15 @@ let lastKnownAccountCount = 0;
|
|
|
107
109
|
const MAX_AUTH_RETRIES = 5;
|
|
108
110
|
const MAX_TRANSIENT_SAME_ACCOUNT_RETRIES = 2;
|
|
109
111
|
const TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS = [250, 1_000];
|
|
112
|
+
/**
|
|
113
|
+
* Retry budget for failures that happened before any request byte was sent
|
|
114
|
+
* (a SYN lost on a lossy uplink). Nothing was dispatched, so each retry is
|
|
115
|
+
* free of duplicate-work risk; on a link losing one connect in five, four
|
|
116
|
+
* retries take the per-request failure rate from about 20% to under 1%.
|
|
117
|
+
* Delays follow TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS, clamped to its last
|
|
118
|
+
* entry.
|
|
119
|
+
*/
|
|
120
|
+
const MAX_CONNECT_PHASE_SAME_ACCOUNT_RETRIES = 4;
|
|
110
121
|
const OVERLOAD_ACCOUNT_ROTATION_DELAYS_MS = [250, 500, 1_000, 2_000];
|
|
111
122
|
const MAX_FALLBACK_NETWORK_RETRIES = 1;
|
|
112
123
|
const FALLBACK_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000;
|
|
@@ -780,6 +791,45 @@ async function refreshAccountQuotaInBackground(account, trigger) {
|
|
|
780
791
|
}
|
|
781
792
|
await applyAccountUsageResult(account, refresh.result, refresh.startedAt);
|
|
782
793
|
}
|
|
794
|
+
/**
|
|
795
|
+
* The logins the account-exposing routes enumerate, per engine, plus every
|
|
796
|
+
* key the token store holds at all (disabled ones included). Overridable by
|
|
797
|
+
* the suite because the token store is a singleton bound to the real home at
|
|
798
|
+
* import — a case cannot point it elsewhere, and reading the operator's own
|
|
799
|
+
* logins inside a fixture test is exactly how a phantom would hide.
|
|
800
|
+
*/
|
|
801
|
+
let accountDirectoryOverride = null;
|
|
802
|
+
async function listRoutableAccountsByEngine(allowlist) {
|
|
803
|
+
if (accountDirectoryOverride) {
|
|
804
|
+
return {
|
|
805
|
+
anthropic: accountDirectoryOverride.anthropic,
|
|
806
|
+
codex: accountDirectoryOverride.codex,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
// The allowlist is an Anthropic routing concept, keyed by anthropic:
|
|
810
|
+
// prefixes; applying it to Codex keys would exclude every Codex login.
|
|
811
|
+
const [anthropic, codex] = await Promise.all([
|
|
812
|
+
listAnthropicAccountsForUsage(allowlist),
|
|
813
|
+
listCodexAccountsForUsage(),
|
|
814
|
+
]);
|
|
815
|
+
return { anthropic, codex };
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Every login the token store knows, routable or not. This is what separates
|
|
819
|
+
* a DISABLED login (still here, shown as unrouted) from a REMOVED one (gone,
|
|
820
|
+
* and not shown): usage counters and quota snapshots outlive a logout, and
|
|
821
|
+
* without this check a deleted login renders as an unrouted account forever.
|
|
822
|
+
*/
|
|
823
|
+
async function listKnownAccountKeys() {
|
|
824
|
+
if (accountDirectoryOverride) {
|
|
825
|
+
return accountDirectoryOverride.knownKeys;
|
|
826
|
+
}
|
|
827
|
+
const [anthropic, codex] = await Promise.all([
|
|
828
|
+
tokenStore.listByPrefix("anthropic:"),
|
|
829
|
+
tokenStore.listByPrefix("codex:"),
|
|
830
|
+
]);
|
|
831
|
+
return new Set([...anthropic.map(normalizeAnthropicAccountKey), ...codex]);
|
|
832
|
+
}
|
|
783
833
|
/**
|
|
784
834
|
* Fetch fresh limits from Anthropic's usage endpoint for every eligible OAuth
|
|
785
835
|
* account and write them through the exact same chain the passive header
|
|
@@ -789,24 +839,38 @@ async function refreshAccountQuotaInBackground(account, trigger) {
|
|
|
789
839
|
*/
|
|
790
840
|
async function refreshAccountLimits(options = {}) {
|
|
791
841
|
const fetchedAt = Date.now();
|
|
792
|
-
const
|
|
842
|
+
const directory = await listRoutableAccountsByEngine(options.accountAllowlist);
|
|
843
|
+
const allAccounts = [
|
|
844
|
+
...directory.anthropic.map((account) => ({
|
|
845
|
+
account,
|
|
846
|
+
provider: "anthropic",
|
|
847
|
+
})),
|
|
848
|
+
...directory.codex.map((account) => ({
|
|
849
|
+
account,
|
|
850
|
+
provider: "codex",
|
|
851
|
+
})),
|
|
852
|
+
];
|
|
793
853
|
const accounts = options.accountFilter
|
|
794
|
-
? allAccounts.filter((account) => account.label === options.accountFilter ||
|
|
854
|
+
? allAccounts.filter(({ account }) => account.label === options.accountFilter ||
|
|
795
855
|
account.key === options.accountFilter)
|
|
796
856
|
: allAccounts;
|
|
797
857
|
const persisted = await loadAccountQuotas().catch(() => ({}));
|
|
798
|
-
const buildResult = (account, status, quota, error) => {
|
|
858
|
+
const buildResult = (account, provider, status, quota, error) => {
|
|
799
859
|
const state = accountRuntimeState.get(account.key);
|
|
860
|
+
// The quota store keys Anthropic snapshots by bare label for historical
|
|
861
|
+
// reasons (see CLAUDE.md) and Codex snapshots by full key. A Codex login
|
|
862
|
+
// must never fall back to the bare label: with one email on both engines
|
|
863
|
+
// that label holds the ANTHROPIC account's windows.
|
|
864
|
+
const persistedQuota = provider === "anthropic"
|
|
865
|
+
? (persisted[account.key] ?? persisted[account.label] ?? null)
|
|
866
|
+
: (persisted[account.key] ?? null);
|
|
800
867
|
const result = {
|
|
801
868
|
account: account.label,
|
|
802
869
|
key: account.key,
|
|
870
|
+
provider,
|
|
803
871
|
type: account.type,
|
|
804
872
|
status,
|
|
805
|
-
quota: quota ??
|
|
806
|
-
state?.quota ??
|
|
807
|
-
persisted[account.key] ??
|
|
808
|
-
persisted[account.label] ??
|
|
809
|
-
null,
|
|
873
|
+
quota: quota ?? state?.quota ?? persistedQuota,
|
|
810
874
|
};
|
|
811
875
|
if (error !== undefined) {
|
|
812
876
|
result.error = error;
|
|
@@ -823,7 +887,7 @@ async function refreshAccountLimits(options = {}) {
|
|
|
823
887
|
return {
|
|
824
888
|
fetchedAt,
|
|
825
889
|
snapshot: true,
|
|
826
|
-
results: accounts.map((account) => buildResult(account, "snapshot", null)),
|
|
890
|
+
results: accounts.map(({ account, provider }) => buildResult(account, provider, "snapshot", null)),
|
|
827
891
|
refreshMetrics: accountQuotaRefreshCoordinator.getMetrics(),
|
|
828
892
|
};
|
|
829
893
|
}
|
|
@@ -835,23 +899,41 @@ async function refreshAccountLimits(options = {}) {
|
|
|
835
899
|
if (index >= accounts.length) {
|
|
836
900
|
return;
|
|
837
901
|
}
|
|
838
|
-
const account = accounts[index];
|
|
902
|
+
const { account, provider } = accounts[index];
|
|
839
903
|
if (account.type !== "oauth") {
|
|
840
|
-
results[index] = buildResult(account, "skipped_api_key", null);
|
|
904
|
+
results[index] = buildResult(account, provider, "skipped_api_key", null);
|
|
841
905
|
continue;
|
|
842
906
|
}
|
|
843
907
|
const lastFetch = lastUsageFetchAt.get(account.key) ?? 0;
|
|
844
908
|
if (Date.now() - lastFetch < MIN_USAGE_REFETCH_INTERVAL_MS) {
|
|
845
|
-
results[index] = buildResult(account, "throttled", null);
|
|
909
|
+
results[index] = buildResult(account, provider, "throttled", null);
|
|
846
910
|
continue;
|
|
847
911
|
}
|
|
848
912
|
lastUsageFetchAt.set(account.key, Date.now());
|
|
913
|
+
if (provider === "codex") {
|
|
914
|
+
// Codex has its own usage endpoint and no overage/cooldown
|
|
915
|
+
// reconciliation to run; the snapshot is written under the full key,
|
|
916
|
+
// which is the only key the Codex engine ever reads it back by.
|
|
917
|
+
try {
|
|
918
|
+
const fetched = await fetchCodexAccountUsage(account);
|
|
919
|
+
if (fetched.ok === false) {
|
|
920
|
+
results[index] = buildResult(account, provider, "error", null, `codex usage fetch failed: ${fetched.reason}`);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
await saveAccountQuota(account.key, fetched.quota);
|
|
924
|
+
results[index] = buildResult(account, provider, "refreshed", fetched.quota);
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
results[index] = buildResult(account, provider, "error", null, err instanceof Error ? err.message : String(err));
|
|
928
|
+
}
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
849
931
|
// Isolate failures per account: an unexpected rejection must not abort
|
|
850
932
|
// the Promise.all sweep and turn the whole /limits response into a 502.
|
|
851
933
|
try {
|
|
852
934
|
const refresh = await accountQuotaRefreshCoordinator.run(account, `manual:${account.key}`, fetchValidatedAccountUsage, { force: true });
|
|
853
935
|
if (refresh.kind !== "completed") {
|
|
854
|
-
results[index] = buildResult(account, "throttled", null);
|
|
936
|
+
results[index] = buildResult(account, provider, "throttled", null);
|
|
855
937
|
continue;
|
|
856
938
|
}
|
|
857
939
|
const fetchResult = refresh.result;
|
|
@@ -859,18 +941,18 @@ async function refreshAccountLimits(options = {}) {
|
|
|
859
941
|
// file without strictNullChecks, where negated boolean-discriminant
|
|
860
942
|
// narrowing does not apply.
|
|
861
943
|
if (fetchResult.ok === false) {
|
|
862
|
-
results[index] = buildResult(account, "error", null, fetchResult.error);
|
|
944
|
+
results[index] = buildResult(account, provider, "error", null, fetchResult.error);
|
|
863
945
|
continue;
|
|
864
946
|
}
|
|
865
947
|
const quota = await applyAccountUsageResult(account, fetchResult, refresh.startedAt, persisted[account.key] ?? persisted[account.label] ?? null);
|
|
866
948
|
if (!quota) {
|
|
867
|
-
results[index] = buildResult(account, "error", null, "usage payload had no recognizable limit windows");
|
|
949
|
+
results[index] = buildResult(account, provider, "error", null, "usage payload had no recognizable limit windows");
|
|
868
950
|
continue;
|
|
869
951
|
}
|
|
870
|
-
results[index] = buildResult(account, "refreshed", quota);
|
|
952
|
+
results[index] = buildResult(account, provider, "refreshed", quota);
|
|
871
953
|
}
|
|
872
954
|
catch (err) {
|
|
873
|
-
results[index] = buildResult(account, "error", null, err instanceof Error ? err.message : String(err));
|
|
955
|
+
results[index] = buildResult(account, provider, "error", null, err instanceof Error ? err.message : String(err));
|
|
874
956
|
}
|
|
875
957
|
}
|
|
876
958
|
};
|
|
@@ -5768,6 +5850,9 @@ function createAnthropicAttemptLogger(args) {
|
|
|
5768
5850
|
? { cacheReadTokens: extra.cacheReadTokens }
|
|
5769
5851
|
: {}),
|
|
5770
5852
|
...(extra?.retryable !== undefined ? { retryable: extra.retryable } : {}),
|
|
5853
|
+
...(extra?.connectPhase !== undefined
|
|
5854
|
+
? { connectPhase: extra.connectPhase }
|
|
5855
|
+
: {}),
|
|
5771
5856
|
...(extra?.rateLimitKind ? { rateLimitKind: extra.rateLimitKind } : {}),
|
|
5772
5857
|
...(extra?.cooldownReason
|
|
5773
5858
|
? { cooldownReason: extra.cooldownReason }
|
|
@@ -5971,6 +6056,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
5971
6056
|
}
|
|
5972
6057
|
catch (fetchErr) {
|
|
5973
6058
|
const retryable = isRetryableNetworkError(fetchErr);
|
|
6059
|
+
const connectPhase = isConnectPhaseNetworkError(fetchErr);
|
|
5974
6060
|
const transportScope = classifyNetworkTransportScope(fetchErr);
|
|
5975
6061
|
// Every dispatched upstream request is an attempt, including terminal
|
|
5976
6062
|
// transport failures. Record it once before preserving the throw behavior.
|
|
@@ -5982,6 +6068,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
5982
6068
|
logger.always(`[proxy] fetch error account=${account.label} code=${errorCode} (${retryable ? "retryable" : "terminal"}): ${errorMessage}`);
|
|
5983
6069
|
logAttempt(502, "network_error", errorMessage, {
|
|
5984
6070
|
retryable,
|
|
6071
|
+
connectPhase,
|
|
5985
6072
|
errorCode,
|
|
5986
6073
|
transportScope,
|
|
5987
6074
|
});
|
|
@@ -5998,6 +6085,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
5998
6085
|
retrySameAccount: true,
|
|
5999
6086
|
transportScope,
|
|
6000
6087
|
errorCode,
|
|
6088
|
+
connectPhase,
|
|
6001
6089
|
lastError,
|
|
6002
6090
|
sawRateLimit,
|
|
6003
6091
|
sawNetworkError,
|
|
@@ -6267,6 +6355,20 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
6267
6355
|
transportPermit.errorCode ?? undefined;
|
|
6268
6356
|
loopState.lastTransportScope = transportPermit.transportScope;
|
|
6269
6357
|
loopState.lastError = `Anthropic transport recovery probe failed (${transportPermit.errorCode ?? "unknown"})`;
|
|
6358
|
+
// The probe that failed was another request's attempt; this one has
|
|
6359
|
+
// sent nothing. Give it the same bounded same-account budget a direct
|
|
6360
|
+
// transport failure gets, instead of failing every queued request the
|
|
6361
|
+
// moment one probe times out.
|
|
6362
|
+
const probeRetryBudget = transportPermit.connectPhase
|
|
6363
|
+
? MAX_CONNECT_PHASE_SAME_ACCOUNT_RETRIES
|
|
6364
|
+
: MAX_TRANSIENT_SAME_ACCOUNT_RETRIES;
|
|
6365
|
+
if (transientSameAccountRetries < probeRetryBudget) {
|
|
6366
|
+
transientSameAccountRetries += 1;
|
|
6367
|
+
const delayMs = getTransientSameAccountRetryDelayMs(transientSameAccountRetries);
|
|
6368
|
+
logger.always(`[proxy] retrying same account=${account.label} after failed transport recovery probe (${transientSameAccountRetries}/${probeRetryBudget}) in ${delayMs}ms`);
|
|
6369
|
+
await sleep(delayMs);
|
|
6370
|
+
continue;
|
|
6371
|
+
}
|
|
6270
6372
|
break accountLoop;
|
|
6271
6373
|
}
|
|
6272
6374
|
loopState.attemptNumber += 1;
|
|
@@ -6368,7 +6470,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
6368
6470
|
throw error;
|
|
6369
6471
|
}
|
|
6370
6472
|
if (fetchResult.transportScope) {
|
|
6371
|
-
providerTransportCoordinator.reportTransportFailure(fetchResult.errorCode, fetchResult.transportScope, transportPermit);
|
|
6473
|
+
providerTransportCoordinator.reportTransportFailure(fetchResult.errorCode, fetchResult.transportScope, transportPermit, fetchResult.connectPhase === true);
|
|
6372
6474
|
}
|
|
6373
6475
|
else {
|
|
6374
6476
|
providerTransportCoordinator.reportSuccess(transportPermit);
|
|
@@ -6445,12 +6547,16 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
6445
6547
|
}
|
|
6446
6548
|
continue accountLoop;
|
|
6447
6549
|
}
|
|
6448
|
-
// Transient error retry (network errors, 529 overloaded)
|
|
6550
|
+
// Transient error retry (network errors, 529 overloaded). A failure
|
|
6551
|
+
// from the connect phase sent nothing, so it earns the larger budget.
|
|
6552
|
+
const sameAccountRetryBudget = fetchResult.connectPhase
|
|
6553
|
+
? MAX_CONNECT_PHASE_SAME_ACCOUNT_RETRIES
|
|
6554
|
+
: MAX_TRANSIENT_SAME_ACCOUNT_RETRIES;
|
|
6449
6555
|
if (fetchResult.retrySameAccount &&
|
|
6450
|
-
transientSameAccountRetries <
|
|
6556
|
+
transientSameAccountRetries < sameAccountRetryBudget) {
|
|
6451
6557
|
transientSameAccountRetries += 1;
|
|
6452
6558
|
const delayMs = getTransientSameAccountRetryDelayMs(transientSameAccountRetries);
|
|
6453
|
-
logger.always(`[proxy] retrying same account=${account.label} after transient network error (${transientSameAccountRetries}/${
|
|
6559
|
+
logger.always(`[proxy] retrying same account=${account.label} after transient network error (${transientSameAccountRetries}/${sameAccountRetryBudget}) in ${delayMs}ms`);
|
|
6454
6560
|
await sleep(delayMs);
|
|
6455
6561
|
continue;
|
|
6456
6562
|
}
|
|
@@ -6769,11 +6875,6 @@ function buildEarlyClaudeRequestError(args) {
|
|
|
6769
6875
|
* millisecond fields tens of thousands of years into the future, so only the
|
|
6770
6876
|
* seconds fields are converted, and 0 becomes null rather than epoch zero.
|
|
6771
6877
|
*/
|
|
6772
|
-
/**
|
|
6773
|
-
* Account types that represent a real credential rather than proxy plumbing.
|
|
6774
|
-
* Mirrors the Anthropic pool's own account types.
|
|
6775
|
-
*/
|
|
6776
|
-
const REAL_ACCOUNT_TYPES = new Set(["oauth", "api_key"]);
|
|
6777
6878
|
function toMillis(value) {
|
|
6778
6879
|
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
6779
6880
|
? Math.round(value * 1000)
|
|
@@ -7498,9 +7599,31 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
7498
7599
|
// Usage is the optional half; quota and status must still render.
|
|
7499
7600
|
usageError = error instanceof Error ? error.message : String(error);
|
|
7500
7601
|
}
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
|
|
7602
|
+
// Joined by provider-qualified KEY, never by label. One email can
|
|
7603
|
+
// be logged in to both engines; joined by label, the Codex login was
|
|
7604
|
+
// swallowed by the Anthropic row (and its counters could land on
|
|
7605
|
+
// that row, last write winning). Legacy stats entries that predate
|
|
7606
|
+
// the key carry only a label and a type, which is enough to derive
|
|
7607
|
+
// it; a keyed entry for the same login wins over a legacy one.
|
|
7608
|
+
const statsByKey = new Map();
|
|
7609
|
+
for (const [mapKey, entry] of Object.entries(statsAccounts)) {
|
|
7610
|
+
const identity = resolveProxyStatusAccountIdentity(entry.label, entry.type, entry.key ?? mapKey);
|
|
7611
|
+
const identityKey = identity.key ?? mapKey;
|
|
7612
|
+
const existing = statsByKey.get(identityKey);
|
|
7613
|
+
if (!existing || (entry.key && !existing.key)) {
|
|
7614
|
+
statsByKey.set(identityKey, { ...entry, identityKey });
|
|
7615
|
+
}
|
|
7616
|
+
}
|
|
7617
|
+
// Which logins exist at all, disabled ones included. A stats entry
|
|
7618
|
+
// with no login behind it is a REMOVED account, not an unrouted one.
|
|
7619
|
+
// If the store cannot be read, err towards showing rows: hiding a
|
|
7620
|
+
// real login is the worse mistake, and the old behaviour.
|
|
7621
|
+
let knownKeys;
|
|
7622
|
+
try {
|
|
7623
|
+
knownKeys = await listKnownAccountKeys();
|
|
7624
|
+
}
|
|
7625
|
+
catch {
|
|
7626
|
+
knownKeys = null;
|
|
7504
7627
|
}
|
|
7505
7628
|
const rows = [];
|
|
7506
7629
|
const claimed = new Set();
|
|
@@ -7509,13 +7632,14 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
7509
7632
|
// today, which is exactly when an operator most wants to see it.
|
|
7510
7633
|
for (const result of limits.results) {
|
|
7511
7634
|
const label = result.account;
|
|
7512
|
-
claimed.add(
|
|
7513
|
-
const stat =
|
|
7635
|
+
claimed.add(result.key);
|
|
7636
|
+
const stat = statsByKey.get(result.key);
|
|
7514
7637
|
const quota = normalizeQuotaForAccounts(result.quota);
|
|
7515
7638
|
const cooling = isCooling(result.key ?? null);
|
|
7516
7639
|
rows.push({
|
|
7517
7640
|
label,
|
|
7518
7641
|
key: result.key ?? null,
|
|
7642
|
+
provider: result.provider,
|
|
7519
7643
|
kind: "account",
|
|
7520
7644
|
type: result.type ?? stat?.type ?? "oauth",
|
|
7521
7645
|
// result.status describes how the quota was obtained
|
|
@@ -7549,39 +7673,53 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
7549
7673
|
weeklyHealth: quotaHealth(quota.weeklyStatus),
|
|
7550
7674
|
}
|
|
7551
7675
|
: null,
|
|
7552
|
-
usage: usageByAccount.get(
|
|
7676
|
+
usage: usageByAccount.get(result.key) ?? null,
|
|
7553
7677
|
});
|
|
7554
7678
|
}
|
|
7555
7679
|
// Plumbing rows are still reported, but tagged, so a consumer can
|
|
7556
7680
|
// show or hide them rather than rendering them as credentials.
|
|
7557
7681
|
//
|
|
7558
|
-
// A real login can land here too:
|
|
7559
|
-
//
|
|
7560
|
-
//
|
|
7561
|
-
//
|
|
7562
|
-
//
|
|
7563
|
-
//
|
|
7564
|
-
//
|
|
7565
|
-
//
|
|
7566
|
-
|
|
7567
|
-
|
|
7682
|
+
// A real login can land here too: the listers skip accounts the
|
|
7683
|
+
// token store has disabled or the allowlist excludes, so a login
|
|
7684
|
+
// with real usage history but no current route is absent from
|
|
7685
|
+
// limits.results. Tagging that as plumbing hid the one account an
|
|
7686
|
+
// operator is looking for when they ask why traffic stopped. It
|
|
7687
|
+
// stays kind "account", with a status saying why it has no quota
|
|
7688
|
+
// block — but only while the login still EXISTS. A stats entry whose
|
|
7689
|
+
// login has been removed from the store is history, not a
|
|
7690
|
+
// credential, and rendering it as "unrouted" put a phantom account
|
|
7691
|
+
// on every dashboard for as long as the counters file lived.
|
|
7692
|
+
for (const entry of statsByKey.values()) {
|
|
7693
|
+
const identity = resolveProxyStatusAccountIdentity(entry.label, entry.type, entry.identityKey);
|
|
7694
|
+
if (identity.key !== null && claimed.has(identity.key)) {
|
|
7695
|
+
continue;
|
|
7696
|
+
}
|
|
7697
|
+
const isLogin = identity.provider !== "other";
|
|
7698
|
+
if (isLogin &&
|
|
7699
|
+
identity.key !== null &&
|
|
7700
|
+
knownKeys !== null &&
|
|
7701
|
+
!knownKeys.has(identity.key)) {
|
|
7568
7702
|
continue;
|
|
7569
7703
|
}
|
|
7570
|
-
const isRealAccount = REAL_ACCOUNT_TYPES.has(entry.type);
|
|
7571
7704
|
rows.push({
|
|
7572
7705
|
label: entry.label,
|
|
7573
|
-
key: null,
|
|
7574
|
-
|
|
7706
|
+
key: isLogin ? identity.key : null,
|
|
7707
|
+
...(isLogin ? { provider: identity.provider } : {}),
|
|
7708
|
+
kind: isLogin
|
|
7575
7709
|
? "account"
|
|
7576
7710
|
: entry.type === "translation"
|
|
7577
7711
|
? "translation"
|
|
7578
7712
|
: "internal",
|
|
7579
|
-
type
|
|
7580
|
-
|
|
7713
|
+
// The row's type is the credential kind; the engine is
|
|
7714
|
+
// `provider`. Stats record Codex logins as "codex-oauth", which
|
|
7715
|
+
// consumers that only know the credential kinds read as
|
|
7716
|
+
// plumbing, so it is reported as the OAuth login it is.
|
|
7717
|
+
type: identity.provider === "codex" ? "oauth" : entry.type,
|
|
7718
|
+
status: isLogin ? "unrouted" : null,
|
|
7581
7719
|
cooling: false,
|
|
7582
7720
|
allowed: null,
|
|
7583
7721
|
expired: null,
|
|
7584
|
-
isPrimary: isPrimaryAccount(
|
|
7722
|
+
isPrimary: isLogin ? isPrimaryAccount(identity.key) : false,
|
|
7585
7723
|
requests: entry.successCount + entry.errorCount,
|
|
7586
7724
|
errors: entry.errorCount,
|
|
7587
7725
|
rateLimits: entry.rateLimitCount,
|
|
@@ -7593,7 +7731,9 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
7593
7731
|
// and cost in the ledger — hardcoding null here discarded
|
|
7594
7732
|
// exactly the usage an operator is looking for when they ask
|
|
7595
7733
|
// why traffic stopped.
|
|
7596
|
-
usage:
|
|
7734
|
+
usage: identity.key !== null
|
|
7735
|
+
? (usageByAccount.get(identity.key) ?? null)
|
|
7736
|
+
: null,
|
|
7597
7737
|
});
|
|
7598
7738
|
}
|
|
7599
7739
|
const response = {
|
|
@@ -7776,12 +7916,46 @@ function describeTransportError(error) {
|
|
|
7776
7916
|
.join(": ");
|
|
7777
7917
|
return detail ? `${message} (${detail})` : message;
|
|
7778
7918
|
}
|
|
7919
|
+
/**
|
|
7920
|
+
* Whether a transport failure provably happened while connecting, before any
|
|
7921
|
+
* byte of the request left the process.
|
|
7922
|
+
*
|
|
7923
|
+
* Node tags its connect errors with `syscall: "connect"`; with happy-eyeballs
|
|
7924
|
+
* enabled (the default) the error is an AggregateError whose every entry is
|
|
7925
|
+
* one such attempt, and undici wraps either as `fetch failed` with the
|
|
7926
|
+
* original as `cause`. The code alone cannot make this call: an `ETIMEDOUT`
|
|
7927
|
+
* from the connect timer and an `ETIMEDOUT` from a socket that went quiet
|
|
7928
|
+
* after dispatch look identical by code, and only the first is safe to
|
|
7929
|
+
* retry. Both proxies behind one lossy Wi-Fi uplink returned 116 terminal
|
|
7930
|
+
* 502s in a day on exactly that ambiguity.
|
|
7931
|
+
*/
|
|
7932
|
+
function isConnectPhaseNetworkError(error) {
|
|
7933
|
+
const seen = new Set();
|
|
7934
|
+
let current = error;
|
|
7935
|
+
while (current && typeof current === "object" && !seen.has(current)) {
|
|
7936
|
+
seen.add(current);
|
|
7937
|
+
const candidate = current;
|
|
7938
|
+
if (candidate.syscall === "connect") {
|
|
7939
|
+
return true;
|
|
7940
|
+
}
|
|
7941
|
+
if (Array.isArray(candidate.errors) && candidate.errors.length > 0) {
|
|
7942
|
+
return candidate.errors.every((entry) => entry !== null &&
|
|
7943
|
+
typeof entry === "object" &&
|
|
7944
|
+
entry.syscall === "connect");
|
|
7945
|
+
}
|
|
7946
|
+
current = candidate.cause;
|
|
7947
|
+
}
|
|
7948
|
+
return false;
|
|
7949
|
+
}
|
|
7779
7950
|
/**
|
|
7780
7951
|
* Determine whether a POST can be retried without risking duplicate provider
|
|
7781
7952
|
* work. Only failures that prove connection establishment did not complete are
|
|
7782
7953
|
* safe; a reset, socket error, or response timeout can happen after dispatch.
|
|
7783
7954
|
*/
|
|
7784
7955
|
function isRetryableNetworkError(error) {
|
|
7956
|
+
if (isConnectPhaseNetworkError(error)) {
|
|
7957
|
+
return true;
|
|
7958
|
+
}
|
|
7785
7959
|
const code = getErrorCode(error);
|
|
7786
7960
|
return (code !== undefined &&
|
|
7787
7961
|
[
|
|
@@ -8001,7 +8175,15 @@ export const __testHooks = {
|
|
|
8001
8175
|
limitsRefreshInFlight = null;
|
|
8002
8176
|
accountQuotaRefreshCoordinator.clear();
|
|
8003
8177
|
},
|
|
8178
|
+
setAccountDirectoryForTests: (override) => {
|
|
8179
|
+
accountDirectoryOverride = override;
|
|
8180
|
+
},
|
|
8004
8181
|
isRetryableNetworkError,
|
|
8182
|
+
isConnectPhaseNetworkError,
|
|
8183
|
+
MAX_CONNECT_PHASE_SAME_ACCOUNT_RETRIES,
|
|
8184
|
+
clearProviderTransportCoordinatorForTests: () => {
|
|
8185
|
+
providerTransportCoordinator.clear();
|
|
8186
|
+
},
|
|
8005
8187
|
isPermanentRefreshFailure,
|
|
8006
8188
|
getStreamFailureDetails,
|
|
8007
8189
|
trackUpstreamReadableStream,
|
package/dist/types/proxy.d.ts
CHANGED
|
@@ -445,6 +445,8 @@ export type ProxyProviderTransportPermit = {
|
|
|
445
445
|
allowed: false;
|
|
446
446
|
errorCode: string | null;
|
|
447
447
|
transportScope: ProxyNetworkTransportScope;
|
|
448
|
+
/** The degrading failure happened before any request byte was sent. */
|
|
449
|
+
connectPhase: boolean;
|
|
448
450
|
};
|
|
449
451
|
export type ProxyAccountRoutingCandidate = {
|
|
450
452
|
account: string;
|
|
@@ -626,6 +628,8 @@ export type RequestAttemptLogEntry = {
|
|
|
626
628
|
transportScope?: ProxyTransportScope;
|
|
627
629
|
/** Whether this failed attempt may be retried without changing the request. */
|
|
628
630
|
retryable?: boolean;
|
|
631
|
+
/** The transport failure happened before any request byte was sent. */
|
|
632
|
+
connectPhase?: boolean;
|
|
629
633
|
/** Distinguishes short-lived admission throttles from exhausted quota windows. */
|
|
630
634
|
rateLimitKind?: "transient" | "quota";
|
|
631
635
|
/** Reset-aware cooldown reason selected for a rate-limited attempt. */
|
|
@@ -701,6 +705,8 @@ export type AnthropicAttemptLogger = (status: number, errorType?: string, errorM
|
|
|
701
705
|
cacheCreationTokens?: number;
|
|
702
706
|
cacheReadTokens?: number;
|
|
703
707
|
retryable?: boolean;
|
|
708
|
+
/** The transport failure happened before any request byte was sent. */
|
|
709
|
+
connectPhase?: boolean;
|
|
704
710
|
/** Low-level transport code such as ETIMEDOUT or EADDRNOTAVAIL. */
|
|
705
711
|
errorCode?: string;
|
|
706
712
|
transportScope?: ProxyTransportScope;
|
|
@@ -874,6 +880,9 @@ export type AnthropicUpstreamFetchResult = {
|
|
|
874
880
|
retrySameAccount?: boolean;
|
|
875
881
|
transportScope?: ProxyNetworkTransportScope;
|
|
876
882
|
errorCode?: string;
|
|
883
|
+
/** The transport failure happened while connecting, before any request
|
|
884
|
+
* byte was sent, so retrying it cannot duplicate provider work. */
|
|
885
|
+
connectPhase?: boolean;
|
|
877
886
|
/** When set, the caller should wait this many ms before retrying (from upstream retry-after). */
|
|
878
887
|
retryAfterMs?: number;
|
|
879
888
|
/** Set on a genuine 429: how long / why to cool this account before rotating. */
|
|
@@ -1209,8 +1218,14 @@ export type ProxyQuotaRefreshRunResult = {
|
|
|
1209
1218
|
export type ProxyLimitsAccountResult = {
|
|
1210
1219
|
/** Account label (quota-store key). */
|
|
1211
1220
|
account: string;
|
|
1212
|
-
/** Token-store key ("anthropic:<label>"). */
|
|
1221
|
+
/** Token-store key ("anthropic:<label>" or "codex:<label>"). */
|
|
1213
1222
|
key: string;
|
|
1223
|
+
/**
|
|
1224
|
+
* Which pool engine owns this login. Two logins can share a label — an
|
|
1225
|
+
* operator may use one email for both — so the key, not the label, is the
|
|
1226
|
+
* identity, and this names the engine without parsing the key's prefix.
|
|
1227
|
+
*/
|
|
1228
|
+
provider: ProxyAccountProvider;
|
|
1214
1229
|
type: ProxyAccountType;
|
|
1215
1230
|
status: "refreshed" | "throttled" | "skipped_api_key" | "snapshot" | "error";
|
|
1216
1231
|
/** Fresh quota on "refreshed"; last known snapshot otherwise (may be null). */
|
|
@@ -1219,6 +1234,20 @@ export type ProxyLimitsAccountResult = {
|
|
|
1219
1234
|
coolingUntil?: number;
|
|
1220
1235
|
coolingReason?: AccountCoolingReason;
|
|
1221
1236
|
};
|
|
1237
|
+
/** The pool engine a login belongs to, as named on limits and accounts rows. */
|
|
1238
|
+
export type ProxyAccountProvider = "anthropic" | "codex";
|
|
1239
|
+
/**
|
|
1240
|
+
* Test-only replacement for the token store behind the account-exposing
|
|
1241
|
+
* routes. The token store is a module singleton bound to the real home at
|
|
1242
|
+
* import, so a suite cannot redirect it; this lets a case state which logins
|
|
1243
|
+
* exist (`knownKeys`, including disabled ones) and which are routable per
|
|
1244
|
+
* engine, exactly as the real listers would answer.
|
|
1245
|
+
*/
|
|
1246
|
+
export type ProxyAccountDirectoryOverride = {
|
|
1247
|
+
knownKeys: Set<string>;
|
|
1248
|
+
anthropic: ProxyPassthroughAccount[];
|
|
1249
|
+
codex: ProxyPassthroughAccount[];
|
|
1250
|
+
};
|
|
1222
1251
|
/** Response body of the proxy's GET /limits endpoint. */
|
|
1223
1252
|
export type ProxyLimitsRefreshResponse = {
|
|
1224
1253
|
fetchedAt: number;
|
|
@@ -141,10 +141,21 @@ export type CliClientUsageTotals = {
|
|
|
141
141
|
};
|
|
142
142
|
/** One row of GET /accounts. */
|
|
143
143
|
export type CliAccountsRow = {
|
|
144
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Bare label, e.g. "someone@example.com". Display only: two rows can share
|
|
146
|
+
* it when one email is logged in to both engines. `key` is the identity.
|
|
147
|
+
*/
|
|
145
148
|
label: string;
|
|
146
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Full pool key, e.g. "anthropic:someone@example.com" or
|
|
151
|
+
* "codex:someone@example.com". Null only for plumbing rows.
|
|
152
|
+
*/
|
|
147
153
|
key: string | null;
|
|
154
|
+
/**
|
|
155
|
+
* Which pool engine owns this login. Absent on plumbing rows. Consumers
|
|
156
|
+
* that key a list by row must key by `key`, not `label` — see above.
|
|
157
|
+
*/
|
|
158
|
+
provider?: "anthropic" | "codex";
|
|
148
159
|
/**
|
|
149
160
|
* What this row actually is. Only "account" rows are real logins; the proxy
|
|
150
161
|
* also tracks internal and translation pseudo-accounts, which have no quota
|
|
@@ -183,6 +194,13 @@ export type CliAccountsResponse = {
|
|
|
183
194
|
/** One request as recorded in the proxy request log, reduced to what costing needs. */
|
|
184
195
|
export type ProxyLedgerEntry = {
|
|
185
196
|
account: string;
|
|
197
|
+
/**
|
|
198
|
+
* Provider-qualified identity, "anthropic:<label>" or "codex:<label>".
|
|
199
|
+
* Read from the log row when present; derived from `accountType` for rows
|
|
200
|
+
* written before the pool logged it. This, not `account`, is the join key:
|
|
201
|
+
* one email can be logged in to both engines.
|
|
202
|
+
*/
|
|
203
|
+
accountKey: string;
|
|
186
204
|
/** Derived calling CLI; see CliAccountUsageTotals.byClient. */
|
|
187
205
|
clientApp: string;
|
|
188
206
|
accountType: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.9.
|
|
3
|
+
"version": "12.9.4",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|
|
@@ -138,6 +138,7 @@
|
|
|
138
138
|
"test:local-usage": "pnpm exec tsx test/continuous-test-suite-local-usage.ts",
|
|
139
139
|
"test:dynamic": "pnpm exec tsx test/continuous-test-suite-dynamic.ts",
|
|
140
140
|
"test:proxy": "pnpm exec tsx test/continuous-test-suite-proxy.ts",
|
|
141
|
+
"test:proxy-connect-retry": "pnpm exec tsx test/continuous-test-suite-proxy-connect-retry.ts",
|
|
141
142
|
"test:codex": "pnpm exec tsx test/continuous-test-suite-codex.ts",
|
|
142
143
|
"test:bugfixes": "pnpm exec tsx test/continuous-test-suite-bugfixes.ts",
|
|
143
144
|
"test:json-e2e": "pnpm exec tsx test/continuous-test-suite-json-e2e.ts",
|