@khanglvm/llm-router 1.3.1 → 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +337 -41
- package/package.json +19 -3
- package/src/cli/router-module.js +7331 -3805
- package/src/cli/wrangler-toml.js +1 -1
- package/src/cli-entry.js +162 -24
- package/src/node/amp-client-config.js +426 -0
- package/src/node/coding-tool-config.js +763 -0
- package/src/node/config-store.js +49 -18
- package/src/node/instance-state.js +213 -12
- package/src/node/listen-port.js +5 -37
- package/src/node/local-server-settings.js +122 -0
- package/src/node/local-server.js +3 -2
- package/src/node/provider-probe.js +13 -0
- package/src/node/start-command.js +282 -40
- package/src/node/startup-manager.js +64 -29
- package/src/node/web-command.js +106 -0
- package/src/node/web-console-assets.js +26 -0
- package/src/node/web-console-client.js +56 -0
- package/src/node/web-console-dev-assets.js +258 -0
- package/src/node/web-console-server.js +3146 -0
- package/src/node/web-console-styles.generated.js +1 -0
- package/src/node/web-console-ui/config-editor-utils.js +616 -0
- package/src/node/web-console-ui/lib/utils.js +6 -0
- package/src/node/web-console-ui/rate-limit-utils.js +144 -0
- package/src/node/web-console-ui/select-search-utils.js +36 -0
- package/src/runtime/codex-request-transformer.js +46 -5
- package/src/runtime/codex-response-transformer.js +268 -35
- package/src/runtime/config.js +1394 -35
- package/src/runtime/handler/amp-gemini.js +913 -0
- package/src/runtime/handler/amp-response.js +308 -0
- package/src/runtime/handler/amp.js +290 -0
- package/src/runtime/handler/auth.js +17 -2
- package/src/runtime/handler/provider-call.js +168 -50
- package/src/runtime/handler/provider-translation.js +937 -26
- package/src/runtime/handler/request.js +149 -6
- package/src/runtime/handler/route-debug.js +22 -1
- package/src/runtime/handler.js +449 -9
- package/src/runtime/subscription-auth.js +1 -6
- package/src/shared/local-router-defaults.js +62 -0
- package/src/translator/index.js +3 -1
- package/src/translator/request/openai-to-claude.js +217 -6
- package/src/translator/response/openai-to-claude.js +206 -58
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { configFileExists, getDefaultConfigPath, readConfigFile } from "./config-store.js";
|
|
5
|
-
import { buildStartArgsFromState, clearRuntimeState, getActiveRuntimeState, writeRuntimeState } from "./instance-state.js";
|
|
4
|
+
import { configFileExists, getDefaultConfigPath, readConfigFile, readConfigFileState, writeConfigFile } from "./config-store.js";
|
|
5
|
+
import { buildStartArgsFromState, clearRuntimeState, getActiveRuntimeState, stopProcessByPid, waitForRuntimeMatch, writeRuntimeState } from "./instance-state.js";
|
|
6
|
+
import {
|
|
7
|
+
FIXED_LOCAL_ROUTER_HOST,
|
|
8
|
+
applyLocalServerSettings,
|
|
9
|
+
areLocalServerSettingsEqual,
|
|
10
|
+
readLocalServerSettings
|
|
11
|
+
} from "./local-server-settings.js";
|
|
6
12
|
import { resolveListenPort } from "./listen-port.js";
|
|
7
13
|
import { startLocalRouteServer } from "./local-server.js";
|
|
8
14
|
import { reclaimPort, stopStartupManagedListener } from "./port-reclaim.js";
|
|
@@ -15,6 +21,14 @@ function summarizeConfig(config, configPath) {
|
|
|
15
21
|
lines.push(`Config: ${configPath}`);
|
|
16
22
|
lines.push(`Default model: ${target.defaultModel || "(not set)"}`);
|
|
17
23
|
lines.push(`Master key: ${target.masterKey || "(not set)"}`);
|
|
24
|
+
lines.push(`AMP upstream URL: ${target.amp?.upstreamUrl || "(disabled)"}`);
|
|
25
|
+
lines.push(`AMP upstream API key: ${target.amp?.upstreamApiKey || "(not set)"}`);
|
|
26
|
+
lines.push(`AMP restrict management to localhost: ${target.amp?.restrictManagementToLocalhost === true ? "yes" : "no"}`);
|
|
27
|
+
lines.push(`AMP force model mappings: ${target.amp?.forceModelMappings === true ? "yes" : "no"}`);
|
|
28
|
+
lines.push(`AMP model mappings: ${(target.amp?.modelMappings || []).map((mapping) => `${mapping.from}->${mapping.to}`).join(", ") || "(none)"}`);
|
|
29
|
+
const ampDefinitions = Array.isArray(target.amp?.subagentDefinitions) ? target.amp.subagentDefinitions : null;
|
|
30
|
+
lines.push(`AMP subagent definitions: ${ampDefinitions ? ampDefinitions.map((entry) => `${entry.id}=>${(entry.patterns || []).join("|")}`).join(", ") || "(none)" : "(default built-ins)"}`);
|
|
31
|
+
lines.push(`AMP subagent mappings: ${Object.entries(target.amp?.subagentMappings || {}).map(([agent, route]) => `${agent}->${route}`).join(", ") || "(none)"}`);
|
|
18
32
|
|
|
19
33
|
if (!target.providers || target.providers.length === 0) {
|
|
20
34
|
lines.push("Providers: (none)");
|
|
@@ -57,6 +71,30 @@ function toNumber(value, fallback) {
|
|
|
57
71
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
function formatStartupConfigMigrationMessage(configState, configPath) {
|
|
75
|
+
if (!configState?.changed) return "";
|
|
76
|
+
|
|
77
|
+
const beforeVersion = Number(configState.beforeVersion);
|
|
78
|
+
const afterVersion = Number(configState.afterVersion);
|
|
79
|
+
const versionChanged = Number.isInteger(beforeVersion) && Number.isInteger(afterVersion) && beforeVersion !== afterVersion;
|
|
80
|
+
const baseMessage = versionChanged
|
|
81
|
+
? `Config auto-migrated from v${beforeVersion} to v${afterVersion}`
|
|
82
|
+
: "Config auto-normalized for startup compatibility";
|
|
83
|
+
|
|
84
|
+
if (configState.persisted) {
|
|
85
|
+
return `${baseMessage} and saved to ${configPath}.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (configState.persistError) {
|
|
89
|
+
const detail = configState.persistError instanceof Error
|
|
90
|
+
? configState.persistError.message
|
|
91
|
+
: String(configState.persistError);
|
|
92
|
+
return `${baseMessage} for this run, but could not be saved to ${configPath}: ${detail}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
function safeRealpath(filePath) {
|
|
61
99
|
if (!filePath) return "";
|
|
62
100
|
try {
|
|
@@ -96,16 +134,18 @@ function snapshotCliVersionState(cliPath) {
|
|
|
96
134
|
return { cliPath, realpath, packageJsonPath, version };
|
|
97
135
|
}
|
|
98
136
|
|
|
99
|
-
function buildStartArgs({ configPath,
|
|
100
|
-
|
|
137
|
+
function buildStartArgs({ configPath, watchConfig, watchBinary, requireAuth, useConfigDefaults = false }) {
|
|
138
|
+
const args = [
|
|
101
139
|
"start",
|
|
102
|
-
`--config=${configPath}
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
`--config=${configPath}`
|
|
141
|
+
];
|
|
142
|
+
if (useConfigDefaults) return args;
|
|
143
|
+
args.push(
|
|
105
144
|
`--watch-config=${watchConfig ? "true" : "false"}`,
|
|
106
145
|
`--watch-binary=${watchBinary ? "true" : "false"}`,
|
|
107
146
|
`--require-auth=${requireAuth ? "true" : "false"}`
|
|
108
|
-
|
|
147
|
+
);
|
|
148
|
+
return args;
|
|
109
149
|
}
|
|
110
150
|
|
|
111
151
|
function spawnReplacementCli({ cliPath, startArgs }) {
|
|
@@ -157,10 +197,17 @@ function normalizeStartupConflictChoice(value) {
|
|
|
157
197
|
return "";
|
|
158
198
|
}
|
|
159
199
|
|
|
160
|
-
async function detectStartupManagedConflictOnPort(port) {
|
|
200
|
+
async function detectStartupManagedConflictOnPort(port, deps = {}) {
|
|
201
|
+
const getActiveRuntimeStateFn = typeof deps.getActiveRuntimeState === "function"
|
|
202
|
+
? deps.getActiveRuntimeState
|
|
203
|
+
: getActiveRuntimeState;
|
|
204
|
+
const startupStatusFn = typeof deps.startupStatus === "function"
|
|
205
|
+
? deps.startupStatus
|
|
206
|
+
: startupStatus;
|
|
207
|
+
|
|
161
208
|
let runtime = null;
|
|
162
209
|
try {
|
|
163
|
-
runtime = await
|
|
210
|
+
runtime = await getActiveRuntimeStateFn();
|
|
164
211
|
} catch {
|
|
165
212
|
runtime = null;
|
|
166
213
|
}
|
|
@@ -175,7 +222,7 @@ async function detectStartupManagedConflictOnPort(port) {
|
|
|
175
222
|
|
|
176
223
|
let status = null;
|
|
177
224
|
try {
|
|
178
|
-
status = await
|
|
225
|
+
status = await startupStatusFn();
|
|
179
226
|
} catch {
|
|
180
227
|
status = null;
|
|
181
228
|
}
|
|
@@ -197,27 +244,89 @@ async function detectStartupManagedConflictOnPort(port) {
|
|
|
197
244
|
};
|
|
198
245
|
}
|
|
199
246
|
|
|
200
|
-
async function
|
|
247
|
+
async function handoffToStartupManagedWithLatest({
|
|
201
248
|
runtimeState,
|
|
202
249
|
fallbackStartArgs,
|
|
250
|
+
cliPath,
|
|
251
|
+
line,
|
|
203
252
|
error
|
|
204
|
-
}) {
|
|
253
|
+
}, deps = {}) {
|
|
254
|
+
const getActiveRuntimeStateFn = typeof deps.getActiveRuntimeState === "function"
|
|
255
|
+
? deps.getActiveRuntimeState
|
|
256
|
+
: getActiveRuntimeState;
|
|
257
|
+
const stopProcessByPidFn = typeof deps.stopProcessByPid === "function"
|
|
258
|
+
? deps.stopProcessByPid
|
|
259
|
+
: stopProcessByPid;
|
|
260
|
+
const clearRuntimeStateFn = typeof deps.clearRuntimeState === "function"
|
|
261
|
+
? deps.clearRuntimeState
|
|
262
|
+
: clearRuntimeState;
|
|
263
|
+
const reclaimPortFn = typeof deps.reclaimPort === "function"
|
|
264
|
+
? deps.reclaimPort
|
|
265
|
+
: (args) => reclaimPort(args, deps);
|
|
266
|
+
const installStartupFn = typeof deps.installStartup === "function"
|
|
267
|
+
? deps.installStartup
|
|
268
|
+
: installStartup;
|
|
269
|
+
const waitForRuntimeMatchFn = typeof deps.waitForRuntimeMatch === "function"
|
|
270
|
+
? deps.waitForRuntimeMatch
|
|
271
|
+
: (options, waitOptions = {}) => waitForRuntimeMatch(options, waitOptions);
|
|
272
|
+
|
|
205
273
|
const startArgs = runtimeState?.managedByStartup
|
|
206
274
|
? buildStartArgsFromState(runtimeState)
|
|
207
275
|
: fallbackStartArgs;
|
|
276
|
+
|
|
277
|
+
let activeRuntime = null;
|
|
278
|
+
try {
|
|
279
|
+
activeRuntime = await getActiveRuntimeStateFn();
|
|
280
|
+
} catch {
|
|
281
|
+
activeRuntime = null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (activeRuntime && Number(activeRuntime.pid) !== Number(process.pid) && !activeRuntime.managedByStartup) {
|
|
285
|
+
const stopped = await stopProcessByPidFn(activeRuntime.pid);
|
|
286
|
+
if (!stopped?.ok) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
errorMessage: stopped?.reason || `Failed to stop existing llm-router pid ${activeRuntime.pid}.`
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
await clearRuntimeStateFn({ pid: activeRuntime.pid });
|
|
293
|
+
line(`Stopped manual llm-router on http://${activeRuntime.host}:${activeRuntime.port} so the startup service can own the router.`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const reclaimed = await reclaimPortFn({ port: startArgs.port, line, error });
|
|
297
|
+
if (!reclaimed.ok) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
errorMessage: reclaimed.errorMessage
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
208
304
|
try {
|
|
209
|
-
|
|
305
|
+
await clearRuntimeStateFn();
|
|
306
|
+
const detail = await installStartupFn({
|
|
210
307
|
configPath: startArgs.configPath,
|
|
211
308
|
host: startArgs.host,
|
|
212
309
|
port: startArgs.port,
|
|
213
310
|
watchConfig: startArgs.watchConfig,
|
|
214
311
|
watchBinary: startArgs.watchBinary,
|
|
215
|
-
requireAuth: startArgs.requireAuth
|
|
312
|
+
requireAuth: startArgs.requireAuth,
|
|
313
|
+
cliPath
|
|
314
|
+
});
|
|
315
|
+
const runtime = await waitForRuntimeMatchFn(startArgs, {
|
|
316
|
+
getActiveRuntimeState: getActiveRuntimeStateFn,
|
|
317
|
+
requireManagedByStartup: true
|
|
216
318
|
});
|
|
217
|
-
|
|
319
|
+
if (!runtime) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
errorMessage: `Startup-managed llm-router did not become ready on http://${startArgs.host}:${startArgs.port}.`
|
|
323
|
+
};
|
|
324
|
+
}
|
|
218
325
|
return {
|
|
219
326
|
ok: true,
|
|
220
|
-
detail
|
|
327
|
+
detail,
|
|
328
|
+
runtime,
|
|
329
|
+
startArgs
|
|
221
330
|
};
|
|
222
331
|
} catch (startupRestartError) {
|
|
223
332
|
const message = startupRestartError instanceof Error ? startupRestartError.message : String(startupRestartError);
|
|
@@ -229,14 +338,15 @@ async function restartStartupManagedWithLatest({
|
|
|
229
338
|
}
|
|
230
339
|
}
|
|
231
340
|
|
|
232
|
-
async function attemptServerStartAfterStartupStop(buildLocalServerOptions, {
|
|
341
|
+
async function attemptServerStartAfterStartupStop(buildLocalServerOptions, deps = {}, {
|
|
233
342
|
attempts = 24,
|
|
234
343
|
delayMs = 250
|
|
235
344
|
} = {}) {
|
|
236
345
|
let lastError;
|
|
237
346
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
238
347
|
try {
|
|
239
|
-
const
|
|
348
|
+
const startLocalRouteServerFn = typeof deps.startLocalRouteServer === "function" ? deps.startLocalRouteServer : startLocalRouteServer;
|
|
349
|
+
const server = await startLocalRouteServerFn(buildLocalServerOptions());
|
|
240
350
|
return { ok: true, server };
|
|
241
351
|
} catch (error) {
|
|
242
352
|
lastError = error;
|
|
@@ -253,20 +363,30 @@ async function attemptServerStartAfterStartupStop(buildLocalServerOptions, {
|
|
|
253
363
|
|
|
254
364
|
export async function runStartCommand(options = {}) {
|
|
255
365
|
const configPath = options.configPath || getDefaultConfigPath();
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const watchConfig = toBoolean(options.watchConfig, true);
|
|
259
|
-
const watchBinary = toBoolean(options.watchBinary, true);
|
|
366
|
+
const requestedWatchConfig = options.watchConfig;
|
|
367
|
+
const requestedWatchBinary = options.watchBinary;
|
|
260
368
|
const binaryWatchIntervalMs = Math.max(
|
|
261
369
|
1000,
|
|
262
370
|
toNumber(options.binaryWatchIntervalMs ?? process.env.LLM_ROUTER_BINARY_WATCH_INTERVAL_MS, 15000)
|
|
263
371
|
);
|
|
264
|
-
const
|
|
372
|
+
const requestedRequireAuth = options.requireAuth;
|
|
265
373
|
const onStartupConflict = typeof options.onStartupConflict === "function" ? options.onStartupConflict : null;
|
|
266
374
|
const managedByStartup = options.managedByStartup === true || process.env.LLM_ROUTER_MANAGED_BY_STARTUP === "1";
|
|
267
375
|
const cliPathForWatch = String(options.cliPathForWatch || process.env.LLM_ROUTER_CLI_PATH || process.argv[1] || "");
|
|
268
376
|
const line = typeof options.onLine === "function" ? options.onLine : console.log;
|
|
269
377
|
const error = typeof options.onError === "function" ? options.onError : console.error;
|
|
378
|
+
const startLocalRouteServerFn = typeof options.startLocalRouteServer === "function" ? options.startLocalRouteServer : startLocalRouteServer;
|
|
379
|
+
const getActiveRuntimeStateFn = typeof options.getActiveRuntimeState === "function" ? options.getActiveRuntimeState : getActiveRuntimeState;
|
|
380
|
+
const stopProcessByPidFn = typeof options.stopProcessByPid === "function" ? options.stopProcessByPid : stopProcessByPid;
|
|
381
|
+
const clearRuntimeStateFn = typeof options.clearRuntimeState === "function" ? options.clearRuntimeState : clearRuntimeState;
|
|
382
|
+
const installStartupFn = typeof options.installStartup === "function" ? options.installStartup : installStartup;
|
|
383
|
+
const startupStatusFn = typeof options.startupStatus === "function" ? options.startupStatus : startupStatus;
|
|
384
|
+
const reclaimPortFn = typeof options.reclaimPort === "function"
|
|
385
|
+
? options.reclaimPort
|
|
386
|
+
: (args) => reclaimPort(args, options);
|
|
387
|
+
const waitForRuntimeMatchFn = typeof options.waitForRuntimeMatch === "function"
|
|
388
|
+
? options.waitForRuntimeMatch
|
|
389
|
+
: (startOptions, waitOptions = {}) => waitForRuntimeMatch(startOptions, waitOptions);
|
|
270
390
|
|
|
271
391
|
if (!(await configFileExists(configPath))) {
|
|
272
392
|
return {
|
|
@@ -279,7 +399,40 @@ export async function runStartCommand(options = {}) {
|
|
|
279
399
|
};
|
|
280
400
|
}
|
|
281
401
|
|
|
282
|
-
|
|
402
|
+
let configState;
|
|
403
|
+
try {
|
|
404
|
+
configState = await readConfigFileState(configPath);
|
|
405
|
+
} catch (readConfigError) {
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
exitCode: 2,
|
|
409
|
+
errorMessage: `Failed to load config from ${configPath}: ${readConfigError instanceof Error ? readConfigError.message : String(readConfigError)}`
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const configMigrationMessage = formatStartupConfigMigrationMessage(configState, configPath);
|
|
414
|
+
if (configMigrationMessage) {
|
|
415
|
+
if (configState.persistError) {
|
|
416
|
+
error(configMigrationMessage);
|
|
417
|
+
} else {
|
|
418
|
+
line(configMigrationMessage);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let config = configState.config;
|
|
423
|
+
const persistedLocalServer = readLocalServerSettings(config);
|
|
424
|
+
const host = FIXED_LOCAL_ROUTER_HOST;
|
|
425
|
+
const port = resolveListenPort({ explicitPort: persistedLocalServer.port });
|
|
426
|
+
const watchConfig = requestedWatchConfig === undefined ? persistedLocalServer.watchConfig : toBoolean(requestedWatchConfig, persistedLocalServer.watchConfig);
|
|
427
|
+
const watchBinary = requestedWatchBinary === undefined ? persistedLocalServer.watchBinary : toBoolean(requestedWatchBinary, persistedLocalServer.watchBinary);
|
|
428
|
+
const requireAuth = requestedRequireAuth === undefined ? persistedLocalServer.requireAuth : toBoolean(requestedRequireAuth, persistedLocalServer.requireAuth);
|
|
429
|
+
const resolvedLocalServer = { host, port, watchConfig, watchBinary, requireAuth };
|
|
430
|
+
|
|
431
|
+
if (!areLocalServerSettingsEqual(persistedLocalServer, resolvedLocalServer)) {
|
|
432
|
+
config = await readConfigFile(configPath, { persistMigrated: false });
|
|
433
|
+
config = applyLocalServerSettings(config, resolvedLocalServer);
|
|
434
|
+
config = await writeConfigFile(config, configPath);
|
|
435
|
+
}
|
|
283
436
|
if (!configHasProvider(config)) {
|
|
284
437
|
return {
|
|
285
438
|
ok: false,
|
|
@@ -302,6 +455,54 @@ export async function runStartCommand(options = {}) {
|
|
|
302
455
|
};
|
|
303
456
|
}
|
|
304
457
|
|
|
458
|
+
const requestedStartArgs = {
|
|
459
|
+
configPath,
|
|
460
|
+
host,
|
|
461
|
+
port,
|
|
462
|
+
watchConfig,
|
|
463
|
+
watchBinary,
|
|
464
|
+
requireAuth
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const startup = await startupStatusFn().catch(() => null);
|
|
468
|
+
if (!managedByStartup && startup?.installed) {
|
|
469
|
+
const handoff = await handoffToStartupManagedWithLatest({
|
|
470
|
+
runtimeState: null,
|
|
471
|
+
fallbackStartArgs: requestedStartArgs,
|
|
472
|
+
cliPath: cliPathForWatch,
|
|
473
|
+
line,
|
|
474
|
+
error
|
|
475
|
+
}, {
|
|
476
|
+
getActiveRuntimeState: getActiveRuntimeStateFn,
|
|
477
|
+
stopProcessByPid: stopProcessByPidFn,
|
|
478
|
+
clearRuntimeState: clearRuntimeStateFn,
|
|
479
|
+
reclaimPort: reclaimPortFn,
|
|
480
|
+
installStartup: installStartupFn,
|
|
481
|
+
waitForRuntimeMatch: waitForRuntimeMatchFn,
|
|
482
|
+
startupStatus: startupStatusFn,
|
|
483
|
+
onLine: line,
|
|
484
|
+
onError: error
|
|
485
|
+
});
|
|
486
|
+
if (!handoff.ok) {
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
exitCode: 1,
|
|
490
|
+
errorMessage: handoff.errorMessage
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
exitCode: 0,
|
|
496
|
+
data: [
|
|
497
|
+
`Startup-managed llm-router is active on http://${handoff.runtime.host}:${handoff.runtime.port}.`,
|
|
498
|
+
`manager=${handoff.detail?.manager || startup.manager || "unknown"}`,
|
|
499
|
+
`service=${handoff.detail?.serviceId || startup.serviceId || "unknown"}`
|
|
500
|
+
].join("\n")
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let restartRequestedByConfig = false;
|
|
505
|
+
|
|
305
506
|
const buildLocalServerOptions = () => ({
|
|
306
507
|
port,
|
|
307
508
|
host,
|
|
@@ -319,6 +520,33 @@ export async function runStartCommand(options = {}) {
|
|
|
319
520
|
},
|
|
320
521
|
onConfigReload: (nextConfig, reason) => {
|
|
321
522
|
if (reason === "startup") return;
|
|
523
|
+
const nextLocalServer = readLocalServerSettings(nextConfig, resolvedLocalServer);
|
|
524
|
+
if (!areLocalServerSettingsEqual(nextLocalServer, resolvedLocalServer)) {
|
|
525
|
+
if (restartRequestedByConfig) return;
|
|
526
|
+
restartRequestedByConfig = true;
|
|
527
|
+
line(`Local server settings changed in config (${reason}). Restarting to apply local router settings...`);
|
|
528
|
+
void (async () => {
|
|
529
|
+
if (managedByStartup) {
|
|
530
|
+
await shutdown();
|
|
531
|
+
process.exit(0);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
await shutdown();
|
|
536
|
+
const launch = await spawnReplacementCli({
|
|
537
|
+
cliPath: cliPathForWatch || process.argv[1],
|
|
538
|
+
startArgs: buildStartArgs({ configPath, ...nextLocalServer })
|
|
539
|
+
});
|
|
540
|
+
if (!launch.ok) {
|
|
541
|
+
error(`Failed to relaunch llm-router after config runtime change: ${launch.error instanceof Error ? launch.error.message : String(launch.error)}`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
process.exit(0);
|
|
546
|
+
})();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
322
550
|
line(`Config hot-reloaded in memory (${reason}).`);
|
|
323
551
|
if (!configHasProvider(nextConfig)) {
|
|
324
552
|
error("Reloaded config has no enabled providers.");
|
|
@@ -329,9 +557,18 @@ export async function runStartCommand(options = {}) {
|
|
|
329
557
|
}
|
|
330
558
|
});
|
|
331
559
|
|
|
560
|
+
const activeRuntime = await getActiveRuntimeStateFn().catch(() => null);
|
|
561
|
+
if (activeRuntime && Number(activeRuntime.pid) !== Number(process.pid)) {
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
exitCode: 1,
|
|
565
|
+
errorMessage: `Another llm-router instance is already running at http://${activeRuntime.host}:${activeRuntime.port}. Stop it before starting a new one.`
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
332
569
|
let server;
|
|
333
570
|
try {
|
|
334
|
-
server = await
|
|
571
|
+
server = await startLocalRouteServerFn(buildLocalServerOptions());
|
|
335
572
|
} catch (startError) {
|
|
336
573
|
if (startError?.code !== "EADDRINUSE") {
|
|
337
574
|
return {
|
|
@@ -342,7 +579,7 @@ export async function runStartCommand(options = {}) {
|
|
|
342
579
|
}
|
|
343
580
|
|
|
344
581
|
if (!managedByStartup && onStartupConflict) {
|
|
345
|
-
const conflict = await detectStartupManagedConflictOnPort(port);
|
|
582
|
+
const conflict = await detectStartupManagedConflictOnPort(port, { getActiveRuntimeState: getActiveRuntimeStateFn, startupStatus: startupStatusFn });
|
|
346
583
|
if (conflict.running) {
|
|
347
584
|
let choice = "";
|
|
348
585
|
try {
|
|
@@ -357,17 +594,22 @@ export async function runStartCommand(options = {}) {
|
|
|
357
594
|
}
|
|
358
595
|
|
|
359
596
|
if (choice === "restart-startup") {
|
|
360
|
-
const restarted = await
|
|
597
|
+
const restarted = await handoffToStartupManagedWithLatest({
|
|
361
598
|
runtimeState: conflict.runtime,
|
|
362
|
-
fallbackStartArgs:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
port,
|
|
366
|
-
watchConfig,
|
|
367
|
-
watchBinary,
|
|
368
|
-
requireAuth
|
|
369
|
-
},
|
|
599
|
+
fallbackStartArgs: requestedStartArgs,
|
|
600
|
+
cliPath: cliPathForWatch,
|
|
601
|
+
line,
|
|
370
602
|
error
|
|
603
|
+
}, {
|
|
604
|
+
getActiveRuntimeState: getActiveRuntimeStateFn,
|
|
605
|
+
stopProcessByPid: stopProcessByPidFn,
|
|
606
|
+
clearRuntimeState: clearRuntimeStateFn,
|
|
607
|
+
reclaimPort: reclaimPortFn,
|
|
608
|
+
installStartup: installStartupFn,
|
|
609
|
+
waitForRuntimeMatch: waitForRuntimeMatchFn,
|
|
610
|
+
startupStatus: startupStatusFn,
|
|
611
|
+
onLine: line,
|
|
612
|
+
onError: error
|
|
371
613
|
});
|
|
372
614
|
if (!restarted.ok) {
|
|
373
615
|
return {
|
|
@@ -406,7 +648,7 @@ export async function runStartCommand(options = {}) {
|
|
|
406
648
|
}
|
|
407
649
|
|
|
408
650
|
line("Startup-managed instance stopped. Starting llm-router in this terminal...");
|
|
409
|
-
const takeoverStart = await attemptServerStartAfterStartupStop(buildLocalServerOptions);
|
|
651
|
+
const takeoverStart = await attemptServerStartAfterStartupStop(buildLocalServerOptions, { startLocalRouteServer: startLocalRouteServerFn });
|
|
410
652
|
if (takeoverStart.ok) {
|
|
411
653
|
server = takeoverStart.server;
|
|
412
654
|
line(`Port ${port} reclaimed successfully.`);
|
|
@@ -424,7 +666,7 @@ export async function runStartCommand(options = {}) {
|
|
|
424
666
|
if (server) {
|
|
425
667
|
// Startup conflict handling already resolved the bind conflict.
|
|
426
668
|
} else {
|
|
427
|
-
const reclaimed = await
|
|
669
|
+
const reclaimed = await reclaimPortFn({ port, line, error });
|
|
428
670
|
if (!reclaimed.ok) {
|
|
429
671
|
return {
|
|
430
672
|
ok: false,
|
|
@@ -434,7 +676,7 @@ export async function runStartCommand(options = {}) {
|
|
|
434
676
|
}
|
|
435
677
|
|
|
436
678
|
try {
|
|
437
|
-
server = await
|
|
679
|
+
server = await startLocalRouteServerFn(buildLocalServerOptions());
|
|
438
680
|
line(`Port ${port} reclaimed successfully.`);
|
|
439
681
|
} catch (retryError) {
|
|
440
682
|
return {
|
|
@@ -502,7 +744,7 @@ export async function runStartCommand(options = {}) {
|
|
|
502
744
|
// ignore
|
|
503
745
|
}
|
|
504
746
|
await closeServer();
|
|
505
|
-
await
|
|
747
|
+
await clearRuntimeStateFn({ pid: process.pid });
|
|
506
748
|
resolveDone();
|
|
507
749
|
};
|
|
508
750
|
|
|
@@ -7,6 +7,7 @@ import os from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { promises as fs, existsSync } from "node:fs";
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { FIXED_LOCAL_ROUTER_HOST, FIXED_LOCAL_ROUTER_PORT } from "./local-server-settings.js";
|
|
10
11
|
|
|
11
12
|
const SERVICE_NAME = "llm-router";
|
|
12
13
|
const LAUNCH_AGENT_ID = "dev.llm-router";
|
|
@@ -40,32 +41,48 @@ function runCommand(command, args, { cwd } = {}) {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function
|
|
44
|
-
|
|
44
|
+
export function resolveStartupCliEntryPath({
|
|
45
|
+
execPath = process.execPath,
|
|
46
|
+
env = process.env,
|
|
47
|
+
argv = process.argv,
|
|
48
|
+
exists = existsSync
|
|
49
|
+
} = {}) {
|
|
50
|
+
const envCliPath = String(env?.LLM_ROUTER_CLI_PATH || "").trim();
|
|
51
|
+
if (envCliPath && exists(envCliPath)) {
|
|
52
|
+
return envCliPath;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const argvCliPath = String(argv?.[1] || "").trim();
|
|
56
|
+
if (argvCliPath && exists(argvCliPath)) {
|
|
57
|
+
return path.resolve(argvCliPath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const nodeBinDir = path.dirname(execPath);
|
|
45
61
|
for (const binName of ["llm-router", "llm-router-route"]) {
|
|
46
62
|
const candidate = path.join(nodeBinDir, binName);
|
|
47
|
-
if (
|
|
63
|
+
if (exists(candidate)) return candidate;
|
|
48
64
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (process.env.LLM_ROUTER_CLI_PATH) return process.env.LLM_ROUTER_CLI_PATH;
|
|
53
|
-
if (process.argv[1]) return path.resolve(process.argv[1]);
|
|
65
|
+
|
|
66
|
+
if (envCliPath) return envCliPath;
|
|
67
|
+
if (argvCliPath) return path.resolve(argvCliPath);
|
|
54
68
|
throw new Error("Unable to resolve llm-router CLI entry path.");
|
|
55
69
|
}
|
|
56
70
|
|
|
57
|
-
function makeExecArgs({ configPath
|
|
71
|
+
function makeExecArgs({ configPath }) {
|
|
58
72
|
return [
|
|
59
73
|
"start",
|
|
60
|
-
`--config=${configPath}
|
|
61
|
-
`--host=${host}`,
|
|
62
|
-
`--port=${port}`,
|
|
63
|
-
`--watch-config=${watchConfig ? "true" : "false"}`,
|
|
64
|
-
`--watch-binary=${watchBinary ? "true" : "false"}`,
|
|
65
|
-
`--require-auth=${requireAuth ? "true" : "false"}`
|
|
74
|
+
`--config=${configPath}`
|
|
66
75
|
];
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
function isMissingServiceMessage(value) {
|
|
79
|
+
const text = String(value || "").toLowerCase();
|
|
80
|
+
return text.includes("could not find service")
|
|
81
|
+
|| text.includes("service could not be found")
|
|
82
|
+
|| text.includes("does not exist as a service")
|
|
83
|
+
|| text.includes("unit llm-router.service could not be found");
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
function buildLaunchAgentPlist({ nodePath, cliPath, configPath, host, port, watchConfig, watchBinary, requireAuth }) {
|
|
70
87
|
const logDir = path.join(os.homedir(), "Library", "Logs");
|
|
71
88
|
const stdoutPath = path.join(logDir, "llm-router.out.log");
|
|
@@ -129,18 +146,18 @@ WantedBy=default.target
|
|
|
129
146
|
`;
|
|
130
147
|
}
|
|
131
148
|
|
|
132
|
-
async function installDarwin({ configPath, host, port, watchConfig, watchBinary, requireAuth }) {
|
|
149
|
+
async function installDarwin({ configPath, host, port, watchConfig, watchBinary, requireAuth, cliPath }) {
|
|
133
150
|
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
|
|
134
151
|
const plistPath = path.join(launchAgentsDir, `${LAUNCH_AGENT_ID}.plist`);
|
|
135
152
|
const nodePath = process.execPath;
|
|
136
|
-
const
|
|
153
|
+
const resolvedCliPath = String(cliPath || "").trim() || resolveStartupCliEntryPath();
|
|
137
154
|
|
|
138
155
|
await fs.mkdir(launchAgentsDir, { recursive: true });
|
|
139
156
|
await fs.mkdir(path.join(os.homedir(), "Library", "Logs"), { recursive: true });
|
|
140
157
|
|
|
141
158
|
const content = buildLaunchAgentPlist({
|
|
142
159
|
nodePath,
|
|
143
|
-
cliPath,
|
|
160
|
+
cliPath: resolvedCliPath,
|
|
144
161
|
configPath,
|
|
145
162
|
host,
|
|
146
163
|
port,
|
|
@@ -201,6 +218,7 @@ async function statusDarwin() {
|
|
|
201
218
|
|
|
202
219
|
const domain = resolveDarwinDomain();
|
|
203
220
|
const listResult = runCommand("launchctl", ["print", `${domain}/${LAUNCH_AGENT_ID}`]);
|
|
221
|
+
const rawDetail = listResult.ok ? listResult.stdout : (listResult.stderr || listResult.stdout);
|
|
204
222
|
|
|
205
223
|
return {
|
|
206
224
|
manager: "launchd",
|
|
@@ -208,7 +226,11 @@ async function statusDarwin() {
|
|
|
208
226
|
installed,
|
|
209
227
|
running: listResult.ok,
|
|
210
228
|
filePath: plistPath,
|
|
211
|
-
detail:
|
|
229
|
+
detail: !installed
|
|
230
|
+
? "Startup service is not installed."
|
|
231
|
+
: (!listResult.ok && isMissingServiceMessage(rawDetail))
|
|
232
|
+
? "Startup service is installed but not currently loaded."
|
|
233
|
+
: (rawDetail || (listResult.ok ? "LaunchAgent is running." : "LaunchAgent is not running."))
|
|
212
234
|
};
|
|
213
235
|
}
|
|
214
236
|
|
|
@@ -232,16 +254,16 @@ async function restartDarwin() {
|
|
|
232
254
|
return statusDarwin();
|
|
233
255
|
}
|
|
234
256
|
|
|
235
|
-
async function installLinux({ configPath, host, port, watchConfig, watchBinary, requireAuth }) {
|
|
257
|
+
async function installLinux({ configPath, host, port, watchConfig, watchBinary, requireAuth, cliPath }) {
|
|
236
258
|
const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
|
|
237
259
|
const servicePath = path.join(systemdDir, `${SERVICE_NAME}.service`);
|
|
238
260
|
const nodePath = process.execPath;
|
|
239
|
-
const
|
|
261
|
+
const resolvedCliPath = String(cliPath || "").trim() || resolveStartupCliEntryPath();
|
|
240
262
|
|
|
241
263
|
await fs.mkdir(systemdDir, { recursive: true });
|
|
242
264
|
const content = buildSystemdService({
|
|
243
265
|
nodePath,
|
|
244
|
-
cliPath,
|
|
266
|
+
cliPath: resolvedCliPath,
|
|
245
267
|
configPath,
|
|
246
268
|
host,
|
|
247
269
|
port,
|
|
@@ -255,9 +277,16 @@ async function installLinux({ configPath, host, port, watchConfig, watchBinary,
|
|
|
255
277
|
if (!daemonReload.ok) {
|
|
256
278
|
throw new Error(daemonReload.stderr || daemonReload.stdout || "systemctl daemon-reload failed.");
|
|
257
279
|
}
|
|
258
|
-
const
|
|
259
|
-
if (!
|
|
260
|
-
throw new Error(
|
|
280
|
+
const enable = runCommand("systemctl", ["--user", "enable", `${SERVICE_NAME}.service`]);
|
|
281
|
+
if (!enable.ok) {
|
|
282
|
+
throw new Error(enable.stderr || enable.stdout || "systemctl enable failed.");
|
|
283
|
+
}
|
|
284
|
+
const restart = runCommand("systemctl", ["--user", "restart", `${SERVICE_NAME}.service`]);
|
|
285
|
+
if (!restart.ok) {
|
|
286
|
+
const start = runCommand("systemctl", ["--user", "start", `${SERVICE_NAME}.service`]);
|
|
287
|
+
if (!start.ok) {
|
|
288
|
+
throw new Error(start.stderr || start.stdout || restart.stderr || restart.stdout || "systemctl restart failed.");
|
|
289
|
+
}
|
|
261
290
|
}
|
|
262
291
|
|
|
263
292
|
return {
|
|
@@ -297,13 +326,18 @@ async function statusLinux() {
|
|
|
297
326
|
}
|
|
298
327
|
|
|
299
328
|
const isActive = runCommand("systemctl", ["--user", "is-active", `${SERVICE_NAME}.service`]);
|
|
329
|
+
const rawDetail = isActive.stdout || isActive.stderr;
|
|
300
330
|
return {
|
|
301
331
|
manager: "systemd-user",
|
|
302
332
|
serviceId: `${SERVICE_NAME}.service`,
|
|
303
333
|
installed,
|
|
304
334
|
running: isActive.ok && isActive.stdout.trim() === "active",
|
|
305
335
|
filePath: servicePath,
|
|
306
|
-
detail:
|
|
336
|
+
detail: !installed
|
|
337
|
+
? "Startup service is not installed."
|
|
338
|
+
: (!isActive.ok && isMissingServiceMessage(rawDetail))
|
|
339
|
+
? "Startup service is installed but currently stopped."
|
|
340
|
+
: (rawDetail || (isActive.ok ? "Systemd user service is active." : "Systemd user service is not active."))
|
|
307
341
|
};
|
|
308
342
|
}
|
|
309
343
|
|
|
@@ -328,11 +362,12 @@ async function restartLinux() {
|
|
|
328
362
|
export async function installStartup(options) {
|
|
329
363
|
const payload = {
|
|
330
364
|
configPath: options.configPath,
|
|
331
|
-
host:
|
|
332
|
-
port:
|
|
365
|
+
host: FIXED_LOCAL_ROUTER_HOST,
|
|
366
|
+
port: FIXED_LOCAL_ROUTER_PORT,
|
|
333
367
|
watchConfig: options.watchConfig !== false,
|
|
334
368
|
watchBinary: options.watchBinary !== false,
|
|
335
|
-
requireAuth: options.requireAuth === true
|
|
369
|
+
requireAuth: options.requireAuth === true,
|
|
370
|
+
cliPath: String(options.cliPath || "").trim()
|
|
336
371
|
};
|
|
337
372
|
|
|
338
373
|
if (process.platform === "darwin") return installDarwin(payload);
|