@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.
@@ -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
- if (!canPrompt()) return;
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/semicolon/space/newline-separated values.`);
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: "Undetected models to add manually (comma / newline separated; leave blank to skip)",
1015
- initialValue: unresolvedModels.join("\n"),
1016
- paste: true,
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
- paste: true
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
- paste: true
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-zA-Z0-9]+/g, "-")
1094
+ .replace(/[^a-z0-9]+/g, "-")
1064
1095
  .replace(/^-+|-+$/g, "");
1065
- if (!slug) return fallback;
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
- export function summarizeConfig(config, configPath, { includeSecrets = false } = {}) {
1140
- const target = includeSecrets ? config : sanitizeConfigForDisplay(config);
1141
- const lines = [];
1142
- lines.push(`Config: ${configPath}`);
1143
- lines.push(`Version: ${target.version || 1}`);
1144
- lines.push(`Default model: ${target.defaultModel || "(not set)"}`);
1145
- lines.push(`Master key: ${target.masterKey || "(not set)"}`);
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
- if (!target.providers || target.providers.length === 0) {
1148
- lines.push("Providers: (none)");
1149
- } else {
1150
- lines.push("Providers:");
1151
- for (const provider of target.providers) {
1152
- const providerType = normalizeProviderTypeInput(provider.type) || PROVIDER_TYPE_STANDARD;
1153
- lines.push(`- ${provider.id} (${provider.name})`);
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
- const aliasEntries = Object.entries(target.modelAliases || {});
1190
- if (aliasEntries.length === 0) {
1191
- lines.push("Model aliases: (none)");
1192
- } else {
1193
- lines.push("Model aliases:");
1194
- for (const [aliasId, alias] of aliasEntries) {
1195
- lines.push(`- ${aliasId} strategy=${alias.strategy || "ordered"}`);
1196
- lines.push(` targets=${formatAliasTargetsForSummary(alias.targets)}`);
1197
- lines.push(` fallbackTargets=${formatAliasTargetsForSummary(alias.fallbackTargets)}`);
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
- return lines.join("\n");
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 baseName = String(readArg(args, ["name"], selectedExisting?.name || "") || "");
2328
- const baseProviderType = normalizeProviderTypeInput(
2329
- readArg(args, ["type", "provider-type", "providerType"], selectedExisting?.type || PROVIDER_TYPE_STANDARD)
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 baseSubscriptionType = normalizeSubscriptionTypeInput(
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-profile", "subscriptionProfile"],
2337
- selectedExisting?.subscriptionProfile || selectedExisting?.subscription_profile || baseProviderId || "default"
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
- const hasProviderTypeArg = readArg(args, ["type", "provider-type", "providerType"], undefined) !== undefined;
2373
- const hasSubscriptionTypeArg = readArg(args, ["subscription-type", "subscriptionType"], undefined) !== undefined;
2374
- const hasSubscriptionProfileArg = readArg(args, ["subscription-profile", "subscriptionProfile"], undefined) !== undefined;
2375
- const baseIsSubscription = baseProviderType === PROVIDER_TYPE_SUBSCRIPTION;
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: baseProviderId || slugifyId(baseName || "provider"),
2381
- name: baseName,
2382
- providerType: baseProviderType,
2383
- subscriptionType: baseSubscriptionType,
2384
- subscriptionProfile: baseSubscriptionProfile || baseProviderId || "default",
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: parseProviderModelListInput(baseModels),
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 = parseProviderModelListInput(baseModels);
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?.("Subscription providers use OAuth login and predefined GPT/Codex models. Endpoint probe and API key prompts are skipped.");
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 / ; / space / newline separated; multiline paste supported)",
2822
+ message: "Provider endpoints (comma-separated URLs)",
2511
2823
  required: true,
2512
- initialValue: baseEndpoints.join("\n"),
2513
- paste: true,
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 / ; / space / newline separated; multiline paste supported)",
2831
+ message: "Provider models (comma-separated IDs)",
2521
2832
  required: true,
2522
2833
  initialValue: baseModels,
2523
- paste: true,
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 subscriptionType = normalizeSubscriptionTypeInput(input.subscriptionType);
2610
- const subscriptionProfile = String(input.subscriptionProfile || input.providerId || "default").trim() || "default";
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 && !subscriptionType) {
3136
+ if (isSubscriptionProvider && rawSubscriptionType && !normalizedSubscriptionType) {
2628
3137
  return {
2629
3138
  ok: false,
2630
3139
  mode: context.mode,
2631
3140
  exitCode: EXIT_VALIDATION,
2632
- errorMessage: "subscription-type is required for provider type 'subscription' (e.g. --subscription-type=chatgpt-codex)."
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 slug/camelCase (e.g. openrouter or myProvider).`
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 = [...(input.models || [])];
3195
+ let effectiveModels = dedupeList(input.models || []);
2671
3196
  const shouldProbe = !isSubscriptionProvider && Boolean(input.shouldProbe);
