@juspay/neurolink 10.8.11 → 10.8.13

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.
@@ -116,6 +116,40 @@ const POPULAR_MCP_SERVERS = {
116
116
  },
117
117
  };
118
118
  const MCP_STATUS_TIMEOUT_MS = 30_000;
119
+ /** Where `externalServerManager` looks for manually configured servers. */
120
+ const MCP_CONFIG_FILENAME = ".mcp-config.json";
121
+ /**
122
+ * Explain an empty server list in terms of what the user's directory actually
123
+ * looks like.
124
+ *
125
+ * "Found 0 MCP servers" plus two generic tips reads as an error with no cause:
126
+ * the common case is simply that this project has no `.mcp-config.json`, and
127
+ * saying so turns a dead end into a next step. When the file *is* present, the
128
+ * cause is different — it parsed but declared nothing usable — so the advice
129
+ * has to be different too, otherwise we'd send the user to create a file they
130
+ * already have.
131
+ */
132
+ function explainEmptyServerList() {
133
+ const configPath = path.join(process.cwd(), MCP_CONFIG_FILENAME);
134
+ const configExists = fs.existsSync(configPath);
135
+ const lines = [];
136
+ if (configExists) {
137
+ lines.push(chalk.blue(`📄 Found ${MCP_CONFIG_FILENAME}, but it declares no usable servers.`), chalk.blue(` Check the "mcpServers" object in ${configPath} — each entry needs a "command" (stdio) or "url" (http/sse/websocket).`));
138
+ }
139
+ else {
140
+ lines.push(chalk.blue(`📄 No ${MCP_CONFIG_FILENAME} in this directory (${process.cwd()}).`), chalk.blue(" That file is where NeuroLink reads manually configured servers from."));
141
+ }
142
+ // Neither `install` nor `add` persists anything: both route through
143
+ // NeuroLink.addInMemoryMCPServer(), which registers into the in-process tool
144
+ // registry and is gone when the process exits. Nothing in the CLI writes
145
+ // MCP_CONFIG_FILENAME. Saying otherwise sends the user looking for a file
146
+ // that was never created — so the persistent path is spelled out as the
147
+ // manual edit it actually is.
148
+ lines.push("", chalk.blue("Next steps:"), chalk.blue(configExists
149
+ ? ` • Add an entry to the "mcpServers" object in ${MCP_CONFIG_FILENAME} — editing that file is the only way to configure a server permanently.`
150
+ : ` • Create ${MCP_CONFIG_FILENAME} here with an "mcpServers" object — each entry needs a "command" (stdio) or "url" (http/sse/websocket). Writing that file is the only way to configure a server permanently.`), chalk.blue(" • neurolink mcp install <server> register a popular server for the current run only"), chalk.blue(" • neurolink mcp discover discover tools from servers already reachable"), chalk.blue(" • neurolink mcp --help all MCP subcommands"));
151
+ return lines.join("\n");
152
+ }
119
153
  /**
120
154
  * MCP CLI command factory
121
155
  */
@@ -199,9 +233,9 @@ export class MCPCommandFactory {
199
233
  default: false,
200
234
  description: "Suppress non-essential output",
201
235
  })
202
- .example("neurolink discover", "Discover MCP servers from all sources")
203
- .example("neurolink discover --source claude-desktop", "Discover from Claude Desktop only")
204
- .example("neurolink discover --auto-install", "Discover and auto-install servers");
236
+ .example("neurolink mcp discover", "Discover MCP servers from all sources")
237
+ .example("neurolink mcp discover --source claude-desktop", "Discover from Claude Desktop only")
238
+ .example("neurolink mcp discover --auto-install", "Discover and auto-install servers");
205
239
  },
206
240
  handler: async (argv) => await MCPCommandFactory.executeDiscover(argv),
207
241
  };
@@ -357,8 +391,7 @@ export class MCPCommandFactory {
357
391
  }
358
392
  if (allServers.length === 0) {
359
393
  logger.always(chalk.yellow("No MCP servers configured."));
360
- logger.always(chalk.blue("💡 Use 'neurolink mcp install <server>' to install popular servers"));
361
- logger.always(chalk.blue("💡 Use 'neurolink discover' to find existing servers"));
394
+ logger.always(explainEmptyServerList());
362
395
  return;
363
396
  }
364
397
  // Format and display results
@@ -11,8 +11,27 @@
11
11
  */
12
12
  import type { CommandModule } from "yargs";
13
13
  import type { Hono } from "hono";
14
- import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
14
+ import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxySupervisorState, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
15
15
  import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
