@khanglvm/llm-router 1.3.1 → 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +337 -41
- package/package.json +19 -3
- package/src/cli/router-module.js +7331 -3805
- package/src/cli/wrangler-toml.js +1 -1
- package/src/cli-entry.js +162 -24
- package/src/node/amp-client-config.js +426 -0
- package/src/node/coding-tool-config.js +763 -0
- package/src/node/config-store.js +49 -18
- package/src/node/instance-state.js +213 -12
- package/src/node/listen-port.js +5 -37
- package/src/node/local-server-settings.js +122 -0
- package/src/node/local-server.js +3 -2
- package/src/node/provider-probe.js +13 -0
- package/src/node/start-command.js +282 -40
- package/src/node/startup-manager.js +64 -29
- package/src/node/web-command.js +106 -0
- package/src/node/web-console-assets.js +26 -0
- package/src/node/web-console-client.js +56 -0
- package/src/node/web-console-dev-assets.js +258 -0
- package/src/node/web-console-server.js +3146 -0
- package/src/node/web-console-styles.generated.js +1 -0
- package/src/node/web-console-ui/config-editor-utils.js +616 -0
- package/src/node/web-console-ui/lib/utils.js +6 -0
- package/src/node/web-console-ui/rate-limit-utils.js +144 -0
- package/src/node/web-console-ui/select-search-utils.js +36 -0
- package/src/runtime/codex-request-transformer.js +46 -5
- package/src/runtime/codex-response-transformer.js +268 -35
- package/src/runtime/config.js +1394 -35
- package/src/runtime/handler/amp-gemini.js +913 -0
- package/src/runtime/handler/amp-response.js +308 -0
- package/src/runtime/handler/amp.js +290 -0
- package/src/runtime/handler/auth.js +17 -2
- package/src/runtime/handler/provider-call.js +168 -50
- package/src/runtime/handler/provider-translation.js +937 -26
- package/src/runtime/handler/request.js +149 -6
- package/src/runtime/handler/route-debug.js +22 -1
- package/src/runtime/handler.js +449 -9
- package/src/runtime/subscription-auth.js +1 -6
- package/src/shared/local-router-defaults.js +62 -0
- package/src/translator/index.js +3 -1
- package/src/translator/request/openai-to-claude.js +217 -6
- package/src/translator/response/openai-to-claude.js +206 -58
|
@@ -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
|
+
}
|