@khanglvm/llm-router 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.test-suite.example +19 -0
- package/README.md +230 -0
- package/package.json +26 -0
- package/src/cli/router-module.js +3987 -0
- package/src/cli-entry.js +144 -0
- package/src/index.js +18 -0
- package/src/node/config-store.js +74 -0
- package/src/node/config-workflows.js +245 -0
- package/src/node/instance-state.js +206 -0
- package/src/node/local-server.js +294 -0
- package/src/node/provider-probe.js +905 -0
- package/src/node/start-command.js +498 -0
- package/src/node/startup-manager.js +369 -0
- package/src/runtime/config.js +655 -0
- package/src/runtime/handler/auth.js +32 -0
- package/src/runtime/handler/config-loading.js +45 -0
- package/src/runtime/handler/fallback.js +424 -0
- package/src/runtime/handler/http.js +71 -0
- package/src/runtime/handler/network-guards.js +137 -0
- package/src/runtime/handler/provider-call.js +245 -0
- package/src/runtime/handler/provider-translation.js +232 -0
- package/src/runtime/handler/request.js +194 -0
- package/src/runtime/handler/utils.js +41 -0
- package/src/runtime/handler.js +301 -0
- package/src/translator/formats.js +7 -0
- package/src/translator/index.js +73 -0
- package/src/translator/request/claude-to-openai.js +228 -0
- package/src/translator/request/openai-to-claude.js +241 -0
- package/src/translator/response/claude-to-openai.js +204 -0
- package/src/translator/response/openai-to-claude.js +197 -0
- package/wrangler.toml +20 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { configFileExists, getDefaultConfigPath, readConfigFile } from "./config-store.js";
|
|
5
|
+
import { clearRuntimeState, writeRuntimeState } from "./instance-state.js";
|
|
6
|
+
import { startLocalRouteServer } from "./local-server.js";
|
|
7
|
+
import { configHasProvider, sanitizeConfigForDisplay } from "../runtime/config.js";
|
|
8
|
+
|
|
9
|
+
function summarizeConfig(config, configPath) {
|
|
10
|
+
const target = sanitizeConfigForDisplay(config);
|
|
11
|
+
const lines = [];
|
|
12
|
+
lines.push(`Config: ${configPath}`);
|
|
13
|
+
lines.push(`Default model: ${target.defaultModel || "(not set)"}`);
|
|
14
|
+
lines.push(`Master key: ${target.masterKey || "(not set)"}`);
|
|
15
|
+
|
|
16
|
+
if (!target.providers || target.providers.length === 0) {
|
|
17
|
+
lines.push("Providers: (none)");
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
lines.push("Providers:");
|
|
22
|
+
for (const provider of target.providers) {
|
|
23
|
+
lines.push(`- ${provider.id} (${provider.name})`);
|
|
24
|
+
lines.push(` baseUrl=${provider.baseUrl}`);
|
|
25
|
+
if (provider.baseUrlByFormat?.openai) {
|
|
26
|
+
lines.push(` openaiBaseUrl=${provider.baseUrlByFormat.openai}`);
|
|
27
|
+
}
|
|
28
|
+
if (provider.baseUrlByFormat?.claude) {
|
|
29
|
+
lines.push(` claudeBaseUrl=${provider.baseUrlByFormat.claude}`);
|
|
30
|
+
}
|
|
31
|
+
lines.push(` formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`);
|
|
32
|
+
lines.push(` apiKey=${provider.apiKey || "(from env/hidden)"}`);
|
|
33
|
+
lines.push(` models=${(provider.models || []).map((model) => {
|
|
34
|
+
const fallbacks = (model.fallbackModels || []).join("|");
|
|
35
|
+
return fallbacks ? `${model.id}{fallback:${fallbacks}}` : model.id;
|
|
36
|
+
}).join(", ") || "(none)"}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toBoolean(value, fallback = false) {
|
|
43
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
44
|
+
if (typeof value === "boolean") return value;
|
|
45
|
+
const normalized = String(value).trim().toLowerCase();
|
|
46
|
+
if (["1", "true", "yes", "y"].includes(normalized)) return true;
|
|
47
|
+
if (["0", "false", "no", "n"].includes(normalized)) return false;
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toNumber(value, fallback) {
|
|
52
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
53
|
+
const parsed = Number(value);
|
|
54
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeRealpath(filePath) {
|
|
58
|
+
if (!filePath) return "";
|
|
59
|
+
try {
|
|
60
|
+
return realpathSync(filePath);
|
|
61
|
+
} catch {
|
|
62
|
+
return path.resolve(filePath);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvePackageJsonPathFromCliPath(cliPath) {
|
|
67
|
+
if (!cliPath) return "";
|
|
68
|
+
let dir = path.dirname(cliPath);
|
|
69
|
+
for (let i = 0; i < 8; i += 1) {
|
|
70
|
+
const candidate = path.join(dir, "package.json");
|
|
71
|
+
if (existsSync(candidate)) return candidate;
|
|
72
|
+
const next = path.dirname(dir);
|
|
73
|
+
if (next === dir) break;
|
|
74
|
+
dir = next;
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readPackageVersion(packageJsonPath) {
|
|
80
|
+
if (!packageJsonPath || !existsSync(packageJsonPath)) return "";
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
83
|
+
return typeof parsed?.version === "string" ? parsed.version : "";
|
|
84
|
+
} catch {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function snapshotCliVersionState(cliPath) {
|
|
90
|
+
const realpath = safeRealpath(cliPath);
|
|
91
|
+
const packageJsonPath = resolvePackageJsonPathFromCliPath(realpath);
|
|
92
|
+
const version = readPackageVersion(packageJsonPath);
|
|
93
|
+
return { cliPath, realpath, packageJsonPath, version };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildStartArgs({ configPath, host, port, watchConfig, watchBinary, requireAuth }) {
|
|
97
|
+
return [
|
|
98
|
+
"start",
|
|
99
|
+
`--config=${configPath}`,
|
|
100
|
+
`--host=${host}`,
|
|
101
|
+
`--port=${port}`,
|
|
102
|
+
`--watch-config=${watchConfig ? "true" : "false"}`,
|
|
103
|
+
`--watch-binary=${watchBinary ? "true" : "false"}`,
|
|
104
|
+
`--require-auth=${requireAuth ? "true" : "false"}`
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function spawnReplacementCli({ cliPath, startArgs }) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
try {
|
|
111
|
+
const env = { ...process.env };
|
|
112
|
+
env.LLM_ROUTER_CLI_PATH = cliPath;
|
|
113
|
+
delete env.LLM_ROUTER_MANAGED_BY_STARTUP;
|
|
114
|
+
|
|
115
|
+
const child = spawn(process.execPath, [cliPath, ...startArgs], {
|
|
116
|
+
stdio: "inherit",
|
|
117
|
+
env
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
let settled = false;
|
|
121
|
+
const finish = (result) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
resolve(result);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
child.once("spawn", () => finish({ ok: true, pid: child.pid }));
|
|
128
|
+
child.once("error", (error) => finish({ ok: false, error }));
|
|
129
|
+
} catch (error) {
|
|
130
|
+
resolve({ ok: false, error });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parsePidList(text) {
|
|
136
|
+
const matches = String(text || "").match(/\d+/g) || [];
|
|
137
|
+
return [...new Set(matches
|
|
138
|
+
.map((token) => Number(token))
|
|
139
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function listListeningPidsWithLsof(port) {
|
|
143
|
+
const result = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
144
|
+
encoding: "utf8"
|
|
145
|
+
});
|
|
146
|
+
if (result.error) {
|
|
147
|
+
return { ok: false, pids: [], tool: "lsof", error: result.error };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
pids: parsePidList(result.stdout),
|
|
153
|
+
tool: "lsof"
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function listListeningPidsWithFuser(port) {
|
|
158
|
+
const result = spawnSync("fuser", ["-n", "tcp", String(port)], {
|
|
159
|
+
encoding: "utf8"
|
|
160
|
+
});
|
|
161
|
+
if (result.error) {
|
|
162
|
+
return { ok: false, pids: [], tool: "fuser", error: result.error };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
pids: parsePidList(`${result.stdout || ""}\n${result.stderr || ""}`),
|
|
168
|
+
tool: "fuser"
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function listListeningPids(port) {
|
|
173
|
+
const lsof = listListeningPidsWithLsof(port);
|
|
174
|
+
if (lsof.ok) return lsof;
|
|
175
|
+
|
|
176
|
+
const fuser = listListeningPidsWithFuser(port);
|
|
177
|
+
if (fuser.ok) return fuser;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
pids: [],
|
|
182
|
+
tool: "none",
|
|
183
|
+
error: lsof.error || fuser.error
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sleep(ms) {
|
|
188
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function waitForPortToRelease(port, timeoutMs = 4000) {
|
|
192
|
+
const startedAt = Date.now();
|
|
193
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
194
|
+
const probe = listListeningPids(port);
|
|
195
|
+
if (!probe.ok || probe.pids.length === 0) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
await sleep(150);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const finalProbe = listListeningPids(port);
|
|
202
|
+
return !finalProbe.ok || finalProbe.pids.length === 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function reclaimPort({ port, line, error }) {
|
|
206
|
+
const probe = listListeningPids(port);
|
|
207
|
+
if (!probe.ok) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
errorMessage: `Port ${port} is in use but process lookup failed (${probe.error instanceof Error ? probe.error.message : String(probe.error || "unknown error")}).`
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const targets = probe.pids.filter((pid) => pid !== process.pid);
|
|
215
|
+
if (targets.length === 0) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
errorMessage: `Port ${port} is in use but no external listener PID was detected.`
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
line(`Port ${port} is already in use. Stopping existing listener(s): ${targets.join(", ")}.`);
|
|
223
|
+
|
|
224
|
+
for (const pid of targets) {
|
|
225
|
+
try {
|
|
226
|
+
process.kill(pid, "SIGTERM");
|
|
227
|
+
} catch (killError) {
|
|
228
|
+
error(`Failed sending SIGTERM to pid ${pid}: ${killError instanceof Error ? killError.message : String(killError)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let released = await waitForPortToRelease(port, 3000);
|
|
233
|
+
if (!released) {
|
|
234
|
+
const remaining = listListeningPids(port);
|
|
235
|
+
const remainingTargets = (remaining.pids || []).filter((pid) => pid !== process.pid);
|
|
236
|
+
|
|
237
|
+
if (remainingTargets.length > 0) {
|
|
238
|
+
line(`Port ${port} still busy. Force killing listener(s): ${remainingTargets.join(", ")}.`);
|
|
239
|
+
for (const pid of remainingTargets) {
|
|
240
|
+
try {
|
|
241
|
+
process.kill(pid, "SIGKILL");
|
|
242
|
+
} catch (killError) {
|
|
243
|
+
error(`Failed sending SIGKILL to pid ${pid}: ${killError instanceof Error ? killError.message : String(killError)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
released = await waitForPortToRelease(port, 2000);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!released) {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
errorMessage: `Failed to reclaim port ${port}; listener process is still running.`
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
ok: true
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function runStartCommand(options = {}) {
|
|
263
|
+
const configPath = options.configPath || getDefaultConfigPath();
|
|
264
|
+
const host = options.host || "127.0.0.1";
|
|
265
|
+
const port = toNumber(options.port, 8787);
|
|
266
|
+
const watchConfig = toBoolean(options.watchConfig, true);
|
|
267
|
+
const watchBinary = toBoolean(options.watchBinary, true);
|
|
268
|
+
const binaryWatchIntervalMs = Math.max(
|
|
269
|
+
1000,
|
|
270
|
+
toNumber(options.binaryWatchIntervalMs ?? process.env.LLM_ROUTER_BINARY_WATCH_INTERVAL_MS, 15000)
|
|
271
|
+
);
|
|
272
|
+
const requireAuth = toBoolean(options.requireAuth, false);
|
|
273
|
+
const managedByStartup = options.managedByStartup === true || process.env.LLM_ROUTER_MANAGED_BY_STARTUP === "1";
|
|
274
|
+
const cliPathForWatch = String(options.cliPathForWatch || process.env.LLM_ROUTER_CLI_PATH || process.argv[1] || "");
|
|
275
|
+
const line = typeof options.onLine === "function" ? options.onLine : console.log;
|
|
276
|
+
const error = typeof options.onError === "function" ? options.onError : console.error;
|
|
277
|
+
|
|
278
|
+
if (!(await configFileExists(configPath))) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
exitCode: 2,
|
|
282
|
+
errorMessage: [
|
|
283
|
+
`Config file not found: ${configPath}`,
|
|
284
|
+
"Run 'llm-router config' to create provider config or 'llm-router -h' for help."
|
|
285
|
+
].join("\n")
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const config = await readConfigFile(configPath);
|
|
290
|
+
if (!configHasProvider(config)) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
exitCode: 2,
|
|
294
|
+
errorMessage: [
|
|
295
|
+
`No providers configured in ${configPath}`,
|
|
296
|
+
"Run 'llm-router config' to add a provider or 'llm-router -h' for help."
|
|
297
|
+
].join("\n")
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (requireAuth && !config.masterKey) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
exitCode: 2,
|
|
305
|
+
errorMessage: [
|
|
306
|
+
`Local auth requires masterKey in ${configPath}.`,
|
|
307
|
+
"Run 'llm-router config --operation=set-master-key --master-key=...' or start without --require-auth."
|
|
308
|
+
].join("\n")
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const buildLocalServerOptions = () => ({
|
|
313
|
+
port,
|
|
314
|
+
host,
|
|
315
|
+
configPath,
|
|
316
|
+
watchConfig,
|
|
317
|
+
requireAuth,
|
|
318
|
+
validateConfig: (nextConfig) => {
|
|
319
|
+
if (!configHasProvider(nextConfig)) {
|
|
320
|
+
return "Config has no enabled providers.";
|
|
321
|
+
}
|
|
322
|
+
if (requireAuth && !nextConfig.masterKey) {
|
|
323
|
+
return "masterKey is missing while --require-auth=true.";
|
|
324
|
+
}
|
|
325
|
+
return "";
|
|
326
|
+
},
|
|
327
|
+
onConfigReload: (nextConfig, reason) => {
|
|
328
|
+
if (reason === "startup") return;
|
|
329
|
+
line(`Config hot-reloaded in memory (${reason}).`);
|
|
330
|
+
if (!configHasProvider(nextConfig)) {
|
|
331
|
+
error("Reloaded config has no enabled providers.");
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
onConfigReloadError: (reloadError, reason) => {
|
|
335
|
+
error(`Config reload ignored (${reason}): ${reloadError instanceof Error ? reloadError.message : String(reloadError)}`);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
let server;
|
|
340
|
+
try {
|
|
341
|
+
server = await startLocalRouteServer(buildLocalServerOptions());
|
|
342
|
+
} catch (startError) {
|
|
343
|
+
if (startError?.code !== "EADDRINUSE") {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
exitCode: 1,
|
|
347
|
+
errorMessage: `Failed to start llm-router on http://${host}:${port}: ${startError instanceof Error ? startError.message : String(startError)}`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const reclaimed = await reclaimPort({ port, line, error });
|
|
352
|
+
if (!reclaimed.ok) {
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
exitCode: 1,
|
|
356
|
+
errorMessage: reclaimed.errorMessage
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
server = await startLocalRouteServer(buildLocalServerOptions());
|
|
362
|
+
line(`Port ${port} reclaimed successfully.`);
|
|
363
|
+
} catch (retryError) {
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
exitCode: 1,
|
|
367
|
+
errorMessage: `Failed to start llm-router after reclaiming port ${port}: ${retryError instanceof Error ? retryError.message : String(retryError)}`
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
line(`LLM Router started on http://${host}:${port}`);
|
|
372
|
+
line(`Anthropic base URL: http://${host}:${port}/anthropic`);
|
|
373
|
+
line(`OpenAI base URL: http://${host}:${port}/openai`);
|
|
374
|
+
for (const row of summarizeConfig(config, configPath)) {
|
|
375
|
+
line(row);
|
|
376
|
+
}
|
|
377
|
+
line(`Local auth: ${requireAuth ? "required (masterKey)" : "disabled"}`);
|
|
378
|
+
line(`Config hot reload: ${watchConfig ? "enabled" : "disabled"} (in-memory, no process restart)`);
|
|
379
|
+
line(`Binary update watch: ${watchBinary ? "enabled" : "disabled"}${managedByStartup ? " (startup-managed auto-restart)" : ""}`);
|
|
380
|
+
line("Press Ctrl+C to stop.");
|
|
381
|
+
|
|
382
|
+
let shuttingDown = false;
|
|
383
|
+
let binaryWatchTimer = null;
|
|
384
|
+
let binaryState = watchBinary && cliPathForWatch ? snapshotCliVersionState(cliPathForWatch) : null;
|
|
385
|
+
let binaryNoticeSent = false;
|
|
386
|
+
let binaryRelaunching = false;
|
|
387
|
+
const runtimeVersion = binaryState?.version || readPackageVersion(resolvePackageJsonPathFromCliPath(safeRealpath(cliPathForWatch)));
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
await writeRuntimeState({
|
|
391
|
+
pid: process.pid,
|
|
392
|
+
host,
|
|
393
|
+
port,
|
|
394
|
+
configPath,
|
|
395
|
+
watchConfig,
|
|
396
|
+
watchBinary,
|
|
397
|
+
requireAuth,
|
|
398
|
+
managedByStartup,
|
|
399
|
+
cliPath: cliPathForWatch,
|
|
400
|
+
startedAt: new Date().toISOString(),
|
|
401
|
+
version: runtimeVersion
|
|
402
|
+
});
|
|
403
|
+
} catch (stateError) {
|
|
404
|
+
error(`Failed to write runtime state file: ${stateError instanceof Error ? stateError.message : String(stateError)}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const closeServer = async () => {
|
|
408
|
+
if (!server) return;
|
|
409
|
+
const active = server;
|
|
410
|
+
server = null;
|
|
411
|
+
await new Promise((resolve) => active.close(() => resolve()));
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
let resolveDone;
|
|
415
|
+
const donePromise = new Promise((resolve) => {
|
|
416
|
+
resolveDone = resolve;
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const shutdown = async () => {
|
|
420
|
+
if (shuttingDown) return;
|
|
421
|
+
shuttingDown = true;
|
|
422
|
+
try {
|
|
423
|
+
if (binaryWatchTimer) clearInterval(binaryWatchTimer);
|
|
424
|
+
} catch {
|
|
425
|
+
// ignore
|
|
426
|
+
}
|
|
427
|
+
await closeServer();
|
|
428
|
+
await clearRuntimeState({ pid: process.pid });
|
|
429
|
+
resolveDone();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
if (watchBinary && binaryState) {
|
|
433
|
+
binaryWatchTimer = setInterval(() => {
|
|
434
|
+
if (shuttingDown || binaryRelaunching) return;
|
|
435
|
+
const nextState = snapshotCliVersionState(binaryState.cliPath);
|
|
436
|
+
const changed =
|
|
437
|
+
nextState.realpath !== binaryState.realpath ||
|
|
438
|
+
(nextState.version && binaryState.version && nextState.version !== binaryState.version);
|
|
439
|
+
|
|
440
|
+
if (!changed) return;
|
|
441
|
+
|
|
442
|
+
const from = binaryState.version || binaryState.realpath || "(unknown)";
|
|
443
|
+
const to = nextState.version || nextState.realpath || "(unknown)";
|
|
444
|
+
binaryState = nextState;
|
|
445
|
+
|
|
446
|
+
if (managedByStartup) {
|
|
447
|
+
line(`Detected llm-router update (${from} -> ${to}). Exiting for startup manager to relaunch latest version.`);
|
|
448
|
+
void shutdown().then(() => {
|
|
449
|
+
process.exit(0);
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const cliPath = nextState.cliPath || cliPathForWatch || process.argv[1];
|
|
455
|
+
if (!cliPath) {
|
|
456
|
+
if (!binaryNoticeSent) {
|
|
457
|
+
binaryNoticeSent = true;
|
|
458
|
+
line(`Detected llm-router update (${from} -> ${to}). Restart this process to run the new version.`);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
binaryRelaunching = true;
|
|
464
|
+
void (async () => {
|
|
465
|
+
try {
|
|
466
|
+
line(`Detected llm-router update (${from} -> ${to}). Relaunching latest version...`);
|
|
467
|
+
await shutdown();
|
|
468
|
+
const launch = await spawnReplacementCli({
|
|
469
|
+
cliPath,
|
|
470
|
+
startArgs: buildStartArgs({ configPath, host, port, watchConfig, watchBinary, requireAuth })
|
|
471
|
+
});
|
|
472
|
+
if (!launch.ok) {
|
|
473
|
+
error(`Failed to relaunch updated llm-router: ${launch.error instanceof Error ? launch.error.message : String(launch.error)}`);
|
|
474
|
+
process.exit(1);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
line(`Started updated llm-router process (pid ${launch.pid || "unknown"}).`);
|
|
479
|
+
process.exit(0);
|
|
480
|
+
} catch (relaunchError) {
|
|
481
|
+
error(`Failed during llm-router auto-relaunch: ${relaunchError instanceof Error ? relaunchError.message : String(relaunchError)}`);
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
})();
|
|
485
|
+
}, binaryWatchIntervalMs);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
process.once("SIGINT", () => { void shutdown(); });
|
|
489
|
+
process.once("SIGTERM", () => { void shutdown(); });
|
|
490
|
+
|
|
491
|
+
await donePromise;
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
exitCode: 0,
|
|
496
|
+
data: "Server stopped."
|
|
497
|
+
};
|
|
498
|
+
}
|