@khanglvm/llm-router 1.1.1 → 1.3.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,10 @@ import {
37
37
  sanitizeConfigForDisplay,
38
38
  validateRuntimeConfig
39
39
  } from "../runtime/config.js";
40
+ import {
41
+ CODEX_SUBSCRIPTION_MODELS,
42
+ CLAUDE_CODE_SUBSCRIPTION_MODELS
43
+ } from "../runtime/subscription-constants.js";
40
44
  import { FORMATS } from "../translator/index.js";
41
45
  import {
42
46
  CLOUDFLARE_ACCOUNT_ID_ENV_NAME,
@@ -80,28 +84,23 @@ const MODEL_ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
80
84
  const MODEL_ROUTING_STRATEGY_OPTIONS = [
81
85
  {
82
86
  value: "auto",
83
- label: "Auto",
84
- hint: "Recommended set-and-forget mode. Uses quota, cooldown, and health signals to avoid rate limits."
87
+ label: "Auto"
85
88
  },
86
89
  {
87
90
  value: "ordered",
88
- label: "Ordered",
89
- hint: "Try targets in the listed order. Move to the next one only when earlier targets are unavailable."
91
+ label: "Ordered"
90
92
  },
91
93
  {
92
94
  value: "round-robin",
93
- label: "Round-robin",
94
- hint: "Rotate evenly across eligible targets."
95
+ label: "Round-robin"
95
96
  },
96
97
  {
97
98
  value: "weighted-rr",
98
- label: "Weighted round-robin",
99
- hint: "Rotate across eligible targets, but favor higher weights."
99
+ label: "Weighted round-robin"
100
100
  },
101
101
  {
102
102
  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."
103
+ label: "Quota-aware weighted round-robin"
105
104
  }
106
105
  ];
107
106
  const MODEL_ALIAS_STRATEGIES = MODEL_ROUTING_STRATEGY_OPTIONS.map((option) => option.value);
@@ -111,6 +110,23 @@ const DEFAULT_AI_HELP_GATEWAY_TEST_TIMEOUT_MS = 6000;
111
110
  const PROVIDER_TYPE_STANDARD = "standard";
112
111
  const PROVIDER_TYPE_SUBSCRIPTION = "subscription";
113
112
  const SUBSCRIPTION_TYPE_CHATGPT_CODEX = "chatgpt-codex";
113
+ const SUBSCRIPTION_TYPE_CLAUDE_CODE = "claude-code";
114
+ const SUBSCRIPTION_PROVIDER_PRESETS = Object.freeze([
115
+ Object.freeze({
116
+ subscriptionType: SUBSCRIPTION_TYPE_CHATGPT_CODEX,
117
+ label: "ChatGPT",
118
+ defaultName: "GPT Sub",
119
+ defaultModels: CODEX_SUBSCRIPTION_MODELS,
120
+ targetFormat: FORMATS.OPENAI
121
+ }),
122
+ Object.freeze({
123
+ subscriptionType: SUBSCRIPTION_TYPE_CLAUDE_CODE,
124
+ label: "Claude Code",
125
+ defaultName: "Claude Sub",
126
+ defaultModels: CLAUDE_CODE_SUBSCRIPTION_MODELS,
127
+ targetFormat: FORMATS.CLAUDE
128
+ })
129
+ ]);
114
130
  const RATE_LIMIT_WINDOW_UNIT_ALIASES = new Map([
115
131
  ["s", "second"],
116
132
  ["sec", "second"],
@@ -355,9 +371,42 @@ function normalizeSubscriptionTypeInput(value) {
355
371
  const normalized = String(value || "").trim().toLowerCase();
356
372
  if (!normalized) return "";
357
373
  if (normalized === SUBSCRIPTION_TYPE_CHATGPT_CODEX) return SUBSCRIPTION_TYPE_CHATGPT_CODEX;
374
+ if (normalized === SUBSCRIPTION_TYPE_CLAUDE_CODE) return SUBSCRIPTION_TYPE_CLAUDE_CODE;
358
375
  return "";
359
376
  }
360
377
 
378
+ function getSubscriptionProviderPreset(subscriptionType) {
379
+ return SUBSCRIPTION_PROVIDER_PRESETS.find((preset) => preset.subscriptionType === subscriptionType) || null;
380
+ }
381
+
382
+ function getSupportedSubscriptionTypes() {
383
+ return SUBSCRIPTION_PROVIDER_PRESETS.map((preset) => preset.subscriptionType);
384
+ }
385
+
386
+ function formatSupportedSubscriptionTypes() {
387
+ return getSupportedSubscriptionTypes().join(", ");
388
+ }
389
+
390
+ function getSubscriptionTargetFormat(subscriptionType) {
391
+ const preset = getSubscriptionProviderPreset(subscriptionType);
392
+ return preset?.targetFormat || FORMATS.OPENAI;
393
+ }
394
+
395
+ function getDefaultSubscriptionModelListInput(
396
+ existingProvider,
397
+ fallbackSubscriptionType = SUBSCRIPTION_TYPE_CHATGPT_CODEX
398
+ ) {
399
+ const existingSubType = normalizeSubscriptionTypeInput(
400
+ existingProvider?.subscriptionType || existingProvider?.subscription_type || ""
401
+ ) || normalizeSubscriptionTypeInput(fallbackSubscriptionType) || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
402
+ const preset = getSubscriptionProviderPreset(existingSubType);
403
+ const existingModels = dedupeList((existingProvider?.models || []).map((model) => model?.id).filter(Boolean));
404
+ const defaults = existingModels.length > 0
405
+ ? existingModels
406
+ : (preset?.defaultModels || CODEX_SUBSCRIPTION_MODELS);
407
+ return defaults.join(",");
408
+ }
409
+
361
410
  function normalizeNameForCompare(value) {
362
411
  return String(value || "").trim().toLowerCase();
363
412
  }
@@ -376,20 +425,7 @@ function findProviderByFriendlyName(providers, name, { excludeId = "" } = {}) {
376
425
  }
377
426
 
378
427
  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");
428
+ void context;
393
429
  }
394
430
 
395
431
  function trimOuterPunctuation(value) {
@@ -473,6 +509,26 @@ export function parseEndpointListInput(raw) {
473
509
  return normalized;
474
510
  }
475
511
 
512
+ function isValidHttpUrl(value) {
513
+ try {
514
+ const parsed = new URL(String(value || ""));
515
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && Boolean(parsed.hostname);
516
+ } catch {
517
+ return false;
518
+ }
519
+ }
520
+
521
+ function validateEndpointListInput(raw, { allowEmpty = false } = {}) {
522
+ const parsed = parseEndpointListInput(raw);
523
+ if (parsed.length === 0) {
524
+ return allowEmpty ? undefined : "Enter at least one valid endpoint URL (http:// or https://).";
525
+ }
526
+ if (!parsed.every((item) => isValidHttpUrl(item))) {
527
+ return "One or more endpoints are invalid. Use full http:// or https:// URLs.";
528
+ }
529
+ return undefined;
530
+ }
531
+
476
532
  const MODEL_INPUT_NOISE_TOKENS = new Set([
477
533
  "discover",
478
534
  "progress",
@@ -571,6 +627,14 @@ function parseProviderModelListInput(raw) {
571
627
  .filter(Boolean));
572
628
  }
573
629
 
630
+ function validateProviderModelListInput(raw, { allowEmpty = false } = {}) {
631
+ const parsed = parseProviderModelListInput(raw);
632
+ if (parsed.length === 0) {
633
+ return allowEmpty ? undefined : "Enter at least one valid model id.";
634
+ }
635
+ return undefined;
636
+ }
637
+
574
638
  function normalizeQualifiedModelToken(token) {
575
639
  const value = trimOuterPunctuation(token)
576
640
  .replace(/[)\]}>.,;:]+$/g, "")
@@ -758,7 +822,7 @@ function maybeReportInputCleanup(context, label, rawValue, cleanedValues) {
758
822
  if ((cleanedValues || []).length > 0) {
759
823
  info?.(`Cleaned ${label} input: parsed ${(cleanedValues || []).length} item(s) from free-form text.`);
760
824
  } else {
761
- warn?.(`Could not parse any ${label} from the provided text. Use comma/semicolon/space/newline-separated values.`);
825
+ warn?.(`Could not parse any ${label} from the provided text. Use comma-separated values (for example: a,b,c).`);
762
826
  }
763
827
  }
764
828
 
@@ -1011,10 +1075,9 @@ async function runProbeManualFallback(context, {
1011
1075
  if (unresolvedModels.length > 0) {
1012
1076
  info?.(`Manual fallback needs unresolved model review (${unresolvedModels.length} item(s)).`);
1013
1077
  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
1078
+ message: "Add unresolved models (comma-separated, optional)",
1079
+ initialValue: unresolvedModels.join(","),
1080
+ validate: (value) => validateProviderModelListInput(value, { allowEmpty: true })
1018
1081
  });
1019
1082
  const manualModels = parseProviderModelListInput(unresolvedInput);
1020
1083
  maybeReportInputCleanup(context, "model", unresolvedInput, manualModels);
@@ -1026,7 +1089,7 @@ async function runProbeManualFallback(context, {
1026
1089
  const openaiInput = await context.prompts.text({
1027
1090
  message: "OpenAI-compatible endpoint for unresolved items (optional)",
1028
1091
  initialValue: "",
1029
- paste: true
1092
+ validate: (value) => validateEndpointListInput(value, { allowEmpty: true })
1030
1093
  });
1031
1094
  openaiBase = parseEndpointListInput(openaiInput)[0] || "";
1032
1095
  }
@@ -1036,7 +1099,7 @@ async function runProbeManualFallback(context, {
1036
1099
  const claudeInput = await context.prompts.text({
1037
1100
  message: "Anthropic-compatible endpoint for unresolved items (optional)",
1038
1101
  initialValue: "",
1039
- paste: true
1102
+ validate: (value) => validateEndpointListInput(value, { allowEmpty: true })
1040
1103
  });
1041
1104
  claudeBase = parseEndpointListInput(claudeInput)[0] || "";
1042
1105
  }
@@ -1059,13 +1122,11 @@ async function runProbeManualFallback(context, {
1059
1122
 
1060
1123
  function slugifyId(value, fallback = "provider") {
1061
1124
  const slug = String(value || fallback)
1125
+ .toLowerCase()
1062
1126
  .trim()
1063
- .replace(/[^a-zA-Z0-9]+/g, "-")
1127
+ .replace(/[^a-z0-9]+/g, "-")
1064
1128
  .replace(/^-+|-+$/g, "");
1065
- if (!slug) return fallback;
1066
- return /^[A-Z]/.test(slug)
1067
- ? slug.charAt(0).toLowerCase() + slug.slice(1)
1068
- : slug;
1129
+ return slug || fallback;
1069
1130
  }
1070
1131
 
1071
1132
  function sanitizeRateLimitBucketName(value) {
@@ -1082,6 +1143,23 @@ function slugifyRateLimitBucketId(value, fallback = "bucket") {
1082
1143
  return slug || fallback;
1083
1144
  }
1084
1145
 
1146
+ function resolveUniqueProviderId(baseId, providers, { excludeId = "" } = {}) {
1147
+ const normalizedBase = slugifyId(baseId || "provider");
1148
+ const excluded = String(excludeId || "").trim();
1149
+ const reservedIds = new Set((providers || [])
1150
+ .map((provider) => String(provider?.id || "").trim())
1151
+ .filter((id) => id && id !== excluded));
1152
+ if (!reservedIds.has(normalizedBase)) return normalizedBase;
1153
+
1154
+ let suffix = 2;
1155
+ let candidate = `${normalizedBase}-${suffix}`;
1156
+ while (reservedIds.has(candidate)) {
1157
+ suffix += 1;
1158
+ candidate = `${normalizedBase}-${suffix}`;
1159
+ }
1160
+ return candidate;
1161
+ }
1162
+
1085
1163
  function resolveUniqueRateLimitBucketId(baseId, reservedIds) {
1086
1164
  const normalizedBase = String(baseId || "").trim() || "bucket";
1087
1165
  if (!(reservedIds instanceof Set)) return normalizedBase;
@@ -1136,69 +1214,294 @@ function formatRateLimitBucketScopeLabel(bucket) {
1136
1214
  return models.length > 0 ? models.join(", ") : "(none)";
1137
1215
  }
1138
1216
 
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)"}`);
1217
+ function formatRequestFormatLabel(value) {
1218
+ const normalized = String(value || "").trim().toLowerCase();
1219
+ if (!normalized) return "";
1220
+ if (normalized === FORMATS.OPENAI) return "OpenAI";
1221
+ if (normalized === FORMATS.CLAUDE) return "Claude";
1222
+ return normalized;
1223
+ }
1146
1224
 
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
- }
1225
+ function formatRequestFormatList(values, fallback = "") {
1226
+ const formats = dedupeList([
1227
+ ...(Array.isArray(values) ? values : []),
1228
+ ...(fallback ? [fallback] : [])
1229
+ ]).map(formatRequestFormatLabel).filter(Boolean);
1230
+ return formats.join(", ") || "Unknown";
1231
+ }
1188
1232
 
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
- }
1233
+ function formatProviderTypeLabel(type) {
1234
+ const normalized = normalizeProviderTypeInput(type) || PROVIDER_TYPE_STANDARD;
1235
+ return normalized === PROVIDER_TYPE_SUBSCRIPTION ? "Subscription (OAuth)" : "Standard (API Key)";
1236
+ }
1237
+
1238
+ function formatModelAliasStrategyLabel(strategy) {
1239
+ const normalized = normalizeModelAliasStrategy(strategy) || "ordered";
1240
+ return MODEL_ROUTING_STRATEGY_OPTIONS.find((option) => option.value === normalized)?.label || normalized;
1241
+ }
1242
+
1243
+ function formatReportCell(value, { fallback = "-", maxLength = 180 } = {}) {
1244
+ const compact = String(value ?? "")
1245
+ .replace(/\s+/g, " ")
1246
+ .trim();
1247
+ if (!compact) return fallback;
1248
+ if (compact.length <= maxLength) return compact;
1249
+ return `${compact.slice(0, Math.max(1, maxLength - 3))}...`;
1250
+ }
1251
+
1252
+ function renderAsciiTable(headers, rows, { emptyMessage = "No entries." } = {}) {
1253
+ const normalizedHeaders = (headers || []).map((header) => formatReportCell(header, { fallback: "", maxLength: 120 }));
1254
+ const normalizedRows = (rows || []).map((row) => normalizedHeaders.map((_, index) => {
1255
+ const cell = Array.isArray(row) ? row[index] : undefined;
1256
+ return formatReportCell(cell);
1257
+ }));
1258
+ if (normalizedRows.length === 0) return emptyMessage;
1259
+
1260
+ const widths = normalizedHeaders.map((header, index) => Math.max(
1261
+ header.length,
1262
+ ...normalizedRows.map((row) => row[index].length)
1263
+ ));
1264
+ const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
1265
+ const renderRow = (cells) => `| ${cells.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`;
1266
+
1267
+ return [
1268
+ border,
1269
+ renderRow(normalizedHeaders),
1270
+ border,
1271
+ ...normalizedRows.map((row) => renderRow(row)),
1272
+ border
1273
+ ].join("\n");
1274
+ }
1275
+
1276
+ function buildProviderEndpointRows(provider) {
1277
+ const rows = [];
1278
+ const seenUrls = new Set();
1279
+ const addRow = (label, url) => {
1280
+ const value = String(url || "").trim();
1281
+ if (!value || seenUrls.has(value)) return;
1282
+ seenUrls.add(value);
1283
+ rows.push([label, value]);
1284
+ };
1285
+
1286
+ addRow("OpenAI Endpoint", provider?.baseUrlByFormat?.openai);
1287
+ addRow("Claude Endpoint", provider?.baseUrlByFormat?.claude);
1288
+ addRow("Primary Endpoint", provider?.baseUrl);
1289
+
1290
+ if (rows.length === 0 && normalizeProviderTypeInput(provider?.type) === PROVIDER_TYPE_SUBSCRIPTION) {
1291
+ rows.push(["Endpoint", "Managed by OAuth subscription provider"]);
1199
1292
  }
1293
+ return rows;
1294
+ }
1295
+
1296
+ function buildProviderModelRows(provider) {
1297
+ const providerFormats = dedupeList([
1298
+ ...(provider?.formats || []),
1299
+ ...(provider?.format ? [provider.format] : [])
1300
+ ]);
1301
+ return (provider?.models || []).map((model) => ([
1302
+ model?.id || "(unknown)",
1303
+ formatRequestFormatList(dedupeList([...(model?.formats || []), ...providerFormats])),
1304
+ dedupeList(model?.fallbackModels || []).join(", ") || "None"
1305
+ ]));
1306
+ }
1307
+
1308
+ function buildRateLimitBucketRows(rateLimits) {
1309
+ return (rateLimits || []).map((bucket) => {
1310
+ const requests = Number.parseInt(String(bucket?.requests ?? ""), 10);
1311
+ const requestLimit = Number.isFinite(requests) && requests > 0
1312
+ ? `${new Intl.NumberFormat("en-US").format(requests)} requests`
1313
+ : "(unset)";
1314
+ return [
1315
+ formatRateLimitBucketLabel(bucket),
1316
+ String(bucket?.id || "(not set)").trim() || "(not set)",
1317
+ formatRateLimitBucketScopeLabel(bucket),
1318
+ requestLimit,
1319
+ formatRateLimitWindowForHuman(bucket?.window)
1320
+ ];
1321
+ });
1322
+ }
1323
+
1324
+ function joinReportSections(...sections) {
1325
+ return sections.filter((section) => String(section || "").trim().length > 0).join("\n\n");
1326
+ }
1327
+
1328
+ function formatYesNo(value) {
1329
+ return value ? "Yes" : "No";
1330
+ }
1331
+
1332
+ function renderKeyValueSection(title, rows) {
1333
+ return `${title}\n${renderAsciiTable(["Field", "Value"], rows || [])}`;
1334
+ }
1335
+
1336
+ function renderListSection(title, items, { emptyMessage = "None." } = {}) {
1337
+ const normalized = (items || [])
1338
+ .map((item) => String(item || "").trim())
1339
+ .filter(Boolean);
1340
+ if (normalized.length === 0) return `${title}\n${emptyMessage}`;
1341
+ return `${title}\n${normalized.map((item) => `- ${item}`).join("\n")}`;
1342
+ }
1343
+
1344
+ function buildOperationReport(title, rows, extraSections = []) {
1345
+ return joinReportSections(
1346
+ title,
1347
+ renderKeyValueSection("Overview", rows),
1348
+ ...extraSections
1349
+ );
1350
+ }
1351
+
1352
+ function buildModelAliasTargetRows(targets) {
1353
+ return (targets || []).map((target) => ([
1354
+ target?.ref || "(invalid target)",
1355
+ Number.isFinite(target?.weight) ? String(Number(target.weight)) : "Default"
1356
+ ]));
1357
+ }
1358
+
1359
+ function buildProviderSavedReport({
1360
+ provider,
1361
+ configPath,
1362
+ probe,
1363
+ masterKey
1364
+ }) {
1365
+ const detailsRows = [
1366
+ ["Config File", configPath],
1367
+ ["Provider ID", provider?.id || "(unknown)"],
1368
+ ["Provider Name", provider?.name || provider?.id || "(unknown)"],
1369
+ ["Provider Type", formatProviderTypeLabel(provider?.type)],
1370
+ ["Request Formats", formatRequestFormatList(provider?.formats, provider?.format)],
1371
+ ["API Credential", normalizeProviderTypeInput(provider?.type) === PROVIDER_TYPE_SUBSCRIPTION
1372
+ ? "Managed by OAuth session"
1373
+ : (provider?.apiKey || "(from env/hidden)")]
1374
+ ];
1375
+ if (provider?.subscriptionType) detailsRows.push(["Subscription Type", provider.subscriptionType]);
1376
+ if (provider?.subscriptionProfile) detailsRows.push(["Subscription Profile", provider.subscriptionProfile]);
1377
+ detailsRows.push(["Master Key", masterKey || "(not set)"]);
1378
+
1379
+ const probeRows = probe
1380
+ ? [
1381
+ ["Auto-detection", probe.ok ? "Completed" : "Needs review"],
1382
+ ["Preferred Request Format", formatRequestFormatLabel(probe.preferredFormat) || "(unknown)"],
1383
+ ["Working Formats", formatRequestFormatList(probe.workingFormats || [])]
1384
+ ]
1385
+ : [["Auto-detection", "Skipped"]];
1386
+
1387
+ return joinReportSections(
1388
+ "Provider Saved",
1389
+ "Overview\n" + renderAsciiTable(["Field", "Value"], detailsRows),
1390
+ "Endpoint Mapping\n" + renderAsciiTable(["Endpoint Type", "URL"], buildProviderEndpointRows(provider), {
1391
+ emptyMessage: "No endpoints configured."
1392
+ }),
1393
+ "Models\n" + renderAsciiTable(["Model", "Request Format(s)", "Silent Fallbacks"], buildProviderModelRows(provider), {
1394
+ emptyMessage: "No models configured."
1395
+ }),
1396
+ "Rate-Limit Buckets\n" + renderAsciiTable(["Bucket", "Bucket ID", "Scope", "Request Limit", "Time Window"], buildRateLimitBucketRows(provider?.rateLimits || []), {
1397
+ emptyMessage: "No rate-limit buckets configured."
1398
+ }),
1399
+ "Detection Summary\n" + renderAsciiTable(["Check", "Result"], probeRows)
1400
+ );
1401
+ }
1402
+
1403
+ function buildModelAliasSavedReport(aliasId, alias) {
1404
+ return joinReportSections(
1405
+ "Model Alias Saved",
1406
+ "Overview\n" + renderAsciiTable(["Field", "Value"], [
1407
+ ["Alias ID", aliasId || "(unknown)"],
1408
+ ["Routing Strategy", formatModelAliasStrategyLabel(alias?.strategy)]
1409
+ ]),
1410
+ "Primary Routes\n" + renderAsciiTable(["Model Route", "Weight"], buildModelAliasTargetRows(alias?.targets || []), {
1411
+ emptyMessage: "No primary routes configured."
1412
+ }),
1413
+ "Fallback Routes\n" + renderAsciiTable(["Model Route", "Weight"], buildModelAliasTargetRows(alias?.fallbackTargets || []), {
1414
+ emptyMessage: "No fallback routes configured."
1415
+ })
1416
+ );
1417
+ }
1418
+
1419
+ function buildProviderRateLimitReport({
1420
+ title,
1421
+ providerId,
1422
+ rateLimits
1423
+ }) {
1424
+ return joinReportSections(
1425
+ title || "Rate-Limit Buckets",
1426
+ "Overview\n" + renderAsciiTable(["Field", "Value"], [
1427
+ ["Provider ID", providerId || "(unknown)"],
1428
+ ["Total Buckets", String((rateLimits || []).length)]
1429
+ ]),
1430
+ "Buckets\n" + renderAsciiTable(["Bucket", "Bucket ID", "Scope", "Request Limit", "Time Window"], buildRateLimitBucketRows(rateLimits || []), {
1431
+ emptyMessage: "No rate-limit buckets configured."
1432
+ })
1433
+ );
1434
+ }
1200
1435
 
1201
- return lines.join("\n");
1436
+ function buildProviderConfigSection(provider) {
1437
+ const infoRows = [
1438
+ ["Provider ID", provider?.id || "(unknown)"],
1439
+ ["Provider Name", provider?.name || provider?.id || "(unknown)"],
1440
+ ["Provider Type", formatProviderTypeLabel(provider?.type)],
1441
+ ["Request Formats", formatRequestFormatList(provider?.formats, provider?.format)],
1442
+ ["API Credential", normalizeProviderTypeInput(provider?.type) === PROVIDER_TYPE_SUBSCRIPTION
1443
+ ? "Managed by OAuth session"
1444
+ : (provider?.apiKey || "(from env/hidden)")]
1445
+ ];
1446
+ if (provider?.subscriptionType) infoRows.push(["Subscription Type", provider.subscriptionType]);
1447
+ if (provider?.subscriptionProfile) infoRows.push(["Subscription Profile", provider.subscriptionProfile]);
1448
+
1449
+ return joinReportSections(
1450
+ `Provider: ${provider?.name || provider?.id || "(unknown)"}`,
1451
+ "Provider Details\n" + renderAsciiTable(["Field", "Value"], infoRows),
1452
+ "Endpoint Mapping\n" + renderAsciiTable(["Endpoint Type", "URL"], buildProviderEndpointRows(provider), {
1453
+ emptyMessage: "No endpoints configured."
1454
+ }),
1455
+ "Models\n" + renderAsciiTable(["Model", "Request Format(s)", "Silent Fallbacks"], buildProviderModelRows(provider), {
1456
+ emptyMessage: "No models configured."
1457
+ }),
1458
+ "Rate-Limit Buckets\n" + renderAsciiTable(["Bucket", "Bucket ID", "Scope", "Request Limit", "Time Window"], buildRateLimitBucketRows(provider?.rateLimits || []), {
1459
+ emptyMessage: "No rate-limit buckets configured."
1460
+ })
1461
+ );
1462
+ }
1463
+
1464
+ export function summarizeConfig(config, configPath, { includeSecrets = false } = {}) {
1465
+ const target = includeSecrets ? config : sanitizeConfigForDisplay(config);
1466
+ const providers = Array.isArray(target?.providers) ? target.providers : [];
1467
+ const aliasEntries = Object.entries(target?.modelAliases || {});
1468
+
1469
+ const providerSummaryRows = providers.map((provider) => [
1470
+ provider?.id || "(unknown)",
1471
+ provider?.name || provider?.id || "(unknown)",
1472
+ formatProviderTypeLabel(provider?.type),
1473
+ formatRequestFormatList(provider?.formats, provider?.format),
1474
+ String((provider?.models || []).length),
1475
+ String((provider?.rateLimits || []).length)
1476
+ ]);
1477
+
1478
+ const aliasRows = aliasEntries.map(([aliasId, alias]) => ([
1479
+ aliasId,
1480
+ formatModelAliasStrategyLabel(alias?.strategy),
1481
+ formatAliasTargetsForSummary(alias?.targets),
1482
+ formatAliasTargetsForSummary(alias?.fallbackTargets)
1483
+ ]));
1484
+
1485
+ return joinReportSections(
1486
+ "Current Router Configuration",
1487
+ "Overview\n" + renderAsciiTable(["Field", "Value"], [
1488
+ ["Config File", configPath],
1489
+ ["Schema Version", String(target?.version || 1)],
1490
+ ["Default Route", target?.defaultModel || "(not set)"],
1491
+ ["Master Key", target?.masterKey || "(not set)"]
1492
+ ]),
1493
+ "Providers\n" + renderAsciiTable(
1494
+ ["Provider ID", "Name", "Type", "Request Formats", "Models", "Rate-Limit Buckets"],
1495
+ providerSummaryRows,
1496
+ { emptyMessage: "No providers configured." }
1497
+ ),
1498
+ ...providers.map((provider) => buildProviderConfigSection(provider)),
1499
+ "Model Aliases\n" + renderAsciiTable(
1500
+ ["Alias ID", "Routing Strategy", "Primary Routes", "Fallback Routes"],
1501
+ aliasRows,
1502
+ { emptyMessage: "No model aliases configured." }
1503
+ )
1504
+ );
1202
1505
  }
1203
1506
 
1204
1507
  function runCommand(command, args, { cwd, input, envOverrides } = {}) {
@@ -2307,12 +2610,7 @@ async function resolveUpsertInput(context, existingConfig) {
2307
2610
  { value: "__new__", label: "Add new provider" },
2308
2611
  ...providers.map((provider) => ({
2309
2612
  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)")
2613
+ label: `Edit ${provider.id}`
2316
2614
  }))
2317
2615
  ]
2318
2616
  });
@@ -2324,18 +2622,29 @@ async function resolveUpsertInput(context, existingConfig) {
2324
2622
  }
2325
2623
 
2326
2624
  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)
2625
+ const rawNameArg = readArg(args, ["name"], undefined);
2626
+ const providedName = String(rawNameArg !== undefined ? rawNameArg : (selectedExisting?.name || "")) || "";
2627
+ const rawProviderTypeArg = readArg(args, ["type", "provider-type", "providerType"], undefined);
2628
+ const hasProviderTypeArg = rawProviderTypeArg !== undefined;
2629
+ const hasSubscriptionTypeArg = readArg(args, ["subscription-type", "subscriptionType"], undefined) !== undefined;
2630
+ const hasSubscriptionProfileArg = readArg(args, ["subscription-profile", "subscriptionProfile"], undefined) !== undefined;
2631
+ const hasModelsArg = readArg(args, ["models"], undefined) !== undefined;
2632
+ const initialProviderType = normalizeProviderTypeInput(
2633
+ rawProviderTypeArg !== undefined ? rawProviderTypeArg : (selectedExisting?.type || PROVIDER_TYPE_STANDARD)
2330
2634
  ) || PROVIDER_TYPE_STANDARD;
2331
- const baseSubscriptionType = normalizeSubscriptionTypeInput(
2332
- readArg(args, ["subscription-type", "subscriptionType"], selectedExisting?.subscriptionType || selectedExisting?.subscription_type || "")
2333
- );
2334
- const baseSubscriptionProfile = String(readArg(
2635
+ const rawSubscriptionType = String(readArg(
2335
2636
  args,
2336
- ["subscription-profile", "subscriptionProfile"],
2337
- selectedExisting?.subscriptionProfile || selectedExisting?.subscription_profile || baseProviderId || "default"
2637
+ ["subscription-type", "subscriptionType"],
2638
+ selectedExisting?.subscriptionType || selectedExisting?.subscription_type || ""
2338
2639
  ) || "").trim();
2640
+ const normalizedRequestedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
2641
+ const baseSubscriptionType = initialProviderType === PROVIDER_TYPE_SUBSCRIPTION
2642
+ ? (
2643
+ hasSubscriptionTypeArg
2644
+ ? (normalizedRequestedSubscriptionType || rawSubscriptionType)
2645
+ : (normalizedRequestedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
2646
+ )
2647
+ : "";
2339
2648
  const baseUrl = String(readArg(args, ["base-url", "baseUrl"], selectedExisting?.baseUrl || "") || "");
2340
2649
  const baseEndpoints = parseEndpointListInput(readArg(
2341
2650
  args,
@@ -2365,30 +2674,149 @@ async function resolveUpsertInput(context, existingConfig) {
2365
2674
  const shouldProbe = !toBoolean(readArg(args, ["skip-probe", "skipProbe"], false), false);
2366
2675
  const setMasterKeyFlag = toBoolean(readArg(args, ["set-master-key", "setMasterKey"], false), false);
2367
2676
  const providedMasterKey = String(readArg(args, ["master-key", "masterKey"], "") || "");
2677
+ const subscriptionDeviceCode = toBoolean(readArg(args, ["device-code", "deviceCode"], false), false);
2368
2678
  const parsedHeaders = applyDefaultHeaders(
2369
2679
  parseJsonObjectArg(baseHeaders, "--headers"),
2370
2680
  { force: !hasHeadersArg }
2371
2681
  );
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;
2682
+ let providerType = initialProviderType;
2683
+ if (canPrompt()) {
2684
+ printProviderInputGuidance(context);
2685
+ if (!hasProviderTypeArg) {
2686
+ providerType = await context.prompts.select({
2687
+ message: "Provider auth mode",
2688
+ initialValue: providerType,
2689
+ options: [
2690
+ {
2691
+ value: PROVIDER_TYPE_STANDARD,
2692
+ label: "API Key"
2693
+ },
2694
+ {
2695
+ value: PROVIDER_TYPE_SUBSCRIPTION,
2696
+ label: "OAuth"
2697
+ }
2698
+ ]
2699
+ });
2700
+ }
2701
+ }
2702
+
2703
+ let subscriptionType = providerType === PROVIDER_TYPE_SUBSCRIPTION
2704
+ ? baseSubscriptionType
2705
+ : "";
2706
+ if (providerType === PROVIDER_TYPE_SUBSCRIPTION && canPrompt() && !hasSubscriptionTypeArg) {
2707
+ subscriptionType = await context.prompts.select({
2708
+ message: "Subscription provider",
2709
+ initialValue: subscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX,
2710
+ options: SUBSCRIPTION_PROVIDER_PRESETS.map((preset) => ({
2711
+ value: preset.subscriptionType,
2712
+ label: preset.label
2713
+ }))
2714
+ });
2715
+ }
2716
+ const subscriptionPreset = providerType === PROVIDER_TYPE_SUBSCRIPTION
2717
+ ? getSubscriptionProviderPreset(subscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
2718
+ : null;
2719
+
2720
+ const defaultName = String(
2721
+ providedName ||
2722
+ (providerType === PROVIDER_TYPE_SUBSCRIPTION ? subscriptionPreset?.defaultName : "") ||
2723
+ ""
2724
+ ).trim();
2725
+
2726
+ const name = canPrompt()
2727
+ ? await context.prompts.text({
2728
+ message: "Provider Friendly Name (unique, shown in management screen)",
2729
+ required: true,
2730
+ initialValue: defaultName,
2731
+ placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION
2732
+ ? (subscriptionPreset?.defaultName || "Subscription Sub")
2733
+ : "OpenRouter Primary",
2734
+ validate: (value) => {
2735
+ const candidate = String(value || "").trim();
2736
+ if (!candidate) return "Provider Friendly Name is required.";
2737
+ const duplicate = findProviderByFriendlyName(providers, candidate, { excludeId: selectedExisting?.id || baseProviderId });
2738
+ if (duplicate) return `Provider Friendly Name '${candidate}' already exists (provider-id: ${duplicate.id}). Use a unique name.`;
2739
+ return undefined;
2740
+ }
2741
+ })
2742
+ : defaultName;
2743
+
2744
+ const generatedProviderId = resolveUniqueProviderId(
2745
+ slugifyId(name || subscriptionPreset?.defaultName || "provider"),
2746
+ providers,
2747
+ { excludeId: selectedExisting?.id || baseProviderId }
2748
+ );
2749
+
2750
+ const providerId = canPrompt()
2751
+ ? (baseProviderId || await context.prompts.text({
2752
+ message: "Provider ID (auto-slug from Friendly Name; editable)",
2753
+ required: true,
2754
+ initialValue: generatedProviderId,
2755
+ placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION
2756
+ ? slugifyId(subscriptionPreset?.defaultName || "subscription-sub")
2757
+ : "openrouter-primary",
2758
+ validate: (value) => {
2759
+ const candidate = String(value || "").trim();
2760
+ if (!candidate) return "Provider ID is required.";
2761
+ if (!PROVIDER_ID_PATTERN.test(candidate)) {
2762
+ return "Use lowercase letters, numbers, and dashes only (e.g. openrouter-primary).";
2763
+ }
2764
+ const duplicate = (providers || []).find((provider) =>
2765
+ provider &&
2766
+ String(provider.id || "").trim() === candidate &&
2767
+ String(provider.id || "").trim() !== String(selectedExisting?.id || baseProviderId || "").trim());
2768
+ if (duplicate) {
2769
+ return `Provider ID '${candidate}' already exists.`;
2770
+ }
2771
+ return undefined;
2772
+ }
2773
+ }))
2774
+ : (baseProviderId || generatedProviderId);
2775
+
2776
+ const subscriptionProfile = providerType === PROVIDER_TYPE_SUBSCRIPTION
2777
+ ? String(readArg(
2778
+ args,
2779
+ ["subscription-profile", "subscriptionProfile"],
2780
+ selectedExisting?.subscriptionProfile || selectedExisting?.subscription_profile || providerId
2781
+ ) || "").trim() || providerId
2782
+ : "";
2783
+ const requiresSubscriptionLogin = providerType === PROVIDER_TYPE_SUBSCRIPTION && (
2784
+ !selectedExisting ||
2785
+ normalizeProviderTypeInput(selectedExisting?.type) !== PROVIDER_TYPE_SUBSCRIPTION ||
2786
+ (hasSubscriptionProfileArg && subscriptionProfile !== String(
2787
+ selectedExisting?.subscriptionProfile || selectedExisting?.subscription_profile || selectedExisting?.id || ""
2788
+ ).trim())
2789
+ );
2790
+ const subscriptionModelsInput = providerType === PROVIDER_TYPE_SUBSCRIPTION
2791
+ ? String(
2792
+ hasModelsArg
2793
+ ? readArg(args, ["models"], "")
2794
+ : getDefaultSubscriptionModelListInput(selectedExisting, subscriptionType)
2795
+ )
2796
+ : "";
2797
+ const baseIsSubscription = providerType === PROVIDER_TYPE_SUBSCRIPTION;
2376
2798
 
2377
2799
  if (!canPrompt()) {
2378
2800
  return {
2379
2801
  configPath,
2380
- providerId: baseProviderId || slugifyId(baseName || "provider"),
2381
- name: baseName,
2382
- providerType: baseProviderType,
2383
- subscriptionType: baseSubscriptionType,
2384
- subscriptionProfile: baseSubscriptionProfile || baseProviderId || "default",
2802
+ providerId,
2803
+ name,
2804
+ providerType,
2805
+ subscriptionType,
2806
+ subscriptionProfile,
2807
+ subscriptionDeviceCode,
2808
+ requireSubscriptionLogin: requiresSubscriptionLogin,
2809
+ hasModelsArg,
2810
+ subscriptionModelsInput,
2385
2811
  baseUrl,
2386
2812
  endpoints: baseEndpoints,
2387
2813
  openaiBaseUrl: baseOpenAIBaseUrl,
2388
2814
  claudeBaseUrl: baseClaudeBaseUrl,
2389
2815
  apiKey: baseIsSubscription ? "" : (baseApiKey || selectedExisting?.apiKey || ""),
2390
- models: parseProviderModelListInput(baseModels),
2391
- format: baseIsSubscription ? "openai" : baseFormat,
2816
+ models: baseIsSubscription
2817
+ ? parseProviderModelListInput(subscriptionModelsInput)
2818
+ : parseProviderModelListInput(baseModels),
2819
+ format: baseIsSubscription ? (subscriptionPreset?.targetFormat || FORMATS.OPENAI) : baseFormat,
2392
2820
  formats: baseFormats,
2393
2821
  headers: parsedHeaders,
2394
2822
  probeRequestsPerMinute: baseProbeRequestsPerMinute,
@@ -2398,98 +2826,21 @@ async function resolveUpsertInput(context, existingConfig) {
2398
2826
  };
2399
2827
  }
2400
2828
 
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
2829
  let apiKey = "";
2483
2830
  let endpoints = [];
2484
- let models = parseProviderModelListInput(baseModels);
2831
+ let models = providerType === PROVIDER_TYPE_SUBSCRIPTION
2832
+ ? parseProviderModelListInput(subscriptionModelsInput)
2833
+ : parseProviderModelListInput(baseModels);
2485
2834
  let interactiveHeaders = parsedHeaders;
2486
2835
  let probe = false;
2487
2836
  let probeRequestsPerMinute = baseProbeRequestsPerMinute;
2488
- let manualFormat = providerType === PROVIDER_TYPE_SUBSCRIPTION ? "openai" : baseFormat;
2837
+ let manualFormat = providerType === PROVIDER_TYPE_SUBSCRIPTION
2838
+ ? (subscriptionPreset?.targetFormat || FORMATS.OPENAI)
2839
+ : baseFormat;
2489
2840
 
2490
2841
  if (providerType === PROVIDER_TYPE_SUBSCRIPTION) {
2491
2842
  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.");
2843
+ info?.(`${subscriptionPreset?.label || "Subscription provider"} uses browser OAuth login. Model validation will run after authentication.`);
2493
2844
  } else {
2494
2845
  const askReplaceKey = selectedExisting?.apiKey ? await context.prompts.confirm({
2495
2846
  message: "Replace saved API key?",
@@ -2507,21 +2858,19 @@ async function resolveUpsertInput(context, existingConfig) {
2507
2858
  });
2508
2859
 
2509
2860
  const endpointsInput = await context.prompts.text({
2510
- message: "Provider endpoints (comma / ; / space / newline separated; multiline paste supported)",
2861
+ message: "Provider endpoints (comma-separated URLs)",
2511
2862
  required: true,
2512
- initialValue: baseEndpoints.join("\n"),
2513
- paste: true,
2514
- multiline: true
2863
+ initialValue: baseEndpoints.join(","),
2864
+ validate: (value) => validateEndpointListInput(value)
2515
2865
  });
2516
2866
  endpoints = parseEndpointListInput(endpointsInput);
2517
2867
  maybeReportInputCleanup(context, "endpoint", endpointsInput, endpoints);
2518
2868
 
2519
2869
  const modelsInput = await context.prompts.text({
2520
- message: "Provider models (comma / ; / space / newline separated; multiline paste supported)",
2870
+ message: "Provider models (comma-separated IDs)",
2521
2871
  required: true,
2522
2872
  initialValue: baseModels,
2523
- paste: true,
2524
- multiline: true
2873
+ validate: (value) => validateProviderModelListInput(value)
2525
2874
  });
2526
2875
  models = parseProviderModelListInput(modelsInput);
2527
2876
  maybeReportInputCleanup(context, "model", modelsInput, models);
@@ -2557,46 +2906,268 @@ async function resolveUpsertInput(context, existingConfig) {
2557
2906
  probeRequestsPerMinute = toPositiveInteger(rpmInput, DEFAULT_PROBE_REQUESTS_PER_MINUTE);
2558
2907
  }
2559
2908
 
2560
- if (!probe) {
2561
- manualFormat = await promptProviderFormat(context, {
2562
- message: "Primary provider format",
2563
- initialFormat: manualFormat
2564
- });
2565
- }
2909
+ if (!probe) {
2910
+ manualFormat = await promptProviderFormat(context, {
2911
+ message: "Primary provider format",
2912
+ initialFormat: manualFormat
2913
+ });
2914
+ }
2915
+ }
2916
+
2917
+ const setMasterKey = setMasterKeyFlag || await context.prompts.confirm({
2918
+ message: "Set/update worker master key?",
2919
+ initialValue: false
2920
+ });
2921
+ let masterKey = providedMasterKey;
2922
+ if (setMasterKey && !masterKey) {
2923
+ masterKey = await context.prompts.text({
2924
+ message: "Worker master key",
2925
+ required: true
2926
+ });
2927
+ }
2928
+
2929
+ return {
2930
+ configPath,
2931
+ providerId,
2932
+ name,
2933
+ providerType,
2934
+ subscriptionType,
2935
+ subscriptionProfile,
2936
+ subscriptionDeviceCode,
2937
+ requireSubscriptionLogin: requiresSubscriptionLogin,
2938
+ hasModelsArg,
2939
+ subscriptionModelsInput,
2940
+ baseUrl,
2941
+ endpoints,
2942
+ openaiBaseUrl: baseOpenAIBaseUrl,
2943
+ claudeBaseUrl: baseClaudeBaseUrl,
2944
+ apiKey,
2945
+ models,
2946
+ format: probe ? "" : manualFormat,
2947
+ formats: baseFormats,
2948
+ headers: interactiveHeaders,
2949
+ probeRequestsPerMinute,
2950
+ shouldProbe: probe,
2951
+ setMasterKey,
2952
+ masterKey
2953
+ };
2954
+ }
2955
+
2956
+ async function resolveSubscriptionAuthFns(context) {
2957
+ if (context?.subscriptionAuth && typeof context.subscriptionAuth === "object") {
2958
+ return context.subscriptionAuth;
2959
+ }
2960
+ return import("../runtime/subscription-auth.js");
2961
+ }
2962
+
2963
+ async function resolveSubscriptionProviderFns(context) {
2964
+ if (context?.subscriptionProvider && typeof context.subscriptionProvider === "object") {
2965
+ return context.subscriptionProvider;
2966
+ }
2967
+ return import("../runtime/subscription-provider.js");
2968
+ }
2969
+
2970
+ async function ensureSubscriptionAuthenticated(context, {
2971
+ profile,
2972
+ subscriptionType = SUBSCRIPTION_TYPE_CHATGPT_CODEX,
2973
+ forceLogin = false,
2974
+ deviceCode = false
2975
+ }) {
2976
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
2977
+ const authFns = await resolveSubscriptionAuthFns(context);
2978
+ const getAuthStatus = typeof authFns.getAuthStatus === "function" ? authFns.getAuthStatus : null;
2979
+ const loginWithBrowser = typeof authFns.loginWithBrowser === "function" ? authFns.loginWithBrowser : null;
2980
+ const loginWithDeviceCode = typeof authFns.loginWithDeviceCode === "function" ? authFns.loginWithDeviceCode : null;
2981
+
2982
+ if (!getAuthStatus || !loginWithBrowser || !loginWithDeviceCode) {
2983
+ throw new Error("Subscription auth module is missing required login/status functions.");
2984
+ }
2985
+
2986
+ if (!forceLogin) {
2987
+ const status = await getAuthStatus(profile, { subscriptionType });
2988
+ if (status?.authenticated) {
2989
+ line?.(`Subscription profile '${profile}' already authenticated.`);
2990
+ return { authenticated: true, loginAttempted: false };
2991
+ }
2992
+ }
2993
+
2994
+ line?.(`Starting OAuth login for subscription profile '${profile}'...`);
2995
+ if (deviceCode) {
2996
+ await loginWithDeviceCode(profile, {
2997
+ subscriptionType,
2998
+ onCode: ({ userCode, verificationUri, expiresIn }) => {
2999
+ line?.(`Open ${verificationUri} and enter code ${userCode} (expires in ${Math.floor(Number(expiresIn || 0) / 60)} minutes).`);
3000
+ }
3001
+ });
3002
+ } else {
3003
+ await loginWithBrowser(profile, {
3004
+ subscriptionType,
3005
+ onUrl: (url, meta = {}) => {
3006
+ if (meta?.openedBrowser === true) {
3007
+ line?.("Opened browser for OAuth login. Complete authentication to continue.");
3008
+ } else {
3009
+ line?.(`Open this OAuth URL in your browser: ${url}`);
3010
+ }
3011
+ }
3012
+ });
3013
+ }
3014
+
3015
+ const refreshedStatus = await getAuthStatus(profile, { subscriptionType });
3016
+ if (!refreshedStatus?.authenticated) {
3017
+ throw new Error(`OAuth login did not complete for subscription profile '${profile}'.`);
3018
+ }
3019
+ return { authenticated: true, loginAttempted: true };
3020
+ }
3021
+
3022
+ function buildSubscriptionProbeSeed(models, targetFormat = FORMATS.OPENAI) {
3023
+ const format = targetFormat === FORMATS.CLAUDE ? FORMATS.CLAUDE : FORMATS.OPENAI;
3024
+ const modelSupport = {};
3025
+ const modelPreferredFormat = {};
3026
+ for (const model of (models || [])) {
3027
+ modelSupport[model] = [format];
3028
+ modelPreferredFormat[model] = format;
3029
+ }
3030
+
3031
+ return {
3032
+ ok: true,
3033
+ preferredFormat: format,
3034
+ formats: [format],
3035
+ workingFormats: [format],
3036
+ models: [...(models || [])],
3037
+ modelSupport,
3038
+ modelPreferredFormat
3039
+ };
3040
+ }
3041
+
3042
+ function summarizeSubscriptionProbeFailure(result, fallback = "Subscription model probe failed.") {
3043
+ if (!result || typeof result !== "object") return fallback;
3044
+ if (result.errorKind) return `${result.errorKind}: ${result.status || "unknown-status"}`;
3045
+ if (Number.isFinite(result.status)) return `status=${result.status}`;
3046
+ return fallback;
3047
+ }
3048
+
3049
+ async function closeSubscriptionProbeResponse(result) {
3050
+ if (!(result?.response instanceof Response)) return;
3051
+ const body = result.response.body;
3052
+ if (body && typeof body.cancel === "function") {
3053
+ try {
3054
+ await body.cancel();
3055
+ return;
3056
+ } catch {
3057
+ // Fall through and try draining as a best effort.
3058
+ }
3059
+ }
3060
+ try {
3061
+ await result.response.arrayBuffer();
3062
+ } catch {
3063
+ // Ignore cleanup failure for probe responses.
3064
+ }
3065
+ }
3066
+
3067
+ function buildSubscriptionProbeBody(modelId, subscriptionType) {
3068
+ if (subscriptionType === SUBSCRIPTION_TYPE_CLAUDE_CODE) {
3069
+ return {
3070
+ model: modelId,
3071
+ max_tokens: 16,
3072
+ stream: true,
3073
+ messages: [
3074
+ {
3075
+ role: "user",
3076
+ content: [
3077
+ {
3078
+ type: "text",
3079
+ text: "Reply with exactly: pong"
3080
+ }
3081
+ ]
3082
+ }
3083
+ ]
3084
+ };
3085
+ }
3086
+
3087
+ return {
3088
+ model: modelId,
3089
+ stream: true,
3090
+ store: false,
3091
+ instructions: "You are a helpful assistant. Reply concisely.",
3092
+ input: [
3093
+ {
3094
+ type: "message",
3095
+ role: "user",
3096
+ content: [
3097
+ {
3098
+ type: "input_text",
3099
+ text: "Reply with exactly: pong"
3100
+ }
3101
+ ]
3102
+ }
3103
+ ],
3104
+ tools: [],
3105
+ tool_choice: "auto",
3106
+ parallel_tool_calls: false
3107
+ };
3108
+ }
3109
+
3110
+ async function probeSubscriptionModels(context, {
3111
+ providerId,
3112
+ providerName,
3113
+ subscriptionType,
3114
+ subscriptionProfile,
3115
+ models,
3116
+ headers
3117
+ }) {
3118
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
3119
+ const providerFns = await resolveSubscriptionProviderFns(context);
3120
+ const makeSubscriptionProviderCall = typeof providerFns.makeSubscriptionProviderCall === "function"
3121
+ ? providerFns.makeSubscriptionProviderCall
3122
+ : null;
3123
+ if (!makeSubscriptionProviderCall) {
3124
+ throw new Error("Subscription provider module is missing makeSubscriptionProviderCall.");
2566
3125
  }
2567
3126
 
2568
- const setMasterKey = setMasterKeyFlag || await context.prompts.confirm({
2569
- message: "Set/update worker master key?",
2570
- initialValue: false
2571
- });
2572
- let masterKey = providedMasterKey;
2573
- if (setMasterKey && !masterKey) {
2574
- masterKey = await context.prompts.text({
2575
- message: "Worker master key",
2576
- required: true
3127
+ const uniqueModels = dedupeList(models);
3128
+ const provider = {
3129
+ id: providerId,
3130
+ name: providerName,
3131
+ type: PROVIDER_TYPE_SUBSCRIPTION,
3132
+ subscriptionType,
3133
+ subscriptionProfile,
3134
+ headers: headers || {},
3135
+ models: uniqueModels.map((id) => ({ id }))
3136
+ };
3137
+ const targetFormat = getSubscriptionTargetFormat(subscriptionType);
3138
+
3139
+ const failures = [];
3140
+ for (const modelId of uniqueModels) {
3141
+ line?.(`[subscription probe] Testing model ${modelId}...`);
3142
+ const probeBody = buildSubscriptionProbeBody(modelId, subscriptionType);
3143
+ const result = await makeSubscriptionProviderCall({
3144
+ provider,
3145
+ body: probeBody,
3146
+ stream: true
2577
3147
  });
3148
+
3149
+ if (result?.ok) {
3150
+ await closeSubscriptionProbeResponse(result);
3151
+ continue;
3152
+ }
3153
+
3154
+ let details = summarizeSubscriptionProbeFailure(result);
3155
+ if (result?.response instanceof Response) {
3156
+ try {
3157
+ const raw = await result.response.text();
3158
+ const compact = String(raw || "").trim().replace(/\s+/g, " ");
3159
+ if (compact) details = compact.slice(0, 300);
3160
+ } catch {
3161
+ // Ignore response parsing failure and keep fallback details.
3162
+ }
3163
+ }
3164
+ failures.push({ modelId, details });
2578
3165
  }
2579
3166
 
2580
3167
  return {
2581
- configPath,
2582
- providerId,
2583
- name,
2584
- providerType,
2585
- subscriptionType,
2586
- subscriptionProfile,
2587
- baseUrl,
2588
- endpoints,
2589
- openaiBaseUrl: baseOpenAIBaseUrl,
2590
- claudeBaseUrl: baseClaudeBaseUrl,
2591
- apiKey,
2592
- models,
2593
- format: probe ? "" : manualFormat,
2594
- formats: baseFormats,
2595
- headers: interactiveHeaders,
2596
- probeRequestsPerMinute,
2597
- shouldProbe: probe,
2598
- setMasterKey,
2599
- masterKey
3168
+ ok: failures.length === 0,
3169
+ failures,
3170
+ probe: buildSubscriptionProbeSeed(uniqueModels, targetFormat)
2600
3171
  };
2601
3172
  }
2602
3173
 
@@ -2606,8 +3177,15 @@ async function doUpsertProvider(context) {
2606
3177
  const input = await resolveUpsertInput(context, existingConfig);
2607
3178
  const providerType = normalizeProviderTypeInput(input.providerType) || PROVIDER_TYPE_STANDARD;
2608
3179
  const isSubscriptionProvider = providerType === PROVIDER_TYPE_SUBSCRIPTION;
2609
- const subscriptionType = normalizeSubscriptionTypeInput(input.subscriptionType);
2610
- const subscriptionProfile = String(input.subscriptionProfile || input.providerId || "default").trim() || "default";
3180
+ const rawSubscriptionType = String(input.subscriptionType || "").trim();
3181
+ const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
3182
+ const subscriptionType = isSubscriptionProvider
3183
+ ? (normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
3184
+ : "";
3185
+ const subscriptionPreset = isSubscriptionProvider
3186
+ ? getSubscriptionProviderPreset(subscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
3187
+ : null;
3188
+ const subscriptionProfile = String(input.subscriptionProfile || input.providerId || "default").trim() || input.providerId || "default";
2611
3189
 
2612
3190
  const endpointCandidates = parseEndpointListInput([
2613
3191
  ...(input.endpoints || []),
@@ -2616,6 +3194,7 @@ async function doUpsertProvider(context) {
2616
3194
  input.baseUrl
2617
3195
  ].filter(Boolean).join(","));
2618
3196
  const hasAnyEndpoint = endpointCandidates.length > 0;
3197
+ const hasAnyModel = dedupeList(input.models || []).length > 0;
2619
3198
  if (!input.name) {
2620
3199
  return {
2621
3200
  ok: false,
@@ -2624,12 +3203,12 @@ async function doUpsertProvider(context) {
2624
3203
  errorMessage: "Provider Friendly Name is required."
2625
3204
  };
2626
3205
  }
2627
- if (isSubscriptionProvider && !subscriptionType) {
3206
+ if (isSubscriptionProvider && rawSubscriptionType && !normalizedSubscriptionType) {
2628
3207
  return {
2629
3208
  ok: false,
2630
3209
  mode: context.mode,
2631
3210
  exitCode: EXIT_VALIDATION,
2632
- errorMessage: "subscription-type is required for provider type 'subscription' (e.g. --subscription-type=chatgpt-codex)."
3211
+ errorMessage: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
2633
3212
  };
2634
3213
  }
2635
3214
  if (!isSubscriptionProvider && (!hasAnyEndpoint || !input.apiKey)) {
@@ -2640,13 +3219,29 @@ async function doUpsertProvider(context) {
2640
3219
  errorMessage: "Missing provider inputs: provider-id, name, api-key, and at least one endpoint."
2641
3220
  };
2642
3221
  }
3222
+ if (!isSubscriptionProvider && endpointCandidates.some((endpoint) => !isValidHttpUrl(endpoint))) {
3223
+ return {
3224
+ ok: false,
3225
+ mode: context.mode,
3226
+ exitCode: EXIT_VALIDATION,
3227
+ errorMessage: "One or more endpoints are invalid. Use full http:// or https:// URLs."
3228
+ };
3229
+ }
3230
+ if (!isSubscriptionProvider && !hasAnyModel) {
3231
+ return {
3232
+ ok: false,
3233
+ mode: context.mode,
3234
+ exitCode: EXIT_VALIDATION,
3235
+ errorMessage: "At least one valid model id is required."
3236
+ };
3237
+ }
2643
3238
 
2644
3239
  if (!PROVIDER_ID_PATTERN.test(input.providerId)) {
2645
3240
  return {
2646
3241
  ok: false,
2647
3242
  mode: context.mode,
2648
3243
  exitCode: EXIT_VALIDATION,
2649
- errorMessage: `Invalid provider id '${input.providerId}'. Use slug/camelCase (e.g. openrouter or myProvider).`
3244
+ errorMessage: `Invalid provider id '${input.providerId}'. Use lowercase letters, numbers, and dashes (e.g. openrouter-primary).`
2650
3245
  };
2651
3246
  }
2652
3247
 
@@ -2667,144 +3262,261 @@ async function doUpsertProvider(context) {
2667
3262
  let effectiveBaseUrl = String(input.baseUrl || "").trim();
2668
3263
  let effectiveOpenAIBaseUrl = String(input.openaiBaseUrl || "").trim();
2669
3264
  let effectiveClaudeBaseUrl = String(input.claudeBaseUrl || "").trim();
2670
- let effectiveModels = [...(input.models || [])];
3265
+ let effectiveModels = dedupeList(input.models || []);
2671
3266
  const shouldProbe = !isSubscriptionProvider && Boolean(input.shouldProbe);
2672
3267
 
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
3268
+ if (isSubscriptionProvider) {
3269
+ try {
3270
+ await ensureSubscriptionAuthenticated(context, {
3271
+ profile: subscriptionProfile,
3272
+ subscriptionType,
3273
+ forceLogin: Boolean(input.requireSubscriptionLogin),
3274
+ deviceCode: Boolean(input.subscriptionDeviceCode)
2699
3275
  });
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
3276
+ } catch (error) {
3277
+ return {
3278
+ ok: false,
3279
+ mode: context.mode,
3280
+ exitCode: EXIT_FAILURE,
3281
+ errorMessage: `Subscription OAuth login failed: ${error instanceof Error ? error.message : String(error)}`
3282
+ };
3283
+ }
3284
+
3285
+ if (canPrompt() && !input.hasModelsArg) {
3286
+ const modelsInput = await context.prompts.text({
3287
+ message: "Subscription models (comma-separated IDs)",
3288
+ required: true,
3289
+ initialValue: String(input.subscriptionModelsInput || "").trim(),
3290
+ validate: (value) => validateProviderModelListInput(value)
2724
3291
  });
3292
+ effectiveModels = parseProviderModelListInput(modelsInput);
3293
+ maybeReportInputCleanup(context, "model", modelsInput, effectiveModels);
3294
+ } else if (effectiveModels.length === 0) {
3295
+ effectiveModels = parseProviderModelListInput(input.subscriptionModelsInput || "");
2725
3296
  }
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.`);
3297
+
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
+ };
2730
3305
  }
2731
- selectedFormat = probe.preferredFormat || selectedFormat;
2732
- if (probe?.manualFallbackRecommended) {
3306
+
3307
+ const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : null;
3308
+ const formatProbeFailures = (failures = []) => [
3309
+ "Subscription model probe failed. Remove unsupported models and retry.",
3310
+ ...failures.map((entry) => `- ${entry.modelId}: ${entry.details}`)
3311
+ ].join("\n");
3312
+
3313
+ while (true) {
3314
+ let subscriptionProbe = null;
3315
+ try {
3316
+ subscriptionProbe = await probeSubscriptionModels(context, {
3317
+ providerId: input.providerId,
3318
+ providerName: input.name,
3319
+ subscriptionType,
3320
+ subscriptionProfile,
3321
+ models: effectiveModels,
3322
+ headers: input.headers
3323
+ });
3324
+ } catch (error) {
3325
+ return {
3326
+ ok: false,
3327
+ mode: context.mode,
3328
+ exitCode: EXIT_FAILURE,
3329
+ errorMessage: `Subscription model validation failed: ${error instanceof Error ? error.message : String(error)}`
3330
+ };
3331
+ }
3332
+
3333
+ if (subscriptionProbe.ok) {
3334
+ probe = subscriptionProbe.probe;
3335
+ break;
3336
+ }
3337
+
2733
3338
  if (!canPrompt()) {
2734
- const scope = probe?.failureScope === "full" ? "full" : "partial";
2735
3339
  return {
2736
3340
  ok: false,
2737
3341
  mode: context.mode,
2738
3342
  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."
3343
+ errorMessage: formatProbeFailures(subscriptionProbe.failures)
2742
3344
  };
2743
3345
  }
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
3346
 
2777
- selectedFormat = await promptProviderFormat(context, {
2778
- message: "Probe could not confirm a working format. Choose primary provider format",
2779
- initialFormat: selectedFormat
2780
- });
2781
- } else {
3347
+ warn?.(formatProbeFailures(subscriptionProbe.failures));
3348
+ const retry = await context.prompts.confirm({
3349
+ message: "Edit subscription models and retry probe?",
3350
+ initialValue: true
3351
+ });
3352
+ if (!retry) {
2782
3353
  return {
2783
3354
  ok: false,
2784
3355
  mode: context.mode,
2785
3356
  exitCode: EXIT_FAILURE,
2786
- errorMessage: "Provider probe failed. Provide valid endpoints/models or use --skip-probe=true to force save."
3357
+ errorMessage: formatProbeFailures(subscriptionProbe.failures)
2787
3358
  };
2788
3359
  }
2789
- }
2790
- }
2791
3360
 
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];
3361
+ const modelsInput = await context.prompts.text({
3362
+ message: "Subscription models (comma-separated IDs)",
3363
+ required: true,
3364
+ initialValue: effectiveModels.join(","),
3365
+ validate: (value) => validateProviderModelListInput(value)
3366
+ });
3367
+ effectiveModels = parseProviderModelListInput(modelsInput);
3368
+ maybeReportInputCleanup(context, "model", modelsInput, effectiveModels);
3369
+ if (effectiveModels.length === 0) {
3370
+ return {
3371
+ ok: false,
3372
+ mode: context.mode,
3373
+ exitCode: EXIT_VALIDATION,
3374
+ errorMessage: "Subscription provider requires at least one model after editing."
3375
+ };
3376
+ }
2799
3377
  }
2800
- if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length > 1) {
3378
+
3379
+ selectedFormat = subscriptionPreset?.targetFormat || getSubscriptionTargetFormat(subscriptionType);
3380
+ effectiveBaseUrl = "";
3381
+ effectiveOpenAIBaseUrl = "";
3382
+ effectiveClaudeBaseUrl = "";
3383
+ } else {
3384
+ if (shouldProbe && endpointCandidates.length > 0 && effectiveModels.length === 0) {
2801
3385
  return {
2802
3386
  ok: false,
2803
3387
  mode: context.mode,
2804
3388
  exitCode: EXIT_VALIDATION,
2805
- errorMessage: "Multiple endpoints require probe mode (recommended) or explicit --openai-base-url/--claude-base-url."
3389
+ errorMessage: "Model list is required for endpoint-model probe. Provide --models=modelA,modelB."
2806
3390
  };
2807
3391
  }
3392
+
3393
+ if (shouldProbe) {
3394
+ const startedAt = Date.now();
3395
+ const reportProgress = probeProgressReporter(context);
3396
+ const probeRequestsPerMinute = toPositiveInteger(
3397
+ input.probeRequestsPerMinute,
3398
+ DEFAULT_PROBE_REQUESTS_PER_MINUTE
3399
+ );
3400
+ const canRunMatrixProbe = endpointCandidates.length > 0 && effectiveModels.length > 0;
3401
+ if (canRunMatrixProbe) {
3402
+ probe = await probeProviderEndpointMatrix({
3403
+ endpoints: endpointCandidates,
3404
+ models: effectiveModels,
3405
+ apiKey: input.apiKey,
3406
+ headers: input.headers,
3407
+ requestsPerMinute: probeRequestsPerMinute,
3408
+ maxRateLimitRetries: DEFAULT_PROBE_MAX_RATE_LIMIT_RETRIES,
3409
+ onProgress: reportProgress
3410
+ });
3411
+ effectiveOpenAIBaseUrl = probe.baseUrlByFormat?.openai || effectiveOpenAIBaseUrl;
3412
+ effectiveClaudeBaseUrl = probe.baseUrlByFormat?.claude || effectiveClaudeBaseUrl;
3413
+ effectiveBaseUrl =
3414
+ (probe.preferredFormat && probe.baseUrlByFormat?.[probe.preferredFormat]) ||
3415
+ effectiveOpenAIBaseUrl ||
3416
+ effectiveClaudeBaseUrl ||
3417
+ endpointCandidates[0] ||
3418
+ effectiveBaseUrl;
3419
+ if ((probe.models || []).length > 0) {
3420
+ effectiveModels = effectiveModels.length > 0
3421
+ ? effectiveModels.filter((model) => (probe.models || []).includes(model))
3422
+ : [...probe.models];
3423
+ }
3424
+ } else {
3425
+ const probeBaseUrlByFormat = {};
3426
+ if (effectiveOpenAIBaseUrl) probeBaseUrlByFormat.openai = effectiveOpenAIBaseUrl;
3427
+ if (effectiveClaudeBaseUrl) probeBaseUrlByFormat.claude = effectiveClaudeBaseUrl;
3428
+
3429
+ probe = await probeProvider({
3430
+ baseUrl: effectiveBaseUrl || endpointCandidates[0],
3431
+ baseUrlByFormat: Object.keys(probeBaseUrlByFormat).length > 0 ? probeBaseUrlByFormat : undefined,
3432
+ apiKey: input.apiKey,
3433
+ headers: input.headers,
3434
+ onProgress: reportProgress
3435
+ });
3436
+ }
3437
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
3438
+ if (line) {
3439
+ const tookMs = Date.now() - startedAt;
3440
+ line(`Auto-discovery finished in ${(tookMs / 1000).toFixed(1)}s.`);
3441
+ }
3442
+ selectedFormat = probe.preferredFormat || selectedFormat;
3443
+ if (probe?.manualFallbackRecommended) {
3444
+ if (!canPrompt()) {
3445
+ const scope = probe?.failureScope === "full" ? "full" : "partial";
3446
+ return {
3447
+ ok: false,
3448
+ mode: context.mode,
3449
+ exitCode: EXIT_FAILURE,
3450
+ errorMessage: scope === "full"
3451
+ ? "Auto-discovery failed fully (no working endpoint/format detected). Re-run interactively for manual fallback or use --skip-probe=true with explicit endpoint/format."
3452
+ : "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."
3453
+ };
3454
+ }
3455
+ const fallback = await runProbeManualFallback(context, {
3456
+ probe,
3457
+ selectedFormat,
3458
+ effectiveOpenAIBaseUrl,
3459
+ effectiveClaudeBaseUrl,
3460
+ effectiveModels
3461
+ });
3462
+ selectedFormat = fallback.selectedFormat;
3463
+ effectiveOpenAIBaseUrl = fallback.effectiveOpenAIBaseUrl;
3464
+ effectiveClaudeBaseUrl = fallback.effectiveClaudeBaseUrl;
3465
+ effectiveModels = fallback.effectiveModels;
3466
+ effectiveBaseUrl =
3467
+ (selectedFormat === "openai" ? effectiveOpenAIBaseUrl : "") ||
3468
+ (selectedFormat === "claude" ? effectiveClaudeBaseUrl : "") ||
3469
+ effectiveOpenAIBaseUrl ||
3470
+ effectiveClaudeBaseUrl ||
3471
+ effectiveBaseUrl ||
3472
+ endpointCandidates[0];
3473
+ } else if (!probe.ok) {
3474
+ if (canPrompt()) {
3475
+ const continueWithoutProbe = await context.prompts.confirm({
3476
+ message: "Probe failed to confirm working endpoint/model support. Save provider anyway?",
3477
+ initialValue: false
3478
+ });
3479
+ if (!continueWithoutProbe) {
3480
+ return {
3481
+ ok: false,
3482
+ mode: context.mode,
3483
+ exitCode: EXIT_FAILURE,
3484
+ errorMessage: "Config cancelled because provider probe failed."
3485
+ };
3486
+ }
3487
+
3488
+ selectedFormat = await promptProviderFormat(context, {
3489
+ message: "Probe could not confirm a working format. Choose primary provider format",
3490
+ initialFormat: selectedFormat
3491
+ });
3492
+ } else {
3493
+ return {
3494
+ ok: false,
3495
+ mode: context.mode,
3496
+ exitCode: EXIT_FAILURE,
3497
+ errorMessage: "Provider probe failed. Provide valid endpoints/models or use --skip-probe=true to force save."
3498
+ };
3499
+ }
3500
+ }
3501
+ }
3502
+
3503
+ if (!shouldProbe) {
3504
+ if (!effectiveBaseUrl && endpointCandidates.length > 0) {
3505
+ effectiveBaseUrl = endpointCandidates[0];
3506
+ }
3507
+ if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length === 1 && selectedFormat) {
3508
+ if (selectedFormat === "openai") effectiveOpenAIBaseUrl = endpointCandidates[0];
3509
+ if (selectedFormat === "claude") effectiveClaudeBaseUrl = endpointCandidates[0];
3510
+ }
3511
+ if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length > 1) {
3512
+ return {
3513
+ ok: false,
3514
+ mode: context.mode,
3515
+ exitCode: EXIT_VALIDATION,
3516
+ errorMessage: "Multiple endpoints require probe mode (recommended) or explicit --openai-base-url/--claude-base-url."
3517
+ };
3518
+ }
3519
+ }
2808
3520
  }
2809
3521
 
2810
3522
  const effectiveFormat = isSubscriptionProvider
@@ -2844,24 +3556,17 @@ async function doUpsertProvider(context) {
2844
3556
  });
2845
3557
 
2846
3558
  await writeConfigFile(nextConfig, input.configPath);
3559
+ const savedProvider = (nextConfig.providers || []).find((entry) => entry.id === provider.id) || provider;
2847
3560
  return {
2848
3561
  ok: true,
2849
3562
  mode: context.mode,
2850
3563
  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")
3564
+ data: buildProviderSavedReport({
3565
+ provider: savedProvider,
3566
+ configPath: input.configPath,
3567
+ probe,
3568
+ masterKey: nextConfig.masterKey ? maskSecret(nextConfig.masterKey) : "(not set)"
3569
+ })
2865
3570
  };
2866
3571
  }
2867
3572
 
@@ -2934,13 +3639,16 @@ async function doMigrateConfig(context) {
2934
3639
  ok: true,
2935
3640
  mode: context.mode,
2936
3641
  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")
3642
+ data: buildOperationReport(
3643
+ migration.changed ? "Config Migration Completed" : "Config Already Up To Date",
3644
+ [
3645
+ ["Config File", configPath],
3646
+ ["Previous Version", String(migration.beforeVersion)],
3647
+ ["Current Version", String(migration.afterVersion)],
3648
+ ["Backup Created", formatYesNo(Boolean(migration.backupPath))],
3649
+ ["Backup File", migration.backupPath || "(not created)"]
3650
+ ]
3651
+ )
2944
3652
  };
2945
3653
  }
2946
3654
 
@@ -2958,8 +3666,7 @@ async function doRemoveProvider(context) {
2958
3666
  message: "Remove provider",
2959
3667
  options: config.providers.map((provider) => ({
2960
3668
  value: provider.id,
2961
- label: provider.id,
2962
- hint: `${provider.models.length} model(s)`
3669
+ label: provider.id
2963
3670
  }))
2964
3671
  });
2965
3672
  }
@@ -3009,8 +3716,7 @@ async function doRemoveModel(context) {
3009
3716
  message: "Select provider",
3010
3717
  options: config.providers.map((provider) => ({
3011
3718
  value: provider.id,
3012
- label: provider.id,
3013
- hint: `${provider.models.length} model(s)`
3719
+ label: provider.id
3014
3720
  }))
3015
3721
  });
3016
3722
  }
@@ -3079,8 +3785,7 @@ async function doSetModelFallbacks(context) {
3079
3785
  message: "Select provider for silent-fallback",
3080
3786
  options: config.providers.map((provider) => ({
3081
3787
  value: provider.id,
3082
- label: provider.id,
3083
- hint: `${provider.models.length} model(s)`
3788
+ label: provider.id
3084
3789
  }))
3085
3790
  });
3086
3791
  }
@@ -3165,14 +3870,24 @@ async function doSetModelFallbacks(context) {
3165
3870
  }
3166
3871
 
3167
3872
  await writeConfigFile(updated.config, configPath);
3873
+ const routeRef = `${providerId}/${updated.modelId || modelId}`;
3874
+ const fallbackRows = (updated.fallbackModels || []).map((entry) => [entry]);
3168
3875
  return {
3169
3876
  ok: true,
3170
3877
  mode: context.mode,
3171
3878
  exitCode: EXIT_SUCCESS,
3172
- data: [
3173
- `Updated silent-fallback models for '${providerId}/${updated.modelId || modelId}'.`,
3174
- `fallbacks=${(updated.fallbackModels || []).join(", ") || "(none)"}`
3175
- ].join("\n")
3879
+ data: buildOperationReport(
3880
+ "Model Silent Fallbacks Updated",
3881
+ [
3882
+ ["Source Model Route", routeRef],
3883
+ ["Fallback Count", String((updated.fallbackModels || []).length)]
3884
+ ],
3885
+ [
3886
+ "Fallback Routes\n" + renderAsciiTable(["Model Route"], fallbackRows, {
3887
+ emptyMessage: "No fallback routes configured."
3888
+ })
3889
+ ]
3890
+ )
3176
3891
  };
3177
3892
  }
3178
3893
 
@@ -3219,8 +3934,7 @@ async function doUpsertModelAlias(context) {
3219
3934
  { value: "__new__", label: "Create new alias" },
3220
3935
  ...aliasIds.map((id) => ({
3221
3936
  value: id,
3222
- label: `Edit ${id}`,
3223
- hint: `${(aliases[id]?.targets || []).length} target(s)`
3937
+ label: `Edit ${id}`
3224
3938
  }))
3225
3939
  ]
3226
3940
  });
@@ -3249,8 +3963,7 @@ async function doUpsertModelAlias(context) {
3249
3963
  message: "Model alias routing strategy",
3250
3964
  options: MODEL_ROUTING_STRATEGY_OPTIONS.map((option) => ({
3251
3965
  value: option.value,
3252
- label: option.label,
3253
- hint: option.hint
3966
+ label: option.label
3254
3967
  })),
3255
3968
  initialValue: selectedStrategy
3256
3969
  });
@@ -3293,15 +4006,13 @@ async function doUpsertModelAlias(context) {
3293
4006
  }
3294
4007
 
3295
4008
  await writeConfigFile(updated.config, configPath);
4009
+ const savedAliasId = updated.aliasId || aliasId;
4010
+ const savedAlias = updated.config.modelAliases?.[savedAliasId];
3296
4011
  return {
3297
4012
  ok: true,
3298
4013
  mode: context.mode,
3299
4014
  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")
4015
+ data: buildModelAliasSavedReport(savedAliasId, savedAlias)
3305
4016
  };
3306
4017
  }
3307
4018
 
@@ -3324,15 +4035,13 @@ async function doUpsertModelAlias(context) {
3324
4035
  }
3325
4036
 
3326
4037
  await writeConfigFile(updated.config, configPath);
4038
+ const savedAliasId = updated.aliasId || aliasId;
4039
+ const savedAlias = updated.config.modelAliases?.[savedAliasId];
3327
4040
  return {
3328
4041
  ok: true,
3329
4042
  mode: context.mode,
3330
4043
  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")
4044
+ data: buildModelAliasSavedReport(savedAliasId, savedAlias)
3336
4045
  };
3337
4046
  }
3338
4047
 
@@ -3385,37 +4094,32 @@ function printRateLimitBucketIntro(context) {
3385
4094
  }
3386
4095
 
3387
4096
  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");
4097
+ return buildProviderRateLimitReport({
4098
+ title: "Rate-Limit Bucket Review",
4099
+ providerId: provider?.id,
4100
+ rateLimits: provider?.rateLimits || []
4101
+ });
3397
4102
  }
3398
4103
 
3399
4104
  function buildRateLimitBucketPromptOptions(provider) {
3400
4105
  return (provider?.rateLimits || []).map((bucket) => ({
3401
4106
  value: bucket.id,
3402
- label: `${formatRateLimitBucketLabel(bucket)} (${summarizeRateLimitBucketCap(bucket)})`,
3403
- hint: formatRateLimitBucketScopeLabel(bucket)
4107
+ label: `${formatRateLimitBucketLabel(bucket)} (${summarizeRateLimitBucketCap(bucket)})`
3404
4108
  }));
3405
4109
  }
3406
4110
 
3407
4111
  function buildRateLimitWindowUnitOptions(initialUnit = "") {
3408
4112
  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" }
4113
+ { value: "minute", label: "Minute" },
4114
+ { value: "hour", label: "Hour(s)" },
4115
+ { value: "week", label: "Week" },
4116
+ { value: "month", label: "Month" }
3413
4117
  ];
3414
4118
  if (initialUnit === "day") {
3415
- options.push({ value: "day", label: "Day (legacy)", hint: "Retained for existing configs" });
4119
+ options.push({ value: "day", label: "Day (legacy)" });
3416
4120
  }
3417
4121
  if (initialUnit === "second") {
3418
- options.push({ value: "second", label: "Second (legacy)", hint: "Retained for existing configs" });
4122
+ options.push({ value: "second", label: "Second (legacy)" });
3419
4123
  }
3420
4124
  return options;
3421
4125
  }
@@ -3594,8 +4298,7 @@ async function doSetProviderRateLimits(context) {
3594
4298
  message: "Select provider for rate-limit buckets",
3595
4299
  options: config.providers.map((provider) => ({
3596
4300
  value: provider.id,
3597
- label: provider.id,
3598
- hint: `${(provider.rateLimits || []).length} bucket(s)`
4301
+ label: provider.id
3599
4302
  }))
3600
4303
  });
3601
4304
  }
@@ -3643,7 +4346,16 @@ async function doSetProviderRateLimits(context) {
3643
4346
  if (action === "remove") {
3644
4347
  const bucketOptions = buildRateLimitBucketPromptOptions(provider);
3645
4348
  if (bucketOptions.length === 0) {
3646
- return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no rate-limit buckets.` };
4349
+ return {
4350
+ ok: true,
4351
+ mode: context.mode,
4352
+ exitCode: EXIT_SUCCESS,
4353
+ data: buildProviderRateLimitReport({
4354
+ title: "Rate-Limit Bucket Review",
4355
+ providerId,
4356
+ rateLimits: provider.rateLimits || []
4357
+ })
4358
+ };
3647
4359
  }
3648
4360
 
3649
4361
  const selectedBucketId = await context.prompts.select({
@@ -3676,14 +4388,27 @@ async function doSetProviderRateLimits(context) {
3676
4388
  ok: true,
3677
4389
  mode: context.mode,
3678
4390
  exitCode: EXIT_SUCCESS,
3679
- data: `Removed rate-limit bucket '${formatRateLimitBucketLabel(selectedBucket, { includeId: true })}' from '${providerId}'.`
4391
+ data: buildProviderRateLimitReport({
4392
+ title: `Rate-Limit Bucket Removed: ${formatRateLimitBucketLabel(selectedBucket, { includeId: true })}`,
4393
+ providerId,
4394
+ rateLimits: removed.rateLimits || []
4395
+ })
3680
4396
  };
3681
4397
  }
3682
4398
 
3683
4399
  if (action === "edit") {
3684
4400
  const bucketOptions = buildRateLimitBucketPromptOptions(provider);
3685
4401
  if (bucketOptions.length === 0) {
3686
- return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no rate-limit buckets.` };
4402
+ return {
4403
+ ok: true,
4404
+ mode: context.mode,
4405
+ exitCode: EXIT_SUCCESS,
4406
+ data: buildProviderRateLimitReport({
4407
+ title: "Rate-Limit Bucket Review",
4408
+ providerId,
4409
+ rateLimits: provider.rateLimits || []
4410
+ })
4411
+ };
3687
4412
  }
3688
4413
 
3689
4414
  const selectedBucketId = await context.prompts.select({
@@ -3729,10 +4454,11 @@ async function doSetProviderRateLimits(context) {
3729
4454
  ok: true,
3730
4455
  mode: context.mode,
3731
4456
  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")
4457
+ data: buildProviderRateLimitReport({
4458
+ title: `Rate-Limit Bucket Updated: ${formatRateLimitBucketLabel(wizard.bucket, { includeId: true })}`,
4459
+ providerId,
4460
+ rateLimits: updated.rateLimits || []
4461
+ })
3736
4462
  };
3737
4463
  }
3738
4464
 
@@ -3789,10 +4515,11 @@ async function doSetProviderRateLimits(context) {
3789
4515
  ok: true,
3790
4516
  mode: context.mode,
3791
4517
  exitCode: EXIT_SUCCESS,
3792
- data: [
3793
- `Updated rate-limit buckets for '${providerId}'.`,
3794
- `bucketCount=${updated.rateLimits?.length || 0}`
3795
- ].join("\n")
4518
+ data: buildProviderRateLimitReport({
4519
+ title: "Rate-Limit Buckets Updated",
4520
+ providerId,
4521
+ rateLimits: updated.rateLimits || []
4522
+ })
3796
4523
  };
3797
4524
  }
3798
4525
 
@@ -3800,7 +4527,16 @@ async function doSetProviderRateLimits(context) {
3800
4527
  if (!bucketId) {
3801
4528
  const bucketOptions = buildRateLimitBucketPromptOptions(provider);
3802
4529
  if (bucketOptions.length === 0) {
3803
- return { ok: true, mode: context.mode, exitCode: EXIT_SUCCESS, data: `Provider '${providerId}' has no rate-limit buckets.` };
4530
+ return {
4531
+ ok: true,
4532
+ mode: context.mode,
4533
+ exitCode: EXIT_SUCCESS,
4534
+ data: buildProviderRateLimitReport({
4535
+ title: "Rate-Limit Bucket Review",
4536
+ providerId,
4537
+ rateLimits: provider.rateLimits || []
4538
+ })
4539
+ };
3804
4540
  }
3805
4541
  bucketId = await context.prompts.select({
3806
4542
  message: `Remove rate-limit bucket from ${providerId}`,
@@ -3921,7 +4657,11 @@ async function doSetProviderRateLimits(context) {
3921
4657
  ok: true,
3922
4658
  mode: context.mode,
3923
4659
  exitCode: EXIT_SUCCESS,
3924
- data: `Removed rate-limit bucket '${bucketId}' from '${providerId}'.`
4660
+ data: buildProviderRateLimitReport({
4661
+ title: `Rate-Limit Bucket Removed: ${bucketId}`,
4662
+ providerId,
4663
+ rateLimits: removed.rateLimits || []
4664
+ })
3925
4665
  };
3926
4666
  }
3927
4667
 
@@ -3966,10 +4706,11 @@ async function doSetProviderRateLimits(context) {
3966
4706
  ok: true,
3967
4707
  mode: context.mode,
3968
4708
  exitCode: EXIT_SUCCESS,
3969
- data: [
3970
- `Updated rate-limit buckets for '${providerId}'.`,
3971
- `bucketCount=${updated.rateLimits?.length || 0}`
3972
- ].join("\n")
4709
+ data: buildProviderRateLimitReport({
4710
+ title: "Rate-Limit Buckets Updated",
4711
+ providerId,
4712
+ rateLimits: updated.rateLimits || []
4713
+ })
3973
4714
  };
3974
4715
  }
3975
4716
 
@@ -4017,10 +4758,17 @@ async function doSetMasterKey(context) {
4017
4758
  ok: true,
4018
4759
  mode: context.mode,
4019
4760
  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")
4761
+ data: buildOperationReport(
4762
+ "Worker Master Key Updated",
4763
+ [
4764
+ ["Config File", configPath],
4765
+ ["Stored Key", maskSecret(masterKey)],
4766
+ ["Generated In This Run", formatYesNo(keyGenerated)]
4767
+ ],
4768
+ keyGenerated
4769
+ ? [renderListSection("Generated Key (Copy Now)", [masterKey])]
4770
+ : []
4771
+ )
4024
4772
  };
4025
4773
  }
4026
4774
 
@@ -4074,14 +4822,18 @@ async function doStartupInstall(context) {
4074
4822
  ok: true,
4075
4823
  mode: context.mode,
4076
4824
  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")
4825
+ data: buildOperationReport(
4826
+ "OS Startup Service Installed",
4827
+ [
4828
+ ["Startup Manager", result.manager || "Unknown"],
4829
+ ["Service Name", result.serviceId || "Unknown"],
4830
+ ["Service File", result.filePath || "(not provided)"],
4831
+ ["Start Target", `http://${host}:${port}`],
4832
+ ["Config Hot Reload", watchConfig ? "Enabled" : "Disabled"],
4833
+ ["Binary Auto-Restart", watchBinary ? "Enabled" : "Disabled"],
4834
+ ["Local API Auth", requireAuth ? "Required (Master Key)" : "Disabled"]
4835
+ ]
4836
+ )
4085
4837
  };
4086
4838
  }
4087
4839
 
@@ -4101,11 +4853,14 @@ async function doStartupUninstall(context) {
4101
4853
  ok: true,
4102
4854
  mode: context.mode,
4103
4855
  exitCode: EXIT_SUCCESS,
4104
- data: [
4105
- `Uninstalled OS startup (${result.manager})`,
4106
- `service=${result.serviceId}`,
4107
- `file=${result.filePath}`
4108
- ].join("\n")
4856
+ data: buildOperationReport(
4857
+ "OS Startup Service Uninstalled",
4858
+ [
4859
+ ["Startup Manager", result.manager || "Unknown"],
4860
+ ["Service Name", result.serviceId || "Unknown"],
4861
+ ["Service File", result.filePath || "(not provided)"]
4862
+ ]
4863
+ )
4109
4864
  };
4110
4865
  }
4111
4866
 
@@ -4115,14 +4870,17 @@ async function doStartupStatus(context) {
4115
4870
  ok: true,
4116
4871
  mode: context.mode,
4117
4872
  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")
4873
+ data: buildOperationReport(
4874
+ "OS Startup Service Status",
4875
+ [
4876
+ ["Startup Manager", status.manager || "Unknown"],
4877
+ ["Service Name", status.serviceId || "Unknown"],
4878
+ ["Installed", formatYesNo(Boolean(status.installed))],
4879
+ ["Running", formatYesNo(Boolean(status.running))],
4880
+ ["Service File", status.filePath || "(not provided)"],
4881
+ ["Details", status.detail ? String(status.detail).trim() : "(none)"]
4882
+ ]
4883
+ )
4126
4884
  };
4127
4885
  }
4128
4886
 
@@ -4261,11 +5019,14 @@ async function runStopAction(context) {
4261
5019
  ok: true,
4262
5020
  mode: context.mode,
4263
5021
  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")
5022
+ data: buildOperationReport(
5023
+ "Router Stopped",
5024
+ [
5025
+ ["Stop Mode", "Startup-managed service"],
5026
+ ["Startup Manager", stopped.detail?.manager || "Unknown"],
5027
+ ["Service Name", stopped.detail?.serviceId || "Unknown"]
5028
+ ]
5029
+ )
4269
5030
  };
4270
5031
  }
4271
5032
 
@@ -4274,7 +5035,14 @@ async function runStopAction(context) {
4274
5035
  ok: true,
4275
5036
  mode: context.mode,
4276
5037
  exitCode: EXIT_SUCCESS,
4277
- data: `Stopped llm-router process pid=${stopped.detail?.pid || "unknown"} (${stopped.detail?.signal || "SIGTERM"}).`
5038
+ data: buildOperationReport(
5039
+ "Router Stopped",
5040
+ [
5041
+ ["Stop Mode", "Manual process"],
5042
+ ["Process ID", String(stopped.detail?.pid || "Unknown")],
5043
+ ["Signal", stopped.detail?.signal || "SIGTERM"]
5044
+ ]
5045
+ )
4278
5046
  };
4279
5047
  }
4280
5048
 
@@ -4317,11 +5085,14 @@ async function runReloadAction(context) {
4317
5085
  ok: true,
4318
5086
  mode: context.mode,
4319
5087
  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")
5088
+ data: buildOperationReport(
5089
+ "Router Reloaded",
5090
+ [
5091
+ ["Reload Mode", "Startup-managed service"],
5092
+ ["Startup Manager", result.detail?.manager || "Unknown"],
5093
+ ["Service Name", result.detail?.serviceId || "Unknown"]
5094
+ ]
5095
+ )
4325
5096
  };
4326
5097
  }
4327
5098
 
@@ -4754,7 +5525,7 @@ async function runAiHelpAction(context) {
4754
5525
  const suggestions = [];
4755
5526
  if (providerCount === 0) {
4756
5527
  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");
5528
+ 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 (or use --subscription-type=claude-code).");
4758
5529
  } else {
4759
5530
  const providersWithoutModels = providers
4760
5531
  .filter((provider) => (provider.models || []).filter((model) => model && model.enabled !== false).length === 0)
@@ -4881,9 +5652,11 @@ async function runAiHelpAction(context) {
4881
5652
  "## LLM-ROUTER CONFIG WORKFLOWS (CLI)",
4882
5653
  "1. Upsert provider + models:",
4883
5654
  " llm-router config --operation=upsert-provider --provider-id=<id> --name=\"<name>\" --endpoints=<url1,url2> --api-key=<key> --models=<model1,model2>",
4884
- "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",
4886
- " llm-router subscription login --profile=default",
5655
+ "1b. Upsert subscription provider (OAuth-backed ChatGPT Codex / Claude Code):",
5656
+ " llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
5657
+ " llm-router config --operation=upsert-provider --provider-id=claude-sub --name=\"Claude Sub\" --type=subscription --subscription-type=claude-code --subscription-profile=default",
5658
+ " llm-router subscription login --subscription-type=chatgpt-codex --profile=default",
5659
+ " llm-router subscription login --subscription-type=claude-code --profile=default",
4887
5660
  "2. Upsert model alias/group:",
4888
5661
  " llm-router config --operation=upsert-model-alias --alias-id=<alias> --strategy=auto --targets=<provider/model,...>",
4889
5662
  "3. Set provider rate limit bucket:",
@@ -5197,48 +5970,75 @@ async function runDeployAction(context) {
5197
5970
  await fsPromises.writeFile(resolvedOut, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
5198
5971
 
5199
5972
  if (exportOnly) {
5973
+ const exportWarnings = [
5974
+ ...largeConfigWarningLines,
5975
+ mustConfirmLargeConfig
5976
+ ? "Manual deploy may fail on Cloudflare Free tier unless you reduce config size."
5977
+ : ""
5978
+ ].filter(Boolean);
5200
5979
  return {
5201
5980
  ok: true,
5202
5981
  mode: context.mode,
5203
5982
  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")
5983
+ data: buildOperationReport(
5984
+ "Worker Config Exported",
5985
+ [
5986
+ ["Export File", resolvedOut],
5987
+ ["Payload Size (bytes)", new Intl.NumberFormat("en-US").format(payloadBytes)],
5988
+ ["Cloudflare Tier", `${formatCloudflareTierLabel(tierReport)} (${tierReport.reason || "unknown"})`],
5989
+ ["Environment", cfEnv || "(default)"]
5990
+ ],
5991
+ [
5992
+ renderListSection("Warnings", exportWarnings, { emptyMessage: "None." }),
5993
+ renderListSection("Next Command", [
5994
+ `wrangler secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""} < ${resolvedOut}`
5995
+ ])
5996
+ ]
5997
+ )
5212
5998
  };
5213
5999
  }
5214
6000
  }
5215
6001
 
5216
6002
  if (dryRun) {
6003
+ const dryRunWarnings = [
6004
+ allowWeakMasterKey ? "Weak master key override is enabled." : "",
6005
+ ...largeConfigWarningLines,
6006
+ mustConfirmLargeConfig
6007
+ ? "Interactive deploy requires explicit confirmation (default: No)."
6008
+ : "",
6009
+ mustConfirmLargeConfig
6010
+ ? "Use --allow-large-config=true to bypass this check in non-interactive mode."
6011
+ : "",
6012
+ generatedDeployMasterKey ? "Generated a deploy-time master key (not written to local config)." : ""
6013
+ ].filter(Boolean);
6014
+ const dryRunCommands = [
6015
+ `wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} secret put LLM_ROUTER_CONFIG_JSON${cfEnv ? ` --env ${cfEnv}` : ""}`,
6016
+ `wrangler${wranglerConfigPath ? ` --config ${wranglerConfigPath}` : ""} deploy${cfEnv ? ` --env ${cfEnv}` : ""}`
6017
+ ];
5217
6018
  return {
5218
6019
  ok: true,
5219
6020
  mode: context.mode,
5220
6021
  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")
6022
+ data: buildOperationReport(
6023
+ "Cloudflare Deploy Dry Run",
6024
+ [
6025
+ ["Project Directory", projectDir],
6026
+ ["Wrangler Config", wranglerConfigPath || "(default)"],
6027
+ ["Cloudflare API Token Source", cloudflareApiTokenSource === "none"
6028
+ ? "(not set)"
6029
+ : (cloudflareApiTokenSource === "prompt" ? "Prompt input" : `Environment (${cloudflareApiTokenSource})`)],
6030
+ ["Cloudflare Account ID", cloudflareAccountId || "(not set)"],
6031
+ ["Cloudflare Tier", `${formatCloudflareTierLabel(tierReport)} (${tierReport.reason || "unknown"})`],
6032
+ ["Environment", cfEnv || "(default)"],
6033
+ ["Payload Size (bytes)", new Intl.NumberFormat("en-US").format(payloadBytes)],
6034
+ ["Generated Deploy Key", formatYesNo(generatedDeployMasterKey)],
6035
+ ["Weak Key Override", formatYesNo(allowWeakMasterKey)]
6036
+ ],
6037
+ [
6038
+ renderListSection("Warnings", dryRunWarnings, { emptyMessage: "None." }),
6039
+ renderListSection("Commands", dryRunCommands)
6040
+ ]
6041
+ )
5242
6042
  };
5243
6043
  }
5244
6044
 
@@ -5367,15 +6167,36 @@ async function runDeployAction(context) {
5367
6167
  ok: true,
5368
6168
  mode: context.mode,
5369
6169
  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")
6170
+ data: buildOperationReport(
6171
+ "Cloudflare Deployment Completed",
6172
+ [
6173
+ ["Project Directory", projectDir],
6174
+ ["Environment", cfEnv || "(default)"],
6175
+ ["Deploy Target", deployUsesWorkersDev ? "workers.dev" : (deployHost ? deployHost : "custom route")],
6176
+ ["Deploy Zone", deployZoneName || "(not set)"],
6177
+ ["Generated Deploy Key", formatYesNo(generatedDeployMasterKey)]
6178
+ ],
6179
+ [
6180
+ renderListSection(
6181
+ "Notes",
6182
+ [
6183
+ generatedDeployMasterKey
6184
+ ? "Generated a deploy-time master key. Persist it with `llm-router config --operation=set-master-key --master-key=...` if needed."
6185
+ : "",
6186
+ wranglerTargetMessage
6187
+ ],
6188
+ { emptyMessage: "None." }
6189
+ ),
6190
+ renderListSection("Wrangler Output", [secretResult.stdout.trim(), deployResult.stdout.trim()], { emptyMessage: "No additional output." }),
6191
+ postDeployGuide
6192
+ ? renderListSection("Post-Deploy Checks", [
6193
+ `dig +short ${deployHost} @1.1.1.1`,
6194
+ `curl -I https://${deployHost}/anthropic`,
6195
+ `Claude Code base URL: https://${deployHost}/anthropic (no :8787)`
6196
+ ])
6197
+ : ""
6198
+ ]
6199
+ )
5379
6200
  };
5380
6201
  } finally {
5381
6202
  if (typeof cleanupWranglerConfig === "function") {
@@ -5503,16 +6324,31 @@ async function runWorkerKeyAction(context) {
5503
6324
  ok: true,
5504
6325
  mode: context.mode,
5505
6326
  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")
6327
+ data: buildOperationReport(
6328
+ "Worker Key Dry Run",
6329
+ [
6330
+ ["Project Directory", projectDir],
6331
+ ["Environment", cfEnv || "(default)"],
6332
+ ["Target Secret", "LLM_ROUTER_MASTER_KEY"],
6333
+ ["Secret Exists", exists === null ? "Unknown" : formatYesNo(Boolean(exists))],
6334
+ ["Stored Key", maskSecret(masterKey)],
6335
+ ["Generated In This Run", formatYesNo(keyGenerated)],
6336
+ ["Weak Key Override", formatYesNo(allowWeakMasterKey)]
6337
+ ],
6338
+ [
6339
+ renderListSection(
6340
+ "Warnings",
6341
+ [
6342
+ allowWeakMasterKey ? "Weak master key override is enabled." : "",
6343
+ keyGenerated ? "Generated key for this operation." : ""
6344
+ ],
6345
+ { emptyMessage: "None." }
6346
+ ),
6347
+ renderListSection("Command", [
6348
+ `wrangler secret put LLM_ROUTER_MASTER_KEY${cfEnv ? ` --env ${cfEnv}` : ""}`
6349
+ ])
6350
+ ]
6351
+ )
5516
6352
  };
5517
6353
  }
5518
6354
 
@@ -5547,14 +6383,22 @@ async function runWorkerKeyAction(context) {
5547
6383
  ok: true,
5548
6384
  mode: context.mode,
5549
6385
  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")
6386
+ data: buildOperationReport(
6387
+ `${exists === true ? "Worker Key Updated" : "Worker Key Set"}`,
6388
+ [
6389
+ ["Target Secret", "LLM_ROUTER_MASTER_KEY"],
6390
+ ["Environment", cfEnv || "(default)"],
6391
+ ["Project Directory", projectDir],
6392
+ ["Stored Key", maskSecret(masterKey)],
6393
+ ["Generated In This Run", formatYesNo(keyGenerated)]
6394
+ ],
6395
+ [
6396
+ keyGenerated
6397
+ ? renderListSection("Generated Key (Copy Now)", [masterKey])
6398
+ : "",
6399
+ renderListSection("Wrangler Output", [putResult.stdout.trim()], { emptyMessage: "No additional output." })
6400
+ ]
6401
+ )
5558
6402
  };
5559
6403
  }
5560
6404
 
@@ -5570,12 +6414,32 @@ async function runSubscriptionLoginAction(context) {
5570
6414
  const args = context.args || {};
5571
6415
  const profile = String(readArg(args, ["profile", "profileId"], "default") || "default").trim();
5572
6416
  const deviceCode = toBoolean(readArg(args, ["device-code", "deviceCode"], false), false);
6417
+ const rawSubscriptionType = String(readArg(args, ["subscription-type", "subscriptionType"], "") || "").trim();
6418
+ const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
6419
+ if (rawSubscriptionType && !normalizedSubscriptionType) {
6420
+ return {
6421
+ mode: context.mode,
6422
+ exitCode: EXIT_VALIDATION,
6423
+ data: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
6424
+ };
6425
+ }
6426
+ const subscriptionType = normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
6427
+ const subscriptionPreset = getSubscriptionProviderPreset(subscriptionType);
6428
+ const subscriptionLabel = subscriptionPreset?.label || subscriptionType;
6429
+ if (deviceCode && subscriptionType === SUBSCRIPTION_TYPE_CLAUDE_CODE) {
6430
+ return {
6431
+ mode: context.mode,
6432
+ exitCode: EXIT_VALIDATION,
6433
+ data: "Device code flow is not supported for subscription-type=claude-code. Use browser OAuth login."
6434
+ };
6435
+ }
5573
6436
 
5574
6437
  // Import subscription auth functions
5575
6438
  const { loginWithBrowser, loginWithDeviceCode } = await import("../runtime/subscription-auth.js");
5576
6439
 
5577
6440
  const lines = [];
5578
6441
  lines.push(`Logging into subscription profile: ${profile}`);
6442
+ lines.push(`Subscription provider: ${subscriptionLabel} (${subscriptionType})`);
5579
6443
  lines.push("");
5580
6444
 
5581
6445
  try {
@@ -5584,6 +6448,7 @@ async function runSubscriptionLoginAction(context) {
5584
6448
  lines.push("");
5585
6449
 
5586
6450
  const success = await loginWithDeviceCode(profile, {
6451
+ subscriptionType,
5587
6452
  onCode: ({ userCode, verificationUri, expiresIn }) => {
5588
6453
  lines.push(`1. Go to: ${verificationUri}`);
5589
6454
  lines.push(`2. Enter code: ${userCode}`);
@@ -5603,8 +6468,14 @@ async function runSubscriptionLoginAction(context) {
5603
6468
  lines.push("");
5604
6469
 
5605
6470
  const success = await loginWithBrowser(profile, {
5606
- onUrl: (url) => {
5607
- lines.push(`If browser doesn't open, visit: ${url}`);
6471
+ subscriptionType,
6472
+ onUrl: (url, meta = {}) => {
6473
+ if (meta?.openedBrowser === true) {
6474
+ lines.push("Opened browser for OAuth login.");
6475
+ lines.push(`Fallback URL: ${url}`);
6476
+ return;
6477
+ }
6478
+ lines.push(`Open this URL to login: ${url}`);
5608
6479
  }
5609
6480
  });
5610
6481
 
@@ -5638,15 +6509,28 @@ async function runSubscriptionLoginAction(context) {
5638
6509
  async function runSubscriptionLogoutAction(context) {
5639
6510
  const args = context.args || {};
5640
6511
  const profile = String(readArg(args, ["profile", "profileId"], "default") || "default").trim();
6512
+ const rawSubscriptionType = String(readArg(args, ["subscription-type", "subscriptionType"], "") || "").trim();
6513
+ const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
6514
+ if (rawSubscriptionType && !normalizedSubscriptionType) {
6515
+ return {
6516
+ mode: context.mode,
6517
+ exitCode: EXIT_VALIDATION,
6518
+ data: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
6519
+ };
6520
+ }
6521
+ const subscriptionType = normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
6522
+ const subscriptionPreset = getSubscriptionProviderPreset(subscriptionType);
6523
+ const subscriptionLabel = subscriptionPreset?.label || subscriptionType;
5641
6524
 
5642
6525
  // Import subscription auth functions
5643
6526
  const { logout } = await import("../runtime/subscription-auth.js");
5644
6527
 
5645
6528
  const lines = [];
5646
6529
  lines.push(`Logging out subscription profile: ${profile}`);
6530
+ lines.push(`Subscription provider: ${subscriptionLabel} (${subscriptionType})`);
5647
6531
 
5648
6532
  try {
5649
- await logout(profile);
6533
+ await logout(profile, { subscriptionType });
5650
6534
  lines.push("");
5651
6535
  lines.push(`✓ Successfully logged out profile '${profile}'.`);
5652
6536
 
@@ -5673,16 +6557,30 @@ async function runSubscriptionLogoutAction(context) {
5673
6557
  async function runSubscriptionStatusAction(context) {
5674
6558
  const args = context.args || {};
5675
6559
  const profile = String(readArg(args, ["profile", "profileId"], "") || "").trim();
6560
+ const rawSubscriptionType = String(readArg(args, ["subscription-type", "subscriptionType"], "") || "").trim();
6561
+ const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
6562
+ if (rawSubscriptionType && !normalizedSubscriptionType) {
6563
+ return {
6564
+ mode: context.mode,
6565
+ exitCode: EXIT_VALIDATION,
6566
+ data: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
6567
+ };
6568
+ }
6569
+ const subscriptionType = normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
6570
+ const subscriptionPreset = getSubscriptionProviderPreset(subscriptionType);
6571
+ const subscriptionLabel = subscriptionPreset?.label || subscriptionType;
5676
6572
 
5677
6573
  // Import subscription auth functions
5678
6574
  const { getAuthStatus, listTokenProfiles } = await import("../runtime/subscription-auth.js");
5679
6575
 
5680
6576
  const lines = [];
6577
+ lines.push(`Subscription provider: ${subscriptionLabel} (${subscriptionType})`);
6578
+ lines.push("");
5681
6579
 
5682
6580
  try {
5683
6581
  if (profile) {
5684
6582
  // Show status for specific profile
5685
- const status = await getAuthStatus(profile);
6583
+ const status = await getAuthStatus(profile, { subscriptionType });
5686
6584
 
5687
6585
  lines.push(`Subscription Profile: ${profile}`);
5688
6586
  lines.push(`Status: ${status.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}`);
@@ -5695,7 +6593,7 @@ async function runSubscriptionStatusAction(context) {
5695
6593
  }
5696
6594
  } else {
5697
6595
  // List all profiles
5698
- const profiles = await listTokenProfiles();
6596
+ const profiles = await listTokenProfiles({ subscriptionType });
5699
6597
 
5700
6598
  lines.push("Subscription Profiles:");
5701
6599
  lines.push("");
@@ -5703,10 +6601,10 @@ async function runSubscriptionStatusAction(context) {
5703
6601
  if (profiles.length === 0) {
5704
6602
  lines.push(" No authenticated profiles found.");
5705
6603
  lines.push("");
5706
- lines.push(" To login: llm-router subscription login --profile=<name>");
6604
+ lines.push(` To login: llm-router subscription login --subscription-type=${subscriptionType} --profile=<name>`);
5707
6605
  } else {
5708
6606
  for (const p of profiles) {
5709
- const status = await getAuthStatus(p);
6607
+ const status = await getAuthStatus(p, { subscriptionType });
5710
6608
  const statusIcon = status.authenticated ? "✓" : "✗";
5711
6609
  lines.push(` ${statusIcon} ${p}`);
5712
6610
  if (status.authenticated && status.expiresAtIso) {
@@ -5881,6 +6779,7 @@ const routerModule = {
5881
6779
  "provider-type",
5882
6780
  "subscription-type",
5883
6781
  "subscription-profile",
6782
+ "device-code",
5884
6783
  "endpoints",
5885
6784
  "base-url",
5886
6785
  "openai-base-url",
@@ -5932,23 +6831,24 @@ const routerModule = {
5932
6831
  summary: "Manage providers, model aliases, rate-limit buckets, master key, and OS startup. TUI by default; commandline via --operation.",
5933
6832
  args: [
5934
6833
  { 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" },
6834
+ { name: "provider-id", required: false, description: "Provider id (lowercase letters/numbers/dashes).", example: "--provider-id=openrouter-primary" },
5936
6835
  { 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" },
6836
+ { name: "type", required: false, description: "Provider type: standard (API key) | subscription (OAuth).", example: "--type=subscription" },
6837
+ { name: "subscription-type", required: false, description: "For --type=subscription. Supported: chatgpt-codex | claude-code. Defaults to chatgpt-codex.", example: "--subscription-type=claude-code" },
6838
+ { name: "subscription-profile", required: false, description: "OAuth token profile for subscription provider (defaults to provider-id).", example: "--subscription-profile=personal" },
6839
+ { name: "device-code", required: false, description: "For subscription OAuth login during upsert: use device-code flow instead of browser (chatgpt-codex only).", example: "--device-code=true" },
6840
+ { 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
6841
  { name: "base-url", required: false, description: "For standard provider: provider base URL.", example: "--base-url=https://openrouter.ai/api/v1" },
5942
6842
  { 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
6843
  { 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
6844
  { 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" },
6845
+ { name: "models", required: false, description: "Model list (comma-separated IDs; strips common log/error noise). Subscription defaults are prefilled by subscription-type and all selected models are live-validated before save.", example: "--models=claude-sonnet-4-6,claude-opus-4-6" },
5946
6846
  { 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" },
6847
+ { 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
6848
  { name: "clear-fallbacks", required: false, description: "Clear all fallback models for set-model-fallbacks.", example: "--clear-fallbacks=true" },
5949
6849
  { name: "alias-id", required: false, description: "Model alias id for upsert/remove alias operations.", example: "--alias-id=chat.default" },
5950
6850
  { 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" },
6851
+ { 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
6852
  { name: "fallback-targets", required: false, description: "Model alias fallback target list with same syntax as --targets.", example: "--fallback-targets=openrouter/gpt-4o" },
5953
6853
  { name: "clear-fallback-targets", required: false, description: "Clear alias fallback target list.", example: "--clear-fallback-targets=true" },
5954
6854
  { name: "alias-metadata", required: false, description: "Optional alias metadata JSON object.", example: "--alias-metadata={\"owner\":\"router-team\"}" },
@@ -5980,8 +6880,10 @@ const routerModule = {
5980
6880
  examples: [
5981
6881
  "llm-router config",
5982
6882
  "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",
5984
- "llm-router subscription login --profile=default",
6883
+ "llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
6884
+ "llm-router config --operation=upsert-provider --provider-id=claude-sub --name=\"Claude Sub\" --type=subscription --subscription-type=claude-code --subscription-profile=default",
6885
+ "llm-router subscription login --subscription-type=chatgpt-codex --profile=default",
6886
+ "llm-router subscription login --subscription-type=claude-code --profile=default",
5985
6887
  "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
6888
  "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",
5987
6889
  "llm-router config --operation=set-provider-rate-limits --provider-id=openrouter --bucket-name=\"6-hours cap\" --bucket-models=all --bucket-requests=600 --bucket-window=hour:6",
@@ -6129,32 +7031,34 @@ const routerModule = {
6129
7031
  tui: { steps: ["subscription-auth"] },
6130
7032
  commandline: {
6131
7033
  requiredArgs: [],
6132
- optionalArgs: ["profile", "device-code"]
7034
+ optionalArgs: ["profile", "device-code", "subscription-type"]
6133
7035
  },
6134
7036
  help: {
6135
- summary: "Manage OAuth authentication for subscription providers (ChatGPT Codex).",
7037
+ summary: "Manage OAuth authentication for subscription providers (ChatGPT Codex and Claude Code).",
6136
7038
  args: [
6137
7039
  { name: "profile", required: false, description: "Subscription profile ID (defaults to 'default').", example: "--profile=personal" },
6138
- { name: "device-code", required: false, description: "Use device code flow instead of browser (for headless environments).", example: "--device-code=true" }
7040
+ { name: "subscription-type", required: false, description: "Subscription provider type: chatgpt-codex | claude-code (defaults to chatgpt-codex).", example: "--subscription-type=claude-code" },
7041
+ { name: "device-code", required: false, description: "Use device code flow instead of browser (headless environments; chatgpt-codex only).", example: "--device-code=true" }
6139
7042
  ],
6140
7043
  examples: [
6141
7044
  "llm-router subscription login",
6142
- "llm-router subscription login --profile=personal",
6143
- "llm-router subscription login --device-code=true",
7045
+ "llm-router subscription login --subscription-type=chatgpt-codex --profile=personal",
7046
+ "llm-router subscription login --subscription-type=claude-code --profile=work",
7047
+ "llm-router subscription login --subscription-type=chatgpt-codex --device-code=true",
6144
7048
  "llm-router subscription logout --profile=personal",
6145
7049
  "llm-router subscription status",
6146
- "llm-router subscription status --profile=personal"
7050
+ "llm-router subscription status --subscription-type=claude-code --profile=personal"
6147
7051
  ],
6148
7052
  useCases: [
6149
7053
  {
6150
7054
  name: "browser login",
6151
- description: "Login to ChatGPT Codex subscription via browser OAuth.",
6152
- command: "llm-router subscription login --profile=personal"
7055
+ description: "Login to subscription provider via browser OAuth.",
7056
+ command: "llm-router subscription login --subscription-type=claude-code --profile=personal"
6153
7057
  },
6154
7058
  {
6155
7059
  name: "device code login",
6156
- description: "Login on headless server using device code flow.",
6157
- command: "llm-router subscription login --device-code=true --profile=server"
7060
+ description: "Login on headless server using device code flow (chatgpt-codex only).",
7061
+ command: "llm-router subscription login --subscription-type=chatgpt-codex --device-code=true --profile=server"
6158
7062
  },
6159
7063
  {
6160
7064
  name: "check status",