@juspay/neurolink 9.91.0 → 9.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +352 -351
- package/dist/cli/commands/auth.js +25 -15
- package/dist/cli/commands/proxy.d.ts +4 -3
- package/dist/cli/commands/proxy.js +187 -130
- package/dist/lib/providers/sagemaker/client.d.ts +9 -0
- package/dist/lib/providers/sagemaker/client.js +75 -30
- package/dist/lib/proxy/proxyConfig.js +66 -0
- package/dist/lib/proxy/runtimeConfig.d.ts +26 -0
- package/dist/lib/proxy/runtimeConfig.js +428 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +5 -6
- package/dist/lib/server/routes/claudeProxyRoutes.js +68 -37
- package/dist/lib/server/routes/openaiProxyRoutes.d.ts +2 -3
- package/dist/lib/server/routes/openaiProxyRoutes.js +11 -5
- package/dist/lib/types/cli.d.ts +8 -0
- package/dist/lib/types/proxy.d.ts +67 -1
- package/dist/lib/types/subscription.d.ts +6 -0
- package/dist/providers/sagemaker/client.d.ts +9 -0
- package/dist/providers/sagemaker/client.js +75 -30
- package/dist/proxy/proxyConfig.js +66 -0
- package/dist/proxy/runtimeConfig.d.ts +26 -0
- package/dist/proxy/runtimeConfig.js +427 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +5 -6
- package/dist/server/routes/claudeProxyRoutes.js +68 -37
- package/dist/server/routes/openaiProxyRoutes.d.ts +2 -3
- package/dist/server/routes/openaiProxyRoutes.js +11 -5
- package/dist/types/cli.d.ts +8 -0
- package/dist/types/proxy.d.ts +67 -1
- package/dist/types/subscription.d.ts +6 -0
- package/package.json +2 -2
|
@@ -227,6 +227,33 @@ export function validateProxyConfig(config) {
|
|
|
227
227
|
});
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
const rawQuotaRouting = routing["quota-routing"] ?? routing.quotaRouting;
|
|
231
|
+
const normalizedQuotaRouting = typeof rawQuotaRouting === "string"
|
|
232
|
+
? rawQuotaRouting.trim().toLowerCase()
|
|
233
|
+
: undefined;
|
|
234
|
+
if (rawQuotaRouting !== undefined &&
|
|
235
|
+
typeof rawQuotaRouting !== "boolean" &&
|
|
236
|
+
normalizedQuotaRouting !== "true" &&
|
|
237
|
+
normalizedQuotaRouting !== "false") {
|
|
238
|
+
errors.push("routing.quota-routing must be a boolean");
|
|
239
|
+
}
|
|
240
|
+
const rawSessionSoftLimit = routing["session-soft-limit"] ?? routing.sessionSoftLimit;
|
|
241
|
+
if (rawSessionSoftLimit !== undefined) {
|
|
242
|
+
const sessionSoftLimit = Number(rawSessionSoftLimit);
|
|
243
|
+
if (!Number.isFinite(sessionSoftLimit) ||
|
|
244
|
+
sessionSoftLimit <= 0 ||
|
|
245
|
+
sessionSoftLimit > 1) {
|
|
246
|
+
errors.push("routing.session-soft-limit must be a number in (0, 1]");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const rawSessionResetToleranceMs = routing["session-reset-tolerance-ms"] ?? routing.sessionResetToleranceMs;
|
|
250
|
+
if (rawSessionResetToleranceMs !== undefined) {
|
|
251
|
+
const sessionResetToleranceMs = Number(rawSessionResetToleranceMs);
|
|
252
|
+
if (!Number.isInteger(sessionResetToleranceMs) ||
|
|
253
|
+
sessionResetToleranceMs <= 0) {
|
|
254
|
+
errors.push("routing.session-reset-tolerance-ms must be a positive integer");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
230
257
|
}
|
|
231
258
|
if (!hasAccounts && !hasRouting) {
|
|
232
259
|
errors.push('Config must contain at least one of "accounts" or "routing"');
|
|
@@ -296,6 +323,9 @@ function warnPlaintextApiKeys(accounts) {
|
|
|
296
323
|
* - `model-mappings` / `modelMappings` — array of {from, to, provider}
|
|
297
324
|
* - `fallback-chain` / `fallbackChain` — array of {provider, model}
|
|
298
325
|
* - `passthroughModels` / `passthrough-models` — array of model IDs
|
|
326
|
+
* - `quota-routing` / `quotaRouting` — quota-aware fill-first ordering
|
|
327
|
+
* - `session-soft-limit` / `sessionSoftLimit` — proactive handoff threshold
|
|
328
|
+
* - `session-reset-tolerance-ms` / `sessionResetToleranceMs` — reset bucket
|
|
299
329
|
* - `account-allowlist` / `accountAllowlist` — allowed Anthropic account IDs
|
|
300
330
|
*
|
|
301
331
|
* Accepts both camelCase and kebab-case keys for YAML-friendliness.
|
|
@@ -353,6 +383,42 @@ function parseRoutingConfig(raw) {
|
|
|
353
383
|
if (Array.isArray(rawPassthrough)) {
|
|
354
384
|
result.passthroughModels = rawPassthrough.map(String);
|
|
355
385
|
}
|
|
386
|
+
const rawQuotaRouting = raw["quota-routing"] ?? raw.quotaRouting;
|
|
387
|
+
if (rawQuotaRouting !== undefined) {
|
|
388
|
+
if (typeof rawQuotaRouting === "boolean") {
|
|
389
|
+
result.quotaRouting = rawQuotaRouting;
|
|
390
|
+
}
|
|
391
|
+
else if (typeof rawQuotaRouting === "string" &&
|
|
392
|
+
["true", "false"].includes(rawQuotaRouting.trim().toLowerCase())) {
|
|
393
|
+
result.quotaRouting = rawQuotaRouting.trim().toLowerCase() === "true";
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
logger.warn(`[proxy-config] Ignoring routing.quotaRouting: expected boolean, got ${typeof rawQuotaRouting}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const rawSessionSoftLimit = raw["session-soft-limit"] ?? raw.sessionSoftLimit;
|
|
400
|
+
if (rawSessionSoftLimit !== undefined) {
|
|
401
|
+
const sessionSoftLimit = Number(rawSessionSoftLimit);
|
|
402
|
+
if (Number.isFinite(sessionSoftLimit) &&
|
|
403
|
+
sessionSoftLimit > 0 &&
|
|
404
|
+
sessionSoftLimit <= 1) {
|
|
405
|
+
result.sessionSoftLimit = sessionSoftLimit;
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
logger.warn(`[proxy-config] Ignoring routing.sessionSoftLimit: expected number in (0, 1], got ${String(rawSessionSoftLimit)}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const rawSessionResetToleranceMs = raw["session-reset-tolerance-ms"] ?? raw.sessionResetToleranceMs;
|
|
412
|
+
if (rawSessionResetToleranceMs !== undefined) {
|
|
413
|
+
const sessionResetToleranceMs = Number(rawSessionResetToleranceMs);
|
|
414
|
+
if (Number.isInteger(sessionResetToleranceMs) &&
|
|
415
|
+
sessionResetToleranceMs > 0) {
|
|
416
|
+
result.sessionResetToleranceMs = sessionResetToleranceMs;
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
logger.warn(`[proxy-config] Ignoring routing.sessionResetToleranceMs: expected positive integer, got ${String(rawSessionResetToleranceMs)}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
356
422
|
// Primary account (accept kebab-case or camelCase). Email or label of the
|
|
357
423
|
// Anthropic account that should be tried first ("home"). Resolved to a
|
|
358
424
|
// stable key (anthropic:<email>) at proxy boot; absence preserves the
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ProxyRuntimeConfigListener, ProxyRuntimeConfigReloadListener, ProxyRuntimeConfigReloadResult, ProxyRuntimeConfigReloadSource, ProxyRuntimeConfigSnapshot, ProxyRuntimeConfigStatus, ProxyRuntimeConfigStoreOptions } from "../types/index.js";
|
|
2
|
+
/** Atomic last-known-good runtime configuration with file-triggered reloads. */
|
|
3
|
+
export declare class ProxyRuntimeConfigStore {
|
|
4
|
+
private readonly options;
|
|
5
|
+
private currentSnapshot;
|
|
6
|
+
private status;
|
|
7
|
+
private readonly listeners;
|
|
8
|
+
private readonly reloadListeners;
|
|
9
|
+
private reloadQueue;
|
|
10
|
+
private reloadTimer;
|
|
11
|
+
private configFileObserved;
|
|
12
|
+
private envFileObserved;
|
|
13
|
+
private readonly watchListener;
|
|
14
|
+
private constructor();
|
|
15
|
+
static create(options: ProxyRuntimeConfigStoreOptions): Promise<ProxyRuntimeConfigStore>;
|
|
16
|
+
getSnapshot(): ProxyRuntimeConfigSnapshot;
|
|
17
|
+
getStatus(): ProxyRuntimeConfigStatus;
|
|
18
|
+
subscribe(listener: ProxyRuntimeConfigListener): () => void;
|
|
19
|
+
subscribeReload(listener: ProxyRuntimeConfigReloadListener): () => void;
|
|
20
|
+
reload(source?: ProxyRuntimeConfigReloadSource): Promise<ProxyRuntimeConfigReloadResult>;
|
|
21
|
+
startWatching(): void;
|
|
22
|
+
stopWatching(): void;
|
|
23
|
+
private scheduleWatchReload;
|
|
24
|
+
private performReload;
|
|
25
|
+
private notifyReloadListeners;
|
|
26
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { watchFile, unwatchFile } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, } from "./accountSelection.js";
|
|
5
|
+
import { ModelRouter } from "./modelRouter.js";
|
|
6
|
+
import { loadProxyConfig } from "./proxyConfig.js";
|
|
7
|
+
import { logger } from "../utils/logger.js";
|
|
8
|
+
const DEFAULT_WATCH_INTERVAL_MS = 1_000;
|
|
9
|
+
const DEFAULT_WATCH_DEBOUNCE_MS = 100;
|
|
10
|
+
const DEFAULT_SESSION_SOFT_LIMIT = 0.97;
|
|
11
|
+
const DEFAULT_SESSION_RESET_TOLERANCE_MS = 15 * 60 * 1_000;
|
|
12
|
+
function errorCode(error) {
|
|
13
|
+
let current = error;
|
|
14
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
15
|
+
if (!current || typeof current !== "object") {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const code = current.code;
|
|
19
|
+
if (typeof code === "string") {
|
|
20
|
+
return code;
|
|
21
|
+
}
|
|
22
|
+
current = current.cause;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function isMissingFileError(error) {
|
|
27
|
+
return errorCode(error) === "ENOENT";
|
|
28
|
+
}
|
|
29
|
+
function normalizeStrategy(configured, override) {
|
|
30
|
+
if (override) {
|
|
31
|
+
return override;
|
|
32
|
+
}
|
|
33
|
+
return configured === "round-robin" ? "round-robin" : "fill-first";
|
|
34
|
+
}
|
|
35
|
+
function resolveQuotaRoutingEnabled(envValue, configured) {
|
|
36
|
+
if (envValue === undefined) {
|
|
37
|
+
return configured ?? true;
|
|
38
|
+
}
|
|
39
|
+
const normalized = envValue.trim().toLowerCase();
|
|
40
|
+
return normalized !== "off" && normalized !== "false" && normalized !== "0";
|
|
41
|
+
}
|
|
42
|
+
function resolveSessionSoftLimit(envValue, configured, rejectInvalid) {
|
|
43
|
+
if (envValue !== undefined) {
|
|
44
|
+
const parsed = Number(envValue);
|
|
45
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 1) {
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
const message = `Invalid NEUROLINK_PROXY_SESSION_SOFT_LIMIT=${envValue}; ` +
|
|
49
|
+
"expected number in (0, 1]";
|
|
50
|
+
if (rejectInvalid) {
|
|
51
|
+
throw new Error(message);
|
|
52
|
+
}
|
|
53
|
+
logger.warn(`[proxy-config] ${message}; using configured/default value`);
|
|
54
|
+
}
|
|
55
|
+
return configured ?? DEFAULT_SESSION_SOFT_LIMIT;
|
|
56
|
+
}
|
|
57
|
+
function resolveSessionResetToleranceMs(envValue, configured, rejectInvalid) {
|
|
58
|
+
if (envValue !== undefined) {
|
|
59
|
+
const parsed = Number(envValue);
|
|
60
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
const message = `Invalid NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS=${envValue}; ` +
|
|
64
|
+
"expected positive integer";
|
|
65
|
+
if (rejectInvalid) {
|
|
66
|
+
throw new Error(message);
|
|
67
|
+
}
|
|
68
|
+
logger.warn(`[proxy-config] ${message}; using configured/default value`);
|
|
69
|
+
}
|
|
70
|
+
return configured ?? DEFAULT_SESSION_RESET_TOLERANCE_MS;
|
|
71
|
+
}
|
|
72
|
+
async function resolvePrimaryAccountKey(primaryEmail) {
|
|
73
|
+
const trimmed = primaryEmail?.trim();
|
|
74
|
+
if (!trimmed) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const key = normalizeAnthropicAccountKey(trimmed);
|
|
78
|
+
try {
|
|
79
|
+
const { tokenStore } = await import("../auth/tokenStore.js");
|
|
80
|
+
const known = await tokenStore.listByPrefix("anthropic:");
|
|
81
|
+
if (!known.some((knownKey) => normalizeAnthropicAccountKey(knownKey) === key)) {
|
|
82
|
+
logger.warn(`[proxy] WARN: configured routing.primaryAccount=${trimmed} not found in token store; it will activate after authentication`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
logger.debug(`[proxy] could not validate primary account against token store: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
return key;
|
|
89
|
+
}
|
|
90
|
+
async function resolveAccountAllowlist(configuredAccounts) {
|
|
91
|
+
const allowlist = createAccountAllowlist(configuredAccounts);
|
|
92
|
+
if (allowlist === undefined) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
if (allowlist.size === 0) {
|
|
96
|
+
logger.warn("[proxy] routing.accountAllowlist is empty; all stored Anthropic credentials are denied");
|
|
97
|
+
return allowlist;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const { tokenStore } = await import("../auth/tokenStore.js");
|
|
101
|
+
const known = new Set((await tokenStore.listByPrefix("anthropic:")).map(normalizeAnthropicAccountKey));
|
|
102
|
+
known.add(LEGACY_ANTHROPIC_ACCOUNT_KEY);
|
|
103
|
+
known.add(ENV_ANTHROPIC_ACCOUNT_KEY);
|
|
104
|
+
for (const key of allowlist) {
|
|
105
|
+
if (!known.has(key)) {
|
|
106
|
+
logger.warn(`[proxy] WARN: routing.accountAllowlist entry ${key} is allowed but not currently authenticated`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
logger.debug(`[proxy] could not validate account allowlist against token store: ${error instanceof Error ? error.message : String(error)}`);
|
|
112
|
+
}
|
|
113
|
+
return allowlist;
|
|
114
|
+
}
|
|
115
|
+
function cloneLoadedConfig(loaded) {
|
|
116
|
+
if (!loaded) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const routing = loaded.routing;
|
|
120
|
+
if (!routing) {
|
|
121
|
+
return Object.freeze({});
|
|
122
|
+
}
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
routing: Object.freeze({
|
|
125
|
+
...routing,
|
|
126
|
+
...(routing.modelMappings
|
|
127
|
+
? {
|
|
128
|
+
modelMappings: Object.freeze(routing.modelMappings.map((entry) => Object.freeze({ ...entry }))),
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
131
|
+
...(routing.fallbackChain
|
|
132
|
+
? {
|
|
133
|
+
fallbackChain: Object.freeze(routing.fallbackChain.map((entry) => Object.freeze({ ...entry }))),
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
...(routing.passthroughModels
|
|
137
|
+
? { passthroughModels: Object.freeze([...routing.passthroughModels]) }
|
|
138
|
+
: {}),
|
|
139
|
+
...(routing.accountAllowlist
|
|
140
|
+
? { accountAllowlist: Object.freeze([...routing.accountAllowlist]) }
|
|
141
|
+
: {}),
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function assertResolvedRoutingValues(value, path = "routing") {
|
|
146
|
+
if (typeof value === "string") {
|
|
147
|
+
if (value.includes("${")) {
|
|
148
|
+
throw new Error(`Unresolved environment placeholder at ${path}`);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
value.forEach((entry, index) => assertResolvedRoutingValues(entry, `${path}[${index}]`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (value && typeof value === "object") {
|
|
157
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
158
|
+
assertResolvedRoutingValues(entry, `${path}.${key}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function buildCandidate(options, generation, allowMissingConfig, allowMissingEnvFile, rejectInvalidHotEnv) {
|
|
163
|
+
const effectiveEnv = { ...options.baseEnv };
|
|
164
|
+
let envFilePresent = false;
|
|
165
|
+
if (options.envFilePath) {
|
|
166
|
+
try {
|
|
167
|
+
const envContent = await readFile(options.envFilePath, "utf8");
|
|
168
|
+
const { parse } = await import("dotenv");
|
|
169
|
+
Object.assign(effectiveEnv, parse(envContent));
|
|
170
|
+
envFilePresent = true;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (!isMissingFileError(error) ||
|
|
174
|
+
options.envFileRequired ||
|
|
175
|
+
!allowMissingEnvFile) {
|
|
176
|
+
throw new Error(`Failed to load proxy env file ${options.envFilePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let loadedConfig = null;
|
|
181
|
+
let configFilePresent = false;
|
|
182
|
+
try {
|
|
183
|
+
loadedConfig = (await loadProxyConfig(options.configPath, {
|
|
184
|
+
env: effectiveEnv,
|
|
185
|
+
}));
|
|
186
|
+
configFilePresent = true;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (!isMissingFileError(error) ||
|
|
190
|
+
options.configRequired ||
|
|
191
|
+
!allowMissingConfig) {
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const proxyConfig = cloneLoadedConfig(loadedConfig);
|
|
196
|
+
const routing = proxyConfig?.routing;
|
|
197
|
+
assertResolvedRoutingValues(routing);
|
|
198
|
+
const strategy = normalizeStrategy(routing?.strategy, options.strategyOverride);
|
|
199
|
+
const primaryAccountKey = await resolvePrimaryAccountKey(routing?.primaryAccount);
|
|
200
|
+
const accountAllowlist = await resolveAccountAllowlist(routing?.accountAllowlist);
|
|
201
|
+
if (primaryAccountKey &&
|
|
202
|
+
!isAccountAllowed(primaryAccountKey, accountAllowlist)) {
|
|
203
|
+
throw new Error(`Configured routing.primaryAccount=${routing?.primaryAccount} is excluded by routing.accountAllowlist`);
|
|
204
|
+
}
|
|
205
|
+
const quotaRoutingEnabled = resolveQuotaRoutingEnabled(effectiveEnv.NEUROLINK_PROXY_QUOTA_ROUTING, routing?.quotaRouting);
|
|
206
|
+
const sessionSoftLimit = resolveSessionSoftLimit(effectiveEnv.NEUROLINK_PROXY_SESSION_SOFT_LIMIT, routing?.sessionSoftLimit, rejectInvalidHotEnv);
|
|
207
|
+
const sessionResetToleranceMs = resolveSessionResetToleranceMs(effectiveEnv.NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS, routing?.sessionResetToleranceMs, rejectInvalidHotEnv);
|
|
208
|
+
const modelRouter = routing
|
|
209
|
+
? new ModelRouter({
|
|
210
|
+
strategy,
|
|
211
|
+
modelMappings: routing.modelMappings ?? [],
|
|
212
|
+
fallbackChain: routing.fallbackChain ?? [],
|
|
213
|
+
passthroughModels: routing.passthroughModels,
|
|
214
|
+
quotaRouting: routing.quotaRouting,
|
|
215
|
+
sessionSoftLimit: routing.sessionSoftLimit,
|
|
216
|
+
sessionResetToleranceMs: routing.sessionResetToleranceMs,
|
|
217
|
+
primaryAccount: routing.primaryAccount,
|
|
218
|
+
accountAllowlist: routing.accountAllowlist,
|
|
219
|
+
})
|
|
220
|
+
: undefined;
|
|
221
|
+
const fingerprintSource = JSON.stringify({
|
|
222
|
+
strategy,
|
|
223
|
+
passthrough: options.passthrough,
|
|
224
|
+
routing: routing ?? null,
|
|
225
|
+
primaryAccountKey,
|
|
226
|
+
accountAllowlist: accountAllowlist ? [...accountAllowlist].sort() : null,
|
|
227
|
+
quotaRoutingEnabled,
|
|
228
|
+
sessionSoftLimit,
|
|
229
|
+
sessionResetToleranceMs,
|
|
230
|
+
});
|
|
231
|
+
const configHash = createHash("sha256")
|
|
232
|
+
.update(fingerprintSource)
|
|
233
|
+
.digest("hex")
|
|
234
|
+
.slice(0, 16);
|
|
235
|
+
return {
|
|
236
|
+
snapshot: Object.freeze({
|
|
237
|
+
generation,
|
|
238
|
+
loadedAt: new Date().toISOString(),
|
|
239
|
+
configHash,
|
|
240
|
+
proxyConfig,
|
|
241
|
+
strategy,
|
|
242
|
+
modelRouter,
|
|
243
|
+
passthrough: options.passthrough,
|
|
244
|
+
primaryAccountKey,
|
|
245
|
+
accountAllowlist,
|
|
246
|
+
quotaRoutingEnabled,
|
|
247
|
+
sessionSoftLimit,
|
|
248
|
+
sessionResetToleranceMs,
|
|
249
|
+
}),
|
|
250
|
+
configFilePresent,
|
|
251
|
+
envFilePresent,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** Atomic last-known-good runtime configuration with file-triggered reloads. */
|
|
255
|
+
export class ProxyRuntimeConfigStore {
|
|
256
|
+
options;
|
|
257
|
+
currentSnapshot;
|
|
258
|
+
status;
|
|
259
|
+
listeners = new Set();
|
|
260
|
+
reloadListeners = new Set();
|
|
261
|
+
reloadQueue = Promise.resolve();
|
|
262
|
+
reloadTimer;
|
|
263
|
+
configFileObserved;
|
|
264
|
+
envFileObserved;
|
|
265
|
+
watchListener = (current, previous) => {
|
|
266
|
+
if (current.mtimeMs === previous.mtimeMs &&
|
|
267
|
+
current.size === previous.size &&
|
|
268
|
+
current.nlink === previous.nlink) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.scheduleWatchReload();
|
|
272
|
+
};
|
|
273
|
+
constructor(options, snapshot, configFileObserved, envFileObserved) {
|
|
274
|
+
this.options = { ...options, baseEnv: { ...options.baseEnv } };
|
|
275
|
+
this.currentSnapshot = snapshot;
|
|
276
|
+
this.configFileObserved = configFileObserved;
|
|
277
|
+
this.envFileObserved = envFileObserved;
|
|
278
|
+
this.status = {
|
|
279
|
+
configPath: options.configPath,
|
|
280
|
+
...(options.envFilePath ? { envFilePath: options.envFilePath } : {}),
|
|
281
|
+
generation: snapshot.generation,
|
|
282
|
+
loadedAt: snapshot.loadedAt,
|
|
283
|
+
configHash: snapshot.configHash,
|
|
284
|
+
watching: false,
|
|
285
|
+
consecutiveFailures: 0,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
static async create(options) {
|
|
289
|
+
const candidate = await buildCandidate(options, 1, true, true, false);
|
|
290
|
+
return new ProxyRuntimeConfigStore(options, candidate.snapshot, candidate.configFilePresent, candidate.envFilePresent);
|
|
291
|
+
}
|
|
292
|
+
getSnapshot() {
|
|
293
|
+
return this.currentSnapshot;
|
|
294
|
+
}
|
|
295
|
+
getStatus() {
|
|
296
|
+
return { ...this.status };
|
|
297
|
+
}
|
|
298
|
+
subscribe(listener) {
|
|
299
|
+
this.listeners.add(listener);
|
|
300
|
+
return () => this.listeners.delete(listener);
|
|
301
|
+
}
|
|
302
|
+
subscribeReload(listener) {
|
|
303
|
+
this.reloadListeners.add(listener);
|
|
304
|
+
return () => this.reloadListeners.delete(listener);
|
|
305
|
+
}
|
|
306
|
+
reload(source = "manual") {
|
|
307
|
+
const run = this.reloadQueue.then(() => this.performReload(source), () => this.performReload(source));
|
|
308
|
+
this.reloadQueue = run.then(() => undefined, () => undefined);
|
|
309
|
+
return run;
|
|
310
|
+
}
|
|
311
|
+
startWatching() {
|
|
312
|
+
if (this.status.watching) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const interval = Math.max(50, this.options.watchIntervalMs ?? DEFAULT_WATCH_INTERVAL_MS);
|
|
316
|
+
watchFile(this.options.configPath, { interval, persistent: false }, this.watchListener);
|
|
317
|
+
if (this.options.envFilePath) {
|
|
318
|
+
watchFile(this.options.envFilePath, { interval, persistent: false }, this.watchListener);
|
|
319
|
+
}
|
|
320
|
+
this.status = { ...this.status, watching: true };
|
|
321
|
+
}
|
|
322
|
+
stopWatching() {
|
|
323
|
+
if (!this.status.watching) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
unwatchFile(this.options.configPath, this.watchListener);
|
|
327
|
+
if (this.options.envFilePath) {
|
|
328
|
+
unwatchFile(this.options.envFilePath, this.watchListener);
|
|
329
|
+
}
|
|
330
|
+
if (this.reloadTimer) {
|
|
331
|
+
clearTimeout(this.reloadTimer);
|
|
332
|
+
this.reloadTimer = undefined;
|
|
333
|
+
}
|
|
334
|
+
this.status = { ...this.status, watching: false };
|
|
335
|
+
}
|
|
336
|
+
scheduleWatchReload() {
|
|
337
|
+
if (this.reloadTimer) {
|
|
338
|
+
clearTimeout(this.reloadTimer);
|
|
339
|
+
}
|
|
340
|
+
this.reloadTimer = setTimeout(() => {
|
|
341
|
+
this.reloadTimer = undefined;
|
|
342
|
+
void this.reload("watch");
|
|
343
|
+
}, Math.max(0, this.options.watchDebounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS));
|
|
344
|
+
this.reloadTimer.unref?.();
|
|
345
|
+
}
|
|
346
|
+
async performReload(source) {
|
|
347
|
+
const attemptedAt = new Date().toISOString();
|
|
348
|
+
this.status = {
|
|
349
|
+
...this.status,
|
|
350
|
+
lastReloadAttemptAt: attemptedAt,
|
|
351
|
+
lastReloadSource: source,
|
|
352
|
+
};
|
|
353
|
+
try {
|
|
354
|
+
const candidate = await buildCandidate(this.options, this.currentSnapshot.generation + 1, !this.configFileObserved, !this.envFileObserved, true);
|
|
355
|
+
this.configFileObserved ||= candidate.configFilePresent;
|
|
356
|
+
this.envFileObserved ||= candidate.envFilePresent;
|
|
357
|
+
if (candidate.snapshot.configHash === this.currentSnapshot.configHash) {
|
|
358
|
+
this.status = {
|
|
359
|
+
...this.status,
|
|
360
|
+
lastReloadAt: attemptedAt,
|
|
361
|
+
lastReloadError: undefined,
|
|
362
|
+
consecutiveFailures: 0,
|
|
363
|
+
};
|
|
364
|
+
const result = {
|
|
365
|
+
applied: true,
|
|
366
|
+
changed: false,
|
|
367
|
+
generation: this.currentSnapshot.generation,
|
|
368
|
+
};
|
|
369
|
+
this.notifyReloadListeners(result);
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
this.currentSnapshot = candidate.snapshot;
|
|
373
|
+
this.status = {
|
|
374
|
+
...this.status,
|
|
375
|
+
generation: candidate.snapshot.generation,
|
|
376
|
+
loadedAt: candidate.snapshot.loadedAt,
|
|
377
|
+
configHash: candidate.snapshot.configHash,
|
|
378
|
+
lastReloadAt: attemptedAt,
|
|
379
|
+
lastReloadError: undefined,
|
|
380
|
+
consecutiveFailures: 0,
|
|
381
|
+
};
|
|
382
|
+
logger.always(`[proxy] configuration generation ${candidate.snapshot.generation} applied via ${source}`);
|
|
383
|
+
for (const listener of this.listeners) {
|
|
384
|
+
try {
|
|
385
|
+
listener(candidate.snapshot);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
logger.warn(`[proxy] runtime config listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const result = {
|
|
392
|
+
applied: true,
|
|
393
|
+
changed: true,
|
|
394
|
+
generation: candidate.snapshot.generation,
|
|
395
|
+
};
|
|
396
|
+
this.notifyReloadListeners(result);
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
401
|
+
this.status = {
|
|
402
|
+
...this.status,
|
|
403
|
+
lastReloadError: message,
|
|
404
|
+
consecutiveFailures: this.status.consecutiveFailures + 1,
|
|
405
|
+
};
|
|
406
|
+
logger.warn(`[proxy] configuration reload rejected; keeping generation ${this.currentSnapshot.generation}: ${message}`);
|
|
407
|
+
const result = {
|
|
408
|
+
applied: false,
|
|
409
|
+
changed: false,
|
|
410
|
+
generation: this.currentSnapshot.generation,
|
|
411
|
+
error: message,
|
|
412
|
+
};
|
|
413
|
+
this.notifyReloadListeners(result);
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
notifyReloadListeners(result) {
|
|
418
|
+
for (const listener of this.reloadListeners) {
|
|
419
|
+
try {
|
|
420
|
+
listener(result, this.getStatus());
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
logger.warn(`[proxy] runtime config reload listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -10,21 +10,20 @@
|
|
|
10
10
|
* Without a router, models are passed through to the Anthropic provider.
|
|
11
11
|
*/
|
|
12
12
|
import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
|
|
13
|
-
import type { ModelRouter } from "../../proxy/modelRouter.js";
|
|
14
13
|
import { ProxyTracer } from "../../proxy/proxyTracer.js";
|
|
15
14
|
import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
|
|
16
|
-
import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ParsedClaudeError, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
15
|
+
import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
17
16
|
/** Resolve the configured primary's stable key to its current index in the
|
|
18
17
|
* request's enabledAccounts list. Returns 0 (insertion-order fallback) when
|
|
19
18
|
* no key is configured or the key cannot be matched (account disabled/
|
|
20
19
|
* removed). The resolution is per-request because enabledAccounts membership
|
|
21
20
|
* can shift between requests. */
|
|
22
|
-
declare function resolveHomeIndex(enabledAccounts: ProxyPassthroughAccount[]): number;
|
|
21
|
+
declare function resolveHomeIndex(enabledAccounts: ProxyPassthroughAccount[], primaryAccountKey?: string | undefined): number;
|
|
23
22
|
/** If the configured home primary is no longer cooling, reset
|
|
24
23
|
* primaryAccountIndex back to its index so traffic returns to the preferred
|
|
25
24
|
* account once its rate limit window expires. Called at the start of each
|
|
26
25
|
* request. Home is resolved fresh per call via resolveHomeIndex. */
|
|
27
|
-
declare function maybeResetPrimaryToHome(enabledAccounts: ProxyPassthroughAccount[]): void;
|
|
26
|
+
declare function maybeResetPrimaryToHome(enabledAccounts: ProxyPassthroughAccount[], primaryAccountKey?: string | undefined): void;
|
|
28
27
|
declare function claimTransientRateLimitRetry(accountKey: string, coolingUntil: number, now?: number): number | undefined;
|
|
29
28
|
declare function claimTransientCooldownAdmission(accountKey: string, coolingUntil: number, now?: number): number | undefined;
|
|
30
29
|
declare function waitForTransientAccountAvailability(orderedAccounts: ProxyPassthroughAccount[]): Promise<ProxyPassthroughAccount[]>;
|
|
@@ -83,7 +82,7 @@ declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]):
|
|
|
83
82
|
* Cooling/rejected accounts sort last, soonest-back-to-service first, as
|
|
84
83
|
* last resort.
|
|
85
84
|
*/
|
|
86
|
-
declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number): ProxyPassthroughAccount[];
|
|
85
|
+
declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number, primaryKey?: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number): ProxyPassthroughAccount[];
|
|
87
86
|
declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>): {
|
|
88
87
|
stream: ReadableStream<Uint8Array>;
|
|
89
88
|
outcome: Promise<StreamTerminalOutcome>;
|
|
@@ -234,7 +233,7 @@ declare function shouldAttemptClaudeFallback(loopState: AnthropicLoopState): boo
|
|
|
234
233
|
* @param basePath - Base path prefix (default: "" since Claude API uses /v1/...).
|
|
235
234
|
* @returns RouteGroup with Claude-compatible endpoints.
|
|
236
235
|
*/
|
|
237
|
-
export declare function createClaudeProxyRoutes(modelRouter?:
|
|
236
|
+
export declare function createClaudeProxyRoutes(modelRouter?: ModelRouterInterface, basePath?: string, accountStrategy?: "round-robin" | "fill-first", passthroughMode?: boolean, primaryAccountKey?: string, accountAllowlistOrRuntimeOptions?: AccountAllowlist | ClaudeProxyRouteRuntimeOptions): RouteGroup;
|
|
238
237
|
export declare function getTransientSameAccountRetryDelayMs(retryNumber: number): number;
|
|
239
238
|
declare function describeTransportError(error: unknown): string;
|
|
240
239
|
/**
|