@juspay/neurolink 9.91.1 → 9.92.1

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.
@@ -53,10 +53,9 @@ const BLOCKED_UPSTREAM_HEADERS = new Set([
53
53
  let primaryAccountIndex = 0;
54
54
  /** Track account count so we can reset primaryAccountIndex when it changes. */
55
55
  let lastKnownAccountCount = 0;
56
- /** Stable account key (e.g. "anthropic:user@example.com") of the configured
57
- * home/primary account. Set once at proxy boot from routing.primaryAccount.
58
- * When undefined, home semantics fall back to enabledAccounts[0] (insertion
59
- * order) — preserves pre-existing behavior. */
56
+ /** Stable account key used by legacy fixed-config route factories. Runtime
57
+ * config providers pass the request generation's primary key explicitly.
58
+ * When undefined, home semantics retain insertion-order behavior. */
60
59
  let configuredPrimaryAccountKey;
61
60
  let configuredAccountAllowlist;
62
61
  const MAX_AUTH_RETRIES = 5;
@@ -130,8 +129,7 @@ function advancePrimaryIfCurrent(accountKey, enabledCount, primaryAccountKey) {
130
129
  * no key is configured or the key cannot be matched (account disabled/
131
130
  * removed). The resolution is per-request because enabledAccounts membership
132
131
  * can shift between requests. */
133
- function resolveHomeIndex(enabledAccounts) {
134
- const primaryAccountKey = configuredPrimaryAccountKey;
132
+ function resolveHomeIndex(enabledAccounts, primaryAccountKey = configuredPrimaryAccountKey) {
135
133
  if (!primaryAccountKey) {
136
134
  return 0;
137
135
  }
@@ -142,11 +140,11 @@ function resolveHomeIndex(enabledAccounts) {
142
140
  * primaryAccountIndex back to its index so traffic returns to the preferred
143
141
  * account once its rate limit window expires. Called at the start of each
144
142
  * request. Home is resolved fresh per call via resolveHomeIndex. */
145
- function maybeResetPrimaryToHome(enabledAccounts) {
143
+ function maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey = configuredPrimaryAccountKey) {
146
144
  if (enabledAccounts.length <= 1) {
147
145
  return;
148
146
  }
149
- const homeIndex = resolveHomeIndex(enabledAccounts);
147
+ const homeIndex = resolveHomeIndex(enabledAccounts, primaryAccountKey);
150
148
  if (primaryAccountIndex === homeIndex) {
151
149
  return;
152
150
  }
@@ -486,12 +484,7 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
486
484
  * Cooling/rejected accounts sort last, soonest-back-to-service first, as
487
485
  * last resort.
488
486
  */
489
- function orderAccountsByQuota(accounts, now) {
490
- const primaryKey = configuredPrimaryAccountKey;
491
- // Read the env-tunable thresholds once per sort: cheaper than per-compare,
492
- // and a mid-sort env change can never make the comparator intransitive.
493
- const sessionSoftLimit = getSessionSoftLimit();
494
- const sessionResetToleranceMs = getSessionResetToleranceMs();
487
+ function orderAccountsByQuota(accounts, now, primaryKey = configuredPrimaryAccountKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) {
495
488
  const metrics = (key) => accountSortMetrics(key, now, sessionSoftLimit, sessionResetToleranceMs);
496
489
  return [...accounts].sort((a, b) => {
497
490
  const ma = metrics(a.key);
@@ -1545,7 +1538,7 @@ async function handleClaudePassthroughJsonResponse(args) {
1545
1538
  return responseJson;
1546
1539
  }
1547
1540
  async function loadClaudeProxyAccounts(args) {
1548
- const { ctx, body, tracer, requestStartTime, accountStrategy, buildLoggedClaudeError, } = args;
1541
+ const { ctx, body, tracer, requestStartTime, accountStrategy, primaryAccountKey = configuredPrimaryAccountKey, accountAllowlist = configuredAccountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, } = args;
1549
1542
  const fs = await import("fs");
1550
1543
  const os = await import("os");
1551
1544
  const accounts = [];
@@ -1558,7 +1551,7 @@ async function loadClaudeProxyAccounts(args) {
1558
1551
  }
1559
1552
  const compoundKeys = await tokenStore.listByPrefix("anthropic:");
1560
1553
  for (const key of compoundKeys) {
1561
- if (!isAccountAllowed(key, configuredAccountAllowlist)) {
1554
+ if (!isAccountAllowed(key, accountAllowlist)) {
1562
1555
  logger.debug(`[proxy] skipping account=${key} (not in account allowlist)`);
1563
1556
  continue;
1564
1557
  }
@@ -1665,7 +1658,7 @@ async function loadClaudeProxyAccounts(args) {
1665
1658
  addAccount();
1666
1659
  }
1667
1660
  if (accounts.length === 0 &&
1668
- shouldLoadFallbackCredential(compoundKeys.length, LEGACY_ANTHROPIC_ACCOUNT_KEY, configuredAccountAllowlist)) {
1661
+ shouldLoadFallbackCredential(compoundKeys.length, LEGACY_ANTHROPIC_ACCOUNT_KEY, accountAllowlist)) {
1669
1662
  try {
1670
1663
  const creds = JSON.parse(fs.readFileSync(legacyCredPath, "utf8"));
1671
1664
  const legacyAccount = await tryLoadLegacyAccount(creds, legacyCredPath);
@@ -1679,7 +1672,7 @@ async function loadClaudeProxyAccounts(args) {
1679
1672
  }
1680
1673
  if (process.env.ANTHROPIC_API_KEY &&
1681
1674
  accounts.length === 0 &&
1682
- shouldLoadFallbackCredential(compoundKeys.length, ENV_ANTHROPIC_ACCOUNT_KEY, configuredAccountAllowlist)) {
1675
+ shouldLoadFallbackCredential(compoundKeys.length, ENV_ANTHROPIC_ACCOUNT_KEY, accountAllowlist)) {
1683
1676
  accounts.push({
1684
1677
  key: ENV_ANTHROPIC_ACCOUNT_KEY,
1685
1678
  label: "env",
@@ -1688,7 +1681,7 @@ async function loadClaudeProxyAccounts(args) {
1688
1681
  });
1689
1682
  }
1690
1683
  if (accounts.length === 0) {
1691
- const noCredentialsMessage = configuredAccountAllowlist
1684
+ const noCredentialsMessage = accountAllowlist
1692
1685
  ? "No allowed Anthropic credentials are currently available"
1693
1686
  : compoundKeys.length > 0
1694
1687
  ? "Configured Anthropic accounts are disabled or unavailable"
@@ -1728,12 +1721,12 @@ async function loadClaudeProxyAccounts(args) {
1728
1721
  let orderedAccounts = [...enabledAccounts];
1729
1722
  const quotaOrdered = accountStrategy === "fill-first" &&
1730
1723
  orderedAccounts.length > 1 &&
1731
- isQuotaRoutingEnabled();
1724
+ quotaRoutingEnabled;
1732
1725
  if (quotaOrdered) {
1733
1726
  // Fill-first with a smart fill order: spend the account whose window resets
1734
1727
  // soonest first (max utilization), proactively skipping any whose window is
1735
1728
  // rejected until its reset. Supersedes the static home/primary index.
1736
- orderedAccounts = orderAccountsByQuota(enabledAccounts, Date.now());
1729
+ orderedAccounts = orderAccountsByQuota(enabledAccounts, Date.now(), primaryAccountKey, sessionSoftLimit, sessionResetToleranceMs);
1737
1730
  if (logger.shouldLog("debug")) {
1738
1731
  logger.debug(`[proxy] quota-ordered fill sequence: ${orderedAccounts
1739
1732
  .map((a) => a.label)
@@ -1743,7 +1736,7 @@ async function loadClaudeProxyAccounts(args) {
1743
1736
  else {
1744
1737
  if (accountStrategy === "round-robin" &&
1745
1738
  orderedAccounts.length !== lastKnownAccountCount) {
1746
- primaryAccountIndex = resolveHomeIndex(orderedAccounts);
1739
+ primaryAccountIndex = resolveHomeIndex(orderedAccounts, primaryAccountKey);
1747
1740
  lastKnownAccountCount = orderedAccounts.length;
1748
1741
  }
1749
1742
  if (orderedAccounts.length > 1) {
@@ -3768,7 +3761,7 @@ function shouldAttemptClaudeFallback(loopState) {
3768
3761
  return loopState.invalidRequestFailure === null;
3769
3762
  }
3770
3763
  async function handleAnthropicRoutedClaudeRequest(args) {
3771
- const { ctx, body, modelRouter, tracer, requestStartTime, accountStrategy, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
3764
+ const { ctx, body, modelRouter, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
3772
3765
  const parsedRequest = parseClaudeRequest(body);
3773
3766
  const loadedAccounts = await loadClaudeProxyAccounts({
3774
3767
  ctx,
@@ -3776,6 +3769,11 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3776
3769
  tracer,
3777
3770
  requestStartTime,
3778
3771
  accountStrategy,
3772
+ primaryAccountKey,
3773
+ accountAllowlist,
3774
+ quotaRoutingEnabled,
3775
+ sessionSoftLimit,
3776
+ sessionResetToleranceMs,
3779
3777
  buildLoggedClaudeError,
3780
3778
  });
3781
3779
  if ("response" in loadedAccounts) {
@@ -3798,9 +3796,9 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3798
3796
  // from live quota (soonest-reset-first) rather than a static home index.
3799
3797
  const usingQuotaOrder = accountStrategy === "fill-first" &&
3800
3798
  enabledAccounts.length > 1 &&
3801
- isQuotaRoutingEnabled();
3799
+ quotaRoutingEnabled;
3802
3800
  if (!usingQuotaOrder) {
3803
- maybeResetPrimaryToHome(enabledAccounts);
3801
+ maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey);
3804
3802
  }
3805
3803
  // Never re-hammer accounts with a known active cooldown. When every account
3806
3804
  // is cooling, report the earliest persisted retry time without an upstream
@@ -4154,6 +4152,11 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4154
4152
  // ---------------------------------------------------------------------------
4155
4153
  // Route factory
4156
4154
  // ---------------------------------------------------------------------------
4155
+ function isClaudeProxyRouteRuntimeOptions(value) {
4156
+ return (value !== undefined &&
4157
+ "runtimeConfigProvider" in value &&
4158
+ typeof value.runtimeConfigProvider === "function");
4159
+ }
4157
4160
  /**
4158
4161
  * Create Claude-compatible proxy routes.
4159
4162
  *
@@ -4164,7 +4167,13 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4164
4167
  * @param basePath - Base path prefix (default: "" since Claude API uses /v1/...).
4165
4168
  * @returns RouteGroup with Claude-compatible endpoints.
4166
4169
  */
4167
- export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrategy = "fill-first", passthroughMode = false, primaryAccountKey, accountAllowlist) {
4170
+ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrategy = "fill-first", passthroughMode = false, primaryAccountKey, accountAllowlistOrRuntimeOptions) {
4171
+ const accountAllowlist = isClaudeProxyRouteRuntimeOptions(accountAllowlistOrRuntimeOptions)
4172
+ ? accountAllowlistOrRuntimeOptions.accountAllowlist
4173
+ : accountAllowlistOrRuntimeOptions;
4174
+ const runtimeConfigProvider = isClaudeProxyRouteRuntimeOptions(accountAllowlistOrRuntimeOptions)
4175
+ ? accountAllowlistOrRuntimeOptions.runtimeConfigProvider
4176
+ : undefined;
4168
4177
  configuredPrimaryAccountKey = primaryAccountKey;
4169
4178
  configuredAccountAllowlist = accountAllowlist
4170
4179
  ? new Set(accountAllowlist)
@@ -4179,6 +4188,18 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
4179
4188
  method: "POST",
4180
4189
  path: `${basePath}/v1/messages`,
4181
4190
  handler: async (ctx) => {
4191
+ const requestRouting = runtimeConfigProvider?.() ?? {
4192
+ generation: 0,
4193
+ strategy: accountStrategy,
4194
+ modelRouter,
4195
+ passthrough: passthroughMode,
4196
+ primaryAccountKey,
4197
+ accountAllowlist,
4198
+ quotaRoutingEnabled: isQuotaRoutingEnabled(),
4199
+ sessionSoftLimit: getSessionSoftLimit(),
4200
+ sessionResetToleranceMs: getSessionResetToleranceMs(),
4201
+ };
4202
+ const requestModelRouter = requestRouting.modelRouter;
4182
4203
  const body = ctx.body;
4183
4204
  // 1. Validate
4184
4205
  if (typeof body?.model !== "string" ||
@@ -4188,12 +4209,12 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
4188
4209
  // 2. Resolve model via router (or pass through to anthropic)
4189
4210
  // Guard: without a model router, only Claude models are allowed.
4190
4211
  const modelLower = body.model.toLowerCase();
4191
- if (!modelRouter && !modelLower.startsWith("claude-")) {
4212
+ if (!requestModelRouter && !modelLower.startsWith("claude-")) {
4192
4213
  return buildClaudeError(404, `Model '${body.model}' is not an Anthropic model. ` +
4193
4214
  `The proxy only supports Claude models. ` +
4194
4215
  `Use a model router to route non-Claude models to other providers.`);
4195
4216
  }
4196
- const route = modelRouter?.resolve(body.model) ?? {
4217
+ const route = requestModelRouter?.resolve(body.model) ?? {
4197
4218
  provider: "anthropic",
4198
4219
  model: body.model,
4199
4220
  };
@@ -4213,7 +4234,7 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
4213
4234
  }
4214
4235
  if (route.provider === "anthropic") {
4215
4236
  tracer?.setMode("passthrough");
4216
- if (passthroughMode) {
4237
+ if (requestRouting.passthrough) {
4217
4238
  return handleClaudePassthroughRequest({
4218
4239
  ctx,
4219
4240
  body,
@@ -4226,10 +4247,15 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
4226
4247
  return handleAnthropicRoutedClaudeRequest({
4227
4248
  ctx,
4228
4249
  body,
4229
- modelRouter,
4250
+ modelRouter: requestModelRouter,
4230
4251
  tracer,
4231
4252
  requestStartTime,
4232
- accountStrategy,
4253
+ accountStrategy: requestRouting.strategy,
4254
+ primaryAccountKey: requestRouting.primaryAccountKey,
4255
+ accountAllowlist: requestRouting.accountAllowlist,
4256
+ quotaRoutingEnabled: requestRouting.quotaRoutingEnabled,
4257
+ sessionSoftLimit: requestRouting.sessionSoftLimit,
4258
+ sessionResetToleranceMs: requestRouting.sessionResetToleranceMs,
4233
4259
  buildLoggedClaudeError,
4234
4260
  logProxyBody,
4235
4261
  logFinalRequest,
@@ -4243,7 +4269,7 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
4243
4269
  provider: route.provider,
4244
4270
  model: route.model,
4245
4271
  },
4246
- modelRouter,
4272
+ modelRouter: requestModelRouter,
4247
4273
  tracer,
4248
4274
  requestStartTime,
4249
4275
  logProxyBody,
@@ -4274,11 +4300,16 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
4274
4300
  {
4275
4301
  method: "GET",
4276
4302
  path: `${basePath}/v1/models`,
4277
- handler: async (_ctx) => withSpan({
4278
- name: "neurolink.http.claudeProxy.listModels",
4279
- tracer: tracers.http,
4280
- attributes: { "http.route": `${basePath}/v1/models` },
4281
- }, async () => buildAnthropicModelsListResponse(modelRouter)),
4303
+ handler: async (_ctx) => {
4304
+ const requestModelRouter = runtimeConfigProvider
4305
+ ? runtimeConfigProvider().modelRouter
4306
+ : modelRouter;
4307
+ return withSpan({
4308
+ name: "neurolink.http.claudeProxy.listModels",
4309
+ tracer: tracers.http,
4310
+ attributes: { "http.route": `${basePath}/v1/models` },
4311
+ }, async () => buildAnthropicModelsListResponse(requestModelRouter));
4312
+ },
4282
4313
  description: "List available models (Anthropic schema)",
4283
4314
  tags: ["claude-proxy", "models"],
4284
4315
  },
@@ -11,8 +11,7 @@
11
11
  * An optional ModelRouter can remap incoming model names to different
12
12
  * provider/model pairs (e.g. "gpt-4o" -> vertex/gemini-2.5-pro).
13
13
  */
14
- import type { ModelRouter } from "../../proxy/modelRouter.js";
15
- import type { RouteGroup } from "../../types/index.js";
14
+ import type { ModelRouterInterface, ProxyRuntimeConfigProvider, RouteGroup } from "../../types/index.js";
16
15
  /**
17
16
  * Create OpenAI-compatible proxy routes.
18
17
  *
@@ -27,4 +26,4 @@ import type { RouteGroup } from "../../types/index.js";
27
26
  * headers — to avoid SSRF.
28
27
  * @returns RouteGroup with OpenAI-compatible endpoints.
29
28
  */
30
- export declare function createOpenAIProxyRoutes(modelRouter?: ModelRouter, basePath?: string, loopbackPort?: number): RouteGroup;
29
+ export declare function createOpenAIProxyRoutes(modelRouter?: ModelRouterInterface, basePath?: string, loopbackPort?: number, runtimeConfigProvider?: ProxyRuntimeConfigProvider): RouteGroup;
@@ -227,7 +227,7 @@ async function handleOpenAIToAnthropicBridge(args) {
227
227
  * headers — to avoid SSRF.
228
228
  * @returns RouteGroup with OpenAI-compatible endpoints.
229
229
  */
230
- export function createOpenAIProxyRoutes(modelRouter, basePath = "", loopbackPort = DEFAULT_LOOPBACK_PORT) {
230
+ export function createOpenAIProxyRoutes(modelRouter, basePath = "", loopbackPort = DEFAULT_LOOPBACK_PORT, runtimeConfigProvider) {
231
231
  return {
232
232
  prefix: `${basePath}/v1`,
233
233
  routes: [
@@ -239,6 +239,9 @@ export function createOpenAIProxyRoutes(modelRouter, basePath = "", loopbackPort
239
239
  path: `${basePath}/v1/chat/completions`,
240
240
  description: "OpenAI-compatible chat completions (translation mode)",
241
241
  handler: async (ctx) => {
242
+ const requestModelRouter = runtimeConfigProvider
243
+ ? runtimeConfigProvider().modelRouter
244
+ : modelRouter;
242
245
  const requestStartTime = Date.now();
243
246
  const body = ctx.body;
244
247
  // --- Validation ---
@@ -246,8 +249,8 @@ export function createOpenAIProxyRoutes(modelRouter, basePath = "", loopbackPort
246
249
  return buildOpenAIErrorResponse(400, "Request must include 'model' and 'messages' fields");
247
250
  }
248
251
  // --- Resolve target provider/model ---
249
- const route = modelRouter
250
- ? modelRouter.resolve(body.model)
252
+ const route = requestModelRouter
253
+ ? requestModelRouter.resolve(body.model)
251
254
  : { provider: null, model: body.model };
252
255
  const targetProvider = route.provider ?? undefined;
253
256
  const targetModel = route.model ?? body.model;
@@ -302,7 +305,7 @@ export function createOpenAIProxyRoutes(modelRouter, basePath = "", loopbackPort
302
305
  const plan = buildProxyTranslationPlan({
303
306
  provider: targetProvider ?? "auto",
304
307
  model: targetModel,
305
- }, modelRouter?.getFallbackChain() ?? [], body.model,
308
+ }, requestModelRouter?.getFallbackChain() ?? [], body.model,
306
309
  // The classifier only reads fields present on both types.
307
310
  adapted);
308
311
  const attempts = plan.attempts;
@@ -369,7 +372,10 @@ export function createOpenAIProxyRoutes(modelRouter, basePath = "", loopbackPort
369
372
  path: `${basePath}/v1/models`,
370
373
  description: "List available models in OpenAI format",
371
374
  handler: async () => {
372
- return buildModelsListResponse(modelRouter);
375
+ const requestModelRouter = runtimeConfigProvider
376
+ ? runtimeConfigProvider().modelRouter
377
+ : modelRouter;
378
+ return buildModelsListResponse(requestModelRouter);
373
379
  },
374
380
  },
375
381
  ],
@@ -837,6 +837,14 @@ export type ProxyState = {
837
837
  managedBy?: "launchd" | "manual";
838
838
  /** Whether the proxy is running in transparent passthrough mode */
839
839
  passthrough?: boolean;
840
+ /** Active hot-reload configuration generation. */
841
+ configGeneration?: number;
842
+ /** Timestamp when the active configuration generation was loaded. */
843
+ configLoadedAt?: string;
844
+ /** Last rejected hot-reload error, when any. */
845
+ lastConfigReloadError?: string;
846
+ /** Absolute path watched for proxy routing configuration changes. */
847
+ configFile?: string;
840
848
  };
841
849
  /** Stored credentials for an authenticated provider. */
842
850
  export type StoredCredentials = {
@@ -18,7 +18,7 @@ import type { Hono } from "hono";
18
18
  import type { Ora } from "ora";
19
19
  import type { MCPToolRegistry } from "../mcp/toolRegistry.js";
20
20
  import type { ProxyTracer } from "../proxy/proxyTracer.js";
21
- import type { FallbackEntry, ProxyRoutingConfig, CloakingConfig } from "./subscription.js";
21
+ import type { FallbackEntry, ModelMapping, ProxyRoutingConfig, CloakingConfig } from "./subscription.js";
22
22
  import type { RouteDeprecation } from "./server.js";
23
23
  /**
24
24
  * Type describing the ModelRouter contract.
@@ -28,6 +28,8 @@ export type ModelRouterInterface = {
28
28
  resolve(requestedModel: string): RouteResult;
29
29
  isClaudeTarget(requestedModel: string): boolean;
30
30
  getFallbackChain(): FallbackEntry[];
31
+ getModelMappings?: () => ModelMapping[];
32
+ getPassthroughModels?: () => string[];
31
33
  };
32
34
  /** A single text block in a Claude content array. */
33
35
  export type ClaudeTextBlock = {
@@ -1245,6 +1247,70 @@ export type LoadedProxyConfig = {
1245
1247
  strategy?: ProxyStartStrategy;
1246
1248
  };
1247
1249
  };
1250
+ /** Routing values captured once when a proxy request begins. */
1251
+ export type ProxyRequestRoutingSnapshot = {
1252
+ generation: number;
1253
+ strategy: ProxyStartStrategy;
1254
+ modelRouter?: ModelRouterInterface;
1255
+ passthrough: boolean;
1256
+ primaryAccountKey?: string;
1257
+ accountAllowlist?: ReadonlySet<string>;
1258
+ quotaRoutingEnabled: boolean;
1259
+ sessionSoftLimit: number;
1260
+ sessionResetToleranceMs: number;
1261
+ };
1262
+ /** Immutable last-known-good proxy configuration published at runtime. */
1263
+ export type ProxyRuntimeConfigSnapshot = ProxyRequestRoutingSnapshot & {
1264
+ loadedAt: string;
1265
+ configHash: string;
1266
+ proxyConfig: LoadedProxyConfig | null;
1267
+ };
1268
+ /** Source that requested a runtime configuration reload. */
1269
+ export type ProxyRuntimeConfigReloadSource = "startup" | "watch" | "sighup" | "manual";
1270
+ /** Result returned after a serialized runtime configuration reload attempt. */
1271
+ export type ProxyRuntimeConfigReloadResult = {
1272
+ applied: boolean;
1273
+ changed: boolean;
1274
+ generation: number;
1275
+ error?: string;
1276
+ };
1277
+ /** Safe runtime configuration diagnostics exposed through proxy status. */
1278
+ export type ProxyRuntimeConfigStatus = {
1279
+ configPath: string;
1280
+ envFilePath?: string;
1281
+ generation: number;
1282
+ loadedAt: string;
1283
+ configHash: string;
1284
+ watching: boolean;
1285
+ lastReloadAttemptAt?: string;
1286
+ lastReloadAt?: string;
1287
+ lastReloadSource?: ProxyRuntimeConfigReloadSource;
1288
+ lastReloadError?: string;
1289
+ consecutiveFailures: number;
1290
+ };
1291
+ /** Constructor options for the proxy runtime configuration store. */
1292
+ export type ProxyRuntimeConfigStoreOptions = {
1293
+ configPath: string;
1294
+ configRequired: boolean;
1295
+ envFilePath?: string;
1296
+ envFileRequired?: boolean;
1297
+ baseEnv: Record<string, string | undefined>;
1298
+ strategyOverride?: ProxyStartStrategy;
1299
+ passthrough: boolean;
1300
+ watchIntervalMs?: number;
1301
+ watchDebounceMs?: number;
1302
+ };
1303
+ /** Runtime configuration provider captured by route factories. */
1304
+ export type ProxyRuntimeConfigProvider = () => ProxyRequestRoutingSnapshot;
1305
+ /** Optional runtime configuration wiring for Claude proxy route factories. */
1306
+ export type ClaudeProxyRouteRuntimeOptions = {
1307
+ accountAllowlist?: AccountAllowlist;
1308
+ runtimeConfigProvider: ProxyRuntimeConfigProvider;
1309
+ };
1310
+ /** Listener invoked after a new configuration generation is published. */
1311
+ export type ProxyRuntimeConfigListener = (snapshot: ProxyRuntimeConfigSnapshot) => void;
1312
+ /** Listener invoked after every successful or rejected reload attempt. */
1313
+ export type ProxyRuntimeConfigReloadListener = (result: ProxyRuntimeConfigReloadResult, status: ProxyRuntimeConfigStatus) => void;
1248
1314
  /**
1249
1315
  * Handle for a NeuroLink runtime created by the proxy start command.
1250
1316
  * The `neurolink` field is typed structurally (only the method used by the
@@ -909,6 +909,12 @@ export type ProxyRoutingConfig = {
909
909
  modelMappings: ModelMapping[];
910
910
  fallbackChain: FallbackEntry[];
911
911
  passthroughModels?: string[];
912
+ /** Enable quota-aware fill-first account ordering. Defaults to true. */
913
+ quotaRouting?: boolean;
914
+ /** Session utilization threshold used to proactively demote an account. */
915
+ sessionSoftLimit?: number;
916
+ /** Reset-time bucket width used when ordering quota windows. */
917
+ sessionResetToleranceMs?: number;
912
918
  /** Email/label of the Anthropic account that should be tried first
913
919
  * ("home"). When absent, falls back to insertion-order index 0.
914
920
  * Resolved per-request to a stable key (anthropic:<email>); does not
package/dist/neurolink.js CHANGED
@@ -5968,14 +5968,19 @@ Current user's request: ${currentInput}`;
5968
5968
  instanceConfig: this.toolsConfig,
5969
5969
  builtinToolNames: Object.keys(directAgentTools),
5970
5970
  });
5971
- const record = {};
5971
+ // Null prototype + own-property checks: a tool named "__proto__" must
5972
+ // become an own entry — with a plain `{}`, `"__proto__" in record` is
5973
+ // truthy via the prototype and the tool would be silently dropped from
5974
+ // the listing while surviving the native gate. Matches the hardening on
5975
+ // every other record in the tool-resolution pipeline.
5976
+ const record = Object.create(null);
5972
5977
  for (const tool of tools) {
5973
- if (!(tool.name in record)) {
5978
+ if (!Object.hasOwn(record, tool.name)) {
5974
5979
  record[tool.name] = tool;
5975
5980
  }
5976
5981
  }
5977
5982
  const gated = applyToolGate(record, policy);
5978
- const filtered = tools.filter((t) => t.name in gated);
5983
+ const filtered = tools.filter((t) => Object.hasOwn(gated, t.name));
5979
5984
  if (filtered.length !== tools.length) {
5980
5985
  logger.debug(`Tool info filtering applied for system prompt`, {
5981
5986
  beforeCount: tools.length,
@@ -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
+ }