@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +337 -41
  3. package/package.json +19 -3
  4. package/src/cli/router-module.js +7331 -3805
  5. package/src/cli/wrangler-toml.js +1 -1
  6. package/src/cli-entry.js +162 -24
  7. package/src/node/amp-client-config.js +426 -0
  8. package/src/node/coding-tool-config.js +763 -0
  9. package/src/node/config-store.js +49 -18
  10. package/src/node/instance-state.js +213 -12
  11. package/src/node/listen-port.js +5 -37
  12. package/src/node/local-server-settings.js +122 -0
  13. package/src/node/local-server.js +3 -2
  14. package/src/node/provider-probe.js +13 -0
  15. package/src/node/start-command.js +282 -40
  16. package/src/node/startup-manager.js +64 -29
  17. package/src/node/web-command.js +106 -0
  18. package/src/node/web-console-assets.js +26 -0
  19. package/src/node/web-console-client.js +56 -0
  20. package/src/node/web-console-dev-assets.js +258 -0
  21. package/src/node/web-console-server.js +3146 -0
  22. package/src/node/web-console-styles.generated.js +1 -0
  23. package/src/node/web-console-ui/config-editor-utils.js +616 -0
  24. package/src/node/web-console-ui/lib/utils.js +6 -0
  25. package/src/node/web-console-ui/rate-limit-utils.js +144 -0
  26. package/src/node/web-console-ui/select-search-utils.js +36 -0
  27. package/src/runtime/codex-request-transformer.js +46 -5
  28. package/src/runtime/codex-response-transformer.js +268 -35
  29. package/src/runtime/config.js +1394 -35
  30. package/src/runtime/handler/amp-gemini.js +913 -0
  31. package/src/runtime/handler/amp-response.js +308 -0
  32. package/src/runtime/handler/amp.js +290 -0
  33. package/src/runtime/handler/auth.js +17 -2
  34. package/src/runtime/handler/provider-call.js +168 -50
  35. package/src/runtime/handler/provider-translation.js +937 -26
  36. package/src/runtime/handler/request.js +149 -6
  37. package/src/runtime/handler/route-debug.js +22 -1
  38. package/src/runtime/handler.js +449 -9
  39. package/src/runtime/subscription-auth.js +1 -6
  40. package/src/shared/local-router-defaults.js +62 -0
  41. package/src/translator/index.js +3 -1
  42. package/src/translator/request/openai-to-claude.js +217 -6
  43. 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, host, port, watchConfig, watchBinary, requireAuth }) {
100
- return [
137
+ function buildStartArgs({ configPath, watchConfig, watchBinary, requireAuth, useConfigDefaults = false }) {
138
+ const args = [
101
139
  "start",
102
- `--config=${configPath}`,
103
- `--host=${host}`,
104
- `--port=${port}`,
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 getActiveRuntimeState();
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 startupStatus();
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 restartStartupManagedWithLatest({
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
- const restarted = await installStartup({
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
- await clearRuntimeState();
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: restarted
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 server = await startLocalRouteServer(buildLocalServerOptions());
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 host = options.host || "127.0.0.1";
257
- const port = resolveListenPort({ explicitPort: options.port });
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 requireAuth = toBoolean(options.requireAuth, false);
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
- const config = await readConfigFile(configPath);
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 startLocalRouteServer(buildLocalServerOptions());
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 restartStartupManagedWithLatest({
597
+ const restarted = await handoffToStartupManagedWithLatest({
361
598
  runtimeState: conflict.runtime,
362
- fallbackStartArgs: {
363
- configPath,
364
- host,
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 reclaimPort({ port, line, error });
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 startLocalRouteServer(buildLocalServerOptions());
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 clearRuntimeState({ pid: process.pid });
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 resolveCliEntryPath() {
44
- const nodeBinDir = path.dirname(process.execPath);
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 (existsSync(candidate)) return candidate;
63
+ if (exists(candidate)) return candidate;
48
64
  }
49
- if (process.env.LLM_ROUTER_CLI_PATH && existsSync(process.env.LLM_ROUTER_CLI_PATH)) {
50
- return process.env.LLM_ROUTER_CLI_PATH;
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, host, port, watchConfig, watchBinary, requireAuth }) {
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 cliPath = resolveCliEntryPath();
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: listResult.ok ? listResult.stdout : (listResult.stderr || listResult.stdout)
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 cliPath = resolveCliEntryPath();
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 enableNow = runCommand("systemctl", ["--user", "enable", "--now", `${SERVICE_NAME}.service`]);
259
- if (!enableNow.ok) {
260
- throw new Error(enableNow.stderr || enableNow.stdout || "systemctl enable --now failed.");
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: isActive.stdout || isActive.stderr
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: options.host || "127.0.0.1",
332
- port: options.port || 8787,
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);