@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.
- package/CHANGELOG.md +15 -0
- package/dist/browser/neurolink.min.js +263 -263
- package/dist/cli/commands/mcp.js +38 -5
- package/dist/cli/commands/proxy.d.ts +20 -1
- package/dist/cli/commands/proxy.js +73 -3
- package/dist/lib/proxy/modelRouter.d.ts +14 -4
- package/dist/lib/proxy/modelRouter.js +21 -5
- package/dist/lib/proxy/updateState.d.ts +1 -1
- package/dist/lib/proxy/updateState.js +18 -1
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +5 -2
- package/dist/lib/server/routes/claudeProxyRoutes.js +27 -14
- package/dist/lib/types/cli.d.ts +2 -0
- package/dist/lib/types/livekit.d.ts +9 -0
- package/dist/lib/types/proxy.d.ts +10 -1
- package/dist/lib/types/subscription.d.ts +7 -1
- package/dist/lib/voice/livekit/realtimeMcpTools.js +14 -2
- package/dist/proxy/modelRouter.d.ts +14 -4
- package/dist/proxy/modelRouter.js +21 -5
- package/dist/proxy/updateState.d.ts +1 -1
- package/dist/proxy/updateState.js +18 -1
- package/dist/server/routes/claudeProxyRoutes.d.ts +5 -2
- package/dist/server/routes/claudeProxyRoutes.js +27 -14
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/livekit.d.ts +9 -0
- package/dist/types/proxy.d.ts +10 -1
- package/dist/types/subscription.d.ts +7 -1
- package/dist/voice/livekit/realtimeMcpTools.js +14 -2
- package/package.json +8 -2
package/dist/cli/commands/mcp.js
CHANGED
|
@@ -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(
|
|
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.
|
|
2981
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 {
|
|
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 (
|
|
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,
|
package/dist/lib/types/cli.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|