16
+ /**
17
+ * Drop a supervisor `version` that is not a string.
18
+ *
19
+ * `StateFileManager.load()` is a bare `JSON.parse(content) as T` — it validates
20
+ * nothing. A state file written by a different build, or half-written during a
21
+ * crash, can carry any JSON type here, and every status renderer interpolates
22
+ * the field straight into `v${...}`. Coercing at the single load boundary keeps
23
+ * a non-string from reaching the output as "v[object Object]".
24
+ */
25
+ export declare function normalizeSupervisorState(state: ProxySupervisorState | null): ProxySupervisorState | null;
26
+ /**
27
+ * Whether a rolling handoff can actually occur.
28
+ *
29
+ * A live supervisor PID alone is not enough: a supervisor from a build
30
+ * predating rolling state leaves `rolling` absent, and calling that a handoff
31
+ * makes both `/status` clients and the CLI wait for an activation that will
32
+ * never come. Gate on the capability, not on the process.
33
+ */
34
+ export declare function isRollingHandoffCapable(state: ProxySupervisorState | null, isRunning?: (pid: number) => boolean): boolean;
16
35
  /**
17
36
  * Best-effort check that a pid actually belongs to a neurolink proxy process,
18
37
  * so a stale/recycled supervisor pid is never mistaken for a live supervisor
@@ -81,8 +81,42 @@ function clearProxyState() {
81
81
  function saveProxySupervisorState(state) {
82
82
  proxySupervisorStateManager.save(state);
83
83
  }
84
+ /**
85
+ * Drop a supervisor `version` that is not a string.
86
+ *
87
+ * `StateFileManager.load()` is a bare `JSON.parse(content) as T` — it validates
88
+ * nothing. A state file written by a different build, or half-written during a
89
+ * crash, can carry any JSON type here, and every status renderer interpolates
90
+ * the field straight into `v${...}`. Coercing at the single load boundary keeps
91
+ * a non-string from reaching the output as "v[object Object]".
92
+ */
93
+ export function normalizeSupervisorState(state) {
94
+ if (!state) {
95
+ return null;
96
+ }
97
+ return typeof state.version === "string" || state.version === undefined
98
+ ? state
99
+ : { ...state, version: undefined };
100
+ }
101
+ /**
102
+ * Whether a rolling handoff can actually occur.
103
+ *
104
+ * A live supervisor PID alone is not enough: a supervisor from a build
105
+ * predating rolling state leaves `rolling` absent, and calling that a handoff
106
+ * makes both `/status` clients and the CLI wait for an activation that will
107
+ * never come. Gate on the capability, not on the process.
108
+ */
109
+ export function isRollingHandoffCapable(state, isRunning = isProcessRunning) {
110
+ if (!state) {
111
+ return false;
112
+ }
113
+ // Structural, not just non-null: the same unvalidated `as T` load that lets
114
+ // `version` be an object lets `rolling` be a string.
115
+ const hasRollingState = typeof state.rolling === "object" && state.rolling !== null;
116
+ return isRunning(state.pid) && hasRollingState;
117
+ }
84
118
  function loadProxySupervisorState() {
85
- return proxySupervisorStateManager.load();
119
+ return normalizeSupervisorState(proxySupervisorStateManager.load());
86
120
  }
