@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
package/src/cli/wrangler-toml.js
CHANGED
|
@@ -319,6 +319,6 @@ export function buildCloudflareDnsManualGuide({
|
|
|
319
319
|
"- Proxy status must be ON (orange cloud / proxied)",
|
|
320
320
|
host ? `- Verify DNS: dig +short ${host} @1.1.1.1` : "- Verify DNS: dig +short <host> @1.1.1.1",
|
|
321
321
|
host ? `- Verify HTTP: curl -I https://${host}/anthropic` : "- Verify HTTP: curl -I https://<host>/anthropic",
|
|
322
|
-
"- Claude base URL must NOT include
|
|
322
|
+
"- Claude base URL must NOT include a local router port for Cloudflare Worker deployments"
|
|
323
323
|
].join("\n");
|
|
324
324
|
}
|
package/src/cli-entry.js
CHANGED
|
@@ -5,8 +5,10 @@ import { realpathSync } from "node:fs";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { getDefaultConfigPath } from "./node/config-store.js";
|
|
8
|
+
import { FIXED_LOCAL_ROUTER_HOST, FIXED_LOCAL_ROUTER_PORT } from "./node/local-server-settings.js";
|
|
8
9
|
import { resolveListenPort } from "./node/listen-port.js";
|
|
9
10
|
import { runStartCommand } from "./node/start-command.js";
|
|
11
|
+
import { runWebCommand } from "./node/web-command.js";
|
|
10
12
|
|
|
11
13
|
function parseSimpleArgs(argv) {
|
|
12
14
|
const positional = [];
|
|
@@ -48,11 +50,88 @@ function parseBoolean(value, fallback = true) {
|
|
|
48
50
|
if (value === undefined || value === null || value === "") return fallback;
|
|
49
51
|
if (typeof value === "boolean") return value;
|
|
50
52
|
const normalized = String(value).trim().toLowerCase();
|
|
51
|
-
if (["1", "true", "yes", "y"].includes(normalized)) return true;
|
|
52
|
-
if (["0", "false", "no", "n"].includes(normalized)) return false;
|
|
53
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
|
|
54
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
|
|
53
55
|
return fallback;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
function isBooleanLikeToken(value) {
|
|
59
|
+
if (value === undefined || value === null) return false;
|
|
60
|
+
return /^(?:1|0|true|false|yes|no|y|n|on|off)$/i.test(String(value).trim());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRawFlagValue(argv, flagName) {
|
|
64
|
+
const prefix = `--${flagName}`;
|
|
65
|
+
|
|
66
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
67
|
+
const token = argv[index];
|
|
68
|
+
if (token === prefix) {
|
|
69
|
+
const next = argv[index + 1];
|
|
70
|
+
if (isBooleanLikeToken(next)) {
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (token.startsWith(`${prefix}=`)) {
|
|
76
|
+
return token.slice(prefix.length + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stripFlagFromArgv(argv, flagName) {
|
|
84
|
+
const prefix = `--${flagName}`;
|
|
85
|
+
const nextArgv = [];
|
|
86
|
+
|
|
87
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
88
|
+
const token = argv[index];
|
|
89
|
+
if (token === prefix) {
|
|
90
|
+
const next = argv[index + 1];
|
|
91
|
+
if (isBooleanLikeToken(next)) {
|
|
92
|
+
index += 1;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (token.startsWith(`${prefix}=`)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
nextArgv.push(token);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return nextArgv;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hasExplicitConfigOperation(args = {}) {
|
|
106
|
+
return Boolean(String(args.operation || args.op || "").trim());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildWebCommandOptions(args = {}) {
|
|
110
|
+
return {
|
|
111
|
+
configPath: args.config || args.configPath || getDefaultConfigPath(),
|
|
112
|
+
host: args.host || "127.0.0.1",
|
|
113
|
+
port: args.port,
|
|
114
|
+
open: parseBoolean(args.open, true),
|
|
115
|
+
routerHost: FIXED_LOCAL_ROUTER_HOST,
|
|
116
|
+
routerPort: FIXED_LOCAL_ROUTER_PORT,
|
|
117
|
+
routerWatchConfig: parseBoolean(args["router-watch-config"] ?? args.routerWatchConfig, true),
|
|
118
|
+
routerWatchBinary: parseBoolean(args["router-watch-binary"] ?? args.routerWatchBinary, true),
|
|
119
|
+
routerRequireAuth: parseBoolean(args["router-require-auth"] ?? args.routerRequireAuth, false),
|
|
120
|
+
allowRemoteClients: parseBoolean(args["allow-remote-clients"] ?? args.allowRemoteClients, false),
|
|
121
|
+
cliPathForRouter: String(process.env.LLM_ROUTER_CLI_PATH || process.argv[1] || "").trim()
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runWebFastPath(args, runWebCommandImpl = runWebCommand) {
|
|
126
|
+
const result = await runWebCommandImpl(buildWebCommandOptions(args));
|
|
127
|
+
|
|
128
|
+
if (!result.ok && result.errorMessage) {
|
|
129
|
+
console.error(result.errorMessage);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result.exitCode ?? (result.ok ? 0 : 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
56
135
|
async function promptStartupConflictResolution({ port }) {
|
|
57
136
|
if (!(process.stdout.isTTY && process.stdin.isTTY)) return "";
|
|
58
137
|
|
|
@@ -63,11 +142,11 @@ async function promptStartupConflictResolution({ port }) {
|
|
|
63
142
|
|
|
64
143
|
const lines = [
|
|
65
144
|
"",
|
|
66
|
-
`
|
|
67
|
-
"Choose
|
|
68
|
-
"1. Restart
|
|
69
|
-
"2.
|
|
70
|
-
"3.
|
|
145
|
+
`Port ${port} is already used by the llm-router startup service.`,
|
|
146
|
+
"Choose an action:",
|
|
147
|
+
"1. Restart service",
|
|
148
|
+
"2. Run here instead",
|
|
149
|
+
"3. Cancel"
|
|
71
150
|
];
|
|
72
151
|
console.log(lines.join("\n"));
|
|
73
152
|
|
|
@@ -87,7 +166,7 @@ async function promptStartupConflictResolution({ port }) {
|
|
|
87
166
|
async function runStartFastPath(args) {
|
|
88
167
|
const result = await runStartCommand({
|
|
89
168
|
configPath: args.config || args.configPath || getDefaultConfigPath(),
|
|
90
|
-
host:
|
|
169
|
+
host: FIXED_LOCAL_ROUTER_HOST,
|
|
91
170
|
port: resolveListenPort({ explicitPort: args.port }),
|
|
92
171
|
watchConfig: parseBoolean(args["watch-config"] ?? args.watchConfig, true),
|
|
93
172
|
watchBinary: parseBoolean(args["watch-binary"] ?? args.watchBinary, true),
|
|
@@ -105,6 +184,34 @@ async function runStartFastPath(args) {
|
|
|
105
184
|
return result.exitCode ?? (result.ok ? 0 : 1);
|
|
106
185
|
}
|
|
107
186
|
|
|
187
|
+
export function shouldExitTuiOnKeypress(character, key = {}) {
|
|
188
|
+
const sequence = typeof key?.sequence === "string" && key.sequence
|
|
189
|
+
? key.sequence
|
|
190
|
+
: (typeof character === "string" ? character : "");
|
|
191
|
+
return sequence === "" || (key?.ctrl === true && String(key?.name || "").toLowerCase() === "c");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function installTuiSigintExitHandler({
|
|
195
|
+
input = process.stdin,
|
|
196
|
+
isTTY = Boolean(process.stdout.isTTY && process.stdin?.isTTY),
|
|
197
|
+
exit = (code) => process.exit(code)
|
|
198
|
+
} = {}) {
|
|
199
|
+
if (!isTTY || !input || typeof input.on !== "function" || typeof input.off !== "function") {
|
|
200
|
+
return () => {};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const onKeypress = (character, key) => {
|
|
204
|
+
if (shouldExitTuiOnKeypress(character, key)) {
|
|
205
|
+
exit(130);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
input.on("keypress", onKeypress);
|
|
210
|
+
return () => {
|
|
211
|
+
input.off("keypress", onKeypress);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
108
215
|
async function runSnapCli(argv, isTTY) {
|
|
109
216
|
const [{ createRegistry, runSingleModuleCli }, { default: routerModule }] = await Promise.all([
|
|
110
217
|
import("@levu/snap/dist/index.js"),
|
|
@@ -112,37 +219,68 @@ async function runSnapCli(argv, isTTY) {
|
|
|
112
219
|
]);
|
|
113
220
|
|
|
114
221
|
const registry = createRegistry([routerModule]);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
222
|
+
const disposeSigintHandler = installTuiSigintExitHandler({ isTTY });
|
|
223
|
+
try {
|
|
224
|
+
return await runSingleModuleCli({
|
|
225
|
+
registry,
|
|
226
|
+
argv,
|
|
227
|
+
moduleId: "router",
|
|
228
|
+
defaultActionId: "config",
|
|
229
|
+
helpDefaultTarget: "module",
|
|
230
|
+
isTTY
|
|
231
|
+
});
|
|
232
|
+
} finally {
|
|
233
|
+
disposeSigintHandler();
|
|
234
|
+
}
|
|
123
235
|
}
|
|
124
236
|
|
|
125
|
-
export async function runCli(argv = process.argv.slice(2), isTTY = undefined) {
|
|
126
|
-
const
|
|
237
|
+
export async function runCli(argv = process.argv.slice(2), isTTY = undefined, overrides = {}) {
|
|
238
|
+
const runSnapCliImpl = overrides.runSnapCli || runSnapCli;
|
|
239
|
+
const runStartFastPathImpl = overrides.runStartFastPath || runStartFastPath;
|
|
240
|
+
const runWebCommandImpl = overrides.runWebCommand || runWebCommand;
|
|
241
|
+
const tuiRequested = parseBoolean(readRawFlagValue(argv, "tui"), false);
|
|
242
|
+
const normalizedArgv = stripFlagFromArgv(argv, "tui");
|
|
243
|
+
const parsed = parseSimpleArgs(normalizedArgv);
|
|
127
244
|
const first = parsed.positional[0];
|
|
128
245
|
const firstIsStart = first === "start";
|
|
246
|
+
const firstIsWeb = first === "web";
|
|
247
|
+
const firstIsConfig = first === "config";
|
|
248
|
+
const explicitConfigOperation = hasExplicitConfigOperation(parsed.args);
|
|
129
249
|
|
|
130
|
-
// Bare invocation opens the
|
|
250
|
+
// Bare invocation opens the browser-based console by default.
|
|
131
251
|
if (!first && !parsed.wantsHelp) {
|
|
132
|
-
|
|
252
|
+
if (tuiRequested || explicitConfigOperation) {
|
|
253
|
+
return runSnapCliImpl(["config", ...normalizedArgv], isTTY);
|
|
254
|
+
}
|
|
255
|
+
return runWebFastPath(parsed.args, runWebCommandImpl);
|
|
133
256
|
}
|
|
134
257
|
|
|
135
258
|
// Fast-path explicit local start without loading Snap to minimize startup overhead.
|
|
136
259
|
if (firstIsStart && !parsed.wantsHelp) {
|
|
137
|
-
const startArgs =
|
|
260
|
+
const startArgs = normalizedArgv.slice(1);
|
|
138
261
|
const parsedStart = parseSimpleArgs(startArgs);
|
|
139
|
-
return
|
|
262
|
+
return runStartFastPathImpl(parsedStart.args);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (firstIsWeb && !parsed.wantsHelp) {
|
|
266
|
+
const webArgs = normalizedArgv.slice(1);
|
|
267
|
+
const parsedWeb = parseSimpleArgs(webArgs);
|
|
268
|
+
return runWebFastPath(parsedWeb.args, runWebCommandImpl);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (firstIsConfig && !parsed.wantsHelp && !explicitConfigOperation) {
|
|
272
|
+
if (tuiRequested) {
|
|
273
|
+
return runSnapCliImpl(normalizedArgv, isTTY);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const configArgs = parseSimpleArgs(normalizedArgv.slice(1));
|
|
277
|
+
return runWebFastPath(configArgs.args, runWebCommandImpl);
|
|
140
278
|
}
|
|
141
279
|
|
|
142
|
-
const normalized = [...
|
|
280
|
+
const normalized = [...normalizedArgv];
|
|
143
281
|
if (normalized[0] === "help") normalized[0] = "--help";
|
|
144
282
|
if (normalized[0] === "setup") normalized[0] = "config";
|
|
145
|
-
return
|
|
283
|
+
return runSnapCliImpl(normalized, isTTY);
|
|
146
284
|
}
|
|
147
285
|
|
|
148
286
|
function resolveExecutablePath(filePath) {
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
|
|
5
|
+
export function normalizeAmpClientSettingsScope(value) {
|
|
6
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
7
|
+
if (normalized === "workspace") return "workspace";
|
|
8
|
+
if (normalized === "global") return "global";
|
|
9
|
+
return "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeAmpClientProxyUrl(value) {
|
|
13
|
+
const text = String(value || "").trim();
|
|
14
|
+
if (!text) return "";
|
|
15
|
+
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = new URL(text);
|
|
19
|
+
} catch {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ([
|
|
28
|
+
"localhost",
|
|
29
|
+
"0.0.0.0",
|
|
30
|
+
"::",
|
|
31
|
+
"[::]",
|
|
32
|
+
"::0",
|
|
33
|
+
"[::0]",
|
|
34
|
+
"::1",
|
|
35
|
+
"[::1]"
|
|
36
|
+
].includes(parsed.hostname.toLowerCase())) {
|
|
37
|
+
parsed.hostname = "127.0.0.1";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
parsed.username = "";
|
|
41
|
+
parsed.password = "";
|
|
42
|
+
parsed.hash = "";
|
|
43
|
+
parsed.search = "";
|
|
44
|
+
|
|
45
|
+
const normalizedPath = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
46
|
+
parsed.pathname = normalizedPath;
|
|
47
|
+
const out = parsed.toString();
|
|
48
|
+
return normalizedPath === "/" && out.endsWith("/") ? out.slice(0, -1) : out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveAmpClientSettingsFilePath({
|
|
52
|
+
scope = "global",
|
|
53
|
+
explicitPath = "",
|
|
54
|
+
cwd = process.cwd(),
|
|
55
|
+
env = process.env,
|
|
56
|
+
homeDir = os.homedir()
|
|
57
|
+
} = {}) {
|
|
58
|
+
const direct = String(explicitPath || "").trim();
|
|
59
|
+
if (direct) return path.resolve(direct);
|
|
60
|
+
|
|
61
|
+
const normalizedScope = normalizeAmpClientSettingsScope(scope) || "global";
|
|
62
|
+
if (normalizedScope === "workspace") {
|
|
63
|
+
return path.resolve(cwd, ".amp", "settings.json");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const envOverride = String(env?.AMP_SETTINGS_FILE || "").trim();
|
|
67
|
+
if (envOverride) return path.resolve(envOverride);
|
|
68
|
+
|
|
69
|
+
const configHome = String(env?.XDG_CONFIG_HOME || "").trim() || path.join(homeDir, ".config");
|
|
70
|
+
return path.join(configHome, "amp", "settings.json");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveAmpClientSecretsFilePath({
|
|
74
|
+
explicitPath = "",
|
|
75
|
+
env = process.env,
|
|
76
|
+
homeDir = os.homedir()
|
|
77
|
+
} = {}) {
|
|
78
|
+
const direct = String(explicitPath || env?.AMP_SECRETS_FILE || "").trim();
|
|
79
|
+
if (direct) return path.resolve(direct);
|
|
80
|
+
|
|
81
|
+
const dataHome = String(env?.XDG_DATA_HOME || "").trim() || path.join(homeDir, ".local", "share");
|
|
82
|
+
return path.join(dataHome, "amp", "secrets.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readJsonObjectFile(filePath, label) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
88
|
+
const parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
89
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
90
|
+
throw new Error(`${label} must contain a JSON object.`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
data: parsed,
|
|
94
|
+
existed: true
|
|
95
|
+
};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
98
|
+
return {
|
|
99
|
+
data: {},
|
|
100
|
+
existed: false
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (error instanceof SyntaxError) {
|
|
104
|
+
throw new Error(`${label} contains invalid JSON.`);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeJsonObjectFile(filePath, data) {
|
|
111
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
112
|
+
await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
113
|
+
await fs.chmod(filePath, 0o600);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function dedupeTrimmedStrings(values = []) {
|
|
117
|
+
return [...new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function findAmpClientApiKeyForUrl(endpointUrl, {
|
|
121
|
+
env = process.env,
|
|
122
|
+
homeDir = os.homedir(),
|
|
123
|
+
explicitSecretsFile = ""
|
|
124
|
+
} = {}) {
|
|
125
|
+
const normalizedUrl = normalizeAmpClientProxyUrl(endpointUrl);
|
|
126
|
+
if (!normalizedUrl) return "";
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const secretsFilePath = resolveAmpClientSecretsFilePath({
|
|
130
|
+
explicitPath: explicitSecretsFile,
|
|
131
|
+
env,
|
|
132
|
+
homeDir
|
|
133
|
+
});
|
|
134
|
+
const secretsState = await readJsonObjectFile(secretsFilePath, `AMP secrets file '${secretsFilePath}'`);
|
|
135
|
+
const candidates = dedupeTrimmedStrings([
|
|
136
|
+
`apiKey@${normalizedUrl}`,
|
|
137
|
+
normalizedUrl.endsWith("/") ? `apiKey@${normalizedUrl.slice(0, -1)}` : `apiKey@${normalizedUrl}/`
|
|
138
|
+
]);
|
|
139
|
+
for (const fieldName of candidates) {
|
|
140
|
+
const value = String(secretsState.data?.[fieldName] || "").trim();
|
|
141
|
+
if (value) return value;
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasConfiguredAmpRouting(amp) {
|
|
150
|
+
const source = amp && typeof amp === "object" && !Array.isArray(amp) ? amp : {};
|
|
151
|
+
if (String(source.defaultRoute || "").trim()) return true;
|
|
152
|
+
if (Array.isArray(source.rawModelRoutes) && source.rawModelRoutes.length > 0) return true;
|
|
153
|
+
if (Array.isArray(source.modelMappings) && source.modelMappings.length > 0) return true;
|
|
154
|
+
if (source.routes && typeof source.routes === "object" && Object.keys(source.routes).length > 0) return true;
|
|
155
|
+
if (source.subagentMappings && typeof source.subagentMappings === "object" && Object.keys(source.subagentMappings).length > 0) return true;
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resolveAmpBootstrapRouteRef(config = {}) {
|
|
160
|
+
const configuredDefault = String(config?.defaultModel || "").trim();
|
|
161
|
+
if (configuredDefault) return configuredDefault;
|
|
162
|
+
|
|
163
|
+
for (const provider of Array.isArray(config?.providers) ? config.providers : []) {
|
|
164
|
+
if (provider?.enabled === false) continue;
|
|
165
|
+
const providerId = String(provider?.id || "").trim();
|
|
166
|
+
if (!providerId) continue;
|
|
167
|
+
const firstModel = Array.isArray(provider?.models)
|
|
168
|
+
? provider.models.find((entry) => String(entry?.id || "").trim())
|
|
169
|
+
: null;
|
|
170
|
+
if (firstModel?.id) return `${providerId}/${firstModel.id}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function applyAmpRecommendedConnectionDefaults({
|
|
177
|
+
amp,
|
|
178
|
+
patchPlan,
|
|
179
|
+
env = process.env,
|
|
180
|
+
homeDir = os.homedir()
|
|
181
|
+
} = {}) {
|
|
182
|
+
const nextAmp = amp && typeof amp === "object" && !Array.isArray(amp)
|
|
183
|
+
? structuredClone(amp)
|
|
184
|
+
: {};
|
|
185
|
+
let changed = false;
|
|
186
|
+
let discoveredUpstreamApiKey = false;
|
|
187
|
+
|
|
188
|
+
if (!String(nextAmp.upstreamUrl || "").trim()) {
|
|
189
|
+
nextAmp.upstreamUrl = "https://ampcode.com";
|
|
190
|
+
changed = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (nextAmp.restrictManagementToLocalhost !== true) {
|
|
194
|
+
nextAmp.restrictManagementToLocalhost = true;
|
|
195
|
+
changed = true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!String(nextAmp.upstreamApiKey || "").trim()) {
|
|
199
|
+
const discoveredUpstreamApiKeyValue = await findAmpClientApiKeyForUrl(nextAmp.upstreamUrl, {
|
|
200
|
+
env,
|
|
201
|
+
homeDir,
|
|
202
|
+
explicitSecretsFile: patchPlan?.secretsFilePath || ""
|
|
203
|
+
});
|
|
204
|
+
if (discoveredUpstreamApiKeyValue) {
|
|
205
|
+
nextAmp.upstreamApiKey = discoveredUpstreamApiKeyValue;
|
|
206
|
+
changed = true;
|
|
207
|
+
discoveredUpstreamApiKey = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!String(nextAmp.preset || "").trim()) {
|
|
212
|
+
nextAmp.preset = "builtin";
|
|
213
|
+
changed = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
amp: nextAmp,
|
|
218
|
+
changed,
|
|
219
|
+
discoveredUpstreamApiKey,
|
|
220
|
+
error: ""
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function maybeBootstrapAmpConfig({
|
|
225
|
+
config,
|
|
226
|
+
amp,
|
|
227
|
+
patchPlan,
|
|
228
|
+
env = process.env,
|
|
229
|
+
homeDir = os.homedir()
|
|
230
|
+
} = {}) {
|
|
231
|
+
const base = await applyAmpRecommendedConnectionDefaults({
|
|
232
|
+
amp,
|
|
233
|
+
patchPlan,
|
|
234
|
+
env,
|
|
235
|
+
homeDir
|
|
236
|
+
});
|
|
237
|
+
const nextAmp = base.amp;
|
|
238
|
+
let changed = base.changed === true;
|
|
239
|
+
const discoveredUpstreamApiKey = base.discoveredUpstreamApiKey === true;
|
|
240
|
+
|
|
241
|
+
if (!hasConfiguredAmpRouting(nextAmp)) {
|
|
242
|
+
const bootstrapRouteRef = resolveAmpBootstrapRouteRef(config);
|
|
243
|
+
if (!bootstrapRouteRef) {
|
|
244
|
+
return {
|
|
245
|
+
amp: nextAmp,
|
|
246
|
+
changed,
|
|
247
|
+
bootstrapRouteRef: "",
|
|
248
|
+
discoveredUpstreamApiKey,
|
|
249
|
+
error: "AMP bootstrap needs defaultModel (or at least one provider model) before wiring AMP. Set defaultModel first or add a provider/model."
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
nextAmp.defaultRoute = bootstrapRouteRef;
|
|
253
|
+
changed = true;
|
|
254
|
+
return {
|
|
255
|
+
amp: nextAmp,
|
|
256
|
+
changed,
|
|
257
|
+
bootstrapRouteRef,
|
|
258
|
+
discoveredUpstreamApiKey,
|
|
259
|
+
error: ""
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
amp: nextAmp,
|
|
265
|
+
changed,
|
|
266
|
+
bootstrapRouteRef: String(nextAmp.defaultRoute || "").trim(),
|
|
267
|
+
discoveredUpstreamApiKey,
|
|
268
|
+
error: ""
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function buildAmpClientPatchPlan({
|
|
273
|
+
scope = "",
|
|
274
|
+
settingsFilePath = "",
|
|
275
|
+
secretsFilePath = "",
|
|
276
|
+
endpointUrl = "",
|
|
277
|
+
apiKey = "",
|
|
278
|
+
cwd = process.cwd(),
|
|
279
|
+
env = process.env,
|
|
280
|
+
homeDir = os.homedir()
|
|
281
|
+
} = {}) {
|
|
282
|
+
const normalizedScope = normalizeAmpClientSettingsScope(scope);
|
|
283
|
+
const normalizedUrl = normalizeAmpClientProxyUrl(endpointUrl);
|
|
284
|
+
if (!normalizedScope || !normalizedUrl) return null;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
scope: normalizedScope,
|
|
288
|
+
settingsFilePath: resolveAmpClientSettingsFilePath({
|
|
289
|
+
scope: normalizedScope,
|
|
290
|
+
explicitPath: settingsFilePath,
|
|
291
|
+
cwd,
|
|
292
|
+
env,
|
|
293
|
+
homeDir
|
|
294
|
+
}),
|
|
295
|
+
secretsFilePath: resolveAmpClientSecretsFilePath({
|
|
296
|
+
explicitPath: secretsFilePath,
|
|
297
|
+
env,
|
|
298
|
+
homeDir
|
|
299
|
+
}),
|
|
300
|
+
endpointUrl: normalizedUrl,
|
|
301
|
+
apiKey: String(apiKey || "").trim()
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function readAmpClientRoutingState({
|
|
306
|
+
scope = "global",
|
|
307
|
+
settingsFilePath = "",
|
|
308
|
+
endpointUrl = "",
|
|
309
|
+
cwd = process.cwd(),
|
|
310
|
+
env = process.env,
|
|
311
|
+
homeDir = os.homedir()
|
|
312
|
+
} = {}) {
|
|
313
|
+
const normalizedScope = normalizeAmpClientSettingsScope(scope) || "global";
|
|
314
|
+
const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveAmpClientSettingsFilePath({
|
|
315
|
+
scope: normalizedScope,
|
|
316
|
+
cwd,
|
|
317
|
+
env,
|
|
318
|
+
homeDir
|
|
319
|
+
})).trim());
|
|
320
|
+
const targetUrl = normalizeAmpClientProxyUrl(endpointUrl);
|
|
321
|
+
const settingsState = await readJsonObjectFile(resolvedSettingsPath, `AMP settings file '${resolvedSettingsPath}'`);
|
|
322
|
+
const configuredUrl = normalizeAmpClientProxyUrl(settingsState.data?.["amp.url"] || "");
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
scope: normalizedScope,
|
|
326
|
+
settingsFilePath: resolvedSettingsPath,
|
|
327
|
+
configuredUrl,
|
|
328
|
+
routedViaRouter: Boolean(targetUrl && configuredUrl && configuredUrl === targetUrl),
|
|
329
|
+
settingsExists: settingsState.existed
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function patchAmpClientConfigFiles({
|
|
334
|
+
settingsFilePath,
|
|
335
|
+
secretsFilePath,
|
|
336
|
+
endpointUrl,
|
|
337
|
+
apiKey
|
|
338
|
+
} = {}) {
|
|
339
|
+
const normalizedUrl = normalizeAmpClientProxyUrl(endpointUrl);
|
|
340
|
+
const normalizedApiKey = String(apiKey || "").trim();
|
|
341
|
+
const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveAmpClientSettingsFilePath()).trim());
|
|
342
|
+
const resolvedSecretsPath = path.resolve(String(secretsFilePath || resolveAmpClientSecretsFilePath()).trim());
|
|
343
|
+
|
|
344
|
+
if (!normalizedUrl) {
|
|
345
|
+
throw new Error("AMP client endpoint URL must be a valid http:// or https:// URL.");
|
|
346
|
+
}
|
|
347
|
+
if (!normalizedApiKey) {
|
|
348
|
+
throw new Error("AMP client API key is required.");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const settingsState = await readJsonObjectFile(resolvedSettingsPath, `AMP settings file '${resolvedSettingsPath}'`);
|
|
352
|
+
settingsState.data["amp.url"] = normalizedUrl;
|
|
353
|
+
await writeJsonObjectFile(resolvedSettingsPath, settingsState.data);
|
|
354
|
+
|
|
355
|
+
const secretsState = await readJsonObjectFile(resolvedSecretsPath, `AMP secrets file '${resolvedSecretsPath}'`);
|
|
356
|
+
const secretFieldName = `apiKey@${normalizedUrl}`;
|
|
357
|
+
secretsState.data[secretFieldName] = normalizedApiKey;
|
|
358
|
+
await writeJsonObjectFile(resolvedSecretsPath, secretsState.data);
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
settingsFilePath: resolvedSettingsPath,
|
|
362
|
+
secretsFilePath: resolvedSecretsPath,
|
|
363
|
+
endpointUrl: normalizedUrl,
|
|
364
|
+
secretFieldName,
|
|
365
|
+
settingsCreated: !settingsState.existed,
|
|
366
|
+
secretsCreated: !secretsState.existed
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function unpatchAmpClientConfigFiles({
|
|
371
|
+
settingsFilePath,
|
|
372
|
+
secretsFilePath,
|
|
373
|
+
endpointUrl = "",
|
|
374
|
+
env = process.env,
|
|
375
|
+
homeDir = os.homedir()
|
|
376
|
+
} = {}) {
|
|
377
|
+
const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveAmpClientSettingsFilePath({
|
|
378
|
+
env,
|
|
379
|
+
homeDir
|
|
380
|
+
})).trim());
|
|
381
|
+
const resolvedSecretsPath = path.resolve(String(secretsFilePath || resolveAmpClientSecretsFilePath({
|
|
382
|
+
env,
|
|
383
|
+
homeDir
|
|
384
|
+
})).trim());
|
|
385
|
+
|
|
386
|
+
const settingsState = await readJsonObjectFile(resolvedSettingsPath, `AMP settings file '${resolvedSettingsPath}'`);
|
|
387
|
+
const configuredUrl = normalizeAmpClientProxyUrl(settingsState.data?.["amp.url"] || "");
|
|
388
|
+
const removedEndpointUrl = normalizeAmpClientProxyUrl(endpointUrl) || configuredUrl;
|
|
389
|
+
const hadAmpUrl = Object.prototype.hasOwnProperty.call(settingsState.data, "amp.url");
|
|
390
|
+
if (hadAmpUrl) {
|
|
391
|
+
delete settingsState.data["amp.url"];
|
|
392
|
+
await writeJsonObjectFile(resolvedSettingsPath, settingsState.data);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const secretsState = await readJsonObjectFile(resolvedSecretsPath, `AMP secrets file '${resolvedSecretsPath}'`);
|
|
396
|
+
const secretFieldsRemoved = [];
|
|
397
|
+
const candidateUrls = dedupeTrimmedStrings([
|
|
398
|
+
removedEndpointUrl,
|
|
399
|
+
configuredUrl
|
|
400
|
+
].flatMap((url) => (
|
|
401
|
+
url
|
|
402
|
+
? [url, `${url}/`]
|
|
403
|
+
: []
|
|
404
|
+
)));
|
|
405
|
+
|
|
406
|
+
for (const url of candidateUrls) {
|
|
407
|
+
const fieldName = `apiKey@${url}`;
|
|
408
|
+
if (!Object.prototype.hasOwnProperty.call(secretsState.data, fieldName)) continue;
|
|
409
|
+
delete secretsState.data[fieldName];
|
|
410
|
+
secretFieldsRemoved.push(fieldName);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (secretFieldsRemoved.length > 0) {
|
|
414
|
+
await writeJsonObjectFile(resolvedSecretsPath, secretsState.data);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
settingsFilePath: resolvedSettingsPath,
|
|
419
|
+
secretsFilePath: resolvedSecretsPath,
|
|
420
|
+
removedEndpointUrl,
|
|
421
|
+
settingsUpdated: hadAmpUrl,
|
|
422
|
+
secretFieldsRemoved,
|
|
423
|
+
settingsExisted: settingsState.existed,
|
|
424
|
+
secretsExisted: secretsState.existed
|
|
425
|
+
};
|
|
426
|
+
}
|