2672
3197
 
2673
- if (shouldProbe && endpointCandidates.length > 0 && effectiveModels.length === 0) {
2674
- return {
2675
- ok: false,
2676
- mode: context.mode,
2677
- exitCode: EXIT_VALIDATION,
2678
- errorMessage: "Model list is required for endpoint-model probe. Provide --models=modelA,modelB."
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
- effectiveOpenAIBaseUrl = probe.baseUrlByFormat?.openai || effectiveOpenAIBaseUrl;
2701
- effectiveClaudeBaseUrl = probe.baseUrlByFormat?.claude || effectiveClaudeBaseUrl;
2702
- effectiveBaseUrl =
2703
- (probe.preferredFormat && probe.baseUrlByFormat?.[probe.preferredFormat]) ||
2704
- effectiveOpenAIBaseUrl ||
2705
- effectiveClaudeBaseUrl ||
2706
- endpointCandidates[0] ||
2707
- effectiveBaseUrl;
2708
- if ((probe.models || []).length > 0) {
2709
- effectiveModels = effectiveModels.length > 0
2710
- ? effectiveModels.filter((model) => (probe.models || []).includes(model))
2711
- : [...probe.models];
2712
- }
2713
- } else {
2714
- const probeBaseUrlByFormat = {};
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
- const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
2727
- if (line) {
2728
- const tookMs = Date.now() - startedAt;
2729
- line(`Auto-discovery finished in ${(tookMs / 1000).toFixed(1)}s.`);
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
- selectedFormat = probe.preferredFormat || selectedFormat;
2732
- if (probe?.manualFallbackRecommended) {
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: scope === "full"
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
- selectedFormat = await promptProviderFormat(context, {
2778
- message: "Probe could not confirm a working format. Choose primary provider format",
2779
- initialFormat: selectedFormat
2780
- });
2781
- } else {
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: "Provider probe failed. Provide valid endpoints/models or use --skip-probe=true to force save."
3286
+ errorMessage: formatProbeFailures(subscriptionProbe.failures)
2787
3287
  };
2788
3288
  }
2789
- }
2790
- }
2791
3289
 
2792
- if (!shouldProbe && !isSubscriptionProvider) {
2793
- if (!effectiveBaseUrl && endpointCandidates.length > 0) {
2794
- effectiveBaseUrl = endpointCandidates[0];
2795
- }
2796
- if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length === 1 && selectedFormat) {
2797
- if (selectedFormat === "openai") effectiveOpenAIBaseUrl = endpointCandidates[0];
2798
- if (selectedFormat === "claude") effectiveClaudeBaseUrl = endpointCandidates[0];
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
- if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length > 1) {
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: "Multiple endpoints require probe mode (recommended) or explicit --openai-base-url/--claude-base-url."
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
- `Saved provider '${provider.id}' to ${input.configPath}`,
2853
- probe
2854
- ? `probe preferred=${probe.preferredFormat || "(none)"} working=${(probe.workingFormats || []).join(",") || "(none)"}`
2855
- : "probe=skipped",
2856
- `type=${provider.type || PROVIDER_TYPE_STANDARD}`,
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
- ? `Migrated config ${migration.beforeVersion} -> ${migration.afterVersion}.`
2940
- : `Config already at target schema (version ${migration.afterVersion}).`,
2941
- migration.backupPath ? `backup=${migration.backupPath}` : "backup=(not created)",
2942
- `config=${configPath}`
2943
- ].join("\n")
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
- `Updated silent-fallback models for '${providerId}/${updated.modelId || modelId}'.`,
3174
- `fallbacks=${(updated.fallbackModels || []).join(", ") || "(none)"}`
3175
- ].join("\n")
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
- const buckets = provider?.rateLimits || [];
3389
- if (buckets.length === 0) {
3390
- return `Provider '${provider?.id || "(unknown)"}' has no rate-limit buckets.`;
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", hint: "Fixed to 1 minute" },
3410
- { value: "hour", label: "Hour(s)", hint: "Choose any positive hour count" },
3411
- { value: "week", label: "Week", hint: "Fixed to 1 week" },
3412
- { value: "month", label: "Month", hint: "Fixed to 1 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)", hint: "Retained for existing configs" });
4048
+ options.push({ value: "day", label: "Day (legacy)" });
3416
4049
  }