87
121
  function clearProxySupervisorState() {
88
122
  proxySupervisorStateManager.clear();
@@ -1556,6 +1590,7 @@ export async function createProxyStartApp(params) {
1556
1590
  : undefined;
1557
1591
  const runtimeState = loadProxyState();
1558
1592
  const supervisorState = loadProxySupervisorState();
1593
+ const rollingSupervisorRunning = isRollingHandoffCapable(supervisorState);
1559
1594
  const updateState = loadUpdateState();
1560
1595
  const cooldowns = await loadAccountCooldowns();
1561
1596
  const storedAccountKeys = new Set();
@@ -1779,6 +1814,7 @@ export async function createProxyStartApp(params) {
1779
1814
  autoUpdate: {
1780
1815
  enabled: isProxyAutoUpdateEnabled(),
1781
1816
  supervisorPid: supervisorState?.pid ?? null,
1817
+ supervisorVersion: supervisorState?.version ?? null,
1782
1818
  rolling: supervisorState?.rolling ?? null,
1783
1819
  updaterPid: activeUpdaterPid ?? null,
1784
1820
  updaterRunning: activeUpdaterPid
@@ -1786,7 +1822,16 @@ export async function createProxyStartApp(params) {
1786
1822
  : false,
1787
1823
  liveVersion: PROXY_VERSION,
1788
1824
  latestVersion: updateState?.lastCheckVersion || null,
1825
+ lastDetectedVersion: updateState?.lastCheckVersion || null,
1826
+ installedVersion: updateState?.installedVersion ??
1827
+ updateState?.lastUpdateVersion ??
1828
+ null,
1829
+ activatedVersion: PROXY_VERSION,
1830
+ pendingActivationVersion: updateState?.pendingRestartVersion ?? null,
1789
1831
  pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
1832
+ activationMode: rollingSupervisorRunning
1833
+ ? "rolling-handoff"
1834
+ : "restart",
1790
1835
  deferredUpdate: updateState?.deferredUpdate ?? null,
1791
1836
  lastCheckAt: updateState?.lastCheckAt ?? null,
1792
1837
  lastUpdateAt: updateState?.lastUpdateAt ?? null,
@@ -2426,6 +2471,7 @@ async function runLaunchdProxySupervisor(argv, spinner) {
2426
2471
  host,
2427
2472
  port,
2428
2473
  startTime: supervisorStartedAt,
2474
+ version: PROXY_VERSION,
2429
2475
  updaterPid: currentUpdaterPid,
2430
2476
  rolling: snapshot,
2431
2477
  });
@@ -2850,11 +2896,18 @@ export const proxyStatusCommand = {
2850
2896
  autoUpdateEnabled: isProxyAutoUpdateEnabled(),
2851
2897
  workerVersion: null,
2852
2898
  supervisorPid: null,
2899
+ supervisorVersion: supervisorState?.version ?? null,
2853
2900
  supervisorRunning: false,
2854
2901
  rolling: null,
2855
2902
  updaterPid: null,
2856
2903
  updaterRunning: false,
2857
2904
  latestVersion: updateState?.lastCheckVersion || null,
2905
+ lastDetectedVersion: updateState?.lastCheckVersion || null,
2906
+ installedVersion: updateState?.installedVersion ??
2907
+ updateState?.lastUpdateVersion ??
2908
+ null,
2909
+ activatedVersion: supervisorState?.rolling.active?.version ?? null,
2910
+ pendingActivationVersion: updateState?.pendingRestartVersion ?? null,
2858
2911
  pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
2859
2912
  deferredUpdate: updateState?.deferredUpdate ?? null,
2860
2913
  lastUpdateFailure: updateState?.lastFailure ?? null,
@@ -2904,6 +2957,7 @@ export const proxyStatusCommand = {
2904
2957
  servingState?.lastConfigReloadError ?? null;
2905
2958
  status.supervisorPid = supervisorPid ?? null;
2906
2959
  status.supervisorRunning = supervisorRunning;
2960
+ status.supervisorVersion = supervisorState?.version ?? null;
2907
2961
  status.rolling = supervisorState?.rolling ?? null;
2908
2962
  status.updaterPid =
2909
2963
  supervisorState?.updaterPid ?? servingState?.updaterPid ?? null;
@@ -2927,6 +2981,7 @@ export const proxyStatusCommand = {
2927
2981
  typeof statusData.version === "string"
2928
2982
  ? statusData.version
2929
2983
  : null;
2984
+ status.activatedVersion = status.workerVersion;
2930
2985
  if (typeof liveConfig?.generation === "number") {
2931
2986
  status.configGeneration = liveConfig.generation;
2932
2987
  }
@@ -2959,6 +3014,9 @@ export const proxyStatusCommand = {
2959
3014
  if (status.supervisorPid) {
2960
3015
  logger.always(` ${chalk.bold("Supervisor:")} ${status.supervisorRunning ? chalk.cyan(status.supervisorPid) : chalk.red(`${status.supervisorPid} (not running)`)}`);
2961
3016
  }
3017
+ if (status.supervisorVersion) {
3018
+ logger.always(` ${chalk.bold("Supervisor version:")} ${chalk.cyan(`v${status.supervisorVersion}`)}`);
3019
+ }
2962
3020
  if (status.workerVersion) {
2963
3021
  logger.always(` ${chalk.bold("Version:")} ${chalk.cyan(`v${status.workerVersion}`)}`);
2964
3022
  }
@@ -2977,8 +3035,14 @@ export const proxyStatusCommand = {
2977
3035
  logger.always(` ${chalk.bold("Started:")} ${chalk.cyan(status.startTime)}`);
2978
3036
  logger.always(` ${chalk.bold("Uptime:")} ${chalk.cyan(formatUptime(status.uptime ?? 0))}`);
2979
3037
  logger.always(` ${chalk.bold("Auto-update:")} ${status.autoUpdateEnabled ? chalk.green(status.updaterRunning ? `enabled (PID ${status.updaterPid})` : "enabled (worker unavailable)") : chalk.yellow("disabled")}`);
2980
- if (status.pendingRestartVersion) {
2981
- logger.always(` ${chalk.bold("Pending:")} ${chalk.yellow(`v${status.pendingRestartVersion} installed; restart pending`)}`);
3038
+ if (status.pendingActivationVersion) {
3039
+ // A live supervisor PID alone does NOT mean rolling handoff is
3040
+ // available: a supervisor from a build predating rolling state leaves
3041
+ // `rolling` absent, and calling that a handoff tells the operator to
3042
+ // wait for an activation that will never come. Gate on the capability,
3043
+ // not on the process.
3044
+ const rollingCapable = status.supervisorRunning && status.rolling !== null;
3045
+ logger.always(` ${chalk.bold(rollingCapable ? "Pending handoff:" : "Pending restart:")} ${chalk.yellow(`v${status.pendingActivationVersion} installed; ${rollingCapable ? "rolling activation pending" : "restart pending"}`)}`);
2982
3046
  }
2983
3047
  if (status.rolling?.candidate) {
2984
3048
  logger.always(` ${chalk.bold("Handoff:")} ${chalk.yellow(`preparing v${status.rolling.candidate.expectedVersion} (PID ${status.rolling.candidate.pid})`)}`);
@@ -2995,6 +3059,12 @@ export const proxyStatusCommand = {
2995
3059
  if (status.latestVersion) {
2996
3060
  logger.always(` ${chalk.bold("Latest:")} ${chalk.cyan(`v${status.latestVersion}`)}`);
2997
3061
  }
3062
+ if (status.installedVersion) {
3063
+ logger.always(` ${chalk.bold("Installed:")} ${chalk.cyan(`v${status.installedVersion}`)}`);
3064
+ }
3065
+ if (status.activatedVersion) {
3066
+ logger.always(` ${chalk.bold("Activated:")} ${chalk.cyan(`v${status.activatedVersion}`)}`);
3067
+ }
2998
3068
  if (status.lastUpdateFailure) {
2999
3069
  logger.always(` ${chalk.bold("Update error:")} ${chalk.red(`${status.lastUpdateFailure.stage}: ${status.lastUpdateFailure.message}`)}`);
3000
3070
  }
@@ -1,8 +1,18 @@
1
1
  import type { FallbackEntry, ModelMapping, ProxyRoutingConfig, RouteResult } from "../types/index.js";
2
- /** Default and accepted range for concurrent upstream requests per OAuth account. */
2
+ /** Accepted range for an explicitly configured OAuth account admission cap. */
3
3
  export declare const MIN_MAX_INFLIGHT_PER_ACCOUNT = 1;
4
4
  export declare const MAX_MAX_INFLIGHT_PER_ACCOUNT = 20;
5
- export declare const DEFAULT_MAX_INFLIGHT_PER_ACCOUNT = 2;
5
+ /**
6
+ * The single definition of "a usable admission cap": an integer inside the
7
+ * accepted range. Anything else — `0`, `1.5`, `21`, `NaN` — means unlimited.
8
+ *
9
+ * `parseProxyConfig()` already drops invalid YAML values, but `ProxyRoutingConfig`
10
+ * is an exported type, so a programmatic caller can hand `ModelRouter` a value
11
+ * that never passed through it. Without this, `getMaxInflightPerAccount()` would
12
+ * report a bound (`0`, `21`) that the admission path ignores as unlimited, and
13
+ * the two would disagree about whether the account is capped.
14
+ */
15
+ export declare function normalizeMaxInflightPerAccount(capacity: number | undefined): number | undefined;
6
16
  export declare class ModelRouter {
7
17
  private readonly mappings;
8
18
  private readonly passthrough;
@@ -15,8 +25,8 @@ export declare class ModelRouter {
15
25
  getFallbackChain(): FallbackEntry[];
16
26
  /** Whether translation-layer auto-provider fallback is explicitly enabled. */
17
27
  isAutoFallbackEnabled(): boolean;
18
- /** Maximum concurrent upstream requests admitted for each OAuth account. */
19
- getMaxInflightPerAccount(): number;
28
+ /** Explicit per-account admission cap, or undefined when admission is unlimited. */
29
+ getMaxInflightPerAccount(): number | undefined;
20
30
  /** Return the raw model mapping entries (used by /v1/models). */
21
31
  getModelMappings(): ModelMapping[];
22
32
  /** Return models configured for passthrough (used by /v1/models). */
@@ -1,7 +1,24 @@
1
- /** Default and accepted range for concurrent upstream requests per OAuth account. */
1
+ /** Accepted range for an explicitly configured OAuth account admission cap. */
2
2
  export const MIN_MAX_INFLIGHT_PER_ACCOUNT = 1;
3
3
  export const MAX_MAX_INFLIGHT_PER_ACCOUNT = 20;
4
- export const DEFAULT_MAX_INFLIGHT_PER_ACCOUNT = 2;
4
+ /**
5
+ * The single definition of "a usable admission cap": an integer inside the
6
+ * accepted range. Anything else — `0`, `1.5`, `21`, `NaN` — means unlimited.
7
+ *
8
+ * `parseProxyConfig()` already drops invalid YAML values, but `ProxyRoutingConfig`
9
+ * is an exported type, so a programmatic caller can hand `ModelRouter` a value
10
+ * that never passed through it. Without this, `getMaxInflightPerAccount()` would
11
+ * report a bound (`0`, `21`) that the admission path ignores as unlimited, and
12
+ * the two would disagree about whether the account is capped.
13
+ */
14
+ export function normalizeMaxInflightPerAccount(capacity) {
15
+ return typeof capacity === "number" &&
16
+ Number.isInteger(capacity) &&
17
+ capacity >= MIN_MAX_INFLIGHT_PER_ACCOUNT &&
18
+ capacity <= MAX_MAX_INFLIGHT_PER_ACCOUNT
19
+ ? capacity
20
+ : undefined;
21
+ }
5
22
  export class ModelRouter {
6
23
  mappings;
7
24
  passthrough;
@@ -13,8 +30,7 @@ export class ModelRouter {
13
30
  this.passthrough = new Set(config.passthroughModels ?? []);
14
31
  this.fallback = config.fallbackChain;
15
32
  this.autoFallback = config.autoFallback === true;
16
- this.maxInflightPerAccount =
17
- config.maxInflightPerAccount ?? DEFAULT_MAX_INFLIGHT_PER_ACCOUNT;
33
+ this.maxInflightPerAccount = normalizeMaxInflightPerAccount(config.maxInflightPerAccount);
18
34
  }
19
35
  resolve(requestedModel) {
20
36
  const mapping = this.mappings.get(requestedModel);
@@ -42,7 +58,7 @@ export class ModelRouter {
42
58
  isAutoFallbackEnabled() {
43
59
  return this.autoFallback;
44
60
  }
45
- /** Maximum concurrent upstream requests admitted for each OAuth account. */
61
+ /** Explicit per-account admission cap, or undefined when admission is unlimited. */
46
62
  getMaxInflightPerAccount() {
47
63
  return this.maxInflightPerAccount;
48
64
  }
@@ -48,7 +48,7 @@ export declare function suppressVersion(version: string, reason: string, stateFi
48
48
  * @param stateFilePath - Override path for testing
49
49
  */
50
50
  export declare function recordSuccessfulUpdate(version: string, stateFilePath?: string): void;
51
- /** Record that package installation completed but the live restart is pending. */
51
+ /** Record that the package was validated but live activation is still pending. */
52
52
  export declare function recordUpdateInstalled(version: string, stateFilePath?: string): void;
53
53
  /** Abandon a matching installed version so the next cycle may reinstall it. */
54
54
  export declare function abandonPendingUpdate(version: string, stateFilePath?: string): boolean;
@@ -87,6 +87,7 @@ export function getDefaultUpdateState() {
87
87
  lastCheckAt: new Date(0).toISOString(),
88
88
  lastCheckVersion: "",
89
89
  suppressedVersions: {},
90
+ installedVersion: null,
90
91
  lastUpdateAt: null,
91
92
  lastUpdateVersion: null,
92
93
  pendingRestartVersion: null,
@@ -124,6 +125,20 @@ export function loadUpdateState(stateFilePath) {
124
125
  ...getDefaultUpdateState(),
125
126
  ...candidate,
126
127
  suppressedVersions: candidate.suppressedVersions ?? {},
128
+ // Backfill order matters for state files written before `installedVersion`
129
+ // existed. Back then `recordUpdateInstalled()` set ONLY
130
+ // `pendingRestartVersion`, leaving `lastUpdateVersion` on the previously
131
+ // activated build — so a validated-but-not-yet-running update lives in
132
+ // `pendingRestartVersion` and is the newer of the two. Reading
133
+ // `lastUpdateVersion` first would report the superseded version as
134
+ // installed and re-offer an update that is already on disk.
135
+ installedVersion: typeof candidate.installedVersion === "string"
136
+ ? candidate.installedVersion
137
+ : typeof candidate.pendingRestartVersion === "string"
138
+ ? candidate.pendingRestartVersion
139
+ : typeof candidate.lastUpdateVersion === "string"
140
+ ? candidate.lastUpdateVersion
141
+ : null,
127
142
  pendingRestartVersion: typeof candidate.pendingRestartVersion === "string"
128
143
  ? candidate.pendingRestartVersion
129
144
  : null,
@@ -208,15 +223,17 @@ export function recordSuccessfulUpdate(version, stateFilePath) {
208
223
  const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
209
224
  state.lastUpdateAt = new Date().toISOString();
210
225
  state.lastUpdateVersion = version;
226
+ state.installedVersion = version;
211
227
  state.pendingRestartVersion = null;
212
228
  state.deferredUpdate = null;
213
229
  state.lastFailure = null;
214
230
  delete state.suppressedVersions[version];
215
231
  saveUpdateState(state, stateFilePath);
216
232
  }
217
- /** Record that package installation completed but the live restart is pending. */
233
+ /** Record that the package was validated but live activation is still pending. */
218
234
  export function recordUpdateInstalled(version, stateFilePath) {
219
235
  const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
236
+ state.installedVersion = version;
220
237
  state.pendingRestartVersion = version;
221
238
  state.lastFailure = null;
222
239
  saveUpdateState(state, stateFilePath);
@@ -12,8 +12,9 @@
12
12
  import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
13
13
  import { ProxyTracer } from "../../proxy/proxyTracer.js";
14
14
  import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
15
- import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyQuotaCooldownUpdate, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
16
- declare function tryAcquireAccountAdmission(accountKey: string, capacity: number): AccountAdmissionLease | undefined;
15
+ import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyQuotaCooldownUpdate, ProxyPassthroughAccount, QueuedAccountAdmission, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
16
+ declare function tryAcquireAccountAdmission(accountKey: string, capacity: number | undefined): AccountAdmissionLease | undefined;
17
+ declare function enqueueAccountAdmission(accountKey: string, capacity: number): QueuedAccountAdmission;
17
18
  declare function acquireAccountAdmission(accountKey: string, capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<AccountAdmissionLease | undefined>;
18
19
  declare function acquireFirstAvailableAccountAdmission(accountKeys: string[], capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<{
19
20
  accountKey: string;
@@ -375,10 +376,12 @@ export declare const __testHooks: {
375
376
  acquireAccountAdmission: typeof acquireAccountAdmission;
376
377
  acquireFirstAvailableAccountAdmission: typeof acquireFirstAvailableAccountAdmission;
377
378
  tryAcquireAccountAdmission: typeof tryAcquireAccountAdmission;
379
+ enqueueAccountAdmission: typeof enqueueAccountAdmission;
378
380
  getAccountAdmissionSnapshot: (accountKey: string) => {
379
381
  active: number;
380
382
  waiting: number;
381
383
  };
384
+ hasAccountAdmissionState: (accountKey: string) => boolean;
382
385
  describeTransportError: typeof describeTransportError;
383
386
  redactProviderErrorMessage: typeof redactProviderErrorMessage;
384
387
  isUpstreamOverload: typeof isUpstreamOverload;
@@ -29,7 +29,7 @@ import { createSSEInterceptor } from "../../proxy/sseInterceptor.js";
29
29
  import { createStreamTerminalOutcomeTracker, mergeStreamTerminalOutcome, preflightAnthropicStream, } from "../../proxy/streamOutcome.js";
30
30
  import { isPermanentRefreshFailure, needsRefresh, persistTokens, refreshToken, refreshTokenFromLatest, } from "../../proxy/tokenRefresh.js";
31
31
  import { buildProxyTranslationPlan, parseRetryAfterMs, } from "../../proxy/routingPolicy.js";
32
- import { DEFAULT_MAX_INFLIGHT_PER_ACCOUNT, MAX_MAX_INFLIGHT_PER_ACCOUNT, MIN_MAX_INFLIGHT_PER_ACCOUNT, } from "../../proxy/modelRouter.js";
32
+ import { normalizeMaxInflightPerAccount } from "../../proxy/modelRouter.js";
33
33
  import { writeJsonSnapshotAtomically } from "../../proxy/snapshotPersistence.js";
34
34
  import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "../../proxy/usageStats.js";
35
35
  import { sanitizeForLog } from "../../utils/logSanitize.js";
@@ -122,6 +122,9 @@ const transientCooldownAdmissionSchedules = new Map();
122
122
  * make room for another stream on the same account.
123
123
  */
124
124
  const accountAdmissionStates = new Map();
125
+ const unlimitedAccountAdmissionLease = {
126
+ release: () => undefined,
127
+ };
125
128
  function getAccountAdmissionState(accountKey) {
126
129
  let state = accountAdmissionStates.get(accountKey);
127
130
  if (!state) {
@@ -130,13 +133,6 @@ function getAccountAdmissionState(accountKey) {
130
133
  }
131
134
  return state;
132
135
  }
133
- function normalizeAccountAdmissionCapacity(capacity) {
134
- return Number.isInteger(capacity) &&
135
- capacity >= MIN_MAX_INFLIGHT_PER_ACCOUNT &&
136
- capacity <= MAX_MAX_INFLIGHT_PER_ACCOUNT
137
- ? capacity
138
- : DEFAULT_MAX_INFLIGHT_PER_ACCOUNT;
139
- }
140
136
  function drainAccountAdmissionWaiters(accountKey, state) {
141
137
  while (state.waiters.length > 0 && state.active < state.waiters[0].capacity) {
142
138
  const waiter = state.waiters.shift();
@@ -167,8 +163,11 @@ function discardAccountAdmissionState(accountKey, state) {
167
163
  }
168
164
  }
169
165
  function tryAcquireAccountAdmission(accountKey, capacity) {
166
+ const normalizedCapacity = normalizeMaxInflightPerAccount(capacity);
167
+ if (normalizedCapacity === undefined) {
168
+ return unlimitedAccountAdmissionLease;
169
+ }
170
170
  const state = getAccountAdmissionState(accountKey);
171
- const normalizedCapacity = normalizeAccountAdmissionCapacity(capacity);
172
171
  if (state.waiters.length > 0 || state.active >= normalizedCapacity) {
173
172
  return undefined;
174
173
  }
@@ -176,13 +175,23 @@ function tryAcquireAccountAdmission(accountKey, capacity) {
176
175
  return createAccountAdmissionLease(accountKey, state);
177
176
  }
178
177
  function isAccountAdmissionAvailable(accountKey, capacity) {
178
+ const normalizedCapacity = normalizeMaxInflightPerAccount(capacity);
179
+ if (normalizedCapacity === undefined) {
180
+ return true;
181
+ }
179
182
  const state = accountAdmissionStates.get(accountKey);
180
- const normalizedCapacity = normalizeAccountAdmissionCapacity(capacity);
181
183
  return (!state || (state.waiters.length === 0 && state.active < normalizedCapacity));
182
184
  }
183
185
  function enqueueAccountAdmission(accountKey, capacity) {
186
+ // Validate BEFORE getAccountAdmissionState(), which inserts into the map as a
187
+ // side effect. Throwing after it would strand an empty entry for an account
188
+ // that never got admitted — and the throw path never calls
189
+ // discardAccountAdmissionState() to reap it.
190
+ const normalizedCapacity = normalizeMaxInflightPerAccount(capacity);
191
+ if (normalizedCapacity === undefined) {
192
+ throw new Error("Account admission queue requires an explicit capacity");
193
+ }
184
194
  const state = getAccountAdmissionState(accountKey);
185
- const normalizedCapacity = normalizeAccountAdmissionCapacity(capacity);
186
195
  let queued = true;
187
196
  let grantedLease;
188
197
  let resolveAdmission;
@@ -4309,12 +4318,12 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4309
4318
  loopState.authCooldownMessage = `All ${orderedAccounts.length} Anthropic accounts are temporarily unavailable while OAuth refresh is cooling. Earliest retry at ${new Date(earliestRetryAt).toISOString()}.`;
4310
4319
  }
4311
4320
  }
4312
- const accountAdmissionCapacity = modelRouter?.getMaxInflightPerAccount?.() ??
4313
- DEFAULT_MAX_INFLIGHT_PER_ACCOUNT;
4321
+ const accountAdmissionCapacity = modelRouter?.getMaxInflightPerAccount?.();
4314
4322
  // When every eligible account is busy, reserve the first account that frees
4315
4323
  // instead of arbitrarily waiting behind the last configured account.
4316
4324
  let queuedAccountAdmission;
4317
- if (effectiveAccounts.length > 0 &&
4325
+ if (accountAdmissionCapacity !== undefined &&
4326
+ effectiveAccounts.length > 0 &&
4318
4327
  effectiveAccounts.every((account) => !isAccountAdmissionAvailable(account.key, accountAdmissionCapacity))) {
4319
4328
  queuedAccountAdmission = await acquireFirstAvailableAccountAdmission(effectiveAccounts.map((account) => account.key), accountAdmissionCapacity, ctx.abortSignal);
4320
4329
  }
@@ -5275,12 +5284,16 @@ export const __testHooks = {
5275
5284
  acquireAccountAdmission,
5276
5285
  acquireFirstAvailableAccountAdmission,
5277
5286
  tryAcquireAccountAdmission,
5287
+ enqueueAccountAdmission,
5278
5288
  getAccountAdmissionSnapshot: (accountKey) => {
5279
5289
  const state = accountAdmissionStates.get(accountKey);
5280
5290
  return state
5281
5291
  ? { active: state.active, waiting: state.waiters.length }
5282
5292
  : { active: 0, waiting: 0 };
5283
5293
  },
5294
+ // The snapshot above reports {active:0, waiting:0} both for "no entry" and
5295
+ // for "empty entry", so it cannot see a stranded allocation. This can.
5296
+ hasAccountAdmissionState: (accountKey) => accountAdmissionStates.has(accountKey),
5284
5297
  describeTransportError,
5285
5298
  redactProviderErrorMessage,
5286
5299
  isUpstreamOverload,
@@ -870,6 +870,8 @@ export type ProxySupervisorState = {
870
870
  host: string;
871
871
  port: number;
872
872
  startTime: string;
873
+ /** Version loaded by the long-lived supervisor process. */
874
+ version?: string;
873
875
  updaterPid?: number;
874
876
  rolling: ProxyRollingState;
875
877
  };
@@ -500,4 +500,13 @@ export type BuildRealtimeMcpToolsParams = {
500
500
  publishEvent: RealtimeEventPublisher;
501
501
  /** Opens a HITL confirmation for destructive tools and awaits the decision. */
502
502
  requestConfirmation: RealtimeConfirmationRequester;
503
+ /**
504
+ * Hard cap per MCP tool call, in milliseconds (default 30000).
505
+ *
506
+ * Without one, a stalled MCP server holds the realtime turn open forever:
507
+ * Gemini waits on the function result, so the user gets silence rather than
508
+ * an error. Bounding the call turns that into a normal tool failure the
509
+ * model can talk about.
510
+ */
511
+ toolTimeoutMs?: number;
503
512
  };
@@ -30,7 +30,7 @@ export type ModelRouterInterface = {
30
30
  isClaudeTarget(requestedModel: string): boolean;
31
31
  getFallbackChain(): FallbackEntry[];
32
32
  isAutoFallbackEnabled?(): boolean;
33
- getMaxInflightPerAccount?(): number;
33
+ getMaxInflightPerAccount?(): number | undefined;
34
34
  getModelMappings?: () => ModelMapping[];
35
35
  getPassthroughModels?: () => string[];
36
36
  };
@@ -1724,6 +1724,15 @@ export type UpdateState = {
1724
1724
  lastCheckAt: string;
1725
1725
  lastCheckVersion: string;
1726
1726
  suppressedVersions: Record<string, SuppressedVersion>;
1727
+ /**
1728
+ * Last package version whose stable trampoline was successfully validated.
1729
+ *
1730
+ * Optional because `UpdateState` is part of the published type surface and a
1731
+ * required addition would break every downstream object literal — and because
1732
+ * state files written before this field existed legitimately omit it.
1733
+ * `loadUpdateState()` always materializes it, so runtime readers see a value.
1734
+ */
1735
+ installedVersion?: string | null;
1727
1736
  lastUpdateAt: string | null;
1728
1737
  lastUpdateVersion: string | null;
1729
1738
  /** Installed by the updater but not yet confirmed as the running version. */
@@ -910,7 +910,13 @@ export type ProxyRoutingConfig = {
910
910
  fallbackChain: FallbackEntry[];
911
911
  /** Permit a last-resort provider chosen by the translation layer. Disabled by default. */
912
912
  autoFallback?: boolean;
913
- /** Maximum in-flight upstream requests per OAuth account. Defaults to two. */
913
+ /**
914
+ * Optional in-flight upstream request cap per OAuth account.
915
+ *
916
+ * Unlimited admission is the result of omitting it AND of any value outside
917
+ * the accepted range — a non-integer, or anything below 1 or above 20 — since
918
+ * `normalizeMaxInflightPerAccount()` discards those rather than clamping.
919
+ */
914
920
  maxInflightPerAccount?: number;
915
921
  passthroughModels?: string[];
916
922
  /** Enable quota-aware fill-first account ordering. Defaults to true. */
@@ -16,6 +16,14 @@
16
16
  import { z } from "zod";
17
17
  import { logger } from "../../utils/logger.js";
18
18
  import { findSchemaIssue, sanitizeToolParameters } from "./schemaSanitizer.js";
19
+ /**
20
+ * Default hard cap per MCP tool call.
21
+ *
22
+ * Chosen to sit well inside a conversational turn: a realtime voice user is
23
+ * waiting in silence while a tool runs, so a call that has not returned in
24
+ * 30s has already failed as far as the conversation is concerned.
25
+ */
26
+ const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
19
27
  /**
20
28
  * The fields of an MCP tool result we render: text content parts and the error
21
29
  * flag. The result crosses a network boundary from an external MCP server, so it
@@ -86,7 +94,7 @@ function registerToolAliases(toolContext, mcpToolName, handler) {
86
94
  * filtering is needed.
87
95
  */
88
96
  export async function buildRealtimeMcpTools(params) {
89
- const { mcpUrl, authToken, xContext, publishEvent, requestConfirmation } = params;
97
+ const { mcpUrl, authToken, xContext, publishEvent, requestConfirmation, toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS, } = params;
90
98
  const { llm } = await import("@livekit/agents");
91
99
  const { Client: McpClient } = await import("@modelcontextprotocol/sdk/client/index.js");
92
100
  const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
@@ -141,10 +149,14 @@ export async function buildRealtimeMcpTools(params) {
141
149
  publishEvent("tool-start", { name: mcpTool.name });
142
150
  const startedAt = Date.now();
143
151
  try {
152
+ // Third argument is RequestOptions; the SDK aborts the in-flight
153
+ // request when `timeout` elapses. Without it a stalled server holds
154
+ // the turn open indefinitely — Gemini blocks on the function
155
+ // result, so the user hears nothing at all rather than an error.
144
156
  const result = await client.callTool({
145
157
  name: mcpTool.name,
146
158
  arguments: args ?? {},
147
- });
159
+ }, undefined, { timeout: toolTimeoutMs });
148
160
  const text = mcpResultToText(result);
149
161
  logger.info("realtime.tool.result", {
150
162
  tool: mcpTool.name,