@juspay/neurolink 9.86.3 → 9.86.5
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/tokenStore.d.ts +6 -0
- package/dist/auth/tokenStore.js +36 -4
- package/dist/browser/neurolink.min.js +417 -417
- package/dist/cli/commands/auth.js +3 -0
- package/dist/cli/commands/proxy.js +310 -107
- package/dist/core/modules/GenerationHandler.js +19 -1
- package/dist/lib/auth/tokenStore.d.ts +6 -0
- package/dist/lib/auth/tokenStore.js +36 -4
- package/dist/lib/core/modules/GenerationHandler.js +19 -1
- package/dist/lib/neurolink.js +9 -1
- package/dist/lib/proxy/accountCooldown.d.ts +5 -0
- package/dist/lib/proxy/accountCooldown.js +93 -0
- package/dist/lib/proxy/accountQuota.d.ts +3 -4
- package/dist/lib/proxy/accountQuota.js +75 -44
- package/dist/lib/proxy/accountSelection.d.ts +8 -0
- package/dist/lib/proxy/accountSelection.js +26 -0
- package/dist/lib/proxy/proxyConfig.js +24 -0
- package/dist/lib/proxy/proxyPaths.js +2 -0
- package/dist/lib/proxy/requestLogger.js +2 -0
- package/dist/lib/proxy/snapshotPersistence.js +1 -1
- package/dist/lib/proxy/sseInterceptor.js +16 -0
- package/dist/lib/proxy/streamOutcome.d.ts +3 -0
- package/dist/lib/proxy/streamOutcome.js +27 -0
- package/dist/lib/proxy/tokenRefresh.d.ts +8 -0
- package/dist/lib/proxy/tokenRefresh.js +92 -15
- package/dist/lib/proxy/updateState.js +17 -4
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +18 -3
- package/dist/lib/server/routes/claudeProxyRoutes.js +624 -223
- package/dist/lib/types/auth.d.ts +1 -1
- package/dist/lib/types/cli.d.ts +2 -0
- package/dist/lib/types/proxy.d.ts +49 -8
- package/dist/lib/types/subscription.d.ts +5 -0
- package/dist/neurolink.js +9 -1
- package/dist/proxy/accountCooldown.d.ts +5 -0
- package/dist/proxy/accountCooldown.js +92 -0
- package/dist/proxy/accountQuota.d.ts +3 -4
- package/dist/proxy/accountQuota.js +75 -44
- package/dist/proxy/accountSelection.d.ts +8 -0
- package/dist/proxy/accountSelection.js +25 -0
- package/dist/proxy/proxyConfig.js +24 -0
- package/dist/proxy/proxyPaths.js +2 -0
- package/dist/proxy/requestLogger.js +2 -0
- package/dist/proxy/snapshotPersistence.js +1 -1
- package/dist/proxy/sseInterceptor.js +16 -0
- package/dist/proxy/streamOutcome.d.ts +3 -0
- package/dist/proxy/streamOutcome.js +26 -0
- package/dist/proxy/tokenRefresh.d.ts +8 -0
- package/dist/proxy/tokenRefresh.js +92 -15
- package/dist/proxy/updateState.js +17 -4
- package/dist/server/routes/claudeProxyRoutes.d.ts +18 -3
- package/dist/server/routes/claudeProxyRoutes.js +624 -223
- package/dist/types/auth.d.ts +1 -1
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/proxy.d.ts +49 -8
- package/dist/types/subscription.d.ts +5 -0
- package/package.json +1 -1
package/dist/lib/types/auth.d.ts
CHANGED
|
@@ -69,7 +69,7 @@ export type StoredProviderTokens = {
|
|
|
69
69
|
disabled?: boolean;
|
|
70
70
|
/** When the tokens were disabled (Unix ms) */
|
|
71
71
|
disabledAt?: number;
|
|
72
|
-
/** Reason the tokens were disabled (e.g., "
|
|
72
|
+
/** Reason the tokens were disabled (e.g., "refresh_invalid") */
|
|
73
73
|
disabledReason?: string;
|
|
74
74
|
};
|
|
75
75
|
/**
|
package/dist/lib/types/cli.d.ts
CHANGED
|
@@ -825,6 +825,8 @@ export type ProxyState = {
|
|
|
825
825
|
envFile?: string;
|
|
826
826
|
/** Fallback chain from proxy config (persisted at start time) */
|
|
827
827
|
fallbackChain?: FallbackInfo[];
|
|
828
|
+
/** Normalized Anthropic account keys allowed for this proxy process. */
|
|
829
|
+
accountAllowlist?: string[];
|
|
828
830
|
/** Optional fail-open guard PID that reverts Claude settings if proxy dies */
|
|
829
831
|
guardPid?: number;
|
|
830
832
|
/** How the proxy was launched — "launchd" if installed as service, "manual" otherwise */
|
|
@@ -517,6 +517,7 @@ export type AnthropicLoopState = {
|
|
|
517
517
|
contentType?: string;
|
|
518
518
|
} | null;
|
|
519
519
|
authFailureMessage: string | null;
|
|
520
|
+
authCooldownMessage: string | null;
|
|
520
521
|
attemptNumber: number;
|
|
521
522
|
};
|
|
522
523
|
export type AnthropicUpstreamBody = {
|
|
@@ -580,9 +581,9 @@ export type AccountCooldownPlan = {
|
|
|
580
581
|
reason: AccountCoolingReason;
|
|
581
582
|
/** Epoch-ms until which the account should not be used. */
|
|
582
583
|
coolingUntil: number;
|
|
583
|
-
/** When true (5h/7d
|
|
584
|
-
* account is futile until its window resets. When false (transient
|
|
585
|
-
* a small number of jittered same-account retries is allowed first. */
|
|
584
|
+
/** When true (unified/5h/7d rejected), rotate immediately — retrying the
|
|
585
|
+
* same account is futile until its window resets. When false (transient
|
|
586
|
+
* burst), a small number of jittered same-account retries is allowed first. */
|
|
586
587
|
rotateImmediately: boolean;
|
|
587
588
|
};
|
|
588
589
|
export type AnthropicUpstreamFetchResult = {
|
|
@@ -630,12 +631,22 @@ export type RefreshResult = {
|
|
|
630
631
|
error?: string;
|
|
631
632
|
status?: number;
|
|
632
633
|
};
|
|
634
|
+
/** Result shared by callers waiting on the same rotating refresh token. */
|
|
635
|
+
export type SharedRefreshResult = {
|
|
636
|
+
result: RefreshResult;
|
|
637
|
+
token: string;
|
|
638
|
+
refreshToken?: string;
|
|
639
|
+
expiresAt?: number;
|
|
640
|
+
};
|
|
633
641
|
export type TokenPersistTarget = string | {
|
|
634
642
|
credPath: string;
|
|
635
643
|
} | {
|
|
636
644
|
providerKey: string;
|
|
637
645
|
};
|
|
638
646
|
export type AccountQuota = {
|
|
647
|
+
/** Top-level unified status. A rejected value can be authoritative even
|
|
648
|
+
* while both 5h and 7d sub-window statuses still report allowed. */
|
|
649
|
+
unifiedStatus?: string;
|
|
639
650
|
/** 0.0-1.0 (from unified-5h-utilization) */
|
|
640
651
|
sessionUsed: number;
|
|
641
652
|
/** "allowed" | "throttled" | "rejected" */
|
|
@@ -658,9 +669,18 @@ export type AccountQuota = {
|
|
|
658
669
|
/** Why an account is currently cooling. Drives cooldown duration and logging.
|
|
659
670
|
* - "weekly" : 7d unified limit rejected — cool until the weekly reset.
|
|
660
671
|
* - "session" : 5h unified limit rejected — cool until the session reset.
|
|
672
|
+
* - "unified" : top-level unified limit rejected — cool for retry-after.
|
|
661
673
|
* - "transient" : short per-minute/burst 429 — cool for retry-after only.
|
|
662
|
-
* - "auth" :
|
|
663
|
-
export type AccountCoolingReason = "weekly" | "session" | "transient" | "auth";
|
|
674
|
+
* - "auth" : transient refresh failure with bounded backoff. */
|
|
675
|
+
export type AccountCoolingReason = "weekly" | "session" | "unified" | "transient" | "auth";
|
|
676
|
+
/** Restart-safe cooldown snapshot for one account. */
|
|
677
|
+
export type PersistedAccountCooldown = {
|
|
678
|
+
coolingUntil: number;
|
|
679
|
+
reason: AccountCoolingReason;
|
|
680
|
+
updatedAt: number;
|
|
681
|
+
};
|
|
682
|
+
/** Normalized Anthropic account keys eligible for proxy routing. */
|
|
683
|
+
export type AccountAllowlist = ReadonlySet<string>;
|
|
664
684
|
/** Runtime state for a proxy account. */
|
|
665
685
|
export type RuntimeAccountState = {
|
|
666
686
|
consecutiveRefreshFailures: number;
|
|
@@ -668,9 +688,9 @@ export type RuntimeAccountState = {
|
|
|
668
688
|
lastToken?: string;
|
|
669
689
|
lastRefreshToken?: string;
|
|
670
690
|
/** Epoch-ms timestamp until which the account should not be used for new
|
|
671
|
-
* requests. Set from the
|
|
672
|
-
*
|
|
673
|
-
*
|
|
691
|
+
* requests. Set from the actual Anthropic reset/retry window or from
|
|
692
|
+
* bounded refresh backoff. Other requests arriving during this window skip
|
|
693
|
+
* the account rather than hammering it. */
|
|
674
694
|
coolingUntil?: number;
|
|
675
695
|
/** Why the account is cooling (set alongside coolingUntil). */
|
|
676
696
|
coolingReason?: AccountCoolingReason;
|
|
@@ -769,6 +789,8 @@ export type ProxyPaths = {
|
|
|
769
789
|
logsDir: string;
|
|
770
790
|
/** account-quotas.json — per-account rate limit state */
|
|
771
791
|
quotaFile: string;
|
|
792
|
+
/** account-cooldowns.json — restart-safe account cooldown state */
|
|
793
|
+
cooldownFile: string;
|
|
772
794
|
/** Whether this is a dev-mode isolated instance */
|
|
773
795
|
isDev: boolean;
|
|
774
796
|
};
|
|
@@ -958,8 +980,26 @@ export type SSETelemetry = {
|
|
|
958
980
|
timestamp: number;
|
|
959
981
|
data: string;
|
|
960
982
|
}>;
|
|
983
|
+
/** Error carried as a terminal SSE `event: error`, if one was observed. */
|
|
984
|
+
streamErrorMessage?: string;
|
|
961
985
|
rawText?: string;
|
|
962
986
|
};
|
|
987
|
+
/** Terminal outcome of a response body after the HTTP headers were sent. */
|
|
988
|
+
export type StreamTerminalOutcome = {
|
|
989
|
+
kind: "completed";
|
|
990
|
+
} | {
|
|
991
|
+
kind: "upstream_error";
|
|
992
|
+
message: string;
|
|
993
|
+
} | {
|
|
994
|
+
kind: "client_cancelled";
|
|
995
|
+
};
|
|
996
|
+
/** First-writer-wins tracker for an upstream streaming response. */
|
|
997
|
+
export type StreamTerminalOutcomeTracker = {
|
|
998
|
+
outcome: Promise<StreamTerminalOutcome>;
|
|
999
|
+
complete: () => void;
|
|
1000
|
+
fail: (message: string) => void;
|
|
1001
|
+
cancel: () => void;
|
|
1002
|
+
};
|
|
963
1003
|
/** Mutable accumulator the SSE interceptor uses internally. */
|
|
964
1004
|
export type TelemetryAccumulator = {
|
|
965
1005
|
messageId: string;
|
|
@@ -984,6 +1024,7 @@ export type TelemetryAccumulator = {
|
|
|
984
1024
|
rawTextBytes: number;
|
|
985
1025
|
rawTextTruncated: boolean;
|
|
986
1026
|
eventLogTruncated: boolean;
|
|
1027
|
+
streamErrorMessage?: string;
|
|
987
1028
|
};
|
|
988
1029
|
/** Result of createSSEInterceptor: the pass-through stream and a telemetry promise. */
|
|
989
1030
|
export type SSEInterceptorResult = {
|
|
@@ -914,6 +914,11 @@ export type ProxyRoutingConfig = {
|
|
|
914
914
|
* Resolved per-request to a stable key (anthropic:<email>); does not
|
|
915
915
|
* encode an index. */
|
|
916
916
|
primaryAccount?: string;
|
|
917
|
+
/** Anthropic account emails/labels that may be loaded by the proxy. When
|
|
918
|
+
* present, every token-store, legacy, and environment credential outside
|
|
919
|
+
* this set is excluded before refresh or routing. An empty list denies all
|
|
920
|
+
* stored credentials. */
|
|
921
|
+
accountAllowlist?: string[];
|
|
917
922
|
};
|
|
918
923
|
/** Cloaking plugin config */
|
|
919
924
|
export type CloakingConfig = {
|
package/dist/neurolink.js
CHANGED
|
@@ -3858,7 +3858,15 @@ Current user's request: ${currentInput}`;
|
|
|
3858
3858
|
else {
|
|
3859
3859
|
try {
|
|
3860
3860
|
const scalar = JSON.parse(textResult.content);
|
|
3861
|
-
if (scalar
|
|
3861
|
+
if (scalar === "") {
|
|
3862
|
+
// A JSON-encoded empty string is an EMPTY completion, not a
|
|
3863
|
+
// recovered scalar — normalize to a true empty so callers'
|
|
3864
|
+
// empty-response handling fires instead of a literal '""'
|
|
3865
|
+
// reaching the user. `structuredData` stays undefined.
|
|
3866
|
+
textResult.content = "";
|
|
3867
|
+
logger.warn("[NeuroLink] schema requested but the model returned an empty JSON string; normalizing to empty content", { provider: textResult.provider, model: textResult.model });
|
|
3868
|
+
}
|
|
3869
|
+
else if (scalar !== null && scalar !== undefined) {
|
|
3862
3870
|
textResult.structuredData = scalar;
|
|
3863
3871
|
}
|
|
3864
3872
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AccountCoolingReason, PersistedAccountCooldown } from "../types/index.js";
|
|
2
|
+
export declare function initAccountCooldown(cooldownFilePath: string): void;
|
|
3
|
+
export declare function loadAccountCooldowns(): Promise<Record<string, PersistedAccountCooldown>>;
|
|
4
|
+
export declare function saveAccountCooldown(accountKey: string, coolingUntil: number, reason: AccountCoolingReason): Promise<void>;
|
|
5
|
+
export declare function clearAccountCooldown(accountKey: string, expectedCoolingUntil?: number): Promise<void>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { AsyncMutex } from "../utils/asyncMutex.js";
|
|
5
|
+
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
|
|
6
|
+
const COOLDOWN_FILE = "account-cooldowns.json";
|
|
7
|
+
const VALID_REASONS = new Set([
|
|
8
|
+
"weekly",
|
|
9
|
+
"session",
|
|
10
|
+
"unified",
|
|
11
|
+
"transient",
|
|
12
|
+
"auth",
|
|
13
|
+
]);
|
|
14
|
+
let customCooldownFilePath = null;
|
|
15
|
+
let cacheLoaded = false;
|
|
16
|
+
let cacheLoadPromise = null;
|
|
17
|
+
let memoryCache = {};
|
|
18
|
+
const mutationMutex = new AsyncMutex();
|
|
19
|
+
export function initAccountCooldown(cooldownFilePath) {
|
|
20
|
+
customCooldownFilePath = cooldownFilePath;
|
|
21
|
+
cacheLoaded = false;
|
|
22
|
+
cacheLoadPromise = null;
|
|
23
|
+
memoryCache = {};
|
|
24
|
+
}
|
|
25
|
+
function getCooldownFilePath() {
|
|
26
|
+
return customCooldownFilePath ?? join(homedir(), ".neurolink", COOLDOWN_FILE);
|
|
27
|
+
}
|
|
28
|
+
function isPersistedCooldown(value) {
|
|
29
|
+
if (!value || typeof value !== "object") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const candidate = value;
|
|
33
|
+
return (typeof candidate.coolingUntil === "number" &&
|
|
34
|
+
Number.isFinite(candidate.coolingUntil) &&
|
|
35
|
+
typeof candidate.updatedAt === "number" &&
|
|
36
|
+
Number.isFinite(candidate.updatedAt) &&
|
|
37
|
+
typeof candidate.reason === "string" &&
|
|
38
|
+
VALID_REASONS.has(candidate.reason));
|
|
39
|
+
}
|
|
40
|
+
async function ensureAccountCooldownsLoaded() {
|
|
41
|
+
if (!cacheLoaded) {
|
|
42
|
+
if (!cacheLoadPromise) {
|
|
43
|
+
cacheLoadPromise = (async () => {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
|
|
46
|
+
memoryCache = Object.fromEntries(Object.entries(parsed).filter((entry) => isPersistedCooldown(entry[1])));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
memoryCache = {};
|
|
50
|
+
}
|
|
51
|
+
cacheLoaded = true;
|
|
52
|
+
})().finally(() => {
|
|
53
|
+
cacheLoadPromise = null;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
await cacheLoadPromise;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function loadAccountCooldowns() {
|
|
60
|
+
await ensureAccountCooldownsLoaded();
|
|
61
|
+
return mutationMutex.runExclusive(async () => ({ ...memoryCache }));
|
|
62
|
+
}
|
|
63
|
+
export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
|
|
64
|
+
await mutationMutex.runExclusive(async () => {
|
|
65
|
+
await ensureAccountCooldownsLoaded();
|
|
66
|
+
const current = memoryCache[accountKey];
|
|
67
|
+
if (current && current.coolingUntil > coolingUntil) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
memoryCache[accountKey] = {
|
|
71
|
+
coolingUntil,
|
|
72
|
+
reason,
|
|
73
|
+
updatedAt: Date.now(),
|
|
74
|
+
};
|
|
75
|
+
await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
|
|
79
|
+
await mutationMutex.runExclusive(async () => {
|
|
80
|
+
await ensureAccountCooldownsLoaded();
|
|
81
|
+
const current = memoryCache[accountKey];
|
|
82
|
+
if (!current) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (expectedCoolingUntil !== undefined &&
|
|
86
|
+
current.coolingUntil !== expectedCoolingUntil) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
delete memoryCache[accountKey];
|
|
90
|
+
await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* path is never blocked by file I/O.
|
|
11
11
|
*/
|
|
12
12
|
import type { AccountQuota } from "../types/index.js";
|
|
13
|
+
/** Read and normalize Anthropic's authoritative top-level unified status. */
|
|
14
|
+
export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
|
|
13
15
|
/**
|
|
14
16
|
* Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
|
|
15
17
|
* Returns `null` when key headers are absent.
|
|
@@ -22,10 +24,6 @@ export declare function parseQuotaHeaders(headers: Headers | Record<string, stri
|
|
|
22
24
|
* ~/.neurolink/account-quotas.json. Call before the first load/save.
|
|
23
25
|
*/
|
|
24
26
|
export declare function initAccountQuota(quotaFilePath: string): void;
|
|
25
|
-
/**
|
|
26
|
-
* Load all persisted account quotas.
|
|
27
|
-
* First call reads from disk; subsequent calls return the in-memory cache.
|
|
28
|
-
*/
|
|
29
27
|
export declare function loadAccountQuotas(): Promise<Record<string, AccountQuota>>;
|
|
30
28
|
/**
|
|
31
29
|
* Load quota for a single account.
|
|
@@ -42,3 +40,4 @@ export declare function loadAccountQuota(accountKey: string): Promise<AccountQuo
|
|
|
42
40
|
* other accounts' snapshots and blinded quota-aware routing to them).
|
|
43
41
|
*/
|
|
44
42
|
export declare function saveAccountQuota(accountKey: string, quota: AccountQuota): Promise<void>;
|
|
43
|
+
export declare function flushAccountQuotaStateForTests(): Promise<void>;
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* updates an in-memory cache and debounces disk writes so the request/response
|
|
10
10
|
* path is never blocked by file I/O.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { join } from "path";
|
|
13
13
|
import { homedir } from "os";
|
|
14
14
|
import { promises as fs } from "fs";
|
|
15
|
+
import { AsyncMutex } from "../utils/asyncMutex.js";
|
|
16
|
+
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
16
18
|
// Header parsing (pure CPU — no I/O, safe for hot path)
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
@@ -31,6 +33,12 @@ function getHeader(headers, name) {
|
|
|
31
33
|
}
|
|
32
34
|
return undefined;
|
|
33
35
|
}
|
|
36
|
+
/** Read and normalize Anthropic's authoritative top-level unified status. */
|
|
37
|
+
export function getUnifiedRateLimitStatus(headers) {
|
|
38
|
+
const value = getHeader(headers, "anthropic-ratelimit-unified-status");
|
|
39
|
+
const normalized = value?.trim().toLowerCase();
|
|
40
|
+
return normalized || undefined;
|
|
41
|
+
}
|
|
34
42
|
/**
|
|
35
43
|
* Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
|
|
36
44
|
* Returns `null` when key headers are absent.
|
|
@@ -53,6 +61,7 @@ export function parseQuotaHeaders(headers) {
|
|
|
53
61
|
const weeklyResetRaw = getHeader(headers, `${P}unified-7d-reset`);
|
|
54
62
|
const fallbackRaw = getHeader(headers, `${P}unified-fallback-percentage`);
|
|
55
63
|
return {
|
|
64
|
+
unifiedStatus: getUnifiedRateLimitStatus(headers),
|
|
56
65
|
sessionUsed,
|
|
57
66
|
sessionStatus: getHeader(headers, `${P}unified-5h-status`) ?? "unknown",
|
|
58
67
|
sessionResetAt: sessionResetRaw ? parseInt(sessionResetRaw, 10) || 0 : 0,
|
|
@@ -71,8 +80,12 @@ const QUOTA_FILE = "account-quotas.json";
|
|
|
71
80
|
const FLUSH_INTERVAL_MS = 5_000; // write to disk at most every 5 seconds
|
|
72
81
|
let memoryCache = {};
|
|
73
82
|
let cacheLoaded = false;
|
|
83
|
+
let cacheLoadPromise = null;
|
|
74
84
|
let dirty = false;
|
|
85
|
+
let cacheVersion = 0;
|
|
75
86
|
let flushTimer = null;
|
|
87
|
+
const stateMutex = new AsyncMutex();
|
|
88
|
+
const flushMutex = new AsyncMutex();
|
|
76
89
|
/** Custom quota file path set via initAccountQuota(). */
|
|
77
90
|
let customQuotaFilePath = null;
|
|
78
91
|
/**
|
|
@@ -91,41 +104,42 @@ export function initAccountQuota(quotaFilePath) {
|
|
|
91
104
|
// Reset cache so the new path is picked up on next load
|
|
92
105
|
memoryCache = {};
|
|
93
106
|
cacheLoaded = false;
|
|
107
|
+
cacheLoadPromise = null;
|
|
94
108
|
dirty = false;
|
|
109
|
+
cacheVersion = 0;
|
|
95
110
|
}
|
|
96
111
|
function getQuotaFilePath() {
|
|
97
112
|
return customQuotaFilePath ?? join(homedir(), ".neurolink", QUOTA_FILE);
|
|
98
113
|
}
|
|
99
|
-
async function ensureDir() {
|
|
100
|
-
const filePath = getQuotaFilePath();
|
|
101
|
-
const dir = dirname(filePath);
|
|
102
|
-
await fs.mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => {
|
|
103
|
-
// Non-fatal: directory may already exist
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
114
|
/** Flush the in-memory cache to disk (async, non-blocking). */
|
|
107
115
|
async function flushToDisk() {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
await flushMutex.runExclusive(async () => {
|
|
117
|
+
let snapshot;
|
|
118
|
+
let snapshotVersion = 0;
|
|
119
|
+
let filePath = "";
|
|
120
|
+
await stateMutex.runExclusive(async () => {
|
|
121
|
+
if (!dirty) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
snapshot = Object.fromEntries(Object.entries(memoryCache).map(([key, quota]) => [key, { ...quota }]));
|
|
125
|
+
snapshotVersion = cacheVersion;
|
|
126
|
+
filePath = getQuotaFilePath();
|
|
119
127
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (JSON.stringify(memoryCache, null, 2) === snapshot) {
|
|
123
|
-
dirty = false;
|
|
128
|
+
if (!snapshot) {
|
|
129
|
+
return;
|
|
124
130
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
try {
|
|
132
|
+
await writeJsonSnapshotAtomically(filePath, snapshot, 0o600);
|
|
133
|
+
await stateMutex.runExclusive(async () => {
|
|
134
|
+
if (cacheVersion === snapshotVersion) {
|
|
135
|
+
dirty = false;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Non-fatal — quota is best-effort telemetry
|
|
141
|
+
}
|
|
142
|
+
});
|
|
129
143
|
}
|
|
130
144
|
function scheduleFlush() {
|
|
131
145
|
if (flushTimer) {
|
|
@@ -149,19 +163,28 @@ function scheduleFlush() {
|
|
|
149
163
|
* Load all persisted account quotas.
|
|
150
164
|
* First call reads from disk; subsequent calls return the in-memory cache.
|
|
151
165
|
*/
|
|
152
|
-
|
|
153
|
-
if (cacheLoaded) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
166
|
+
async function ensureAccountQuotasLoaded() {
|
|
167
|
+
if (!cacheLoaded) {
|
|
168
|
+
if (!cacheLoadPromise) {
|
|
169
|
+
cacheLoadPromise = (async () => {
|
|
170
|
+
try {
|
|
171
|
+
const raw = await fs.readFile(getQuotaFilePath(), "utf-8");
|
|
172
|
+
memoryCache = JSON.parse(raw);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
memoryCache = {};
|
|
176
|
+
}
|
|
177
|
+
cacheLoaded = true;
|
|
178
|
+
})().finally(() => {
|
|
179
|
+
cacheLoadPromise = null;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
await cacheLoadPromise;
|
|
162
183
|
}
|
|
163
|
-
|
|
164
|
-
|
|
184
|
+
}
|
|
185
|
+
export async function loadAccountQuotas() {
|
|
186
|
+
await ensureAccountQuotasLoaded();
|
|
187
|
+
return stateMutex.runExclusive(async () => ({ ...memoryCache }));
|
|
165
188
|
}
|
|
166
189
|
/**
|
|
167
190
|
* Load quota for a single account.
|
|
@@ -181,10 +204,18 @@ export async function loadAccountQuota(accountKey) {
|
|
|
181
204
|
* other accounts' snapshots and blinded quota-aware routing to them).
|
|
182
205
|
*/
|
|
183
206
|
export async function saveAccountQuota(accountKey, quota) {
|
|
184
|
-
|
|
185
|
-
await
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
207
|
+
await stateMutex.runExclusive(async () => {
|
|
208
|
+
await ensureAccountQuotasLoaded();
|
|
209
|
+
memoryCache[accountKey] = { ...quota };
|
|
210
|
+
dirty = true;
|
|
211
|
+
cacheVersion += 1;
|
|
212
|
+
});
|
|
189
213
|
scheduleFlush();
|
|
190
214
|
}
|
|
215
|
+
export async function flushAccountQuotaStateForTests() {
|
|
216
|
+
if (flushTimer) {
|
|
217
|
+
clearTimeout(flushTimer);
|
|
218
|
+
flushTimer = null;
|
|
219
|
+
}
|
|
220
|
+
await flushToDisk();
|
|
221
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AccountAllowlist } from "../types/index.js";
|
|
2
|
+
export declare const LEGACY_ANTHROPIC_ACCOUNT_KEY = "anthropic:legacy-default";
|
|
3
|
+
export declare const ENV_ANTHROPIC_ACCOUNT_KEY = "anthropic:env";
|
|
4
|
+
export declare function normalizeAnthropicAccountKey(value: string): string;
|
|
5
|
+
export declare function anthropicAccountKeysEqual(left: string, right: string): boolean;
|
|
6
|
+
export declare function createAccountAllowlist(values?: readonly string[]): AccountAllowlist | undefined;
|
|
7
|
+
export declare function isAccountAllowed(accountKey: string, allowlist?: AccountAllowlist): boolean;
|
|
8
|
+
export declare function shouldLoadFallbackCredential(storedAnthropicAccountCount: number, fallbackAccountKey: string, allowlist?: AccountAllowlist): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const LEGACY_ANTHROPIC_ACCOUNT_KEY = "anthropic:legacy-default";
|
|
2
|
+
export const ENV_ANTHROPIC_ACCOUNT_KEY = "anthropic:env";
|
|
3
|
+
export function normalizeAnthropicAccountKey(value) {
|
|
4
|
+
const trimmed = value.trim().toLowerCase();
|
|
5
|
+
return trimmed.startsWith("anthropic:") ? trimmed : `anthropic:${trimmed}`;
|
|
6
|
+
}
|
|
7
|
+
export function anthropicAccountKeysEqual(left, right) {
|
|
8
|
+
return (normalizeAnthropicAccountKey(left) === normalizeAnthropicAccountKey(right));
|
|
9
|
+
}
|
|
10
|
+
export function createAccountAllowlist(values) {
|
|
11
|
+
if (values === undefined) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
return new Set(values
|
|
15
|
+
.map(normalizeAnthropicAccountKey)
|
|
16
|
+
.filter((value) => value !== "anthropic:"));
|
|
17
|
+
}
|
|
18
|
+
export function isAccountAllowed(accountKey, allowlist) {
|
|
19
|
+
return (allowlist === undefined ||
|
|
20
|
+
allowlist.has(normalizeAnthropicAccountKey(accountKey)));
|
|
21
|
+
}
|
|
22
|
+
export function shouldLoadFallbackCredential(storedAnthropicAccountCount, fallbackAccountKey, allowlist) {
|
|
23
|
+
return (storedAnthropicAccountCount === 0 &&
|
|
24
|
+
isAccountAllowed(fallbackAccountKey, allowlist));
|
|
25
|
+
}
|
|
@@ -212,6 +212,22 @@ export function validateProxyConfig(config) {
|
|
|
212
212
|
errors.push('"routing" must be an object');
|
|
213
213
|
return errors;
|
|
214
214
|
}
|
|
215
|
+
if (hasRouting) {
|
|
216
|
+
const routing = cfg.routing;
|
|
217
|
+
const rawAccountAllowlist = routing["account-allowlist"] ?? routing.accountAllowlist;
|
|
218
|
+
if (rawAccountAllowlist !== undefined) {
|
|
219
|
+
if (!Array.isArray(rawAccountAllowlist)) {
|
|
220
|
+
errors.push("routing.account-allowlist must be an array of non-empty strings");
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
rawAccountAllowlist.forEach((entry, index) => {
|
|
224
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
225
|
+
errors.push(`routing.account-allowlist[${index}] must be a non-empty string`);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
215
231
|
if (!hasAccounts && !hasRouting) {
|
|
216
232
|
errors.push('Config must contain at least one of "accounts" or "routing"');
|
|
217
233
|
return errors;
|
|
@@ -280,6 +296,7 @@ function warnPlaintextApiKeys(accounts) {
|
|
|
280
296
|
* - `model-mappings` / `modelMappings` — array of {from, to, provider}
|
|
281
297
|
* - `fallback-chain` / `fallbackChain` — array of {provider, model}
|
|
282
298
|
* - `passthroughModels` / `passthrough-models` — array of model IDs
|
|
299
|
+
* - `account-allowlist` / `accountAllowlist` — allowed Anthropic account IDs
|
|
283
300
|
*
|
|
284
301
|
* Accepts both camelCase and kebab-case keys for YAML-friendliness.
|
|
285
302
|
*/
|
|
@@ -350,6 +367,13 @@ function parseRoutingConfig(raw) {
|
|
|
350
367
|
`string, got ${typeof rawPrimary}`);
|
|
351
368
|
}
|
|
352
369
|
}
|
|
370
|
+
const rawAccountAllowlist = (raw["account-allowlist"] ??
|
|
371
|
+
raw.accountAllowlist);
|
|
372
|
+
if (Array.isArray(rawAccountAllowlist)) {
|
|
373
|
+
result.accountAllowlist = [
|
|
374
|
+
...new Set(rawAccountAllowlist.map((entry) => String(entry).trim())),
|
|
375
|
+
];
|
|
376
|
+
}
|
|
353
377
|
return result;
|
|
354
378
|
}
|
|
355
379
|
// ---------------------------------------------------------------------------
|
package/dist/proxy/proxyPaths.js
CHANGED
|
@@ -21,6 +21,7 @@ export function resolveProxyPaths(dev) {
|
|
|
21
21
|
stateDir: base,
|
|
22
22
|
logsDir: join(base, "logs"),
|
|
23
23
|
quotaFile: join(base, "account-quotas.json"),
|
|
24
|
+
cooldownFile: join(base, "account-cooldowns.json"),
|
|
24
25
|
isDev: true,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
@@ -29,6 +30,7 @@ export function resolveProxyPaths(dev) {
|
|
|
29
30
|
stateDir: base,
|
|
30
31
|
logsDir: join(base, "logs"),
|
|
31
32
|
quotaFile: join(base, "account-quotas.json"),
|
|
33
|
+
cooldownFile: join(base, "account-cooldowns.json"),
|
|
32
34
|
isDev: false,
|
|
33
35
|
};
|
|
34
36
|
}
|
|
@@ -594,6 +594,8 @@ export async function logStreamError(entry) {
|
|
|
594
594
|
const logEntry = {
|
|
595
595
|
...entry,
|
|
596
596
|
responseStatus: 200,
|
|
597
|
+
terminalStatus: 502,
|
|
598
|
+
terminalOutcome: "stream_error",
|
|
597
599
|
errorType: "stream_error",
|
|
598
600
|
note: "mid-stream failure after initial 200",
|
|
599
601
|
};
|
|
@@ -5,7 +5,7 @@ const writeLocks = new Map();
|
|
|
5
5
|
async function writeSnapshotFile(targetPath, payload, mode) {
|
|
6
6
|
const dir = dirname(targetPath);
|
|
7
7
|
const baseName = basename(targetPath);
|
|
8
|
-
await mkdir(dir, { recursive: true });
|
|
8
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
9
9
|
const tempPath = join(dir, `.${baseName}.${process.pid}.${randomUUID()}.tmp`);
|
|
10
10
|
try {
|
|
11
11
|
await writeFile(tempPath, payload, { mode });
|
|
@@ -185,6 +185,9 @@ function finalize(acc) {
|
|
|
185
185
|
streamDurationMs: Date.now() - acc.startTime,
|
|
186
186
|
totalBytesReceived: acc.totalBytesReceived,
|
|
187
187
|
events: acc.events,
|
|
188
|
+
...(acc.streamErrorMessage
|
|
189
|
+
? { streamErrorMessage: acc.streamErrorMessage }
|
|
190
|
+
: {}),
|
|
188
191
|
...(acc.rawTextChunks ? { rawText: acc.rawTextChunks.join("") } : {}),
|
|
189
192
|
};
|
|
190
193
|
}
|
|
@@ -323,6 +326,19 @@ function processEvent(acc, event) {
|
|
|
323
326
|
// Malformed JSON — skip silently, bytes already forwarded to client
|
|
324
327
|
return;
|
|
325
328
|
}
|
|
329
|
+
if (event.event === "error") {
|
|
330
|
+
const payload = parsed && typeof parsed === "object"
|
|
331
|
+
? parsed
|
|
332
|
+
: {};
|
|
333
|
+
const nestedError = payload.error && typeof payload.error === "object"
|
|
334
|
+
? payload.error
|
|
335
|
+
: undefined;
|
|
336
|
+
const message = nestedError?.message ?? payload.message;
|
|
337
|
+
acc.streamErrorMessage =
|
|
338
|
+
typeof message === "string" && message.trim()
|
|
339
|
+
? message.trim()
|
|
340
|
+
: truncateString(event.data, MAX_EVENT_DATA_BYTES);
|
|
341
|
+
}
|
|
326
342
|
switch (event.event) {
|
|
327
343
|
case "message_start":
|
|
328
344
|
processMessageStart(acc, parsed);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { StreamTerminalOutcome, StreamTerminalOutcomeTracker } from "../types/index.js";
|
|
2
|
+
export declare function createStreamTerminalOutcomeTracker(): StreamTerminalOutcomeTracker;
|
|
3
|
+
export declare function mergeStreamTerminalOutcome(outcome: StreamTerminalOutcome, sseErrorMessage?: string): StreamTerminalOutcome;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createStreamTerminalOutcomeTracker() {
|
|
2
|
+
let settled = false;
|
|
3
|
+
let resolveOutcome;
|
|
4
|
+
const outcome = new Promise((resolve) => {
|
|
5
|
+
resolveOutcome = resolve;
|
|
6
|
+
});
|
|
7
|
+
const settle = (value) => {
|
|
8
|
+
if (settled) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
settled = true;
|
|
12
|
+
resolveOutcome(value);
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
outcome,
|
|
16
|
+
complete: () => settle({ kind: "completed" }),
|
|
17
|
+
fail: (message) => settle({ kind: "upstream_error", message }),
|
|
18
|
+
cancel: () => settle({ kind: "client_cancelled" }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function mergeStreamTerminalOutcome(outcome, sseErrorMessage) {
|
|
22
|
+
if (outcome.kind === "completed" && sseErrorMessage) {
|
|
23
|
+
return { kind: "upstream_error", message: sseErrorMessage };
|
|
24
|
+
}
|
|
25
|
+
return outcome;
|
|
26
|
+
}
|