@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.
- package/.env.test-suite.example +19 -0
- package/README.md +230 -0
- package/package.json +26 -0
- package/src/cli/router-module.js +3987 -0
- package/src/cli-entry.js +144 -0
- package/src/index.js +18 -0
- package/src/node/config-store.js +74 -0
- package/src/node/config-workflows.js +245 -0
- package/src/node/instance-state.js +206 -0
- package/src/node/local-server.js +294 -0
- package/src/node/provider-probe.js +905 -0
- package/src/node/start-command.js +498 -0
- package/src/node/startup-manager.js +369 -0
- package/src/runtime/config.js +655 -0
- package/src/runtime/handler/auth.js +32 -0
- package/src/runtime/handler/config-loading.js +45 -0
- package/src/runtime/handler/fallback.js +424 -0
- package/src/runtime/handler/http.js +71 -0
- package/src/runtime/handler/network-guards.js +137 -0
- package/src/runtime/handler/provider-call.js +245 -0
- package/src/runtime/handler/provider-translation.js +232 -0
- package/src/runtime/handler/request.js +194 -0
- package/src/runtime/handler/utils.js +41 -0
- package/src/runtime/handler.js +301 -0
- package/src/translator/formats.js +7 -0
- package/src/translator/index.js +73 -0
- package/src/translator/request/claude-to-openai.js +228 -0
- package/src/translator/request/openai-to-claude.js +241 -0
- package/src/translator/response/claude-to-openai.js +204 -0
- package/src/translator/response/openai-to-claude.js +197 -0
- package/wrangler.toml +20 -0
|
@@ -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;
|