@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
@@ -0,0 +1,3146 @@
1
+ import http from "node:http";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { promises as fs, readFileSync, watch as fsWatch } from "node:fs";
6
+ import { getDefaultConfigPath, writeConfigFile } from "./config-store.js";
7
+ import { clearRuntimeState, getActiveRuntimeState, startDetachedRouterService, stopProcessByPid, waitForRuntimeMatch } from "./instance-state.js";
8
+ import {
9
+ FIXED_LOCAL_ROUTER_HOST,
10
+ FIXED_LOCAL_ROUTER_PORT,
11
+ applyLocalServerSettings,
12
+ areLocalServerSettingsEqual,
13
+ formatStartupDetail,
14
+ formatStartupLabel,
15
+ getFixedLocalRouterOrigin,
16
+ readLocalServerSettings
17
+ } from "./local-server-settings.js";
18
+ import { listListeningPids, reclaimPort } from "./port-reclaim.js";
19
+ import { probeProvider, probeProviderEndpointMatrix } from "./provider-probe.js";
20
+ import { installStartup, startupStatus, stopStartup, uninstallStartup } from "./startup-manager.js";
21
+ import { WEB_CONSOLE_CSS, renderWebConsoleHtml } from "./web-console-assets.js";
22
+ import { startWebConsoleDevAssets } from "./web-console-dev-assets.js";
23
+ import {
24
+ buildAmpClientPatchPlan,
25
+ maybeBootstrapAmpConfig,
26
+ patchAmpClientConfigFiles,
27
+ readAmpClientRoutingState,
28
+ resolveAmpClientSecretsFilePath,
29
+ resolveAmpClientSettingsFilePath,
30
+ unpatchAmpClientConfigFiles
31
+ } from "./amp-client-config.js";
32
+ import {
33
+ ensureClaudeCodeSettingsFileExists,
34
+ ensureCodexCliConfigFileExists,
35
+ patchClaudeCodeSettingsFile,
36
+ patchCodexCliConfigFile,
37
+ readClaudeCodeRoutingState,
38
+ readCodexCliRoutingState,
39
+ resolveClaudeCodeSettingsFilePath,
40
+ resolveCodexCliConfigFilePath,
41
+ unpatchClaudeCodeSettingsFile,
42
+ unpatchCodexCliConfigFile
43
+ } from "./coding-tool-config.js";
44
+ import { loginSubscription } from "../runtime/subscription-provider.js";
45
+ import {
46
+ CONFIG_VERSION,
47
+ DEFAULT_MODEL_ALIAS_ID,
48
+ DEFAULT_PROVIDER_USER_AGENT,
49
+ configHasProvider,
50
+ normalizeRuntimeConfig,
51
+ resolveProviderApiKey,
52
+ validateRuntimeConfig
53
+ } from "../runtime/config.js";
54
+
55
+ const JSON_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
56
+ const MAX_LOG_ENTRIES = 150;
57
+ const WEB_CONSOLE_APP_JS = readFileSync(fileURLToPath(new URL("./web-console-client.js", import.meta.url)), "utf8");
58
+
59
+ function buildDefaultConfigObject() {
60
+ return normalizeRuntimeConfig({}, { migrateToVersion: CONFIG_VERSION });
61
+ }
62
+
63
+ function buildDefaultConfigRawText() {
64
+ return `${JSON.stringify(buildDefaultConfigObject(), null, 2)}\n`;
65
+ }
66
+
67
+ function countRateLimitBuckets(config) {
68
+ return (config?.providers || []).reduce((sum, provider) => sum + ((provider?.rateLimits || []).length || 0), 0);
69
+ }
70
+
71
+ function dedupeTrimmedStrings(values = []) {
72
+ return [...new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))];
73
+ }
74
+
75
+ function normalizeManagedRouteLookup(value) {
76
+ const text = String(value || "").trim();
77
+ if (!text) return "";
78
+ return text.startsWith("alias:") ? text.slice("alias:".length).trim() : text;
79
+ }
80
+
81
+ function collectManagedRouteRefs(config = {}) {
82
+ const refs = new Set();
83
+
84
+ const aliases = config?.modelAliases && typeof config.modelAliases === "object" && !Array.isArray(config.modelAliases)
85
+ ? config.modelAliases
86
+ : {};
87
+ for (const aliasId of Object.keys(aliases)) {
88
+ const normalizedAliasId = String(aliasId || "").trim();
89
+ if (normalizedAliasId) refs.add(normalizedAliasId);
90
+ }
91
+
92
+ for (const provider of Array.isArray(config?.providers) ? config.providers : []) {
93
+ const providerId = String(provider?.id || "").trim();
94
+ if (!providerId) continue;
95
+ for (const model of Array.isArray(provider?.models) ? provider.models : []) {
96
+ const modelId = String(model?.id || "").trim();
97
+ if (!modelId) continue;
98
+ refs.add(`${providerId}/${modelId}`);
99
+ }
100
+ }
101
+
102
+ return refs;
103
+ }
104
+
105
+ function hasManagedRouteRef(routeRefs, ref) {
106
+ const lookup = normalizeManagedRouteLookup(ref);
107
+ return Boolean(lookup && routeRefs instanceof Set && routeRefs.has(lookup));
108
+ }
109
+
110
+ function sortJsonValue(value) {
111
+ if (Array.isArray(value)) {
112
+ return value.map((entry) => sortJsonValue(entry));
113
+ }
114
+ if (!value || typeof value !== "object") return value;
115
+ return Object.fromEntries(
116
+ Object.keys(value)
117
+ .sort((left, right) => left.localeCompare(right))
118
+ .map((key) => [key, sortJsonValue(value[key])])
119
+ );
120
+ }
121
+
122
+ function createJsonSignature(value) {
123
+ return JSON.stringify(sortJsonValue(value));
124
+ }
125
+
126
+ function inferRenamePairs(previousItems = [], nextItems = [], {
127
+ getId = (item) => item?.id,
128
+ getSignature = () => "",
129
+ allowPositionalFallback = false
130
+ } = {}) {
131
+ const previousEntries = previousItems
132
+ .map((item, index) => ({
133
+ item,
134
+ index,
135
+ id: String(getId(item) || "").trim()
136
+ }))
137
+ .filter((entry) => entry.id);
138
+ const nextEntries = nextItems
139
+ .map((item, index) => ({
140
+ item,
141
+ index,
142
+ id: String(getId(item) || "").trim()
143
+ }))
144
+ .filter((entry) => entry.id);
145
+ const nextIds = new Set(nextEntries.map((entry) => entry.id));
146
+ const matchedIds = new Set(previousEntries.filter((entry) => nextIds.has(entry.id)).map((entry) => entry.id));
147
+ const previousRemaining = previousEntries.filter((entry) => !matchedIds.has(entry.id));
148
+ const nextRemaining = nextEntries.filter((entry) => !matchedIds.has(entry.id));
149
+ const mapping = new Map();
150
+ const previousBySignature = new Map();
151
+ const nextBySignature = new Map();
152
+
153
+ for (const entry of previousRemaining) {
154
+ const signature = String(getSignature(entry.item) || "");
155
+ const bucket = previousBySignature.get(signature) || [];
156
+ bucket.push(entry);
157
+ previousBySignature.set(signature, bucket);
158
+ }
159
+ for (const entry of nextRemaining) {
160
+ const signature = String(getSignature(entry.item) || "");
161
+ const bucket = nextBySignature.get(signature) || [];
162
+ bucket.push(entry);
163
+ nextBySignature.set(signature, bucket);
164
+ }
165
+
166
+ for (const [signature, previousBucket] of previousBySignature.entries()) {
167
+ const nextBucket = nextBySignature.get(signature) || [];
168
+ if (previousBucket.length !== 1 || nextBucket.length !== 1) continue;
169
+ const previousEntry = previousBucket[0];
170
+ const nextEntry = nextBucket[0];
171
+ if (previousEntry.id !== nextEntry.id) {
172
+ mapping.set(previousEntry.id, nextEntry.id);
173
+ }
174
+ }
175
+
176
+ const remainingPrevious = previousRemaining.filter((entry) => !mapping.has(entry.id));
177
+ const mappedNextIds = new Set(mapping.values());
178
+ const remainingNext = nextRemaining.filter((entry) => !mappedNextIds.has(entry.id));
179
+
180
+ if (remainingPrevious.length === 1 && remainingNext.length === 1) {
181
+ if (remainingPrevious[0].id !== remainingNext[0].id) {
182
+ mapping.set(remainingPrevious[0].id, remainingNext[0].id);
183
+ }
184
+ return mapping;
185
+ }
186
+
187
+ if (allowPositionalFallback && remainingPrevious.length > 0 && remainingPrevious.length === remainingNext.length) {
188
+ for (let index = 0; index < remainingPrevious.length; index += 1) {
189
+ const previousEntry = remainingPrevious[index];
190
+ const nextEntry = remainingNext[index];
191
+ if (previousEntry.id !== nextEntry.id) {
192
+ mapping.set(previousEntry.id, nextEntry.id);
193
+ }
194
+ }
195
+ }
196
+
197
+ return mapping;
198
+ }
199
+
200
+ function createModelComparable(model = {}) {
201
+ const comparable = {};
202
+ for (const [key, value] of Object.entries(model && typeof model === "object" && !Array.isArray(model) ? model : {})) {
203
+ if (key === "id" || key === "fallbackModels") continue;
204
+ comparable[key] = sortJsonValue(value);
205
+ }
206
+ return comparable;
207
+ }
208
+
209
+ function createProviderComparable(provider = {}) {
210
+ const comparable = {};
211
+ for (const [key, value] of Object.entries(provider && typeof provider === "object" && !Array.isArray(provider) ? provider : {})) {
212
+ if (key === "id" || key === "name") continue;
213
+ if (key === "models") {
214
+ comparable.models = (Array.isArray(value) ? value : []).map((model) => createModelComparable(model));
215
+ continue;
216
+ }
217
+ comparable[key] = sortJsonValue(value);
218
+ }
219
+ return comparable;
220
+ }
221
+
222
+ function createAliasComparable(alias = {}, rewriteRef) {
223
+ const normalizeTargets = (targets = []) => (Array.isArray(targets) ? targets : [])
224
+ .map((target) => {
225
+ const currentRef = String(target?.ref || "").trim();
226
+ if (!currentRef) return null;
227
+ const nextRef = String(typeof rewriteRef === "function" ? rewriteRef(currentRef) : currentRef).trim();
228
+ if (!nextRef) return null;
229
+ const nextTarget = { ref: nextRef };
230
+ if (target?.weight !== undefined) nextTarget.weight = target.weight;
231
+ return nextTarget;
232
+ })
233
+ .filter(Boolean);
234
+
235
+ return {
236
+ strategy: String(alias?.strategy || "ordered").trim() || "ordered",
237
+ targets: normalizeTargets(alias?.targets),
238
+ fallbackTargets: normalizeTargets(alias?.fallbackTargets)
239
+ };
240
+ }
241
+
242
+ function inferProviderRenameMap(previousConfig = {}, nextConfig = {}) {
243
+ return inferRenamePairs(
244
+ Array.isArray(previousConfig?.providers) ? previousConfig.providers : [],
245
+ Array.isArray(nextConfig?.providers) ? nextConfig.providers : [],
246
+ {
247
+ getId: (provider) => provider?.id,
248
+ getSignature: (provider) => createJsonSignature(createProviderComparable(provider))
249
+ }
250
+ );
251
+ }
252
+
253
+ function inferModelRenameMaps(previousConfig = {}, nextConfig = {}, providerRenameMap = new Map()) {
254
+ const previousProviders = Array.isArray(previousConfig?.providers) ? previousConfig.providers : [];
255
+ const nextProviders = Array.isArray(nextConfig?.providers) ? nextConfig.providers : [];
256
+ const nextProvidersById = new Map(
257
+ nextProviders
258
+ .map((provider) => [String(provider?.id || "").trim(), provider])
259
+ .filter(([providerId]) => providerId)
260
+ );
261
+ const modelRenameMaps = new Map();
262
+
263
+ for (const previousProvider of previousProviders) {
264
+ const previousProviderId = String(previousProvider?.id || "").trim();
265
+ if (!previousProviderId) continue;
266
+ const nextProviderId = String(providerRenameMap.get(previousProviderId) || previousProviderId).trim();
267
+ const nextProvider = nextProvidersById.get(nextProviderId);
268
+ if (!nextProvider) continue;
269
+
270
+ const renameMap = inferRenamePairs(
271
+ Array.isArray(previousProvider?.models) ? previousProvider.models : [],
272
+ Array.isArray(nextProvider?.models) ? nextProvider.models : [],
273
+ {
274
+ getId: (model) => model?.id,
275
+ getSignature: (model) => createJsonSignature(createModelComparable(model)),
276
+ allowPositionalFallback: true
277
+ }
278
+ );
279
+
280
+ if (renameMap.size > 0) {
281
+ modelRenameMaps.set(previousProviderId, renameMap);
282
+ }
283
+ }
284
+
285
+ return modelRenameMaps;
286
+ }
287
+
288
+ function rewriteManagedRouteRef(ref, {
289
+ previousValidRouteRefs,
290
+ nextValidRouteRefs,
291
+ providerRenameMap = new Map(),
292
+ modelRenameMaps = new Map(),
293
+ aliasRenameMap = new Map(),
294
+ preserveUnknown = true
295
+ } = {}) {
296
+ const text = String(ref || "").trim();
297
+ if (!text) return "";
298
+ if (hasManagedRouteRef(nextValidRouteRefs, text)) return text;
299
+
300
+ const knownInPreviousConfig = hasManagedRouteRef(previousValidRouteRefs, text);
301
+ if (!knownInPreviousConfig) return preserveUnknown ? text : "";
302
+
303
+ const lookup = normalizeManagedRouteLookup(text);
304
+ const aliasPrefixed = text.startsWith("alias:");
305
+
306
+ if (aliasPrefixed || !lookup.includes("/")) {
307
+ const nextAliasId = String(aliasRenameMap.get(lookup) || lookup).trim();
308
+ if (!nextAliasId) return "";
309
+ return hasManagedRouteRef(nextValidRouteRefs, nextAliasId)
310
+ ? (aliasPrefixed ? `alias:${nextAliasId}` : nextAliasId)
311
+ : "";
312
+ }
313
+
314
+ const slashIndex = lookup.indexOf("/");
315
+ const previousProviderId = lookup.slice(0, slashIndex);
316
+ const previousModelId = lookup.slice(slashIndex + 1);
317
+ const nextProviderId = String(providerRenameMap.get(previousProviderId) || previousProviderId).trim();
318
+ const nextModelId = String((modelRenameMaps.get(previousProviderId) || new Map()).get(previousModelId) || previousModelId).trim();
319
+ const nextRef = nextProviderId && nextModelId ? `${nextProviderId}/${nextModelId}` : "";
320
+ return nextRef && hasManagedRouteRef(nextValidRouteRefs, nextRef) ? nextRef : "";
321
+ }
322
+
323
+ function inferAliasRenameMap(previousConfig = {}, nextConfig = {}, routeRewriteContext = {}) {
324
+ const previousAliases = Object.entries(
325
+ previousConfig?.modelAliases && typeof previousConfig.modelAliases === "object" && !Array.isArray(previousConfig.modelAliases)
326
+ ? previousConfig.modelAliases
327
+ : {}
328
+ )
329
+ .filter(([aliasId]) => {
330
+ const normalizedAliasId = String(aliasId || "").trim();
331
+ return normalizedAliasId && normalizedAliasId !== DEFAULT_MODEL_ALIAS_ID;
332
+ })
333
+ .map(([aliasId, alias]) => ({
334
+ id: String(aliasId || "").trim(),
335
+ alias
336
+ }));
337
+ const nextAliases = Object.entries(
338
+ nextConfig?.modelAliases && typeof nextConfig.modelAliases === "object" && !Array.isArray(nextConfig.modelAliases)
339
+ ? nextConfig.modelAliases
340
+ : {}
341
+ )
342
+ .filter(([aliasId]) => {
343
+ const normalizedAliasId = String(aliasId || "").trim();
344
+ return normalizedAliasId && normalizedAliasId !== DEFAULT_MODEL_ALIAS_ID;
345
+ })
346
+ .map(([aliasId, alias]) => ({
347
+ id: String(aliasId || "").trim(),
348
+ alias
349
+ }));
350
+ const aliasRenameMap = new Map();
351
+
352
+ for (let attempt = 0; attempt < 3; attempt += 1) {
353
+ const nextPairs = inferRenamePairs(previousAliases, nextAliases, {
354
+ getId: (entry) => entry.id,
355
+ getSignature: (entry) => createJsonSignature(createAliasComparable(entry.alias, (routeRef) => rewriteManagedRouteRef(routeRef, {
356
+ ...routeRewriteContext,
357
+ aliasRenameMap
358
+ }))),
359
+ allowPositionalFallback: true
360
+ });
361
+ let changed = false;
362
+ for (const [previousAliasId, nextAliasId] of nextPairs.entries()) {
363
+ if (aliasRenameMap.get(previousAliasId) === nextAliasId) continue;
364
+ aliasRenameMap.set(previousAliasId, nextAliasId);
365
+ changed = true;
366
+ }
367
+ if (!changed) break;
368
+ }
369
+
370
+ return aliasRenameMap;
371
+ }
372
+
373
+ function buildManagedRouteRewriteContext(previousConfig = {}, nextConfig = {}) {
374
+ const previousValidRouteRefs = collectManagedRouteRefs(previousConfig);
375
+ const nextValidRouteRefs = collectManagedRouteRefs(nextConfig);
376
+ const providerRenameMap = inferProviderRenameMap(previousConfig, nextConfig);
377
+ const modelRenameMaps = inferModelRenameMaps(previousConfig, nextConfig, providerRenameMap);
378
+ const routeRewriteContext = {
379
+ previousValidRouteRefs,
380
+ nextValidRouteRefs,
381
+ providerRenameMap,
382
+ modelRenameMaps
383
+ };
384
+ return {
385
+ ...routeRewriteContext,
386
+ aliasRenameMap: inferAliasRenameMap(previousConfig, nextConfig, routeRewriteContext)
387
+ };
388
+ }
389
+
390
+ function formatHostForUrl(host, port) {
391
+ const value = String(host || "127.0.0.1").trim();
392
+ if (!value.includes(":")) return `${value}:${port}`;
393
+ if (value.startsWith("[") && value.endsWith("]")) return `${value}:${port}`;
394
+ return `[${value}]:${port}`;
395
+ }
396
+
397
+ function buildAmpClientEndpointUrl(settings = {}) {
398
+ return getFixedLocalRouterOrigin();
399
+ }
400
+
401
+ function buildCodexCliEndpointUrl(settings = {}) {
402
+ const origin = buildAmpClientEndpointUrl(settings);
403
+ return origin ? `${origin}/openai/v1` : "";
404
+ }
405
+
406
+ function buildClaudeCodeEndpointUrl(settings = {}) {
407
+ const origin = buildAmpClientEndpointUrl(settings);
408
+ return origin ? `${origin}/anthropic` : "";
409
+ }
410
+
411
+ function buildRouterEndpoints({ host, port, running }) {
412
+ if (!running) return [];
413
+ const origin = getFixedLocalRouterOrigin();
414
+ return [
415
+ { label: "Unified", url: `${origin}/route` },
416
+ { label: "Anthropic", url: `${origin}/anthropic` },
417
+ { label: "OpenAI", url: `${origin}/openai` },
418
+ { label: "OpenAI Responses", url: `${origin}/openai/v1/responses` },
419
+ { label: "AMP OpenAI", url: `${origin}/api/provider/openai/v1/chat/completions` },
420
+ { label: "AMP Anthropic", url: `${origin}/api/provider/anthropic/v1/messages` }
421
+ ];
422
+ }
423
+
424
+ function normalizeRuntimeHost(value) {
425
+ return String(value || "127.0.0.1").trim() || "127.0.0.1";
426
+ }
427
+
428
+ function isWildcardRuntimeHost(value) {
429
+ const host = normalizeRuntimeHost(value).toLowerCase();
430
+ return host === "0.0.0.0" || host === "::" || host === "::0" || host === "[::]";
431
+ }
432
+
433
+ function isLoopbackRuntimeHost(value) {
434
+ const host = normalizeRuntimeHost(value).toLowerCase();
435
+ return host === "127.0.0.1"
436
+ || host === "localhost"
437
+ || host === "::1"
438
+ || host === "::ffff:127.0.0.1";
439
+ }
440
+
441
+ function runtimeHostMatchesTarget(runtimeHost, targetHost) {
442
+ const runtimeValue = normalizeRuntimeHost(runtimeHost).toLowerCase();
443
+ const targetValue = normalizeRuntimeHost(targetHost).toLowerCase();
444
+ if (runtimeValue === targetValue) return true;
445
+ if (isWildcardRuntimeHost(runtimeValue) || isWildcardRuntimeHost(targetValue)) return true;
446
+ return isLoopbackRuntimeHost(runtimeValue) && isLoopbackRuntimeHost(targetValue);
447
+ }
448
+
449
+ function runtimeMatchesEndpoint(runtime, settings = {}) {
450
+ if (!runtime || typeof runtime !== "object") return false;
451
+ return runtimeHostMatchesTarget(runtime.host, settings.host)
452
+ && Number(runtime.port) === Number(settings.port);
453
+ }
454
+
455
+ function endpointsConflict({ host: leftHost, port: leftPort }, { host: rightHost, port: rightPort }) {
456
+ return Number(leftPort) === Number(rightPort)
457
+ && runtimeHostMatchesTarget(leftHost, rightHost);
458
+ }
459
+
460
+ function runtimeMatchesSettings(runtime, settings = {}, configPath = "") {
461
+ if (!runtime || typeof runtime !== "object") return false;
462
+ return runtimeMatchesEndpoint(runtime, settings)
463
+ && Boolean(runtime.watchConfig !== false) === Boolean(settings.watchConfig !== false)
464
+ && Boolean(runtime.watchBinary !== false) === Boolean(settings.watchBinary !== false)
465
+ && Boolean(runtime.requireAuth === true) === Boolean(settings.requireAuth === true)
466
+ && String(runtime.configPath || "").trim() === String(configPath || "").trim();
467
+ }
468
+
469
+ function buildRouterSnapshot(runtime, settings = {}, lastError = "") {
470
+ const source = runtime || settings || {};
471
+ const host = normalizeRuntimeHost(source.host);
472
+ const port = Number.isInteger(Number(source.port)) ? Number(source.port) : FIXED_LOCAL_ROUTER_PORT;
473
+ const running = Boolean(runtime);
474
+ return {
475
+ running,
476
+ host,
477
+ port,
478
+ watchConfig: runtime ? runtime.watchConfig !== false : settings.watchConfig !== false,
479
+ watchBinary: runtime ? runtime.watchBinary !== false : settings.watchBinary !== false,
480
+ requireAuth: runtime ? runtime.requireAuth === true : settings.requireAuth === true,
481
+ startedAt: running ? String(runtime.startedAt || "") : "",
482
+ url: running ? `http://${formatHostForUrl(host, port)}` : "",
483
+ endpoints: buildRouterEndpoints({ host, port, running }),
484
+ lastError
485
+ };
486
+ }
487
+
488
+ function isLoopbackAddress(address) {
489
+ const value = String(address || "").trim();
490
+ return value === "::1"
491
+ || value === "::ffff:127.0.0.1"
492
+ || value.startsWith("127.")
493
+ || value.startsWith("::ffff:127.");
494
+ }
495
+
496
+ function commandExists(command, platform = process.platform) {
497
+ const binary = platform === "win32" ? "where" : "which";
498
+ const result = spawnSync(binary, [command], {
499
+ encoding: "utf8",
500
+ stdio: ["ignore", "ignore", "ignore"]
501
+ });
502
+ return result.status === 0;
503
+ }
504
+
505
+ export function detectAvailableEditors({ platform = process.platform, exists = commandExists } = {}) {
506
+ const editors = [
507
+ {
508
+ id: "default",
509
+ label: platform === "darwin" ? "Default App" : platform === "win32" ? "Default Editor" : "Default App",
510
+ description: "Open with the OS default app"
511
+ }
512
+ ];
513
+
514
+ if (exists("code", platform)) {
515
+ editors.push({ id: "vscode", label: "VS Code", description: "Open in Visual Studio Code" });
516
+ }
517
+ if (exists("cursor", platform)) {
518
+ editors.push({ id: "cursor", label: "Cursor", description: "Open in Cursor" });
519
+ }
520
+ if (exists("subl", platform)) {
521
+ editors.push({ id: "sublime", label: "Sublime", description: "Open in Sublime Text" });
522
+ }
523
+ if (exists("zed", platform)) {
524
+ editors.push({ id: "zed", label: "Zed", description: "Open in Zed" });
525
+ }
526
+
527
+ if (platform === "darwin") {
528
+ editors.push({ id: "textedit", label: "TextEdit", description: "Open in TextEdit" });
529
+ } else if (platform === "win32") {
530
+ editors.push({ id: "notepad", label: "Notepad", description: "Open in Notepad" });
531
+ } else {
532
+ if (exists("gedit", platform)) {
533
+ editors.push({ id: "gedit", label: "Gedit", description: "Open in Gedit" });
534
+ }
535
+ if (exists("kate", platform)) {
536
+ editors.push({ id: "kate", label: "Kate", description: "Open in Kate" });
537
+ }
538
+ }
539
+
540
+ return editors;
541
+ }
542
+
543
+ function spawnDetached(command, args) {
544
+ const child = spawn(command, args, {
545
+ detached: true,
546
+ stdio: "ignore"
547
+ });
548
+ child.unref();
549
+ }
550
+
551
+ async function ensureConfigFileExists(configPath) {
552
+ try {
553
+ await fs.access(configPath);
554
+ } catch (error) {
555
+ if (!(error && typeof error === "object" && error.code === "ENOENT")) {
556
+ throw error;
557
+ }
558
+ await writeConfigFile(buildDefaultConfigObject(), configPath, { migrateToVersion: CONFIG_VERSION });
559
+ }
560
+ }
561
+
562
+ async function ensureJsonObjectFileExists(filePath, initialValue = {}) {
563
+ try {
564
+ await fs.access(filePath);
565
+ } catch (error) {
566
+ if (!(error && typeof error === "object" && error.code === "ENOENT")) {
567
+ throw error;
568
+ }
569
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
570
+ await fs.writeFile(filePath, `${JSON.stringify(initialValue, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
571
+ await fs.chmod(filePath, 0o600);
572
+ }
573
+ }
574
+
575
+ export async function openFileInEditor(editorId, filePath, platform = process.platform) {
576
+ const targetPath = path.resolve(filePath);
577
+
578
+ if (platform === "darwin") {
579
+ if (editorId === "vscode") return spawnDetached("code", [targetPath]);
580
+ if (editorId === "cursor") return spawnDetached("cursor", [targetPath]);
581
+ if (editorId === "sublime") return spawnDetached("subl", [targetPath]);
582
+ if (editorId === "zed") return spawnDetached("zed", [targetPath]);
583
+ if (editorId === "textedit") return spawnDetached("open", ["-a", "TextEdit", targetPath]);
584
+ return spawnDetached("open", [targetPath]);
585
+ }
586
+
587
+ if (platform === "win32") {
588
+ if (editorId === "vscode") return spawnDetached("code", [targetPath]);
589
+ if (editorId === "cursor") return spawnDetached("cursor", [targetPath]);
590
+ if (editorId === "sublime") return spawnDetached("subl", [targetPath]);
591
+ if (editorId === "zed") return spawnDetached("zed", [targetPath]);
592
+ if (editorId === "notepad") return spawnDetached("notepad", [targetPath]);
593
+ return spawnDetached("cmd", ["/c", "start", "", targetPath]);
594
+ }
595
+
596
+ if (editorId === "vscode") return spawnDetached("code", [targetPath]);
597
+ if (editorId === "cursor") return spawnDetached("cursor", [targetPath]);
598
+ if (editorId === "sublime") return spawnDetached("subl", [targetPath]);
599
+ if (editorId === "zed") return spawnDetached("zed", [targetPath]);
600
+ if (editorId === "gedit") return spawnDetached("gedit", [targetPath]);
601
+ if (editorId === "kate") return spawnDetached("kate", [targetPath]);
602
+ return spawnDetached("xdg-open", [targetPath]);
603
+ }
604
+
605
+ export async function openConfigInEditor(editorId, configPath, platform = process.platform) {
606
+ await ensureConfigFileExists(configPath);
607
+ return openFileInEditor(editorId, configPath, platform);
608
+ }
609
+
610
+ function createValidationMessages(parseError, validationErrors = []) {
611
+ if (parseError) {
612
+ return [{ kind: "error", message: `JSON parse error: ${parseError}` }];
613
+ }
614
+ return validationErrors.map((message) => ({ kind: "error", message }));
615
+ }
616
+
617
+ function createConfigSummary({ configPath, exists, rawText, parseError, normalizedConfig }) {
618
+ const providerCount = Array.isArray(normalizedConfig?.providers) ? normalizedConfig.providers.length : 0;
619
+ const aliasCount = normalizedConfig?.modelAliases && typeof normalizedConfig.modelAliases === "object"
620
+ ? Object.keys(normalizedConfig.modelAliases).length
621
+ : 0;
622
+ const rateLimitBucketCount = normalizedConfig ? countRateLimitBuckets(normalizedConfig) : 0;
623
+ const validationErrors = normalizedConfig ? validateRuntimeConfig(normalizedConfig) : [];
624
+ const validationMessages = createValidationMessages(parseError, validationErrors);
625
+
626
+ return {
627
+ path: configPath,
628
+ exists,
629
+ rawText,
630
+ parseError,
631
+ providerCount,
632
+ aliasCount,
633
+ rateLimitBucketCount,
634
+ hasMasterKey: Boolean(normalizedConfig?.masterKey),
635
+ validationErrors,
636
+ validationMessages,
637
+ validationSummary: parseError
638
+ ? "Config file contains invalid JSON."
639
+ : validationErrors.length > 0
640
+ ? `${validationErrors.length} validation issue(s) need review.`
641
+ : "Config is ready."
642
+ };
643
+ }
644
+
645
+ async function readConfigState(configPath) {
646
+ let exists = true;
647
+ let rawText = "";
648
+ try {
649
+ rawText = await fs.readFile(configPath, "utf8");
650
+ } catch (error) {
651
+ if (error && typeof error === "object" && error.code === "ENOENT") {
652
+ exists = false;
653
+ rawText = buildDefaultConfigRawText();
654
+ } else {
655
+ throw error;
656
+ }
657
+ }
658
+
659
+ if (!rawText.trim()) rawText = buildDefaultConfigRawText();
660
+
661
+ let parseError = "";
662
+ let normalizedConfig = null;
663
+ try {
664
+ const parsed = rawText.trim() ? JSON.parse(rawText) : {};
665
+ normalizedConfig = normalizeRuntimeConfig(parsed, { migrateToVersion: CONFIG_VERSION });
666
+ } catch (error) {
667
+ parseError = error instanceof Error ? error.message : String(error);
668
+ }
669
+
670
+ const summary = createConfigSummary({
671
+ configPath,
672
+ exists,
673
+ rawText,
674
+ parseError,
675
+ normalizedConfig
676
+ });
677
+
678
+ return {
679
+ rawText,
680
+ normalizedConfig,
681
+ parseError,
682
+ summary
683
+ };
684
+ }
685
+
686
+ async function readJsonBody(req) {
687
+ const chunks = [];
688
+ let size = 0;
689
+ for await (const chunk of req) {
690
+ size += chunk.length;
691
+ if (size > JSON_BODY_LIMIT_BYTES) {
692
+ const error = new Error("Request body is too large.");
693
+ error.statusCode = 413;
694
+ throw error;
695
+ }
696
+ chunks.push(chunk);
697
+ }
698
+
699
+ const raw = Buffer.concat(chunks).toString("utf8");
700
+ if (!raw.trim()) return {};
701
+ try {
702
+ return JSON.parse(raw);
703
+ } catch (error) {
704
+ const wrapped = new Error(error instanceof Error ? error.message : String(error));
705
+ wrapped.statusCode = 400;
706
+ throw wrapped;
707
+ }
708
+ }
709
+
710
+ function sendJson(res, statusCode, payload) {
711
+ res.statusCode = statusCode;
712
+ res.setHeader("content-type", "application/json; charset=utf-8");
713
+ res.setHeader("cache-control", "no-store");
714
+ res.end(`${JSON.stringify(payload)}\n`);
715
+ }
716
+
717
+ function sendText(res, statusCode, contentType, body) {
718
+ res.statusCode = statusCode;
719
+ res.setHeader("content-type", contentType);
720
+ res.setHeader("cache-control", "no-store");
721
+ res.end(body);
722
+ }
723
+
724
+ function startJsonLineStream(res) {
725
+ res.statusCode = 200;
726
+ res.setHeader("content-type", "application/x-ndjson; charset=utf-8");
727
+ res.setHeader("cache-control", "no-store");
728
+ res.setHeader("x-accel-buffering", "no");
729
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
730
+ }
731
+
732
+ function writeJsonLine(res, payload) {
733
+ res.write(`${JSON.stringify(payload)}\n`);
734
+ }
735
+
736
+ function resolveRouterOptions(current, body) {
737
+ return {
738
+ host: FIXED_LOCAL_ROUTER_HOST,
739
+ port: FIXED_LOCAL_ROUTER_PORT,
740
+ watchConfig: body?.watchConfig === undefined ? current.watchConfig : body.watchConfig === true,
741
+ requireAuth: body?.requireAuth === undefined ? current.requireAuth : body.requireAuth === true,
742
+ watchBinary: body?.watchBinary === undefined ? current.watchBinary : body.watchBinary === true
743
+ };
744
+ }
745
+
746
+ function getRouterStateSettings(routerState) {
747
+ return {
748
+ host: FIXED_LOCAL_ROUTER_HOST,
749
+ port: FIXED_LOCAL_ROUTER_PORT,
750
+ watchConfig: routerState?.watchConfig !== false,
751
+ watchBinary: routerState?.watchBinary !== false,
752
+ requireAuth: routerState?.requireAuth === true
753
+ };
754
+ }
755
+
756
+ function isMissingStartupServiceText(value) {
757
+ const text = String(value || "").toLowerCase();
758
+ return text.includes("could not find service")
759
+ || text.includes("service could not be found")
760
+ || text.includes("does not exist as a service")
761
+ || text.includes("could not be found");
762
+ }
763
+
764
+ function routeSnapshotDocument(configState) {
765
+ return configState.parseError ? null : (configState.normalizedConfig || buildDefaultConfigObject());
766
+ }
767
+
768
+ export async function startWebConsoleServer(options = {}, deps = {}) {
769
+ const {
770
+ host = "127.0.0.1",
771
+ port = 8788,
772
+ configPath = getDefaultConfigPath(),
773
+ routerHost = FIXED_LOCAL_ROUTER_HOST,
774
+ routerPort = FIXED_LOCAL_ROUTER_PORT,
775
+ routerWatchConfig = true,
776
+ routerRequireAuth = false,
777
+ routerWatchBinary = true,
778
+ allowRemoteClients = false,
779
+ cliPathForRouter = "",
780
+ devMode = false
781
+ } = options;
782
+
783
+ const installStartupFn = typeof deps.installStartup === "function" ? deps.installStartup : installStartup;
784
+ const uninstallStartupFn = typeof deps.uninstallStartup === "function" ? deps.uninstallStartup : uninstallStartup;
785
+ const startupStatusFn = typeof deps.startupStatus === "function" ? deps.startupStatus : startupStatus;
786
+ const stopStartupFn = typeof deps.stopStartup === "function" ? deps.stopStartup : stopStartup;
787
+ const stopProcessByPidFn = typeof deps.stopProcessByPid === "function" ? deps.stopProcessByPid : stopProcessByPid;
788
+ const openConfigInEditorFn = typeof deps.openConfigInEditor === "function" ? deps.openConfigInEditor : openConfigInEditor;
789
+ const openFileInEditorFn = typeof deps.openFileInEditor === "function" ? deps.openFileInEditor : openFileInEditor;
790
+ const detectAvailableEditorsFn = typeof deps.detectAvailableEditors === "function" ? deps.detectAvailableEditors : detectAvailableEditors;
791
+ const getActiveRuntimeStateFn = typeof deps.getActiveRuntimeState === "function" ? deps.getActiveRuntimeState : getActiveRuntimeState;
792
+ const clearRuntimeStateFn = typeof deps.clearRuntimeState === "function" ? deps.clearRuntimeState : clearRuntimeState;
793
+ const startDetachedRouterServiceFn = typeof deps.startDetachedRouterService === "function" ? deps.startDetachedRouterService : startDetachedRouterService;
794
+ const listListeningPidsFn = typeof deps.listListeningPids === "function"
795
+ ? deps.listListeningPids
796
+ : (targetPort) => listListeningPids(targetPort, deps);
797
+ const reclaimPortFn = typeof deps.reclaimPort === "function"
798
+ ? deps.reclaimPort
799
+ : (args) => reclaimPort(args, deps);
800
+ const waitForRuntimeMatchFn = typeof deps.waitForRuntimeMatch === "function"
801
+ ? deps.waitForRuntimeMatch
802
+ : (startOptions, waitOptions = {}) => waitForRuntimeMatch(startOptions, waitOptions);
803
+ const loginSubscriptionFn = typeof deps.loginSubscription === "function" ? deps.loginSubscription : loginSubscription;
804
+ const ampClientEnv = deps.ampClientEnv && typeof deps.ampClientEnv === "object" ? deps.ampClientEnv : process.env;
805
+ const ampClientCwd = typeof deps.ampClientCwd === "string" && deps.ampClientCwd.trim() ? deps.ampClientCwd : process.cwd();
806
+ const codexCliEnv = deps.codexCliEnv && typeof deps.codexCliEnv === "object" ? deps.codexCliEnv : process.env;
807
+ const claudeCodeEnv = deps.claudeCodeEnv && typeof deps.claudeCodeEnv === "object" ? deps.claudeCodeEnv : process.env;
808
+ const resolvedRouterCliPath = String(cliPathForRouter || process.env.LLM_ROUTER_CLI_PATH || process.argv[1] || "").trim();
809
+
810
+ async function resolvePreferredAmpSettingsTarget() {
811
+ const envOverride = String(ampClientEnv?.AMP_SETTINGS_FILE || "").trim();
812
+ if (envOverride) {
813
+ return {
814
+ scope: "global",
815
+ settingsFilePath: path.resolve(envOverride)
816
+ };
817
+ }
818
+
819
+ const workspaceSettingsPath = resolveAmpClientSettingsFilePath({
820
+ scope: "workspace",
821
+ cwd: ampClientCwd,
822
+ env: ampClientEnv
823
+ });
824
+ try {
825
+ await fs.access(workspaceSettingsPath);
826
+ return {
827
+ scope: "workspace",
828
+ settingsFilePath: workspaceSettingsPath
829
+ };
830
+ } catch (error) {
831
+ if (!(error && typeof error === "object" && error.code === "ENOENT")) {
832
+ throw error;
833
+ }
834
+ }
835
+
836
+ return {
837
+ scope: "global",
838
+ settingsFilePath: resolveAmpClientSettingsFilePath({
839
+ scope: "global",
840
+ cwd: ampClientCwd,
841
+ env: ampClientEnv
842
+ })
843
+ };
844
+ }
845
+
846
+ async function readAmpGlobalRoutingState(settings = {}) {
847
+ const endpointUrl = buildAmpClientEndpointUrl(settings);
848
+ try {
849
+ const settingsTarget = await resolvePreferredAmpSettingsTarget();
850
+ const secretsFilePath = resolveAmpClientSecretsFilePath({
851
+ env: ampClientEnv
852
+ });
853
+ const state = await readAmpClientRoutingState({
854
+ scope: settingsTarget.scope,
855
+ settingsFilePath: settingsTarget.settingsFilePath,
856
+ endpointUrl,
857
+ cwd: ampClientCwd,
858
+ env: ampClientEnv
859
+ });
860
+ return {
861
+ ...state,
862
+ secretsFilePath,
863
+ endpointUrl,
864
+ error: ""
865
+ };
866
+ } catch (error) {
867
+ return {
868
+ scope: "global",
869
+ settingsFilePath: "",
870
+ secretsFilePath: resolveAmpClientSecretsFilePath({
871
+ env: ampClientEnv
872
+ }),
873
+ configuredUrl: "",
874
+ routedViaRouter: false,
875
+ settingsExists: false,
876
+ endpointUrl,
877
+ error: error instanceof Error ? error.message : String(error)
878
+ };
879
+ }
880
+ }
881
+
882
+ function pickDefaultManagedRoute(config = {}) {
883
+ const configuredDefault = String(config?.defaultModel || "").trim();
884
+ if (configuredDefault) return configuredDefault;
885
+
886
+ const aliases = config?.modelAliases && typeof config.modelAliases === "object" && !Array.isArray(config.modelAliases)
887
+ ? Object.keys(config.modelAliases).map((aliasId) => String(aliasId || "").trim()).filter(Boolean)
888
+ : [];
889
+ if (aliases.length > 0) return aliases[0];
890
+
891
+ for (const provider of Array.isArray(config?.providers) ? config.providers : []) {
892
+ if (provider?.enabled === false) continue;
893
+ const providerId = String(provider?.id || "").trim();
894
+ if (!providerId) continue;
895
+ const model = Array.isArray(provider?.models)
896
+ ? provider.models.find((entry) => String(entry?.id || "").trim())
897
+ : null;
898
+ if (model?.id) return `${providerId}/${model.id}`;
899
+ }
900
+
901
+ return "";
902
+ }
903
+
904
+ function normalizeCodexBindingsInput(bindings = {}, config = {}) {
905
+ const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
906
+ return {
907
+ defaultModel: String(source.defaultModel || pickDefaultManagedRoute(config) || "").trim()
908
+ };
909
+ }
910
+
911
+ function normalizeCodexBindingState(bindings = {}) {
912
+ const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
913
+ return {
914
+ defaultModel: String(source.defaultModel || "").trim()
915
+ };
916
+ }
917
+
918
+ function describeManagedAlias(aliasId, alias = null) {
919
+ const primaryTargets = dedupeTrimmedStrings(Array.isArray(alias?.targets) ? alias.targets.map((target) => target?.ref) : []);
920
+ const fallbackTargets = dedupeTrimmedStrings(Array.isArray(alias?.fallbackTargets) ? alias.fallbackTargets.map((target) => target?.ref) : []);
921
+
922
+ if (primaryTargets.length === 0 && fallbackTargets.length === 0) {
923
+ return `LLM Router alias '${aliasId}'.`;
924
+ }
925
+
926
+ const parts = [];
927
+ if (primaryTargets.length > 0) {
928
+ parts.push(`Routes to ${primaryTargets.join(", ")}`);
929
+ }
930
+ if (fallbackTargets.length > 0) {
931
+ parts.push(`fallbacks ${fallbackTargets.join(", ")}`);
932
+ }
933
+ return `${parts.join("; ")}.`;
934
+ }
935
+
936
+ function describeManagedDirectRoute(routeRef, config = {}) {
937
+ const normalizedRef = String(routeRef || "").trim();
938
+ if (!normalizedRef || !normalizedRef.includes("/")) {
939
+ return normalizedRef ? `LLM Router route '${normalizedRef}'.` : "LLM Router route.";
940
+ }
941
+
942
+ const slashIndex = normalizedRef.indexOf("/");
943
+ const providerId = normalizedRef.slice(0, slashIndex).trim();
944
+ const modelId = normalizedRef.slice(slashIndex + 1).trim();
945
+ const provider = Array.isArray(config?.providers)
946
+ ? config.providers.find((entry) => String(entry?.id || "").trim() === providerId)
947
+ : null;
948
+ const providerName = String(provider?.name || providerId).trim() || providerId;
949
+ const model = Array.isArray(provider?.models)
950
+ ? provider.models.find((entry) => String(entry?.id || "").trim() === modelId)
951
+ : null;
952
+ const fallbackModels = dedupeTrimmedStrings(Array.isArray(model?.fallbackModels) ? model.fallbackModels : []);
953
+
954
+ if (fallbackModels.length > 0) {
955
+ return `LLM Router route to ${providerName} model '${modelId}' with fallbacks ${fallbackModels.join(", ")}.`;
956
+ }
957
+ return `LLM Router route to ${providerName} model '${modelId}'.`;
958
+ }
959
+
960
+ function listManagedDirectRouteRefs(config = {}) {
961
+ const routeRefs = new Set();
962
+
963
+ for (const provider of Array.isArray(config?.providers) ? config.providers : []) {
964
+ if (provider?.enabled === false) continue;
965
+ const providerId = String(provider?.id || "").trim();
966
+ if (!providerId) continue;
967
+
968
+ for (const model of Array.isArray(provider?.models) ? provider.models : []) {
969
+ if (model?.enabled === false) continue;
970
+ const modelId = String(model?.id || "").trim();
971
+ if (!modelId) continue;
972
+ routeRefs.add(`${providerId}/${modelId}`);
973
+ }
974
+ }
975
+
976
+ return [...routeRefs];
977
+ }
978
+
979
+ function createCodexCliModelCatalogEntry(slug, description) {
980
+ return {
981
+ slug,
982
+ display_name: slug,
983
+ description,
984
+ default_reasoning_level: "medium",
985
+ supported_reasoning_levels: [
986
+ { effort: "low", description: "Fast responses with lighter reasoning" },
987
+ { effort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
988
+ { effort: "high", description: "Greater reasoning depth for complex problems" },
989
+ { effort: "xhigh", description: "Extra high reasoning depth for complex problems" }
990
+ ],
991
+ shell_type: "shell_command",
992
+ visibility: "list",
993
+ supported_in_api: true,
994
+ priority: 0,
995
+ upgrade: null,
996
+ base_instructions: "You are Codex, a coding agent based on GPT-5.",
997
+ supports_reasoning_summaries: true,
998
+ default_reasoning_summary: "auto",
999
+ support_verbosity: true,
1000
+ default_verbosity: "low",
1001
+ apply_patch_tool_type: "freeform",
1002
+ truncation_policy: {
1003
+ mode: "tokens",
1004
+ limit: 10000
1005
+ },
1006
+ supports_parallel_tool_calls: true,
1007
+ context_window: 272000,
1008
+ effective_context_window_percent: 95,
1009
+ experimental_supported_tools: [],
1010
+ input_modalities: ["text", "image"],
1011
+ prefer_websockets: false
1012
+ };
1013
+ }
1014
+
1015
+ function buildCodexCliModelCatalog(config = {}, bindings = {}) {
1016
+ const aliases = config?.modelAliases && typeof config.modelAliases === "object" && !Array.isArray(config.modelAliases)
1017
+ ? config.modelAliases
1018
+ : {};
1019
+ const boundModel = String(bindings?.defaultModel || "").trim();
1020
+ const catalogEntries = new Map();
1021
+ const aliasIds = new Set(
1022
+ Object.keys(aliases)
1023
+ .map((aliasId) => String(aliasId || "").trim())
1024
+ .filter((aliasId) => aliasId && aliasId !== DEFAULT_MODEL_ALIAS_ID)
1025
+ );
1026
+ const directRouteRefs = new Set(listManagedDirectRouteRefs(config));
1027
+
1028
+ if (boundModel) {
1029
+ if (boundModel.includes("/")) directRouteRefs.add(boundModel);
1030
+ else aliasIds.add(boundModel);
1031
+ }
1032
+
1033
+ for (const aliasId of aliasIds) {
1034
+ catalogEntries.set(aliasId, createCodexCliModelCatalogEntry(
1035
+ aliasId,
1036
+ describeManagedAlias(aliasId, aliases[aliasId])
1037
+ ));
1038
+ }
1039
+ for (const routeRef of directRouteRefs) {
1040
+ catalogEntries.set(routeRef, createCodexCliModelCatalogEntry(
1041
+ routeRef,
1042
+ describeManagedDirectRoute(routeRef, config)
1043
+ ));
1044
+ }
1045
+
1046
+ const models = [...catalogEntries.values()]
1047
+ .sort((left, right) => String(left.slug).localeCompare(String(right.slug)));
1048
+
1049
+ return models.length > 0 ? { models } : undefined;
1050
+ }
1051
+
1052
+ function normalizeClaudeBindingsInput(bindings = {}) {
1053
+ const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
1054
+ return {
1055
+ primaryModel: String(source.primaryModel || "").trim(),
1056
+ defaultOpusModel: String(source.defaultOpusModel || "").trim(),
1057
+ defaultSonnetModel: String(source.defaultSonnetModel || "").trim(),
1058
+ defaultHaikuModel: String(source.defaultHaikuModel || "").trim(),
1059
+ subagentModel: String(source.subagentModel || "").trim()
1060
+ };
1061
+ }
1062
+
1063
+ function normalizeClaudeBindingState(bindings = {}) {
1064
+ return normalizeClaudeBindingsInput(bindings);
1065
+ }
1066
+
1067
+ function areCodexBindingsEqual(left = {}, right = {}) {
1068
+ return String(left?.defaultModel || "").trim() === String(right?.defaultModel || "").trim();
1069
+ }
1070
+
1071
+ function areClaudeBindingsEqual(left = {}, right = {}) {
1072
+ return (
1073
+ String(left?.primaryModel || "").trim() === String(right?.primaryModel || "").trim()
1074
+ && String(left?.defaultOpusModel || "").trim() === String(right?.defaultOpusModel || "").trim()
1075
+ && String(left?.defaultSonnetModel || "").trim() === String(right?.defaultSonnetModel || "").trim()
1076
+ && String(left?.defaultHaikuModel || "").trim() === String(right?.defaultHaikuModel || "").trim()
1077
+ && String(left?.subagentModel || "").trim() === String(right?.subagentModel || "").trim()
1078
+ );
1079
+ }
1080
+
1081
+ function reconcileManagedRouteBinding(ref, rewriteContext) {
1082
+ return rewriteManagedRouteRef(ref, rewriteContext);
1083
+ }
1084
+
1085
+ function reconcileCodexBindingsForConfig(bindings = {}, previousConfig = {}, nextConfig = {}) {
1086
+ const currentBindings = normalizeCodexBindingState(bindings);
1087
+ const rewriteContext = buildManagedRouteRewriteContext(previousConfig, nextConfig);
1088
+ const nextDefaultModel = reconcileManagedRouteBinding(currentBindings.defaultModel, rewriteContext);
1089
+ return {
1090
+ defaultModel: nextDefaultModel || pickDefaultManagedRoute(nextConfig)
1091
+ };
1092
+ }
1093
+
1094
+ function reconcileClaudeBindingsForConfig(bindings = {}, previousConfig = {}, nextConfig = {}) {
1095
+ const currentBindings = normalizeClaudeBindingState(bindings);
1096
+ const rewriteContext = buildManagedRouteRewriteContext(previousConfig, nextConfig);
1097
+ return {
1098
+ primaryModel: reconcileManagedRouteBinding(currentBindings.primaryModel, rewriteContext),
1099
+ defaultOpusModel: reconcileManagedRouteBinding(currentBindings.defaultOpusModel, rewriteContext),
1100
+ defaultSonnetModel: reconcileManagedRouteBinding(currentBindings.defaultSonnetModel, rewriteContext),
1101
+ defaultHaikuModel: reconcileManagedRouteBinding(currentBindings.defaultHaikuModel, rewriteContext),
1102
+ subagentModel: reconcileManagedRouteBinding(currentBindings.subagentModel, rewriteContext)
1103
+ };
1104
+ }
1105
+
1106
+ async function readCodexCliGlobalRoutingState(settings = {}, config = null) {
1107
+ const endpointUrl = buildAmpClientEndpointUrl(settings);
1108
+ const apiKey = String(config?.masterKey || "").trim();
1109
+ try {
1110
+ const state = await readCodexCliRoutingState({
1111
+ endpointUrl,
1112
+ apiKey,
1113
+ env: codexCliEnv
1114
+ });
1115
+ return {
1116
+ ...state,
1117
+ endpointUrl,
1118
+ error: ""
1119
+ };
1120
+ } catch (error) {
1121
+ return {
1122
+ tool: "codex-cli",
1123
+ configFilePath: resolveCodexCliConfigFilePath({ env: codexCliEnv }),
1124
+ backupFilePath: "",
1125
+ configExists: false,
1126
+ backupExists: false,
1127
+ routedViaRouter: false,
1128
+ configuredBaseUrl: "",
1129
+ bindings: {
1130
+ defaultModel: ""
1131
+ },
1132
+ endpointUrl,
1133
+ error: error instanceof Error ? error.message : String(error)
1134
+ };
1135
+ }
1136
+ }
1137
+
1138
+ async function readClaudeCodeGlobalRoutingState(settings = {}, config = null) {
1139
+ const endpointUrl = buildAmpClientEndpointUrl(settings);
1140
+ const apiKey = String(config?.masterKey || "").trim();
1141
+ try {
1142
+ const state = await readClaudeCodeRoutingState({
1143
+ endpointUrl,
1144
+ apiKey,
1145
+ env: claudeCodeEnv
1146
+ });
1147
+ return {
1148
+ ...state,
1149
+ endpointUrl,
1150
+ error: ""
1151
+ };
1152
+ } catch (error) {
1153
+ return {
1154
+ tool: "claude-code",
1155
+ settingsFilePath: resolveClaudeCodeSettingsFilePath({ env: claudeCodeEnv }),
1156
+ backupFilePath: "",
1157
+ settingsExists: false,
1158
+ backupExists: false,
1159
+ routedViaRouter: false,
1160
+ configuredBaseUrl: "",
1161
+ bindings: {
1162
+ primaryModel: "",
1163
+ defaultOpusModel: "",
1164
+ defaultSonnetModel: "",
1165
+ defaultHaikuModel: "",
1166
+ subagentModel: ""
1167
+ },
1168
+ endpointUrl,
1169
+ error: error instanceof Error ? error.message : String(error)
1170
+ };
1171
+ }
1172
+ }
1173
+
1174
+ async function syncAmpGlobalRoutingIfNeeded({
1175
+ previousConfig = null,
1176
+ nextConfig = null,
1177
+ previousSettings = {},
1178
+ nextSettings = {}
1179
+ } = {}) {
1180
+ const previousEndpointUrl = buildAmpClientEndpointUrl(previousSettings);
1181
+ const nextEndpointUrl = buildAmpClientEndpointUrl(nextSettings);
1182
+ const previousMasterKey = String(previousConfig?.masterKey || previousConfig?.normalizedConfig?.masterKey || "").trim();
1183
+ const nextMasterKey = String(nextConfig?.masterKey || "").trim();
1184
+
1185
+ if (!previousEndpointUrl || !nextEndpointUrl) return false;
1186
+ if (previousEndpointUrl === nextEndpointUrl && previousMasterKey === nextMasterKey) return false;
1187
+
1188
+ const routingState = await readAmpGlobalRoutingState(previousSettings);
1189
+ if (routingState.error) {
1190
+ addLog("warn", "AMP global route check failed.", routingState.error);
1191
+ return false;
1192
+ }
1193
+ if (!routingState.routedViaRouter) return false;
1194
+
1195
+ const settingsTarget = await resolvePreferredAmpSettingsTarget();
1196
+ const patchPlan = buildAmpClientPatchPlan({
1197
+ scope: settingsTarget.scope,
1198
+ settingsFilePath: settingsTarget.settingsFilePath,
1199
+ endpointUrl: nextEndpointUrl,
1200
+ apiKey: nextMasterKey,
1201
+ cwd: ampClientCwd,
1202
+ env: ampClientEnv
1203
+ });
1204
+ if (!patchPlan) return false;
1205
+
1206
+ try {
1207
+ await patchAmpClientConfigFiles(patchPlan);
1208
+ addLog("info", "Updated AMP global route to match the local router.", nextEndpointUrl);
1209
+ return true;
1210
+ } catch (error) {
1211
+ addLog("warn", "AMP global route update failed.", error instanceof Error ? error.message : String(error));
1212
+ return false;
1213
+ }
1214
+ }
1215
+
1216
+ async function syncCodexCliRoutingIfNeeded({
1217
+ previousConfig = null,
1218
+ nextConfig = null,
1219
+ previousSettings = {},
1220
+ nextSettings = {}
1221
+ } = {}) {
1222
+ const previousEndpointUrl = buildAmpClientEndpointUrl(previousSettings);
1223
+ const nextEndpointUrl = buildAmpClientEndpointUrl(nextSettings);
1224
+ const previousMasterKey = String(previousConfig?.masterKey || "").trim();
1225
+ const nextMasterKey = String(nextConfig?.masterKey || "").trim();
1226
+ const endpointOrKeyChanged = Boolean(
1227
+ previousEndpointUrl
1228
+ && nextEndpointUrl
1229
+ && (previousEndpointUrl !== nextEndpointUrl || previousMasterKey !== nextMasterKey)
1230
+ );
1231
+
1232
+ const routingState = await readCodexCliGlobalRoutingState(previousSettings, previousConfig);
1233
+ if (routingState.error) {
1234
+ addLog("warn", "Codex CLI route check failed.", routingState.error);
1235
+ return false;
1236
+ }
1237
+ if (!routingState.routedViaRouter) return false;
1238
+
1239
+ try {
1240
+ const currentBindings = normalizeCodexBindingState(routingState.bindings);
1241
+ const bindings = reconcileCodexBindingsForConfig(currentBindings, previousConfig, nextConfig);
1242
+ const bindingsChanged = !areCodexBindingsEqual(currentBindings, bindings);
1243
+ const previousCatalog = buildCodexCliModelCatalog(previousConfig, currentBindings) || {};
1244
+ const nextCatalog = buildCodexCliModelCatalog(nextConfig, bindings) || {};
1245
+ const catalogChanged = createJsonSignature(previousCatalog) !== createJsonSignature(nextCatalog);
1246
+ if (!endpointOrKeyChanged && !bindingsChanged && !catalogChanged) return false;
1247
+
1248
+ await patchCodexCliConfigFile({
1249
+ endpointUrl: nextEndpointUrl,
1250
+ apiKey: nextMasterKey,
1251
+ bindings,
1252
+ modelCatalog: Object.keys(nextCatalog).length > 0 ? nextCatalog : undefined,
1253
+ captureBackup: false,
1254
+ env: codexCliEnv
1255
+ });
1256
+ if (endpointOrKeyChanged) {
1257
+ addLog("info", "Updated Codex CLI route to match the local router.", buildCodexCliEndpointUrl(nextSettings));
1258
+ } else {
1259
+ addLog("info", "Updated Codex CLI bindings to match the saved router config.", bindings.defaultModel || "No model selected");
1260
+ }
1261
+ return true;
1262
+ } catch (error) {
1263
+ addLog("warn", "Codex CLI route update failed.", error instanceof Error ? error.message : String(error));
1264
+ return false;
1265
+ }
1266
+ }
1267
+
1268
+ async function syncClaudeCodeRoutingIfNeeded({
1269
+ previousConfig = null,
1270
+ nextConfig = null,
1271
+ previousSettings = {},
1272
+ nextSettings = {}
1273
+ } = {}) {
1274
+ const previousEndpointUrl = buildAmpClientEndpointUrl(previousSettings);
1275
+ const nextEndpointUrl = buildAmpClientEndpointUrl(nextSettings);
1276
+ const previousMasterKey = String(previousConfig?.masterKey || "").trim();
1277
+ const nextMasterKey = String(nextConfig?.masterKey || "").trim();
1278
+ const endpointOrKeyChanged = Boolean(
1279
+ previousEndpointUrl
1280
+ && nextEndpointUrl
1281
+ && (previousEndpointUrl !== nextEndpointUrl || previousMasterKey !== nextMasterKey)
1282
+ );
1283
+
1284
+ const routingState = await readClaudeCodeGlobalRoutingState(previousSettings, previousConfig);
1285
+ if (routingState.error) {
1286
+ addLog("warn", "Claude Code route check failed.", routingState.error);
1287
+ return false;
1288
+ }
1289
+ if (!routingState.routedViaRouter) return false;
1290
+
1291
+ try {
1292
+ const currentBindings = normalizeClaudeBindingState(routingState.bindings);
1293
+ const bindings = reconcileClaudeBindingsForConfig(currentBindings, previousConfig, nextConfig);
1294
+ const bindingsChanged = !areClaudeBindingsEqual(currentBindings, bindings);
1295
+ if (!endpointOrKeyChanged && !bindingsChanged) return false;
1296
+
1297
+ await patchClaudeCodeSettingsFile({
1298
+ endpointUrl: nextEndpointUrl,
1299
+ apiKey: nextMasterKey,
1300
+ bindings,
1301
+ captureBackup: false,
1302
+ env: claudeCodeEnv
1303
+ });
1304
+ if (endpointOrKeyChanged) {
1305
+ addLog("info", "Updated Claude Code route to match the local router.", buildClaudeCodeEndpointUrl(nextSettings));
1306
+ } else {
1307
+ addLog("info", "Updated Claude Code bindings to match the saved router config.", bindings.primaryModel || "Default");
1308
+ }
1309
+ return true;
1310
+ } catch (error) {
1311
+ addLog("warn", "Claude Code route update failed.", error instanceof Error ? error.message : String(error));
1312
+ return false;
1313
+ }
1314
+ }
1315
+
1316
+ async function resolveProbeApiKey(apiKeyEnv, apiKey, { context = "testing config" } = {}) {
1317
+ const resolvedApiKeyEnv = String(apiKeyEnv || "").trim();
1318
+ const resolvedApiKey = String(apiKey || "").trim();
1319
+ if (!resolvedApiKeyEnv && !resolvedApiKey) {
1320
+ const error = new Error(`API key or env is required before ${context}.`);
1321
+ error.statusCode = 400;
1322
+ throw error;
1323
+ }
1324
+
1325
+ const finalApiKey = resolvedApiKey || String(process.env[resolvedApiKeyEnv] || "").trim();
1326
+ if (!finalApiKey) {
1327
+ const error = new Error(`Environment variable '${resolvedApiKeyEnv}' is not set.`);
1328
+ error.statusCode = 400;
1329
+ throw error;
1330
+ }
1331
+
1332
+ return finalApiKey;
1333
+ }
1334
+
1335
+ const discoverProviderModelsFn = typeof deps.discoverProviderModels === "function" ? deps.discoverProviderModels : async ({ endpoints, apiKeyEnv, apiKey, headers }) => {
1336
+ const finalApiKey = await resolveProbeApiKey(apiKeyEnv, apiKey, { context: "discovering models" });
1337
+ const endpointList = dedupeTrimmedStrings(endpoints);
1338
+ const workingFormats = new Set();
1339
+ const discoveredModels = [];
1340
+ const discoveredModelSet = new Set();
1341
+ const baseUrlByFormat = {};
1342
+ const authByFormat = {};
1343
+ let preferredFormat = "";
1344
+
1345
+ for (const endpoint of endpointList) {
1346
+ const result = await probeProvider({
1347
+ baseUrl: endpoint,
1348
+ apiKey: finalApiKey,
1349
+ headers,
1350
+ timeoutMs: 8000
1351
+ });
1352
+
1353
+ for (const format of (result.workingFormats || [])) {
1354
+ workingFormats.add(format);
1355
+ }
1356
+ for (const [format, baseUrl] of Object.entries(result.baseUrlByFormat || {})) {
1357
+ if (!baseUrlByFormat[format]) baseUrlByFormat[format] = baseUrl;
1358
+ }
1359
+ for (const [format, auth] of Object.entries(result.authByFormat || {})) {
1360
+ if (!authByFormat[format]) authByFormat[format] = auth;
1361
+ }
1362
+ if (!preferredFormat && result.preferredFormat) {
1363
+ preferredFormat = result.preferredFormat;
1364
+ }
1365
+ for (const modelId of (result.models || [])) {
1366
+ if (discoveredModelSet.has(modelId)) continue;
1367
+ discoveredModelSet.add(modelId);
1368
+ discoveredModels.push(modelId);
1369
+ }
1370
+ }
1371
+
1372
+ const warnings = [];
1373
+ if (discoveredModels.length === 0) {
1374
+ warnings.push("Model list API did not return any models. Add model ids manually if needed.");
1375
+ }
1376
+ if (workingFormats.size === 0) {
1377
+ warnings.push("No working endpoint format detected yet. Run Test config after choosing models.");
1378
+ }
1379
+
1380
+ const resolvedWorkingFormats = [...workingFormats];
1381
+ const resolvedPreferredFormat = preferredFormat || resolvedWorkingFormats[0] || "";
1382
+
1383
+ return {
1384
+ ok: discoveredModels.length > 0,
1385
+ endpoints: endpointList,
1386
+ models: discoveredModels,
1387
+ workingFormats: resolvedWorkingFormats,
1388
+ preferredFormat: resolvedPreferredFormat || null,
1389
+ baseUrlByFormat,
1390
+ authByFormat,
1391
+ warnings
1392
+ };
1393
+ };
1394
+
1395
+ const testProviderConfigFn = typeof deps.testProviderConfig === "function" ? deps.testProviderConfig : async ({ endpoints, models, apiKeyEnv, apiKey, headers, onProgress }) => {
1396
+ const finalApiKey = await resolveProbeApiKey(apiKeyEnv, apiKey, { context: "testing config" });
1397
+
1398
+ return probeProviderEndpointMatrix({
1399
+ endpoints,
1400
+ models,
1401
+ apiKey: finalApiKey,
1402
+ headers,
1403
+ requestsPerMinute: 30,
1404
+ onProgress
1405
+ });
1406
+ };
1407
+
1408
+ const eventClients = new Set();
1409
+ const devEventClients = new Set();
1410
+ const logs = [];
1411
+ let webServer = null;
1412
+ let closing = false;
1413
+ let resolveDone;
1414
+ let doneResolved = false;
1415
+ let closePromise = null;
1416
+ let actualWebPort = Number(port);
1417
+ let configWatcher = null;
1418
+ let configWatchTimer = null;
1419
+ let ignoreConfigWatchUntil = 0;
1420
+
1421
+ const routerState = {
1422
+ host: FIXED_LOCAL_ROUTER_HOST,
1423
+ port: FIXED_LOCAL_ROUTER_PORT,
1424
+ watchConfig: routerWatchConfig,
1425
+ watchBinary: routerWatchBinary,
1426
+ requireAuth: routerRequireAuth,
1427
+ lastError: ""
1428
+ };
1429
+
1430
+ const done = new Promise((resolve) => {
1431
+ resolveDone = (value) => {
1432
+ if (doneResolved) return;
1433
+ doneResolved = true;
1434
+ resolve(value);
1435
+ };
1436
+ });
1437
+
1438
+ function pushEvent(name, payload) {
1439
+ const serialized = `event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
1440
+ for (const client of eventClients) {
1441
+ client.write(serialized);
1442
+ }
1443
+ }
1444
+
1445
+ function pushDevReload(payload) {
1446
+ const serialized = `event: reload\ndata: ${JSON.stringify(payload)}\n\n`;
1447
+ for (const client of devEventClients) {
1448
+ client.write(serialized);
1449
+ }
1450
+ }
1451
+
1452
+ function addLog(level, message, detail = "") {
1453
+ const entry = {
1454
+ id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
1455
+ level,
1456
+ message,
1457
+ detail,
1458
+ time: new Date().toISOString()
1459
+ };
1460
+ logs.unshift(entry);
1461
+ logs.splice(MAX_LOG_ENTRIES);
1462
+ pushEvent("log", entry);
1463
+ return entry;
1464
+ }
1465
+
1466
+ const devReloadScript = devMode ? String.raw`<script>
1467
+ (() => {
1468
+ const source = new EventSource("/__dev/events");
1469
+ source.addEventListener("reload", () => {
1470
+ window.location.reload();
1471
+ });
1472
+ source.onerror = () => {
1473
+ source.close();
1474
+ setTimeout(() => window.location.reload(), 500);
1475
+ };
1476
+ })();
1477
+ </script>` : "";
1478
+
1479
+ const devAssets = devMode
1480
+ ? await startWebConsoleDevAssets({
1481
+ onChange: (payload) => pushDevReload(payload),
1482
+ onError: (message) => addLog("warn", "Web console dev asset issue.", message)
1483
+ })
1484
+ : null;
1485
+
1486
+ if (devMode) {
1487
+ addLog("info", "Web console dev asset watcher enabled.");
1488
+ }
1489
+
1490
+ async function readActiveRuntime() {
1491
+ return getActiveRuntimeStateFn().catch(() => null);
1492
+ }
1493
+
1494
+ function getWebConsoleConflictMessage(settings = getRouterStateSettings(routerState)) {
1495
+ if (!endpointsConflict(
1496
+ { host: settings.host, port: settings.port },
1497
+ { host, port: actualWebPort || port }
1498
+ )) {
1499
+ return "";
1500
+ }
1501
+
1502
+ return `Fixed router port ${settings.port} conflicts with the web console on http://${formatHostForUrl(host, actualWebPort || port)}. Relaunch the web console on another port.`;
1503
+ }
1504
+
1505
+ async function readManagedRuntime(settings = getRouterStateSettings(routerState)) {
1506
+ const activeRuntime = await readActiveRuntime();
1507
+ return runtimeMatchesEndpoint(activeRuntime, settings) ? activeRuntime : null;
1508
+ }
1509
+
1510
+ async function readExternalRuntime(settings = getRouterStateSettings(routerState)) {
1511
+ const activeRuntime = await readActiveRuntime();
1512
+ if (!activeRuntime) return null;
1513
+ return runtimeMatchesEndpoint(activeRuntime, settings) ? null : activeRuntime;
1514
+ }
1515
+
1516
+ function probeRouterPort(settings = getRouterStateSettings(routerState)) {
1517
+ const webConsoleConflict = getWebConsoleConflictMessage(settings);
1518
+ if (webConsoleConflict) {
1519
+ return {
1520
+ occupied: true,
1521
+ listenerPids: [],
1522
+ tool: "web-console",
1523
+ error: "",
1524
+ occupiedBySelf: true,
1525
+ reason: webConsoleConflict
1526
+ };
1527
+ }
1528
+
1529
+ const probe = listListeningPidsFn(settings.port);
1530
+ if (!probe?.ok) {
1531
+ return {
1532
+ occupied: false,
1533
+ listenerPids: [],
1534
+ tool: String(probe?.tool || ""),
1535
+ error: probe?.error instanceof Error ? probe.error.message : String(probe?.error || ""),
1536
+ occupiedBySelf: false,
1537
+ reason: ""
1538
+ };
1539
+ }
1540
+
1541
+ return {
1542
+ occupied: probe.pids.some((pid) => pid !== process.pid),
1543
+ listenerPids: probe.pids.filter((pid) => pid !== process.pid),
1544
+ tool: String(probe.tool || ""),
1545
+ error: "",
1546
+ occupiedBySelf: false,
1547
+ reason: ""
1548
+ };
1549
+ }
1550
+
1551
+ async function reclaimRouterPortIfNeeded(settings = getRouterStateSettings(routerState), {
1552
+ reason = "Reclaiming router port from web console."
1553
+ } = {}) {
1554
+ const probe = probeRouterPort(settings);
1555
+ if (!probe.occupied) {
1556
+ return {
1557
+ ok: true,
1558
+ attempted: false,
1559
+ listenerPids: probe.listenerPids,
1560
+ errorMessage: probe.error
1561
+ };
1562
+ }
1563
+
1564
+ addLog("warn", reason, `Port ${settings.port} is occupied by PID${probe.listenerPids.length === 1 ? "" : "s"}: ${probe.listenerPids.join(", ")}`);
1565
+ const reclaimed = await reclaimPortFn({
1566
+ port: settings.port,
1567
+ line: (message) => addLog("warn", message),
1568
+ error: (message) => addLog("error", message)
1569
+ });
1570
+ if (!reclaimed.ok) {
1571
+ return {
1572
+ ok: false,
1573
+ attempted: true,
1574
+ errorMessage: reclaimed.errorMessage || `Failed to reclaim port ${settings.port}.`
1575
+ };
1576
+ }
1577
+
1578
+ const activeRuntime = await readActiveRuntime();
1579
+ if (activeRuntime && Number(activeRuntime.port) === Number(settings.port)) {
1580
+ if (activeRuntime.managedByStartup) {
1581
+ await clearRuntimeStateFn();
1582
+ } else {
1583
+ await clearRuntimeStateFn({ pid: activeRuntime.pid });
1584
+ }
1585
+ }
1586
+
1587
+ routerState.lastError = "";
1588
+ addLog("success", `Port ${settings.port} reclaimed.`);
1589
+ return {
1590
+ ok: true,
1591
+ attempted: true,
1592
+ listenerPids: probe.listenerPids
1593
+ };
1594
+ }
1595
+
1596
+ async function stopExternalRuntime(runtime, { reason = "Stopped another llm-router instance." } = {}) {
1597
+ if (!runtime || Number(runtime.pid) === Number(process.pid)) return false;
1598
+
1599
+ const runtimeUrl = `http://${formatHostForUrl(runtime.host, runtime.port)}`;
1600
+ if (runtime.managedByStartup) {
1601
+ await stopStartupFn();
1602
+ await clearRuntimeStateFn();
1603
+ addLog("warn", reason, runtimeUrl);
1604
+ return true;
1605
+ }
1606
+
1607
+ const stopped = await stopProcessByPidFn(runtime.pid);
1608
+ if (!stopped?.ok) {
1609
+ const error = new Error(stopped?.reason || `Failed stopping llm-router pid ${runtime.pid}.`);
1610
+ error.statusCode = 409;
1611
+ throw error;
1612
+ }
1613
+
1614
+ await clearRuntimeStateFn({ pid: runtime.pid });
1615
+ addLog("warn", reason, runtimeUrl);
1616
+ return true;
1617
+ }
1618
+
1619
+ async function stopUntrackedStartupRuntime({ reason = "Stopped startup-managed llm-router." } = {}) {
1620
+ const startup = await startupStatusFn().catch(() => null);
1621
+ if (!startup?.running) return false;
1622
+ await stopStartupFn();
1623
+ await clearRuntimeStateFn();
1624
+ addLog("warn", reason, formatStartupDetail({ ...startup, installed: true, running: true }));
1625
+ return true;
1626
+ }
1627
+
1628
+ async function startStartupOwnedRouter(settings, { restart = false } = {}) {
1629
+ await clearRuntimeStateFn();
1630
+ const detail = await installStartupFn({
1631
+ configPath,
1632
+ host: settings.host,
1633
+ port: settings.port,
1634
+ watchConfig: settings.watchConfig,
1635
+ watchBinary: settings.watchBinary,
1636
+ requireAuth: settings.requireAuth,
1637
+ cliPath: resolvedRouterCliPath
1638
+ });
1639
+ let runtime = await waitForRuntimeMatchFn({
1640
+ configPath,
1641
+ host: settings.host,
1642
+ port: settings.port,
1643
+ watchConfig: settings.watchConfig,
1644
+ watchBinary: settings.watchBinary,
1645
+ requireAuth: settings.requireAuth
1646
+ }, {
1647
+ getActiveRuntimeState: getActiveRuntimeStateFn,
1648
+ requireManagedByStartup: true
1649
+ });
1650
+
1651
+ if (!runtime) {
1652
+ runtime = await readManagedRuntime(settings);
1653
+ }
1654
+
1655
+ if (!runtime) {
1656
+ const startError = new Error(`Startup-managed llm-router did not become ready on http://${settings.host}:${settings.port}.`);
1657
+ startError.statusCode = 500;
1658
+ throw startError;
1659
+ }
1660
+
1661
+ syncRouterDefaults(runtime);
1662
+ routerState.lastError = "";
1663
+ const routerSnapshot = buildRouterSnapshot(runtime, settings, routerState.lastError);
1664
+ addLog("success", `${restart ? "Router restarted" : "Router started"} on ${routerSnapshot.url}`, formatStartupDetail({
1665
+ ...detail,
1666
+ manager: detail?.manager || "startup",
1667
+ installed: true,
1668
+ running: true
1669
+ }));
1670
+ return {
1671
+ message: restart ? "Router restarted." : "Router started.",
1672
+ snapshot: await buildSnapshot()
1673
+ };
1674
+ }
1675
+
1676
+ async function reconcileManagedRouterWithConfig({ reason = "sync", configStateOverride = null } = {}) {
1677
+ const configState = configStateOverride || await readConfigState(configPath);
1678
+ const configLocalServer = getConfigLocalServer(configState);
1679
+ syncRouterDefaults(configLocalServer);
1680
+
1681
+ if (configState.parseError || !configHasProvider(configState.normalizedConfig)) {
1682
+ return {
1683
+ ok: false,
1684
+ skipped: true,
1685
+ reason,
1686
+ configState,
1687
+ settings: configLocalServer
1688
+ };
1689
+ }
1690
+
1691
+ const startup = await startupStatusFn().catch(() => null);
1692
+ const activeRuntime = await readManagedRuntime(configLocalServer);
1693
+ if (activeRuntime) {
1694
+ return {
1695
+ ok: true,
1696
+ alreadyRunning: true,
1697
+ reason,
1698
+ configState,
1699
+ settings: configLocalServer
1700
+ };
1701
+ }
1702
+
1703
+ const externalRuntime = await readExternalRuntime(configLocalServer);
1704
+ if (externalRuntime) {
1705
+ await stopExternalRuntime(externalRuntime, {
1706
+ reason: `Stopped existing llm-router so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
1707
+ });
1708
+ } else {
1709
+ await stopUntrackedStartupRuntime({
1710
+ reason: `Stopped startup-managed llm-router so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
1711
+ });
1712
+ }
1713
+
1714
+ const configStateForStart = {
1715
+ normalizedConfig: configState.normalizedConfig,
1716
+ parseError: configState.parseError
1717
+ };
1718
+
1719
+ await startManagedRouter(configLocalServer, {
1720
+ skipPersist: true,
1721
+ configStateOverride: configStateForStart
1722
+ });
1723
+ return {
1724
+ ok: true,
1725
+ started: true,
1726
+ reason,
1727
+ configState,
1728
+ settings: configLocalServer
1729
+ };
1730
+ }
1731
+
1732
+ function getConfigLocalServer(configState) {
1733
+ return readLocalServerSettings(configState?.normalizedConfig, getRouterStateSettings(routerState));
1734
+ }
1735
+
1736
+ function syncRouterDefaults(settings) {
1737
+ routerState.host = settings.host;
1738
+ routerState.port = settings.port;
1739
+ routerState.watchConfig = settings.watchConfig;
1740
+ routerState.watchBinary = settings.watchBinary;
1741
+ routerState.requireAuth = settings.requireAuth;
1742
+ }
1743
+
1744
+ async function persistLocalServerConfig(settings) {
1745
+ const currentConfigState = await readConfigState(configPath);
1746
+ if (currentConfigState.parseError) {
1747
+ const error = new Error(`Config JSON must parse before saving local server settings: ${currentConfigState.parseError}`);
1748
+ error.statusCode = 400;
1749
+ throw error;
1750
+ }
1751
+
1752
+ const previousSettings = getConfigLocalServer(currentConfigState);
1753
+ const nextConfig = applyLocalServerSettings(currentConfigState.normalizedConfig || buildDefaultConfigObject(), settings);
1754
+ ignoreConfigWatchUntil = Date.now() + 800;
1755
+ const savedConfig = await writeConfigFile(nextConfig, configPath, { migrateToVersion: CONFIG_VERSION });
1756
+ const savedSettings = readLocalServerSettings(savedConfig, settings);
1757
+
1758
+ const managedRuntime = await readManagedRuntime(previousSettings);
1759
+ if (!managedRuntime) {
1760
+ syncRouterDefaults(savedSettings);
1761
+ }
1762
+
1763
+ return {
1764
+ previousConfig: currentConfigState.normalizedConfig,
1765
+ previousSettings,
1766
+ savedConfig,
1767
+ savedSettings
1768
+ };
1769
+ }
1770
+
1771
+ async function writeAndBroadcastConfig(parsed, { source = "" } = {}) {
1772
+ const previousConfigState = await readConfigState(configPath);
1773
+ const previousConfig = previousConfigState.normalizedConfig || buildDefaultConfigObject();
1774
+ const previousLocalServer = getConfigLocalServer(previousConfigState);
1775
+ ignoreConfigWatchUntil = Date.now() + 800;
1776
+ const savedConfig = await writeConfigFile(parsed, configPath, { migrateToVersion: CONFIG_VERSION });
1777
+ const nextLocalServer = readLocalServerSettings(savedConfig, previousLocalServer);
1778
+
1779
+ if (source !== "autosave") {
1780
+ addLog("success", `Config saved to ${path.basename(configPath)}.`);
1781
+ }
1782
+
1783
+ const managedRuntime = await readManagedRuntime(previousLocalServer);
1784
+
1785
+ if (managedRuntime && !areLocalServerSettingsEqual(previousLocalServer, nextLocalServer)) {
1786
+ try {
1787
+ await restartManagedRouterWithSettings(nextLocalServer, {
1788
+ reason: "Restarting managed router to apply saved local server settings.",
1789
+ configStateOverride: {
1790
+ normalizedConfig: savedConfig,
1791
+ parseError: ""
1792
+ }
1793
+ });
1794
+ } catch (restartError) {
1795
+ addLog("warn", "Managed router restart skipped.", restartError instanceof Error ? restartError.message : String(restartError));
1796
+ }
1797
+ } else if (!managedRuntime) {
1798
+ syncRouterDefaults(nextLocalServer);
1799
+ try {
1800
+ await reconcileManagedRouterWithConfig({
1801
+ reason: "config-save",
1802
+ configStateOverride: {
1803
+ normalizedConfig: savedConfig,
1804
+ parseError: ""
1805
+ }
1806
+ });
1807
+ } catch (reconcileError) {
1808
+ addLog("warn", "Managed router auto-start skipped.", reconcileError instanceof Error ? reconcileError.message : String(reconcileError));
1809
+ }
1810
+ }
1811
+
1812
+ await syncAmpGlobalRoutingIfNeeded({
1813
+ previousConfig,
1814
+ nextConfig: savedConfig,
1815
+ previousSettings: previousLocalServer,
1816
+ nextSettings: nextLocalServer
1817
+ });
1818
+ await syncCodexCliRoutingIfNeeded({
1819
+ previousConfig,
1820
+ nextConfig: savedConfig,
1821
+ previousSettings: previousLocalServer,
1822
+ nextSettings: nextLocalServer
1823
+ });
1824
+ await syncClaudeCodeRoutingIfNeeded({
1825
+ previousConfig,
1826
+ nextConfig: savedConfig,
1827
+ previousSettings: previousLocalServer,
1828
+ nextSettings: nextLocalServer
1829
+ });
1830
+
1831
+ const snapshot = await broadcastState();
1832
+ return {
1833
+ snapshot,
1834
+ savedConfig,
1835
+ nextLocalServer
1836
+ };
1837
+ }
1838
+
1839
+ let routerRestartPromise = null;
1840
+
1841
+ async function restartManagedRouterWithSettings(settings, {
1842
+ reason = "Restarting managed router.",
1843
+ configStateOverride = null
1844
+ } = {}) {
1845
+ if (routerRestartPromise) return routerRestartPromise;
1846
+ routerRestartPromise = (async () => {
1847
+ try {
1848
+ return await startManagedRouter(settings, {
1849
+ restart: true,
1850
+ skipPersist: true,
1851
+ configStateOverride,
1852
+ restartReason: reason
1853
+ });
1854
+ } finally {
1855
+ routerRestartPromise = null;
1856
+ }
1857
+ })();
1858
+ return routerRestartPromise;
1859
+ }
1860
+
1861
+ async function buildSnapshot() {
1862
+ const configState = await readConfigState(configPath);
1863
+ const configLocalServer = getConfigLocalServer(configState);
1864
+ const startup = await startupStatusFn().catch((error) => ({
1865
+ manager: "unknown",
1866
+ serviceId: "llm-router",
1867
+ installed: false,
1868
+ running: false,
1869
+ detail: error instanceof Error ? error.message : String(error)
1870
+ }));
1871
+ const managedRuntime = await readManagedRuntime(configLocalServer);
1872
+ const externalRuntime = managedRuntime ? null : await readExternalRuntime(configLocalServer);
1873
+ const portProbe = probeRouterPort(configLocalServer);
1874
+ const routerSnapshot = {
1875
+ ...buildRouterSnapshot(managedRuntime, configLocalServer, routerState.lastError),
1876
+ portBusy: !managedRuntime && portProbe.occupied,
1877
+ portBusySelf: !managedRuntime && portProbe.occupiedBySelf === true,
1878
+ portBusyReason: !managedRuntime ? String(portProbe.reason || "") : "",
1879
+ listenerPids: !managedRuntime ? portProbe.listenerPids : [],
1880
+ portProbeError: !managedRuntime ? portProbe.error : ""
1881
+ };
1882
+ const ampClientGlobal = await readAmpGlobalRoutingState(configLocalServer);
1883
+ const codexCliGlobal = await readCodexCliGlobalRoutingState(configLocalServer, configState.normalizedConfig);
1884
+ const claudeCodeGlobal = await readClaudeCodeGlobalRoutingState(configLocalServer, configState.normalizedConfig);
1885
+
1886
+ return {
1887
+ web: {
1888
+ host,
1889
+ port: actualWebPort,
1890
+ url: `http://${formatHostForUrl(host, actualWebPort)}`,
1891
+ localOnly: !allowRemoteClients
1892
+ },
1893
+ config: {
1894
+ ...configState.summary,
1895
+ document: routeSnapshotDocument(configState),
1896
+ localServer: configLocalServer
1897
+ },
1898
+ router: routerSnapshot,
1899
+ startup: {
1900
+ ...startup,
1901
+ label: formatStartupLabel(startup),
1902
+ friendlyDetail: formatStartupDetail(startup),
1903
+ defaults: configLocalServer
1904
+ },
1905
+ ampClient: {
1906
+ global: ampClientGlobal
1907
+ },
1908
+ codingTools: {
1909
+ codexCli: codexCliGlobal,
1910
+ claudeCode: claudeCodeGlobal
1911
+ },
1912
+ defaults: {
1913
+ providerUserAgent: DEFAULT_PROVIDER_USER_AGENT
1914
+ },
1915
+ editors: detectAvailableEditorsFn(),
1916
+ externalRuntime,
1917
+ logs
1918
+ };
1919
+ }
1920
+
1921
+ async function broadcastState() {
1922
+ const snapshot = await buildSnapshot();
1923
+ pushEvent("state", { snapshot });
1924
+ return snapshot;
1925
+ }
1926
+
1927
+ function scheduleConfigRefresh(reason = "change") {
1928
+ if (configWatchTimer) clearTimeout(configWatchTimer);
1929
+ configWatchTimer = setTimeout(() => {
1930
+ configWatchTimer = null;
1931
+ if (closing) return;
1932
+ if (Date.now() < ignoreConfigWatchUntil) return;
1933
+ addLog("info", `Config file changed on disk (${reason}).`);
1934
+ void reconcileManagedRouterWithConfig({ reason: `config-watch:${reason}` })
1935
+ .catch((reconcileError) => {
1936
+ addLog("warn", "Managed router auto-start skipped.", reconcileError instanceof Error ? reconcileError.message : String(reconcileError));
1937
+ })
1938
+ .finally(() => {
1939
+ void broadcastState();
1940
+ });
1941
+ }, 150);
1942
+ }
1943
+
1944
+ function startConfigWatcher() {
1945
+ const configDir = path.dirname(configPath);
1946
+ const configFile = path.basename(configPath);
1947
+ try {
1948
+ configWatcher = fsWatch(configDir, (eventType, filename) => {
1949
+ if (closing) return;
1950
+ if (filename && String(filename) !== configFile) return;
1951
+ if (Date.now() < ignoreConfigWatchUntil) return;
1952
+ scheduleConfigRefresh(eventType || "change");
1953
+ });
1954
+ } catch (error) {
1955
+ addLog("warn", "Could not start config watcher.", error instanceof Error ? error.message : String(error));
1956
+ }
1957
+ }
1958
+
1959
+ async function stopManagedRouter({
1960
+ reason = "Stopped from web console.",
1961
+ settings = getRouterStateSettings(routerState),
1962
+ reclaimPortIfStopped = false
1963
+ } = {}) {
1964
+ const activeRuntime = await readManagedRuntime(settings);
1965
+ if (!activeRuntime) {
1966
+ if (reclaimPortIfStopped) {
1967
+ const reclaimed = await reclaimRouterPortIfNeeded(settings, { reason });
1968
+ if (!reclaimed.ok) {
1969
+ const stopError = new Error(reclaimed.errorMessage || `Failed reclaiming port ${settings.port}.`);
1970
+ stopError.statusCode = 409;
1971
+ throw stopError;
1972
+ }
1973
+ if (reclaimed.attempted) return true;
1974
+ }
1975
+ routerState.lastError = "";
1976
+ return false;
1977
+ }
1978
+
1979
+ if (activeRuntime.managedByStartup) {
1980
+ await stopStartupFn();
1981
+ await clearRuntimeStateFn();
1982
+ routerState.lastError = "";
1983
+ addLog("info", reason);
1984
+ return true;
1985
+ }
1986
+
1987
+ const stopped = await stopProcessByPidFn(activeRuntime.pid);
1988
+ if (!stopped?.ok) {
1989
+ const stopError = new Error(stopped?.reason || `Failed stopping llm-router pid ${activeRuntime.pid}.`);
1990
+ stopError.statusCode = 409;
1991
+ throw stopError;
1992
+ }
1993
+
1994
+ await clearRuntimeStateFn({ pid: activeRuntime.pid });
1995
+ routerState.lastError = "";
1996
+ addLog("info", reason);
1997
+ return true;
1998
+ }
1999
+
2000
+ async function startManagedRouter(body = {}, {
2001
+ restart = false,
2002
+ skipPersist = false,
2003
+ configStateOverride = null,
2004
+ restartReason = "Restarting managed router from web console."
2005
+ } = {}) {
2006
+ let configState = configStateOverride || await readConfigState(configPath);
2007
+ let persistedLocalServer = null;
2008
+ if (configState.parseError) {
2009
+ const error = new Error(`Config JSON must parse before starting the router: ${configState.parseError}`);
2010
+ error.statusCode = 400;
2011
+ throw error;
2012
+ }
2013
+ if (!configHasProvider(configState.normalizedConfig)) {
2014
+ const error = new Error("At least one enabled provider is required before starting the router.");
2015
+ error.statusCode = 400;
2016
+ throw error;
2017
+ }
2018
+
2019
+ const currentDefaults = getConfigLocalServer(configState);
2020
+ let nextOptions = resolveRouterOptions(currentDefaults, body);
2021
+
2022
+ if (nextOptions.requireAuth && !configState.normalizedConfig.masterKey) {
2023
+ const error = new Error("masterKey is required when enabling auth for the managed router.");
2024
+ error.statusCode = 400;
2025
+ throw error;
2026
+ }
2027
+
2028
+ if (!skipPersist) {
2029
+ const persisted = await persistLocalServerConfig(nextOptions);
2030
+ persistedLocalServer = persisted;
2031
+ configState = {
2032
+ ...configState,
2033
+ normalizedConfig: persisted.savedConfig
2034
+ };
2035
+ nextOptions = persisted.savedSettings;
2036
+ }
2037
+
2038
+ const startup = await startupStatusFn().catch(() => null);
2039
+ const preferStartupOwnership = Boolean(startup?.installed);
2040
+ const runningRuntime = await readManagedRuntime(nextOptions);
2041
+ const webConsoleConflict = getWebConsoleConflictMessage(nextOptions);
2042
+
2043
+ if (webConsoleConflict) {
2044
+ routerState.lastError = webConsoleConflict;
2045
+ addLog("error", "Failed to start router.", webConsoleConflict);
2046
+ await broadcastState().catch(() => {});
2047
+ const conflictError = new Error(webConsoleConflict);
2048
+ conflictError.statusCode = 409;
2049
+ throw conflictError;
2050
+ }
2051
+
2052
+ if (restart) {
2053
+ await stopManagedRouter({ reason: restartReason, settings: nextOptions });
2054
+ } else if (runningRuntime) {
2055
+ return {
2056
+ message: "Router is already running.",
2057
+ snapshot: await buildSnapshot()
2058
+ };
2059
+ }
2060
+
2061
+ const externalRuntime = await readExternalRuntime(nextOptions);
2062
+ if (externalRuntime) {
2063
+ await stopExternalRuntime(externalRuntime, {
2064
+ reason: "Stopped another llm-router instance before starting the managed router."
2065
+ });
2066
+ } else {
2067
+ await stopUntrackedStartupRuntime({
2068
+ reason: "Stopped startup-managed llm-router before starting the managed router."
2069
+ });
2070
+ }
2071
+
2072
+ const reclaimed = await reclaimRouterPortIfNeeded(nextOptions, {
2073
+ reason: "Stopping the existing listener so the web console can take over the router port."
2074
+ });
2075
+ if (!reclaimed.ok) {
2076
+ const reclaimError = new Error(reclaimed.errorMessage || `Failed reclaiming port ${nextOptions.port}.`);
2077
+ reclaimError.statusCode = 409;
2078
+ throw reclaimError;
2079
+ }
2080
+
2081
+ try {
2082
+ if (preferStartupOwnership) {
2083
+ const result = await startStartupOwnedRouter(nextOptions, { restart });
2084
+ if (persistedLocalServer) {
2085
+ await syncAmpGlobalRoutingIfNeeded({
2086
+ previousConfig: persistedLocalServer.previousConfig,
2087
+ nextConfig: persistedLocalServer.savedConfig,
2088
+ previousSettings: persistedLocalServer.previousSettings,
2089
+ nextSettings: persistedLocalServer.savedSettings
2090
+ });
2091
+ await syncCodexCliRoutingIfNeeded({
2092
+ previousConfig: persistedLocalServer.previousConfig,
2093
+ nextConfig: persistedLocalServer.savedConfig,
2094
+ previousSettings: persistedLocalServer.previousSettings,
2095
+ nextSettings: persistedLocalServer.savedSettings
2096
+ });
2097
+ await syncClaudeCodeRoutingIfNeeded({
2098
+ previousConfig: persistedLocalServer.previousConfig,
2099
+ nextConfig: persistedLocalServer.savedConfig,
2100
+ previousSettings: persistedLocalServer.previousSettings,
2101
+ nextSettings: persistedLocalServer.savedSettings
2102
+ });
2103
+ result.snapshot = await buildSnapshot();
2104
+ }
2105
+ return result;
2106
+ }
2107
+
2108
+ const started = await startDetachedRouterServiceFn({
2109
+ cliPath: resolvedRouterCliPath,
2110
+ configPath,
2111
+ host: nextOptions.host,
2112
+ port: nextOptions.port,
2113
+ watchConfig: nextOptions.watchConfig,
2114
+ watchBinary: nextOptions.watchBinary,
2115
+ requireAuth: nextOptions.requireAuth
2116
+ });
2117
+ if (!started?.ok) {
2118
+ const startError = new Error(started?.errorMessage || `Failed to start llm-router on http://${nextOptions.host}:${nextOptions.port}.`);
2119
+ startError.statusCode = 500;
2120
+ throw startError;
2121
+ }
2122
+
2123
+ const runtime = started.runtime || await readManagedRuntime(nextOptions);
2124
+ syncRouterDefaults(runtime || nextOptions);
2125
+ routerState.lastError = "";
2126
+ const routerSnapshot = buildRouterSnapshot(runtime, nextOptions, routerState.lastError);
2127
+ addLog("success", `Router started on ${routerSnapshot.url}`);
2128
+ if (persistedLocalServer) {
2129
+ await syncAmpGlobalRoutingIfNeeded({
2130
+ previousConfig: persistedLocalServer.previousConfig,
2131
+ nextConfig: persistedLocalServer.savedConfig,
2132
+ previousSettings: persistedLocalServer.previousSettings,
2133
+ nextSettings: persistedLocalServer.savedSettings
2134
+ });
2135
+ await syncCodexCliRoutingIfNeeded({
2136
+ previousConfig: persistedLocalServer.previousConfig,
2137
+ nextConfig: persistedLocalServer.savedConfig,
2138
+ previousSettings: persistedLocalServer.previousSettings,
2139
+ nextSettings: persistedLocalServer.savedSettings
2140
+ });
2141
+ await syncClaudeCodeRoutingIfNeeded({
2142
+ previousConfig: persistedLocalServer.previousConfig,
2143
+ nextConfig: persistedLocalServer.savedConfig,
2144
+ previousSettings: persistedLocalServer.previousSettings,
2145
+ nextSettings: persistedLocalServer.savedSettings
2146
+ });
2147
+ }
2148
+ return {
2149
+ message: restart ? "Router restarted." : "Router started.",
2150
+ snapshot: await buildSnapshot()
2151
+ };
2152
+ } catch (error) {
2153
+ routerState.lastError = error instanceof Error ? error.message : String(error);
2154
+ addLog("error", "Failed to start router.", routerState.lastError);
2155
+ await broadcastState().catch(() => {});
2156
+ throw error;
2157
+ }
2158
+ }
2159
+
2160
+ async function shutdown(reason = "web-console-closed") {
2161
+ if (closePromise) return closePromise;
2162
+ closing = true;
2163
+ closePromise = (async () => {
2164
+ if (configWatchTimer) {
2165
+ clearTimeout(configWatchTimer);
2166
+ configWatchTimer = null;
2167
+ }
2168
+ if (configWatcher) {
2169
+ configWatcher.close();
2170
+ configWatcher = null;
2171
+ }
2172
+
2173
+ for (const client of eventClients) {
2174
+ client.end();
2175
+ }
2176
+ eventClients.clear();
2177
+ for (const client of devEventClients) {
2178
+ client.end();
2179
+ }
2180
+ devEventClients.clear();
2181
+
2182
+ if (devAssets) {
2183
+ await devAssets.close();
2184
+ }
2185
+
2186
+ if (webServer) {
2187
+ await new Promise((resolve) => webServer.close(() => resolve()));
2188
+ webServer = null;
2189
+ }
2190
+
2191
+ resolveDone({ reason });
2192
+ return { ok: true, reason };
2193
+ })();
2194
+ return closePromise;
2195
+ }
2196
+
2197
+ webServer = http.createServer(async (req, res) => {
2198
+ if (!allowRemoteClients && !isLoopbackAddress(req.socket?.remoteAddress)) {
2199
+ sendJson(res, 403, { error: "The web console only accepts local requests." });
2200
+ return;
2201
+ }
2202
+
2203
+ const requestUrl = new URL(req.url || "/", `http://${formatHostForUrl(host, actualWebPort || port || 8788)}`);
2204
+ const method = req.method || "GET";
2205
+
2206
+ try {
2207
+ if (method === "GET" && requestUrl.pathname === "/") {
2208
+ sendText(res, 200, "text/html; charset=utf-8", renderWebConsoleHtml({ bodyHtml: devReloadScript }));
2209
+ return;
2210
+ }
2211
+
2212
+ if (devMode && method === "GET" && requestUrl.pathname === "/__dev/events") {
2213
+ res.writeHead(200, {
2214
+ "content-type": "text/event-stream; charset=utf-8",
2215
+ "cache-control": "no-store",
2216
+ connection: "keep-alive"
2217
+ });
2218
+ res.write(": connected\n\n");
2219
+ devEventClients.add(res);
2220
+ req.on("close", () => {
2221
+ devEventClients.delete(res);
2222
+ });
2223
+ return;
2224
+ }
2225
+
2226
+ if (method === "GET" && requestUrl.pathname === "/styles.css") {
2227
+ sendText(res, 200, "text/css; charset=utf-8", devAssets ? devAssets.getStylesCss() : WEB_CONSOLE_CSS);
2228
+ return;
2229
+ }
2230
+
2231
+ if (method === "GET" && requestUrl.pathname === "/app.js") {
2232
+ sendText(res, 200, "application/javascript; charset=utf-8", devAssets ? devAssets.getAppJs() : WEB_CONSOLE_APP_JS);
2233
+ return;
2234
+ }
2235
+
2236
+ if (method === "GET" && requestUrl.pathname === "/api/state") {
2237
+ sendJson(res, 200, await buildSnapshot());
2238
+ return;
2239
+ }
2240
+
2241
+ if (method === "GET" && requestUrl.pathname === "/api/events") {
2242
+ res.writeHead(200, {
2243
+ "content-type": "text/event-stream; charset=utf-8",
2244
+ "cache-control": "no-store",
2245
+ connection: "keep-alive"
2246
+ });
2247
+ res.write(": connected\n\n");
2248
+ eventClients.add(res);
2249
+ req.on("close", () => {
2250
+ eventClients.delete(res);
2251
+ });
2252
+ return;
2253
+ }
2254
+
2255
+ if (method === "POST" && requestUrl.pathname === "/api/config/validate") {
2256
+ const body = await readJsonBody(req);
2257
+ const rawText = body?.rawText !== undefined
2258
+ ? String(body.rawText || "")
2259
+ : `${JSON.stringify(body?.config || {}, null, 2)}\n`;
2260
+ let normalizedConfig = null;
2261
+ let parseError = "";
2262
+ try {
2263
+ const parsed = body?.config && typeof body.config === "object" && !Array.isArray(body.config)
2264
+ ? body.config
2265
+ : (rawText.trim() ? JSON.parse(rawText) : {});
2266
+ normalizedConfig = normalizeRuntimeConfig(parsed, { migrateToVersion: CONFIG_VERSION });
2267
+ } catch (error) {
2268
+ parseError = error instanceof Error ? error.message : String(error);
2269
+ }
2270
+ const summary = createConfigSummary({
2271
+ configPath,
2272
+ exists: true,
2273
+ rawText,
2274
+ parseError,
2275
+ normalizedConfig
2276
+ });
2277
+ sendJson(res, 200, {
2278
+ summary,
2279
+ validationMessages: summary.validationMessages
2280
+ });
2281
+ return;
2282
+ }
2283
+
2284
+ if (method === "POST" && requestUrl.pathname === "/api/config/test-provider") {
2285
+ const body = await readJsonBody(req);
2286
+ const endpoints = Array.isArray(body?.endpoints) ? body.endpoints.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
2287
+ const models = Array.isArray(body?.models) ? body.models.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
2288
+ const apiKeyEnv = String(body?.apiKeyEnv || "").trim();
2289
+ const apiKey = String(body?.apiKey || "").trim();
2290
+ const headers = body?.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
2291
+ ? body.headers
2292
+ : undefined;
2293
+
2294
+ if (endpoints.length === 0) {
2295
+ sendJson(res, 400, { error: "At least one endpoint is required before testing config." });
2296
+ return;
2297
+ }
2298
+ if (models.length === 0) {
2299
+ sendJson(res, 400, { error: "At least one model id is required before testing config." });
2300
+ return;
2301
+ }
2302
+ if (!apiKeyEnv && !apiKey) {
2303
+ sendJson(res, 400, { error: "API key or env is required before testing config." });
2304
+ return;
2305
+ }
2306
+
2307
+ addLog("info", "Testing provider config.", `${endpoints.length} endpoint(s) · ${models.length} model(s)`);
2308
+ const result = await testProviderConfigFn({ endpoints, models, apiKeyEnv, apiKey, headers });
2309
+ addLog(result.ok ? "success" : "warn", "Config test finished.", result.ok
2310
+ ? `${(result.workingFormats || []).join(", ") || "No working formats"} · ${(result.models || []).length} model(s) confirmed`
2311
+ : (result.warnings || []).join(" ") || "Could not confirm a working endpoint/model combination.");
2312
+ sendJson(res, 200, { result });
2313
+ return;
2314
+ }
2315
+
2316
+ if (method === "POST" && requestUrl.pathname === "/api/config/test-provider-stream") {
2317
+ const body = await readJsonBody(req);
2318
+ const endpoints = Array.isArray(body?.endpoints) ? body.endpoints.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
2319
+ const models = Array.isArray(body?.models) ? body.models.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
2320
+ const apiKeyEnv = String(body?.apiKeyEnv || "").trim();
2321
+ const apiKey = String(body?.apiKey || "").trim();
2322
+ const headers = body?.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
2323
+ ? body.headers
2324
+ : undefined;
2325
+
2326
+ if (endpoints.length === 0) {
2327
+ sendJson(res, 400, { error: "At least one endpoint is required before testing config." });
2328
+ return;
2329
+ }
2330
+ if (models.length === 0) {
2331
+ sendJson(res, 400, { error: "At least one model id is required before testing config." });
2332
+ return;
2333
+ }
2334
+ if (!apiKeyEnv && !apiKey) {
2335
+ sendJson(res, 400, { error: "API key or env is required before testing config." });
2336
+ return;
2337
+ }
2338
+
2339
+ addLog("info", "Testing provider config.", `${endpoints.length} endpoint(s) · ${models.length} model(s)`);
2340
+ startJsonLineStream(res);
2341
+ writeJsonLine(res, { type: "start", modelCount: models.length, endpointCount: endpoints.length });
2342
+ try {
2343
+ const result = await testProviderConfigFn({
2344
+ endpoints,
2345
+ models,
2346
+ apiKeyEnv,
2347
+ apiKey,
2348
+ headers,
2349
+ onProgress: (event) => writeJsonLine(res, { type: "progress", event })
2350
+ });
2351
+ addLog(result.ok ? "success" : "warn", "Config test finished.", result.ok
2352
+ ? `${(result.workingFormats || []).join(", ") || "No working formats"} · ${(result.models || []).length} model(s) confirmed`
2353
+ : (result.warnings || []).join(" ") || "Could not confirm a working endpoint/model combination.");
2354
+ writeJsonLine(res, { type: "result", result });
2355
+ } catch (error) {
2356
+ writeJsonLine(res, {
2357
+ type: "error",
2358
+ error: error instanceof Error ? error.message : String(error),
2359
+ statusCode: error && typeof error === "object" ? error.statusCode || 500 : 500
2360
+ });
2361
+ } finally {
2362
+ res.end();
2363
+ }
2364
+ return;
2365
+ }
2366
+
2367
+ if (method === "POST" && requestUrl.pathname === "/api/config/discover-provider-models") {
2368
+ const body = await readJsonBody(req);
2369
+ const endpoints = Array.isArray(body?.endpoints) ? body.endpoints.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
2370
+ const apiKeyEnv = String(body?.apiKeyEnv || "").trim();
2371
+ const apiKey = String(body?.apiKey || "").trim();
2372
+ const headers = body?.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
2373
+ ? body.headers
2374
+ : undefined;
2375
+
2376
+ if (endpoints.length === 0) {
2377
+ sendJson(res, 400, { error: "At least one endpoint is required before discovering models." });
2378
+ return;
2379
+ }
2380
+ if (!apiKeyEnv && !apiKey) {
2381
+ sendJson(res, 400, { error: "API key or env is required before discovering models." });
2382
+ return;
2383
+ }
2384
+
2385
+ addLog("info", "Discovering provider models.", `${endpoints.length} endpoint(s)`);
2386
+ const result = await discoverProviderModelsFn({ endpoints, apiKeyEnv, apiKey, headers });
2387
+ addLog(result.ok ? "success" : "warn", "Model discovery finished.", result.ok
2388
+ ? `${(result.models || []).length} model(s) discovered`
2389
+ : (result.warnings || []).join(" ") || "Could not discover models from the provider model list API.");
2390
+ sendJson(res, 200, { result });
2391
+ return;
2392
+ }
2393
+
2394
+ if (method === "POST" && requestUrl.pathname === "/api/config/save") {
2395
+ const body = await readJsonBody(req);
2396
+ let parsed;
2397
+ try {
2398
+ if (body?.config && typeof body.config === "object" && !Array.isArray(body.config)) {
2399
+ parsed = body.config;
2400
+ } else {
2401
+ const rawText = String(body?.rawText || "");
2402
+ parsed = rawText.trim() ? JSON.parse(rawText) : {};
2403
+ }
2404
+ } catch (error) {
2405
+ sendJson(res, 400, {
2406
+ error: `Config JSON parse failed: ${error instanceof Error ? error.message : String(error)}`
2407
+ });
2408
+ return;
2409
+ }
2410
+
2411
+ const source = String(body?.source || "").trim();
2412
+ const { snapshot } = await writeAndBroadcastConfig(parsed, { source });
2413
+ sendJson(res, 200, snapshot);
2414
+ return;
2415
+ }
2416
+
2417
+ if (method === "POST" && requestUrl.pathname === "/api/amp/apply") {
2418
+ const body = await readJsonBody(req);
2419
+ let parsed;
2420
+ try {
2421
+ if (body?.config && typeof body.config === "object" && !Array.isArray(body.config)) {
2422
+ parsed = body.config;
2423
+ } else {
2424
+ const rawText = String(body?.rawText || "");
2425
+ parsed = rawText.trim() ? JSON.parse(rawText) : {};
2426
+ }
2427
+ } catch (error) {
2428
+ sendJson(res, 400, {
2429
+ error: `Config JSON parse failed: ${error instanceof Error ? error.message : String(error)}`
2430
+ });
2431
+ return;
2432
+ }
2433
+
2434
+ const patchPlan = buildAmpClientPatchPlan({
2435
+ scope: body?.patchScope,
2436
+ settingsFilePath: body?.settingsFilePath,
2437
+ secretsFilePath: body?.secretsFilePath,
2438
+ endpointUrl: body?.endpointUrl,
2439
+ apiKey: body?.apiKey || parsed?.masterKey,
2440
+ cwd: ampClientCwd,
2441
+ env: ampClientEnv
2442
+ });
2443
+
2444
+ const bootstrap = await maybeBootstrapAmpConfig({
2445
+ config: parsed,
2446
+ amp: parsed?.amp,
2447
+ patchPlan,
2448
+ env: ampClientEnv
2449
+ });
2450
+ if (bootstrap.error) {
2451
+ sendJson(res, 400, { error: bootstrap.error });
2452
+ return;
2453
+ }
2454
+
2455
+ const nextConfig = {
2456
+ ...(parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}),
2457
+ amp: bootstrap.amp
2458
+ };
2459
+ const source = String(body?.source || "autosave").trim() || "autosave";
2460
+ const { snapshot } = await writeAndBroadcastConfig(nextConfig, { source });
2461
+
2462
+ let patchResult = null;
2463
+ let patchError = "";
2464
+ if (patchPlan) {
2465
+ try {
2466
+ patchResult = await patchAmpClientConfigFiles(patchPlan);
2467
+ } catch (error) {
2468
+ patchError = error instanceof Error ? error.message : String(error);
2469
+ addLog("warn", "AMP client patch failed.", patchError);
2470
+ }
2471
+ }
2472
+
2473
+ sendJson(res, 200, {
2474
+ ...snapshot,
2475
+ amp: {
2476
+ patchResult,
2477
+ patchError,
2478
+ bootstrapDefaultRoute: bootstrap.bootstrapRouteRef || "",
2479
+ defaultsBootstrapped: bootstrap.changed === true,
2480
+ upstreamKeyAutoDiscovered: bootstrap.discoveredUpstreamApiKey === true
2481
+ }
2482
+ });
2483
+ return;
2484
+ }
2485
+
2486
+ if (method === "POST" && requestUrl.pathname === "/api/amp/global-route") {
2487
+ const body = await readJsonBody(req);
2488
+ const enabled = body?.enabled !== false;
2489
+
2490
+ if (!enabled) {
2491
+ const currentConfigState = await readConfigState(configPath).catch(() => null);
2492
+ const currentSettings = getConfigLocalServer(currentConfigState);
2493
+ const routingState = await readAmpGlobalRoutingState(currentSettings);
2494
+ if (routingState.error) {
2495
+ sendJson(res, 400, { error: routingState.error });
2496
+ return;
2497
+ }
2498
+
2499
+ if (!routingState.configuredUrl) {
2500
+ const snapshot = await broadcastState();
2501
+ sendJson(res, 200, { ...snapshot, message: "AMP already routes directly." });
2502
+ return;
2503
+ }
2504
+
2505
+ const unpatchResult = await unpatchAmpClientConfigFiles({
2506
+ settingsFilePath: routingState.settingsFilePath,
2507
+ endpointUrl: routingState.configuredUrl,
2508
+ env: ampClientEnv
2509
+ });
2510
+ addLog("info", "AMP global routing disabled.", routingState.configuredUrl);
2511
+ const snapshot = await broadcastState();
2512
+ sendJson(res, 200, {
2513
+ ...snapshot,
2514
+ message: "AMP now routes directly.",
2515
+ ampClient: {
2516
+ ...(snapshot.ampClient || {}),
2517
+ unpatchResult
2518
+ }
2519
+ });
2520
+ return;
2521
+ }
2522
+
2523
+ let parsed;
2524
+ try {
2525
+ if (body?.config && typeof body.config === "object" && !Array.isArray(body.config)) {
2526
+ parsed = body.config;
2527
+ } else {
2528
+ const rawText = String(body?.rawText || "");
2529
+ parsed = rawText.trim() ? JSON.parse(rawText) : {};
2530
+ }
2531
+ } catch (error) {
2532
+ sendJson(res, 400, {
2533
+ error: `Config JSON parse failed: ${error instanceof Error ? error.message : String(error)}`
2534
+ });
2535
+ return;
2536
+ }
2537
+
2538
+ const configState = {
2539
+ normalizedConfig: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {},
2540
+ parseError: ""
2541
+ };
2542
+ const endpointUrl = String(body?.endpointUrl || buildAmpClientEndpointUrl(getConfigLocalServer(configState))).trim();
2543
+ const settingsTarget = await resolvePreferredAmpSettingsTarget();
2544
+ const patchPlan = buildAmpClientPatchPlan({
2545
+ scope: settingsTarget.scope,
2546
+ settingsFilePath: body?.settingsFilePath || settingsTarget.settingsFilePath,
2547
+ secretsFilePath: body?.secretsFilePath,
2548
+ endpointUrl,
2549
+ apiKey: body?.apiKey || parsed?.masterKey,
2550
+ cwd: ampClientCwd,
2551
+ env: ampClientEnv
2552
+ });
2553
+ if (!patchPlan) {
2554
+ sendJson(res, 400, { error: "AMP global route needs a valid local router URL and gateway key." });
2555
+ return;
2556
+ }
2557
+
2558
+ const bootstrap = await maybeBootstrapAmpConfig({
2559
+ config: parsed,
2560
+ amp: parsed?.amp,
2561
+ patchPlan,
2562
+ env: ampClientEnv
2563
+ });
2564
+ if (bootstrap.error) {
2565
+ sendJson(res, 400, { error: bootstrap.error });
2566
+ return;
2567
+ }
2568
+
2569
+ const nextConfig = {
2570
+ ...(parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}),
2571
+ amp: bootstrap.amp
2572
+ };
2573
+
2574
+ await writeAndBroadcastConfig(nextConfig, { source: "amp-global-route" });
2575
+ const patchResult = await patchAmpClientConfigFiles(patchPlan);
2576
+ addLog("success", "AMP global routing enabled.", patchPlan.endpointUrl);
2577
+ const snapshot = await broadcastState();
2578
+ sendJson(res, 200, {
2579
+ ...snapshot,
2580
+ message: "AMP now routes via LLM-Router.",
2581
+ amp: {
2582
+ patchResult,
2583
+ bootstrapDefaultRoute: bootstrap.bootstrapRouteRef || "",
2584
+ defaultsBootstrapped: bootstrap.changed === true,
2585
+ upstreamKeyAutoDiscovered: bootstrap.discoveredUpstreamApiKey === true
2586
+ }
2587
+ });
2588
+ return;
2589
+ }
2590
+
2591
+ if (method === "POST" && requestUrl.pathname === "/api/codex-cli/global-route") {
2592
+ const body = await readJsonBody(req);
2593
+ const enabled = body?.enabled !== false;
2594
+
2595
+ if (!enabled) {
2596
+ const unpatchResult = await unpatchCodexCliConfigFile({
2597
+ env: codexCliEnv
2598
+ });
2599
+ addLog("info", "Codex CLI routing disabled.");
2600
+ const snapshot = await broadcastState();
2601
+ sendJson(res, 200, {
2602
+ ...snapshot,
2603
+ message: "Codex CLI now routes directly.",
2604
+ codingTools: {
2605
+ ...(snapshot.codingTools || {}),
2606
+ codexCli: {
2607
+ ...(snapshot.codingTools?.codexCli || {}),
2608
+ unpatchResult
2609
+ }
2610
+ }
2611
+ });
2612
+ return;
2613
+ }
2614
+
2615
+ let parsed;
2616
+ try {
2617
+ if (body?.config && typeof body.config === "object" && !Array.isArray(body.config)) {
2618
+ parsed = body.config;
2619
+ } else {
2620
+ const rawText = String(body?.rawText || "");
2621
+ parsed = rawText.trim() ? JSON.parse(rawText) : {};
2622
+ }
2623
+ } catch (error) {
2624
+ sendJson(res, 400, {
2625
+ error: `Config JSON parse failed: ${error instanceof Error ? error.message : String(error)}`
2626
+ });
2627
+ return;
2628
+ }
2629
+
2630
+ const nextConfig = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2631
+ const endpointUrl = String(body?.endpointUrl || buildAmpClientEndpointUrl(getConfigLocalServer({
2632
+ normalizedConfig: nextConfig,
2633
+ parseError: ""
2634
+ }))).trim();
2635
+ const apiKey = String(body?.apiKey || nextConfig?.masterKey || "").trim();
2636
+ if (!endpointUrl || !apiKey) {
2637
+ sendJson(res, 400, { error: "Codex CLI routing needs a valid local router URL and gateway key." });
2638
+ return;
2639
+ }
2640
+
2641
+ const bindings = normalizeCodexBindingsInput(body?.bindings, nextConfig);
2642
+ const patchResult = await patchCodexCliConfigFile({
2643
+ endpointUrl,
2644
+ apiKey,
2645
+ bindings,
2646
+ modelCatalog: buildCodexCliModelCatalog(nextConfig, bindings),
2647
+ captureBackup: true,
2648
+ env: codexCliEnv
2649
+ });
2650
+ addLog("success", "Codex CLI routing enabled.", patchResult.baseUrl);
2651
+ const snapshot = await broadcastState();
2652
+ sendJson(res, 200, {
2653
+ ...snapshot,
2654
+ message: "Codex CLI now routes via LLM-Router.",
2655
+ codingTools: {
2656
+ ...(snapshot.codingTools || {}),
2657
+ codexCli: {
2658
+ ...(snapshot.codingTools?.codexCli || {}),
2659
+ patchResult
2660
+ }
2661
+ }
2662
+ });
2663
+ return;
2664
+ }
2665
+
2666
+ if (method === "POST" && requestUrl.pathname === "/api/codex-cli/model-bindings") {
2667
+ const body = await readJsonBody(req);
2668
+ const configState = await readConfigState(configPath);
2669
+ const configLocalServer = getConfigLocalServer(configState);
2670
+ const endpointUrl = buildAmpClientEndpointUrl(configLocalServer);
2671
+ const apiKey = String(configState.normalizedConfig?.masterKey || "").trim();
2672
+ if (!endpointUrl || !apiKey) {
2673
+ sendJson(res, 400, { error: "Codex CLI bindings need a running local router URL and gateway key." });
2674
+ return;
2675
+ }
2676
+
2677
+ const routingState = await readCodexCliGlobalRoutingState(configLocalServer, configState.normalizedConfig);
2678
+ if (routingState.error) {
2679
+ sendJson(res, 400, { error: routingState.error });
2680
+ return;
2681
+ }
2682
+ if (!routingState.routedViaRouter) {
2683
+ sendJson(res, 400, { error: "Connect Codex CLI to LLM-Router before updating model bindings." });
2684
+ return;
2685
+ }
2686
+
2687
+ const bindings = normalizeCodexBindingsInput(body?.bindings, configState.normalizedConfig);
2688
+ const patchResult = await patchCodexCliConfigFile({
2689
+ endpointUrl,
2690
+ apiKey,
2691
+ bindings,
2692
+ modelCatalog: buildCodexCliModelCatalog(configState.normalizedConfig, bindings),
2693
+ captureBackup: false,
2694
+ env: codexCliEnv
2695
+ });
2696
+ addLog("success", "Codex CLI model binding updated.", patchResult.bindings.defaultModel || "No model selected");
2697
+ const snapshot = await broadcastState();
2698
+ sendJson(res, 200, {
2699
+ ...snapshot,
2700
+ message: "Codex CLI model bindings updated."
2701
+ });
2702
+ return;
2703
+ }
2704
+
2705
+ if (method === "POST" && requestUrl.pathname === "/api/claude-code/global-route") {
2706
+ const body = await readJsonBody(req);
2707
+ const enabled = body?.enabled !== false;
2708
+
2709
+ if (!enabled) {
2710
+ const unpatchResult = await unpatchClaudeCodeSettingsFile({
2711
+ env: claudeCodeEnv
2712
+ });
2713
+ addLog("info", "Claude Code routing disabled.");
2714
+ const snapshot = await broadcastState();
2715
+ sendJson(res, 200, {
2716
+ ...snapshot,
2717
+ message: "Claude Code now routes directly.",
2718
+ codingTools: {
2719
+ ...(snapshot.codingTools || {}),
2720
+ claudeCode: {
2721
+ ...(snapshot.codingTools?.claudeCode || {}),
2722
+ unpatchResult
2723
+ }
2724
+ }
2725
+ });
2726
+ return;
2727
+ }
2728
+
2729
+ let parsed;
2730
+ try {
2731
+ if (body?.config && typeof body.config === "object" && !Array.isArray(body.config)) {
2732
+ parsed = body.config;
2733
+ } else {
2734
+ const rawText = String(body?.rawText || "");
2735
+ parsed = rawText.trim() ? JSON.parse(rawText) : {};
2736
+ }
2737
+ } catch (error) {
2738
+ sendJson(res, 400, {
2739
+ error: `Config JSON parse failed: ${error instanceof Error ? error.message : String(error)}`
2740
+ });
2741
+ return;
2742
+ }
2743
+
2744
+ const nextConfig = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2745
+ const endpointUrl = String(body?.endpointUrl || buildAmpClientEndpointUrl(getConfigLocalServer({
2746
+ normalizedConfig: nextConfig,
2747
+ parseError: ""
2748
+ }))).trim();
2749
+ const apiKey = String(body?.apiKey || nextConfig?.masterKey || "").trim();
2750
+ if (!endpointUrl || !apiKey) {
2751
+ sendJson(res, 400, { error: "Claude Code routing needs a valid local router URL and gateway key." });
2752
+ return;
2753
+ }
2754
+
2755
+ const bindings = normalizeClaudeBindingsInput(body?.bindings, nextConfig);
2756
+ const patchResult = await patchClaudeCodeSettingsFile({
2757
+ endpointUrl,
2758
+ apiKey,
2759
+ bindings,
2760
+ captureBackup: true,
2761
+ env: claudeCodeEnv
2762
+ });
2763
+ addLog("success", "Claude Code routing enabled.", patchResult.baseUrl);
2764
+ const snapshot = await broadcastState();
2765
+ sendJson(res, 200, {
2766
+ ...snapshot,
2767
+ message: "Claude Code now routes via LLM-Router.",
2768
+ codingTools: {
2769
+ ...(snapshot.codingTools || {}),
2770
+ claudeCode: {
2771
+ ...(snapshot.codingTools?.claudeCode || {}),
2772
+ patchResult
2773
+ }
2774
+ }
2775
+ });
2776
+ return;
2777
+ }
2778
+
2779
+ if (method === "POST" && requestUrl.pathname === "/api/claude-code/model-bindings") {
2780
+ const body = await readJsonBody(req);
2781
+ const configState = await readConfigState(configPath);
2782
+ const configLocalServer = getConfigLocalServer(configState);
2783
+ const endpointUrl = buildAmpClientEndpointUrl(configLocalServer);
2784
+ const apiKey = String(configState.normalizedConfig?.masterKey || "").trim();
2785
+ if (!endpointUrl || !apiKey) {
2786
+ sendJson(res, 400, { error: "Claude Code bindings need a running local router URL and gateway key." });
2787
+ return;
2788
+ }
2789
+
2790
+ const routingState = await readClaudeCodeGlobalRoutingState(configLocalServer, configState.normalizedConfig);
2791
+ if (routingState.error) {
2792
+ sendJson(res, 400, { error: routingState.error });
2793
+ return;
2794
+ }
2795
+ if (!routingState.routedViaRouter) {
2796
+ sendJson(res, 400, { error: "Connect Claude Code to LLM-Router before updating model bindings." });
2797
+ return;
2798
+ }
2799
+
2800
+ const patchResult = await patchClaudeCodeSettingsFile({
2801
+ endpointUrl,
2802
+ apiKey,
2803
+ bindings: normalizeClaudeBindingsInput(body?.bindings, configState.normalizedConfig),
2804
+ captureBackup: false,
2805
+ env: claudeCodeEnv
2806
+ });
2807
+ addLog("success", "Claude Code model bindings updated.", patchResult.bindings.primaryModel || "Default");
2808
+ const snapshot = await broadcastState();
2809
+ sendJson(res, 200, {
2810
+ ...snapshot,
2811
+ message: "Claude Code model bindings updated."
2812
+ });
2813
+ return;
2814
+ }
2815
+
2816
+ if (method === "POST" && requestUrl.pathname === "/api/config/open") {
2817
+ const body = await readJsonBody(req);
2818
+ const editorId = String(body?.editorId || "default").trim() || "default";
2819
+ await openConfigInEditorFn(editorId, configPath);
2820
+ addLog("info", `Opened config file in ${editorId}.`);
2821
+ sendJson(res, 200, { ok: true, editorId });
2822
+ return;
2823
+ }
2824
+
2825
+ if (method === "POST" && requestUrl.pathname === "/api/amp/config/open") {
2826
+ const body = await readJsonBody(req);
2827
+ const editorId = String(body?.editorId || "default").trim() || "default";
2828
+ const settingsTarget = await resolvePreferredAmpSettingsTarget();
2829
+ const secretsFilePath = resolveAmpClientSecretsFilePath({
2830
+ env: ampClientEnv
2831
+ });
2832
+ await ensureJsonObjectFileExists(settingsTarget.settingsFilePath, {});
2833
+ await ensureJsonObjectFileExists(secretsFilePath, {});
2834
+ await openFileInEditorFn(editorId, settingsTarget.settingsFilePath);
2835
+ addLog("info", `Opened AMP config file in ${editorId}.`, settingsTarget.settingsFilePath);
2836
+ sendJson(res, 200, {
2837
+ ok: true,
2838
+ editorId,
2839
+ filePath: settingsTarget.settingsFilePath,
2840
+ secretsFilePath,
2841
+ scope: settingsTarget.scope
2842
+ });
2843
+ return;
2844
+ }
2845
+
2846
+ if (method === "POST" && requestUrl.pathname === "/api/codex-cli/config/open") {
2847
+ const body = await readJsonBody(req);
2848
+ const editorId = String(body?.editorId || "default").trim() || "default";
2849
+ const ensured = await ensureCodexCliConfigFileExists({
2850
+ env: codexCliEnv
2851
+ });
2852
+ await openFileInEditorFn(editorId, ensured.configFilePath);
2853
+ addLog("info", `Opened Codex CLI config file in ${editorId}.`, ensured.configFilePath);
2854
+ sendJson(res, 200, {
2855
+ ok: true,
2856
+ editorId,
2857
+ filePath: ensured.configFilePath,
2858
+ backupFilePath: ensured.backupFilePath
2859
+ });
2860
+ return;
2861
+ }
2862
+
2863
+ if (method === "POST" && requestUrl.pathname === "/api/claude-code/config/open") {
2864
+ const body = await readJsonBody(req);
2865
+ const editorId = String(body?.editorId || "default").trim() || "default";
2866
+ const ensured = await ensureClaudeCodeSettingsFileExists({
2867
+ env: claudeCodeEnv
2868
+ });
2869
+ await openFileInEditorFn(editorId, ensured.settingsFilePath);
2870
+ addLog("info", `Opened Claude Code config file in ${editorId}.`, ensured.settingsFilePath);
2871
+ sendJson(res, 200, {
2872
+ ok: true,
2873
+ editorId,
2874
+ filePath: ensured.settingsFilePath,
2875
+ backupFilePath: ensured.backupFilePath
2876
+ });
2877
+ return;
2878
+ }
2879
+
2880
+ if (method === "POST" && requestUrl.pathname === "/api/router/start") {
2881
+ const body = await readJsonBody(req);
2882
+ const { message, snapshot } = await startManagedRouter(body);
2883
+ await broadcastState();
2884
+ sendJson(res, 200, { ...snapshot, message });
2885
+ return;
2886
+ }
2887
+
2888
+ if (method === "POST" && requestUrl.pathname === "/api/router/restart") {
2889
+ const body = await readJsonBody(req);
2890
+ const { message, snapshot } = await startManagedRouter(body, { restart: true });
2891
+ await broadcastState();
2892
+ sendJson(res, 200, { ...snapshot, message });
2893
+ return;
2894
+ }
2895
+
2896
+ if (method === "POST" && requestUrl.pathname === "/api/router/stop") {
2897
+ const body = await readJsonBody(req);
2898
+ const configState = await readConfigState(configPath);
2899
+ const stopSettings = resolveRouterOptions(getConfigLocalServer(configState), body);
2900
+ await stopManagedRouter({
2901
+ settings: stopSettings,
2902
+ reclaimPortIfStopped: body?.reclaimPort === true
2903
+ });
2904
+ const snapshot = await broadcastState();
2905
+ sendJson(res, 200, { ...snapshot, message: "Router stopped." });
2906
+ return;
2907
+ }
2908
+
2909
+ if (method === "POST" && requestUrl.pathname === "/api/router/reclaim") {
2910
+ const body = await readJsonBody(req);
2911
+ const configState = await readConfigState(configPath);
2912
+ const reclaimSettings = resolveRouterOptions(getConfigLocalServer(configState), body);
2913
+ const reclaimed = await reclaimRouterPortIfNeeded(reclaimSettings, {
2914
+ reason: "Reclaiming router port from the web console."
2915
+ });
2916
+ if (!reclaimed.ok) {
2917
+ const reclaimError = new Error(reclaimed.errorMessage || `Failed reclaiming port ${reclaimSettings.port}.`);
2918
+ reclaimError.statusCode = 409;
2919
+ throw reclaimError;
2920
+ }
2921
+ const snapshot = await broadcastState();
2922
+ sendJson(res, 200, {
2923
+ ...snapshot,
2924
+ message: reclaimed.attempted
2925
+ ? `Port ${reclaimSettings.port} reclaimed.`
2926
+ : `Port ${reclaimSettings.port} is already free.`
2927
+ });
2928
+ return;
2929
+ }
2930
+
2931
+ if (method === "POST" && requestUrl.pathname === "/api/startup/enable") {
2932
+ const body = await readJsonBody(req);
2933
+ const configState = await readConfigState(configPath);
2934
+ if (configState.parseError) {
2935
+ sendJson(res, 400, { error: `Config JSON must parse before enabling startup: ${configState.parseError}` });
2936
+ return;
2937
+ }
2938
+ if (!configHasProvider(configState.normalizedConfig)) {
2939
+ sendJson(res, 400, { error: "At least one enabled provider is required before enabling startup." });
2940
+ return;
2941
+ }
2942
+
2943
+ const externalRuntime = await readExternalRuntime();
2944
+ if (externalRuntime) {
2945
+ await stopExternalRuntime(externalRuntime, {
2946
+ reason: "Stopped another llm-router instance before enabling startup."
2947
+ });
2948
+ }
2949
+
2950
+ let startupOptions = resolveRouterOptions(getConfigLocalServer(configState), body);
2951
+ if (startupOptions.requireAuth && !configState.normalizedConfig.masterKey) {
2952
+ sendJson(res, 400, { error: "masterKey is required when enabling startup with auth." });
2953
+ return;
2954
+ }
2955
+
2956
+ const persisted = await persistLocalServerConfig(startupOptions);
2957
+ startupOptions = persisted.savedSettings;
2958
+
2959
+ const activeRuntime = await readActiveRuntime();
2960
+ if (activeRuntime) {
2961
+ await stopManagedRouter({ reason: "Stopped managed router before enabling startup." });
2962
+ }
2963
+
2964
+ const webConsoleConflict = getWebConsoleConflictMessage(startupOptions);
2965
+ if (webConsoleConflict) {
2966
+ const conflictError = new Error(webConsoleConflict);
2967
+ conflictError.statusCode = 409;
2968
+ throw conflictError;
2969
+ }
2970
+
2971
+ const detail = await installStartupFn({
2972
+ configPath,
2973
+ host: startupOptions.host,
2974
+ port: startupOptions.port,
2975
+ watchConfig: startupOptions.watchConfig,
2976
+ watchBinary: startupOptions.watchBinary,
2977
+ requireAuth: startupOptions.requireAuth,
2978
+ cliPath: resolvedRouterCliPath
2979
+ });
2980
+ await syncAmpGlobalRoutingIfNeeded({
2981
+ previousConfig: persisted.previousConfig,
2982
+ nextConfig: persisted.savedConfig,
2983
+ previousSettings: persisted.previousSettings,
2984
+ nextSettings: persisted.savedSettings
2985
+ });
2986
+ syncRouterDefaults(startupOptions);
2987
+ addLog("success", "Startup enabled.", formatStartupDetail({ ...detail, manager: detail.manager || "startup", installed: true, running: true }));
2988
+ const snapshot = await broadcastState();
2989
+ sendJson(res, 200, { ...snapshot, message: "Startup enabled." });
2990
+ return;
2991
+ }
2992
+
2993
+ if (method === "POST" && requestUrl.pathname === "/api/startup/disable") {
2994
+ await readJsonBody(req);
2995
+ const statusBefore = await startupStatusFn().catch(() => null);
2996
+ if (!statusBefore?.installed) {
2997
+ addLog("info", "Startup already disabled.");
2998
+ const snapshot = await broadcastState();
2999
+ sendJson(res, 200, { ...snapshot, message: "Startup already disabled." });
3000
+ return;
3001
+ }
3002
+
3003
+ try {
3004
+ await uninstallStartupFn();
3005
+ } catch (startupError) {
3006
+ const message = startupError instanceof Error ? startupError.message : String(startupError);
3007
+ if (!isMissingStartupServiceText(message)) {
3008
+ throw startupError;
3009
+ }
3010
+ }
3011
+
3012
+ addLog("info", "Startup disabled.", formatStartupDetail({ ...statusBefore, installed: false, running: false }));
3013
+ const snapshot = await broadcastState();
3014
+ sendJson(res, 200, { ...snapshot, message: "Startup disabled." });
3015
+ return;
3016
+ }
3017
+
3018
+ if (method === "POST" && requestUrl.pathname === "/api/subscription/login") {
3019
+ const body = await readJsonBody(req);
3020
+ const requestedProfileId = String(body?.profileId || "").trim();
3021
+ const fallbackProfileId = String(body?.providerId || "default").trim() || "default";
3022
+ const profileId = requestedProfileId || fallbackProfileId;
3023
+ const subscriptionType = String(body?.subscriptionType || "").trim();
3024
+
3025
+ if (!["chatgpt-codex", "claude-code"].includes(subscriptionType)) {
3026
+ sendJson(res, 400, { error: "Unsupported subscription type." });
3027
+ return;
3028
+ }
3029
+
3030
+ let authUrl = "";
3031
+ let openedBrowser = false;
3032
+ addLog("info", `Opening ${subscriptionType} sign-in for profile '${profileId}'…`);
3033
+ await loginSubscriptionFn(profileId, {
3034
+ subscriptionType,
3035
+ onUrl: (url, meta = {}) => {
3036
+ authUrl = String(url || "");
3037
+ openedBrowser = meta?.openedBrowser === true;
3038
+ addLog(
3039
+ openedBrowser ? "info" : "warn",
3040
+ openedBrowser
3041
+ ? `Opened browser sign-in for profile '${profileId}'.`
3042
+ : `Open the sign-in page manually for profile '${profileId}'.`,
3043
+ authUrl
3044
+ );
3045
+ }
3046
+ });
3047
+
3048
+ addLog("success", `Subscription login completed for profile '${profileId}'.`);
3049
+ const snapshot = await broadcastState();
3050
+ sendJson(res, 200, {
3051
+ ...snapshot,
3052
+ ok: true,
3053
+ authUrl,
3054
+ openedBrowser,
3055
+ message: "Subscription login completed."
3056
+ });
3057
+ return;
3058
+ }
3059
+
3060
+ if (method === "POST" && requestUrl.pathname === "/api/provider/probe") {
3061
+ const body = await readJsonBody(req);
3062
+ const providerId = String(body?.providerId || "").trim();
3063
+ const configState = await readConfigState(configPath);
3064
+ if (configState.parseError) {
3065
+ sendJson(res, 400, { error: `Config JSON must parse before probing providers: ${configState.parseError}` });
3066
+ return;
3067
+ }
3068
+ const provider = (configState.normalizedConfig?.providers || []).find((entry) => entry.id === providerId);
3069
+ if (!provider) {
3070
+ sendJson(res, 404, { error: `Provider '${providerId}' was not found.` });
3071
+ return;
3072
+ }
3073
+ const apiKey = resolveProviderApiKey(provider, process.env);
3074
+ if (!apiKey) {
3075
+ sendJson(res, 400, { error: `Provider '${providerId}' does not have an API key configured for probing.` });
3076
+ return;
3077
+ }
3078
+ if (!provider.baseUrl && !provider.baseUrlByFormat) {
3079
+ sendJson(res, 400, { error: `Provider '${providerId}' does not have a probeable endpoint configured.` });
3080
+ return;
3081
+ }
3082
+
3083
+ addLog("info", `Probing provider ${providerId}…`);
3084
+ const result = await probeProvider({
3085
+ baseUrl: provider.baseUrl,
3086
+ baseUrlByFormat: provider.baseUrlByFormat,
3087
+ apiKey,
3088
+ headers: provider.headers,
3089
+ timeoutMs: 8000
3090
+ });
3091
+ addLog(result.ok ? "success" : "warn", `Probe finished for ${providerId}.`, result.workingFormats?.join(", ") || "No working formats detected.");
3092
+ sendJson(res, 200, { result });
3093
+ return;
3094
+ }
3095
+
3096
+ if (method === "POST" && requestUrl.pathname === "/api/exit") {
3097
+ sendJson(res, 200, { ok: true, message: "Closing web console." });
3098
+ setTimeout(() => {
3099
+ void shutdown("user-exit");
3100
+ }, 25);
3101
+ return;
3102
+ }
3103
+
3104
+ sendJson(res, 404, { error: "Not found." });
3105
+ } catch (error) {
3106
+ const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 500;
3107
+ sendJson(res, statusCode, {
3108
+ error: error instanceof Error ? error.message : String(error)
3109
+ });
3110
+ }
3111
+ });
3112
+
3113
+ await new Promise((resolve, reject) => {
3114
+ webServer.once("error", reject);
3115
+ webServer.listen(port, host, () => {
3116
+ webServer.off("error", reject);
3117
+ const address = webServer.address();
3118
+ if (typeof address === "object" && address) {
3119
+ actualWebPort = Number(address.port);
3120
+ }
3121
+ resolve();
3122
+ });
3123
+ });
3124
+
3125
+ addLog("info", `Web console listening on http://${formatHostForUrl(host, actualWebPort)}`);
3126
+ if (devMode) addLog("info", "Development mode enabled for web assets.");
3127
+ startConfigWatcher();
3128
+
3129
+ try {
3130
+ await reconcileManagedRouterWithConfig({ reason: "web-console-startup" });
3131
+ } catch (reconcileError) {
3132
+ addLog("warn", "Managed router auto-start skipped.", reconcileError instanceof Error ? reconcileError.message : String(reconcileError));
3133
+ }
3134
+
3135
+ return {
3136
+ host,
3137
+ port: actualWebPort,
3138
+ url: `http://${formatHostForUrl(host, actualWebPort)}`,
3139
+ done,
3140
+ close: (reason) => shutdown(reason),
3141
+ getSnapshot: buildSnapshot,
3142
+ startRouter: (body) => startManagedRouter(body),
3143
+ restartRouter: (body) => startManagedRouter(body, { restart: true }),
3144
+ stopRouter: (reason) => stopManagedRouter(typeof reason === "string" ? { reason } : reason)
3145
+ };
3146
+ }