@khanglvm/llm-router 1.0.5

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.
@@ -0,0 +1,3987 @@
1
+ import { promises as fsPromises } from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import path from "node:path";
5
+ import { SnapTui, runPasswordPrompt } from "@levu/snap/dist/index.js";
6
+ import {
7
+ applyConfigChanges,
8
+ buildProviderFromConfigInput,
9
+ buildWorkerConfigPayload,
10
+ parseModelListInput
11
+ } from "../node/config-workflows.js";
12
+ import {
13
+ configFileExists,
14
+ getDefaultConfigPath,
15
+ readConfigFile,
16
+ removeProvider,
17
+ writeConfigFile
18
+ } from "../node/config-store.js";
19
+ import { probeProvider, probeProviderEndpointMatrix } from "../node/provider-probe.js";
20
+ import { runStartCommand } from "../node/start-command.js";
21
+ import { installStartup, restartStartup, startupStatus, stopStartup, uninstallStartup } from "../node/startup-manager.js";
22
+ import {
23
+ buildStartArgsFromState,
24
+ clearRuntimeState,
25
+ getActiveRuntimeState,
26
+ spawnDetachedStart,
27
+ stopProcessByPid
28
+ } from "../node/instance-state.js";
29
+ import {
30
+ configHasProvider,
31
+ DEFAULT_PROVIDER_USER_AGENT,
32
+ maskSecret,
33
+ PROVIDER_ID_PATTERN,
34
+ sanitizeConfigForDisplay
35
+ } from "../runtime/config.js";
36
+
37
+ const EXIT_SUCCESS = 0;
38
+ const EXIT_FAILURE = 1;
39
+ const EXIT_VALIDATION = 2;
40
+ const NPM_PACKAGE_NAME = "@khanglvm/llm-router";
41
+ const STRONG_MASTER_KEY_MIN_LENGTH = 24;
42
+ const DEFAULT_GENERATED_MASTER_KEY_LENGTH = 48;
43
+ const MAX_GENERATED_MASTER_KEY_LENGTH = 256;
44
+ const WEAK_MASTER_KEY_PATTERN = /(password|changeme|default|secret|token|admin|qwerty|letmein|123456)/i;
45
+ export const CLOUDFLARE_FREE_SECRET_SIZE_LIMIT_BYTES = 5 * 1024;
46
+ const CLOUDFLARE_FREE_TIER_PATTERN = /\bfree\b/i;
47
+ const CLOUDFLARE_PAID_TIER_PATTERN = /\b(pro|business|enterprise|paid|unbound)\b/i;
48
+ const CLOUDFLARE_API_TOKEN_ENV_NAME = "CLOUDFLARE_API_TOKEN";
49
+ const CLOUDFLARE_API_TOKEN_ALT_ENV_NAME = "CF_API_TOKEN";
50
+ const CLOUDFLARE_ACCOUNT_ID_ENV_NAME = "CLOUDFLARE_ACCOUNT_ID";
51
+ const CLOUDFLARE_API_TOKEN_PRESET_NAME = "Edit Cloudflare Workers";
52
+ const CLOUDFLARE_API_TOKEN_DASHBOARD_URL = "https://dash.cloudflare.com/profile/api-tokens";
53
+ const CLOUDFLARE_API_TOKEN_GUIDE_URL = "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/";
54
+ const CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4";
55
+ const CLOUDFLARE_VERIFY_TOKEN_URL = `${CLOUDFLARE_API_BASE_URL}/user/tokens/verify`;
56
+ const CLOUDFLARE_MEMBERSHIPS_URL = `${CLOUDFLARE_API_BASE_URL}/memberships`;
57
+ const CLOUDFLARE_ZONES_URL = `${CLOUDFLARE_API_BASE_URL}/zones`;
58
+ const CLOUDFLARE_API_PREFLIGHT_TIMEOUT_MS = 10_000;
59
+
60
+ function canPrompt() {
61
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
62
+ }
63
+
64
+ function readArg(args, names, fallback = undefined) {
65
+ for (const name of names) {
66
+ if (args[name] !== undefined && args[name] !== "") return args[name];
67
+ }
68
+ return fallback;
69
+ }
70
+
71
+ function toBoolean(value, fallback = false) {
72
+ if (value === undefined || value === null || value === "") return fallback;
73
+ if (typeof value === "boolean") return value;
74
+ const normalized = String(value).trim().toLowerCase();
75
+ if (["1", "true", "yes", "y"].includes(normalized)) return true;
76
+ if (["0", "false", "no", "n"].includes(normalized)) return false;
77
+ return fallback;
78
+ }
79
+
80
+ function toNumber(value, fallback) {
81
+ if (value === undefined || value === null || value === "") return fallback;
82
+ const parsed = Number(value);
83
+ return Number.isFinite(parsed) ? parsed : fallback;
84
+ }
85
+
86
+ function clampMasterKeyLength(value) {
87
+ const parsed = Math.floor(toNumber(value, DEFAULT_GENERATED_MASTER_KEY_LENGTH));
88
+ if (!Number.isFinite(parsed)) return DEFAULT_GENERATED_MASTER_KEY_LENGTH;
89
+ return Math.min(MAX_GENERATED_MASTER_KEY_LENGTH, Math.max(parsed, STRONG_MASTER_KEY_MIN_LENGTH));
90
+ }
91
+
92
+ function normalizeMasterKeyPrefix(value) {
93
+ const normalized = String(value ?? "gw_")
94
+ .replace(/[\r\n\t]/g, "")
95
+ .trim();
96
+ if (!normalized) return "gw_";
97
+ return normalized.slice(0, 32);
98
+ }
99
+
100
+ function analyzeMasterKeyStrength(rawKey) {
101
+ const key = String(rawKey || "");
102
+ const reasons = [];
103
+ if (key.length < STRONG_MASTER_KEY_MIN_LENGTH) {
104
+ reasons.push(`length must be >= ${STRONG_MASTER_KEY_MIN_LENGTH}`);
105
+ }
106
+
107
+ const hasLower = /[a-z]/.test(key);
108
+ const hasUpper = /[A-Z]/.test(key);
109
+ const hasDigit = /[0-9]/.test(key);
110
+ const hasSymbol = /[^A-Za-z0-9]/.test(key);
111
+ const classes = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean).length;
112
+ if (classes < 3) {
113
+ reasons.push("use at least 3 character classes (lower/upper/digits/symbols)");
114
+ }
115
+
116
+ if (WEAK_MASTER_KEY_PATTERN.test(key)) {
117
+ reasons.push("contains common weak pattern");
118
+ }
119
+ if (/(.)\1{5,}/.test(key)) {
120
+ reasons.push("contains long repeated characters");
121
+ }
122
+
123
+ return {
124
+ strong: reasons.length === 0,
125
+ reasons
126
+ };
127
+ }
128
+
129
+ function generateStrongMasterKey({ length, prefix } = {}) {
130
+ const targetLength = clampMasterKeyLength(length);
131
+ const safePrefix = normalizeMasterKeyPrefix(prefix);
132
+ const randomLength = Math.max(
133
+ STRONG_MASTER_KEY_MIN_LENGTH,
134
+ targetLength - safePrefix.length
135
+ );
136
+
137
+ let fallbackKey = "";
138
+ for (let attempt = 0; attempt < 12; attempt += 1) {
139
+ const token = randomBytes(Math.ceil(randomLength * 0.8) + 16)
140
+ .toString("base64url")
141
+ .slice(0, randomLength);
142
+ const key = `${safePrefix}${token}`;
143
+ fallbackKey = key;
144
+ if (analyzeMasterKeyStrength(key).strong) {
145
+ return key;
146
+ }
147
+ }
148
+
149
+ return fallbackKey;
150
+ }
151
+
152
+ async function ensureStrongWorkerMasterKey(context, masterKey, { allowWeakMasterKey = false } = {}) {
153
+ const report = analyzeMasterKeyStrength(masterKey);
154
+ if (report.strong || allowWeakMasterKey) {
155
+ return { ok: true, allowWeakMasterKey };
156
+ }
157
+
158
+ const reasons = report.reasons.join("; ");
159
+ if (canPrompt()) {
160
+ const proceed = await context.prompts.confirm({
161
+ message: `Worker master key looks weak (${reasons}). Continue anyway?`,
162
+ initialValue: false
163
+ });
164
+ if (proceed) {
165
+ return { ok: true, allowWeakMasterKey: true };
166
+ }
167
+ }
168
+
169
+ return {
170
+ ok: false,
171
+ errorMessage: `Weak worker master key rejected (${reasons}). Use a stronger random key or pass --allow-weak-master-key=true to override.`
172
+ };
173
+ }
174
+
175
+ function parseJsonObjectArg(value, fieldName) {
176
+ if (value === undefined || value === null || value === "") return {};
177
+ if (typeof value === "object" && !Array.isArray(value)) return value;
178
+ try {
179
+ const parsed = JSON.parse(String(value));
180
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
181
+ throw new Error("must be a JSON object");
182
+ }
183
+ return parsed;
184
+ } catch (error) {
185
+ throw new Error(`${fieldName} must be a JSON object string. ${error instanceof Error ? error.message : String(error)}`);
186
+ }
187
+ }
188
+
189
+ function hasHeaderName(headers, name) {
190
+ const lower = String(name).toLowerCase();
191
+ return Object.keys(headers || {}).some((key) => key.toLowerCase() === lower);
192
+ }
193
+
194
+ function applyDefaultHeaders(headers, { force = true } = {}) {
195
+ const source = headers && typeof headers === "object" && !Array.isArray(headers) ? headers : {};
196
+ const next = { ...source };
197
+ if (force && !hasHeaderName(next, "user-agent")) {
198
+ next["User-Agent"] = DEFAULT_PROVIDER_USER_AGENT;
199
+ }
200
+ return next;
201
+ }
202
+
203
+ async function promptSecretInput(context, {
204
+ message,
205
+ required = true,
206
+ validate
207
+ } = {}) {
208
+ if (context?.prompts && typeof context.prompts.password === "function") {
209
+ return context.prompts.password({
210
+ message,
211
+ required,
212
+ validate,
213
+ mask: "*"
214
+ });
215
+ }
216
+
217
+ if (canPrompt()) {
218
+ return runPasswordPrompt({
219
+ message,
220
+ required,
221
+ validate,
222
+ mask: "*"
223
+ });
224
+ }
225
+
226
+ return context.prompts.text({
227
+ message,
228
+ required,
229
+ validate
230
+ });
231
+ }
232
+
233
+ function providerEndpointsFromConfig(provider) {
234
+ const values = [
235
+ provider?.baseUrlByFormat?.openai,
236
+ provider?.baseUrlByFormat?.claude,
237
+ provider?.baseUrl
238
+ ];
239
+ return parseModelListInput(values.filter(Boolean).join(","));
240
+ }
241
+
242
+ function normalizeNameForCompare(value) {
243
+ return String(value || "").trim().toLowerCase();
244
+ }
245
+
246
+ function findProviderByFriendlyName(providers, name, { excludeId = "" } = {}) {
247
+ const needle = normalizeNameForCompare(name);
248
+ if (!needle) return null;
249
+ const excluded = String(excludeId || "").trim();
250
+ return (providers || []).find((provider) => {
251
+ if (!provider || typeof provider !== "object") return false;
252
+ const sameName = normalizeNameForCompare(provider.name) === needle;
253
+ if (!sameName) return false;
254
+ if (!excluded) return true;
255
+ return String(provider.id || "").trim() !== excluded;
256
+ }) || null;
257
+ }
258
+
259
+ function printProviderInputGuidance(context) {
260
+ if (!canPrompt()) return;
261
+ const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : null;
262
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
263
+ const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : null;
264
+ if (!line) return;
265
+
266
+ info?.("Provider config tips:");
267
+ line(" - Provider Friendly Name is shown in the management screen and must be unique.");
268
+ line(" - Provider ID is auto-generated by slugifying the friendly name; you can edit it.");
269
+ line(" - Examples:");
270
+ line(" Friendly Name: OpenRouter Primary, RamClouds Production");
271
+ line(" Provider ID: openrouterPrimary, ramcloudsProd");
272
+ line(" API Key: sk-or-v1-xxxxxxxx, sk-ant-api03-xxxxxxxx, sk-xxxxxxxx");
273
+ }
274
+
275
+ function trimOuterPunctuation(value) {
276
+ return String(value || "")
277
+ .trim()
278
+ .replace(/^[\s"'`([{<]+/, "")
279
+ .replace(/[\s"'`)\]}>.,;:]+$/, "")
280
+ .trim();
281
+ }
282
+
283
+ function dedupeList(values) {
284
+ return [...new Set((values || []).filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
285
+ }
286
+
287
+ function tokenizeLooseListInput(raw) {
288
+ if (Array.isArray(raw)) return dedupeList(raw.flatMap((item) => tokenizeLooseListInput(item)));
289
+ const text = String(raw || "").replace(/[;,]+/g, "\n");
290
+ const tokens = text
291
+ .split(/\r?\n/g)
292
+ .flatMap((line) => String(line || "").trim().split(/\s+/g));
293
+ return dedupeList(tokens);
294
+ }
295
+
296
+ function normalizeEndpointToken(token) {
297
+ let value = trimOuterPunctuation(token);
298
+ if (!value) return "";
299
+
300
+ value = value
301
+ .replace(/^(?:openaiBaseUrl|claudeBaseUrl|anthropicBaseUrl|baseUrl)\s*=\s*/i, "")
302
+ .replace(/^url\s*=\s*/i, "");
303
+
304
+ const urlMatch = value.match(/https?:\/\/[^\s,;'"`<>()\]]+/i);
305
+ if (urlMatch) value = urlMatch[0];
306
+
307
+ // Common typo: missing colon after scheme.
308
+ if (/^http\/\/+/i.test(value) || /^https\/\/+/i.test(value)) {
309
+ value = value.replace(/^http\/\/+/i, "http://").replace(/^https\/\/+/i, "https://");
310
+ }
311
+ if (/^ttps?:\/\//i.test(value)) {
312
+ value = `h${value}`;
313
+ }
314
+ if (/^https?:\/\/$/i.test(value)) return "";
315
+
316
+ // Accept domain-like values pasted without scheme.
317
+ if (!/^https?:\/\//i.test(value) && /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^\s]*)?$/i.test(value)) {
318
+ value = `https://${value}`;
319
+ }
320
+
321
+ value = value.replace(/[)\]}>.,;:]+$/g, "");
322
+ return /^https?:\/\/.+/i.test(value) ? value : "";
323
+ }
324
+
325
+ function parseEndpointListInput(raw) {
326
+ const text = Array.isArray(raw) ? raw.join("\n") : String(raw || "");
327
+ const extracted = [];
328
+
329
+ const urlRegex = /https?:\/\/[^\s,;'"`<>()\]]+/gi;
330
+ for (const match of text.matchAll(urlRegex)) {
331
+ extracted.push(match[0]);
332
+ }
333
+
334
+ const typoUrlRegex = /\bhttps?:\/\/?[^\s,;'"`<>()\]]+/gi;
335
+ for (const match of text.matchAll(typoUrlRegex)) {
336
+ extracted.push(match[0]);
337
+ }
338
+
339
+ const domainRegex = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^\s,;'"`<>()\]]*)?/gi;
340
+ for (const match of text.matchAll(domainRegex)) {
341
+ extracted.push(match[0]);
342
+ }
343
+
344
+ const fallbackTokens = tokenizeLooseListInput(text);
345
+ const normalized = dedupeList([...(extracted.length > 0 ? extracted : []), ...fallbackTokens]
346
+ .map(normalizeEndpointToken)
347
+ .filter(Boolean));
348
+
349
+ return normalized;
350
+ }
351
+
352
+ const MODEL_INPUT_NOISE_TOKENS = new Set([
353
+ "discover",
354
+ "progress",
355
+ "endpoint",
356
+ "testing",
357
+ "formats",
358
+ "format",
359
+ "working",
360
+ "supported",
361
+ "auto-discovery",
362
+ "auto",
363
+ "discovery",
364
+ "completed",
365
+ "started",
366
+ "done",
367
+ "openai",
368
+ "claude",
369
+ "anthropic",
370
+ "skip",
371
+ "ok",
372
+ "tentative",
373
+ "network-error",
374
+ "format-mismatch",
375
+ "model-unsupported",
376
+ "auth-error",
377
+ "unconfirmed",
378
+ "error",
379
+ "errors",
380
+ "warning",
381
+ "warnings",
382
+ "failed",
383
+ "failure",
384
+ "invalid",
385
+ "request",
386
+ "response",
387
+ "http",
388
+ "https",
389
+ "status",
390
+ "probe",
391
+ "provider",
392
+ "models",
393
+ "model",
394
+ "on",
395
+ "at"
396
+ ]);
397
+
398
+ function normalizeModelToken(token) {
399
+ let value = trimOuterPunctuation(token);
400
+ if (!value) return "";
401
+
402
+ value = value
403
+ .replace(/^(?:models?|modelSupport|modelPreferredFormat)\s*=\s*/i, "")
404
+ .replace(/\[(?:openai|claude)\]?$/i, "")
405
+ .replace(/[)\]}>.,;:]+$/g, "")
406
+ .trim();
407
+
408
+ if (!value) return "";
409
+ if (value.includes("://")) return "";
410
+ if (value.includes("@")) return "";
411
+ if (/^\d+(?:\/\d+)?$/.test(value)) return "";
412
+ if (/^https?$/i.test(value)) return "";
413
+ if (/^(?:openai|claude|anthropic)$/i.test(value)) return "";
414
+ if (MODEL_INPUT_NOISE_TOKENS.has(value.toLowerCase())) return "";
415
+
416
+ // Ignore obvious prose fragments. Keep model-like IDs with delimiters.
417
+ if (!/[._:/-]/.test(value) && !/\d/.test(value)) return "";
418
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(value)) return "";
419
+
420
+ return value;
421
+ }
422
+
423
+ function parseProviderModelListInput(raw) {
424
+ const text = Array.isArray(raw) ? raw.join("\n") : String(raw || "");
425
+ const extracted = [];
426
+
427
+ // "Progress ... - <model> on <format> @ <endpoint>"
428
+ const progressRegex = /-\s+([A-Za-z0-9][A-Za-z0-9._:/-]*)\s+on\s+(?:openai|claude)\s+@/gi;
429
+ for (const match of text.matchAll(progressRegex)) {
430
+ extracted.push(match[1]);
431
+ }
432
+
433
+ // "models=foo[openai], bar[claude]"
434
+ const modelsLineRegex = /\bmodels?\s*=\s*([^\n\r]+)/gi;
435
+ for (const match of text.matchAll(modelsLineRegex)) {
436
+ extracted.push(...tokenizeLooseListInput(match[1]));
437
+ }
438
+
439
+ const fallbackTokens = tokenizeLooseListInput(text);
440
+ return dedupeList([...(extracted.length > 0 ? extracted : []), ...fallbackTokens]
441
+ .map(normalizeModelToken)
442
+ .filter(Boolean));
443
+ }
444
+
445
+ function normalizeQualifiedModelToken(token) {
446
+ const value = trimOuterPunctuation(token)
447
+ .replace(/[)\]}>.,;:]+$/g, "")
448
+ .trim();
449
+ if (!value) return "";
450
+ if (value.includes("://") || value.includes("@")) return "";
451
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(value)) return "";
452
+ return value;
453
+ }
454
+
455
+ function parseQualifiedModelListInput(raw) {
456
+ const text = Array.isArray(raw) ? raw.join("\n") : String(raw || "");
457
+ const tokens = tokenizeLooseListInput(text);
458
+ return dedupeList(tokens
459
+ .map(normalizeQualifiedModelToken)
460
+ .filter(Boolean));
461
+ }
462
+
463
+ function maybeReportInputCleanup(context, label, rawValue, cleanedValues) {
464
+ if (!canPrompt()) return;
465
+ const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : null;
466
+ const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : null;
467
+ if (!info && !warn) return;
468
+
469
+ const raw = String(rawValue || "").trim();
470
+ if (!raw) return;
471
+
472
+ const normalizedRaw = raw.toLowerCase();
473
+ const looksMessy =
474
+ /[;\n\r\t]/.test(raw) ||
475
+ /\[discover\]|auto-discovery|error|warning|failed|models?=/i.test(raw) ||
476
+ /\s{2,}/.test(raw);
477
+
478
+ if (!looksMessy) return;
479
+
480
+ if ((cleanedValues || []).length > 0) {
481
+ info?.(`Cleaned ${label} input: parsed ${(cleanedValues || []).length} item(s) from free-form text.`);
482
+ } else {
483
+ warn?.(`Could not parse any ${label} from the provided text. Use comma/semicolon/space/newline-separated values.`);
484
+ }
485
+ }
486
+
487
+ function truncateLogText(value, max = 160) {
488
+ const text = String(value || "").trim();
489
+ if (!text) return "";
490
+ if (text.length <= max) return text;
491
+ return `${text.slice(0, max - 3)}...`;
492
+ }
493
+
494
+ function describeModelCheckStatus(event) {
495
+ const statusCode = Number(event.status || 0);
496
+ const statusSuffix = statusCode > 0 ? ` (http ${statusCode})` : "";
497
+ const rawMessage = event.error || event.message || "";
498
+ const detail = truncateLogText(rawMessage === "ok" ? "" : rawMessage);
499
+ const outcome = String(event.outcome || "");
500
+
501
+ if (event.confirmed) {
502
+ return {
503
+ shortLabel: "ok",
504
+ fullLabel: `ok${statusSuffix}`,
505
+ detail,
506
+ isOk: true
507
+ };
508
+ }
509
+
510
+ if (outcome === "runtime-error") {
511
+ return {
512
+ shortLabel: "tentative",
513
+ fullLabel: `tentative${statusSuffix}`,
514
+ detail,
515
+ isOk: false
516
+ };
517
+ }
518
+ if (outcome === "model-unsupported") {
519
+ return {
520
+ shortLabel: "model-unsupported",
521
+ fullLabel: `model-unsupported${statusSuffix}`,
522
+ detail,
523
+ isOk: false
524
+ };
525
+ }
526
+ if (outcome === "format-mismatch") {
527
+ return {
528
+ shortLabel: "format-mismatch",
529
+ fullLabel: `format-mismatch${statusSuffix}`,
530
+ detail,
531
+ isOk: false
532
+ };
533
+ }
534
+ if (outcome === "network-error") {
535
+ return {
536
+ shortLabel: "network-error",
537
+ fullLabel: `network-error${statusSuffix}`,
538
+ detail,
539
+ isOk: false
540
+ };
541
+ }
542
+ if (outcome === "auth-error") {
543
+ return {
544
+ shortLabel: "auth-error",
545
+ fullLabel: `auth-error${statusSuffix}`,
546
+ detail,
547
+ isOk: false
548
+ };
549
+ }
550
+ if (outcome === "unconfirmed") {
551
+ return {
552
+ shortLabel: "unconfirmed",
553
+ fullLabel: `unconfirmed${statusSuffix}`,
554
+ detail,
555
+ isOk: false
556
+ };
557
+ }
558
+
559
+ return {
560
+ shortLabel: event.supported ? "tentative" : "skip",
561
+ fullLabel: `${event.supported ? "tentative" : "skip"}${statusSuffix}`,
562
+ detail,
563
+ isOk: false
564
+ };
565
+ }
566
+
567
+ function probeProgressReporter(context) {
568
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
569
+ if (!line) return () => {};
570
+
571
+ const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : line;
572
+ const success = typeof context?.terminal?.success === "function" ? context.terminal.success.bind(context.terminal) : line;
573
+ const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : line;
574
+ const interactiveTerminal = canPrompt();
575
+ const progress = interactiveTerminal && typeof SnapTui?.createProgress === "function" ? SnapTui.createProgress() : null;
576
+ const endpointSpinner = interactiveTerminal && typeof SnapTui?.createSpinner === "function" ? SnapTui.createSpinner() : null;
577
+ const PROGRESS_UI_MIN_UPDATE_MS = 120;
578
+ const SPINNER_UI_MIN_UPDATE_MS = 120;
579
+
580
+ let lastProgressPrinted = -1;
581
+ let totalChecks = 0;
582
+ let matrixStarted = false;
583
+ let endpointSpinnerRunning = false;
584
+ let lastProgressUiUpdateAt = 0;
585
+ let lastProgressUiMessage = "";
586
+ let lastSpinnerUiUpdateAt = 0;
587
+ let lastSpinnerUiMessage = "";
588
+
589
+ const clearSpinnerForLog = () => {
590
+ if (!endpointSpinner || !endpointSpinnerRunning) return;
591
+ if (typeof endpointSpinner.clear === "function") {
592
+ endpointSpinner.clear();
593
+ }
594
+ };
595
+
596
+ const maybeLine = (message, { forceInteractive = false } = {}) => {
597
+ if (!interactiveTerminal || forceInteractive) {
598
+ clearSpinnerForLog();
599
+ line(message);
600
+ }
601
+ };
602
+
603
+ const setSpinnerMessage = (message, { force = false } = {}) => {
604
+ if (!endpointSpinner || !endpointSpinnerRunning) return;
605
+ const next = String(message || "").trim();
606
+ if (!next) return;
607
+ const now = Date.now();
608
+ if (!force) {
609
+ if (next === lastSpinnerUiMessage) return;
610
+ if (now - lastSpinnerUiUpdateAt < SPINNER_UI_MIN_UPDATE_MS) return;
611
+ }
612
+ endpointSpinner.message(next);
613
+ lastSpinnerUiMessage = next;
614
+ lastSpinnerUiUpdateAt = now;
615
+ };
616
+
617
+ const setProgressMessage = (message, { force = false } = {}) => {
618
+ if (!progress) return;
619
+ const next = String(message || "").trim();
620
+ if (!next) return;
621
+ const now = Date.now();
622
+ if (!force) {
623
+ if (next === lastProgressUiMessage) return;
624
+ if (now - lastProgressUiUpdateAt < PROGRESS_UI_MIN_UPDATE_MS) return;
625
+ }
626
+ progress.message(next);
627
+ lastProgressUiMessage = next;
628
+ lastProgressUiUpdateAt = now;
629
+ };
630
+
631
+ return (event) => {
632
+ if (!event || typeof event !== "object") return;
633
+ const phase = String(event.phase || "");
634
+
635
+ if (phase === "matrix-start") {
636
+ const endpointCount = Number(event.endpointCount || 0);
637
+ const modelCount = Number(event.modelCount || 0);
638
+ totalChecks = endpointCount * modelCount * 2;
639
+ matrixStarted = true;
640
+
641
+ info(`Auto-discovery started: ${endpointCount} endpoint(s) x ${modelCount} model(s).`);
642
+ progress?.start(`Auto-discovery progress: 0/${totalChecks || 0}`);
643
+ lastProgressUiMessage = `Auto-discovery progress: 0/${totalChecks || 0}`;
644
+ lastProgressUiUpdateAt = Date.now();
645
+ return;
646
+ }
647
+ if (phase === "endpoint-start") {
648
+ if (endpointSpinner && endpointSpinnerRunning) {
649
+ endpointSpinner.stop();
650
+ }
651
+ endpointSpinner?.start(`Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`);
652
+ endpointSpinnerRunning = Boolean(endpointSpinner);
653
+ lastSpinnerUiMessage = `Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`;
654
+ lastSpinnerUiUpdateAt = Date.now();
655
+ maybeLine(`[discover] Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`);
656
+ return;
657
+ }
658
+ if (phase === "endpoint-formats") {
659
+ const formats = Array.isArray(event.formatsToTest) && event.formatsToTest.length > 0
660
+ ? event.formatsToTest.join(", ")
661
+ : "(none)";
662
+ maybeLine(`[discover] Testing formats for ${event.endpoint}: ${formats}`);
663
+ return;
664
+ }
665
+ if (phase === "format-start") {
666
+ maybeLine(`[discover] ${event.endpoint} -> ${event.format} (${event.modelCount || 0} model checks)`);
667
+ return;
668
+ }
669
+ if (phase === "model-check") {
670
+ const completed = Number(event.completedChecks || 0);
671
+ const total = Number(event.totalChecks || 0);
672
+ if (completed <= 0 || total <= 0) return;
673
+ const status = describeModelCheckStatus(event);
674
+ const shouldPrintLine = interactiveTerminal
675
+ ? (!status.isOk || completed === total)
676
+ : (!status.isOk || completed === total || completed - lastProgressPrinted >= 3);
677
+
678
+ if (matrixStarted) {
679
+ setProgressMessage(
680
+ `Auto-discovery progress: ${completed}/${total} (${event.model} on ${event.format} @ ${event.endpoint}: ${status.shortLabel})`,
681
+ { force: !status.isOk || completed === total }
682
+ );
683
+ }
684
+
685
+ if (shouldPrintLine) {
686
+ lastProgressPrinted = completed;
687
+ const detailSuffix = status.detail ? ` - ${status.detail}` : "";
688
+ maybeLine(
689
+ `[discover] Progress ${completed}/${total} - ${event.model} on ${event.format} @ ${event.endpoint}: ${status.fullLabel}${detailSuffix}`,
690
+ { forceInteractive: !status.isOk || completed === total }
691
+ );
692
+ }
693
+ return;
694
+ }
695
+ if (phase === "endpoint-done") {
696
+ const formats = Array.isArray(event.workingFormats) && event.workingFormats.length > 0
697
+ ? event.workingFormats.join(", ")
698
+ : "(none)";
699
+ if (endpointSpinner && endpointSpinnerRunning) {
700
+ endpointSpinner.stop();
701
+ endpointSpinnerRunning = false;
702
+ }
703
+ if (formats === "(none)") {
704
+ warn(`[discover] Endpoint done: ${event.endpoint} working formats=${formats}`);
705
+ } else {
706
+ success(`[discover] Endpoint done: ${event.endpoint} working formats=${formats}`);
707
+ }
708
+ return;
709
+ }
710
+ if (phase === "matrix-done") {
711
+ const openaiBase = event.baseUrlByFormat?.openai || "(none)";
712
+ const claudeBase = event.baseUrlByFormat?.claude || "(none)";
713
+ const formats = Array.isArray(event.workingFormats) && event.workingFormats.length > 0
714
+ ? event.workingFormats.join(", ")
715
+ : "(none)";
716
+ const finalMessage = `Auto-discovery completed: working formats=${formats}, models=${event.supportedModelCount || 0}, openaiBase=${openaiBase}, claudeBase=${claudeBase}`;
717
+ if (endpointSpinner && endpointSpinnerRunning) {
718
+ endpointSpinner.stop();
719
+ endpointSpinnerRunning = false;
720
+ }
721
+ if (matrixStarted) {
722
+ progress?.stop(`Auto-discovery progress: ${event.supportedModelCount || 0} model(s) confirmed`);
723
+ lastProgressUiMessage = "";
724
+ }
725
+ if (formats === "(none)") {
726
+ warn(finalMessage);
727
+ } else {
728
+ success(finalMessage);
729
+ }
730
+ matrixStarted = false;
731
+ totalChecks = 0;
732
+ lastSpinnerUiMessage = "";
733
+ }
734
+ };
735
+ }
736
+
737
+ async function promptProviderFormat(context, {
738
+ message = "Primary provider format",
739
+ initialFormat = ""
740
+ } = {}) {
741
+ const preferred = initialFormat === "claude" ? "claude" : (initialFormat === "openai" ? "openai" : "");
742
+ const options = preferred === "claude"
743
+ ? [
744
+ { value: "claude", label: "Anthropic-compatible" },
745
+ { value: "openai", label: "OpenAI-compatible" }
746
+ ]
747
+ : [
748
+ { value: "openai", label: "OpenAI-compatible" },
749
+ { value: "claude", label: "Anthropic-compatible" }
750
+ ];
751
+
752
+ return context.prompts.select({ message, options });
753
+ }
754
+
755
+ function slugifyId(value, fallback = "provider") {
756
+ const slug = String(value || fallback)
757
+ .trim()
758
+ .replace(/[^a-zA-Z0-9]+/g, "-")
759
+ .replace(/^-+|-+$/g, "");
760
+ if (!slug) return fallback;
761
+ return /^[A-Z]/.test(slug)
762
+ ? slug.charAt(0).toLowerCase() + slug.slice(1)
763
+ : slug;
764
+ }
765
+
766
+ function summarizeConfig(config, configPath, { includeSecrets = false } = {}) {
767
+ const target = includeSecrets ? config : sanitizeConfigForDisplay(config);
768
+ const lines = [];
769
+ lines.push(`Config: ${configPath}`);
770
+ lines.push(`Default model: ${target.defaultModel || "(not set)"}`);
771
+ lines.push(`Master key: ${target.masterKey || "(not set)"}`);
772
+
773
+ if (!target.providers || target.providers.length === 0) {
774
+ lines.push("Providers: (none)");
775
+ return lines.join("\n");
776
+ }
777
+
778
+ lines.push("Providers:");
779
+ for (const provider of target.providers) {
780
+ lines.push(`- ${provider.id} (${provider.name})`);
781
+ lines.push(` baseUrl=${provider.baseUrl}`);
782
+ if (provider.baseUrlByFormat?.openai) {
783
+ lines.push(` openaiBaseUrl=${provider.baseUrlByFormat.openai}`);
784
+ }
785
+ if (provider.baseUrlByFormat?.claude) {
786
+ lines.push(` claudeBaseUrl=${provider.baseUrlByFormat.claude}`);
787
+ }
788
+ lines.push(` formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`);
789
+ lines.push(` apiKey=${provider.apiKey || "(from env/hidden)"}`);
790
+ lines.push(` models=${(provider.models || []).map((model) => {
791
+ const fallbacks = (model.fallbackModels || []).join("|");
792
+ return fallbacks ? `${model.id}{fallback:${fallbacks}}` : model.id;
793
+ }).join(", ") || "(none)"}`);
794
+ }
795
+
796
+ return lines.join("\n");
797
+ }
798
+
799
+ function runCommand(command, args, { cwd, input, envOverrides } = {}) {
800
+ const safeEnvOverrides = envOverrides && typeof envOverrides === "object"
801
+ ? envOverrides
802
+ : {};
803
+ const result = spawnSync(command, args, {
804
+ cwd,
805
+ encoding: "utf8",
806
+ input,
807
+ env: {
808
+ ...process.env,
809
+ ...safeEnvOverrides,
810
+ FORCE_COLOR: "0"
811
+ }
812
+ });
813
+
814
+ return {
815
+ ok: result.status === 0,
816
+ status: result.status ?? 1,
817
+ stdout: result.stdout || "",
818
+ stderr: result.stderr || "",
819
+ error: result.error
820
+ };
821
+ }
822
+
823
+ function runCommandAsync(command, args, { cwd, input, envOverrides } = {}) {
824
+ const safeEnvOverrides = envOverrides && typeof envOverrides === "object"
825
+ ? envOverrides
826
+ : {};
827
+
828
+ return new Promise((resolve) => {
829
+ const child = spawn(command, args, {
830
+ cwd,
831
+ env: {
832
+ ...process.env,
833
+ ...safeEnvOverrides,
834
+ FORCE_COLOR: "0"
835
+ },
836
+ stdio: ["pipe", "pipe", "pipe"]
837
+ });
838
+
839
+ let stdout = "";
840
+ let stderr = "";
841
+ let spawnError = null;
842
+
843
+ if (child.stdout) {
844
+ child.stdout.on("data", (chunk) => {
845
+ stdout += String(chunk);
846
+ });
847
+ }
848
+
849
+ if (child.stderr) {
850
+ child.stderr.on("data", (chunk) => {
851
+ stderr += String(chunk);
852
+ });
853
+ }
854
+
855
+ child.on("error", (error) => {
856
+ spawnError = error;
857
+ });
858
+
859
+ child.on("close", (code) => {
860
+ resolve({
861
+ ok: code === 0,
862
+ status: Number.isInteger(code) ? code : 1,
863
+ stdout,
864
+ stderr,
865
+ error: spawnError
866
+ });
867
+ });
868
+
869
+ if (input !== undefined && input !== null) {
870
+ child.stdin.write(String(input));
871
+ }
872
+ child.stdin.end();
873
+ });
874
+ }
875
+
876
+ function runWrangler(args, { cwd, input, envOverrides } = {}) {
877
+ const direct = runCommand("wrangler", args, { cwd, input, envOverrides });
878
+ if (!direct.error) return direct;
879
+
880
+ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
881
+ return runCommand(npxCmd, ["wrangler", ...args], { cwd, input, envOverrides });
882
+ }
883
+
884
+ async function runWranglerAsync(args, { cwd, input, envOverrides } = {}) {
885
+ const direct = await runCommandAsync("wrangler", args, { cwd, input, envOverrides });
886
+ if (!direct.error) return direct;
887
+
888
+ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
889
+ return runCommandAsync(npxCmd, ["wrangler", ...args], { cwd, input, envOverrides });
890
+ }
891
+
892
+ export function resolveCloudflareApiTokenFromEnv(env = process.env) {
893
+ const primary = String(env?.[CLOUDFLARE_API_TOKEN_ENV_NAME] || "").trim();
894
+ if (primary) {
895
+ return {
896
+ token: primary,
897
+ source: CLOUDFLARE_API_TOKEN_ENV_NAME
898
+ };
899
+ }
900
+
901
+ const fallback = String(env?.[CLOUDFLARE_API_TOKEN_ALT_ENV_NAME] || "").trim();
902
+ if (fallback) {
903
+ return {
904
+ token: fallback,
905
+ source: CLOUDFLARE_API_TOKEN_ALT_ENV_NAME
906
+ };
907
+ }
908
+
909
+ return {
910
+ token: "",
911
+ source: "none"
912
+ };
913
+ }
914
+
915
+ export function buildCloudflareApiTokenSetupGuide() {
916
+ return [
917
+ `Cloudflare deploy requires ${CLOUDFLARE_API_TOKEN_ENV_NAME}.`,
918
+ `Create a User Profile API token in dashboard: ${CLOUDFLARE_API_TOKEN_DASHBOARD_URL}`,
919
+ "Do not use Account API Tokens for this deploy flow.",
920
+ `Token docs: ${CLOUDFLARE_API_TOKEN_GUIDE_URL}`,
921
+ `Recommended preset: ${CLOUDFLARE_API_TOKEN_PRESET_NAME}.`,
922
+ `Then set ${CLOUDFLARE_API_TOKEN_ENV_NAME} in your shell/CI environment.`
923
+ ].join("\n");
924
+ }
925
+
926
+ export function validateCloudflareApiTokenInput(value) {
927
+ const candidate = String(value || "").trim();
928
+ if (!candidate) return `${CLOUDFLARE_API_TOKEN_ENV_NAME} is required for deploy.`;
929
+ return undefined;
930
+ }
931
+
932
+ function buildCloudflareApiTokenTroubleshooting(preflightMessage = "") {
933
+ return [
934
+ preflightMessage,
935
+ "Required token capabilities for wrangler deploy:",
936
+ "- User details: Read",
937
+ "- User memberships: Read",
938
+ `- Account preset/template: ${CLOUDFLARE_API_TOKEN_PRESET_NAME}`,
939
+ `Verify token manually: curl \"${CLOUDFLARE_VERIFY_TOKEN_URL}\" -H \"Authorization: Bearer $${CLOUDFLARE_API_TOKEN_ENV_NAME}\"`,
940
+ buildCloudflareApiTokenSetupGuide()
941
+ ].filter(Boolean).join("\n");
942
+ }
943
+
944
+ function normalizeCloudflareMembershipAccount(entry) {
945
+ if (!entry || typeof entry !== "object") return null;
946
+ const accountObj = entry.account && typeof entry.account === "object" ? entry.account : {};
947
+ const accountId = String(
948
+ accountObj.id
949
+ || entry.account_id
950
+ || entry.accountId
951
+ || entry.id
952
+ || ""
953
+ ).trim();
954
+ if (!accountId) return null;
955
+
956
+ const accountName = String(
957
+ accountObj.name
958
+ || entry.account_name
959
+ || entry.accountName
960
+ || entry.name
961
+ || `Account ${accountId.slice(0, 8)}`
962
+ ).trim();
963
+
964
+ return {
965
+ accountId,
966
+ accountName: accountName || `Account ${accountId.slice(0, 8)}`
967
+ };
968
+ }
969
+
970
+ export function extractCloudflareMembershipAccounts(payload) {
971
+ const list = Array.isArray(payload?.result) ? payload.result : [];
972
+ const map = new Map();
973
+ for (const entry of list) {
974
+ const normalized = normalizeCloudflareMembershipAccount(entry);
975
+ if (!normalized) continue;
976
+ if (!map.has(normalized.accountId)) {
977
+ map.set(normalized.accountId, normalized);
978
+ }
979
+ }
980
+ return Array.from(map.values());
981
+ }
982
+
983
+ function cloudflareErrorFromPayload(payload, fallback) {
984
+ const base = String(fallback || "Unknown Cloudflare API error");
985
+ if (!payload || typeof payload !== "object") return base;
986
+
987
+ const errors = Array.isArray(payload.errors) ? payload.errors : [];
988
+ const first = errors.find((entry) => entry && typeof entry === "object");
989
+ if (!first) return base;
990
+
991
+ const code = Number.isFinite(first.code) ? `code ${first.code}` : "";
992
+ const message = String(first.message || first.error || "").trim();
993
+ if (code && message) return `${message} (${code})`;
994
+ if (message) return message;
995
+ if (code) return code;
996
+ return base;
997
+ }
998
+
999
+ export function evaluateCloudflareTokenVerifyResult(payload) {
1000
+ const status = String(payload?.result?.status || "").toLowerCase();
1001
+ const active = payload?.success === true && status === "active";
1002
+ if (active) {
1003
+ return { ok: true, message: "Token is active." };
1004
+ }
1005
+ return {
1006
+ ok: false,
1007
+ message: cloudflareErrorFromPayload(
1008
+ payload,
1009
+ "Token verification failed. Ensure token is valid and active."
1010
+ )
1011
+ };
1012
+ }
1013
+
1014
+ export function evaluateCloudflareMembershipsResult(payload) {
1015
+ if (payload?.success !== true || !Array.isArray(payload?.result)) {
1016
+ return {
1017
+ ok: false,
1018
+ message: cloudflareErrorFromPayload(
1019
+ payload,
1020
+ "Could not list Cloudflare memberships for this token."
1021
+ )
1022
+ };
1023
+ }
1024
+
1025
+ if (payload.result.length === 0) {
1026
+ return {
1027
+ ok: false,
1028
+ message: "Token can authenticate but has no accessible memberships."
1029
+ };
1030
+ }
1031
+
1032
+ const accounts = extractCloudflareMembershipAccounts(payload);
1033
+ return {
1034
+ ok: true,
1035
+ message: `Token has access to ${payload.result.length} membership(s).`,
1036
+ count: payload.result.length,
1037
+ accounts
1038
+ };
1039
+ }
1040
+
1041
+ async function cloudflareApiGetJson(url, token) {
1042
+ try {
1043
+ const response = await fetch(url, {
1044
+ method: "GET",
1045
+ headers: {
1046
+ Authorization: `Bearer ${token}`
1047
+ },
1048
+ signal: typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
1049
+ ? AbortSignal.timeout(CLOUDFLARE_API_PREFLIGHT_TIMEOUT_MS)
1050
+ : undefined
1051
+ });
1052
+ const rawText = await response.text();
1053
+ const payload = parseJsonSafely(rawText) || {};
1054
+ return {
1055
+ ok: response.ok,
1056
+ status: response.status,
1057
+ payload
1058
+ };
1059
+ } catch (error) {
1060
+ return {
1061
+ ok: false,
1062
+ status: 0,
1063
+ payload: null,
1064
+ error: error instanceof Error ? error.message : String(error)
1065
+ };
1066
+ }
1067
+ }
1068
+
1069
+ async function preflightCloudflareApiToken(token) {
1070
+ const verified = await cloudflareApiGetJson(CLOUDFLARE_VERIFY_TOKEN_URL, token);
1071
+ if (verified.status === 0) {
1072
+ return {
1073
+ ok: false,
1074
+ stage: "verify",
1075
+ message: `Cloudflare token preflight failed while verifying token: ${verified.error || "network error"}`
1076
+ };
1077
+ }
1078
+
1079
+ const verifyEval = evaluateCloudflareTokenVerifyResult(verified.payload);
1080
+ if (!verified.ok || !verifyEval.ok) {
1081
+ return {
1082
+ ok: false,
1083
+ stage: "verify",
1084
+ message: `Cloudflare token verification failed: ${verifyEval.message}`
1085
+ };
1086
+ }
1087
+
1088
+ const memberships = await cloudflareApiGetJson(CLOUDFLARE_MEMBERSHIPS_URL, token);
1089
+ if (memberships.status === 0) {
1090
+ return {
1091
+ ok: false,
1092
+ stage: "memberships",
1093
+ message: `Cloudflare token preflight failed while checking memberships: ${memberships.error || "network error"}`
1094
+ };
1095
+ }
1096
+
1097
+ const membershipEval = evaluateCloudflareMembershipsResult(memberships.payload);
1098
+ if (!memberships.ok || !membershipEval.ok) {
1099
+ return {
1100
+ ok: false,
1101
+ stage: "memberships",
1102
+ message: `Cloudflare memberships check failed: ${membershipEval.message}`
1103
+ };
1104
+ }
1105
+
1106
+ return {
1107
+ ok: true,
1108
+ stage: "ready",
1109
+ message: membershipEval.message,
1110
+ memberships: membershipEval.accounts || []
1111
+ };
1112
+ }
1113
+
1114
+ function buildWranglerCloudflareEnv({
1115
+ apiToken,
1116
+ accountId
1117
+ } = {}) {
1118
+ const env = {};
1119
+ const token = String(apiToken || "").trim();
1120
+ if (token) env[CLOUDFLARE_API_TOKEN_ENV_NAME] = token;
1121
+ const account = String(accountId || "").trim();
1122
+ if (account) env[CLOUDFLARE_ACCOUNT_ID_ENV_NAME] = account;
1123
+ return Object.keys(env).length > 0 ? env : undefined;
1124
+ }
1125
+
1126
+ function formatCloudflareAccountOptions(accounts = []) {
1127
+ return (accounts || []).map((entry) => `\`${entry.accountName}\`: \`${entry.accountId}\``);
1128
+ }
1129
+
1130
+ export function hasNoDeployTargets(outputText = "") {
1131
+ return /no deploy targets/i.test(String(outputText || ""));
1132
+ }
1133
+
1134
+ function parseOptionalBoolean(value) {
1135
+ if (value === undefined || value === null || value === "") return undefined;
1136
+ return toBoolean(value, false);
1137
+ }
1138
+
1139
+ function parseTomlStringField(text, key) {
1140
+ const pattern = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']+)["']\\s*$`, "m");
1141
+ const match = String(text || "").match(pattern);
1142
+ return match?.[1] ? String(match[1]).trim() : "";
1143
+ }
1144
+
1145
+ function topLevelTomlLineInfo(text = "") {
1146
+ const lines = String(text || "").split(/\r?\n/g);
1147
+ const info = [];
1148
+ let currentSection = "";
1149
+
1150
+ for (let index = 0; index < lines.length; index += 1) {
1151
+ const line = lines[index];
1152
+ const trimmed = line.trim();
1153
+ if (/^\s*\[.*\]\s*$/.test(line)) {
1154
+ currentSection = trimmed;
1155
+ }
1156
+ info.push({
1157
+ index,
1158
+ line,
1159
+ trimmed,
1160
+ section: currentSection
1161
+ });
1162
+ }
1163
+
1164
+ return info;
1165
+ }
1166
+
1167
+ export function hasWranglerDeployTargetConfigured(tomlText = "") {
1168
+ const info = topLevelTomlLineInfo(tomlText);
1169
+
1170
+ const hasTopLevelWorkersDev = info.some((entry) =>
1171
+ entry.section === "" && /^\s*workers_dev\s*=\s*true\s*$/i.test(entry.line)
1172
+ );
1173
+ if (hasTopLevelWorkersDev) return true;
1174
+
1175
+ const hasTopLevelRoute = info.some((entry) =>
1176
+ entry.section === "" && /^\s*route\s*=\s*["'][^"']+["']\s*$/i.test(entry.line)
1177
+ );
1178
+ if (hasTopLevelRoute) return true;
1179
+
1180
+ const hasTopLevelRoutes = info.some((entry) =>
1181
+ entry.section === "" && /^\s*routes\s*=\s*\[/i.test(entry.line)
1182
+ );
1183
+ if (hasTopLevelRoutes) return true;
1184
+
1185
+ return false;
1186
+ }
1187
+
1188
+ function stripNonTopLevelRouteDeclarations(text = "") {
1189
+ const lines = String(text || "").split(/\r?\n/g);
1190
+ const output = [];
1191
+ let currentSection = "";
1192
+ let skippingRoutesArray = false;
1193
+
1194
+ for (const line of lines) {
1195
+ const trimmed = line.trim();
1196
+
1197
+ if (/^\s*\[.*\]\s*$/.test(line)) {
1198
+ currentSection = trimmed;
1199
+ skippingRoutesArray = false;
1200
+ output.push(line);
1201
+ continue;
1202
+ }
1203
+
1204
+ if (currentSection && /^\s*route\s*=/.test(line)) {
1205
+ continue;
1206
+ }
1207
+
1208
+ if (currentSection && /^\s*routes\s*=\s*\[/.test(line)) {
1209
+ skippingRoutesArray = true;
1210
+ if (line.includes("]")) {
1211
+ skippingRoutesArray = false;
1212
+ }
1213
+ continue;
1214
+ }
1215
+
1216
+ if (skippingRoutesArray) {
1217
+ if (trimmed.includes("]")) {
1218
+ skippingRoutesArray = false;
1219
+ }
1220
+ continue;
1221
+ }
1222
+
1223
+ output.push(line);
1224
+ }
1225
+
1226
+ return output.join("\n");
1227
+ }
1228
+
1229
+ function insertTopLevelBlockBeforeFirstSection(text = "", block = "") {
1230
+ const source = String(text || "");
1231
+ const blockText = String(block || "").trim();
1232
+ if (!blockText) return source;
1233
+
1234
+ const lines = source.split(/\r?\n/g);
1235
+ const firstSectionIndex = lines.findIndex((line) => /^\s*\[.*\]\s*$/.test(line));
1236
+ if (firstSectionIndex < 0) {
1237
+ const prefix = source.trimEnd();
1238
+ return `${prefix}${prefix ? "\n" : ""}${blockText}\n`;
1239
+ }
1240
+
1241
+ const before = lines.slice(0, firstSectionIndex).join("\n").trimEnd();
1242
+ const after = lines.slice(firstSectionIndex).join("\n").trimStart();
1243
+ return `${before}${before ? "\n" : ""}${blockText}\n\n${after}\n`;
1244
+ }
1245
+
1246
+ function upsertTomlBooleanField(text, key, value) {
1247
+ const normalized = String(text || "");
1248
+ const replacement = `${key} = ${value ? "true" : "false"}`;
1249
+ if (new RegExp(`^\\s*${key}\\s*=`, "m").test(normalized)) {
1250
+ return normalized.replace(new RegExp(`^\\s*${key}\\s*=.*$`, "m"), replacement);
1251
+ }
1252
+ return `${normalized.trimEnd()}\n${replacement}\n`;
1253
+ }
1254
+
1255
+ function stripTopLevelRouteDeclarations(text = "") {
1256
+ const lines = String(text || "").split(/\r?\n/g);
1257
+ const output = [];
1258
+ let currentSection = "";
1259
+ let skippingRoutesArray = false;
1260
+
1261
+ for (const line of lines) {
1262
+ const trimmed = line.trim();
1263
+
1264
+ if (/^\s*\[.*\]\s*$/.test(line)) {
1265
+ currentSection = trimmed;
1266
+ skippingRoutesArray = false;
1267
+ output.push(line);
1268
+ continue;
1269
+ }
1270
+
1271
+ if (!currentSection && /^\s*route\s*=/.test(line)) {
1272
+ continue;
1273
+ }
1274
+
1275
+ if (!currentSection && /^\s*routes\s*=\s*\[/.test(line)) {
1276
+ skippingRoutesArray = true;
1277
+ if (line.includes("]")) {
1278
+ skippingRoutesArray = false;
1279
+ }
1280
+ continue;
1281
+ }
1282
+
1283
+ if (skippingRoutesArray) {
1284
+ if (trimmed.includes("]")) {
1285
+ skippingRoutesArray = false;
1286
+ }
1287
+ continue;
1288
+ }
1289
+
1290
+ output.push(line);
1291
+ }
1292
+
1293
+ return output.join("\n");
1294
+ }
1295
+
1296
+ export function normalizeWranglerRoutePattern(value) {
1297
+ const raw = String(value || "").trim();
1298
+ if (!raw) return "";
1299
+
1300
+ let candidate = raw;
1301
+ if (/^https?:\/\//i.test(candidate)) {
1302
+ try {
1303
+ const parsed = new URL(candidate);
1304
+ candidate = `${parsed.hostname}${parsed.pathname || "/"}`;
1305
+ } catch {
1306
+ return "";
1307
+ }
1308
+ }
1309
+
1310
+ if (candidate.startsWith("/")) return "";
1311
+ if (!candidate.includes("*")) {
1312
+ if (candidate.endsWith("/")) candidate = `${candidate}*`;
1313
+ else if (!candidate.includes("/")) candidate = `${candidate}/*`;
1314
+ }
1315
+
1316
+ return candidate;
1317
+ }
1318
+
1319
+ export function buildDefaultWranglerTomlForDeploy({
1320
+ name = "llm-router-route",
1321
+ main = "src/index.js",
1322
+ compatibilityDate = "2024-01-01",
1323
+ useWorkersDev = false,
1324
+ routePattern = "",
1325
+ zoneName = ""
1326
+ } = {}) {
1327
+ const lines = [
1328
+ `name = "${String(name || "llm-router-route")}"`,
1329
+ `main = "${String(main || "src/index.js")}"`,
1330
+ `compatibility_date = "${String(compatibilityDate || "2024-01-01")}"`,
1331
+ `workers_dev = ${useWorkersDev ? "true" : "false"}`
1332
+ ];
1333
+
1334
+ const normalizedPattern = normalizeWranglerRoutePattern(routePattern);
1335
+ const normalizedZone = String(zoneName || "").trim();
1336
+ if (!useWorkersDev && normalizedPattern && normalizedZone) {
1337
+ lines.push("routes = [");
1338
+ lines.push(` { pattern = "${normalizedPattern}", zone_name = "${normalizedZone}" }`);
1339
+ lines.push("]");
1340
+ }
1341
+
1342
+ lines.push("preview_urls = false");
1343
+ lines.push("");
1344
+ lines.push("[vars]");
1345
+ lines.push('ENVIRONMENT = "production"');
1346
+ lines.push("");
1347
+ return `${lines.join("\n")}`;
1348
+ }
1349
+
1350
+ export function applyWranglerDeployTargetToToml(existingToml, {
1351
+ useWorkersDev = false,
1352
+ routePattern = "",
1353
+ zoneName = "",
1354
+ replaceExistingTarget = false
1355
+ } = {}) {
1356
+ let next = String(existingToml || "");
1357
+ next = stripNonTopLevelRouteDeclarations(next);
1358
+ if (replaceExistingTarget) {
1359
+ next = stripTopLevelRouteDeclarations(next);
1360
+ }
1361
+ next = upsertTomlBooleanField(next, "workers_dev", useWorkersDev);
1362
+
1363
+ if (!useWorkersDev) {
1364
+ const normalizedPattern = normalizeWranglerRoutePattern(routePattern);
1365
+ const normalizedZone = String(zoneName || "").trim();
1366
+ if (normalizedPattern && normalizedZone && (replaceExistingTarget || !hasWranglerDeployTargetConfigured(next))) {
1367
+ const routeBlock = `routes = [\n { pattern = "${normalizedPattern}", zone_name = "${normalizedZone}" }\n]`;
1368
+ next = insertTopLevelBlockBeforeFirstSection(next, routeBlock);
1369
+ }
1370
+ }
1371
+
1372
+ if (!/^\s*preview_urls\s*=/mi.test(next)) {
1373
+ next = `${next.trimEnd()}\npreview_urls = false\n`;
1374
+ }
1375
+
1376
+ return `${next.trimEnd()}\n`;
1377
+ }
1378
+
1379
+ async function createTemporaryWranglerConfigFile(projectDir, tomlText) {
1380
+ await fsPromises.mkdir(projectDir, { recursive: true });
1381
+ const suffix = `${Date.now()}-${randomBytes(4).toString("hex")}`;
1382
+ const wranglerConfigPath = path.join(projectDir, `.llm-router.deploy.${suffix}.wrangler.toml`);
1383
+ await fsPromises.writeFile(wranglerConfigPath, String(tomlText || ""), "utf8");
1384
+
1385
+ let cleaned = false;
1386
+ return {
1387
+ wranglerConfigPath,
1388
+ async cleanup() {
1389
+ if (cleaned) return;
1390
+ cleaned = true;
1391
+ try {
1392
+ await fsPromises.unlink(wranglerConfigPath);
1393
+ } catch (error) {
1394
+ if (!error || error.code !== "ENOENT") {
1395
+ throw error;
1396
+ }
1397
+ }
1398
+ }
1399
+ };
1400
+ }
1401
+
1402
+ async function prepareWranglerDeployConfig(context, {
1403
+ projectDir,
1404
+ args = {},
1405
+ cloudflareApiToken = "",
1406
+ cloudflareAccountId = "",
1407
+ wait = async (_label, fn) => fn()
1408
+ } = {}) {
1409
+ const wranglerPath = path.join(projectDir, "wrangler.toml");
1410
+ const line = typeof context?.terminal?.line === "function"
1411
+ ? context.terminal.line.bind(context.terminal)
1412
+ : console.log;
1413
+
1414
+ let exists = false;
1415
+ let currentToml = "";
1416
+ try {
1417
+ currentToml = await fsPromises.readFile(wranglerPath, "utf8");
1418
+ exists = true;
1419
+ } catch {
1420
+ exists = false;
1421
+ currentToml = "";
1422
+ }
1423
+
1424
+ const workersDevArg = parseOptionalBoolean(readArg(args, ["workers-dev", "workersDev"], undefined));
1425
+ const zoneNameArg = String(readArg(args, ["zone-name", "zoneName"], "") || "").trim();
1426
+ const routePatternArgRaw = String(readArg(args, ["route-pattern", "routePattern"], "") || "").trim();
1427
+ const domainArgRaw = String(readArg(args, ["domain"], "") || "").trim();
1428
+ const routePatternArg = normalizeWranglerRoutePattern(routePatternArgRaw || domainArgRaw);
1429
+ const hasExistingTarget = exists && hasWranglerDeployTargetConfigured(currentToml);
1430
+ const hasExplicitTargetArgs = workersDevArg !== undefined || Boolean(routePatternArg) || Boolean(zoneNameArg);
1431
+
1432
+ if (workersDevArg === undefined && ((routePatternArg && !zoneNameArg) || (!routePatternArg && zoneNameArg))) {
1433
+ return {
1434
+ ok: false,
1435
+ errorMessage: "Custom route deploy target requires both --route-pattern (or --domain) and --zone-name."
1436
+ };
1437
+ }
1438
+
1439
+ if (hasExistingTarget && !hasExplicitTargetArgs) {
1440
+ const tempConfig = await createTemporaryWranglerConfigFile(projectDir, currentToml);
1441
+ return {
1442
+ ok: true,
1443
+ wranglerPath,
1444
+ wranglerConfigPath: tempConfig.wranglerConfigPath,
1445
+ cleanup: tempConfig.cleanup,
1446
+ changed: false,
1447
+ message: ""
1448
+ };
1449
+ }
1450
+
1451
+ let useWorkersDev = workersDevArg === true;
1452
+ let routePattern = routePatternArg;
1453
+ let zoneName = zoneNameArg;
1454
+
1455
+ if (workersDevArg === false && (!routePattern || !zoneName)) {
1456
+ return {
1457
+ ok: false,
1458
+ errorMessage: "workers-dev=false requires both --route-pattern and --zone-name."
1459
+ };
1460
+ }
1461
+
1462
+ if (workersDevArg !== true && (!routePattern || !zoneName)) {
1463
+ if (!canPrompt()) {
1464
+ return {
1465
+ ok: false,
1466
+ errorMessage: [
1467
+ "Wrangler deploy target is not configured.",
1468
+ "Provide one of:",
1469
+ "- --workers-dev=true (quick public workers.dev URL), or",
1470
+ "- --route-pattern=router.example.com/* --zone-name=example.com (custom domain route)."
1471
+ ].join("\n")
1472
+ };
1473
+ }
1474
+
1475
+ const targetMode = await context.prompts.select({
1476
+ message: "No deploy target found. Choose deploy target mode",
1477
+ options: [
1478
+ { value: "workers-dev", label: "Use workers.dev URL (quick start)" },
1479
+ { value: "custom-route", label: "Use custom domain route (production)" }
1480
+ ]
1481
+ });
1482
+
1483
+ if (targetMode === "workers-dev") {
1484
+ useWorkersDev = true;
1485
+ routePattern = "";
1486
+ zoneName = "";
1487
+ } else {
1488
+ const promptedHost = await context.prompts.text({
1489
+ message: "Custom domain host (example: llm.example.com)",
1490
+ required: true,
1491
+ validate: (value) => {
1492
+ const normalized = extractHostnameFromRoutePattern(value);
1493
+ if (!normalized || !normalized.includes(".")) return "Enter a valid domain hostname.";
1494
+ return undefined;
1495
+ }
1496
+ });
1497
+
1498
+ const normalizedHost = extractHostnameFromRoutePattern(promptedHost);
1499
+ const suggestedRoutePattern = normalizeWranglerRoutePattern(`${normalizedHost}/*`);
1500
+ const zones = cloudflareApiToken
1501
+ ? await wait("Loading Cloudflare zones...", () => cloudflareListZones(cloudflareApiToken, cloudflareAccountId), { doneMessage: "Cloudflare zones loaded." })
1502
+ : []; const suggestedZoneFromApi = suggestZoneNameForHostname(normalizedHost, zones);
1503
+ const suggestedZone = suggestedZoneFromApi || inferZoneNameFromHostname(normalizedHost);
1504
+
1505
+ const promptedRoute = await context.prompts.text({
1506
+ message: "Route pattern (example: llm.example.com/*)",
1507
+ required: true,
1508
+ initialValue: suggestedRoutePattern,
1509
+ validate: (value) => {
1510
+ const normalized = normalizeWranglerRoutePattern(value);
1511
+ if (!normalized) return "Enter a valid route pattern.";
1512
+ return undefined;
1513
+ }
1514
+ });
1515
+ const promptedZone = await context.prompts.text({
1516
+ message: "Zone name (example: example.com)",
1517
+ required: true,
1518
+ initialValue: suggestedZone,
1519
+ validate: (value) => String(value || "").trim() ? undefined : "Zone name is required."
1520
+ });
1521
+ useWorkersDev = false;
1522
+ routePattern = normalizeWranglerRoutePattern(promptedRoute);
1523
+ zoneName = String(promptedZone || "").trim();
1524
+
1525
+ const routeHost = extractHostnameFromRoutePattern(routePattern);
1526
+ if (routeHost && zoneName && !isHostnameUnderZone(routeHost, zoneName)) {
1527
+ const proceedMismatch = await context.prompts.confirm({
1528
+ message: `Route host ${routeHost} does not appear under zone ${zoneName}. Continue anyway?`,
1529
+ initialValue: false
1530
+ });
1531
+ if (!proceedMismatch) {
1532
+ return {
1533
+ ok: false,
1534
+ errorMessage: "Cancelled due to route host and zone mismatch."
1535
+ };
1536
+ }
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ const nextToml = exists
1542
+ ? applyWranglerDeployTargetToToml(currentToml, {
1543
+ useWorkersDev,
1544
+ routePattern,
1545
+ zoneName,
1546
+ replaceExistingTarget: hasExplicitTargetArgs
1547
+ })
1548
+ : buildDefaultWranglerTomlForDeploy({
1549
+ name: parseTomlStringField(currentToml, "name") || "llm-router-route",
1550
+ main: parseTomlStringField(currentToml, "main") || "src/index.js",
1551
+ compatibilityDate: parseTomlStringField(currentToml, "compatibility_date") || "2024-01-01",
1552
+ useWorkersDev,
1553
+ routePattern,
1554
+ zoneName
1555
+ });
1556
+
1557
+ const tempConfig = await createTemporaryWranglerConfigFile(projectDir, nextToml);
1558
+
1559
+ if (useWorkersDev) {
1560
+ line("Prepared temporary deploy target: workers_dev=true");
1561
+ } else {
1562
+ line(`Prepared temporary deploy target: route=${routePattern} zone=${zoneName}`);
1563
+ line(buildCloudflareDnsManualGuide({
1564
+ hostname: extractHostnameFromRoutePattern(routePattern),
1565
+ zoneName,
1566
+ routePattern
1567
+ }));
1568
+ }
1569
+
1570
+ return {
1571
+ ok: true,
1572
+ wranglerPath,
1573
+ wranglerConfigPath: tempConfig.wranglerConfigPath,
1574
+ cleanup: tempConfig.cleanup,
1575
+ changed: true,
1576
+ routePattern,
1577
+ zoneName,
1578
+ useWorkersDev,
1579
+ message: useWorkersDev
1580
+ ? "Using workers.dev deploy target (temporary config)."
1581
+ : `Using custom route deploy target (${routePattern}) with temporary config.`
1582
+ };
1583
+ }
1584
+
1585
+
1586
+ function normalizeHostname(value) {
1587
+ return String(value || "")
1588
+ .trim()
1589
+ .toLowerCase()
1590
+ .replace(/^https?:\/\//, "")
1591
+ .replace(/\/.*$/, "")
1592
+ .replace(/:\d+$/, "")
1593
+ .replace(/\.$/, "");
1594
+ }
1595
+
1596
+ export function extractHostnameFromRoutePattern(value) {
1597
+ const route = String(value || "").trim();
1598
+ if (!route) return "";
1599
+
1600
+ if (/^https?:\/\//i.test(route)) {
1601
+ try {
1602
+ return normalizeHostname(new URL(route).hostname);
1603
+ } catch {
1604
+ return "";
1605
+ }
1606
+ }
1607
+
1608
+ const left = route.split("/")[0] || "";
1609
+ return normalizeHostname(left.replace(/\*+$/g, ""));
1610
+ }
1611
+
1612
+ export function inferZoneNameFromHostname(hostname) {
1613
+ const host = normalizeHostname(hostname);
1614
+ if (!host || !host.includes(".")) return "";
1615
+ const labels = host.split(".").filter(Boolean);
1616
+ if (labels.length <= 2) return host;
1617
+ return labels.slice(-2).join(".");
1618
+ }
1619
+
1620
+ export function isHostnameUnderZone(hostname, zoneName) {
1621
+ const host = normalizeHostname(hostname);
1622
+ const zone = normalizeHostname(zoneName);
1623
+ if (!host || !zone) return false;
1624
+ return host === zone || host.endsWith(`.${zone}`);
1625
+ }
1626
+
1627
+ export function suggestZoneNameForHostname(hostname, zones = []) {
1628
+ const host = normalizeHostname(hostname);
1629
+ if (!host) return "";
1630
+
1631
+ let best = "";
1632
+ for (const zone of zones || []) {
1633
+ const candidate = normalizeHostname(zone?.name || zone);
1634
+ if (!candidate) continue;
1635
+ if (host === candidate || host.endsWith(`.${candidate}`)) {
1636
+ if (!best || candidate.length > best.length) {
1637
+ best = candidate;
1638
+ }
1639
+ }
1640
+ }
1641
+ return best;
1642
+ }
1643
+
1644
+ export function buildCloudflareDnsManualGuide({
1645
+ hostname = "",
1646
+ zoneName = "",
1647
+ routePattern = ""
1648
+ } = {}) {
1649
+ const host = normalizeHostname(hostname || extractHostnameFromRoutePattern(routePattern));
1650
+ const zone = normalizeHostname(zoneName || inferZoneNameFromHostname(host));
1651
+ const subdomain = host && zone && host.endsWith(`.${zone}`)
1652
+ ? host.slice(0, -(`.${zone}`).length)
1653
+ : "";
1654
+ const label = subdomain || "<subdomain>";
1655
+
1656
+ return [
1657
+ "Custom domain checklist:",
1658
+ `- Route target: ${routePattern || `${host || "<host>"}/*`} (zone: ${zone || "<zone>"})`,
1659
+ `- DNS: create/update CNAME \`${label}\` -> \`@\` in zone \`${zone || "<zone>"}\``,
1660
+ "- Proxy status must be ON (orange cloud / proxied)",
1661
+ host ? `- Verify DNS: dig +short ${host} @1.1.1.1` : "- Verify DNS: dig +short <host> @1.1.1.1",
1662
+ host ? `- Verify HTTP: curl -I https://${host}/anthropic` : "- Verify HTTP: curl -I https://<host>/anthropic",
1663
+ "- Claude base URL must NOT include :8787 for Cloudflare Worker deployments"
1664
+ ].join("\n");
1665
+ }
1666
+
1667
+ async function cloudflareListZones(token, accountId = "") {
1668
+ const params = new URLSearchParams({ per_page: "50" });
1669
+ if (accountId) params.set("account.id", accountId);
1670
+ const result = await cloudflareApiGetJson(`${CLOUDFLARE_ZONES_URL}?${params.toString()}`, token);
1671
+ if (!result.ok || !Array.isArray(result.payload?.result)) return [];
1672
+ return result.payload.result
1673
+ .map((zone) => ({ id: String(zone?.id || "").trim(), name: normalizeHostname(zone?.name || "") }))
1674
+ .filter((zone) => zone.id && zone.name);
1675
+ }
1676
+ function parseJsonSafely(value) {
1677
+ const text = String(value || "").trim();
1678
+ if (!text) return null;
1679
+ try {
1680
+ return JSON.parse(text);
1681
+ } catch {
1682
+ return null;
1683
+ }
1684
+ }
1685
+
1686
+ function collectCloudflareTierSignals(value, out = [], depth = 0, parentKey = "") {
1687
+ if (depth > 6 || value === null || value === undefined) return out;
1688
+
1689
+ if (typeof value === "string") {
1690
+ if (/(plan|tier|subscription|type|account|membership|name)/i.test(parentKey)) {
1691
+ const normalized = value.trim().toLowerCase();
1692
+ if (normalized) out.push(normalized);
1693
+ }
1694
+ return out;
1695
+ }
1696
+
1697
+ if (Array.isArray(value)) {
1698
+ for (const item of value) {
1699
+ collectCloudflareTierSignals(item, out, depth + 1, parentKey);
1700
+ }
1701
+ return out;
1702
+ }
1703
+
1704
+ if (typeof value === "object") {
1705
+ for (const [key, child] of Object.entries(value)) {
1706
+ collectCloudflareTierSignals(child, out, depth + 1, key);
1707
+ }
1708
+ }
1709
+
1710
+ return out;
1711
+ }
1712
+
1713
+ export function inferCloudflareTierFromWhoami(payload) {
1714
+ if (!payload || typeof payload !== "object") {
1715
+ return {
1716
+ tier: "unknown",
1717
+ reason: "invalid-payload",
1718
+ signals: []
1719
+ };
1720
+ }
1721
+
1722
+ if (payload.loggedIn === false) {
1723
+ return {
1724
+ tier: "unknown",
1725
+ reason: "not-logged-in",
1726
+ signals: []
1727
+ };
1728
+ }
1729
+
1730
+ const signals = [...new Set(collectCloudflareTierSignals(payload))]
1731
+ .slice(0, 12);
1732
+ const freeSignals = signals.filter((entry) => CLOUDFLARE_FREE_TIER_PATTERN.test(entry));
1733
+ const paidSignals = signals.filter((entry) => CLOUDFLARE_PAID_TIER_PATTERN.test(entry));
1734
+
1735
+ if (freeSignals.length > 0 && paidSignals.length > 0) {
1736
+ return {
1737
+ tier: "unknown",
1738
+ reason: "ambiguous-tier",
1739
+ signals
1740
+ };
1741
+ }
1742
+
1743
+ if (freeSignals.length > 0) {
1744
+ return {
1745
+ tier: "free",
1746
+ reason: "detected-free",
1747
+ signals
1748
+ };
1749
+ }
1750
+
1751
+ if (paidSignals.length > 0) {
1752
+ return {
1753
+ tier: "paid",
1754
+ reason: "detected-paid",
1755
+ signals
1756
+ };
1757
+ }
1758
+
1759
+ return {
1760
+ tier: "unknown",
1761
+ reason: "tier-not-found",
1762
+ signals
1763
+ };
1764
+ }
1765
+
1766
+ function detectCloudflareTierViaWrangler(projectDir, cfEnv = "", apiToken = "", accountId = "") {
1767
+ const args = ["whoami", "--json"];
1768
+ if (cfEnv) args.push("--env", cfEnv);
1769
+
1770
+ const result = runWranglerWithNpx(args, {
1771
+ cwd: projectDir,
1772
+ envOverrides: buildWranglerCloudflareEnv({
1773
+ apiToken,
1774
+ accountId
1775
+ })
1776
+ });
1777
+ const parsed = parseJsonSafely(result.stdout) || parseJsonSafely(result.stderr);
1778
+ if (!parsed) {
1779
+ const errorText = `${result.stderr || ""}\n${result.stdout || ""}`.toLowerCase();
1780
+ const reason = errorText.includes("unknown argument: json")
1781
+ ? "whoami-json-not-supported"
1782
+ : (result.ok ? "whoami-unparseable" : "whoami-failed");
1783
+ return {
1784
+ tier: "unknown",
1785
+ reason,
1786
+ signals: [],
1787
+ source: "npx wrangler whoami --json"
1788
+ };
1789
+ }
1790
+
1791
+ return {
1792
+ ...inferCloudflareTierFromWhoami(parsed),
1793
+ source: "npx wrangler whoami --json"
1794
+ };
1795
+ }
1796
+
1797
+ export function shouldConfirmLargeWorkerConfigDeploy({ payloadBytes, tier }) {
1798
+ if (!Number.isFinite(payloadBytes)) return false;
1799
+ if (payloadBytes <= CLOUDFLARE_FREE_SECRET_SIZE_LIMIT_BYTES) return false;
1800
+ return String(tier || "unknown") !== "paid";
1801
+ }
1802
+
1803
+ function formatCloudflareTierLabel(tierReport) {
1804
+ if (tierReport?.tier === "free") return "free";
1805
+ if (tierReport?.tier === "paid") return "paid";
1806
+ return "unknown";
1807
+ }
1808
+
1809
+ function buildLargeWorkerConfigWarningLines({ payloadBytes, tierReport }) {
1810
+ const lines = [
1811
+ `LLM_ROUTER_CONFIG_JSON payload is ${payloadBytes} bytes, above Cloudflare Free tier limit (${CLOUDFLARE_FREE_SECRET_SIZE_LIMIT_BYTES} bytes).`
1812
+ ];
1813
+
1814
+ if (tierReport?.tier === "free") {
1815
+ lines.push("Detected Cloudflare tier: free.");
1816
+ } else if (tierReport?.tier === "paid") {
1817
+ lines.push("Detected Cloudflare tier: paid (no free-tier block expected).");
1818
+ } else {
1819
+ lines.push("Could not reliably determine Cloudflare tier.");
1820
+ lines.push(`Tier check reason: ${tierReport?.reason || "unknown"}.`);
1821
+ }
1822
+
1823
+ return lines;
1824
+ }
1825
+
1826
+ function runNpmInstallLatest(packageName) {
1827
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
1828
+ return runCommand(npmCmd, ["install", "-g", `${packageName}@latest`]);
1829
+ }
1830
+
1831
+ async function stopRunningInstance() {
1832
+ const active = await getActiveRuntimeState();
1833
+ if (active?.managedByStartup) {
1834
+ const stopped = await stopStartup();
1835
+ await clearRuntimeState();
1836
+ return {
1837
+ ok: true,
1838
+ mode: "startup",
1839
+ detail: stopped
1840
+ };
1841
+ }
1842
+
1843
+ if (active) {
1844
+ const stopped = await stopProcessByPid(active.pid);
1845
+ if (!stopped.ok) {
1846
+ return {
1847
+ ok: false,
1848
+ mode: "manual",
1849
+ reason: stopped.reason || `Failed stopping pid ${active.pid}.`
1850
+ };
1851
+ }
1852
+ await clearRuntimeState({ pid: active.pid });
1853
+ return {
1854
+ ok: true,
1855
+ mode: "manual",
1856
+ detail: {
1857
+ pid: active.pid,
1858
+ signal: stopped.signal || "SIGTERM"
1859
+ }
1860
+ };
1861
+ }
1862
+
1863
+ const startup = await startupStatus();
1864
+ if (startup.running) {
1865
+ const stopped = await stopStartup();
1866
+ await clearRuntimeState();
1867
+ return {
1868
+ ok: true,
1869
+ mode: "startup",
1870
+ detail: stopped
1871
+ };
1872
+ }
1873
+
1874
+ return {
1875
+ ok: true,
1876
+ mode: "none",
1877
+ detail: null
1878
+ };
1879
+ }
1880
+
1881
+ async function reloadRunningInstance({
1882
+ terminalLine = () => {},
1883
+ terminalError = () => {},
1884
+ runDetachedForManual = false
1885
+ } = {}) {
1886
+ const active = await getActiveRuntimeState();
1887
+ if (active?.managedByStartup) {
1888
+ const restarted = await restartStartup();
1889
+ await clearRuntimeState();
1890
+ return {
1891
+ ok: true,
1892
+ mode: "startup",
1893
+ detail: restarted
1894
+ };
1895
+ }
1896
+
1897
+ if (active) {
1898
+ const stopped = await stopProcessByPid(active.pid);
1899
+ if (!stopped.ok) {
1900
+ return {
1901
+ ok: false,
1902
+ mode: "manual",
1903
+ reason: stopped.reason || `Failed stopping pid ${active.pid}.`
1904
+ };
1905
+ }
1906
+ await clearRuntimeState({ pid: active.pid });
1907
+ const startArgs = buildStartArgsFromState(active);
1908
+
1909
+ if (runDetachedForManual) {
1910
+ const pid = spawnDetachedStart({
1911
+ cliPath: active.cliPath || process.argv[1],
1912
+ ...startArgs
1913
+ });
1914
+ return {
1915
+ ok: true,
1916
+ mode: "manual-detached",
1917
+ detail: {
1918
+ pid,
1919
+ ...startArgs
1920
+ }
1921
+ };
1922
+ }
1923
+
1924
+ const restarted = await runStartCommand({
1925
+ ...startArgs,
1926
+ cliPathForWatch: process.argv[1],
1927
+ onLine: terminalLine,
1928
+ onError: terminalError
1929
+ });
1930
+
1931
+ return {
1932
+ ok: restarted.ok,
1933
+ mode: "manual-inline",
1934
+ detail: restarted
1935
+ };
1936
+ }
1937
+
1938
+ const startup = await startupStatus();
1939
+ if (startup.running) {
1940
+ const restarted = await restartStartup();
1941
+ await clearRuntimeState();
1942
+ return {
1943
+ ok: true,
1944
+ mode: "startup",
1945
+ detail: restarted
1946
+ };
1947
+ }
1948
+
1949
+ return {
1950
+ ok: false,
1951
+ mode: "none",
1952
+ reason: "No running llm-router instance detected."
1953
+ };
1954
+ }
1955
+
1956
+ function removeModelFromConfig(config, providerId, modelId) {
1957
+ const next = structuredClone(config);
1958
+ const provider = next.providers.find((p) => p.id === providerId);
1959
+ if (!provider) return { config: next, changed: false, reason: `Provider '${providerId}' not found.` };
1960
+
1961
+ const before = provider.models.length;
1962
+ provider.models = provider.models.filter((m) => m.id !== modelId && !(m.aliases || []).includes(modelId));
1963
+ const changed = provider.models.length !== before;
1964
+
1965
+ if (!changed) {
1966
+ return { config: next, changed: false, reason: `Model '${modelId}' not found under '${providerId}'.` };
1967
+ }
1968
+
1969
+ if (next.defaultModel && next.defaultModel.startsWith(`${providerId}/`)) {
1970
+ const exact = next.defaultModel.slice(providerId.length + 1);
1971
+ if (exact === modelId) {
1972
+ next.defaultModel = provider.models[0] ? `${providerId}/${provider.models[0].id}` : undefined;
1973
+ }
1974
+ }
1975
+
1976
+ return { config: next, changed: true };
1977
+ }
1978
+
1979
+ function resolveProviderAndModel(config, providerId, modelId) {
1980
+ const provider = (config.providers || []).find((item) => item.id === providerId);
1981
+ if (!provider) {
1982
+ return { provider: null, model: null, reason: `Provider '${providerId}' not found.` };
1983
+ }
1984
+
1985
+ const model = (provider.models || []).find((item) => item.id === modelId || (item.aliases || []).includes(modelId));
1986
+ if (!model) {
1987
+ return { provider, model: null, reason: `Model '${modelId}' not found under '${providerId}'.` };
1988
+ }
1989
+
1990
+ return { provider, model, reason: "" };
1991
+ }
1992
+
1993
+ function listFallbackModelOptions(config, providerId, modelId) {
1994
+ const self = `${providerId}/${modelId}`;
1995
+ const options = [];
1996
+
1997
+ for (const provider of (config.providers || [])) {
1998
+ for (const model of (provider.models || [])) {
1999
+ const qualified = `${provider.id}/${model.id}`;
2000
+ if (qualified === self) continue;
2001
+ options.push({
2002
+ value: qualified,
2003
+ label: qualified
2004
+ });
2005
+ }
2006
+ }
2007
+
2008
+ return options;
2009
+ }
2010
+
2011
+ function setModelFallbacksInConfig(config, providerId, modelId, fallbackModels) {
2012
+ const next = structuredClone(config);
2013
+ const resolved = resolveProviderAndModel(next, providerId, modelId);
2014
+ if (!resolved.provider || !resolved.model) {
2015
+ return { config: next, changed: false, reason: resolved.reason || "Provider/model not found." };
2016
+ }
2017
+
2018
+ const canonicalModelId = resolved.model.id;
2019
+ const options = listFallbackModelOptions(next, providerId, canonicalModelId);
2020
+ const availableSet = new Set(options.map((option) => option.value));
2021
+ const nextFallbacks = dedupeList((fallbackModels || []).map((entry) => String(entry || "").trim()).filter(Boolean));
2022
+ const invalidEntries = nextFallbacks.filter((entry) => !availableSet.has(entry));
2023
+ if (invalidEntries.length > 0) {
2024
+ return {
2025
+ config: next,
2026
+ changed: false,
2027
+ reason: `Invalid fallback model(s): ${invalidEntries.join(", ")}.`,
2028
+ invalidEntries
2029
+ };
2030
+ }
2031
+
2032
+ const currentFallbacks = dedupeList(resolved.model.fallbackModels || []);
2033
+ const changed = currentFallbacks.join("\n") !== nextFallbacks.join("\n");
2034
+ resolved.model.fallbackModels = nextFallbacks;
2035
+
2036
+ return {
2037
+ config: next,
2038
+ changed,
2039
+ reason: "",
2040
+ modelId: canonicalModelId,
2041
+ fallbackModels: nextFallbacks
2042
+ };
2043
+ }
2044
+
2045
+ function setMasterKeyInConfig(config, masterKey) {
2046
+ return {
2047
+ ...config,
2048
+ masterKey
2049
+ };
2050
+ }
2051
+
2052
+ async function resolveUpsertInput(context, existingConfig) {
2053
+ const args = context.args || {};
2054
+ const configPath = readArg(args, ["config", "configPath"], getDefaultConfigPath());
2055
+ const providers = existingConfig.providers || [];
2056
+
2057
+ const argProviderId = String(readArg(args, ["provider-id", "providerId"], "") || "");
2058
+ let selectedExisting = null;
2059
+
2060
+ if (canPrompt() && !argProviderId && providers.length > 0) {
2061
+ const choice = await context.prompts.select({
2062
+ message: "Provider config action",
2063
+ options: [
2064
+ { value: "__new__", label: "Add new provider" },
2065
+ ...providers.map((provider) => ({
2066
+ value: provider.id,
2067
+ label: `Edit ${provider.id}`,
2068
+ hint: `${provider.baseUrl}`
2069
+ }))
2070
+ ]
2071
+ });
2072
+ if (choice !== "__new__") {
2073
+ selectedExisting = providers.find((p) => p.id === choice) || null;
2074
+ }
2075
+ } else if (argProviderId) {
2076
+ selectedExisting = providers.find((p) => p.id === argProviderId) || null;
2077
+ }
2078
+
2079
+ const baseProviderId = argProviderId || selectedExisting?.id || "";
2080
+ const baseName = String(readArg(args, ["name"], selectedExisting?.name || "") || "");
2081
+ const baseUrl = String(readArg(args, ["base-url", "baseUrl"], selectedExisting?.baseUrl || "") || "");
2082
+ const baseEndpoints = parseEndpointListInput(readArg(
2083
+ args,
2084
+ ["endpoints"],
2085
+ providerEndpointsFromConfig(selectedExisting).join(",")
2086
+ ));
2087
+ const baseOpenAIBaseUrl = String(readArg(
2088
+ args,
2089
+ ["openai-base-url", "openaiBaseUrl"],
2090
+ selectedExisting?.baseUrlByFormat?.openai || ""
2091
+ ) || "");
2092
+ const baseClaudeBaseUrl = String(readArg(
2093
+ args,
2094
+ ["claude-base-url", "claudeBaseUrl", "anthropic-base-url", "anthropicBaseUrl"],
2095
+ selectedExisting?.baseUrlByFormat?.claude || ""
2096
+ ) || "");
2097
+ const baseApiKey = String(readArg(args, ["api-key", "apiKey"], "") || "");
2098
+ const baseModels = String(readArg(args, ["models"], (selectedExisting?.models || []).map((m) => m.id).join(",")) || "");
2099
+ const baseFormat = String(readArg(args, ["format"], selectedExisting?.format || "") || "");
2100
+ const baseFormats = parseModelListInput(readArg(args, ["formats"], (selectedExisting?.formats || []).join(",")));
2101
+ const hasHeadersArg = args.headers !== undefined;
2102
+ const baseHeaders = readArg(args, ["headers"], selectedExisting?.headers ? JSON.stringify(selectedExisting.headers) : "");
2103
+ const shouldProbe = !toBoolean(readArg(args, ["skip-probe", "skipProbe"], false), false);
2104
+ const setMasterKeyFlag = toBoolean(readArg(args, ["set-master-key", "setMasterKey"], false), false);
2105
+ const providedMasterKey = String(readArg(args, ["master-key", "masterKey"], "") || "");
2106
+ const parsedHeaders = applyDefaultHeaders(
2107
+ parseJsonObjectArg(baseHeaders, "--headers"),
2108
+ { force: !hasHeadersArg }
2109
+ );
2110
+
2111
+ if (!canPrompt()) {
2112
+ return {
2113
+ configPath,
2114
+ providerId: baseProviderId || slugifyId(baseName || "provider"),
2115
+ name: baseName,
2116
+ baseUrl,
2117
+ endpoints: baseEndpoints,
2118
+ openaiBaseUrl: baseOpenAIBaseUrl,
2119
+ claudeBaseUrl: baseClaudeBaseUrl,
2120
+ apiKey: baseApiKey || selectedExisting?.apiKey || "",
2121
+ models: parseProviderModelListInput(baseModels),
2122
+ format: baseFormat,
2123
+ formats: baseFormats,
2124
+ headers: parsedHeaders,
2125
+ shouldProbe,
2126
+ setMasterKey: setMasterKeyFlag || Boolean(providedMasterKey),
2127
+ masterKey: providedMasterKey
2128
+ };
2129
+ }
2130
+
2131
+ printProviderInputGuidance(context);
2132
+
2133
+ const name = baseName || await context.prompts.text({
2134
+ message: "Provider Friendly Name (unique, shown in management screen)",
2135
+ required: true,
2136
+ placeholder: "OpenRouter Primary",
2137
+ validate: (value) => {
2138
+ const candidate = String(value || "").trim();
2139
+ if (!candidate) return "Provider Friendly Name is required.";
2140
+ const duplicate = findProviderByFriendlyName(providers, candidate, { excludeId: selectedExisting?.id || baseProviderId });
2141
+ if (duplicate) return `Provider Friendly Name '${candidate}' already exists (provider-id: ${duplicate.id}). Use a unique name.`;
2142
+ return undefined;
2143
+ }
2144
+ });
2145
+
2146
+ const providerId = baseProviderId || await context.prompts.text({
2147
+ message: "Provider ID (auto-slug from Friendly Name; editable)",
2148
+ required: true,
2149
+ initialValue: slugifyId(name),
2150
+ placeholder: "openrouterPrimary",
2151
+ validate: (value) => {
2152
+ const candidate = String(value || "").trim();
2153
+ if (!candidate) return "Provider ID is required.";
2154
+ if (!PROVIDER_ID_PATTERN.test(candidate)) {
2155
+ return "Use slug/camelCase with letters, numbers, underscore, dot, or hyphen (e.g. openrouterPrimary).";
2156
+ }
2157
+ return undefined;
2158
+ }
2159
+ });
2160
+
2161
+ const askReplaceKey = selectedExisting?.apiKey ? await context.prompts.confirm({
2162
+ message: "Replace saved API key?",
2163
+ initialValue: false
2164
+ }) : true;
2165
+
2166
+ const apiKey = (baseApiKey || (!askReplaceKey ? selectedExisting?.apiKey : "")) || await promptSecretInput(context, {
2167
+ message: "Provider API key",
2168
+ required: true,
2169
+ validate: (value) => {
2170
+ const candidate = String(value || "").trim();
2171
+ if (!candidate) return "Provider API key is required.";
2172
+ return undefined;
2173
+ }
2174
+ });
2175
+
2176
+ const endpointsInput = await context.prompts.text({
2177
+ message: "Provider endpoints (comma / ; / space / newline separated; multiline paste supported)",
2178
+ required: true,
2179
+ initialValue: baseEndpoints.join(","),
2180
+ paste: true,
2181
+ multiline: true
2182
+ });
2183
+ const endpoints = parseEndpointListInput(endpointsInput);
2184
+ maybeReportInputCleanup(context, "endpoint", endpointsInput, endpoints);
2185
+
2186
+ const modelsInput = await context.prompts.text({
2187
+ message: "Provider models (comma / ; / space / newline separated; multiline paste supported)",
2188
+ required: true,
2189
+ initialValue: baseModels,
2190
+ paste: true,
2191
+ multiline: true
2192
+ });
2193
+ const models = parseProviderModelListInput(modelsInput);
2194
+ maybeReportInputCleanup(context, "model", modelsInput, models);
2195
+
2196
+ const headersInput = await context.prompts.text({
2197
+ message: "Custom headers JSON (optional; default User-Agent included)",
2198
+ initialValue: JSON.stringify(applyDefaultHeaders(
2199
+ parseJsonObjectArg(baseHeaders, "Custom headers"),
2200
+ { force: true }
2201
+ ))
2202
+ });
2203
+ const interactiveHeaders = parseJsonObjectArg(headersInput, "Custom headers");
2204
+
2205
+ const probe = await context.prompts.confirm({
2206
+ message: "Auto-detect endpoint formats and model support via live probe?",
2207
+ initialValue: shouldProbe
2208
+ });
2209
+
2210
+ let manualFormat = baseFormat;
2211
+ if (!probe) {
2212
+ manualFormat = await promptProviderFormat(context, {
2213
+ message: "Primary provider format",
2214
+ initialFormat: manualFormat
2215
+ });
2216
+ }
2217
+
2218
+ const setMasterKey = setMasterKeyFlag || await context.prompts.confirm({
2219
+ message: "Set/update worker master key?",
2220
+ initialValue: false
2221
+ });
2222
+ let masterKey = providedMasterKey;
2223
+ if (setMasterKey && !masterKey) {
2224
+ masterKey = await context.prompts.text({
2225
+ message: "Worker master key",
2226
+ required: true
2227
+ });
2228
+ }
2229
+
2230
+ return {
2231
+ configPath,
2232
+ providerId,
2233
+ name,
2234
+ baseUrl,
2235
+ endpoints,
2236
+ openaiBaseUrl: baseOpenAIBaseUrl,
2237
+ claudeBaseUrl: baseClaudeBaseUrl,
2238
+ apiKey,
2239
+ models,
2240
+ format: probe ? "" : manualFormat,
2241
+ formats: baseFormats,
2242
+ headers: interactiveHeaders,
2243
+ shouldProbe: probe,
2244
+ setMasterKey,
2245
+ masterKey
2246
+ };
2247
+ }
2248
+
2249
+ async function doUpsertProvider(context) {
2250
+ const configPath = readArg(context.args, ["config", "configPath"], getDefaultConfigPath());
2251
+ const existingConfig = await readConfigFile(configPath);
2252
+ const input = await resolveUpsertInput(context, existingConfig);
2253
+
2254
+ const endpointCandidates = parseEndpointListInput([
2255
+ ...(input.endpoints || []),
2256
+ input.openaiBaseUrl,
2257
+ input.claudeBaseUrl,
2258
+ input.baseUrl
2259
+ ].filter(Boolean).join(","));
2260
+ const hasAnyEndpoint = endpointCandidates.length > 0;
2261
+ if (!input.name || !hasAnyEndpoint || !input.apiKey) {
2262
+ return {
2263
+ ok: false,
2264
+ mode: context.mode,
2265
+ exitCode: EXIT_VALIDATION,
2266
+ errorMessage: "Missing provider inputs: provider-id, name, api-key, and at least one endpoint."
2267
+ };
2268
+ }
2269
+
2270
+ if (!PROVIDER_ID_PATTERN.test(input.providerId)) {
2271
+ return {
2272
+ ok: false,
2273
+ mode: context.mode,
2274
+ exitCode: EXIT_VALIDATION,
2275
+ errorMessage: `Invalid provider id '${input.providerId}'. Use slug/camelCase (e.g. openrouter or myProvider).`
2276
+ };
2277
+ }
2278
+
2279
+ const duplicateFriendlyName = findProviderByFriendlyName(existingConfig.providers || [], input.name, {
2280
+ excludeId: input.providerId
2281
+ });
2282
+ if (duplicateFriendlyName) {
2283
+ return {
2284
+ ok: false,
2285
+ mode: context.mode,
2286
+ exitCode: EXIT_VALIDATION,
2287
+ errorMessage: `Provider Friendly Name '${input.name}' already exists (provider-id: ${duplicateFriendlyName.id}). Choose a unique name.`
2288
+ };
2289
+ }
2290
+
2291
+ let probe = null;
2292
+ let selectedFormat = String(input.format || "").trim();
2293
+ let effectiveBaseUrl = String(input.baseUrl || "").trim();
2294
+ let effectiveOpenAIBaseUrl = String(input.openaiBaseUrl || "").trim();
2295
+ let effectiveClaudeBaseUrl = String(input.claudeBaseUrl || "").trim();
2296
+ let effectiveModels = [...(input.models || [])];
2297
+
2298
+ if (input.shouldProbe && endpointCandidates.length > 0 && effectiveModels.length === 0) {
2299
+ return {
2300
+ ok: false,
2301
+ mode: context.mode,
2302
+ exitCode: EXIT_VALIDATION,
2303
+ errorMessage: "Model list is required for endpoint-model probe. Provide --models=modelA,modelB."
2304
+ };
2305
+ }
2306
+
2307
+ if (input.shouldProbe) {
2308
+ const startedAt = Date.now();
2309
+ const reportProgress = probeProgressReporter(context);
2310
+ const canRunMatrixProbe = endpointCandidates.length > 0 && effectiveModels.length > 0;
2311
+ if (canRunMatrixProbe) {
2312
+ probe = await probeProviderEndpointMatrix({
2313
+ endpoints: endpointCandidates,
2314
+ models: effectiveModels,
2315
+ apiKey: input.apiKey,
2316
+ headers: input.headers,
2317
+ onProgress: reportProgress
2318
+ });
2319
+ effectiveOpenAIBaseUrl = probe.baseUrlByFormat?.openai || effectiveOpenAIBaseUrl;
2320
+ effectiveClaudeBaseUrl = probe.baseUrlByFormat?.claude || effectiveClaudeBaseUrl;
2321
+ effectiveBaseUrl =
2322
+ (probe.preferredFormat && probe.baseUrlByFormat?.[probe.preferredFormat]) ||
2323
+ effectiveOpenAIBaseUrl ||
2324
+ effectiveClaudeBaseUrl ||
2325
+ endpointCandidates[0] ||
2326
+ effectiveBaseUrl;
2327
+ if ((probe.models || []).length > 0) {
2328
+ effectiveModels = effectiveModels.length > 0
2329
+ ? effectiveModels.filter((model) => (probe.models || []).includes(model))
2330
+ : [...probe.models];
2331
+ }
2332
+ } else {
2333
+ const probeBaseUrlByFormat = {};
2334
+ if (effectiveOpenAIBaseUrl) probeBaseUrlByFormat.openai = effectiveOpenAIBaseUrl;
2335
+ if (effectiveClaudeBaseUrl) probeBaseUrlByFormat.claude = effectiveClaudeBaseUrl;
2336
+
2337
+ probe = await probeProvider({
2338
+ baseUrl: effectiveBaseUrl || endpointCandidates[0],
2339
+ baseUrlByFormat: Object.keys(probeBaseUrlByFormat).length > 0 ? probeBaseUrlByFormat : undefined,
2340
+ apiKey: input.apiKey,
2341
+ headers: input.headers,
2342
+ onProgress: reportProgress
2343
+ });
2344
+ }
2345
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
2346
+ if (line) {
2347
+ const tookMs = Date.now() - startedAt;
2348
+ line(`Auto-discovery finished in ${(tookMs / 1000).toFixed(1)}s.`);
2349
+ }
2350
+
2351
+ if (!probe.ok) {
2352
+ if (canPrompt()) {
2353
+ const continueWithoutProbe = await context.prompts.confirm({
2354
+ message: "Probe failed to confirm working endpoint/model support. Save provider anyway?",
2355
+ initialValue: false
2356
+ });
2357
+ if (!continueWithoutProbe) {
2358
+ return {
2359
+ ok: false,
2360
+ mode: context.mode,
2361
+ exitCode: EXIT_FAILURE,
2362
+ errorMessage: "Config cancelled because provider probe failed."
2363
+ };
2364
+ }
2365
+
2366
+ selectedFormat = await promptProviderFormat(context, {
2367
+ message: "Probe could not confirm a working format. Choose primary provider format",
2368
+ initialFormat: selectedFormat
2369
+ });
2370
+ } else {
2371
+ return {
2372
+ ok: false,
2373
+ mode: context.mode,
2374
+ exitCode: EXIT_FAILURE,
2375
+ errorMessage: "Provider probe failed. Provide valid endpoints/models or use --skip-probe=true to force save."
2376
+ };
2377
+ }
2378
+ } else {
2379
+ selectedFormat = probe.preferredFormat || selectedFormat;
2380
+ }
2381
+ }
2382
+
2383
+ if (!input.shouldProbe) {
2384
+ if (!effectiveBaseUrl && endpointCandidates.length > 0) {
2385
+ effectiveBaseUrl = endpointCandidates[0];
2386
+ }
2387
+ if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length === 1 && selectedFormat) {
2388
+ if (selectedFormat === "openai") effectiveOpenAIBaseUrl = endpointCandidates[0];
2389
+ if (selectedFormat === "claude") effectiveClaudeBaseUrl = endpointCandidates[0];
2390
+ }
2391
+ if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length > 1) {
2392
+ return {
2393
+ ok: false,
2394
+ mode: context.mode,
2395
+ exitCode: EXIT_VALIDATION,
2396
+ errorMessage: "Multiple endpoints require probe mode (recommended) or explicit --openai-base-url/--claude-base-url."
2397
+ };
2398
+ }
2399
+ }
2400
+
2401
+ const effectiveFormat = selectedFormat || (input.shouldProbe ? "" : "openai");
2402
+
2403
+ const provider = buildProviderFromConfigInput({
2404
+ providerId: input.providerId,
2405
+ name: input.name,
2406
+ baseUrl: effectiveBaseUrl,
2407
+ openaiBaseUrl: effectiveOpenAIBaseUrl,
2408
+ claudeBaseUrl: effectiveClaudeBaseUrl,
2409
+ apiKey: input.apiKey,
2410
+ models: effectiveModels,
2411
+ format: effectiveFormat,
2412
+ formats: input.formats,
2413
+ headers: input.headers,
2414
+ probe
2415
+ });
2416
+
2417
+ if (!provider.models || provider.models.length === 0) {
2418
+ return {
2419
+ ok: false,
2420
+ mode: context.mode,
2421
+ exitCode: EXIT_VALIDATION,
2422
+ errorMessage: "Provider must have at least one model. Add --models or enable probe discovery."
2423
+ };
2424
+ }
2425
+
2426
+ const nextConfig = applyConfigChanges(existingConfig, {
2427
+ provider,
2428
+ masterKey: input.setMasterKey ? input.masterKey : existingConfig.masterKey,
2429
+ setDefaultModel: true
2430
+ });
2431
+
2432
+ await writeConfigFile(nextConfig, input.configPath);
2433
+ return {
2434
+ ok: true,
2435
+ mode: context.mode,
2436
+ exitCode: EXIT_SUCCESS,
2437
+ data: [
2438
+ `Saved provider '${provider.id}' to ${input.configPath}`,
2439
+ probe
2440
+ ? `probe preferred=${probe.preferredFormat || "(none)"} working=${(probe.workingFormats || []).join(",") || "(none)"}`
2441
+ : "probe=skipped",
2442
+ provider.baseUrlByFormat?.openai ? `openaiBaseUrl=${provider.baseUrlByFormat.openai}` : "",
2443
+ provider.baseUrlByFormat?.claude ? `claudeBaseUrl=${provider.baseUrlByFormat.claude}` : "",
2444
+ `formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`,
2445
+ `models=${provider.models.map((m) => `${m.id}${m.formats?.length ? `[${m.formats.join("|")}]` : ""}`).join(", ")}`,
2446
+ `masterKey=${nextConfig.masterKey ? maskSecret(nextConfig.masterKey) : "(not set)"}`
2447
+ ].join("\n")
2448
+ };
2449
+ }
2450
+
2451
+ async function doListConfig(context) {
2452
+ const configPath = readArg(context.args, ["config", "configPath"], getDefaultConfigPath());
2453
+ const config = await readConfigFile(configPath);
2454
+ return {
2455
+ ok: true,
2456
+ mode: context.mode,
2457
+ exitCode: EXIT_SUCCESS,
2458
+ data: summarizeConfig(config, configPath)
2459
+ };
2460
+ }
2461
+
2462
+ async function doRemoveProvider(context) {
2463
+ const args = context.args || {};
2464
+ const configPath = readArg(args, ["config", "configPath"], getDefaultConfigPath());
2465
+ const config = await readConfigFile(configPath);
2466
+ let providerId = String(readArg(args, ["provider-id", "providerId"], "") || "");
2467
+
2468
+ if (canPrompt() && !providerId) {
2469
+ if (!config.providers.length) {
2470
+ return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: "No providers to remove." };
2471
+ }
2472
+ providerId = await context.prompts.select({
2473
+ message: "Remove provider",
2474
+ options: config.providers.map((provider) => ({
2475
+ value: provider.id,
2476
+ label: provider.id,
2477
+ hint: `${provider.models.length} model(s)`
2478
+ }))
2479
+ });
2480
+ }
2481
+
2482
+ if (!providerId) {
2483
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: "provider-id is required." };
2484
+ }
2485
+
2486
+ const exists = config.providers.some((p) => p.id === providerId);
2487
+ if (!exists) {
2488
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: `Provider '${providerId}' not found.` };
2489
+ }
2490
+
2491
+ if (canPrompt()) {
2492
+ const confirm = await context.prompts.confirm({ message: `Delete provider '${providerId}'?`, initialValue: false });
2493
+ if (!confirm) {
2494
+ return { ok: false, mode: context.mode, exitCode: EXIT_FAILURE, errorMessage: "Cancelled." };
2495
+ }
2496
+ }
2497
+
2498
+ let nextConfig = removeProvider(config, providerId);
2499
+ if (nextConfig.defaultModel?.startsWith(`${providerId}/`)) {
2500
+ const fallbackProvider = nextConfig.providers[0];
2501
+ nextConfig = {
2502
+ ...nextConfig,
2503
+ defaultModel: fallbackProvider?.models?.[0] ? `${fallbackProvider.id}/${fallbackProvider.models[0].id}` : undefined
2504
+ };
2505
+ }
2506
+
2507
+ await writeConfigFile(nextConfig, configPath);
2508
+ return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Removed provider '${providerId}'.` };
2509
+ }
2510
+
2511
+ async function doRemoveModel(context) {
2512
+ const args = context.args || {};
2513
+ const configPath = readArg(args, ["config", "configPath"], getDefaultConfigPath());
2514
+ const config = await readConfigFile(configPath);
2515
+ let providerId = String(readArg(args, ["provider-id", "providerId"], "") || "");
2516
+ let modelId = String(readArg(args, ["model"], "") || "");
2517
+
2518
+ if (canPrompt()) {
2519
+ if (!providerId) {
2520
+ if (!config.providers.length) {
2521
+ return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: "No providers configured." };
2522
+ }
2523
+ providerId = await context.prompts.select({
2524
+ message: "Select provider",
2525
+ options: config.providers.map((provider) => ({
2526
+ value: provider.id,
2527
+ label: provider.id,
2528
+ hint: `${provider.models.length} model(s)`
2529
+ }))
2530
+ });
2531
+ }
2532
+ const provider = config.providers.find((p) => p.id === providerId);
2533
+ if (!provider) {
2534
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: `Provider '${providerId}' not found.` };
2535
+ }
2536
+ if (!modelId) {
2537
+ if (!provider.models.length) {
2538
+ return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no models.` };
2539
+ }
2540
+ modelId = await context.prompts.select({
2541
+ message: `Remove model from ${providerId}`,
2542
+ options: provider.models.map((model) => ({
2543
+ value: model.id,
2544
+ label: model.id
2545
+ }))
2546
+ });
2547
+ }
2548
+ }
2549
+
2550
+ if (!providerId || !modelId) {
2551
+ return {
2552
+ ok: false,
2553
+ mode: context.mode,
2554
+ exitCode: EXIT_VALIDATION,
2555
+ errorMessage: "provider-id and model are required."
2556
+ };
2557
+ }
2558
+
2559
+ const removal = removeModelFromConfig(config, providerId, modelId);
2560
+ if (!removal.changed) {
2561
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: removal.reason };
2562
+ }
2563
+ await writeConfigFile(removal.config, configPath);
2564
+ return {
2565
+ ok: true,
2566
+ mode: context.mode,
2567
+ exitCode: EXIT_SUCCESS,
2568
+ data: `Removed model '${modelId}' from '${providerId}'.`
2569
+ };
2570
+ }
2571
+
2572
+ async function doSetModelFallbacks(context) {
2573
+ const args = context.args || {};
2574
+ const configPath = readArg(args, ["config", "configPath"], getDefaultConfigPath());
2575
+ const config = await readConfigFile(configPath);
2576
+ let providerId = String(readArg(args, ["provider-id", "providerId"], "") || "");
2577
+ let modelId = String(readArg(args, ["model"], "") || "");
2578
+ const hasFallbackModelsArg =
2579
+ Object.prototype.hasOwnProperty.call(args, "fallback-models") ||
2580
+ Object.prototype.hasOwnProperty.call(args, "fallbackModels") ||
2581
+ Object.prototype.hasOwnProperty.call(args, "fallbacks");
2582
+ const fallbackModelsRaw = hasFallbackModelsArg
2583
+ ? (args["fallback-models"] ?? args.fallbackModels ?? args.fallbacks ?? "")
2584
+ : "";
2585
+ const clearFallbacks = toBoolean(readArg(args, ["clear-fallbacks", "clearFallbacks"], false), false);
2586
+ let selectedFallbacks = clearFallbacks ? [] : parseQualifiedModelListInput(fallbackModelsRaw);
2587
+
2588
+ if (canPrompt()) {
2589
+ if (!providerId) {
2590
+ if (!config.providers.length) {
2591
+ return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: "No providers configured." };
2592
+ }
2593
+ providerId = await context.prompts.select({
2594
+ message: "Select provider for silent-fallback",
2595
+ options: config.providers.map((provider) => ({
2596
+ value: provider.id,
2597
+ label: provider.id,
2598
+ hint: `${provider.models.length} model(s)`
2599
+ }))
2600
+ });
2601
+ }
2602
+
2603
+ const resolved = resolveProviderAndModel(config, providerId, modelId);
2604
+ const provider = resolved.provider;
2605
+ if (!provider) {
2606
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: resolved.reason };
2607
+ }
2608
+
2609
+ if (!modelId) {
2610
+ if (!provider.models.length) {
2611
+ return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no models.` };
2612
+ }
2613
+ modelId = await context.prompts.select({
2614
+ message: `Select source model from ${providerId}`,
2615
+ options: provider.models.map((model) => ({
2616
+ value: model.id,
2617
+ label: model.id
2618
+ }))
2619
+ });
2620
+ } else if (!resolved.model) {
2621
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: resolved.reason };
2622
+ }
2623
+
2624
+ const resolvedModel = resolveProviderAndModel(config, providerId, modelId);
2625
+ if (!resolvedModel.model) {
2626
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: resolvedModel.reason };
2627
+ }
2628
+
2629
+ const sourceModelId = resolvedModel.model.id;
2630
+ const fallbackOptions = listFallbackModelOptions(config, providerId, sourceModelId);
2631
+ const fallbackOptionSet = new Set(fallbackOptions.map((option) => option.value));
2632
+ const currentFallbacks = dedupeList(resolvedModel.model.fallbackModels || [])
2633
+ .filter((entry) => fallbackOptionSet.has(entry));
2634
+ const initialValues = selectedFallbacks.length > 0
2635
+ ? selectedFallbacks.filter((entry) => fallbackOptionSet.has(entry))
2636
+ : currentFallbacks;
2637
+
2638
+ if (fallbackOptions.length === 0) {
2639
+ selectedFallbacks = [];
2640
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
2641
+ line?.("No other models available. Silent-fallback list will be cleared.");
2642
+ } else {
2643
+ selectedFallbacks = await context.prompts.multiselect({
2644
+ message: `Silent-fallback models for ${providerId}/${sourceModelId}`,
2645
+ options: fallbackOptions,
2646
+ initialValues,
2647
+ required: false
2648
+ });
2649
+ }
2650
+
2651
+ modelId = sourceModelId;
2652
+ }
2653
+
2654
+ if (!providerId || !modelId) {
2655
+ return {
2656
+ ok: false,
2657
+ mode: context.mode,
2658
+ exitCode: EXIT_VALIDATION,
2659
+ errorMessage: "provider-id and model are required."
2660
+ };
2661
+ }
2662
+
2663
+ if (!canPrompt() && !hasFallbackModelsArg && !clearFallbacks) {
2664
+ return {
2665
+ ok: false,
2666
+ mode: context.mode,
2667
+ exitCode: EXIT_VALIDATION,
2668
+ errorMessage: "fallback-models is required (or use --clear-fallbacks=true)."
2669
+ };
2670
+ }
2671
+
2672
+ const updated = setModelFallbacksInConfig(config, providerId, modelId, selectedFallbacks);
2673
+ if (!updated.changed && updated.reason) {
2674
+ return {
2675
+ ok: false,
2676
+ mode: context.mode,
2677
+ exitCode: EXIT_VALIDATION,
2678
+ errorMessage: updated.reason
2679
+ };
2680
+ }
2681
+
2682
+ await writeConfigFile(updated.config, configPath);
2683
+ return {
2684
+ ok: true,
2685
+ mode: context.mode,
2686
+ exitCode: EXIT_SUCCESS,
2687
+ data: [
2688
+ `Updated silent-fallback models for '${providerId}/${updated.modelId || modelId}'.`,
2689
+ `fallbacks=${(updated.fallbackModels || []).join(", ") || "(none)"}`
2690
+ ].join("\n")
2691
+ };
2692
+ }
2693
+
2694
+ async function doSetMasterKey(context) {
2695
+ const args = context.args || {};
2696
+ const configPath = readArg(args, ["config", "configPath"], getDefaultConfigPath());
2697
+ const config = await readConfigFile(configPath);
2698
+ let masterKey = String(readArg(args, ["master-key", "masterKey"], "") || "");
2699
+ const generateMasterKey = toBoolean(readArg(args, ["generate-master-key", "generateMasterKey"], false), false);
2700
+ const generatedLength = readArg(args, ["master-key-length", "masterKeyLength"], DEFAULT_GENERATED_MASTER_KEY_LENGTH);
2701
+ const generatedPrefix = readArg(args, ["master-key-prefix", "masterKeyPrefix"], "gw_");
2702
+ let keyGenerated = false;
2703
+
2704
+ if (!masterKey && generateMasterKey) {
2705
+ masterKey = generateStrongMasterKey({ length: generatedLength, prefix: generatedPrefix });
2706
+ keyGenerated = true;
2707
+ }
2708
+
2709
+ if (canPrompt() && !masterKey) {
2710
+ const autoGenerate = await context.prompts.confirm({
2711
+ message: "Generate a strong master key automatically?",
2712
+ initialValue: true
2713
+ });
2714
+ if (autoGenerate) {
2715
+ masterKey = generateStrongMasterKey({
2716
+ length: generatedLength,
2717
+ prefix: generatedPrefix
2718
+ });
2719
+ keyGenerated = true;
2720
+ } else {
2721
+ masterKey = await context.prompts.text({
2722
+ message: "Worker master key",
2723
+ required: true
2724
+ });
2725
+ }
2726
+ }
2727
+
2728
+ if (!masterKey) {
2729
+ return { ok: false, mode: context.mode, exitCode: EXIT_VALIDATION, errorMessage: "master-key is required." };
2730
+ }
2731
+
2732
+ const next = setMasterKeyInConfig(config, masterKey);
2733
+ await writeConfigFile(next, configPath);
2734
+ return {
2735
+ ok: true,
2736
+ mode: context.mode,
2737
+ exitCode: EXIT_SUCCESS,
2738
+ data: [
2739
+ `Updated master key in ${configPath} (${maskSecret(masterKey)}).`,
2740
+ keyGenerated ? `Generated key (copy now): ${masterKey}` : ""
2741
+ ].filter(Boolean).join("\n")
2742
+ };
2743
+ }
2744
+
2745
+ async function doStartupInstall(context) {
2746
+ const configPath = readArg(context.args, ["config", "configPath"], getDefaultConfigPath());
2747
+ const host = String(readArg(context.args, ["host"], "127.0.0.1"));
2748
+ const port = toNumber(readArg(context.args, ["port"]), 8787);
2749
+ const watchConfig = toBoolean(readArg(context.args, ["watch-config", "watchConfig"], true), true);
2750
+ const watchBinary = toBoolean(readArg(context.args, ["watch-binary", "watchBinary"], true), true);
2751
+ const requireAuth = toBoolean(readArg(context.args, ["require-auth", "requireAuth"], false), false);
2752
+
2753
+ if (!(await configFileExists(configPath))) {
2754
+ return {
2755
+ ok: false,
2756
+ mode: context.mode,
2757
+ exitCode: EXIT_VALIDATION,
2758
+ errorMessage: `Config not found at ${configPath}. Run 'llm-router config' first.`
2759
+ };
2760
+ }
2761
+
2762
+ const config = await readConfigFile(configPath);
2763
+ if (!configHasProvider(config)) {
2764
+ return {
2765
+ ok: false,
2766
+ mode: context.mode,
2767
+ exitCode: EXIT_VALIDATION,
2768
+ errorMessage: `No providers configured in ${configPath}. Run 'llm-router config'.`
2769
+ };
2770
+ }
2771
+ if (requireAuth && !config.masterKey) {
2772
+ return {
2773
+ ok: false,
2774
+ mode: context.mode,
2775
+ exitCode: EXIT_VALIDATION,
2776
+ errorMessage: `Local auth requires masterKey in ${configPath}. Run 'llm-router config --operation=set-master-key' first.`
2777
+ };
2778
+ }
2779
+
2780
+ if (canPrompt()) {
2781
+ const confirm = await context.prompts.confirm({
2782
+ message: `Install llm-router startup service on ${process.platform}?`,
2783
+ initialValue: true
2784
+ });
2785
+ if (!confirm) {
2786
+ return { ok: false, mode: context.mode, exitCode: EXIT_FAILURE, errorMessage: "Cancelled." };
2787
+ }
2788
+ }
2789
+
2790
+ const result = await installStartup({ configPath, host, port, watchConfig, watchBinary, requireAuth });
2791
+ return {
2792
+ ok: true,
2793
+ mode: context.mode,
2794
+ exitCode: EXIT_SUCCESS,
2795
+ data: [
2796
+ `Installed OS startup (${result.manager})`,
2797
+ `service=${result.serviceId}`,
2798
+ `file=${result.filePath}`,
2799
+ `start target=http://${host}:${port}`,
2800
+ `binary watch=${watchBinary ? "enabled" : "disabled"}`,
2801
+ `local auth=${requireAuth ? "required (masterKey)" : "disabled"}`
2802
+ ].join("\n")
2803
+ };
2804
+ }
2805
+
2806
+ async function doStartupUninstall(context) {
2807
+ if (canPrompt()) {
2808
+ const confirm = await context.prompts.confirm({
2809
+ message: "Uninstall llm-router OS startup service?",
2810
+ initialValue: false
2811
+ });
2812
+ if (!confirm) {
2813
+ return { ok: false, mode: context.mode, exitCode: EXIT_FAILURE, errorMessage: "Cancelled." };
2814
+ }
2815
+ }
2816
+
2817
+ const result = await uninstallStartup();
2818
+ return {
2819
+ ok: true,
2820
+ mode: context.mode,
2821
+ exitCode: EXIT_SUCCESS,
2822
+ data: [
2823
+ `Uninstalled OS startup (${result.manager})`,
2824
+ `service=${result.serviceId}`,
2825
+ `file=${result.filePath}`
2826
+ ].join("\n")
2827
+ };
2828
+ }
2829
+
2830
+ async function doStartupStatus(context) {
2831
+ const status = await startupStatus();
2832
+ return {
2833
+ ok: true,
2834
+ mode: context.mode,
2835
+ exitCode: EXIT_SUCCESS,
2836
+ data: [
2837
+ `manager=${status.manager}`,
2838
+ `service=${status.serviceId}`,
2839
+ `installed=${status.installed}`,
2840
+ `running=${status.running}`,
2841
+ status.filePath ? `file=${status.filePath}` : "",
2842
+ status.detail ? `detail=${String(status.detail).trim()}` : ""
2843
+ ].filter(Boolean).join("\n")
2844
+ };
2845
+ }
2846
+
2847
+ async function resolveConfigOperation(context) {
2848
+ const opArg = String(readArg(context.args, ["operation", "op"], "") || "").trim();
2849
+ if (opArg) return opArg;
2850
+
2851
+ if (canPrompt()) {
2852
+ return context.prompts.select({
2853
+ message: "Config operation",
2854
+ options: [
2855
+ { value: "upsert-provider", label: "Add/Edit provider" },
2856
+ { value: "remove-provider", label: "Remove provider" },
2857
+ { value: "remove-model", label: "Remove model from provider" },
2858
+ { value: "set-model-fallbacks", label: "Set model silent-fallbacks" },
2859
+ { value: "set-master-key", label: "Set worker master key" },
2860
+ { value: "list", label: "Show config summary" },
2861
+ { value: "startup-install", label: "Install OS startup" },
2862
+ { value: "startup-status", label: "Show OS startup status" },
2863
+ { value: "startup-uninstall", label: "Uninstall OS startup" }
2864
+ ]
2865
+ });
2866
+ }
2867
+
2868
+ return "list";
2869
+ }
2870
+
2871
+ async function runConfigAction(context) {
2872
+ const op = await resolveConfigOperation(context);
2873
+
2874
+ switch (op) {
2875
+ case "upsert-provider":
2876
+ case "add-provider":
2877
+ case "edit-provider":
2878
+ return doUpsertProvider(context);
2879
+ case "remove-provider":
2880
+ return doRemoveProvider(context);
2881
+ case "remove-model":
2882
+ return doRemoveModel(context);
2883
+ case "set-model-fallbacks":
2884
+ case "set-model-fallback":
2885
+ return doSetModelFallbacks(context);
2886
+ case "set-master-key":
2887
+ return doSetMasterKey(context);
2888
+ case "list":
2889
+ return doListConfig(context);
2890
+ case "startup-install":
2891
+ return doStartupInstall(context);
2892
+ case "startup-uninstall":
2893
+ return doStartupUninstall(context);
2894
+ case "startup-status":
2895
+ return doStartupStatus(context);
2896
+ default:
2897
+ return {
2898
+ ok: false,
2899
+ mode: context.mode,
2900
+ exitCode: EXIT_VALIDATION,
2901
+ errorMessage: `Unknown config operation '${op}'.`
2902
+ };
2903
+ }
2904
+ }
2905
+
2906
+ async function runStartAction(context) {
2907
+ const args = context.args || {};
2908
+ const result = await runStartCommand({
2909
+ configPath: readArg(args, ["config", "configPath"], getDefaultConfigPath()),
2910
+ host: String(readArg(args, ["host"], "127.0.0.1")),
2911
+ port: toNumber(readArg(args, ["port"]), 8787),
2912
+ watchConfig: toBoolean(readArg(args, ["watch-config", "watchConfig"], true), true),
2913
+ watchBinary: toBoolean(readArg(args, ["watch-binary", "watchBinary"], true), true),
2914
+ requireAuth: toBoolean(readArg(args, ["require-auth", "requireAuth"], false), false),
2915
+ cliPathForWatch: process.argv[1],
2916
+ onLine: (line) => context.terminal.line(line),
2917
+ onError: (line) => context.terminal.error(line)
2918
+ });
2919
+
2920
+ return {
2921
+ ok: result.ok,
2922
+ mode: context.mode,
2923
+ exitCode: result.exitCode,
2924
+ data: result.data,
2925
+ errorMessage: result.errorMessage
2926
+ };
2927
+ }
2928
+
2929
+ async function runStopAction(context) {
2930
+ let stopped;
2931
+ try {
2932
+ stopped = await stopRunningInstance();
2933
+ } catch (error) {
2934
+ return {
2935
+ ok: false,
2936
+ mode: context.mode,
2937
+ exitCode: EXIT_FAILURE,
2938
+ errorMessage: `Failed to stop llm-router: ${error instanceof Error ? error.message : String(error)}`
2939
+ };
2940
+ }
2941
+ if (!stopped.ok) {
2942
+ return {
2943
+ ok: false,
2944
+ mode: context.mode,
2945
+ exitCode: EXIT_FAILURE,
2946
+ errorMessage: stopped.reason || "Failed to stop llm-router."
2947
+ };
2948
+ }
2949
+
2950
+ if (stopped.mode === "startup") {
2951
+ return {
2952
+ ok: true,
2953
+ mode: context.mode,
2954
+ exitCode: EXIT_SUCCESS,
2955
+ data: [
2956
+ "Stopped startup-managed llm-router instance.",
2957
+ `manager=${stopped.detail?.manager || "unknown"}`,
2958
+ `service=${stopped.detail?.serviceId || "unknown"}`
2959
+ ].join("\n")
2960
+ };
2961
+ }
2962
+
2963
+ if (stopped.mode === "manual") {
2964
+ return {
2965
+ ok: true,
2966
+ mode: context.mode,
2967
+ exitCode: EXIT_SUCCESS,
2968
+ data: `Stopped llm-router process pid=${stopped.detail?.pid || "unknown"} (${stopped.detail?.signal || "SIGTERM"}).`
2969
+ };
2970
+ }
2971
+
2972
+ return {
2973
+ ok: true,
2974
+ mode: context.mode,
2975
+ exitCode: EXIT_SUCCESS,
2976
+ data: "No running llm-router instance found."
2977
+ };
2978
+ }
2979
+
2980
+ async function runReloadAction(context) {
2981
+ let result;
2982
+ try {
2983
+ result = await reloadRunningInstance({
2984
+ terminalLine: (line) => context.terminal.line(line),
2985
+ terminalError: (line) => context.terminal.error(line),
2986
+ runDetachedForManual: false
2987
+ });
2988
+ } catch (error) {
2989
+ return {
2990
+ ok: false,
2991
+ mode: context.mode,
2992
+ exitCode: EXIT_FAILURE,
2993
+ errorMessage: `Failed to reload llm-router: ${error instanceof Error ? error.message : String(error)}`
2994
+ };
2995
+ }
2996
+
2997
+ if (!result.ok && result.mode !== "manual-inline") {
2998
+ return {
2999
+ ok: false,
3000
+ mode: context.mode,
3001
+ exitCode: EXIT_FAILURE,
3002
+ errorMessage: result.reason || "Failed to reload llm-router."
3003
+ };
3004
+ }
3005
+
3006
+ if (result.mode === "startup") {
3007
+ return {
3008
+ ok: true,
3009
+ mode: context.mode,
3010
+ exitCode: EXIT_SUCCESS,
3011
+ data: [
3012
+ "Restarted startup-managed llm-router instance.",
3013
+ `manager=${result.detail?.manager || "unknown"}`,
3014
+ `service=${result.detail?.serviceId || "unknown"}`
3015
+ ].join("\n")
3016
+ };
3017
+ }
3018
+
3019
+ if (result.mode === "manual-inline") {
3020
+ return {
3021
+ ok: result.detail?.ok === true,
3022
+ mode: context.mode,
3023
+ exitCode: result.detail?.exitCode ?? (result.detail?.ok ? EXIT_SUCCESS : EXIT_FAILURE),
3024
+ data: result.detail?.data,
3025
+ errorMessage: result.detail?.errorMessage || (result.detail?.ok ? undefined : "Failed to restart llm-router.")
3026
+ };
3027
+ }
3028
+
3029
+ return {
3030
+ ok: false,
3031
+ mode: context.mode,
3032
+ exitCode: EXIT_FAILURE,
3033
+ errorMessage: result.reason || "No running llm-router instance detected."
3034
+ };
3035
+ }
3036
+
3037
+ async function runUpdateAction(context) {
3038
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : console.log;
3039
+ line(`Updating ${NPM_PACKAGE_NAME} to latest with npm...`);
3040
+
3041
+ const updateResult = runNpmInstallLatest(NPM_PACKAGE_NAME);
3042
+ if (!updateResult.ok) {
3043
+ return {
3044
+ ok: false,
3045
+ mode: context.mode,
3046
+ exitCode: EXIT_FAILURE,
3047
+ errorMessage: [
3048
+ `Failed to update ${NPM_PACKAGE_NAME}.`,
3049
+ updateResult.error ? String(updateResult.error) : "",
3050
+ updateResult.stderr || updateResult.stdout
3051
+ ].filter(Boolean).join("\n")
3052
+ };
3053
+ }
3054
+
3055
+ let reloadResult;
3056
+ try {
3057
+ reloadResult = await reloadRunningInstance({
3058
+ runDetachedForManual: true
3059
+ });
3060
+ } catch (error) {
3061
+ reloadResult = {
3062
+ ok: false,
3063
+ mode: "error",
3064
+ reason: error instanceof Error ? error.message : String(error)
3065
+ };
3066
+ }
3067
+
3068
+ const details = [`Updated ${NPM_PACKAGE_NAME} successfully.`];
3069
+ if (reloadResult.ok && reloadResult.mode === "startup") {
3070
+ details.push("Detected startup-managed running instance and restarted it.");
3071
+ } else if (reloadResult.ok && reloadResult.mode === "manual-detached") {
3072
+ details.push(`Detected running terminal instance and restarted it in background (pid ${reloadResult.detail?.pid || "unknown"}).`);
3073
+ } else if (reloadResult.mode === "none") {
3074
+ details.push("No running instance detected; update applied for next start.");
3075
+ } else if (!reloadResult.ok) {
3076
+ details.push(`Update succeeded but auto-reload failed: ${reloadResult.reason || "unknown error"}`);
3077
+ }
3078
+
3079
+ return {
3080
+ ok: true,
3081
+ mode: context.mode,
3082
+ exitCode: EXIT_SUCCESS,
3083
+ data: details.join("\n")
3084
+ };
3085
+ }
3086
+
3087
+ async function runDeployAction(context) {
3088
+ const args = context.args || {};
3089
+ const configPath = readArg(args, ["config", "configPath"], getDefaultConfigPath());
3090
+ const projectDir = path.resolve(readArg(args, ["project-dir", "projectDir"], process.cwd()));
3091
+ const dryRun = toBoolean(readArg(args, ["dry-run", "dryRun"], false), false);
3092
+ const exportOnly = toBoolean(readArg(args, ["export-only", "exportOnly"], false), false);
3093
+ const generateMasterKey = toBoolean(readArg(args, ["generate-master-key", "generateMasterKey"], false), false);
3094
+ const generatedLength = readArg(args, ["master-key-length", "masterKeyLength"], DEFAULT_GENERATED_MASTER_KEY_LENGTH);
3095
+ const generatedPrefix = readArg(args, ["master-key-prefix", "masterKeyPrefix"], "gw_");
3096
+ let allowWeakMasterKey = toBoolean(readArg(args, ["allow-weak-master-key", "allowWeakMasterKey"], false), false);
3097
+ const allowLargeConfig = toBoolean(readArg(args, ["allow-large-config", "allowLargeConfig"], false), false);
3098
+ const outPath = String(readArg(args, ["out", "output"], "") || "");
3099
+ const cfEnv = String(readArg(args, ["env"], "") || "");
3100
+ const argAccountId = String(readArg(args, ["account-id", "accountId"], "") || "").trim();
3101
+ let masterKey = String(readArg(args, ["master-key", "masterKey"], "") || "");
3102
+ let generatedDeployMasterKey = false;
3103
+ let wranglerTargetMessage = "";
3104
+ const requiresCloudflareToken = !dryRun && !exportOnly;
3105
+ const envToken = resolveCloudflareApiTokenFromEnv(process.env);
3106
+ let cloudflareApiToken = envToken.token;
3107
+ let cloudflareApiTokenSource = envToken.source;
3108
+ const envAccountId = String(process.env?.[CLOUDFLARE_ACCOUNT_ID_ENV_NAME] || "").trim();
3109
+ let cloudflareAccountId = argAccountId || envAccountId;
3110
+ const line = typeof context?.terminal?.line === "function"
3111
+ ? context.terminal.line.bind(context.terminal)
3112
+ : console.log;
3113
+ let wranglerConfigPath = "";
3114
+ let cleanupWranglerConfig = null;
3115
+ let deployRoutePattern = "";
3116
+ let deployZoneName = "";
3117
+ let deployUsesWorkersDev = false;
3118
+ const longTaskSpinner = canPrompt() && typeof SnapTui?.createSpinner === "function"
3119
+ ? SnapTui.createSpinner()
3120
+ : null;
3121
+ const withLongTaskSpinner = async (label, fn, { doneMessage = "" } = {}) => {
3122
+ if (!longTaskSpinner) {
3123
+ line(label);
3124
+ const result = await fn();
3125
+ if (doneMessage) line(doneMessage);
3126
+ return result;
3127
+ }
3128
+
3129
+ longTaskSpinner.start(label);
3130
+ try {
3131
+ const result = await fn();
3132
+ longTaskSpinner.stop(doneMessage || `${label} done`);
3133
+ return result;
3134
+ } catch (error) {
3135
+ longTaskSpinner.error("Operation failed");
3136
+ throw error;
3137
+ }
3138
+ };
3139
+
3140
+ try {
3141
+ if (requiresCloudflareToken && !cloudflareApiToken) {
3142
+ const tokenGuide = buildCloudflareApiTokenSetupGuide();
3143
+ if (canPrompt()) {
3144
+ line(tokenGuide);
3145
+ cloudflareApiToken = await promptSecretInput(context, {
3146
+ message: `Cloudflare API token (${CLOUDFLARE_API_TOKEN_ENV_NAME})`,
3147
+ required: true,
3148
+ validate: validateCloudflareApiTokenInput
3149
+ });
3150
+ cloudflareApiTokenSource = "prompt";
3151
+ } else {
3152
+ return {
3153
+ ok: false,
3154
+ mode: context.mode,
3155
+ exitCode: EXIT_VALIDATION,
3156
+ errorMessage: [
3157
+ tokenGuide,
3158
+ `Set ${CLOUDFLARE_API_TOKEN_ENV_NAME} and re-run deploy.`
3159
+ ].join("\n")
3160
+ };
3161
+ }
3162
+ }
3163
+
3164
+ if (requiresCloudflareToken) {
3165
+ let preflight = await withLongTaskSpinner("Verifying Cloudflare API token...", () => preflightCloudflareApiToken(cloudflareApiToken), {
3166
+ doneMessage: "Cloudflare API token verified."
3167
+ });
3168
+ let attempts = 1;
3169
+ while (!preflight.ok && canPrompt() && cloudflareApiTokenSource === "prompt" && attempts < 3) {
3170
+ const retry = await context.prompts.confirm({
3171
+ message: `${preflight.message} Enter a different Cloudflare API token?`,
3172
+ initialValue: true
3173
+ });
3174
+ if (!retry) break;
3175
+
3176
+ cloudflareApiToken = await promptSecretInput(context, {
3177
+ message: `Cloudflare API token (${CLOUDFLARE_API_TOKEN_ENV_NAME})`,
3178
+ required: true,
3179
+ validate: validateCloudflareApiTokenInput
3180
+ });
3181
+ cloudflareApiTokenSource = "prompt";
3182
+ attempts += 1;
3183
+ preflight = await withLongTaskSpinner("Re-validating Cloudflare API token...", () => preflightCloudflareApiToken(cloudflareApiToken), {
3184
+ doneMessage: "Cloudflare API token re-validated."
3185
+ });
3186
+ }
3187
+
3188
+ if (!preflight.ok) {
3189
+ return {
3190
+ ok: false,
3191
+ mode: context.mode,
3192
+ exitCode: EXIT_VALIDATION,
3193
+ errorMessage: buildCloudflareApiTokenTroubleshooting(preflight.message)
3194
+ };
3195
+ }
3196
+
3197
+ const availableAccounts = Array.isArray(preflight.memberships) ? preflight.memberships : [];
3198
+ if (cloudflareAccountId) {
3199
+ const matched = availableAccounts.find((entry) => entry.accountId === cloudflareAccountId);
3200
+ if (!matched && availableAccounts.length > 0) {
3201
+ return {
3202
+ ok: false,
3203
+ mode: context.mode,
3204
+ exitCode: EXIT_VALIDATION,
3205
+ errorMessage: [
3206
+ `Configured ${CLOUDFLARE_ACCOUNT_ID_ENV_NAME} (${cloudflareAccountId}) is not available for this token.`,
3207
+ "Available accounts:",
3208
+ ...formatCloudflareAccountOptions(availableAccounts)
3209
+ ].join("\n")
3210
+ };
3211
+ }
3212
+ } else if (availableAccounts.length === 1) {
3213
+ cloudflareAccountId = availableAccounts[0].accountId;
3214
+ line(`Using Cloudflare account ${availableAccounts[0].accountName} (${cloudflareAccountId}) from token memberships.`);
3215
+ } else if (availableAccounts.length > 1) {
3216
+ if (canPrompt()) {
3217
+ const selectedAccount = await context.prompts.select({
3218
+ message: "Multiple Cloudflare accounts found. Select account for deploy",
3219
+ options: availableAccounts.map((entry) => ({
3220
+ value: entry.accountId,
3221
+ label: `${entry.accountName} (${entry.accountId})`
3222
+ }))
3223
+ });
3224
+ cloudflareAccountId = String(selectedAccount || "").trim();
3225
+ } else {
3226
+ return {
3227
+ ok: false,
3228
+ mode: context.mode,
3229
+ exitCode: EXIT_VALIDATION,
3230
+ errorMessage: [
3231
+ "More than one Cloudflare account is available for this token.",
3232
+ `Set --account-id=<id> or ${CLOUDFLARE_ACCOUNT_ID_ENV_NAME}=<id>.`,
3233
+ "Available accounts:",
3234
+ ...formatCloudflareAccountOptions(availableAccounts)
3235
+ ].join("\n")
3236
+ };
3237
+ }
3238
+ }
3239
+
3240
+ line(`Cloudflare token preflight passed (${cloudflareApiTokenSource === "prompt" ? "from prompt" : `from ${cloudflareApiTokenSource}`}).`);
3241
+
3242
+ const targetResolution = await prepareWranglerDeployConfig(context, {
3243
+ projectDir,
3244
+ args,
3245
+ cloudflareApiToken,
3246
+ cloudflareAccountId,
3247
+ wait: withLongTaskSpinner
3248
+ });
3249
+ if (!targetResolution.ok) {
3250
+ return {
3251
+ ok: false,
3252
+ mode: context.mode,
3253
+ exitCode: EXIT_VALIDATION,
3254
+ errorMessage: targetResolution.errorMessage || "Failed to configure wrangler deploy target."
3255
+ };
3256
+ }
3257
+ wranglerConfigPath = String(targetResolution.wranglerConfigPath || "").trim();
3258
+ cleanupWranglerConfig = typeof targetResolution.cleanup === "function"
3259
+ ? targetResolution.cleanup
3260
+ : null;
3261
+ wranglerTargetMessage = targetResolution.message || "";
3262
+ deployRoutePattern = String(targetResolution.routePattern || "").trim();
3263
+ deployZoneName = String(targetResolution.zoneName || "").trim();
3264
+ deployUsesWorkersDev = targetResolution.useWorkersDev === true;
3265
+ }
3266
+
3267
+ const wranglerEnvOverrides = buildWranglerCloudflareEnv({
3268
+ apiToken: cloudflareApiToken,
3269
+ accountId: cloudflareAccountId
3270
+ });
3271
+ const wranglerConfigArgs = wranglerConfigPath ? ["--config", wranglerConfigPath] : [];
3272
+
3273
+ if (canPrompt() && !masterKey) {
3274
+ const ask = await context.prompts.confirm({
3275
+ message: "Set/override worker master key for this deploy?",
3276
+ initialValue: false
3277
+ });
3278
+ if (ask) {
3279
+ masterKey = await context.prompts.text({ message: "Worker master key", required: true });
3280
+ }
3281
+ }
3282
+
3283
+ const config = await readConfigFile(configPath);
3284
+ if (!masterKey && !config.masterKey && generateMasterKey) {
3285
+ masterKey = generateStrongMasterKey({
3286
+ length: generatedLength,
3287
+ prefix: generatedPrefix
3288
+ });
3289
+ generatedDeployMasterKey = true;
3290
+ }
3291
+
3292
+ const effectiveMasterKey = String(masterKey || config.masterKey || "");
3293
+ const keyCheck = await ensureStrongWorkerMasterKey(context, effectiveMasterKey, { allowWeakMasterKey });
3294
+ if (!keyCheck.ok) {
3295
+ return {
3296
+ ok: false,
3297
+ mode: context.mode,
3298
+ exitCode: EXIT_VALIDATION,
3299
+ errorMessage: keyCheck.errorMessage
3300
+ };
3301
+ }
3302
+ allowWeakMasterKey = keyCheck.allowWeakMasterKey === true;
3303
+
3304
+ const payload = buildWorkerConfigPayload(config, { masterKey: effectiveMasterKey });
3305
+ const payloadJson = JSON.stringify(payload);
3306
+ const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
3307
+ const tierReport = payloadBytes > CLOUDFLARE_FREE_SECRET_SIZE_LIMIT_BYTES
3308
+ ? detectCloudflareTierViaWrangler(projectDir, cfEnv, cloudflareApiToken, cloudflareAccountId)
3309
+ : { tier: "unknown", reason: "size-within-free-limit", signals: [] };
3310
+ const mustConfirmLargeConfig = shouldConfirmLargeWorkerConfigDeploy({
3311
+ payloadBytes,
3312
+ tier: tierReport.tier
3313
+ });
3314
+ const largeConfigWarningLines = mustConfirmLargeConfig
3315
+ ? buildLargeWorkerConfigWarningLines({
3316
+ payloadBytes,
3317
+ tierReport
3318
+ })
3319
+ : [];
3320
+
3321
+ if (outPath || exportOnly) {
3322
+ const finalOut = outPath || path.resolve(process.cwd(), ".llm-router.worker.json");
3323
+ const resolvedOut = path.resolve(finalOut);
3324
+ await fsPromises.mkdir(path.dirname(resolvedOut), { recursive: true });
3325
+ await fsPromises.writeFile(resolvedOut, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
3326
+
3327
+ if (exportOnly) {
3328
+ return {
3329
+ ok: true,
3330
+ mode: context.mode,
3331
+ exitCode: EXIT_SUCCESS,
3332
+ data: [
3333
+ ...largeConfigWarningLines,
3334
+ mustConfirmLargeConfig
3335
+ ? "Manual deploy may fail on Cloudflare Free tier unless you reduce config size."
3336
+ : "",
3337
+ `Exported worker config to ${resolvedOut}`,
3338
+ `wrangler secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""} < ${resolvedOut}`
3339
+ ].filter(Boolean).join("\n")
3340
+ };
3341
+ }
3342
+ }
3343
+
3344
+ if (dryRun) {
3345
+ return {
3346
+ ok: true,
3347
+ mode: context.mode,
3348
+ exitCode: EXIT_SUCCESS,
3349
+ data: [
3350
+ "Dry run (no deployment executed).",
3351
+ allowWeakMasterKey ? "WARNING: weak master key override enabled." : "",
3352
+ ...largeConfigWarningLines,
3353
+ mustConfirmLargeConfig
3354
+ ? "Interactive deploy requires explicit confirmation (default: No)."
3355
+ : "",
3356
+ mustConfirmLargeConfig
3357
+ ? "Use --allow-large-config=true to bypass this check in non-interactive mode."
3358
+ : "",
3359
+ generatedDeployMasterKey ? "Generated a deploy-time master key (not written to local config)." : "",
3360
+ `projectDir=${projectDir}`,
3361
+ cloudflareApiTokenSource !== "none"
3362
+ ? `cloudflareApiToken=${cloudflareApiTokenSource === "prompt" ? "provided-via-prompt" : `from-${cloudflareApiTokenSource}`}`
3363
+ : "",
3364
+ cloudflareAccountId ? `cloudflareAccountId=${cloudflareAccountId}` : "",
3365
+ `cloudflareTier=${formatCloudflareTierLabel(tierReport)} (${tierReport.reason || "unknown"})`,
3366
+ `wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""}`,
3367
+ `wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} deploy${cfEnv ? ` --env ${cfEnv}` : ""}`,
3368
+ `Payload bytes=${payloadBytes}`
3369
+ ].filter(Boolean).join("\n")
3370
+ };
3371
+ }
3372
+
3373
+ if (mustConfirmLargeConfig && !allowLargeConfig) {
3374
+ if (canPrompt()) {
3375
+ const proceed = await context.prompts.confirm({
3376
+ message: `${largeConfigWarningLines.join(" ")} Continue deploy anyway?`,
3377
+ initialValue: false
3378
+ });
3379
+ if (!proceed) {
3380
+ return {
3381
+ ok: false,
3382
+ mode: context.mode,
3383
+ exitCode: EXIT_FAILURE,
3384
+ errorMessage: "Deployment cancelled because oversized worker config was not confirmed."
3385
+ };
3386
+ }
3387
+ } else {
3388
+ return {
3389
+ ok: false,
3390
+ mode: context.mode,
3391
+ exitCode: EXIT_VALIDATION,
3392
+ errorMessage: [
3393
+ ...largeConfigWarningLines,
3394
+ "Non-interactive mode requires --allow-large-config=true to continue deployment."
3395
+ ].filter(Boolean).join("\n")
3396
+ };
3397
+ }
3398
+ }
3399
+
3400
+ if (canPrompt()) {
3401
+ const confirm = await context.prompts.confirm({
3402
+ message: `Deploy current config to Cloudflare Worker from ${projectDir}?`,
3403
+ initialValue: true
3404
+ });
3405
+ if (!confirm) {
3406
+ return { ok: false, mode: context.mode, exitCode: EXIT_FAILURE, errorMessage: "Deployment cancelled." };
3407
+ }
3408
+ }
3409
+
3410
+ const deploySpinner = canPrompt() && typeof SnapTui?.createSpinner === "function"
3411
+ ? SnapTui.createSpinner()
3412
+ : null;
3413
+ const withDeploySpinner = async (label, fn, { doneMessage = "" } = {}) => {
3414
+ if (!deploySpinner) {
3415
+ line(label);
3416
+ const result = await fn();
3417
+ if (doneMessage) line(doneMessage);
3418
+ return result;
3419
+ }
3420
+ deploySpinner.start(label);
3421
+ try {
3422
+ const result = await fn();
3423
+ deploySpinner.stop(doneMessage || `${label} done`);
3424
+ return result;
3425
+ } catch (error) {
3426
+ deploySpinner.error("Deploy step failed");
3427
+ throw error;
3428
+ }
3429
+ };
3430
+
3431
+ const envArgs = cfEnv ? ["--env", cfEnv] : [];
3432
+ const secretResult = await withDeploySpinner("Uploading worker config secret via Wrangler...", () => runWranglerAsync([...wranglerConfigArgs, "secret", "put", "LLM_ROUTER_CONFIG_JSON", ...envArgs], {
3433
+ cwd: projectDir,
3434
+ input: payloadJson,
3435
+ envOverrides: wranglerEnvOverrides
3436
+ }), { doneMessage: "Worker config secret uploaded." });
3437
+ if (!secretResult.ok) {
3438
+ return {
3439
+ ok: false,
3440
+ mode: context.mode,
3441
+ exitCode: EXIT_FAILURE,
3442
+ errorMessage: [
3443
+ "Failed to upload LLM_ROUTER_CONFIG_JSON secret.",
3444
+ secretResult.error ? String(secretResult.error) : "",
3445
+ secretResult.stderr || secretResult.stdout
3446
+ ].filter(Boolean).join("\n")
3447
+ };
3448
+ }
3449
+
3450
+ const deployResult = await withDeploySpinner("Deploying Cloudflare Worker via Wrangler...", () => runWranglerAsync([...wranglerConfigArgs, "deploy", ...envArgs], {
3451
+ cwd: projectDir,
3452
+ envOverrides: wranglerEnvOverrides
3453
+ }), { doneMessage: "Cloudflare Worker deploy finished." });
3454
+ if (!deployResult.ok) {
3455
+ return {
3456
+ ok: false,
3457
+ mode: context.mode,
3458
+ exitCode: EXIT_FAILURE,
3459
+ errorMessage: [
3460
+ "Secret uploaded but worker deploy failed.",
3461
+ deployResult.error ? String(deployResult.error) : "",
3462
+ deployResult.stderr || deployResult.stdout
3463
+ ].filter(Boolean).join("\n")
3464
+ };
3465
+ }
3466
+
3467
+ const deploySummary = [deployResult.stdout, deployResult.stderr].filter(Boolean).join("\n");
3468
+ if (hasNoDeployTargets(deploySummary)) {
3469
+ return {
3470
+ ok: false,
3471
+ mode: context.mode,
3472
+ exitCode: EXIT_VALIDATION,
3473
+ errorMessage: [
3474
+ "Worker upload succeeded, but no deploy target is configured.",
3475
+ "Set one deploy target and re-run:",
3476
+ "- `--workers-dev=true`, or",
3477
+ "- `--route-pattern=router.example.com/* --zone-name=example.com` (or `--domain=router.example.com`).",
3478
+ deploySummary.trim()
3479
+ ].filter(Boolean).join("\n")
3480
+ };
3481
+ }
3482
+
3483
+ const deployHost = extractHostnameFromRoutePattern(deployRoutePattern);
3484
+ const postDeployGuide = !deployUsesWorkersDev && deployHost
3485
+ ? [
3486
+ "",
3487
+ "Post-deploy checks:",
3488
+ `- dig +short ${deployHost} @1.1.1.1`,
3489
+ `- curl -I https://${deployHost}/anthropic`,
3490
+ `- Claude Code base URL: https://${deployHost}/anthropic (no :8787)`
3491
+ ].join("\n")
3492
+ : "";
3493
+
3494
+ return {
3495
+ ok: true,
3496
+ mode: context.mode,
3497
+ exitCode: EXIT_SUCCESS,
3498
+ data: [
3499
+ "Cloudflare deployment completed.",
3500
+ generatedDeployMasterKey ? "Generated a deploy-time master key. Persist it with `llm-router config --operation=set-master-key --master-key=...` if needed." : "",
3501
+ wranglerTargetMessage,
3502
+ deployZoneName ? `Deploy zone: ${deployZoneName}` : "",
3503
+ secretResult.stdout.trim(),
3504
+ deployResult.stdout.trim(),
3505
+ postDeployGuide
3506
+ ].filter(Boolean).join("\n")
3507
+ };
3508
+ } finally {
3509
+ if (typeof cleanupWranglerConfig === "function") {
3510
+ try {
3511
+ await cleanupWranglerConfig();
3512
+ } catch {
3513
+ // best-effort cleanup for temporary wrangler config file
3514
+ }
3515
+ }
3516
+ }
3517
+ }
3518
+
3519
+ function parseWranglerSecretListOutput(text) {
3520
+ const trimmed = String(text || "").trim();
3521
+ if (!trimmed) return [];
3522
+
3523
+ try {
3524
+ const parsed = JSON.parse(trimmed);
3525
+ if (Array.isArray(parsed)) {
3526
+ return parsed
3527
+ .map((item) => {
3528
+ if (typeof item === "string") return item;
3529
+ if (item && typeof item === "object") {
3530
+ return item.name || item.key || item.secret_name || null;
3531
+ }
3532
+ return null;
3533
+ })
3534
+ .filter(Boolean);
3535
+ }
3536
+ } catch {
3537
+ // fall through to text parser
3538
+ }
3539
+
3540
+ const names = new Set();
3541
+ for (const line of trimmed.split(/\r?\n/g)) {
3542
+ if (line.includes("LLM_ROUTER_")) {
3543
+ for (const match of line.matchAll(/\b[A-Z][A-Z0-9_]{2,}\b/g)) {
3544
+ names.add(match[0]);
3545
+ }
3546
+ }
3547
+ }
3548
+ return [...names];
3549
+ }
3550
+
3551
+ async function runWorkerKeyAction(context) {
3552
+ const args = context.args || {};
3553
+ const configPath = readArg(args, ["config", "configPath"], getDefaultConfigPath());
3554
+ const projectDir = path.resolve(readArg(args, ["project-dir", "projectDir"], process.cwd()));
3555
+ const cfEnv = String(readArg(args, ["env"], "") || "");
3556
+ const dryRun = toBoolean(readArg(args, ["dry-run", "dryRun"], false), false);
3557
+ const useConfigKey = toBoolean(readArg(args, ["use-config-key", "useConfigKey"], true), true);
3558
+ const generateMasterKey = toBoolean(readArg(args, ["generate-master-key", "generateMasterKey"], false), false);
3559
+ const generatedLength = readArg(args, ["master-key-length", "masterKeyLength"], DEFAULT_GENERATED_MASTER_KEY_LENGTH);
3560
+ const generatedPrefix = readArg(args, ["master-key-prefix", "masterKeyPrefix"], "gw_");
3561
+ let allowWeakMasterKey = toBoolean(readArg(args, ["allow-weak-master-key", "allowWeakMasterKey"], false), false);
3562
+ let masterKey = String(readArg(args, ["master-key", "masterKey"], "") || "");
3563
+ let keyGenerated = false;
3564
+
3565
+ if (!masterKey && useConfigKey) {
3566
+ try {
3567
+ const config = await readConfigFile(configPath);
3568
+ masterKey = String(config.masterKey || "");
3569
+ } catch {
3570
+ // allow prompting/manual input fallback
3571
+ }
3572
+ }
3573
+
3574
+ if (!masterKey && generateMasterKey) {
3575
+ masterKey = generateStrongMasterKey({
3576
+ length: generatedLength,
3577
+ prefix: generatedPrefix
3578
+ });
3579
+ keyGenerated = true;
3580
+ }
3581
+
3582
+ if (canPrompt() && !masterKey) {
3583
+ const autoGenerate = await context.prompts.confirm({
3584
+ message: "Generate a strong worker master key automatically?",
3585
+ initialValue: true
3586
+ });
3587
+ if (autoGenerate) {
3588
+ masterKey = generateStrongMasterKey({
3589
+ length: generatedLength,
3590
+ prefix: generatedPrefix
3591
+ });
3592
+ keyGenerated = true;
3593
+ } else {
3594
+ masterKey = await context.prompts.text({
3595
+ message: "New worker master key (LLM_ROUTER_MASTER_KEY)",
3596
+ required: true
3597
+ });
3598
+ }
3599
+ }
3600
+
3601
+ if (!masterKey) {
3602
+ return {
3603
+ ok: false,
3604
+ mode: context.mode,
3605
+ exitCode: EXIT_VALIDATION,
3606
+ errorMessage: "master-key is required (or set one in local config and use --use-config-key=true)."
3607
+ };
3608
+ }
3609
+
3610
+ const keyCheck = await ensureStrongWorkerMasterKey(context, masterKey, { allowWeakMasterKey });
3611
+ if (!keyCheck.ok) {
3612
+ return {
3613
+ ok: false,
3614
+ mode: context.mode,
3615
+ exitCode: EXIT_VALIDATION,
3616
+ errorMessage: keyCheck.errorMessage
3617
+ };
3618
+ }
3619
+ allowWeakMasterKey = keyCheck.allowWeakMasterKey === true;
3620
+
3621
+ const envArgs = cfEnv ? ["--env", cfEnv] : [];
3622
+ let exists = null;
3623
+ const listResult = runWrangler(["secret", "list", ...envArgs], { cwd: projectDir });
3624
+ if (listResult.ok) {
3625
+ const secretNames = parseWranglerSecretListOutput(`${listResult.stdout}\n${listResult.stderr}`);
3626
+ exists = secretNames.includes("LLM_ROUTER_MASTER_KEY");
3627
+ }
3628
+
3629
+ if (dryRun) {
3630
+ return {
3631
+ ok: true,
3632
+ mode: context.mode,
3633
+ exitCode: EXIT_SUCCESS,
3634
+ data: [
3635
+ "Dry run (no secret update executed).",
3636
+ allowWeakMasterKey ? "WARNING: weak master key override enabled." : "",
3637
+ keyGenerated ? "Generated key for this operation." : "",
3638
+ `projectDir=${projectDir}`,
3639
+ cfEnv ? `env=${cfEnv}` : "",
3640
+ `target=LLM_ROUTER_MASTER_KEY (${exists === null ? "existence unknown" : (exists ? "exists" : "missing")})`,
3641
+ `wrangler secret put LLM_ROUTER_MASTER_KEY${cfEnv ? ` --env ${cfEnv}` : ""}`,
3642
+ `masterKey=${maskSecret(masterKey)}`
3643
+ ].filter(Boolean).join("\n")
3644
+ };
3645
+ }
3646
+
3647
+ if (canPrompt()) {
3648
+ const confirm = await context.prompts.confirm({
3649
+ message: `${exists === true ? "Update" : "Set"} LLM_ROUTER_MASTER_KEY on Cloudflare Worker${cfEnv ? ` (${cfEnv})` : ""}?`,
3650
+ initialValue: true
3651
+ });
3652
+ if (!confirm) {
3653
+ return { ok: false, mode: context.mode, exitCode: EXIT_FAILURE, errorMessage: "Operation cancelled." };
3654
+ }
3655
+ }
3656
+
3657
+ const putResult = runWrangler(["secret", "put", "LLM_ROUTER_MASTER_KEY", ...envArgs], {
3658
+ cwd: projectDir,
3659
+ input: masterKey
3660
+ });
3661
+ if (!putResult.ok) {
3662
+ return {
3663
+ ok: false,
3664
+ mode: context.mode,
3665
+ exitCode: EXIT_FAILURE,
3666
+ errorMessage: [
3667
+ "Failed to create/update LLM_ROUTER_MASTER_KEY secret.",
3668
+ putResult.error ? String(putResult.error) : "",
3669
+ putResult.stderr || putResult.stdout
3670
+ ].filter(Boolean).join("\n")
3671
+ };
3672
+ }
3673
+
3674
+ return {
3675
+ ok: true,
3676
+ mode: context.mode,
3677
+ exitCode: EXIT_SUCCESS,
3678
+ data: [
3679
+ `${exists === true ? "Updated" : "Set"} LLM_ROUTER_MASTER_KEY on Cloudflare Worker.`,
3680
+ cfEnv ? `env=${cfEnv}` : "",
3681
+ `projectDir=${projectDir}`,
3682
+ `masterKey=${maskSecret(masterKey)}`,
3683
+ keyGenerated ? `Generated key (copy now): ${masterKey}` : "",
3684
+ putResult.stdout.trim()
3685
+ ].filter(Boolean).join("\n")
3686
+ };
3687
+ }
3688
+
3689
+ const routerModule = {
3690
+ moduleId: "router",
3691
+ description: "LLM Router local start, config manager, and Cloudflare deploy.",
3692
+ actions: [
3693
+ {
3694
+ actionId: "start",
3695
+ description: "Start local llm-router route.",
3696
+ tui: { steps: ["start-server"] },
3697
+ commandline: { requiredArgs: [], optionalArgs: ["host", "port", "config", "watch-config", "watch-binary", "require-auth"] },
3698
+ help: {
3699
+ summary: "Start local llm-router on localhost. Hot-reloads config in memory and auto-relaunches after llm-router upgrades.",
3700
+ args: [
3701
+ { name: "host", required: false, description: "Listen host.", example: "--host=127.0.0.1" },
3702
+ { name: "port", required: false, description: "Listen port.", example: "--port=8787" },
3703
+ { name: "config", required: false, description: "Path to config file.", example: "--config=~/.llm-router.json" },
3704
+ { name: "watch-config", required: false, description: "Hot-reload config in memory without process restart.", example: "--watch-config=true" },
3705
+ { name: "watch-binary", required: false, description: "Watch for llm-router upgrades and relaunch the latest version.", example: "--watch-binary=true" },
3706
+ { name: "require-auth", required: false, description: "Require local API auth using config.masterKey.", example: "--require-auth=true" }
3707
+ ],
3708
+ examples: ["llm-router start", "llm-router start --port=8787", "llm-router start --require-auth=true"],
3709
+ useCases: [
3710
+ {
3711
+ name: "run local route",
3712
+ description: "Serve Anthropic and OpenAI route endpoints locally.",
3713
+ command: "llm-router start"
3714
+ }
3715
+ ],
3716
+ keybindings: ["Ctrl+C stop"]
3717
+ },
3718
+ run: runStartAction
3719
+ },
3720
+ {
3721
+ actionId: "stop",
3722
+ description: "Stop a running llm-router instance (manual or OS startup-managed).",
3723
+ tui: { steps: ["stop-instance"] },
3724
+ commandline: { requiredArgs: [], optionalArgs: [] },
3725
+ help: {
3726
+ summary: "Stop a running llm-router instance.",
3727
+ args: [],
3728
+ examples: ["llm-router stop"],
3729
+ useCases: [
3730
+ {
3731
+ name: "stop instance",
3732
+ description: "Stops startup-managed service or running terminal process.",
3733
+ command: "llm-router stop"
3734
+ }
3735
+ ],
3736
+ keybindings: []
3737
+ },
3738
+ run: runStopAction
3739
+ },
3740
+ {
3741
+ actionId: "reload",
3742
+ description: "Force restart running llm-router instance.",
3743
+ tui: { steps: ["reload-instance"] },
3744
+ commandline: { requiredArgs: [], optionalArgs: [] },
3745
+ help: {
3746
+ summary: "Restart running llm-router: restart startup service or restart terminal instance in current terminal.",
3747
+ args: [],
3748
+ examples: ["llm-router reload"],
3749
+ useCases: [
3750
+ {
3751
+ name: "force restart",
3752
+ description: "Restarts currently running llm-router instance.",
3753
+ command: "llm-router reload"
3754
+ }
3755
+ ],
3756
+ keybindings: []
3757
+ },
3758
+ run: runReloadAction
3759
+ },
3760
+ {
3761
+ actionId: "update",
3762
+ description: "Update llm-router global package to latest and reload running instance.",
3763
+ tui: { steps: ["npm-install", "reload-running"] },
3764
+ commandline: { requiredArgs: [], optionalArgs: [] },
3765
+ help: {
3766
+ summary: "Run npm global install for latest llm-router and reload any running instance.",
3767
+ args: [],
3768
+ examples: ["llm-router update"],
3769
+ useCases: [
3770
+ {
3771
+ name: "upgrade cli",
3772
+ description: "Installs latest global package and reloads startup/manual running instance.",
3773
+ command: "llm-router update"
3774
+ }
3775
+ ],
3776
+ keybindings: []
3777
+ },
3778
+ run: runUpdateAction
3779
+ },
3780
+ {
3781
+ actionId: "config",
3782
+ description: "Config manager for providers/models/master-key/startup service.",
3783
+ tui: { steps: ["select-operation", "execute"] },
3784
+ commandline: {
3785
+ requiredArgs: [],
3786
+ optionalArgs: [
3787
+ "operation",
3788
+ "op",
3789
+ "config",
3790
+ "provider-id",
3791
+ "name",
3792
+ "endpoints",
3793
+ "base-url",
3794
+ "openai-base-url",
3795
+ "claude-base-url",
3796
+ "anthropic-base-url",
3797
+ "api-key",
3798
+ "models",
3799
+ "format",
3800
+ "formats",
3801
+ "headers",
3802
+ "skip-probe",
3803
+ "set-master-key",
3804
+ "master-key",
3805
+ "generate-master-key",
3806
+ "master-key-length",
3807
+ "master-key-prefix",
3808
+ "model",
3809
+ "fallback-models",
3810
+ "fallbacks",
3811
+ "clear-fallbacks",
3812
+ "host",
3813
+ "port",
3814
+ "watch-config",
3815
+ "watch-binary",
3816
+ "require-auth"
3817
+ ]
3818
+ },
3819
+ help: {
3820
+ summary: "Manage providers/models, master key, and OS startup. TUI by default; commandline via --operation.",
3821
+ args: [
3822
+ { name: "operation", required: false, description: "Config operation (optional; prompts if omitted).", example: "--operation=upsert-provider" },
3823
+ { name: "provider-id", required: false, description: "Provider id (slug/camelCase).", example: "--provider-id=openrouter" },
3824
+ { name: "name", required: false, description: "Provider Friendly Name (must be unique; shown in management screen).", example: "--name=OpenRouter Primary" },
3825
+ { name: "endpoints", required: false, description: "Provider endpoint candidates for auto-probe (comma/semicolon/space/newline separated; TUI supports multiline paste).", example: "--endpoints=https://ramclouds.me,https://ramclouds.me/v1" },
3826
+ { name: "base-url", required: false, description: "Provider base URL.", example: "--base-url=https://openrouter.ai/api/v1" },
3827
+ { name: "openai-base-url", required: false, description: "OpenAI endpoint base URL (format-specific override).", example: "--openai-base-url=https://ramclouds.me/v1" },
3828
+ { name: "claude-base-url", required: false, description: "Anthropic endpoint base URL (format-specific override).", example: "--claude-base-url=https://ramclouds.me" },
3829
+ { name: "api-key", required: false, description: "Provider API key.", example: "--api-key=sk-or-v1-..." },
3830
+ { name: "models", required: false, description: "Model list (comma/semicolon/space/newline separated; strips common log/error noise; TUI supports multiline paste).", example: "--models=gpt-4o,claude-3-5-sonnet-latest" },
3831
+ { name: "model", required: false, description: "Single model id (used by remove-model).", example: "--model=gpt-4o" },
3832
+ { name: "fallback-models", required: false, description: "Qualified fallback models for set-model-fallbacks (comma/semicolon/space separated).", example: "--fallback-models=openrouter/gpt-4o,anthropic/claude-3-7-sonnet" },
3833
+ { name: "clear-fallbacks", required: false, description: "Clear all fallback models for set-model-fallbacks.", example: "--clear-fallbacks=true" },
3834
+ { name: "format", required: false, description: "Manual format if probe is skipped.", example: "--format=openai" },
3835
+ { name: "headers", required: false, description: "Custom provider headers as JSON object (default User-Agent applied when omitted).", example: "--headers={\"User-Agent\":\"Mozilla/5.0\"}" },
3836
+ { name: "skip-probe", required: false, description: "Skip live endpoint/model probe.", example: "--skip-probe=true" },
3837
+ { name: "master-key", required: false, description: "Worker auth token.", example: "--master-key=my-token" },
3838
+ { name: "generate-master-key", required: false, description: "Generate a strong master key automatically (set-master-key flow).", example: "--generate-master-key=true" },
3839
+ { name: "master-key-length", required: false, description: "Generated master key length (min 24).", example: "--master-key-length=48" },
3840
+ { name: "master-key-prefix", required: false, description: "Generated master key prefix.", example: "--master-key-prefix=gw_" },
3841
+ { name: "watch-binary", required: false, description: "For startup-install: detect llm-router upgrades and auto-relaunch under OS startup.", example: "--watch-binary=true" },
3842
+ { name: "require-auth", required: false, description: "Require masterKey auth for local start/startup-install.", example: "--require-auth=true" },
3843
+ { name: "config", required: false, description: "Path to config file.", example: "--config=~/.llm-router.json" }
3844
+ ],
3845
+ examples: [
3846
+ "llm-router config",
3847
+ "llm-router config --operation=upsert-provider --provider-id=ramclouds --name=RamClouds --api-key=sk-... --endpoints=https://ramclouds.me,https://ramclouds.me/v1 --models=claude-opus-4-6-thinking,gpt-5.3-codex",
3848
+ "llm-router config --operation=set-model-fallbacks --provider-id=openrouter --model=gpt-4o --fallback-models=anthropic/claude-3-7-sonnet,openrouter/gpt-4.1-mini",
3849
+ "llm-router config --operation=remove-model --provider-id=openrouter --model=gpt-4o",
3850
+ "llm-router config --operation=startup-install"
3851
+ ],
3852
+ useCases: [
3853
+ {
3854
+ name: "interactive config",
3855
+ description: "Add/edit/remove providers and manage startup.",
3856
+ command: "llm-router config"
3857
+ }
3858
+ ],
3859
+ keybindings: ["Enter confirm", "Esc cancel"]
3860
+ },
3861
+ run: runConfigAction
3862
+ },
3863
+ {
3864
+ actionId: "deploy",
3865
+ description: "Guide/deploy current config to Cloudflare Worker.",
3866
+ tui: { steps: ["validate", "confirm", "deploy"] },
3867
+ commandline: {
3868
+ requiredArgs: [],
3869
+ optionalArgs: [
3870
+ "mode",
3871
+ "config",
3872
+ "project-dir",
3873
+ "master-key",
3874
+ "account-id",
3875
+ "workers-dev",
3876
+ "route-pattern",
3877
+ "zone-name",
3878
+ "domain",
3879
+ "generate-master-key",
3880
+ "master-key-length",
3881
+ "master-key-prefix",
3882
+ "allow-weak-master-key",
3883
+ "allow-large-config",
3884
+ "env",
3885
+ "dry-run",
3886
+ "export-only",
3887
+ "out"
3888
+ ]
3889
+ },
3890
+ help: {
3891
+ summary: "Export worker config and/or deploy to Cloudflare Worker with Wrangler.",
3892
+ args: [
3893
+ { name: "mode", required: false, description: "Optional compatibility flag (ignored).", example: "--mode=run" },
3894
+ { name: "config", required: false, description: "Path to config file.", example: "--config=~/.llm-router.json" },
3895
+ { name: "project-dir", required: false, description: "Worker project directory (uses wrangler.toml as optional base).", example: "--project-dir=./route" },
3896
+ { name: "master-key", required: false, description: "Override master key for deployment payload.", example: "--master-key=prod-token" },
3897
+ { name: "account-id", required: false, description: "Cloudflare account id override (useful for multi-account tokens).", example: "--account-id=03819f97b5cb3101faecbbcb6019c4cc" },
3898
+ { name: "workers-dev", required: false, description: "Use workers.dev deploy target in temporary runtime config.", example: "--workers-dev=true" },
3899
+ { name: "route-pattern", required: false, description: "Route pattern for custom domain target (temporary runtime config).", example: "--route-pattern=router.example.com/*" },
3900
+ { name: "zone-name", required: false, description: "Cloudflare zone name for route target (temporary runtime config).", example: "--zone-name=example.com" },
3901
+ { name: "domain", required: false, description: "Convenience alias for route host (auto-converted to <domain>/*).", example: "--domain=router.example.com" },
3902
+ { name: "generate-master-key", required: false, description: "Generate a strong master key when config has no master key.", example: "--generate-master-key=true" },
3903
+ { name: "master-key-length", required: false, description: "Generated master key length (min 24).", example: "--master-key-length=48" },
3904
+ { name: "master-key-prefix", required: false, description: "Generated master key prefix.", example: "--master-key-prefix=gw_" },
3905
+ { name: "allow-weak-master-key", required: false, description: "Allow weak master key (not recommended).", example: "--allow-weak-master-key=true" },
3906
+ { name: "allow-large-config", required: false, description: "Bypass oversized Free-tier secret confirmation (useful in CI).", example: "--allow-large-config=true" },
3907
+ { name: "env", required: false, description: "Wrangler environment.", example: "--env=production" },
3908
+ { name: "dry-run", required: false, description: "Print commands only.", example: "--dry-run=true" },
3909
+ { name: "export-only", required: false, description: "Only export config JSON, no deploy.", example: "--export-only=true" },
3910
+ { name: "out", required: false, description: "Write exported JSON to file.", example: "--out=.llm-router.worker.json" }
3911
+ ],
3912
+ examples: [
3913
+ "llm-router deploy",
3914
+ "llm-router deploy --dry-run=true",
3915
+ "llm-router deploy --account-id=03819f97b5cb3101faecbbcb6019c4cc",
3916
+ "llm-router deploy --workers-dev=true",
3917
+ "llm-router deploy --route-pattern=router.example.com/* --zone-name=example.com",
3918
+ "llm-router deploy --generate-master-key=true",
3919
+ "llm-router deploy --export-only=true --out=.llm-router.worker.json",
3920
+ "llm-router deploy --allow-large-config=true",
3921
+ "llm-router deploy --env=production"
3922
+ ],
3923
+ useCases: [
3924
+ {
3925
+ name: "cloudflare deploy",
3926
+ description: "Push LLM_ROUTER_CONFIG_JSON secret and deploy worker.",
3927
+ command: "llm-router deploy"
3928
+ }
3929
+ ],
3930
+ keybindings: ["Enter confirm", "Esc cancel"]
3931
+ },
3932
+ run: runDeployAction
3933
+ },
3934
+ {
3935
+ actionId: "worker-key",
3936
+ description: "Quickly create/update the LLM_ROUTER_MASTER_KEY Worker secret.",
3937
+ tui: { steps: ["key-input", "confirm", "secret-put"] },
3938
+ commandline: {
3939
+ requiredArgs: [],
3940
+ optionalArgs: [
3941
+ "config",
3942
+ "project-dir",
3943
+ "master-key",
3944
+ "generate-master-key",
3945
+ "master-key-length",
3946
+ "master-key-prefix",
3947
+ "allow-weak-master-key",
3948
+ "use-config-key",
3949
+ "env",
3950
+ "dry-run"
3951
+ ]
3952
+ },
3953
+ help: {
3954
+ summary: "Fast master-key rotation/update on Cloudflare Worker using LLM_ROUTER_MASTER_KEY secret (runtime override).",
3955
+ args: [
3956
+ { name: "master-key", required: false, description: "New worker master key. If omitted, reads local config when allowed.", example: "--master-key=prod-token-v2" },
3957
+ { name: "generate-master-key", required: false, description: "Generate a strong worker master key automatically.", example: "--generate-master-key=true" },
3958
+ { name: "master-key-length", required: false, description: "Generated master key length (min 24).", example: "--master-key-length=48" },
3959
+ { name: "master-key-prefix", required: false, description: "Generated master key prefix.", example: "--master-key-prefix=gw_" },
3960
+ { name: "allow-weak-master-key", required: false, description: "Allow weak master key (not recommended).", example: "--allow-weak-master-key=true" },
3961
+ { name: "use-config-key", required: false, description: "Read key from local config if --master-key is omitted.", example: "--use-config-key=true" },
3962
+ { name: "config", required: false, description: "Path to local config file.", example: "--config=~/.llm-router.json" },
3963
+ { name: "project-dir", required: false, description: "Directory containing wrangler.toml.", example: "--project-dir=./route" },
3964
+ { name: "env", required: false, description: "Wrangler environment.", example: "--env=production" },
3965
+ { name: "dry-run", required: false, description: "Print commands only.", example: "--dry-run=true" }
3966
+ ],
3967
+ examples: [
3968
+ "llm-router worker-key --master-key=prod-token-v2",
3969
+ "llm-router worker-key --generate-master-key=true",
3970
+ "llm-router worker-key --env=production --master-key=rotated-key",
3971
+ "llm-router worker-key --use-config-key=true"
3972
+ ],
3973
+ useCases: [
3974
+ {
3975
+ name: "rotate leaked key",
3976
+ description: "Set LLM_ROUTER_MASTER_KEY quickly without rebuilding the full worker config secret.",
3977
+ command: "llm-router worker-key --master-key=new-secret"
3978
+ }
3979
+ ],
3980
+ keybindings: ["Enter confirm", "Esc cancel"]
3981
+ },
3982
+ run: runWorkerKeyAction
3983
+ }
3984
+ ]
3985
+ };
3986
+
3987
+ export default routerModule;