3417
4050
  if (initialUnit === "second") {
3418
- options.push({ value: "second", label: "Second (legacy)", hint: "Retained for existing configs" });
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 { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no rate-limit buckets.` };
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: `Removed rate-limit bucket '${formatRateLimitBucketLabel(selectedBucket, { includeId: true })}' from '${providerId}'.`
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 { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no rate-limit buckets.` };
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
- `Updated rate-limit bucket '${formatRateLimitBucketLabel(wizard.bucket, { includeId: true })}' on '${providerId}'.`,
3734
- `bucketCount=${updated.rateLimits?.length || 0}`
3735
- ].join("\n")
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
- `Updated rate-limit buckets for '${providerId}'.`,
3794
- `bucketCount=${updated.rateLimits?.length || 0}`
3795
- ].join("\n")
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 { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no rate-limit buckets.` };
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: `Removed rate-limit bucket '${bucketId}' from '${providerId}'.`
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
- `Updated rate-limit buckets for '${providerId}'.`,
3971
- `bucketCount=${updated.rateLimits?.length || 0}`
3972
- ].join("\n")
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
- `Updated master key in ${configPath} (${maskSecret(masterKey)}).`,
4022
- keyGenerated ? `Generated key (copy now): ${masterKey}` : ""
4023
- ].filter(Boolean).join("\n")
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
- `Installed OS startup (${result.manager})`,
4079
- `service=${result.serviceId}`,
4080
- `file=${result.filePath}`,
4081
- `start target=http://${host}:${port}`,
4082
- `binary watch=${watchBinary ? "enabled" : "disabled"}`,
4083
- `local auth=${requireAuth ? "required (masterKey)" : "disabled"}`
4084
- ].join("\n")
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
- `Uninstalled OS startup (${result.manager})`,
4106
- `service=${result.serviceId}`,
4107
- `file=${result.filePath}`
4108
- ].join("\n")
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
- `manager=${status.manager}`,
4120
- `service=${status.serviceId}`,
4121
- `installed=${status.installed}`,
4122
- `running=${status.running}`,
4123
- status.filePath ? `file=${status.filePath}` : "",
4124
- status.detail ? `detail=${String(status.detail).trim()}` : ""
4125
- ].filter(Boolean).join("\n")
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 startup-managed llm-router instance.",
4266
- `manager=${stopped.detail?.manager || "unknown"}`,
4267
- `service=${stopped.detail?.serviceId || "unknown"}`
4268
- ].join("\n")
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: `Stopped llm-router process pid=${stopped.detail?.pid || "unknown"} (${stopped.detail?.signal || "SIGTERM"}).`
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
- "Restarted startup-managed llm-router instance.",
4322
- `manager=${result.detail?.manager || "unknown"}`,
4323
- `service=${result.detail?.serviceId || "unknown"}`
4324
- ].join("\n")
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=\"ChatGPT Subscription\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default");
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=\"ChatGPT Subscription\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
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
- ...largeConfigWarningLines,
5206
- mustConfirmLargeConfig
5207
- ? "Manual deploy may fail on Cloudflare Free tier unless you reduce config size."
5208
- : "",
5209
- `Exported worker config to ${resolvedOut}`,
5210
- `wrangler secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""} < ${resolvedOut}`
5211
- ].filter(Boolean).join("\n")
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
- "Dry run (no deployment executed).",
5223
- allowWeakMasterKey ? "WARNING: weak master key override enabled." : "",
5224
- ...largeConfigWarningLines,
5225
- mustConfirmLargeConfig
5226
- ? "Interactive deploy requires explicit confirmation (default: No)."
5227
- : "",
5228
- mustConfirmLargeConfig
5229
- ? "Use --allow-large-config=true to bypass this check in non-interactive mode."
5230
- : "",
5231
- generatedDeployMasterKey ? "Generated a deploy-time master key (not written to local config)." : "",
5232
- `projectDir=${projectDir}`,
5233
- cloudflareApiTokenSource !== "none"
5234
- ? `cloudflareApiToken=${cloudflareApiTokenSource === "prompt" ? "provided-via-prompt" : `from-${cloudflareApiTokenSource}`}`
5235
- : "",
5236
- cloudflareAccountId ? `cloudflareAccountId=${cloudflareAccountId}` : "",
5237
- `cloudflareTier=${formatCloudflareTierLabel(tierReport)} (${tierReport.reason || "unknown"})`,
5238
- `wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""}`,
5239
- `wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} deploy${cfEnv ? ` --env ${cfEnv}` : ""}`,
5240
- `Payload bytes=${payloadBytes}`
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 deployment completed.",
5372
- generatedDeployMasterKey ? "Generated a deploy-time master key. Persist it with `llm-router config --operation=set-master-key --master-key=...` if needed." : "",
5373
- wranglerTargetMessage,
5374
- deployZoneName ? `Deploy zone: ${deployZoneName}` : "",
5375
- secretResult.stdout.trim(),
5376
- deployResult.stdout.trim(),
5377
- postDeployGuide
5378
- ].filter(Boolean).join("\n")
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
- "Dry run (no secret update executed).",
5508
- allowWeakMasterKey ? "WARNING: weak master key override enabled." : "",
5509
- keyGenerated ? "Generated key for this operation." : "",
5510
- `projectDir=${projectDir}`,
5511
- cfEnv ? `env=${cfEnv}` : "",
5512
- `target=LLM_ROUTER_MASTER_KEY (${exists === null ? "existence unknown" : (exists ? "exists" : "missing")})`,
5513
- `wrangler secret put LLM_ROUTER_MASTER_KEY${cfEnv ? ` --env ${cfEnv}` : ""}`,
5514
- `masterKey=${maskSecret(masterKey)}`
5515
- ].filter(Boolean).join("\n")
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"} LLM_ROUTER_MASTER_KEY on Cloudflare Worker.`,
5552
- cfEnv ? `env=${cfEnv}` : "",
5553
- `projectDir=${projectDir}`,
5554
- `masterKey=${maskSecret(masterKey)}`,
5555
- keyGenerated ? `Generated key (copy now): ${masterKey}` : "",
5556
- putResult.stdout.trim()
5557
- ].filter(Boolean).join("\n")
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
- lines.push(`If browser doesn't open, visit: ${url}`);
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 (slug/camelCase).", example: "--provider-id=openrouter" },
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: "Required when --type=subscription. Supported: chatgpt-codex.", example: "--subscription-type=chatgpt-codex" },
5939
- { name: "subscription-profile", required: false, description: "OAuth token profile for subscription provider.", example: "--subscription-profile=personal" },
5940
- { name: "endpoints", required: false, description: "For standard provider: endpoint candidates for auto-probe (comma/semicolon/space/newline separated; TUI supports multiline paste).", example: "--endpoints=https://ramclouds.me,https://ramclouds.me/v1" },
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/semicolon/space/newline separated; strips common log/error noise; TUI supports multiline paste). For chatgpt-codex subscription type, predefined models are applied automatically.", example: "--models=gpt-4o,claude-3-5-sonnet-latest" },
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/semicolon/space separated).", example: "--fallback-models=openrouter/gpt-4o,anthropic/claude-3-7-sonnet" },
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/semicolon/space/newline separated).", example: "--targets=openrouter/gpt-4o-mini@3,anthropic/claude-3-5-haiku@2" },
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=\"ChatGPT Subscription\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
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",