@khanglvm/llm-router 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +17 -13
- package/package.json +2 -2
- package/src/cli/router-module.js +1289 -511
- package/src/runtime/codex-request-transformer.js +284 -28
- package/src/runtime/codex-response-transformer.js +433 -0
- package/src/runtime/config.js +9 -7
- package/src/runtime/handler/provider-call.js +83 -2
- package/src/runtime/subscription-auth.js +31 -4
- package/src/runtime/subscription-constants.js +11 -7
- package/src/runtime/subscription-provider.js +159 -32
package/src/cli/router-module.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
sanitizeConfigForDisplay,
|
|
38
38
|
validateRuntimeConfig
|
|
39
39
|
} from "../runtime/config.js";
|
|
40
|
+
import { CODEX_SUBSCRIPTION_MODELS } from "../runtime/subscription-constants.js";
|
|
40
41
|
import { FORMATS } from "../translator/index.js";
|
|
41
42
|
import {
|
|
42
43
|
CLOUDFLARE_ACCOUNT_ID_ENV_NAME,
|
|
@@ -80,28 +81,23 @@ const MODEL_ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
|
80
81
|
const MODEL_ROUTING_STRATEGY_OPTIONS = [
|
|
81
82
|
{
|
|
82
83
|
value: "auto",
|
|
83
|
-
label: "Auto"
|
|
84
|
-
hint: "Recommended set-and-forget mode. Uses quota, cooldown, and health signals to avoid rate limits."
|
|
84
|
+
label: "Auto"
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
value: "ordered",
|
|
88
|
-
label: "Ordered"
|
|
89
|
-
hint: "Try targets in the listed order. Move to the next one only when earlier targets are unavailable."
|
|
88
|
+
label: "Ordered"
|
|
90
89
|
},
|
|
91
90
|
{
|
|
92
91
|
value: "round-robin",
|
|
93
|
-
label: "Round-robin"
|
|
94
|
-
hint: "Rotate evenly across eligible targets."
|
|
92
|
+
label: "Round-robin"
|
|
95
93
|
},
|
|
96
94
|
{
|
|
97
95
|
value: "weighted-rr",
|
|
98
|
-
label: "Weighted round-robin"
|
|
99
|
-
hint: "Rotate across eligible targets, but favor higher weights."
|
|
96
|
+
label: "Weighted round-robin"
|
|
100
97
|
},
|
|
101
98
|
{
|
|
102
99
|
value: "quota-aware-weighted-rr",
|
|
103
|
-
label: "Quota-aware weighted round-robin"
|
|
104
|
-
hint: "Like weighted round-robin, but also shifts traffic away from targets nearing limits."
|
|
100
|
+
label: "Quota-aware weighted round-robin"
|
|
105
101
|
}
|
|
106
102
|
];
|
|
107
103
|
const MODEL_ALIAS_STRATEGIES = MODEL_ROUTING_STRATEGY_OPTIONS.map((option) => option.value);
|
|
@@ -111,6 +107,14 @@ const DEFAULT_AI_HELP_GATEWAY_TEST_TIMEOUT_MS = 6000;
|
|
|
111
107
|
const PROVIDER_TYPE_STANDARD = "standard";
|
|
112
108
|
const PROVIDER_TYPE_SUBSCRIPTION = "subscription";
|
|
113
109
|
const SUBSCRIPTION_TYPE_CHATGPT_CODEX = "chatgpt-codex";
|
|
110
|
+
const SUBSCRIPTION_PROVIDER_PRESETS = Object.freeze([
|
|
111
|
+
Object.freeze({
|
|
112
|
+
subscriptionType: SUBSCRIPTION_TYPE_CHATGPT_CODEX,
|
|
113
|
+
label: "ChatGPT",
|
|
114
|
+
defaultName: "GPT Sub",
|
|
115
|
+
defaultModels: CODEX_SUBSCRIPTION_MODELS
|
|
116
|
+
})
|
|
117
|
+
]);
|
|
114
118
|
const RATE_LIMIT_WINDOW_UNIT_ALIASES = new Map([
|
|
115
119
|
["s", "second"],
|
|
116
120
|
["sec", "second"],
|
|
@@ -358,6 +362,18 @@ function normalizeSubscriptionTypeInput(value) {
|
|
|
358
362
|
return "";
|
|
359
363
|
}
|
|
360
364
|
|
|
365
|
+
function getSubscriptionProviderPreset(subscriptionType) {
|
|
366
|
+
return SUBSCRIPTION_PROVIDER_PRESETS.find((preset) => preset.subscriptionType === subscriptionType) || null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getDefaultSubscriptionModelListInput(existingProvider) {
|
|
370
|
+
const existingModels = dedupeList((existingProvider?.models || []).map((model) => model?.id).filter(Boolean));
|
|
371
|
+
const defaults = existingModels.length > 0
|
|
372
|
+
? existingModels
|
|
373
|
+
: CODEX_SUBSCRIPTION_MODELS;
|
|
374
|
+
return defaults.join(",");
|
|
375
|
+
}
|
|
376
|
+
|
|
361
377
|
function normalizeNameForCompare(value) {
|
|
362
378
|
return String(value || "").trim().toLowerCase();
|
|
363
379
|
}
|
|
@@ -376,20 +392,7 @@ function findProviderByFriendlyName(providers, name, { excludeId = "" } = {}) {
|
|
|
376
392
|
}
|
|
377
393
|
|
|
378
394
|
function printProviderInputGuidance(context) {
|
|
379
|
-
|
|
380
|
-
const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : null;
|
|
381
|
-
const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
|
|
382
|
-
const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : null;
|
|
383
|
-
if (!line) return;
|
|
384
|
-
|
|
385
|
-
info?.("Provider config tips:");
|
|
386
|
-
line(" - Provider Friendly Name is shown in the management screen and must be unique.");
|
|
387
|
-
line(" - Provider ID is auto-generated by slugifying the friendly name; you can edit it.");
|
|
388
|
-
line(" - Provider type can be standard (API key + endpoint) or subscription (OAuth-backed ChatGPT Codex).");
|
|
389
|
-
line(" - Examples:");
|
|
390
|
-
line(" Friendly Name: OpenRouter Primary, RamClouds Production");
|
|
391
|
-
line(" Provider ID: openrouterPrimary, ramcloudsProd, chatgptPersonal");
|
|
392
|
-
line(" API Key: sk-or-v1-xxxxxxxx, sk-ant-api03-xxxxxxxx, sk-xxxxxxxx");
|
|
395
|
+
void context;
|
|
393
396
|
}
|
|
394
397
|
|
|
395
398
|
function trimOuterPunctuation(value) {
|
|
@@ -473,6 +476,26 @@ export function parseEndpointListInput(raw) {
|
|
|
473
476
|
return normalized;
|
|
474
477
|
}
|
|
475
478
|
|
|
479
|
+
function isValidHttpUrl(value) {
|
|
480
|
+
try {
|
|
481
|
+
const parsed = new URL(String(value || ""));
|
|
482
|
+
return (parsed.protocol === "http:" || parsed.protocol === "https:") && Boolean(parsed.hostname);
|
|
483
|
+
} catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function validateEndpointListInput(raw, { allowEmpty = false } = {}) {
|
|
489
|
+
const parsed = parseEndpointListInput(raw);
|
|
490
|
+
if (parsed.length === 0) {
|
|
491
|
+
return allowEmpty ? undefined : "Enter at least one valid endpoint URL (http:// or https://).";
|
|
492
|
+
}
|
|
493
|
+
if (!parsed.every((item) => isValidHttpUrl(item))) {
|
|
494
|
+
return "One or more endpoints are invalid. Use full http:// or https:// URLs.";
|
|
495
|
+
}
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
476
499
|
const MODEL_INPUT_NOISE_TOKENS = new Set([
|
|
477
500
|
"discover",
|
|
478
501
|
"progress",
|
|
@@ -571,6 +594,14 @@ function parseProviderModelListInput(raw) {
|
|
|
571
594
|
.filter(Boolean));
|
|
572
595
|
}
|
|
573
596
|
|
|
597
|
+
function validateProviderModelListInput(raw, { allowEmpty = false } = {}) {
|
|
598
|
+
const parsed = parseProviderModelListInput(raw);
|
|
599
|
+
if (parsed.length === 0) {
|
|
600
|
+
return allowEmpty ? undefined : "Enter at least one valid model id.";
|
|
601
|
+
}
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
|
|
574
605
|
function normalizeQualifiedModelToken(token) {
|
|
575
606
|
const value = trimOuterPunctuation(token)
|
|
576
607
|
.replace(/[)\]}>.,;:]+$/g, "")
|
|
@@ -758,7 +789,7 @@ function maybeReportInputCleanup(context, label, rawValue, cleanedValues) {
|
|
|
758
789
|
if ((cleanedValues || []).length > 0) {
|
|
759
790
|
info?.(`Cleaned ${label} input: parsed ${(cleanedValues || []).length} item(s) from free-form text.`);
|
|
760
791
|
} else {
|
|
761
|
-
warn?.(`Could not parse any ${label} from the provided text. Use comma
|
|
792
|
+
warn?.(`Could not parse any ${label} from the provided text. Use comma-separated values (for example: a,b,c).`);
|
|
762
793
|
}
|
|
763
794
|
}
|
|
764
795
|
|
|
@@ -1011,10 +1042,9 @@ async function runProbeManualFallback(context, {
|
|
|
1011
1042
|
if (unresolvedModels.length > 0) {
|
|
1012
1043
|
info?.(`Manual fallback needs unresolved model review (${unresolvedModels.length} item(s)).`);
|
|
1013
1044
|
const unresolvedInput = await context.prompts.text({
|
|
1014
|
-
message: "
|
|
1015
|
-
initialValue: unresolvedModels.join("
|
|
1016
|
-
|
|
1017
|
-
multiline: true
|
|
1045
|
+
message: "Add unresolved models (comma-separated, optional)",
|
|
1046
|
+
initialValue: unresolvedModels.join(","),
|
|
1047
|
+
validate: (value) => validateProviderModelListInput(value, { allowEmpty: true })
|
|
1018
1048
|
});
|
|
1019
1049
|
const manualModels = parseProviderModelListInput(unresolvedInput);
|
|
1020
1050
|
maybeReportInputCleanup(context, "model", unresolvedInput, manualModels);
|
|
@@ -1026,7 +1056,7 @@ async function runProbeManualFallback(context, {
|
|
|
1026
1056
|
const openaiInput = await context.prompts.text({
|
|
1027
1057
|
message: "OpenAI-compatible endpoint for unresolved items (optional)",
|
|
1028
1058
|
initialValue: "",
|
|
1029
|
-
|
|
1059
|
+
validate: (value) => validateEndpointListInput(value, { allowEmpty: true })
|
|
1030
1060
|
});
|
|
1031
1061
|
openaiBase = parseEndpointListInput(openaiInput)[0] || "";
|
|
1032
1062
|
}
|
|
@@ -1036,7 +1066,7 @@ async function runProbeManualFallback(context, {
|
|
|
1036
1066
|
const claudeInput = await context.prompts.text({
|
|
1037
1067
|
message: "Anthropic-compatible endpoint for unresolved items (optional)",
|
|
1038
1068
|
initialValue: "",
|
|
1039
|
-
|
|
1069
|
+
validate: (value) => validateEndpointListInput(value, { allowEmpty: true })
|
|
1040
1070
|
});
|
|
1041
1071
|
claudeBase = parseEndpointListInput(claudeInput)[0] || "";
|
|
1042
1072
|
}
|
|
@@ -1059,13 +1089,11 @@ async function runProbeManualFallback(context, {
|
|
|
1059
1089
|
|
|
1060
1090
|
function slugifyId(value, fallback = "provider") {
|
|
1061
1091
|
const slug = String(value || fallback)
|
|
1092
|
+
.toLowerCase()
|
|
1062
1093
|
.trim()
|
|
1063
|
-
.replace(/[^a-
|
|
1094
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1064
1095
|
.replace(/^-+|-+$/g, "");
|
|
1065
|
-
|
|
1066
|
-
return /^[A-Z]/.test(slug)
|
|
1067
|
-
? slug.charAt(0).toLowerCase() + slug.slice(1)
|
|
1068
|
-
: slug;
|
|
1096
|
+
return slug || fallback;
|
|
1069
1097
|
}
|
|
1070
1098
|
|
|
1071
1099
|
function sanitizeRateLimitBucketName(value) {
|
|
@@ -1082,6 +1110,23 @@ function slugifyRateLimitBucketId(value, fallback = "bucket") {
|
|
|
1082
1110
|
return slug || fallback;
|
|
1083
1111
|
}
|
|
1084
1112
|
|
|
1113
|
+
function resolveUniqueProviderId(baseId, providers, { excludeId = "" } = {}) {
|
|
1114
|
+
const normalizedBase = slugifyId(baseId || "provider");
|
|
1115
|
+
const excluded = String(excludeId || "").trim();
|
|
1116
|
+
const reservedIds = new Set((providers || [])
|
|
1117
|
+
.map((provider) => String(provider?.id || "").trim())
|
|
1118
|
+
.filter((id) => id && id !== excluded));
|
|
1119
|
+
if (!reservedIds.has(normalizedBase)) return normalizedBase;
|
|
1120
|
+
|
|
1121
|
+
let suffix = 2;
|
|
1122
|
+
let candidate = `${normalizedBase}-${suffix}`;
|
|
1123
|
+
while (reservedIds.has(candidate)) {
|
|
1124
|
+
suffix += 1;
|
|
1125
|
+
candidate = `${normalizedBase}-${suffix}`;
|
|
1126
|
+
}
|
|
1127
|
+
return candidate;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1085
1130
|
function resolveUniqueRateLimitBucketId(baseId, reservedIds) {
|
|
1086
1131
|
const normalizedBase = String(baseId || "").trim() || "bucket";
|
|
1087
1132
|
if (!(reservedIds instanceof Set)) return normalizedBase;
|
|
@@ -1136,69 +1181,294 @@ function formatRateLimitBucketScopeLabel(bucket) {
|
|
|
1136
1181
|
return models.length > 0 ? models.join(", ") : "(none)";
|
|
1137
1182
|
}
|
|
1138
1183
|
|
|
1139
|
-
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1184
|
+
function formatRequestFormatLabel(value) {
|
|
1185
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
1186
|
+
if (!normalized) return "";
|
|
1187
|
+
if (normalized === FORMATS.OPENAI) return "OpenAI";
|
|
1188
|
+
if (normalized === FORMATS.CLAUDE) return "Claude";
|
|
1189
|
+
return normalized;
|
|
1190
|
+
}
|
|
1146
1191
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
lines.push(` type=${providerType}`);
|
|
1155
|
-
if (providerType === PROVIDER_TYPE_SUBSCRIPTION) {
|
|
1156
|
-
lines.push(` subscriptionType=${provider.subscriptionType || provider.subscription_type || "(not set)"}`);
|
|
1157
|
-
lines.push(` subscriptionProfile=${provider.subscriptionProfile || provider.subscription_profile || provider.id || "default"}`);
|
|
1158
|
-
}
|
|
1159
|
-
if (provider.baseUrl) {
|
|
1160
|
-
lines.push(` baseUrl=${provider.baseUrl}`);
|
|
1161
|
-
}
|
|
1162
|
-
if (provider.baseUrlByFormat?.openai) {
|
|
1163
|
-
lines.push(` openaiBaseUrl=${provider.baseUrlByFormat.openai}`);
|
|
1164
|
-
}
|
|
1165
|
-
if (provider.baseUrlByFormat?.claude) {
|
|
1166
|
-
lines.push(` claudeBaseUrl=${provider.baseUrlByFormat.claude}`);
|
|
1167
|
-
}
|
|
1168
|
-
lines.push(` formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`);
|
|
1169
|
-
lines.push(` apiKey=${provider.apiKey || "(from env/hidden)"}`);
|
|
1170
|
-
lines.push(` models=${(provider.models || []).map((model) => {
|
|
1171
|
-
const fallbacks = (model.fallbackModels || []).join("|");
|
|
1172
|
-
return fallbacks ? `${model.id}{fallback:${fallbacks}}` : model.id;
|
|
1173
|
-
}).join(", ") || "(none)"}`);
|
|
1174
|
-
|
|
1175
|
-
const rateLimits = provider.rateLimits || [];
|
|
1176
|
-
if (rateLimits.length === 0) {
|
|
1177
|
-
lines.push(" rateLimits=(none)");
|
|
1178
|
-
} else {
|
|
1179
|
-
lines.push(" rateLimits:");
|
|
1180
|
-
for (const bucket of rateLimits) {
|
|
1181
|
-
lines.push(
|
|
1182
|
-
` - ${formatRateLimitBucketLabel(bucket, { includeId: true })}: models=${formatRateLimitBucketScopeLabel(bucket)} cap=${summarizeRateLimitBucketCap(bucket)} window=${formatRateLimitWindowForSummary(bucket.window)}`
|
|
1183
|
-
);
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1192
|
+
function formatRequestFormatList(values, fallback = "") {
|
|
1193
|
+
const formats = dedupeList([
|
|
1194
|
+
...(Array.isArray(values) ? values : []),
|
|
1195
|
+
...(fallback ? [fallback] : [])
|
|
1196
|
+
]).map(formatRequestFormatLabel).filter(Boolean);
|
|
1197
|
+
return formats.join(", ") || "Unknown";
|
|
1198
|
+
}
|
|
1188
1199
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1200
|
+
function formatProviderTypeLabel(type) {
|
|
1201
|
+
const normalized = normalizeProviderTypeInput(type) || PROVIDER_TYPE_STANDARD;
|
|
1202
|
+
return normalized === PROVIDER_TYPE_SUBSCRIPTION ? "Subscription (OAuth)" : "Standard (API Key)";
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function formatModelAliasStrategyLabel(strategy) {
|
|
1206
|
+
const normalized = normalizeModelAliasStrategy(strategy) || "ordered";
|
|
1207
|
+
return MODEL_ROUTING_STRATEGY_OPTIONS.find((option) => option.value === normalized)?.label || normalized;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function formatReportCell(value, { fallback = "-", maxLength = 180 } = {}) {
|
|
1211
|
+
const compact = String(value ?? "")
|
|
1212
|
+
.replace(/\s+/g, " ")
|
|
1213
|
+
.trim();
|
|
1214
|
+
if (!compact) return fallback;
|
|
1215
|
+
if (compact.length <= maxLength) return compact;
|
|
1216
|
+
return `${compact.slice(0, Math.max(1, maxLength - 3))}...`;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function renderAsciiTable(headers, rows, { emptyMessage = "No entries." } = {}) {
|
|
1220
|
+
const normalizedHeaders = (headers || []).map((header) => formatReportCell(header, { fallback: "", maxLength: 120 }));
|
|
1221
|
+
const normalizedRows = (rows || []).map((row) => normalizedHeaders.map((_, index) => {
|
|
1222
|
+
const cell = Array.isArray(row) ? row[index] : undefined;
|
|
1223
|
+
return formatReportCell(cell);
|
|
1224
|
+
}));
|
|
1225
|
+
if (normalizedRows.length === 0) return emptyMessage;
|
|
1226
|
+
|
|
1227
|
+
const widths = normalizedHeaders.map((header, index) => Math.max(
|
|
1228
|
+
header.length,
|
|
1229
|
+
...normalizedRows.map((row) => row[index].length)
|
|
1230
|
+
));
|
|
1231
|
+
const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
|
|
1232
|
+
const renderRow = (cells) => `| ${cells.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`;
|
|
1233
|
+
|
|
1234
|
+
return [
|
|
1235
|
+
border,
|
|
1236
|
+
renderRow(normalizedHeaders),
|
|
1237
|
+
border,
|
|
1238
|
+
...normalizedRows.map((row) => renderRow(row)),
|
|
1239
|
+
border
|
|
1240
|
+
].join("\n");
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function buildProviderEndpointRows(provider) {
|
|
1244
|
+
const rows = [];
|
|
1245
|
+
const seenUrls = new Set();
|
|
1246
|
+
const addRow = (label, url) => {
|
|
1247
|
+
const value = String(url || "").trim();
|
|
1248
|
+
if (!value || seenUrls.has(value)) return;
|
|
1249
|
+
seenUrls.add(value);
|
|
1250
|
+
rows.push([label, value]);
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
addRow("OpenAI Endpoint", provider?.baseUrlByFormat?.openai);
|
|
1254
|
+
addRow("Claude Endpoint", provider?.baseUrlByFormat?.claude);
|
|
1255
|
+
addRow("Primary Endpoint", provider?.baseUrl);
|
|
1256
|
+
|
|
1257
|
+
if (rows.length === 0 && normalizeProviderTypeInput(provider?.type) === PROVIDER_TYPE_SUBSCRIPTION) {
|
|
1258
|
+
rows.push(["Endpoint", "Managed by OAuth subscription provider"]);
|
|
1199
1259
|
}
|
|
1260
|
+
return rows;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function buildProviderModelRows(provider) {
|
|
1264
|
+
const providerFormats = dedupeList([
|
|
1265
|
+
...(provider?.formats || []),
|
|
1266
|
+
...(provider?.format ? [provider.format] : [])
|
|
1267
|
+
]);
|
|
1268
|
+
return (provider?.models || []).map((model) => ([
|
|
1269
|
+
model?.id || "(unknown)",
|
|
1270
|
+
formatRequestFormatList(dedupeList([...(model?.formats || []), ...providerFormats])),
|
|
1271
|
+
dedupeList(model?.fallbackModels || []).join(", ") || "None"
|
|
1272
|
+
]));
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function buildRateLimitBucketRows(rateLimits) {
|
|
1276
|
+
return (rateLimits || []).map((bucket) => {
|
|
1277
|
+
const requests = Number.parseInt(String(bucket?.requests ?? ""), 10);
|
|
1278
|
+
const requestLimit = Number.isFinite(requests) && requests > 0
|
|
1279
|
+
? `${new Intl.NumberFormat("en-US").format(requests)} requests`
|
|
1280
|
+
: "(unset)";
|
|
1281
|
+
return [
|
|
1282
|
+
formatRateLimitBucketLabel(bucket),
|
|
1283
|
+
String(bucket?.id || "(not set)").trim() || "(not set)",
|
|
1284
|
+
formatRateLimitBucketScopeLabel(bucket),
|
|
1285
|
+
requestLimit,
|
|
1286
|
+
formatRateLimitWindowForHuman(bucket?.window)
|
|
1287
|
+
];
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function joinReportSections(...sections) {
|
|
1292
|
+
return sections.filter((section) => String(section || "").trim().length > 0).join("\n\n");
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function formatYesNo(value) {
|
|
1296
|
+
return value ? "Yes" : "No";
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function renderKeyValueSection(title, rows) {
|
|
1300
|
+
return `${title}\n${renderAsciiTable(["Field", "Value"], rows || [])}`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function renderListSection(title, items, { emptyMessage = "None." } = {}) {
|
|
1304
|
+
const normalized = (items || [])
|
|
1305
|
+
.map((item) => String(item || "").trim())
|
|
1306
|
+
.filter(Boolean);
|
|
1307
|
+
if (normalized.length === 0) return `${title}\n${emptyMessage}`;
|
|
1308
|
+
return `${title}\n${normalized.map((item) => `- ${item}`).join("\n")}`;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function buildOperationReport(title, rows, extraSections = []) {
|
|
1312
|
+
return joinReportSections(
|
|
1313
|
+
title,
|
|
1314
|
+
renderKeyValueSection("Overview", rows),
|
|
1315
|
+
...extraSections
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function buildModelAliasTargetRows(targets) {
|
|
1320
|
+
return (targets || []).map((target) => ([
|
|
1321
|
+
target?.ref || "(invalid target)",
|
|
1322
|
+
Number.isFinite(target?.weight) ? String(Number(target.weight)) : "Default"
|
|
1323
|
+
]));
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function buildProviderSavedReport({
|
|
1327
|
+
provider,
|
|
1328
|
+
configPath,
|
|
1329
|
+
probe,
|
|
1330
|
+
masterKey
|
|
1331
|
+
}) {
|
|
1332
|
+
const detailsRows = [
|
|
1333
|
+
["Config File", configPath],
|
|
1334
|
+
["Provider ID", provider?.id || "(unknown)"],
|
|
1335
|
+
["Provider Name", provider?.name || provider?.id || "(unknown)"],
|
|
1336
|
+
["Provider Type", formatProviderTypeLabel(provider?.type)],
|
|
1337
|
+
["Request Formats", formatRequestFormatList(provider?.formats, provider?.format)],
|
|
1338
|
+
["API Credential", normalizeProviderTypeInput(provider?.type) === PROVIDER_TYPE_SUBSCRIPTION
|
|
1339
|
+
? "Managed by OAuth session"
|
|
1340
|
+
: (provider?.apiKey || "(from env/hidden)")]
|
|
1341
|
+
];
|
|
1342
|
+
if (provider?.subscriptionType) detailsRows.push(["Subscription Type", provider.subscriptionType]);
|
|
1343
|
+
if (provider?.subscriptionProfile) detailsRows.push(["Subscription Profile", provider.subscriptionProfile]);
|
|
1344
|
+
detailsRows.push(["Master Key", masterKey || "(not set)"]);
|
|
1345
|
+
|
|
1346
|
+
const probeRows = probe
|
|
1347
|
+
? [
|
|
1348
|
+
["Auto-detection", probe.ok ? "Completed" : "Needs review"],
|
|
1349
|
+
["Preferred Request Format", formatRequestFormatLabel(probe.preferredFormat) || "(unknown)"],
|
|
1350
|
+
["Working Formats", formatRequestFormatList(probe.workingFormats || [])]
|
|
1351
|
+
]
|
|
1352
|
+
: [["Auto-detection", "Skipped"]];
|
|
1353
|
+
|
|
1354
|
+
return joinReportSections(
|
|
1355
|
+
"Provider Saved",
|
|
1356
|
+
"Overview\n" + renderAsciiTable(["Field", "Value"], detailsRows),
|
|
1357
|
+
"Endpoint Mapping\n" + renderAsciiTable(["Endpoint Type", "URL"], buildProviderEndpointRows(provider), {
|
|
1358
|
+
emptyMessage: "No endpoints configured."
|
|
1359
|
+
}),
|
|
1360
|
+
"Models\n" + renderAsciiTable(["Model", "Request Format(s)", "Silent Fallbacks"], buildProviderModelRows(provider), {
|
|
1361
|
+
emptyMessage: "No models configured."
|
|
1362
|
+
}),
|
|
1363
|
+
"Rate-Limit Buckets\n" + renderAsciiTable(["Bucket", "Bucket ID", "Scope", "Request Limit", "Time Window"], buildRateLimitBucketRows(provider?.rateLimits || []), {
|
|
1364
|
+
emptyMessage: "No rate-limit buckets configured."
|
|
1365
|
+
}),
|
|
1366
|
+
"Detection Summary\n" + renderAsciiTable(["Check", "Result"], probeRows)
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function buildModelAliasSavedReport(aliasId, alias) {
|
|
1371
|
+
return joinReportSections(
|
|
1372
|
+
"Model Alias Saved",
|
|
1373
|
+
"Overview\n" + renderAsciiTable(["Field", "Value"], [
|
|
1374
|
+
["Alias ID", aliasId || "(unknown)"],
|
|
1375
|
+
["Routing Strategy", formatModelAliasStrategyLabel(alias?.strategy)]
|
|
1376
|
+
]),
|
|
1377
|
+
"Primary Routes\n" + renderAsciiTable(["Model Route", "Weight"], buildModelAliasTargetRows(alias?.targets || []), {
|
|
1378
|
+
emptyMessage: "No primary routes configured."
|
|
1379
|
+
}),
|
|
1380
|
+
"Fallback Routes\n" + renderAsciiTable(["Model Route", "Weight"], buildModelAliasTargetRows(alias?.fallbackTargets || []), {
|
|
1381
|
+
emptyMessage: "No fallback routes configured."
|
|
1382
|
+
})
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function buildProviderRateLimitReport({
|
|
1387
|
+
title,
|
|
1388
|
+
providerId,
|
|
1389
|
+
rateLimits
|
|
1390
|
+
}) {
|
|
1391
|
+
return joinReportSections(
|
|
1392
|
+
title || "Rate-Limit Buckets",
|
|
1393
|
+
"Overview\n" + renderAsciiTable(["Field", "Value"], [
|
|
1394
|
+
["Provider ID", providerId || "(unknown)"],
|
|
1395
|
+
["Total Buckets", String((rateLimits || []).length)]
|
|
1396
|
+
]),
|
|
1397
|
+
"Buckets\n" + renderAsciiTable(["Bucket", "Bucket ID", "Scope", "Request Limit", "Time Window"], buildRateLimitBucketRows(rateLimits || []), {
|
|
1398
|
+
emptyMessage: "No rate-limit buckets configured."
|
|
1399
|
+
})
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function buildProviderConfigSection(provider) {
|
|
1404
|
+
const infoRows = [
|
|
1405
|
+
["Provider ID", provider?.id || "(unknown)"],
|
|
1406
|
+
["Provider Name", provider?.name || provider?.id || "(unknown)"],
|
|
1407
|
+
["Provider Type", formatProviderTypeLabel(provider?.type)],
|
|
1408
|
+
["Request Formats", formatRequestFormatList(provider?.formats, provider?.format)],
|
|
1409
|
+
["API Credential", normalizeProviderTypeInput(provider?.type) === PROVIDER_TYPE_SUBSCRIPTION
|
|
1410
|
+
? "Managed by OAuth session"
|
|
1411
|
+
: (provider?.apiKey || "(from env/hidden)")]
|
|
1412
|
+
];
|
|
1413
|
+
if (provider?.subscriptionType) infoRows.push(["Subscription Type", provider.subscriptionType]);
|
|
1414
|
+
if (provider?.subscriptionProfile) infoRows.push(["Subscription Profile", provider.subscriptionProfile]);
|
|
1415
|
+
|
|
1416
|
+
return joinReportSections(
|
|
1417
|
+
`Provider: ${provider?.name || provider?.id || "(unknown)"}`,
|
|
1418
|
+
"Provider Details\n" + renderAsciiTable(["Field", "Value"], infoRows),
|
|
1419
|
+
"Endpoint Mapping\n" + renderAsciiTable(["Endpoint Type", "URL"], buildProviderEndpointRows(provider), {
|
|
1420
|
+
emptyMessage: "No endpoints configured."
|
|
1421
|
+
}),
|
|
1422
|
+
"Models\n" + renderAsciiTable(["Model", "Request Format(s)", "Silent Fallbacks"], buildProviderModelRows(provider), {
|
|
1423
|
+
emptyMessage: "No models configured."
|
|
1424
|
+
}),
|
|
1425
|
+
"Rate-Limit Buckets\n" + renderAsciiTable(["Bucket", "Bucket ID", "Scope", "Request Limit", "Time Window"], buildRateLimitBucketRows(provider?.rateLimits || []), {
|
|
1426
|
+
emptyMessage: "No rate-limit buckets configured."
|
|
1427
|
+
})
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1200
1430
|
|
|
1201
|
-
|
|
1431
|
+
export function summarizeConfig(config, configPath, { includeSecrets = false } = {}) {
|
|
1432
|
+
const target = includeSecrets ? config : sanitizeConfigForDisplay(config);
|
|
1433
|
+
const providers = Array.isArray(target?.providers) ? target.providers : [];
|
|
1434
|
+
const aliasEntries = Object.entries(target?.modelAliases || {});
|
|
1435
|
+
|
|
1436
|
+
const providerSummaryRows = providers.map((provider) => [
|
|
1437
|
+
provider?.id || "(unknown)",
|
|
1438
|
+
provider?.name || provider?.id || "(unknown)",
|
|
1439
|
+
formatProviderTypeLabel(provider?.type),
|
|
1440
|
+
formatRequestFormatList(provider?.formats, provider?.format),
|
|
1441
|
+
String((provider?.models || []).length),
|
|
1442
|
+
String((provider?.rateLimits || []).length)
|
|
1443
|
+
]);
|
|
1444
|
+
|
|
1445
|
+
const aliasRows = aliasEntries.map(([aliasId, alias]) => ([
|
|
1446
|
+
aliasId,
|
|
1447
|
+
formatModelAliasStrategyLabel(alias?.strategy),
|
|
1448
|
+
formatAliasTargetsForSummary(alias?.targets),
|
|
1449
|
+
formatAliasTargetsForSummary(alias?.fallbackTargets)
|
|
1450
|
+
]));
|
|
1451
|
+
|
|
1452
|
+
return joinReportSections(
|
|
1453
|
+
"Current Router Configuration",
|
|
1454
|
+
"Overview\n" + renderAsciiTable(["Field", "Value"], [
|
|
1455
|
+
["Config File", configPath],
|
|
1456
|
+
["Schema Version", String(target?.version || 1)],
|
|
1457
|
+
["Default Route", target?.defaultModel || "(not set)"],
|
|
1458
|
+
["Master Key", target?.masterKey || "(not set)"]
|
|
1459
|
+
]),
|
|
1460
|
+
"Providers\n" + renderAsciiTable(
|
|
1461
|
+
["Provider ID", "Name", "Type", "Request Formats", "Models", "Rate-Limit Buckets"],
|
|
1462
|
+
providerSummaryRows,
|
|
1463
|
+
{ emptyMessage: "No providers configured." }
|
|
1464
|
+
),
|
|
1465
|
+
...providers.map((provider) => buildProviderConfigSection(provider)),
|
|
1466
|
+
"Model Aliases\n" + renderAsciiTable(
|
|
1467
|
+
["Alias ID", "Routing Strategy", "Primary Routes", "Fallback Routes"],
|
|
1468
|
+
aliasRows,
|
|
1469
|
+
{ emptyMessage: "No model aliases configured." }
|
|
1470
|
+
)
|
|
1471
|
+
);
|
|
1202
1472
|
}
|
|
1203
1473
|
|
|
1204
1474
|
function runCommand(command, args, { cwd, input, envOverrides } = {}) {
|
|
@@ -2307,12 +2577,7 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2307
2577
|
{ value: "__new__", label: "Add new provider" },
|
|
2308
2578
|
...providers.map((provider) => ({
|
|
2309
2579
|
value: provider.id,
|
|
2310
|
-
label: `Edit ${provider.id}
|
|
2311
|
-
hint: provider.baseUrl
|
|
2312
|
-
? String(provider.baseUrl)
|
|
2313
|
-
: (normalizeProviderTypeInput(provider.type) === PROVIDER_TYPE_SUBSCRIPTION
|
|
2314
|
-
? `subscription:${provider.subscriptionType || provider.subscription_type || "(unknown)"}`
|
|
2315
|
-
: "(no endpoint)")
|
|
2580
|
+
label: `Edit ${provider.id}`
|
|
2316
2581
|
}))
|
|
2317
2582
|
]
|
|
2318
2583
|
});
|
|
@@ -2324,18 +2589,29 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2324
2589
|
}
|
|
2325
2590
|
|
|
2326
2591
|
const baseProviderId = argProviderId || selectedExisting?.id || "";
|
|
2327
|
-
const
|
|
2328
|
-
const
|
|
2329
|
-
|
|
2592
|
+
const rawNameArg = readArg(args, ["name"], undefined);
|
|
2593
|
+
const providedName = String(rawNameArg !== undefined ? rawNameArg : (selectedExisting?.name || "")) || "";
|
|
2594
|
+
const rawProviderTypeArg = readArg(args, ["type", "provider-type", "providerType"], undefined);
|
|
2595
|
+
const hasProviderTypeArg = rawProviderTypeArg !== undefined;
|
|
2596
|
+
const hasSubscriptionTypeArg = readArg(args, ["subscription-type", "subscriptionType"], undefined) !== undefined;
|
|
2597
|
+
const hasSubscriptionProfileArg = readArg(args, ["subscription-profile", "subscriptionProfile"], undefined) !== undefined;
|
|
2598
|
+
const hasModelsArg = readArg(args, ["models"], undefined) !== undefined;
|
|
2599
|
+
const initialProviderType = normalizeProviderTypeInput(
|
|
2600
|
+
rawProviderTypeArg !== undefined ? rawProviderTypeArg : (selectedExisting?.type || PROVIDER_TYPE_STANDARD)
|
|
2330
2601
|
) || PROVIDER_TYPE_STANDARD;
|
|
2331
|
-
const
|
|
2332
|
-
readArg(args, ["subscription-type", "subscriptionType"], selectedExisting?.subscriptionType || selectedExisting?.subscription_type || "")
|
|
2333
|
-
);
|
|
2334
|
-
const baseSubscriptionProfile = String(readArg(
|
|
2602
|
+
const rawSubscriptionType = String(readArg(
|
|
2335
2603
|
args,
|
|
2336
|
-
["subscription-
|
|
2337
|
-
selectedExisting?.
|
|
2604
|
+
["subscription-type", "subscriptionType"],
|
|
2605
|
+
selectedExisting?.subscriptionType || selectedExisting?.subscription_type || ""
|
|
2338
2606
|
) || "").trim();
|
|
2607
|
+
const normalizedRequestedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
|
|
2608
|
+
const baseSubscriptionType = initialProviderType === PROVIDER_TYPE_SUBSCRIPTION
|
|
2609
|
+
? (
|
|
2610
|
+
hasSubscriptionTypeArg
|
|
2611
|
+
? (normalizedRequestedSubscriptionType || rawSubscriptionType)
|
|
2612
|
+
: (normalizedRequestedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
|
|
2613
|
+
)
|
|
2614
|
+
: "";
|
|
2339
2615
|
const baseUrl = String(readArg(args, ["base-url", "baseUrl"], selectedExisting?.baseUrl || "") || "");
|
|
2340
2616
|
const baseEndpoints = parseEndpointListInput(readArg(
|
|
2341
2617
|
args,
|
|
@@ -2365,29 +2641,144 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2365
2641
|
const shouldProbe = !toBoolean(readArg(args, ["skip-probe", "skipProbe"], false), false);
|
|
2366
2642
|
const setMasterKeyFlag = toBoolean(readArg(args, ["set-master-key", "setMasterKey"], false), false);
|
|
2367
2643
|
const providedMasterKey = String(readArg(args, ["master-key", "masterKey"], "") || "");
|
|
2644
|
+
const subscriptionDeviceCode = toBoolean(readArg(args, ["device-code", "deviceCode"], false), false);
|
|
2368
2645
|
const parsedHeaders = applyDefaultHeaders(
|
|
2369
2646
|
parseJsonObjectArg(baseHeaders, "--headers"),
|
|
2370
2647
|
{ force: !hasHeadersArg }
|
|
2371
2648
|
);
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2649
|
+
let providerType = initialProviderType;
|
|
2650
|
+
if (canPrompt()) {
|
|
2651
|
+
printProviderInputGuidance(context);
|
|
2652
|
+
if (!hasProviderTypeArg) {
|
|
2653
|
+
providerType = await context.prompts.select({
|
|
2654
|
+
message: "Provider auth mode",
|
|
2655
|
+
initialValue: providerType,
|
|
2656
|
+
options: [
|
|
2657
|
+
{
|
|
2658
|
+
value: PROVIDER_TYPE_STANDARD,
|
|
2659
|
+
label: "API Key"
|
|
2660
|
+
},
|
|
2661
|
+
{
|
|
2662
|
+
value: PROVIDER_TYPE_SUBSCRIPTION,
|
|
2663
|
+
label: "OAuth"
|
|
2664
|
+
}
|
|
2665
|
+
]
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
let subscriptionType = providerType === PROVIDER_TYPE_SUBSCRIPTION
|
|
2671
|
+
? baseSubscriptionType
|
|
2672
|
+
: "";
|
|
2673
|
+
if (providerType === PROVIDER_TYPE_SUBSCRIPTION && canPrompt() && !hasSubscriptionTypeArg) {
|
|
2674
|
+
subscriptionType = await context.prompts.select({
|
|
2675
|
+
message: "Subscription provider",
|
|
2676
|
+
initialValue: subscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX,
|
|
2677
|
+
options: SUBSCRIPTION_PROVIDER_PRESETS.map((preset) => ({
|
|
2678
|
+
value: preset.subscriptionType,
|
|
2679
|
+
label: preset.label
|
|
2680
|
+
}))
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
const subscriptionPreset = providerType === PROVIDER_TYPE_SUBSCRIPTION
|
|
2684
|
+
? getSubscriptionProviderPreset(subscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
|
|
2685
|
+
: null;
|
|
2686
|
+
|
|
2687
|
+
const defaultName = String(
|
|
2688
|
+
providedName ||
|
|
2689
|
+
(providerType === PROVIDER_TYPE_SUBSCRIPTION ? subscriptionPreset?.defaultName : "") ||
|
|
2690
|
+
""
|
|
2691
|
+
).trim();
|
|
2692
|
+
|
|
2693
|
+
const name = canPrompt()
|
|
2694
|
+
? await context.prompts.text({
|
|
2695
|
+
message: "Provider Friendly Name (unique, shown in management screen)",
|
|
2696
|
+
required: true,
|
|
2697
|
+
initialValue: defaultName,
|
|
2698
|
+
placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION ? "GPT Sub" : "OpenRouter Primary",
|
|
2699
|
+
validate: (value) => {
|
|
2700
|
+
const candidate = String(value || "").trim();
|
|
2701
|
+
if (!candidate) return "Provider Friendly Name is required.";
|
|
2702
|
+
const duplicate = findProviderByFriendlyName(providers, candidate, { excludeId: selectedExisting?.id || baseProviderId });
|
|
2703
|
+
if (duplicate) return `Provider Friendly Name '${candidate}' already exists (provider-id: ${duplicate.id}). Use a unique name.`;
|
|
2704
|
+
return undefined;
|
|
2705
|
+
}
|
|
2706
|
+
})
|
|
2707
|
+
: defaultName;
|
|
2708
|
+
|
|
2709
|
+
const generatedProviderId = resolveUniqueProviderId(
|
|
2710
|
+
slugifyId(name || subscriptionPreset?.defaultName || "provider"),
|
|
2711
|
+
providers,
|
|
2712
|
+
{ excludeId: selectedExisting?.id || baseProviderId }
|
|
2713
|
+
);
|
|
2714
|
+
|
|
2715
|
+
const providerId = canPrompt()
|
|
2716
|
+
? (baseProviderId || await context.prompts.text({
|
|
2717
|
+
message: "Provider ID (auto-slug from Friendly Name; editable)",
|
|
2718
|
+
required: true,
|
|
2719
|
+
initialValue: generatedProviderId,
|
|
2720
|
+
placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION ? "gpt-sub" : "openrouter-primary",
|
|
2721
|
+
validate: (value) => {
|
|
2722
|
+
const candidate = String(value || "").trim();
|
|
2723
|
+
if (!candidate) return "Provider ID is required.";
|
|
2724
|
+
if (!PROVIDER_ID_PATTERN.test(candidate)) {
|
|
2725
|
+
return "Use lowercase letters, numbers, and dashes only (e.g. openrouter-primary).";
|
|
2726
|
+
}
|
|
2727
|
+
const duplicate = (providers || []).find((provider) =>
|
|
2728
|
+
provider &&
|
|
2729
|
+
String(provider.id || "").trim() === candidate &&
|
|
2730
|
+
String(provider.id || "").trim() !== String(selectedExisting?.id || baseProviderId || "").trim());
|
|
2731
|
+
if (duplicate) {
|
|
2732
|
+
return `Provider ID '${candidate}' already exists.`;
|
|
2733
|
+
}
|
|
2734
|
+
return undefined;
|
|
2735
|
+
}
|
|
2736
|
+
}))
|
|
2737
|
+
: (baseProviderId || generatedProviderId);
|
|
2738
|
+
|
|
2739
|
+
const subscriptionProfile = providerType === PROVIDER_TYPE_SUBSCRIPTION
|
|
2740
|
+
? String(readArg(
|
|
2741
|
+
args,
|
|
2742
|
+
["subscription-profile", "subscriptionProfile"],
|
|
2743
|
+
selectedExisting?.subscriptionProfile || selectedExisting?.subscription_profile || providerId
|
|
2744
|
+
) || "").trim() || providerId
|
|
2745
|
+
: "";
|
|
2746
|
+
const requiresSubscriptionLogin = providerType === PROVIDER_TYPE_SUBSCRIPTION && (
|
|
2747
|
+
!selectedExisting ||
|
|
2748
|
+
normalizeProviderTypeInput(selectedExisting?.type) !== PROVIDER_TYPE_SUBSCRIPTION ||
|
|
2749
|
+
(hasSubscriptionProfileArg && subscriptionProfile !== String(
|
|
2750
|
+
selectedExisting?.subscriptionProfile || selectedExisting?.subscription_profile || selectedExisting?.id || ""
|
|
2751
|
+
).trim())
|
|
2752
|
+
);
|
|
2753
|
+
const subscriptionModelsInput = providerType === PROVIDER_TYPE_SUBSCRIPTION
|
|
2754
|
+
? String(
|
|
2755
|
+
hasModelsArg
|
|
2756
|
+
? readArg(args, ["models"], "")
|
|
2757
|
+
: getDefaultSubscriptionModelListInput(selectedExisting)
|
|
2758
|
+
)
|
|
2759
|
+
: "";
|
|
2760
|
+
const baseIsSubscription = providerType === PROVIDER_TYPE_SUBSCRIPTION;
|
|
2376
2761
|
|
|
2377
2762
|
if (!canPrompt()) {
|
|
2378
2763
|
return {
|
|
2379
2764
|
configPath,
|
|
2380
|
-
providerId
|
|
2381
|
-
name
|
|
2382
|
-
providerType
|
|
2383
|
-
subscriptionType
|
|
2384
|
-
subscriptionProfile
|
|
2765
|
+
providerId,
|
|
2766
|
+
name,
|
|
2767
|
+
providerType,
|
|
2768
|
+
subscriptionType,
|
|
2769
|
+
subscriptionProfile,
|
|
2770
|
+
subscriptionDeviceCode,
|
|
2771
|
+
requireSubscriptionLogin: requiresSubscriptionLogin,
|
|
2772
|
+
hasModelsArg,
|
|
2773
|
+
subscriptionModelsInput,
|
|
2385
2774
|
baseUrl,
|
|
2386
2775
|
endpoints: baseEndpoints,
|
|
2387
2776
|
openaiBaseUrl: baseOpenAIBaseUrl,
|
|
2388
2777
|
claudeBaseUrl: baseClaudeBaseUrl,
|
|
2389
2778
|
apiKey: baseIsSubscription ? "" : (baseApiKey || selectedExisting?.apiKey || ""),
|
|
2390
|
-
models:
|
|
2779
|
+
models: baseIsSubscription
|
|
2780
|
+
? parseProviderModelListInput(subscriptionModelsInput)
|
|
2781
|
+
: parseProviderModelListInput(baseModels),
|
|
2391
2782
|
format: baseIsSubscription ? "openai" : baseFormat,
|
|
2392
2783
|
formats: baseFormats,
|
|
2393
2784
|
headers: parsedHeaders,
|
|
@@ -2398,90 +2789,11 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2398
2789
|
};
|
|
2399
2790
|
}
|
|
2400
2791
|
|
|
2401
|
-
printProviderInputGuidance(context);
|
|
2402
|
-
|
|
2403
|
-
const name = baseName || await context.prompts.text({
|
|
2404
|
-
message: "Provider Friendly Name (unique, shown in management screen)",
|
|
2405
|
-
required: true,
|
|
2406
|
-
placeholder: "OpenRouter Primary",
|
|
2407
|
-
validate: (value) => {
|
|
2408
|
-
const candidate = String(value || "").trim();
|
|
2409
|
-
if (!candidate) return "Provider Friendly Name is required.";
|
|
2410
|
-
const duplicate = findProviderByFriendlyName(providers, candidate, { excludeId: selectedExisting?.id || baseProviderId });
|
|
2411
|
-
if (duplicate) return `Provider Friendly Name '${candidate}' already exists (provider-id: ${duplicate.id}). Use a unique name.`;
|
|
2412
|
-
return undefined;
|
|
2413
|
-
}
|
|
2414
|
-
});
|
|
2415
|
-
|
|
2416
|
-
const providerId = baseProviderId || await context.prompts.text({
|
|
2417
|
-
message: "Provider ID (auto-slug from Friendly Name; editable)",
|
|
2418
|
-
required: true,
|
|
2419
|
-
initialValue: slugifyId(name),
|
|
2420
|
-
placeholder: "openrouterPrimary",
|
|
2421
|
-
validate: (value) => {
|
|
2422
|
-
const candidate = String(value || "").trim();
|
|
2423
|
-
if (!candidate) return "Provider ID is required.";
|
|
2424
|
-
if (!PROVIDER_ID_PATTERN.test(candidate)) {
|
|
2425
|
-
return "Use slug/camelCase with letters, numbers, underscore, dot, or hyphen (e.g. openrouterPrimary).";
|
|
2426
|
-
}
|
|
2427
|
-
return undefined;
|
|
2428
|
-
}
|
|
2429
|
-
});
|
|
2430
|
-
|
|
2431
|
-
let providerType = baseProviderType;
|
|
2432
|
-
if (!hasProviderTypeArg) {
|
|
2433
|
-
providerType = await context.prompts.select({
|
|
2434
|
-
message: "Provider type",
|
|
2435
|
-
initialValue: providerType,
|
|
2436
|
-
options: [
|
|
2437
|
-
{
|
|
2438
|
-
value: PROVIDER_TYPE_STANDARD,
|
|
2439
|
-
label: "Standard API provider",
|
|
2440
|
-
hint: "Use endpoint + API key providers such as OpenRouter, Anthropic, OpenAI-compatible gateways."
|
|
2441
|
-
},
|
|
2442
|
-
{
|
|
2443
|
-
value: PROVIDER_TYPE_SUBSCRIPTION,
|
|
2444
|
-
label: "Subscription provider",
|
|
2445
|
-
hint: "Use OAuth-backed ChatGPT subscription access for predefined Codex/GPT models."
|
|
2446
|
-
}
|
|
2447
|
-
]
|
|
2448
|
-
});
|
|
2449
|
-
}
|
|
2450
|
-
|
|
2451
|
-
let subscriptionType = baseSubscriptionType;
|
|
2452
|
-
let subscriptionProfile = baseSubscriptionProfile || providerId || "default";
|
|
2453
|
-
if (providerType === PROVIDER_TYPE_SUBSCRIPTION) {
|
|
2454
|
-
if (!hasSubscriptionTypeArg) {
|
|
2455
|
-
subscriptionType = await context.prompts.select({
|
|
2456
|
-
message: "Subscription provider type",
|
|
2457
|
-
initialValue: subscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX,
|
|
2458
|
-
options: [
|
|
2459
|
-
{
|
|
2460
|
-
value: SUBSCRIPTION_TYPE_CHATGPT_CODEX,
|
|
2461
|
-
label: "ChatGPT Codex",
|
|
2462
|
-
hint: "Uses llm-router subscription login/logout/status OAuth flow and predefined GPT/Codex models."
|
|
2463
|
-
}
|
|
2464
|
-
]
|
|
2465
|
-
});
|
|
2466
|
-
}
|
|
2467
|
-
if (!hasSubscriptionProfileArg) {
|
|
2468
|
-
subscriptionProfile = await context.prompts.text({
|
|
2469
|
-
message: "Subscription profile ID",
|
|
2470
|
-
required: true,
|
|
2471
|
-
initialValue: subscriptionProfile,
|
|
2472
|
-
placeholder: "personal",
|
|
2473
|
-
validate: (value) => {
|
|
2474
|
-
const candidate = String(value || "").trim();
|
|
2475
|
-
if (!candidate) return "Subscription profile ID is required.";
|
|
2476
|
-
return undefined;
|
|
2477
|
-
}
|
|
2478
|
-
});
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
2792
|
let apiKey = "";
|
|
2483
2793
|
let endpoints = [];
|
|
2484
|
-
let models =
|
|
2794
|
+
let models = providerType === PROVIDER_TYPE_SUBSCRIPTION
|
|
2795
|
+
? parseProviderModelListInput(subscriptionModelsInput)
|
|
2796
|
+
: parseProviderModelListInput(baseModels);
|
|
2485
2797
|
let interactiveHeaders = parsedHeaders;
|
|
2486
2798
|
let probe = false;
|
|
2487
2799
|
let probeRequestsPerMinute = baseProbeRequestsPerMinute;
|
|
@@ -2489,7 +2801,7 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2489
2801
|
|
|
2490
2802
|
if (providerType === PROVIDER_TYPE_SUBSCRIPTION) {
|
|
2491
2803
|
const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : null;
|
|
2492
|
-
info?.("
|
|
2804
|
+
info?.(`${subscriptionPreset?.label || "ChatGPT"} uses browser OAuth login. Model validation will run after authentication.`);
|
|
2493
2805
|
} else {
|
|
2494
2806
|
const askReplaceKey = selectedExisting?.apiKey ? await context.prompts.confirm({
|
|
2495
2807
|
message: "Replace saved API key?",
|
|
@@ -2507,21 +2819,19 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2507
2819
|
});
|
|
2508
2820
|
|
|
2509
2821
|
const endpointsInput = await context.prompts.text({
|
|
2510
|
-
message: "Provider endpoints (comma
|
|
2822
|
+
message: "Provider endpoints (comma-separated URLs)",
|
|
2511
2823
|
required: true,
|
|
2512
|
-
initialValue: baseEndpoints.join("
|
|
2513
|
-
|
|
2514
|
-
multiline: true
|
|
2824
|
+
initialValue: baseEndpoints.join(","),
|
|
2825
|
+
validate: (value) => validateEndpointListInput(value)
|
|
2515
2826
|
});
|
|
2516
2827
|
endpoints = parseEndpointListInput(endpointsInput);
|
|
2517
2828
|
maybeReportInputCleanup(context, "endpoint", endpointsInput, endpoints);
|
|
2518
2829
|
|
|
2519
2830
|
const modelsInput = await context.prompts.text({
|
|
2520
|
-
message: "Provider models (comma
|
|
2831
|
+
message: "Provider models (comma-separated IDs)",
|
|
2521
2832
|
required: true,
|
|
2522
2833
|
initialValue: baseModels,
|
|
2523
|
-
|
|
2524
|
-
multiline: true
|
|
2834
|
+
validate: (value) => validateProviderModelListInput(value)
|
|
2525
2835
|
});
|
|
2526
2836
|
models = parseProviderModelListInput(modelsInput);
|
|
2527
2837
|
maybeReportInputCleanup(context, "model", modelsInput, models);
|
|
@@ -2584,6 +2894,10 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2584
2894
|
providerType,
|
|
2585
2895
|
subscriptionType,
|
|
2586
2896
|
subscriptionProfile,
|
|
2897
|
+
subscriptionDeviceCode,
|
|
2898
|
+
requireSubscriptionLogin: requiresSubscriptionLogin,
|
|
2899
|
+
hasModelsArg,
|
|
2900
|
+
subscriptionModelsInput,
|
|
2587
2901
|
baseUrl,
|
|
2588
2902
|
endpoints,
|
|
2589
2903
|
openaiBaseUrl: baseOpenAIBaseUrl,
|
|
@@ -2600,14 +2914,208 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
2600
2914
|
};
|
|
2601
2915
|
}
|
|
2602
2916
|
|
|
2917
|
+
async function resolveSubscriptionAuthFns(context) {
|
|
2918
|
+
if (context?.subscriptionAuth && typeof context.subscriptionAuth === "object") {
|
|
2919
|
+
return context.subscriptionAuth;
|
|
2920
|
+
}
|
|
2921
|
+
return import("../runtime/subscription-auth.js");
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
async function resolveSubscriptionProviderFns(context) {
|
|
2925
|
+
if (context?.subscriptionProvider && typeof context.subscriptionProvider === "object") {
|
|
2926
|
+
return context.subscriptionProvider;
|
|
2927
|
+
}
|
|
2928
|
+
return import("../runtime/subscription-provider.js");
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
async function ensureSubscriptionAuthenticated(context, {
|
|
2932
|
+
profile,
|
|
2933
|
+
forceLogin = false,
|
|
2934
|
+
deviceCode = false
|
|
2935
|
+
}) {
|
|
2936
|
+
const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
|
|
2937
|
+
const authFns = await resolveSubscriptionAuthFns(context);
|
|
2938
|
+
const getAuthStatus = typeof authFns.getAuthStatus === "function" ? authFns.getAuthStatus : null;
|
|
2939
|
+
const loginWithBrowser = typeof authFns.loginWithBrowser === "function" ? authFns.loginWithBrowser : null;
|
|
2940
|
+
const loginWithDeviceCode = typeof authFns.loginWithDeviceCode === "function" ? authFns.loginWithDeviceCode : null;
|
|
2941
|
+
|
|
2942
|
+
if (!getAuthStatus || !loginWithBrowser || !loginWithDeviceCode) {
|
|
2943
|
+
throw new Error("Subscription auth module is missing required login/status functions.");
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
if (!forceLogin) {
|
|
2947
|
+
const status = await getAuthStatus(profile);
|
|
2948
|
+
if (status?.authenticated) {
|
|
2949
|
+
line?.(`Subscription profile '${profile}' already authenticated.`);
|
|
2950
|
+
return { authenticated: true, loginAttempted: false };
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
line?.(`Starting OAuth login for subscription profile '${profile}'...`);
|
|
2955
|
+
if (deviceCode) {
|
|
2956
|
+
await loginWithDeviceCode(profile, {
|
|
2957
|
+
onCode: ({ userCode, verificationUri, expiresIn }) => {
|
|
2958
|
+
line?.(`Open ${verificationUri} and enter code ${userCode} (expires in ${Math.floor(Number(expiresIn || 0) / 60)} minutes).`);
|
|
2959
|
+
}
|
|
2960
|
+
});
|
|
2961
|
+
} else {
|
|
2962
|
+
await loginWithBrowser(profile, {
|
|
2963
|
+
onUrl: (url, meta = {}) => {
|
|
2964
|
+
if (meta?.openedBrowser === true) {
|
|
2965
|
+
line?.("Opened browser for OAuth login. Complete authentication to continue.");
|
|
2966
|
+
} else {
|
|
2967
|
+
line?.(`Open this OAuth URL in your browser: ${url}`);
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
const refreshedStatus = await getAuthStatus(profile);
|
|
2974
|
+
if (!refreshedStatus?.authenticated) {
|
|
2975
|
+
throw new Error(`OAuth login did not complete for subscription profile '${profile}'.`);
|
|
2976
|
+
}
|
|
2977
|
+
return { authenticated: true, loginAttempted: true };
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
function buildSubscriptionProbeSeed(models) {
|
|
2981
|
+
const modelSupport = {};
|
|
2982
|
+
const modelPreferredFormat = {};
|
|
2983
|
+
for (const model of (models || [])) {
|
|
2984
|
+
modelSupport[model] = [FORMATS.OPENAI];
|
|
2985
|
+
modelPreferredFormat[model] = FORMATS.OPENAI;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
return {
|
|
2989
|
+
ok: true,
|
|
2990
|
+
preferredFormat: FORMATS.OPENAI,
|
|
2991
|
+
formats: [FORMATS.OPENAI],
|
|
2992
|
+
workingFormats: [FORMATS.OPENAI],
|
|
2993
|
+
models: [...(models || [])],
|
|
2994
|
+
modelSupport,
|
|
2995
|
+
modelPreferredFormat
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
function summarizeSubscriptionProbeFailure(result, fallback = "Subscription model probe failed.") {
|
|
3000
|
+
if (!result || typeof result !== "object") return fallback;
|
|
3001
|
+
if (result.errorKind) return `${result.errorKind}: ${result.status || "unknown-status"}`;
|
|
3002
|
+
if (Number.isFinite(result.status)) return `status=${result.status}`;
|
|
3003
|
+
return fallback;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
async function closeSubscriptionProbeResponse(result) {
|
|
3007
|
+
if (!(result?.response instanceof Response)) return;
|
|
3008
|
+
const body = result.response.body;
|
|
3009
|
+
if (body && typeof body.cancel === "function") {
|
|
3010
|
+
try {
|
|
3011
|
+
await body.cancel();
|
|
3012
|
+
return;
|
|
3013
|
+
} catch {
|
|
3014
|
+
// Fall through and try draining as a best effort.
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
try {
|
|
3018
|
+
await result.response.arrayBuffer();
|
|
3019
|
+
} catch {
|
|
3020
|
+
// Ignore cleanup failure for probe responses.
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
async function probeSubscriptionModels(context, {
|
|
3025
|
+
providerId,
|
|
3026
|
+
providerName,
|
|
3027
|
+
subscriptionType,
|
|
3028
|
+
subscriptionProfile,
|
|
3029
|
+
models,
|
|
3030
|
+
headers
|
|
3031
|
+
}) {
|
|
3032
|
+
const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
|
|
3033
|
+
const providerFns = await resolveSubscriptionProviderFns(context);
|
|
3034
|
+
const makeSubscriptionProviderCall = typeof providerFns.makeSubscriptionProviderCall === "function"
|
|
3035
|
+
? providerFns.makeSubscriptionProviderCall
|
|
3036
|
+
: null;
|
|
3037
|
+
if (!makeSubscriptionProviderCall) {
|
|
3038
|
+
throw new Error("Subscription provider module is missing makeSubscriptionProviderCall.");
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
const uniqueModels = dedupeList(models);
|
|
3042
|
+
const provider = {
|
|
3043
|
+
id: providerId,
|
|
3044
|
+
name: providerName,
|
|
3045
|
+
type: PROVIDER_TYPE_SUBSCRIPTION,
|
|
3046
|
+
subscriptionType,
|
|
3047
|
+
subscriptionProfile,
|
|
3048
|
+
headers: headers || {},
|
|
3049
|
+
models: uniqueModels.map((id) => ({ id }))
|
|
3050
|
+
};
|
|
3051
|
+
|
|
3052
|
+
const failures = [];
|
|
3053
|
+
for (const modelId of uniqueModels) {
|
|
3054
|
+
line?.(`[subscription probe] Testing model ${modelId}...`);
|
|
3055
|
+
const probeBody = {
|
|
3056
|
+
model: modelId,
|
|
3057
|
+
stream: true,
|
|
3058
|
+
store: false,
|
|
3059
|
+
instructions: "You are a helpful assistant. Reply concisely.",
|
|
3060
|
+
input: [
|
|
3061
|
+
{
|
|
3062
|
+
type: "message",
|
|
3063
|
+
role: "user",
|
|
3064
|
+
content: [
|
|
3065
|
+
{
|
|
3066
|
+
type: "input_text",
|
|
3067
|
+
text: "Reply with exactly: pong"
|
|
3068
|
+
}
|
|
3069
|
+
]
|
|
3070
|
+
}
|
|
3071
|
+
],
|
|
3072
|
+
tools: [],
|
|
3073
|
+
tool_choice: "auto",
|
|
3074
|
+
parallel_tool_calls: false
|
|
3075
|
+
};
|
|
3076
|
+
const result = await makeSubscriptionProviderCall({
|
|
3077
|
+
provider,
|
|
3078
|
+
body: probeBody,
|
|
3079
|
+
stream: true
|
|
3080
|
+
});
|
|
3081
|
+
|
|
3082
|
+
if (result?.ok) {
|
|
3083
|
+
await closeSubscriptionProbeResponse(result);
|
|
3084
|
+
continue;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
let details = summarizeSubscriptionProbeFailure(result);
|
|
3088
|
+
if (result?.response instanceof Response) {
|
|
3089
|
+
try {
|
|
3090
|
+
const raw = await result.response.text();
|
|
3091
|
+
const compact = String(raw || "").trim().replace(/\s+/g, " ");
|
|
3092
|
+
if (compact) details = compact.slice(0, 300);
|
|
3093
|
+
} catch {
|
|
3094
|
+
// Ignore response parsing failure and keep fallback details.
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
failures.push({ modelId, details });
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
return {
|
|
3101
|
+
ok: failures.length === 0,
|
|
3102
|
+
failures,
|
|
3103
|
+
probe: buildSubscriptionProbeSeed(uniqueModels)
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
|
|
2603
3107
|
async function doUpsertProvider(context) {
|
|
2604
3108
|
const configPath = readArg(context.args, ["config", "configPath"], getDefaultConfigPath());
|
|
2605
3109
|
const existingConfig = await readConfigFile(configPath);
|
|
2606
3110
|
const input = await resolveUpsertInput(context, existingConfig);
|
|
2607
3111
|
const providerType = normalizeProviderTypeInput(input.providerType) || PROVIDER_TYPE_STANDARD;
|
|
2608
3112
|
const isSubscriptionProvider = providerType === PROVIDER_TYPE_SUBSCRIPTION;
|
|
2609
|
-
const
|
|
2610
|
-
const
|
|
3113
|
+
const rawSubscriptionType = String(input.subscriptionType || "").trim();
|
|
3114
|
+
const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
|
|
3115
|
+
const subscriptionType = isSubscriptionProvider
|
|
3116
|
+
? (normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
|
|
3117
|
+
: "";
|
|
3118
|
+
const subscriptionProfile = String(input.subscriptionProfile || input.providerId || "default").trim() || input.providerId || "default";
|
|
2611
3119
|
|
|
2612
3120
|
const endpointCandidates = parseEndpointListInput([
|
|
2613
3121
|
...(input.endpoints || []),
|
|
@@ -2616,6 +3124,7 @@ async function doUpsertProvider(context) {
|
|
|
2616
3124
|
input.baseUrl
|
|
2617
3125
|
].filter(Boolean).join(","));
|
|
2618
3126
|
const hasAnyEndpoint = endpointCandidates.length > 0;
|
|
3127
|
+
const hasAnyModel = dedupeList(input.models || []).length > 0;
|
|
2619
3128
|
if (!input.name) {
|
|
2620
3129
|
return {
|
|
2621
3130
|
ok: false,
|
|
@@ -2624,12 +3133,12 @@ async function doUpsertProvider(context) {
|
|
|
2624
3133
|
errorMessage: "Provider Friendly Name is required."
|
|
2625
3134
|
};
|
|
2626
3135
|
}
|
|
2627
|
-
if (isSubscriptionProvider && !
|
|
3136
|
+
if (isSubscriptionProvider && rawSubscriptionType && !normalizedSubscriptionType) {
|
|
2628
3137
|
return {
|
|
2629
3138
|
ok: false,
|
|
2630
3139
|
mode: context.mode,
|
|
2631
3140
|
exitCode: EXIT_VALIDATION,
|
|
2632
|
-
errorMessage:
|
|
3141
|
+
errorMessage: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${SUBSCRIPTION_TYPE_CHATGPT_CODEX}.`
|
|
2633
3142
|
};
|
|
2634
3143
|
}
|
|
2635
3144
|
if (!isSubscriptionProvider && (!hasAnyEndpoint || !input.apiKey)) {
|
|
@@ -2640,13 +3149,29 @@ async function doUpsertProvider(context) {
|
|
|
2640
3149
|
errorMessage: "Missing provider inputs: provider-id, name, api-key, and at least one endpoint."
|
|
2641
3150
|
};
|
|
2642
3151
|
}
|
|
3152
|
+
if (!isSubscriptionProvider && endpointCandidates.some((endpoint) => !isValidHttpUrl(endpoint))) {
|
|
3153
|
+
return {
|
|
3154
|
+
ok: false,
|
|
3155
|
+
mode: context.mode,
|
|
3156
|
+
exitCode: EXIT_VALIDATION,
|
|
3157
|
+
errorMessage: "One or more endpoints are invalid. Use full http:// or https:// URLs."
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
if (!isSubscriptionProvider && !hasAnyModel) {
|
|
3161
|
+
return {
|
|
3162
|
+
ok: false,
|
|
3163
|
+
mode: context.mode,
|
|
3164
|
+
exitCode: EXIT_VALIDATION,
|
|
3165
|
+
errorMessage: "At least one valid model id is required."
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
2643
3168
|
|
|
2644
3169
|
if (!PROVIDER_ID_PATTERN.test(input.providerId)) {
|
|
2645
3170
|
return {
|
|
2646
3171
|
ok: false,
|
|
2647
3172
|
mode: context.mode,
|
|
2648
3173
|
exitCode: EXIT_VALIDATION,
|
|
2649
|
-
errorMessage: `Invalid provider id '${input.providerId}'. Use
|
|
3174
|
+
errorMessage: `Invalid provider id '${input.providerId}'. Use lowercase letters, numbers, and dashes (e.g. openrouter-primary).`
|
|
2650
3175
|
};
|
|
2651
3176
|
}
|
|
2652
3177
|
|
|
@@ -2667,144 +3192,260 @@ async function doUpsertProvider(context) {
|
|
|
2667
3192
|
let effectiveBaseUrl = String(input.baseUrl || "").trim();
|
|
2668
3193
|
let effectiveOpenAIBaseUrl = String(input.openaiBaseUrl || "").trim();
|
|
2669
3194
|
let effectiveClaudeBaseUrl = String(input.claudeBaseUrl || "").trim();
|
|
2670
|
-
let effectiveModels =
|
|
3195
|
+
let effectiveModels = dedupeList(input.models || []);
|
|
2671
3196
|
const shouldProbe = !isSubscriptionProvider && Boolean(input.shouldProbe);
|
|
2672
3197
|
|
|
2673
|
-
if (
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
};
|
|
2680
|
-
}
|
|
2681
|
-
|
|
2682
|
-
if (shouldProbe) {
|
|
2683
|
-
const startedAt = Date.now();
|
|
2684
|
-
const reportProgress = probeProgressReporter(context);
|
|
2685
|
-
const probeRequestsPerMinute = toPositiveInteger(
|
|
2686
|
-
input.probeRequestsPerMinute,
|
|
2687
|
-
DEFAULT_PROBE_REQUESTS_PER_MINUTE
|
|
2688
|
-
);
|
|
2689
|
-
const canRunMatrixProbe = endpointCandidates.length > 0 && effectiveModels.length > 0;
|
|
2690
|
-
if (canRunMatrixProbe) {
|
|
2691
|
-
probe = await probeProviderEndpointMatrix({
|
|
2692
|
-
endpoints: endpointCandidates,
|
|
2693
|
-
models: effectiveModels,
|
|
2694
|
-
apiKey: input.apiKey,
|
|
2695
|
-
headers: input.headers,
|
|
2696
|
-
requestsPerMinute: probeRequestsPerMinute,
|
|
2697
|
-
maxRateLimitRetries: DEFAULT_PROBE_MAX_RATE_LIMIT_RETRIES,
|
|
2698
|
-
onProgress: reportProgress
|
|
3198
|
+
if (isSubscriptionProvider) {
|
|
3199
|
+
try {
|
|
3200
|
+
await ensureSubscriptionAuthenticated(context, {
|
|
3201
|
+
profile: subscriptionProfile,
|
|
3202
|
+
forceLogin: Boolean(input.requireSubscriptionLogin),
|
|
3203
|
+
deviceCode: Boolean(input.subscriptionDeviceCode)
|
|
2699
3204
|
});
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
if (effectiveOpenAIBaseUrl) probeBaseUrlByFormat.openai = effectiveOpenAIBaseUrl;
|
|
2716
|
-
if (effectiveClaudeBaseUrl) probeBaseUrlByFormat.claude = effectiveClaudeBaseUrl;
|
|
2717
|
-
|
|
2718
|
-
probe = await probeProvider({
|
|
2719
|
-
baseUrl: effectiveBaseUrl || endpointCandidates[0],
|
|
2720
|
-
baseUrlByFormat: Object.keys(probeBaseUrlByFormat).length > 0 ? probeBaseUrlByFormat : undefined,
|
|
2721
|
-
apiKey: input.apiKey,
|
|
2722
|
-
headers: input.headers,
|
|
2723
|
-
onProgress: reportProgress
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
return {
|
|
3207
|
+
ok: false,
|
|
3208
|
+
mode: context.mode,
|
|
3209
|
+
exitCode: EXIT_FAILURE,
|
|
3210
|
+
errorMessage: `Subscription OAuth login failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3211
|
+
};
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
if (canPrompt() && !input.hasModelsArg) {
|
|
3215
|
+
const modelsInput = await context.prompts.text({
|
|
3216
|
+
message: "Subscription models (comma-separated IDs)",
|
|
3217
|
+
required: true,
|
|
3218
|
+
initialValue: String(input.subscriptionModelsInput || "").trim(),
|
|
3219
|
+
validate: (value) => validateProviderModelListInput(value)
|
|
2724
3220
|
});
|
|
3221
|
+
effectiveModels = parseProviderModelListInput(modelsInput);
|
|
3222
|
+
maybeReportInputCleanup(context, "model", modelsInput, effectiveModels);
|
|
3223
|
+
} else if (effectiveModels.length === 0) {
|
|
3224
|
+
effectiveModels = parseProviderModelListInput(input.subscriptionModelsInput || "");
|
|
2725
3225
|
}
|
|
2726
|
-
|
|
2727
|
-
if (
|
|
2728
|
-
|
|
2729
|
-
|
|
3226
|
+
|
|
3227
|
+
if (effectiveModels.length === 0) {
|
|
3228
|
+
return {
|
|
3229
|
+
ok: false,
|
|
3230
|
+
mode: context.mode,
|
|
3231
|
+
exitCode: EXIT_VALIDATION,
|
|
3232
|
+
errorMessage: "Subscription provider requires at least one model after editing."
|
|
3233
|
+
};
|
|
2730
3234
|
}
|
|
2731
|
-
|
|
2732
|
-
|
|
3235
|
+
|
|
3236
|
+
const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : null;
|
|
3237
|
+
const formatProbeFailures = (failures = []) => [
|
|
3238
|
+
"Subscription model probe failed. Remove unsupported models and retry.",
|
|
3239
|
+
...failures.map((entry) => `- ${entry.modelId}: ${entry.details}`)
|
|
3240
|
+
].join("\n");
|
|
3241
|
+
|
|
3242
|
+
while (true) {
|
|
3243
|
+
let subscriptionProbe = null;
|
|
3244
|
+
try {
|
|
3245
|
+
subscriptionProbe = await probeSubscriptionModels(context, {
|
|
3246
|
+
providerId: input.providerId,
|
|
3247
|
+
providerName: input.name,
|
|
3248
|
+
subscriptionType,
|
|
3249
|
+
subscriptionProfile,
|
|
3250
|
+
models: effectiveModels,
|
|
3251
|
+
headers: input.headers
|
|
3252
|
+
});
|
|
3253
|
+
} catch (error) {
|
|
3254
|
+
return {
|
|
3255
|
+
ok: false,
|
|
3256
|
+
mode: context.mode,
|
|
3257
|
+
exitCode: EXIT_FAILURE,
|
|
3258
|
+
errorMessage: `Subscription model validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
if (subscriptionProbe.ok) {
|
|
3263
|
+
probe = subscriptionProbe.probe;
|
|
3264
|
+
break;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
2733
3267
|
if (!canPrompt()) {
|
|
2734
|
-
const scope = probe?.failureScope === "full" ? "full" : "partial";
|
|
2735
3268
|
return {
|
|
2736
3269
|
ok: false,
|
|
2737
3270
|
mode: context.mode,
|
|
2738
3271
|
exitCode: EXIT_FAILURE,
|
|
2739
|
-
errorMessage:
|
|
2740
|
-
? "Auto-discovery failed fully (no working endpoint/format detected). Re-run interactively for manual fallback or use --skip-probe=true with explicit endpoint/format."
|
|
2741
|
-
: "Auto-discovery failed partially (some endpoint/model checks unresolved). Re-run interactively for manual fallback or use --skip-probe=true with explicit endpoint/model inputs."
|
|
3272
|
+
errorMessage: formatProbeFailures(subscriptionProbe.failures)
|
|
2742
3273
|
};
|
|
2743
3274
|
}
|
|
2744
|
-
const fallback = await runProbeManualFallback(context, {
|
|
2745
|
-
probe,
|
|
2746
|
-
selectedFormat,
|
|
2747
|
-
effectiveOpenAIBaseUrl,
|
|
2748
|
-
effectiveClaudeBaseUrl,
|
|
2749
|
-
effectiveModels
|
|
2750
|
-
});
|
|
2751
|
-
selectedFormat = fallback.selectedFormat;
|
|
2752
|
-
effectiveOpenAIBaseUrl = fallback.effectiveOpenAIBaseUrl;
|
|
2753
|
-
effectiveClaudeBaseUrl = fallback.effectiveClaudeBaseUrl;
|
|
2754
|
-
effectiveModels = fallback.effectiveModels;
|
|
2755
|
-
effectiveBaseUrl =
|
|
2756
|
-
(selectedFormat === "openai" ? effectiveOpenAIBaseUrl : "") ||
|
|
2757
|
-
(selectedFormat === "claude" ? effectiveClaudeBaseUrl : "") ||
|
|
2758
|
-
effectiveOpenAIBaseUrl ||
|
|
2759
|
-
effectiveClaudeBaseUrl ||
|
|
2760
|
-
effectiveBaseUrl ||
|
|
2761
|
-
endpointCandidates[0];
|
|
2762
|
-
} else if (!probe.ok) {
|
|
2763
|
-
if (canPrompt()) {
|
|
2764
|
-
const continueWithoutProbe = await context.prompts.confirm({
|
|
2765
|
-
message: "Probe failed to confirm working endpoint/model support. Save provider anyway?",
|
|
2766
|
-
initialValue: false
|
|
2767
|
-
});
|
|
2768
|
-
if (!continueWithoutProbe) {
|
|
2769
|
-
return {
|
|
2770
|
-
ok: false,
|
|
2771
|
-
mode: context.mode,
|
|
2772
|
-
exitCode: EXIT_FAILURE,
|
|
2773
|
-
errorMessage: "Config cancelled because provider probe failed."
|
|
2774
|
-
};
|
|
2775
|
-
}
|
|
2776
3275
|
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
}
|
|
3276
|
+
warn?.(formatProbeFailures(subscriptionProbe.failures));
|
|
3277
|
+
const retry = await context.prompts.confirm({
|
|
3278
|
+
message: "Edit subscription models and retry probe?",
|
|
3279
|
+
initialValue: true
|
|
3280
|
+
});
|
|
3281
|
+
if (!retry) {
|
|
2782
3282
|
return {
|
|
2783
3283
|
ok: false,
|
|
2784
3284
|
mode: context.mode,
|
|
2785
3285
|
exitCode: EXIT_FAILURE,
|
|
2786
|
-
errorMessage:
|
|
3286
|
+
errorMessage: formatProbeFailures(subscriptionProbe.failures)
|
|
2787
3287
|
};
|
|
2788
3288
|
}
|
|
2789
|
-
}
|
|
2790
|
-
}
|
|
2791
3289
|
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
3290
|
+
const modelsInput = await context.prompts.text({
|
|
3291
|
+
message: "Subscription models (comma-separated IDs)",
|
|
3292
|
+
required: true,
|
|
3293
|
+
initialValue: effectiveModels.join(","),
|
|
3294
|
+
validate: (value) => validateProviderModelListInput(value)
|
|
3295
|
+
});
|
|
3296
|
+
effectiveModels = parseProviderModelListInput(modelsInput);
|
|
3297
|
+
maybeReportInputCleanup(context, "model", modelsInput, effectiveModels);
|
|
3298
|
+
if (effectiveModels.length === 0) {
|
|
3299
|
+
return {
|
|
3300
|
+
ok: false,
|
|
3301
|
+
mode: context.mode,
|
|
3302
|
+
exitCode: EXIT_VALIDATION,
|
|
3303
|
+
errorMessage: "Subscription provider requires at least one model after editing."
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
2799
3306
|
}
|
|
2800
|
-
|
|
3307
|
+
|
|
3308
|
+
selectedFormat = FORMATS.OPENAI;
|
|
3309
|
+
effectiveBaseUrl = "";
|
|
3310
|
+
effectiveOpenAIBaseUrl = "";
|
|
3311
|
+
effectiveClaudeBaseUrl = "";
|
|
3312
|
+
} else {
|
|
3313
|
+
if (shouldProbe && endpointCandidates.length > 0 && effectiveModels.length === 0) {
|
|
2801
3314
|
return {
|
|
2802
3315
|
ok: false,
|
|
2803
3316
|
mode: context.mode,
|
|
2804
3317
|
exitCode: EXIT_VALIDATION,
|
|
2805
|
-
errorMessage: "
|
|
3318
|
+
errorMessage: "Model list is required for endpoint-model probe. Provide --models=modelA,modelB."
|
|
2806
3319
|
};
|
|
2807
3320
|
}
|
|
3321
|
+
|
|
3322
|
+
if (shouldProbe) {
|
|
3323
|
+
const startedAt = Date.now();
|
|
3324
|
+
const reportProgress = probeProgressReporter(context);
|
|
3325
|
+
const probeRequestsPerMinute = toPositiveInteger(
|
|
3326
|
+
input.probeRequestsPerMinute,
|
|
3327
|
+
DEFAULT_PROBE_REQUESTS_PER_MINUTE
|
|
3328
|
+
);
|
|
3329
|
+
const canRunMatrixProbe = endpointCandidates.length > 0 && effectiveModels.length > 0;
|
|
3330
|
+
if (canRunMatrixProbe) {
|
|
3331
|
+
probe = await probeProviderEndpointMatrix({
|
|
3332
|
+
endpoints: endpointCandidates,
|
|
3333
|
+
models: effectiveModels,
|
|
3334
|
+
apiKey: input.apiKey,
|
|
3335
|
+
headers: input.headers,
|
|
3336
|
+
requestsPerMinute: probeRequestsPerMinute,
|
|
3337
|
+
maxRateLimitRetries: DEFAULT_PROBE_MAX_RATE_LIMIT_RETRIES,
|
|
3338
|
+
onProgress: reportProgress
|
|
3339
|
+
});
|
|
3340
|
+
effectiveOpenAIBaseUrl = probe.baseUrlByFormat?.openai || effectiveOpenAIBaseUrl;
|
|
3341
|
+
effectiveClaudeBaseUrl = probe.baseUrlByFormat?.claude || effectiveClaudeBaseUrl;
|
|
3342
|
+
effectiveBaseUrl =
|
|
3343
|
+
(probe.preferredFormat && probe.baseUrlByFormat?.[probe.preferredFormat]) ||
|
|
3344
|
+
effectiveOpenAIBaseUrl ||
|
|
3345
|
+
effectiveClaudeBaseUrl ||
|
|
3346
|
+
endpointCandidates[0] ||
|
|
3347
|
+
effectiveBaseUrl;
|
|
3348
|
+
if ((probe.models || []).length > 0) {
|
|
3349
|
+
effectiveModels = effectiveModels.length > 0
|
|
3350
|
+
? effectiveModels.filter((model) => (probe.models || []).includes(model))
|
|
3351
|
+
: [...probe.models];
|
|
3352
|
+
}
|
|
3353
|
+
} else {
|
|
3354
|
+
const probeBaseUrlByFormat = {};
|
|
3355
|
+
if (effectiveOpenAIBaseUrl) probeBaseUrlByFormat.openai = effectiveOpenAIBaseUrl;
|
|
3356
|
+
if (effectiveClaudeBaseUrl) probeBaseUrlByFormat.claude = effectiveClaudeBaseUrl;
|
|
3357
|
+
|
|
3358
|
+
probe = await probeProvider({
|
|
3359
|
+
baseUrl: effectiveBaseUrl || endpointCandidates[0],
|
|
3360
|
+
baseUrlByFormat: Object.keys(probeBaseUrlByFormat).length > 0 ? probeBaseUrlByFormat : undefined,
|
|
3361
|
+
apiKey: input.apiKey,
|
|
3362
|
+
headers: input.headers,
|
|
3363
|
+
onProgress: reportProgress
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
|
|
3367
|
+
if (line) {
|
|
3368
|
+
const tookMs = Date.now() - startedAt;
|
|
3369
|
+
line(`Auto-discovery finished in ${(tookMs / 1000).toFixed(1)}s.`);
|
|
3370
|
+
}
|
|
3371
|
+
selectedFormat = probe.preferredFormat || selectedFormat;
|
|
3372
|
+
if (probe?.manualFallbackRecommended) {
|
|
3373
|
+
if (!canPrompt()) {
|
|
3374
|
+
const scope = probe?.failureScope === "full" ? "full" : "partial";
|
|
3375
|
+
return {
|
|
3376
|
+
ok: false,
|
|
3377
|
+
mode: context.mode,
|
|
3378
|
+
exitCode: EXIT_FAILURE,
|
|
3379
|
+
errorMessage: scope === "full"
|
|
3380
|
+
? "Auto-discovery failed fully (no working endpoint/format detected). Re-run interactively for manual fallback or use --skip-probe=true with explicit endpoint/format."
|
|
3381
|
+
: "Auto-discovery failed partially (some endpoint/model checks unresolved). Re-run interactively for manual fallback or use --skip-probe=true with explicit endpoint/model inputs."
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
const fallback = await runProbeManualFallback(context, {
|
|
3385
|
+
probe,
|
|
3386
|
+
selectedFormat,
|
|
3387
|
+
effectiveOpenAIBaseUrl,
|
|
3388
|
+
effectiveClaudeBaseUrl,
|
|
3389
|
+
effectiveModels
|
|
3390
|
+
});
|
|
3391
|
+
selectedFormat = fallback.selectedFormat;
|
|
3392
|
+
effectiveOpenAIBaseUrl = fallback.effectiveOpenAIBaseUrl;
|
|
3393
|
+
effectiveClaudeBaseUrl = fallback.effectiveClaudeBaseUrl;
|
|
3394
|
+
effectiveModels = fallback.effectiveModels;
|
|
3395
|
+
effectiveBaseUrl =
|
|
3396
|
+
(selectedFormat === "openai" ? effectiveOpenAIBaseUrl : "") ||
|
|
3397
|
+
(selectedFormat === "claude" ? effectiveClaudeBaseUrl : "") ||
|
|
3398
|
+
effectiveOpenAIBaseUrl ||
|
|
3399
|
+
effectiveClaudeBaseUrl ||
|
|
3400
|
+
effectiveBaseUrl ||
|
|
3401
|
+
endpointCandidates[0];
|
|
3402
|
+
} else if (!probe.ok) {
|
|
3403
|
+
if (canPrompt()) {
|
|
3404
|
+
const continueWithoutProbe = await context.prompts.confirm({
|
|
3405
|
+
message: "Probe failed to confirm working endpoint/model support. Save provider anyway?",
|
|
3406
|
+
initialValue: false
|
|
3407
|
+
});
|
|
3408
|
+
if (!continueWithoutProbe) {
|
|
3409
|
+
return {
|
|
3410
|
+
ok: false,
|
|
3411
|
+
mode: context.mode,
|
|
3412
|
+
exitCode: EXIT_FAILURE,
|
|
3413
|
+
errorMessage: "Config cancelled because provider probe failed."
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
selectedFormat = await promptProviderFormat(context, {
|
|
3418
|
+
message: "Probe could not confirm a working format. Choose primary provider format",
|
|
3419
|
+
initialFormat: selectedFormat
|
|
3420
|
+
});
|
|
3421
|
+
} else {
|
|
3422
|
+
return {
|
|
3423
|
+
ok: false,
|
|
3424
|
+
mode: context.mode,
|
|
3425
|
+
exitCode: EXIT_FAILURE,
|
|
3426
|
+
errorMessage: "Provider probe failed. Provide valid endpoints/models or use --skip-probe=true to force save."
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
if (!shouldProbe) {
|
|
3433
|
+
if (!effectiveBaseUrl && endpointCandidates.length > 0) {
|
|
3434
|
+
effectiveBaseUrl = endpointCandidates[0];
|
|
3435
|
+
}
|
|
3436
|
+
if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length === 1 && selectedFormat) {
|
|
3437
|
+
if (selectedFormat === "openai") effectiveOpenAIBaseUrl = endpointCandidates[0];
|
|
3438
|
+
if (selectedFormat === "claude") effectiveClaudeBaseUrl = endpointCandidates[0];
|
|
3439
|
+
}
|
|
3440
|
+
if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length > 1) {
|
|
3441
|
+
return {
|
|
3442
|
+
ok: false,
|
|
3443
|
+
mode: context.mode,
|
|
3444
|
+
exitCode: EXIT_VALIDATION,
|
|
3445
|
+
errorMessage: "Multiple endpoints require probe mode (recommended) or explicit --openai-base-url/--claude-base-url."
|
|
3446
|
+
};
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
2808
3449
|
}
|
|
2809
3450
|
|
|
2810
3451
|
const effectiveFormat = isSubscriptionProvider
|
|
@@ -2844,24 +3485,17 @@ async function doUpsertProvider(context) {
|
|
|
2844
3485
|
});
|
|
2845
3486
|
|
|
2846
3487
|
await writeConfigFile(nextConfig, input.configPath);
|
|
3488
|
+
const savedProvider = (nextConfig.providers || []).find((entry) => entry.id === provider.id) || provider;
|
|
2847
3489
|
return {
|
|
2848
3490
|
ok: true,
|
|
2849
3491
|
mode: context.mode,
|
|
2850
3492
|
exitCode: EXIT_SUCCESS,
|
|
2851
|
-
data:
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
provider.subscriptionType ? `subscriptionType=${provider.subscriptionType}` : "",
|
|
2858
|
-
provider.subscriptionProfile ? `subscriptionProfile=${provider.subscriptionProfile}` : "",
|
|
2859
|
-
provider.baseUrlByFormat?.openai ? `openaiBaseUrl=${provider.baseUrlByFormat.openai}` : "",
|
|
2860
|
-
provider.baseUrlByFormat?.claude ? `claudeBaseUrl=${provider.baseUrlByFormat.claude}` : "",
|
|
2861
|
-
`formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`,
|
|
2862
|
-
`models=${provider.models.map((m) => `${m.id}${m.formats?.length ? `[${m.formats.join("|")}]` : ""}`).join(", ")}`,
|
|
2863
|
-
`masterKey=${nextConfig.masterKey ? maskSecret(nextConfig.masterKey) : "(not set)"}`
|
|
2864
|
-
].join("\n")
|
|
3493
|
+
data: buildProviderSavedReport({
|
|
3494
|
+
provider: savedProvider,
|
|
3495
|
+
configPath: input.configPath,
|
|
3496
|
+
probe,
|
|
3497
|
+
masterKey: nextConfig.masterKey ? maskSecret(nextConfig.masterKey) : "(not set)"
|
|
3498
|
+
})
|
|
2865
3499
|
};
|
|
2866
3500
|
}
|
|
2867
3501
|
|
|
@@ -2934,13 +3568,16 @@ async function doMigrateConfig(context) {
|
|
|
2934
3568
|
ok: true,
|
|
2935
3569
|
mode: context.mode,
|
|
2936
3570
|
exitCode: EXIT_SUCCESS,
|
|
2937
|
-
data:
|
|
2938
|
-
migration.changed
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
3571
|
+
data: buildOperationReport(
|
|
3572
|
+
migration.changed ? "Config Migration Completed" : "Config Already Up To Date",
|
|
3573
|
+
[
|
|
3574
|
+
["Config File", configPath],
|
|
3575
|
+
["Previous Version", String(migration.beforeVersion)],
|
|
3576
|
+
["Current Version", String(migration.afterVersion)],
|
|
3577
|
+
["Backup Created", formatYesNo(Boolean(migration.backupPath))],
|
|
3578
|
+
["Backup File", migration.backupPath || "(not created)"]
|
|
3579
|
+
]
|
|
3580
|
+
)
|
|
2944
3581
|
};
|
|
2945
3582
|
}
|
|
2946
3583
|
|
|
@@ -2958,8 +3595,7 @@ async function doRemoveProvider(context) {
|
|
|
2958
3595
|
message: "Remove provider",
|
|
2959
3596
|
options: config.providers.map((provider) => ({
|
|
2960
3597
|
value: provider.id,
|
|
2961
|
-
label: provider.id
|
|
2962
|
-
hint: `${provider.models.length} model(s)`
|
|
3598
|
+
label: provider.id
|
|
2963
3599
|
}))
|
|
2964
3600
|
});
|
|
2965
3601
|
}
|
|
@@ -3009,8 +3645,7 @@ async function doRemoveModel(context) {
|
|
|
3009
3645
|
message: "Select provider",
|
|
3010
3646
|
options: config.providers.map((provider) => ({
|
|
3011
3647
|
value: provider.id,
|
|
3012
|
-
label: provider.id
|
|
3013
|
-
hint: `${provider.models.length} model(s)`
|
|
3648
|
+
label: provider.id
|
|
3014
3649
|
}))
|
|
3015
3650
|
});
|
|
3016
3651
|
}
|
|
@@ -3079,8 +3714,7 @@ async function doSetModelFallbacks(context) {
|
|
|
3079
3714
|
message: "Select provider for silent-fallback",
|
|
3080
3715
|
options: config.providers.map((provider) => ({
|
|
3081
3716
|
value: provider.id,
|
|
3082
|
-
label: provider.id
|
|
3083
|
-
hint: `${provider.models.length} model(s)`
|
|
3717
|
+
label: provider.id
|
|
3084
3718
|
}))
|
|
3085
3719
|
});
|
|
3086
3720
|
}
|
|
@@ -3165,14 +3799,24 @@ async function doSetModelFallbacks(context) {
|
|
|
3165
3799
|
}
|
|
3166
3800
|
|
|
3167
3801
|
await writeConfigFile(updated.config, configPath);
|
|
3802
|
+
const routeRef = `${providerId}/${updated.modelId || modelId}`;
|
|
3803
|
+
const fallbackRows = (updated.fallbackModels || []).map((entry) => [entry]);
|
|
3168
3804
|
return {
|
|
3169
3805
|
ok: true,
|
|
3170
3806
|
mode: context.mode,
|
|
3171
3807
|
exitCode: EXIT_SUCCESS,
|
|
3172
|
-
data:
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3808
|
+
data: buildOperationReport(
|
|
3809
|
+
"Model Silent Fallbacks Updated",
|
|
3810
|
+
[
|
|
3811
|
+
["Source Model Route", routeRef],
|
|
3812
|
+
["Fallback Count", String((updated.fallbackModels || []).length)]
|
|
3813
|
+
],
|
|
3814
|
+
[
|
|
3815
|
+
"Fallback Routes\n" + renderAsciiTable(["Model Route"], fallbackRows, {
|
|
3816
|
+
emptyMessage: "No fallback routes configured."
|
|
3817
|
+
})
|
|
3818
|
+
]
|
|
3819
|
+
)
|
|
3176
3820
|
};
|
|
3177
3821
|
}
|
|
3178
3822
|
|
|
@@ -3219,8 +3863,7 @@ async function doUpsertModelAlias(context) {
|
|
|
3219
3863
|
{ value: "__new__", label: "Create new alias" },
|
|
3220
3864
|
...aliasIds.map((id) => ({
|
|
3221
3865
|
value: id,
|
|
3222
|
-
label: `Edit ${id}
|
|
3223
|
-
hint: `${(aliases[id]?.targets || []).length} target(s)`
|
|
3866
|
+
label: `Edit ${id}`
|
|
3224
3867
|
}))
|
|
3225
3868
|
]
|
|
3226
3869
|
});
|
|
@@ -3249,8 +3892,7 @@ async function doUpsertModelAlias(context) {
|
|
|
3249
3892
|
message: "Model alias routing strategy",
|
|
3250
3893
|
options: MODEL_ROUTING_STRATEGY_OPTIONS.map((option) => ({
|
|
3251
3894
|
value: option.value,
|
|
3252
|
-
label: option.label
|
|
3253
|
-
hint: option.hint
|
|
3895
|
+
label: option.label
|
|
3254
3896
|
})),
|
|
3255
3897
|
initialValue: selectedStrategy
|
|
3256
3898
|
});
|
|
@@ -3293,15 +3935,13 @@ async function doUpsertModelAlias(context) {
|
|
|
3293
3935
|
}
|
|
3294
3936
|
|
|
3295
3937
|
await writeConfigFile(updated.config, configPath);
|
|
3938
|
+
const savedAliasId = updated.aliasId || aliasId;
|
|
3939
|
+
const savedAlias = updated.config.modelAliases?.[savedAliasId];
|
|
3296
3940
|
return {
|
|
3297
3941
|
ok: true,
|
|
3298
3942
|
mode: context.mode,
|
|
3299
3943
|
exitCode: EXIT_SUCCESS,
|
|
3300
|
-
data:
|
|
3301
|
-
`Upserted model alias '${updated.aliasId || aliasId}'.`,
|
|
3302
|
-
`targets=${formatAliasTargetsForSummary(updated.config.modelAliases?.[updated.aliasId || aliasId]?.targets)}`,
|
|
3303
|
-
`fallbackTargets=${formatAliasTargetsForSummary(updated.config.modelAliases?.[updated.aliasId || aliasId]?.fallbackTargets)}`
|
|
3304
|
-
].join("\n")
|
|
3944
|
+
data: buildModelAliasSavedReport(savedAliasId, savedAlias)
|
|
3305
3945
|
};
|
|
3306
3946
|
}
|
|
3307
3947
|
|
|
@@ -3324,15 +3964,13 @@ async function doUpsertModelAlias(context) {
|
|
|
3324
3964
|
}
|
|
3325
3965
|
|
|
3326
3966
|
await writeConfigFile(updated.config, configPath);
|
|
3967
|
+
const savedAliasId = updated.aliasId || aliasId;
|
|
3968
|
+
const savedAlias = updated.config.modelAliases?.[savedAliasId];
|
|
3327
3969
|
return {
|
|
3328
3970
|
ok: true,
|
|
3329
3971
|
mode: context.mode,
|
|
3330
3972
|
exitCode: EXIT_SUCCESS,
|
|
3331
|
-
data:
|
|
3332
|
-
`Upserted model alias '${updated.aliasId || aliasId}'.`,
|
|
3333
|
-
`targets=${formatAliasTargetsForSummary(updated.config.modelAliases?.[updated.aliasId || aliasId]?.targets)}`,
|
|
3334
|
-
`fallbackTargets=${formatAliasTargetsForSummary(updated.config.modelAliases?.[updated.aliasId || aliasId]?.fallbackTargets)}`
|
|
3335
|
-
].join("\n")
|
|
3973
|
+
data: buildModelAliasSavedReport(savedAliasId, savedAlias)
|
|
3336
3974
|
};
|
|
3337
3975
|
}
|
|
3338
3976
|
|
|
@@ -3385,37 +4023,32 @@ function printRateLimitBucketIntro(context) {
|
|
|
3385
4023
|
}
|
|
3386
4024
|
|
|
3387
4025
|
function buildProviderRateLimitReview(provider) {
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
return [
|
|
3394
|
-
`Rate-limit buckets for '${provider?.id || "(unknown)"}':`,
|
|
3395
|
-
...buckets.map((bucket) => `- ${formatRateLimitBucketLabel(bucket, { includeId: true })}: ${summarizeRateLimitBucketCap(bucket)} | scope=${formatRateLimitBucketScopeLabel(bucket)}`)
|
|
3396
|
-
].join("\n");
|
|
4026
|
+
return buildProviderRateLimitReport({
|
|
4027
|
+
title: "Rate-Limit Bucket Review",
|
|
4028
|
+
providerId: provider?.id,
|
|
4029
|
+
rateLimits: provider?.rateLimits || []
|
|
4030
|
+
});
|
|
3397
4031
|
}
|
|
3398
4032
|
|
|
3399
4033
|
function buildRateLimitBucketPromptOptions(provider) {
|
|
3400
4034
|
return (provider?.rateLimits || []).map((bucket) => ({
|
|
3401
4035
|
value: bucket.id,
|
|
3402
|
-
label: `${formatRateLimitBucketLabel(bucket)} (${summarizeRateLimitBucketCap(bucket)})
|
|
3403
|
-
hint: formatRateLimitBucketScopeLabel(bucket)
|
|
4036
|
+
label: `${formatRateLimitBucketLabel(bucket)} (${summarizeRateLimitBucketCap(bucket)})`
|
|
3404
4037
|
}));
|
|
3405
4038
|
}
|
|
3406
4039
|
|
|
3407
4040
|
function buildRateLimitWindowUnitOptions(initialUnit = "") {
|
|
3408
4041
|
const options = [
|
|
3409
|
-
{ value: "minute", label: "Minute"
|
|
3410
|
-
{ value: "hour", label: "Hour(s)"
|
|
3411
|
-
{ value: "week", label: "Week"
|
|
3412
|
-
{ value: "month", label: "Month"
|
|
4042
|
+
{ value: "minute", label: "Minute" },
|
|
4043
|
+
{ value: "hour", label: "Hour(s)" },
|
|
4044
|
+
{ value: "week", label: "Week" },
|
|
4045
|
+
{ value: "month", label: "Month" }
|
|
3413
4046
|
];
|
|
3414
4047
|
if (initialUnit === "day") {
|
|
3415
|
-
options.push({ value: "day", label: "Day (legacy)"
|
|
4048
|
+
options.push({ value: "day", label: "Day (legacy)" });
|
|
3416
4049
|
}
|
|
3417
4050
|
if (initialUnit === "second") {
|
|
3418
|
-
options.push({ value: "second", label: "Second (legacy)"
|
|
4051
|
+
options.push({ value: "second", label: "Second (legacy)" });
|
|
3419
4052
|
}
|
|
3420
4053
|
return options;
|
|
3421
4054
|
}
|
|
@@ -3594,8 +4227,7 @@ async function doSetProviderRateLimits(context) {
|
|
|
3594
4227
|
message: "Select provider for rate-limit buckets",
|
|
3595
4228
|
options: config.providers.map((provider) => ({
|
|
3596
4229
|
value: provider.id,
|
|
3597
|
-
label: provider.id
|
|
3598
|
-
hint: `${(provider.rateLimits || []).length} bucket(s)`
|
|
4230
|
+
label: provider.id
|
|
3599
4231
|
}))
|
|
3600
4232
|
});
|
|
3601
4233
|
}
|
|
@@ -3643,7 +4275,16 @@ async function doSetProviderRateLimits(context) {
|
|
|
3643
4275
|
if (action === "remove") {
|
|
3644
4276
|
const bucketOptions = buildRateLimitBucketPromptOptions(provider);
|
|
3645
4277
|
if (bucketOptions.length === 0) {
|
|
3646
|
-
return {
|
|
4278
|
+
return {
|
|
4279
|
+
ok: true,
|
|
4280
|
+
mode: context.mode,
|
|
4281
|
+
exitCode: EXIT_SUCCESS,
|
|
4282
|
+
data: buildProviderRateLimitReport({
|
|
4283
|
+
title: "Rate-Limit Bucket Review",
|
|
4284
|
+
providerId,
|
|
4285
|
+
rateLimits: provider.rateLimits || []
|
|
4286
|
+
})
|
|
4287
|
+
};
|
|
3647
4288
|
}
|
|
3648
4289
|
|
|
3649
4290
|
const selectedBucketId = await context.prompts.select({
|
|
@@ -3676,14 +4317,27 @@ async function doSetProviderRateLimits(context) {
|
|
|
3676
4317
|
ok: true,
|
|
3677
4318
|
mode: context.mode,
|
|
3678
4319
|
exitCode: EXIT_SUCCESS,
|
|
3679
|
-
data:
|
|
4320
|
+
data: buildProviderRateLimitReport({
|
|
4321
|
+
title: `Rate-Limit Bucket Removed: ${formatRateLimitBucketLabel(selectedBucket, { includeId: true })}`,
|
|
4322
|
+
providerId,
|
|
4323
|
+
rateLimits: removed.rateLimits || []
|
|
4324
|
+
})
|
|
3680
4325
|
};
|
|
3681
4326
|
}
|
|
3682
4327
|
|
|
3683
4328
|
if (action === "edit") {
|
|
3684
4329
|
const bucketOptions = buildRateLimitBucketPromptOptions(provider);
|
|
3685
4330
|
if (bucketOptions.length === 0) {
|
|
3686
|
-
return {
|
|
4331
|
+
return {
|
|
4332
|
+
ok: true,
|
|
4333
|
+
mode: context.mode,
|
|
4334
|
+
exitCode: EXIT_SUCCESS,
|
|
4335
|
+
data: buildProviderRateLimitReport({
|
|
4336
|
+
title: "Rate-Limit Bucket Review",
|
|
4337
|
+
providerId,
|
|
4338
|
+
rateLimits: provider.rateLimits || []
|
|
4339
|
+
})
|
|
4340
|
+
};
|
|
3687
4341
|
}
|
|
3688
4342
|
|
|
3689
4343
|
const selectedBucketId = await context.prompts.select({
|
|
@@ -3729,10 +4383,11 @@ async function doSetProviderRateLimits(context) {
|
|
|
3729
4383
|
ok: true,
|
|
3730
4384
|
mode: context.mode,
|
|
3731
4385
|
exitCode: EXIT_SUCCESS,
|
|
3732
|
-
data:
|
|
3733
|
-
`
|
|
3734
|
-
|
|
3735
|
-
|
|
4386
|
+
data: buildProviderRateLimitReport({
|
|
4387
|
+
title: `Rate-Limit Bucket Updated: ${formatRateLimitBucketLabel(wizard.bucket, { includeId: true })}`,
|
|
4388
|
+
providerId,
|
|
4389
|
+
rateLimits: updated.rateLimits || []
|
|
4390
|
+
})
|
|
3736
4391
|
};
|
|
3737
4392
|
}
|
|
3738
4393
|
|
|
@@ -3789,10 +4444,11 @@ async function doSetProviderRateLimits(context) {
|
|
|
3789
4444
|
ok: true,
|
|
3790
4445
|
mode: context.mode,
|
|
3791
4446
|
exitCode: EXIT_SUCCESS,
|
|
3792
|
-
data:
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
4447
|
+
data: buildProviderRateLimitReport({
|
|
4448
|
+
title: "Rate-Limit Buckets Updated",
|
|
4449
|
+
providerId,
|
|
4450
|
+
rateLimits: updated.rateLimits || []
|
|
4451
|
+
})
|
|
3796
4452
|
};
|
|
3797
4453
|
}
|
|
3798
4454
|
|
|
@@ -3800,7 +4456,16 @@ async function doSetProviderRateLimits(context) {
|
|
|
3800
4456
|
if (!bucketId) {
|
|
3801
4457
|
const bucketOptions = buildRateLimitBucketPromptOptions(provider);
|
|
3802
4458
|
if (bucketOptions.length === 0) {
|
|
3803
|
-
return {
|
|
4459
|
+
return {
|
|
4460
|
+
ok: true,
|
|
4461
|
+
mode: context.mode,
|
|
4462
|
+
exitCode: EXIT_SUCCESS,
|
|
4463
|
+
data: buildProviderRateLimitReport({
|
|
4464
|
+
title: "Rate-Limit Bucket Review",
|
|
4465
|
+
providerId,
|
|
4466
|
+
rateLimits: provider.rateLimits || []
|
|
4467
|
+
})
|
|
4468
|
+
};
|
|
3804
4469
|
}
|
|
3805
4470
|
bucketId = await context.prompts.select({
|
|
3806
4471
|
message: `Remove rate-limit bucket from ${providerId}`,
|
|
@@ -3921,7 +4586,11 @@ async function doSetProviderRateLimits(context) {
|
|
|
3921
4586
|
ok: true,
|
|
3922
4587
|
mode: context.mode,
|
|
3923
4588
|
exitCode: EXIT_SUCCESS,
|
|
3924
|
-
data:
|
|
4589
|
+
data: buildProviderRateLimitReport({
|
|
4590
|
+
title: `Rate-Limit Bucket Removed: ${bucketId}`,
|
|
4591
|
+
providerId,
|
|
4592
|
+
rateLimits: removed.rateLimits || []
|
|
4593
|
+
})
|
|
3925
4594
|
};
|
|
3926
4595
|
}
|
|
3927
4596
|
|
|
@@ -3966,10 +4635,11 @@ async function doSetProviderRateLimits(context) {
|
|
|
3966
4635
|
ok: true,
|
|
3967
4636
|
mode: context.mode,
|
|
3968
4637
|
exitCode: EXIT_SUCCESS,
|
|
3969
|
-
data:
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
4638
|
+
data: buildProviderRateLimitReport({
|
|
4639
|
+
title: "Rate-Limit Buckets Updated",
|
|
4640
|
+
providerId,
|
|
4641
|
+
rateLimits: updated.rateLimits || []
|
|
4642
|
+
})
|
|
3973
4643
|
};
|
|
3974
4644
|
}
|
|
3975
4645
|
|
|
@@ -4017,10 +4687,17 @@ async function doSetMasterKey(context) {
|
|
|
4017
4687
|
ok: true,
|
|
4018
4688
|
mode: context.mode,
|
|
4019
4689
|
exitCode: EXIT_SUCCESS,
|
|
4020
|
-
data:
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4690
|
+
data: buildOperationReport(
|
|
4691
|
+
"Worker Master Key Updated",
|
|
4692
|
+
[
|
|
4693
|
+
["Config File", configPath],
|
|
4694
|
+
["Stored Key", maskSecret(masterKey)],
|
|
4695
|
+
["Generated In This Run", formatYesNo(keyGenerated)]
|
|
4696
|
+
],
|
|
4697
|
+
keyGenerated
|
|
4698
|
+
? [renderListSection("Generated Key (Copy Now)", [masterKey])]
|
|
4699
|
+
: []
|
|
4700
|
+
)
|
|
4024
4701
|
};
|
|
4025
4702
|
}
|
|
4026
4703
|
|
|
@@ -4074,14 +4751,18 @@ async function doStartupInstall(context) {
|
|
|
4074
4751
|
ok: true,
|
|
4075
4752
|
mode: context.mode,
|
|
4076
4753
|
exitCode: EXIT_SUCCESS,
|
|
4077
|
-
data:
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4754
|
+
data: buildOperationReport(
|
|
4755
|
+
"OS Startup Service Installed",
|
|
4756
|
+
[
|
|
4757
|
+
["Startup Manager", result.manager || "Unknown"],
|
|
4758
|
+
["Service Name", result.serviceId || "Unknown"],
|
|
4759
|
+
["Service File", result.filePath || "(not provided)"],
|
|
4760
|
+
["Start Target", `http://${host}:${port}`],
|
|
4761
|
+
["Config Hot Reload", watchConfig ? "Enabled" : "Disabled"],
|
|
4762
|
+
["Binary Auto-Restart", watchBinary ? "Enabled" : "Disabled"],
|
|
4763
|
+
["Local API Auth", requireAuth ? "Required (Master Key)" : "Disabled"]
|
|
4764
|
+
]
|
|
4765
|
+
)
|
|
4085
4766
|
};
|
|
4086
4767
|
}
|
|
4087
4768
|
|
|
@@ -4101,11 +4782,14 @@ async function doStartupUninstall(context) {
|
|
|
4101
4782
|
ok: true,
|
|
4102
4783
|
mode: context.mode,
|
|
4103
4784
|
exitCode: EXIT_SUCCESS,
|
|
4104
|
-
data:
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4785
|
+
data: buildOperationReport(
|
|
4786
|
+
"OS Startup Service Uninstalled",
|
|
4787
|
+
[
|
|
4788
|
+
["Startup Manager", result.manager || "Unknown"],
|
|
4789
|
+
["Service Name", result.serviceId || "Unknown"],
|
|
4790
|
+
["Service File", result.filePath || "(not provided)"]
|
|
4791
|
+
]
|
|
4792
|
+
)
|
|
4109
4793
|
};
|
|
4110
4794
|
}
|
|
4111
4795
|
|
|
@@ -4115,14 +4799,17 @@ async function doStartupStatus(context) {
|
|
|
4115
4799
|
ok: true,
|
|
4116
4800
|
mode: context.mode,
|
|
4117
4801
|
exitCode: EXIT_SUCCESS,
|
|
4118
|
-
data:
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4802
|
+
data: buildOperationReport(
|
|
4803
|
+
"OS Startup Service Status",
|
|
4804
|
+
[
|
|
4805
|
+
["Startup Manager", status.manager || "Unknown"],
|
|
4806
|
+
["Service Name", status.serviceId || "Unknown"],
|
|
4807
|
+
["Installed", formatYesNo(Boolean(status.installed))],
|
|
4808
|
+
["Running", formatYesNo(Boolean(status.running))],
|
|
4809
|
+
["Service File", status.filePath || "(not provided)"],
|
|
4810
|
+
["Details", status.detail ? String(status.detail).trim() : "(none)"]
|
|
4811
|
+
]
|
|
4812
|
+
)
|
|
4126
4813
|
};
|
|
4127
4814
|
}
|
|
4128
4815
|
|
|
@@ -4261,11 +4948,14 @@ async function runStopAction(context) {
|
|
|
4261
4948
|
ok: true,
|
|
4262
4949
|
mode: context.mode,
|
|
4263
4950
|
exitCode: EXIT_SUCCESS,
|
|
4264
|
-
data:
|
|
4265
|
-
"Stopped
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4951
|
+
data: buildOperationReport(
|
|
4952
|
+
"Router Stopped",
|
|
4953
|
+
[
|
|
4954
|
+
["Stop Mode", "Startup-managed service"],
|
|
4955
|
+
["Startup Manager", stopped.detail?.manager || "Unknown"],
|
|
4956
|
+
["Service Name", stopped.detail?.serviceId || "Unknown"]
|
|
4957
|
+
]
|
|
4958
|
+
)
|
|
4269
4959
|
};
|
|
4270
4960
|
}
|
|
4271
4961
|
|
|
@@ -4274,7 +4964,14 @@ async function runStopAction(context) {
|
|
|
4274
4964
|
ok: true,
|
|
4275
4965
|
mode: context.mode,
|
|
4276
4966
|
exitCode: EXIT_SUCCESS,
|
|
4277
|
-
data:
|
|
4967
|
+
data: buildOperationReport(
|
|
4968
|
+
"Router Stopped",
|
|
4969
|
+
[
|
|
4970
|
+
["Stop Mode", "Manual process"],
|
|
4971
|
+
["Process ID", String(stopped.detail?.pid || "Unknown")],
|
|
4972
|
+
["Signal", stopped.detail?.signal || "SIGTERM"]
|
|
4973
|
+
]
|
|
4974
|
+
)
|
|
4278
4975
|
};
|
|
4279
4976
|
}
|
|
4280
4977
|
|
|
@@ -4317,11 +5014,14 @@ async function runReloadAction(context) {
|
|
|
4317
5014
|
ok: true,
|
|
4318
5015
|
mode: context.mode,
|
|
4319
5016
|
exitCode: EXIT_SUCCESS,
|
|
4320
|
-
data:
|
|
4321
|
-
"
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
5017
|
+
data: buildOperationReport(
|
|
5018
|
+
"Router Reloaded",
|
|
5019
|
+
[
|
|
5020
|
+
["Reload Mode", "Startup-managed service"],
|
|
5021
|
+
["Startup Manager", result.detail?.manager || "Unknown"],
|
|
5022
|
+
["Service Name", result.detail?.serviceId || "Unknown"]
|
|
5023
|
+
]
|
|
5024
|
+
)
|
|
4325
5025
|
};
|
|
4326
5026
|
}
|
|
4327
5027
|
|
|
@@ -4754,7 +5454,7 @@ async function runAiHelpAction(context) {
|
|
|
4754
5454
|
const suggestions = [];
|
|
4755
5455
|
if (providerCount === 0) {
|
|
4756
5456
|
suggestions.push("Add first provider with at least one model. Run: llm-router config --operation=upsert-provider --provider-id=<id> --name=\"<name>\" --base-url=<url> --api-key=<key> --models=<model1,model2>");
|
|
4757
|
-
suggestions.push("Or add OAuth-backed subscription provider. Run: llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"
|
|
5457
|
+
suggestions.push("Or add OAuth-backed subscription provider. Run: llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default");
|
|
4758
5458
|
} else {
|
|
4759
5459
|
const providersWithoutModels = providers
|
|
4760
5460
|
.filter((provider) => (provider.models || []).filter((model) => model && model.enabled !== false).length === 0)
|
|
@@ -4882,7 +5582,7 @@ async function runAiHelpAction(context) {
|
|
|
4882
5582
|
"1. Upsert provider + models:",
|
|
4883
5583
|
" llm-router config --operation=upsert-provider --provider-id=<id> --name=\"<name>\" --endpoints=<url1,url2> --api-key=<key> --models=<model1,model2>",
|
|
4884
5584
|
"1b. Upsert subscription provider (OAuth-backed ChatGPT Codex):",
|
|
4885
|
-
" llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"
|
|
5585
|
+
" llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
|
|
4886
5586
|
" llm-router subscription login --profile=default",
|
|
4887
5587
|
"2. Upsert model alias/group:",
|
|
4888
5588
|
" llm-router config --operation=upsert-model-alias --alias-id=<alias> --strategy=auto --targets=<provider/model,...>",
|
|
@@ -5197,48 +5897,75 @@ async function runDeployAction(context) {
|
|
|
5197
5897
|
await fsPromises.writeFile(resolvedOut, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
5198
5898
|
|
|
5199
5899
|
if (exportOnly) {
|
|
5900
|
+
const exportWarnings = [
|
|
5901
|
+
...largeConfigWarningLines,
|
|
5902
|
+
mustConfirmLargeConfig
|
|
5903
|
+
? "Manual deploy may fail on Cloudflare Free tier unless you reduce config size."
|
|
5904
|
+
: ""
|
|
5905
|
+
].filter(Boolean);
|
|
5200
5906
|
return {
|
|
5201
5907
|
ok: true,
|
|
5202
5908
|
mode: context.mode,
|
|
5203
5909
|
exitCode: EXIT_SUCCESS,
|
|
5204
|
-
data:
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5910
|
+
data: buildOperationReport(
|
|
5911
|
+
"Worker Config Exported",
|
|
5912
|
+
[
|
|
5913
|
+
["Export File", resolvedOut],
|
|
5914
|
+
["Payload Size (bytes)", new Intl.NumberFormat("en-US").format(payloadBytes)],
|
|
5915
|
+
["Cloudflare Tier", `${formatCloudflareTierLabel(tierReport)} (${tierReport.reason || "unknown"})`],
|
|
5916
|
+
["Environment", cfEnv || "(default)"]
|
|
5917
|
+
],
|
|
5918
|
+
[
|
|
5919
|
+
renderListSection("Warnings", exportWarnings, { emptyMessage: "None." }),
|
|
5920
|
+
renderListSection("Next Command", [
|
|
5921
|
+
`wrangler secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""} < ${resolvedOut}`
|
|
5922
|
+
])
|
|
5923
|
+
]
|
|
5924
|
+
)
|
|
5212
5925
|
};
|
|
5213
5926
|
}
|
|
5214
5927
|
}
|
|
5215
5928
|
|
|
5216
5929
|
if (dryRun) {
|
|
5930
|
+
const dryRunWarnings = [
|
|
5931
|
+
allowWeakMasterKey ? "Weak master key override is enabled." : "",
|
|
5932
|
+
...largeConfigWarningLines,
|
|
5933
|
+
mustConfirmLargeConfig
|
|
5934
|
+
? "Interactive deploy requires explicit confirmation (default: No)."
|
|
5935
|
+
: "",
|
|
5936
|
+
mustConfirmLargeConfig
|
|
5937
|
+
? "Use --allow-large-config=true to bypass this check in non-interactive mode."
|
|
5938
|
+
: "",
|
|
5939
|
+
generatedDeployMasterKey ? "Generated a deploy-time master key (not written to local config)." : ""
|
|
5940
|
+
].filter(Boolean);
|
|
5941
|
+
const dryRunCommands = [
|
|
5942
|
+
`wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""}`,
|
|
5943
|
+
`wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} deploy${cfEnv ? ` --env ${cfEnv}` : ""}`
|
|
5944
|
+
];
|
|
5217
5945
|
return {
|
|
5218
5946
|
ok: true,
|
|
5219
5947
|
mode: context.mode,
|
|
5220
5948
|
exitCode: EXIT_SUCCESS,
|
|
5221
|
-
data:
|
|
5222
|
-
"
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
].filter(Boolean).join("\n")
|
|
5949
|
+
data: buildOperationReport(
|
|
5950
|
+
"Cloudflare Deploy Dry Run",
|
|
5951
|
+
[
|
|
5952
|
+
["Project Directory", projectDir],
|
|
5953
|
+
["Wrangler Config", wranglerConfigPath || "(default)"],
|
|
5954
|
+
["Cloudflare API Token Source", cloudflareApiTokenSource === "none"
|
|
5955
|
+
? "(not set)"
|
|
5956
|
+
: (cloudflareApiTokenSource === "prompt" ? "Prompt input" : `Environment (${cloudflareApiTokenSource})`)],
|
|
5957
|
+
["Cloudflare Account ID", cloudflareAccountId || "(not set)"],
|
|
5958
|
+
["Cloudflare Tier", `${formatCloudflareTierLabel(tierReport)} (${tierReport.reason || "unknown"})`],
|
|
5959
|
+
["Environment", cfEnv || "(default)"],
|
|
5960
|
+
["Payload Size (bytes)", new Intl.NumberFormat("en-US").format(payloadBytes)],
|
|
5961
|
+
["Generated Deploy Key", formatYesNo(generatedDeployMasterKey)],
|
|
5962
|
+
["Weak Key Override", formatYesNo(allowWeakMasterKey)]
|
|
5963
|
+
],
|
|
5964
|
+
[
|
|
5965
|
+
renderListSection("Warnings", dryRunWarnings, { emptyMessage: "None." }),
|
|
5966
|
+
renderListSection("Commands", dryRunCommands)
|
|
5967
|
+
]
|
|
5968
|
+
)
|
|
5242
5969
|
};
|
|
5243
5970
|
}
|
|
5244
5971
|
|
|
@@ -5367,15 +6094,36 @@ async function runDeployAction(context) {
|
|
|
5367
6094
|
ok: true,
|
|
5368
6095
|
mode: context.mode,
|
|
5369
6096
|
exitCode: EXIT_SUCCESS,
|
|
5370
|
-
data:
|
|
5371
|
-
"Cloudflare
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
6097
|
+
data: buildOperationReport(
|
|
6098
|
+
"Cloudflare Deployment Completed",
|
|
6099
|
+
[
|
|
6100
|
+
["Project Directory", projectDir],
|
|
6101
|
+
["Environment", cfEnv || "(default)"],
|
|
6102
|
+
["Deploy Target", deployUsesWorkersDev ? "workers.dev" : (deployHost ? deployHost : "custom route")],
|
|
6103
|
+
["Deploy Zone", deployZoneName || "(not set)"],
|
|
6104
|
+
["Generated Deploy Key", formatYesNo(generatedDeployMasterKey)]
|
|
6105
|
+
],
|
|
6106
|
+
[
|
|
6107
|
+
renderListSection(
|
|
6108
|
+
"Notes",
|
|
6109
|
+
[
|
|
6110
|
+
generatedDeployMasterKey
|
|
6111
|
+
? "Generated a deploy-time master key. Persist it with `llm-router config --operation=set-master-key --master-key=...` if needed."
|
|
6112
|
+
: "",
|
|
6113
|
+
wranglerTargetMessage
|
|
6114
|
+
],
|
|
6115
|
+
{ emptyMessage: "None." }
|
|
6116
|
+
),
|
|
6117
|
+
renderListSection("Wrangler Output", [secretResult.stdout.trim(), deployResult.stdout.trim()], { emptyMessage: "No additional output." }),
|
|
6118
|
+
postDeployGuide
|
|
6119
|
+
? renderListSection("Post-Deploy Checks", [
|
|
6120
|
+
`dig +short ${deployHost} @1.1.1.1`,
|
|
6121
|
+
`curl -I https://${deployHost}/anthropic`,
|
|
6122
|
+
`Claude Code base URL: https://${deployHost}/anthropic (no :8787)`
|
|
6123
|
+
])
|
|
6124
|
+
: ""
|
|
6125
|
+
]
|
|
6126
|
+
)
|
|
5379
6127
|
};
|
|
5380
6128
|
} finally {
|
|
5381
6129
|
if (typeof cleanupWranglerConfig === "function") {
|
|
@@ -5503,16 +6251,31 @@ async function runWorkerKeyAction(context) {
|
|
|
5503
6251
|
ok: true,
|
|
5504
6252
|
mode: context.mode,
|
|
5505
6253
|
exitCode: EXIT_SUCCESS,
|
|
5506
|
-
data:
|
|
5507
|
-
"
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
6254
|
+
data: buildOperationReport(
|
|
6255
|
+
"Worker Key Dry Run",
|
|
6256
|
+
[
|
|
6257
|
+
["Project Directory", projectDir],
|
|
6258
|
+
["Environment", cfEnv || "(default)"],
|
|
6259
|
+
["Target Secret", "LLM_ROUTER_MASTER_KEY"],
|
|
6260
|
+
["Secret Exists", exists === null ? "Unknown" : formatYesNo(Boolean(exists))],
|
|
6261
|
+
["Stored Key", maskSecret(masterKey)],
|
|
6262
|
+
["Generated In This Run", formatYesNo(keyGenerated)],
|
|
6263
|
+
["Weak Key Override", formatYesNo(allowWeakMasterKey)]
|
|
6264
|
+
],
|
|
6265
|
+
[
|
|
6266
|
+
renderListSection(
|
|
6267
|
+
"Warnings",
|
|
6268
|
+
[
|
|
6269
|
+
allowWeakMasterKey ? "Weak master key override is enabled." : "",
|
|
6270
|
+
keyGenerated ? "Generated key for this operation." : ""
|
|
6271
|
+
],
|
|
6272
|
+
{ emptyMessage: "None." }
|
|
6273
|
+
),
|
|
6274
|
+
renderListSection("Command", [
|
|
6275
|
+
`wrangler secret put LLM_ROUTER_MASTER_KEY${cfEnv ? ` --env ${cfEnv}` : ""}`
|
|
6276
|
+
])
|
|
6277
|
+
]
|
|
6278
|
+
)
|
|
5516
6279
|
};
|
|
5517
6280
|
}
|
|
5518
6281
|
|
|
@@ -5547,14 +6310,22 @@ async function runWorkerKeyAction(context) {
|
|
|
5547
6310
|
ok: true,
|
|
5548
6311
|
mode: context.mode,
|
|
5549
6312
|
exitCode: EXIT_SUCCESS,
|
|
5550
|
-
data:
|
|
5551
|
-
`${exists === true ? "Updated" : "Set"}
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
6313
|
+
data: buildOperationReport(
|
|
6314
|
+
`${exists === true ? "Worker Key Updated" : "Worker Key Set"}`,
|
|
6315
|
+
[
|
|
6316
|
+
["Target Secret", "LLM_ROUTER_MASTER_KEY"],
|
|
6317
|
+
["Environment", cfEnv || "(default)"],
|
|
6318
|
+
["Project Directory", projectDir],
|
|
6319
|
+
["Stored Key", maskSecret(masterKey)],
|
|
6320
|
+
["Generated In This Run", formatYesNo(keyGenerated)]
|
|
6321
|
+
],
|
|
6322
|
+
[
|
|
6323
|
+
keyGenerated
|
|
6324
|
+
? renderListSection("Generated Key (Copy Now)", [masterKey])
|
|
6325
|
+
: "",
|
|
6326
|
+
renderListSection("Wrangler Output", [putResult.stdout.trim()], { emptyMessage: "No additional output." })
|
|
6327
|
+
]
|
|
6328
|
+
)
|
|
5558
6329
|
};
|
|
5559
6330
|
}
|
|
5560
6331
|
|
|
@@ -5603,8 +6374,13 @@ async function runSubscriptionLoginAction(context) {
|
|
|
5603
6374
|
lines.push("");
|
|
5604
6375
|
|
|
5605
6376
|
const success = await loginWithBrowser(profile, {
|
|
5606
|
-
onUrl: (url) => {
|
|
5607
|
-
|
|
6377
|
+
onUrl: (url, meta = {}) => {
|
|
6378
|
+
if (meta?.openedBrowser === true) {
|
|
6379
|
+
lines.push("Opened browser for OAuth login.");
|
|
6380
|
+
lines.push(`Fallback URL: ${url}`);
|
|
6381
|
+
return;
|
|
6382
|
+
}
|
|
6383
|
+
lines.push(`Open this URL to login: ${url}`);
|
|
5608
6384
|
}
|
|
5609
6385
|
});
|
|
5610
6386
|
|
|
@@ -5881,6 +6657,7 @@ const routerModule = {
|
|
|
5881
6657
|
"provider-type",
|
|
5882
6658
|
"subscription-type",
|
|
5883
6659
|
"subscription-profile",
|
|
6660
|
+
"device-code",
|
|
5884
6661
|
"endpoints",
|
|
5885
6662
|
"base-url",
|
|
5886
6663
|
"openai-base-url",
|
|
@@ -5932,23 +6709,24 @@ const routerModule = {
|
|
|
5932
6709
|
summary: "Manage providers, model aliases, rate-limit buckets, master key, and OS startup. TUI by default; commandline via --operation.",
|
|
5933
6710
|
args: [
|
|
5934
6711
|
{ name: "operation", required: false, description: "Config operation (optional; prompts if omitted).", example: "--operation=upsert-provider" },
|
|
5935
|
-
{ name: "provider-id", required: false, description: "Provider id (
|
|
6712
|
+
{ name: "provider-id", required: false, description: "Provider id (lowercase letters/numbers/dashes).", example: "--provider-id=openrouter-primary" },
|
|
5936
6713
|
{ name: "name", required: false, description: "Provider Friendly Name (must be unique; shown in management screen).", example: "--name=OpenRouter Primary" },
|
|
5937
|
-
{ name: "type", required: false, description: "Provider type: standard | subscription.", example: "--type=subscription" },
|
|
5938
|
-
{ name: "subscription-type", required: false, description: "
|
|
5939
|
-
{ name: "subscription-profile", required: false, description: "OAuth token profile for subscription provider.", example: "--subscription-profile=personal" },
|
|
5940
|
-
{ name: "
|
|
6714
|
+
{ name: "type", required: false, description: "Provider type: standard (API key) | subscription (OAuth).", example: "--type=subscription" },
|
|
6715
|
+
{ name: "subscription-type", required: false, description: "For --type=subscription. Defaults to chatgpt-codex.", example: "--subscription-type=chatgpt-codex" },
|
|
6716
|
+
{ name: "subscription-profile", required: false, description: "OAuth token profile for subscription provider (defaults to provider-id).", example: "--subscription-profile=personal" },
|
|
6717
|
+
{ name: "device-code", required: false, description: "For subscription OAuth login during upsert: use device-code flow instead of browser.", example: "--device-code=true" },
|
|
6718
|
+
{ name: "endpoints", required: false, description: "For standard provider: endpoint candidates for auto-probe (comma-separated URLs).", example: "--endpoints=https://ramclouds.me,https://ramclouds.me/v1" },
|
|
5941
6719
|
{ name: "base-url", required: false, description: "For standard provider: provider base URL.", example: "--base-url=https://openrouter.ai/api/v1" },
|
|
5942
6720
|
{ name: "openai-base-url", required: false, description: "For standard provider: OpenAI endpoint base URL (format-specific override).", example: "--openai-base-url=https://ramclouds.me/v1" },
|
|
5943
6721
|
{ name: "claude-base-url", required: false, description: "For standard provider: Anthropic endpoint base URL (format-specific override).", example: "--claude-base-url=https://ramclouds.me" },
|
|
5944
6722
|
{ name: "api-key", required: false, description: "For standard provider: API key.", example: "--api-key=sk-or-v1-..." },
|
|
5945
|
-
{ name: "models", required: false, description: "Model list (comma
|
|
6723
|
+
{ name: "models", required: false, description: "Model list (comma-separated IDs; strips common log/error noise). For chatgpt-codex subscription type, defaults are prefilled and you can add/remove models; all selected models are live-validated before save.", example: "--models=gpt-5.3-codex,gpt-5-codex" },
|
|
5946
6724
|
{ name: "model", required: false, description: "Single model id (used by remove-model).", example: "--model=gpt-4o" },
|
|
5947
|
-
{ name: "fallback-models", required: false, description: "Qualified fallback models for set-model-fallbacks (comma
|
|
6725
|
+
{ name: "fallback-models", required: false, description: "Qualified fallback models for set-model-fallbacks (comma-separated).", example: "--fallback-models=openrouter/gpt-4o,anthropic/claude-3-7-sonnet" },
|
|
5948
6726
|
{ name: "clear-fallbacks", required: false, description: "Clear all fallback models for set-model-fallbacks.", example: "--clear-fallbacks=true" },
|
|
5949
6727
|
{ name: "alias-id", required: false, description: "Model alias id for upsert/remove alias operations.", example: "--alias-id=chat.default" },
|
|
5950
6728
|
{ name: "strategy", required: false, description: "Model alias routing strategy: auto | ordered | round-robin | weighted-rr | quota-aware-weighted-rr.", example: "--strategy=auto" },
|
|
5951
|
-
{ name: "targets", required: false, description: "Model alias target list syntax: <ref>@<weight> (comma
|
|
6729
|
+
{ name: "targets", required: false, description: "Model alias target list syntax: <ref>@<weight> (comma-separated).", example: "--targets=openrouter/gpt-4o-mini@3,anthropic/claude-3-5-haiku@2" },
|
|
5952
6730
|
{ name: "fallback-targets", required: false, description: "Model alias fallback target list with same syntax as --targets.", example: "--fallback-targets=openrouter/gpt-4o" },
|
|
5953
6731
|
{ name: "clear-fallback-targets", required: false, description: "Clear alias fallback target list.", example: "--clear-fallback-targets=true" },
|
|
5954
6732
|
{ name: "alias-metadata", required: false, description: "Optional alias metadata JSON object.", example: "--alias-metadata={\"owner\":\"router-team\"}" },
|
|
@@ -5980,7 +6758,7 @@ const routerModule = {
|
|
|
5980
6758
|
examples: [
|
|
5981
6759
|
"llm-router config",
|
|
5982
6760
|
"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",
|
|
5983
|
-
"llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"
|
|
6761
|
+
"llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
|
|
5984
6762
|
"llm-router subscription login --profile=default",
|
|
5985
6763
|
"llm-router config --operation=upsert-model-alias --alias-id=chat.default --strategy=auto --targets=openrouter/gpt-4o-mini@3,anthropic/claude-3-5-haiku@2 --fallback-targets=openrouter/gpt-4o",
|
|
5986
6764
|
"llm-router config --operation=set-provider-rate-limits --provider-id=openrouter --bucket-id=openrouter-all-month --bucket-models=all --bucket-requests=20000 --bucket-window=month:1",
|