@khanglvm/llm-router 1.3.1 → 2.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +337 -41
  3. package/package.json +19 -3
  4. package/src/cli/router-module.js +7331 -3805
  5. package/src/cli/wrangler-toml.js +1 -1
  6. package/src/cli-entry.js +162 -24
  7. package/src/node/amp-client-config.js +426 -0
  8. package/src/node/coding-tool-config.js +763 -0
  9. package/src/node/config-store.js +49 -18
  10. package/src/node/instance-state.js +213 -12
  11. package/src/node/listen-port.js +5 -37
  12. package/src/node/local-server-settings.js +122 -0
  13. package/src/node/local-server.js +3 -2
  14. package/src/node/provider-probe.js +13 -0
  15. package/src/node/start-command.js +282 -40
  16. package/src/node/startup-manager.js +64 -29
  17. package/src/node/web-command.js +106 -0
  18. package/src/node/web-console-assets.js +26 -0
  19. package/src/node/web-console-client.js +56 -0
  20. package/src/node/web-console-dev-assets.js +258 -0
  21. package/src/node/web-console-server.js +3146 -0
  22. package/src/node/web-console-styles.generated.js +1 -0
  23. package/src/node/web-console-ui/config-editor-utils.js +616 -0
  24. package/src/node/web-console-ui/lib/utils.js +6 -0
  25. package/src/node/web-console-ui/rate-limit-utils.js +144 -0
  26. package/src/node/web-console-ui/select-search-utils.js +36 -0
  27. package/src/runtime/codex-request-transformer.js +46 -5
  28. package/src/runtime/codex-response-transformer.js +268 -35
  29. package/src/runtime/config.js +1394 -35
  30. package/src/runtime/handler/amp-gemini.js +913 -0
  31. package/src/runtime/handler/amp-response.js +308 -0
  32. package/src/runtime/handler/amp.js +290 -0
  33. package/src/runtime/handler/auth.js +17 -2
  34. package/src/runtime/handler/provider-call.js +168 -50
  35. package/src/runtime/handler/provider-translation.js +937 -26
  36. package/src/runtime/handler/request.js +149 -6
  37. package/src/runtime/handler/route-debug.js +22 -1
  38. package/src/runtime/handler.js +449 -9
  39. package/src/runtime/subscription-auth.js +1 -6
  40. package/src/shared/local-router-defaults.js +62 -0
  41. package/src/translator/index.js +3 -1
  42. package/src/translator/request/openai-to-claude.js +217 -6
  43. package/src/translator/response/openai-to-claude.js +206 -58
@@ -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 :8787 for Cloudflare Worker deployments"
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
- `A startup-managed llm-router instance is already running on port ${port}.`,
67
- "Choose how to continue:",
68
- "1. Restart startup-managed llm-router instance (use latest installed version)",
69
- "2. Stop running instance and start it here",
70
- "3. Exit"
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: args.host || "127.0.0.1",
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
- return runSingleModuleCli({
116
- registry,
117
- argv,
118
- moduleId: "router",
119
- defaultActionId: "config",
120
- helpDefaultTarget: "module",
121
- isTTY
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 parsed = parseSimpleArgs(argv);
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 interactive config manager.
250
+ // Bare invocation opens the browser-based console by default.
131
251
  if (!first && !parsed.wantsHelp) {
132
- return runSnapCli(["config"], isTTY);
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 = argv.slice(1);
260
+ const startArgs = normalizedArgv.slice(1);
138
261
  const parsedStart = parseSimpleArgs(startArgs);
139
- return runStartFastPath(parsedStart.args);
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 = [...argv];
280
+ const normalized = [...normalizedArgv];
143
281
  if (normalized[0] === "help") normalized[0] = "--help";
144
282
  if (normalized[0] === "setup") normalized[0] = "config";
145
- return runSnapCli(normalized, isTTY);
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
+ }