@khanglvm/ai-router 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/ai-router",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Generic AI Router Proxy (local + Cloudflare Worker)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -18,7 +18,7 @@
18
18
  "test:provider-smoke": "node ./scripts/provider-smoke-suite.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@levu/snap": "^0.1.1"
21
+ "@levu/snap": "^0.3.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "wrangler": "^3.0.0"
@@ -1,6 +1,7 @@
1
1
  import { promises as fsPromises } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
+ import { SnapTui } from "@levu/snap/dist/index.js";
4
5
  import {
5
6
  applySetupChanges,
6
7
  buildProviderFromSetupInput,
@@ -92,41 +93,439 @@ function providerEndpointsFromConfig(provider) {
92
93
  return parseModelListInput(values.filter(Boolean).join(","));
93
94
  }
94
95
 
96
+ function normalizeNameForCompare(value) {
97
+ return String(value || "").trim().toLowerCase();
98
+ }
99
+
100
+ function findProviderByFriendlyName(providers, name, { excludeId = "" } = {}) {
101
+ const needle = normalizeNameForCompare(name);
102
+ if (!needle) return null;
103
+ const excluded = String(excludeId || "").trim();
104
+ return (providers || []).find((provider) => {
105
+ if (!provider || typeof provider !== "object") return false;
106
+ const sameName = normalizeNameForCompare(provider.name) === needle;
107
+ if (!sameName) return false;
108
+ if (!excluded) return true;
109
+ return String(provider.id || "").trim() !== excluded;
110
+ }) || null;
111
+ }
112
+
113
+ function printProviderInputGuidance(context) {
114
+ if (!canPrompt()) return;
115
+ const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : null;
116
+ const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
117
+ const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : null;
118
+ if (!line) return;
119
+
120
+ info?.("Provider setup tips:");
121
+ line(" - Provider Friendly Name is shown in the management screen and must be unique.");
122
+ line(" - Provider ID is auto-generated by slugifying the friendly name; you can edit it.");
123
+ line(" - Examples:");
124
+ line(" Friendly Name: OpenRouter Primary, RamClouds Production");
125
+ line(" Provider ID: openrouterPrimary, ramcloudsProd");
126
+ line(" API Key: sk-or-v1-xxxxxxxx, sk-ant-api03-xxxxxxxx, sk-xxxxxxxx");
127
+ }
128
+
129
+ function trimOuterPunctuation(value) {
130
+ return String(value || "")
131
+ .trim()
132
+ .replace(/^[\s"'`([{<]+/, "")
133
+ .replace(/[\s"'`)\]}>.,;:]+$/, "")
134
+ .trim();
135
+ }
136
+
137
+ function dedupeList(values) {
138
+ return [...new Set((values || []).filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
139
+ }
140
+
141
+ function tokenizeLooseListInput(raw) {
142
+ if (Array.isArray(raw)) return dedupeList(raw.flatMap((item) => tokenizeLooseListInput(item)));
143
+ const text = String(raw || "")
144
+ .replace(/[\n\r\t]+/g, " ")
145
+ .replace(/[;,]+/g, " ");
146
+ return dedupeList(text.split(/\s+/g));
147
+ }
148
+
149
+ function normalizeEndpointToken(token) {
150
+ let value = trimOuterPunctuation(token);
151
+ if (!value) return "";
152
+
153
+ value = value
154
+ .replace(/^(?:openaiBaseUrl|claudeBaseUrl|anthropicBaseUrl|baseUrl)\s*=\s*/i, "")
155
+ .replace(/^url\s*=\s*/i, "");
156
+
157
+ const urlMatch = value.match(/https?:\/\/[^\s,;'"`<>()\]]+/i);
158
+ if (urlMatch) value = urlMatch[0];
159
+
160
+ // Common typo: missing colon after scheme.
161
+ if (/^http\/\/+/i.test(value) || /^https\/\/+/i.test(value)) {
162
+ value = value.replace(/^http\/\/+/i, "http://").replace(/^https\/\/+/i, "https://");
163
+ }
164
+ if (/^ttps?:\/\//i.test(value)) {
165
+ value = `h${value}`;
166
+ }
167
+ if (/^https?:\/\/$/i.test(value)) return "";
168
+
169
+ // Accept domain-like values pasted without scheme.
170
+ if (!/^https?:\/\//i.test(value) && /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^\s]*)?$/i.test(value)) {
171
+ value = `https://${value}`;
172
+ }
173
+
174
+ value = value.replace(/[)\]}>.,;:]+$/g, "");
175
+ return /^https?:\/\/.+/i.test(value) ? value : "";
176
+ }
177
+
178
+ function parseEndpointListInput(raw) {
179
+ const text = Array.isArray(raw) ? raw.join("\n") : String(raw || "");
180
+ const extracted = [];
181
+
182
+ const urlRegex = /https?:\/\/[^\s,;'"`<>()\]]+/gi;
183
+ for (const match of text.matchAll(urlRegex)) {
184
+ extracted.push(match[0]);
185
+ }
186
+
187
+ const typoUrlRegex = /\bhttps?:\/\/?[^\s,;'"`<>()\]]+/gi;
188
+ for (const match of text.matchAll(typoUrlRegex)) {
189
+ extracted.push(match[0]);
190
+ }
191
+
192
+ const domainRegex = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^\s,;'"`<>()\]]*)?/gi;
193
+ for (const match of text.matchAll(domainRegex)) {
194
+ extracted.push(match[0]);
195
+ }
196
+
197
+ const fallbackTokens = tokenizeLooseListInput(text);
198
+ const normalized = dedupeList([...(extracted.length > 0 ? extracted : []), ...fallbackTokens]
199
+ .map(normalizeEndpointToken)
200
+ .filter(Boolean));
201
+
202
+ return normalized;
203
+ }
204
+
205
+ const MODEL_INPUT_NOISE_TOKENS = new Set([
206
+ "discover",
207
+ "progress",
208
+ "endpoint",
209
+ "testing",
210
+ "formats",
211
+ "format",
212
+ "working",
213
+ "supported",
214
+ "auto-discovery",
215
+ "auto",
216
+ "discovery",
217
+ "completed",
218
+ "started",
219
+ "done",
220
+ "openai",
221
+ "claude",
222
+ "anthropic",
223
+ "skip",
224
+ "ok",
225
+ "tentative",
226
+ "network-error",
227
+ "format-mismatch",
228
+ "model-unsupported",
229
+ "auth-error",
230
+ "unconfirmed",
231
+ "error",
232
+ "errors",
233
+ "warning",
234
+ "warnings",
235
+ "failed",
236
+ "failure",
237
+ "invalid",
238
+ "request",
239
+ "response",
240
+ "http",
241
+ "https",
242
+ "status",
243
+ "probe",
244
+ "provider",
245
+ "models",
246
+ "model",
247
+ "on",
248
+ "at"
249
+ ]);
250
+
251
+ function normalizeModelToken(token) {
252
+ let value = trimOuterPunctuation(token);
253
+ if (!value) return "";
254
+
255
+ value = value
256
+ .replace(/^(?:models?|modelSupport|modelPreferredFormat)\s*=\s*/i, "")
257
+ .replace(/\[(?:openai|claude)\]?$/i, "")
258
+ .replace(/[)\]}>.,;:]+$/g, "")
259
+ .trim();
260
+
261
+ if (!value) return "";
262
+ if (value.includes("://")) return "";
263
+ if (value.includes("@")) return "";
264
+ if (/^\d+(?:\/\d+)?$/.test(value)) return "";
265
+ if (/^https?$/i.test(value)) return "";
266
+ if (/^(?:openai|claude|anthropic)$/i.test(value)) return "";
267
+ if (MODEL_INPUT_NOISE_TOKENS.has(value.toLowerCase())) return "";
268
+
269
+ // Ignore obvious prose fragments. Keep model-like IDs with delimiters.
270
+ if (!/[._:/-]/.test(value) && !/\d/.test(value)) return "";
271
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(value)) return "";
272
+
273
+ return value;
274
+ }
275
+
276
+ function parseProviderModelListInput(raw) {
277
+ const text = Array.isArray(raw) ? raw.join("\n") : String(raw || "");
278
+ const extracted = [];
279
+
280
+ // "Progress ... - <model> on <format> @ <endpoint>"
281
+ const progressRegex = /-\s+([A-Za-z0-9][A-Za-z0-9._:/-]*)\s+on\s+(?:openai|claude)\s+@/gi;
282
+ for (const match of text.matchAll(progressRegex)) {
283
+ extracted.push(match[1]);
284
+ }
285
+
286
+ // "models=foo[openai], bar[claude]"
287
+ const modelsLineRegex = /\bmodels?\s*=\s*([^\n\r]+)/gi;
288
+ for (const match of text.matchAll(modelsLineRegex)) {
289
+ extracted.push(...tokenizeLooseListInput(match[1]));
290
+ }
291
+
292
+ const fallbackTokens = tokenizeLooseListInput(text);
293
+ return dedupeList([...(extracted.length > 0 ? extracted : []), ...fallbackTokens]
294
+ .map(normalizeModelToken)
295
+ .filter(Boolean));
296
+ }
297
+
298
+ function maybeReportInputCleanup(context, label, rawValue, cleanedValues) {
299
+ if (!canPrompt()) return;
300
+ const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : null;
301
+ const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : null;
302
+ if (!info && !warn) return;
303
+
304
+ const raw = String(rawValue || "").trim();
305
+ if (!raw) return;
306
+
307
+ const normalizedRaw = raw.toLowerCase();
308
+ const looksMessy =
309
+ /[;\n\r\t]/.test(raw) ||
310
+ /\[discover\]|auto-discovery|error|warning|failed|models?=/i.test(raw) ||
311
+ /\s{2,}/.test(raw);
312
+
313
+ if (!looksMessy) return;
314
+
315
+ if ((cleanedValues || []).length > 0) {
316
+ info?.(`Cleaned ${label} input: parsed ${(cleanedValues || []).length} item(s) from free-form text.`);
317
+ } else {
318
+ warn?.(`Could not parse any ${label} from the provided text. Use comma/semicolon/space-separated values.`);
319
+ }
320
+ }
321
+
322
+ function truncateLogText(value, max = 160) {
323
+ const text = String(value || "").trim();
324
+ if (!text) return "";
325
+ if (text.length <= max) return text;
326
+ return `${text.slice(0, max - 3)}...`;
327
+ }
328
+
329
+ function describeModelCheckStatus(event) {
330
+ const statusCode = Number(event.status || 0);
331
+ const statusSuffix = statusCode > 0 ? ` (http ${statusCode})` : "";
332
+ const rawMessage = event.error || event.message || "";
333
+ const detail = truncateLogText(rawMessage === "ok" ? "" : rawMessage);
334
+ const outcome = String(event.outcome || "");
335
+
336
+ if (event.confirmed) {
337
+ return {
338
+ shortLabel: "ok",
339
+ fullLabel: `ok${statusSuffix}`,
340
+ detail,
341
+ isOk: true
342
+ };
343
+ }
344
+
345
+ if (outcome === "runtime-error") {
346
+ return {
347
+ shortLabel: "tentative",
348
+ fullLabel: `tentative${statusSuffix}`,
349
+ detail,
350
+ isOk: false
351
+ };
352
+ }
353
+ if (outcome === "model-unsupported") {
354
+ return {
355
+ shortLabel: "model-unsupported",
356
+ fullLabel: `model-unsupported${statusSuffix}`,
357
+ detail,
358
+ isOk: false
359
+ };
360
+ }
361
+ if (outcome === "format-mismatch") {
362
+ return {
363
+ shortLabel: "format-mismatch",
364
+ fullLabel: `format-mismatch${statusSuffix}`,
365
+ detail,
366
+ isOk: false
367
+ };
368
+ }
369
+ if (outcome === "network-error") {
370
+ return {
371
+ shortLabel: "network-error",
372
+ fullLabel: `network-error${statusSuffix}`,
373
+ detail,
374
+ isOk: false
375
+ };
376
+ }
377
+ if (outcome === "auth-error") {
378
+ return {
379
+ shortLabel: "auth-error",
380
+ fullLabel: `auth-error${statusSuffix}`,
381
+ detail,
382
+ isOk: false
383
+ };
384
+ }
385
+ if (outcome === "unconfirmed") {
386
+ return {
387
+ shortLabel: "unconfirmed",
388
+ fullLabel: `unconfirmed${statusSuffix}`,
389
+ detail,
390
+ isOk: false
391
+ };
392
+ }
393
+
394
+ return {
395
+ shortLabel: event.supported ? "tentative" : "skip",
396
+ fullLabel: `${event.supported ? "tentative" : "skip"}${statusSuffix}`,
397
+ detail,
398
+ isOk: false
399
+ };
400
+ }
401
+
95
402
  function probeProgressReporter(context) {
96
403
  const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
97
404
  if (!line) return () => {};
98
405
 
406
+ const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : line;
407
+ const success = typeof context?.terminal?.success === "function" ? context.terminal.success.bind(context.terminal) : line;
408
+ const warn = typeof context?.terminal?.warn === "function" ? context.terminal.warn.bind(context.terminal) : line;
409
+ const interactiveTerminal = canPrompt();
410
+ const progress = interactiveTerminal && typeof SnapTui?.createProgress === "function" ? SnapTui.createProgress() : null;
411
+ const endpointSpinner = interactiveTerminal && typeof SnapTui?.createSpinner === "function" ? SnapTui.createSpinner() : null;
412
+ const PROGRESS_UI_MIN_UPDATE_MS = 120;
413
+ const SPINNER_UI_MIN_UPDATE_MS = 120;
414
+
99
415
  let lastProgressPrinted = -1;
416
+ let totalChecks = 0;
417
+ let matrixStarted = false;
418
+ let endpointSpinnerRunning = false;
419
+ let lastProgressUiUpdateAt = 0;
420
+ let lastProgressUiMessage = "";
421
+ let lastSpinnerUiUpdateAt = 0;
422
+ let lastSpinnerUiMessage = "";
423
+
424
+ const clearSpinnerForLog = () => {
425
+ if (!endpointSpinner || !endpointSpinnerRunning) return;
426
+ if (typeof endpointSpinner.clear === "function") {
427
+ endpointSpinner.clear();
428
+ }
429
+ };
430
+
431
+ const maybeLine = (message, { forceInteractive = false } = {}) => {
432
+ if (!interactiveTerminal || forceInteractive) {
433
+ clearSpinnerForLog();
434
+ line(message);
435
+ }
436
+ };
437
+
438
+ const setSpinnerMessage = (message, { force = false } = {}) => {
439
+ if (!endpointSpinner || !endpointSpinnerRunning) return;
440
+ const next = String(message || "").trim();
441
+ if (!next) return;
442
+ const now = Date.now();
443
+ if (!force) {
444
+ if (next === lastSpinnerUiMessage) return;
445
+ if (now - lastSpinnerUiUpdateAt < SPINNER_UI_MIN_UPDATE_MS) return;
446
+ }
447
+ endpointSpinner.message(next);
448
+ lastSpinnerUiMessage = next;
449
+ lastSpinnerUiUpdateAt = now;
450
+ };
451
+
452
+ const setProgressMessage = (message, { force = false } = {}) => {
453
+ if (!progress) return;
454
+ const next = String(message || "").trim();
455
+ if (!next) return;
456
+ const now = Date.now();
457
+ if (!force) {
458
+ if (next === lastProgressUiMessage) return;
459
+ if (now - lastProgressUiUpdateAt < PROGRESS_UI_MIN_UPDATE_MS) return;
460
+ }
461
+ progress.message(next);
462
+ lastProgressUiMessage = next;
463
+ lastProgressUiUpdateAt = now;
464
+ };
465
+
100
466
  return (event) => {
101
467
  if (!event || typeof event !== "object") return;
102
468
  const phase = String(event.phase || "");
103
469
 
104
470
  if (phase === "matrix-start") {
105
- line(`Auto-discovery started: ${event.endpointCount || 0} endpoint(s) x ${event.modelCount || 0} model(s).`);
471
+ const endpointCount = Number(event.endpointCount || 0);
472
+ const modelCount = Number(event.modelCount || 0);
473
+ totalChecks = endpointCount * modelCount * 2;
474
+ matrixStarted = true;
475
+
476
+ info(`Auto-discovery started: ${endpointCount} endpoint(s) x ${modelCount} model(s).`);
477
+ progress?.start(`Auto-discovery progress: 0/${totalChecks || 0}`);
478
+ lastProgressUiMessage = `Auto-discovery progress: 0/${totalChecks || 0}`;
479
+ lastProgressUiUpdateAt = Date.now();
106
480
  return;
107
481
  }
108
482
  if (phase === "endpoint-start") {
109
- line(`[discover] Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`);
483
+ if (endpointSpinner && endpointSpinnerRunning) {
484
+ endpointSpinner.stop();
485
+ }
486
+ endpointSpinner?.start(`Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`);
487
+ endpointSpinnerRunning = Boolean(endpointSpinner);
488
+ lastSpinnerUiMessage = `Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`;
489
+ lastSpinnerUiUpdateAt = Date.now();
490
+ maybeLine(`[discover] Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`);
110
491
  return;
111
492
  }
112
493
  if (phase === "endpoint-formats") {
113
494
  const formats = Array.isArray(event.formatsToTest) && event.formatsToTest.length > 0
114
495
  ? event.formatsToTest.join(", ")
115
496
  : "(none)";
116
- line(`[discover] Testing formats for ${event.endpoint}: ${formats}`);
497
+ setSpinnerMessage(`Testing ${event.endpoint}: ${formats}`, { force: true });
498
+ maybeLine(`[discover] Testing formats for ${event.endpoint}: ${formats}`);
117
499
  return;
118
500
  }
119
501
  if (phase === "format-start") {
120
- line(`[discover] ${event.endpoint} -> ${event.format} (${event.modelCount || 0} model checks)`);
502
+ setSpinnerMessage(`${event.endpoint} -> ${event.format} (${event.modelCount || 0} model checks)`, { force: true });
503
+ maybeLine(`[discover] ${event.endpoint} -> ${event.format} (${event.modelCount || 0} model checks)`);
121
504
  return;
122
505
  }
123
506
  if (phase === "model-check") {
124
507
  const completed = Number(event.completedChecks || 0);
125
508
  const total = Number(event.totalChecks || 0);
126
509
  if (completed <= 0 || total <= 0) return;
127
- if (completed === total || completed - lastProgressPrinted >= 3) {
510
+ const status = describeModelCheckStatus(event);
511
+ const shouldPrintLine = interactiveTerminal
512
+ ? (!status.isOk || completed === total)
513
+ : (!status.isOk || completed === total || completed - lastProgressPrinted >= 3);
514
+
515
+ if (matrixStarted) {
516
+ setProgressMessage(
517
+ `Auto-discovery progress: ${completed}/${total} (${event.model} on ${event.format} @ ${event.endpoint}: ${status.shortLabel})`,
518
+ { force: !status.isOk || completed === total }
519
+ );
520
+ }
521
+
522
+ if (shouldPrintLine) {
128
523
  lastProgressPrinted = completed;
129
- line(`[discover] Progress ${completed}/${total} - ${event.model} on ${event.format} @ ${event.endpoint}: ${event.supported ? "ok" : "skip"}`);
524
+ const detailSuffix = status.detail ? ` - ${status.detail}` : "";
525
+ maybeLine(
526
+ `[discover] Progress ${completed}/${total} - ${event.model} on ${event.format} @ ${event.endpoint}: ${status.fullLabel}${detailSuffix}`,
527
+ { forceInteractive: !status.isOk || completed === total }
528
+ );
130
529
  }
131
530
  return;
132
531
  }
@@ -134,16 +533,40 @@ function probeProgressReporter(context) {
134
533
  const formats = Array.isArray(event.workingFormats) && event.workingFormats.length > 0
135
534
  ? event.workingFormats.join(", ")
136
535
  : "(none)";
137
- line(`[discover] Endpoint done: ${event.endpoint} working formats=${formats}`);
536
+ if (endpointSpinner && endpointSpinnerRunning) {
537
+ endpointSpinner.stop();
538
+ endpointSpinnerRunning = false;
539
+ }
540
+ if (formats === "(none)") {
541
+ warn(`[discover] Endpoint done: ${event.endpoint} working formats=${formats}`);
542
+ } else {
543
+ success(`[discover] Endpoint done: ${event.endpoint} working formats=${formats}`);
544
+ }
138
545
  return;
139
546
  }
140
- if (phase === "matrix-done") {
547
+ if (phase === "matrix-done") {
141
548
  const openaiBase = event.baseUrlByFormat?.openai || "(none)";
142
549
  const claudeBase = event.baseUrlByFormat?.claude || "(none)";
143
550
  const formats = Array.isArray(event.workingFormats) && event.workingFormats.length > 0
144
551
  ? event.workingFormats.join(", ")
145
552
  : "(none)";
146
- line(`Auto-discovery completed: working formats=${formats}, models=${event.supportedModelCount || 0}, openaiBase=${openaiBase}, claudeBase=${claudeBase}`);
553
+ const finalMessage = `Auto-discovery completed: working formats=${formats}, models=${event.supportedModelCount || 0}, openaiBase=${openaiBase}, claudeBase=${claudeBase}`;
554
+ if (endpointSpinner && endpointSpinnerRunning) {
555
+ endpointSpinner.stop();
556
+ endpointSpinnerRunning = false;
557
+ }
558
+ if (matrixStarted) {
559
+ progress?.stop(`Auto-discovery progress: ${event.supportedModelCount || 0} model(s) confirmed`);
560
+ lastProgressUiMessage = "";
561
+ }
562
+ if (formats === "(none)") {
563
+ warn(finalMessage);
564
+ } else {
565
+ success(finalMessage);
566
+ }
567
+ matrixStarted = false;
568
+ totalChecks = 0;
569
+ lastSpinnerUiMessage = "";
147
570
  }
148
571
  };
149
572
  }
@@ -295,7 +718,7 @@ async function resolveUpsertInput(context, existingConfig) {
295
718
  const baseProviderId = argProviderId || selectedExisting?.id || "";
296
719
  const baseName = String(readArg(args, ["name"], selectedExisting?.name || "") || "");
297
720
  const baseUrl = String(readArg(args, ["base-url", "baseUrl"], selectedExisting?.baseUrl || "") || "");
298
- const baseEndpoints = parseModelListInput(readArg(
721
+ const baseEndpoints = parseEndpointListInput(readArg(
299
722
  args,
300
723
  ["endpoints"],
301
724
  providerEndpointsFromConfig(selectedExisting).join(",")
@@ -334,7 +757,7 @@ async function resolveUpsertInput(context, existingConfig) {
334
757
  openaiBaseUrl: baseOpenAIBaseUrl,
335
758
  claudeBaseUrl: baseClaudeBaseUrl,
336
759
  apiKey: baseApiKey || selectedExisting?.apiKey || "",
337
- models: parseModelListInput(baseModels),
760
+ models: parseProviderModelListInput(baseModels),
338
761
  format: baseFormat,
339
762
  formats: baseFormats,
340
763
  headers: parsedHeaders,
@@ -344,16 +767,34 @@ async function resolveUpsertInput(context, existingConfig) {
344
767
  };
345
768
  }
346
769
 
770
+ printProviderInputGuidance(context);
771
+
347
772
  const name = baseName || await context.prompts.text({
348
- message: "Provider name",
773
+ message: "Provider Friendly Name (unique, shown in management screen)",
349
774
  required: true,
350
- placeholder: "OpenRouter"
775
+ placeholder: "OpenRouter Primary",
776
+ validate: (value) => {
777
+ const candidate = String(value || "").trim();
778
+ if (!candidate) return "Provider Friendly Name is required.";
779
+ const duplicate = findProviderByFriendlyName(providers, candidate, { excludeId: selectedExisting?.id || baseProviderId });
780
+ if (duplicate) return `Provider Friendly Name '${candidate}' already exists (provider-id: ${duplicate.id}). Use a unique name.`;
781
+ return undefined;
782
+ }
351
783
  });
352
784
 
353
785
  const providerId = baseProviderId || await context.prompts.text({
354
- message: "Provider id (slug/camelCase)",
786
+ message: "Provider ID (auto-slug from Friendly Name; editable)",
355
787
  required: true,
356
- initialValue: slugifyId(name)
788
+ initialValue: slugifyId(name),
789
+ placeholder: "openrouterPrimary",
790
+ validate: (value) => {
791
+ const candidate = String(value || "").trim();
792
+ if (!candidate) return "Provider ID is required.";
793
+ if (!PROVIDER_ID_PATTERN.test(candidate)) {
794
+ return "Use slug/camelCase with letters, numbers, underscore, dot, or hyphen (e.g. openrouterPrimary).";
795
+ }
796
+ return undefined;
797
+ }
357
798
  });
358
799
 
359
800
  const askReplaceKey = selectedExisting?.apiKey ? await context.prompts.confirm({
@@ -363,21 +804,34 @@ async function resolveUpsertInput(context, existingConfig) {
363
804
 
364
805
  const apiKey = (baseApiKey || (!askReplaceKey ? selectedExisting?.apiKey : "")) || await context.prompts.text({
365
806
  message: "Provider API key",
366
- required: true
807
+ required: true,
808
+ placeholder: "sk-or-v1-..., sk-ant-api03-..., or sk-...",
809
+ validate: (value) => {
810
+ const candidate = String(value || "").trim();
811
+ if (!candidate) return "Provider API key is required.";
812
+ return undefined;
813
+ }
367
814
  });
368
815
 
369
816
  const endpointsInput = await context.prompts.text({
370
- message: "Provider endpoints (comma separated)",
817
+ message: "Provider endpoints (comma / ; / space separated; multiline paste supported)",
371
818
  required: true,
372
- initialValue: baseEndpoints.join(",")
819
+ initialValue: baseEndpoints.join(","),
820
+ paste: true,
821
+ multiline: true
373
822
  });
374
- const endpoints = parseModelListInput(endpointsInput);
823
+ const endpoints = parseEndpointListInput(endpointsInput);
824
+ maybeReportInputCleanup(context, "endpoint", endpointsInput, endpoints);
375
825
 
376
826
  const modelsInput = await context.prompts.text({
377
- message: "Provider models (comma separated)",
827
+ message: "Provider models (comma / ; / space separated; multiline paste supported)",
378
828
  required: true,
379
- initialValue: baseModels
829
+ initialValue: baseModels,
830
+ paste: true,
831
+ multiline: true
380
832
  });
833
+ const models = parseProviderModelListInput(modelsInput);
834
+ maybeReportInputCleanup(context, "model", modelsInput, models);
381
835
 
382
836
  const headersInput = await context.prompts.text({
383
837
  message: "Custom headers JSON (optional; default User-Agent included)",
@@ -422,7 +876,7 @@ async function resolveUpsertInput(context, existingConfig) {
422
876
  openaiBaseUrl: baseOpenAIBaseUrl,
423
877
  claudeBaseUrl: baseClaudeBaseUrl,
424
878
  apiKey,
425
- models: parseModelListInput(modelsInput),
879
+ models,
426
880
  format: probe ? "" : manualFormat,
427
881
  formats: baseFormats,
428
882
  headers: interactiveHeaders,
@@ -437,7 +891,7 @@ async function doUpsertProvider(context) {
437
891
  const existingConfig = await readConfigFile(configPath);
438
892
  const input = await resolveUpsertInput(context, existingConfig);
439
893
 
440
- const endpointCandidates = parseModelListInput([
894
+ const endpointCandidates = parseEndpointListInput([
441
895
  ...(input.endpoints || []),
442
896
  input.openaiBaseUrl,
443
897
  input.claudeBaseUrl,
@@ -462,6 +916,18 @@ async function doUpsertProvider(context) {
462
916
  };
463
917
  }
464
918
 
919
+ const duplicateFriendlyName = findProviderByFriendlyName(existingConfig.providers || [], input.name, {
920
+ excludeId: input.providerId
921
+ });
922
+ if (duplicateFriendlyName) {
923
+ return {
924
+ ok: false,
925
+ mode: context.mode,
926
+ exitCode: EXIT_VALIDATION,
927
+ errorMessage: `Provider Friendly Name '${input.name}' already exists (provider-id: ${duplicateFriendlyName.id}). Choose a unique name.`
928
+ };
929
+ }
930
+
465
931
  let probe = null;
466
932
  let selectedFormat = String(input.format || "").trim();
467
933
  let effectiveBaseUrl = String(input.baseUrl || "").trim();
@@ -1261,13 +1727,13 @@ const routerModule = {
1261
1727
  args: [
1262
1728
  { name: "operation", required: false, description: "Setup operation (optional; prompts if omitted).", example: "--operation=upsert-provider" },
1263
1729
  { name: "provider-id", required: false, description: "Provider id (slug/camelCase).", example: "--provider-id=openrouter" },
1264
- { name: "name", required: false, description: "Provider display name.", example: "--name=OpenRouter" },
1265
- { name: "endpoints", required: false, description: "Comma-separated provider endpoint candidates for auto-probe.", example: "--endpoints=https://ramclouds.me,https://ramclouds.me/v1" },
1730
+ { name: "name", required: false, description: "Provider Friendly Name (must be unique; shown in management screen).", example: "--name=OpenRouter Primary" },
1731
+ { name: "endpoints", required: false, description: "Provider endpoint candidates for auto-probe (comma/semicolon/space separated; TUI supports multiline paste).", example: "--endpoints=https://ramclouds.me,https://ramclouds.me/v1" },
1266
1732
  { name: "base-url", required: false, description: "Provider base URL.", example: "--base-url=https://openrouter.ai/api/v1" },
1267
1733
  { name: "openai-base-url", required: false, description: "OpenAI endpoint base URL (format-specific override).", example: "--openai-base-url=https://ramclouds.me/v1" },
1268
1734
  { name: "claude-base-url", required: false, description: "Anthropic endpoint base URL (format-specific override).", example: "--claude-base-url=https://ramclouds.me" },
1269
- { name: "api-key", required: false, description: "Provider API key.", example: "--api-key=sk-..." },
1270
- { name: "models", required: false, description: "Comma-separated model list.", example: "--models=gpt-4o,claude-3-5-sonnet-latest" },
1735
+ { name: "api-key", required: false, description: "Provider API key.", example: "--api-key=sk-or-v1-..." },
1736
+ { name: "models", required: false, description: "Model list (comma/semicolon/space separated; strips common log/error noise; TUI supports multiline paste).", example: "--models=gpt-4o,claude-3-5-sonnet-latest" },
1271
1737
  { name: "model", required: false, description: "Single model id (used by remove-model).", example: "--model=gpt-4o" },
1272
1738
  { name: "format", required: false, description: "Manual format if probe is skipped.", example: "--format=openai" },
1273
1739
  { name: "headers", required: false, description: "Custom provider headers as JSON object (default User-Agent applied when omitted).", example: "--headers={\"User-Agent\":\"Mozilla/5.0\"}" },
@@ -224,11 +224,105 @@ function isUnsupportedModelMessage(message) {
224
224
  /no such model/,
225
225
  /model .*does not exist/,
226
226
  /model .*not available/,
227
- /unrecognized model/
227
+ /unrecognized model/,
228
+ /model .*is not supported/,
229
+ /not enabled for this model/,
230
+ /not available for this api/,
231
+ /not available in this api/,
232
+ /does not support .*api/,
233
+ /must use .*endpoint/,
234
+ /use .*\/v1/,
235
+ /only available via/
228
236
  ];
229
237
  return patterns.some((pattern) => pattern.test(text));
230
238
  }
231
239
 
240
+ function isTransientModelRuntimeError(result, message) {
241
+ const status = Number(result?.status || 0);
242
+ if ([408, 409, 429, 500, 502, 503, 504].includes(status)) return true;
243
+
244
+ const text = String(message || "").toLowerCase();
245
+ if (!text) return false;
246
+ const patterns = [
247
+ /rate limit/,
248
+ /too many requests/,
249
+ /quota/,
250
+ /overloaded/,
251
+ /try again/,
252
+ /temporar/,
253
+ /service unavailable/,
254
+ /gateway timeout/,
255
+ /upstream/,
256
+ /timeout/
257
+ ];
258
+ return patterns.some((pattern) => pattern.test(text));
259
+ }
260
+
261
+ function classifyModelProbeResult(format, result) {
262
+ const message = getResultMessage(result);
263
+
264
+ if (result.error) {
265
+ return {
266
+ supported: false,
267
+ confirmed: false,
268
+ outcome: "network-error",
269
+ message: result.error
270
+ };
271
+ }
272
+
273
+ if (result.ok) {
274
+ return {
275
+ supported: true,
276
+ confirmed: true,
277
+ outcome: "ok",
278
+ message: "ok"
279
+ };
280
+ }
281
+
282
+ if (!looksExpectedFormat(format, result)) {
283
+ return {
284
+ supported: false,
285
+ confirmed: false,
286
+ outcome: "format-mismatch",
287
+ message: message || "Endpoint response does not match expected format."
288
+ };
289
+ }
290
+
291
+ if (!authLooksValid(result)) {
292
+ return {
293
+ supported: false,
294
+ confirmed: false,
295
+ outcome: "auth-error",
296
+ message: message || "Authentication failed for this format."
297
+ };
298
+ }
299
+
300
+ if (isUnsupportedModelMessage(message)) {
301
+ return {
302
+ supported: false,
303
+ confirmed: false,
304
+ outcome: "model-unsupported",
305
+ message: message || "Model is not supported on this endpoint."
306
+ };
307
+ }
308
+
309
+ if (isTransientModelRuntimeError(result, message)) {
310
+ return {
311
+ supported: true,
312
+ confirmed: false,
313
+ outcome: "runtime-error",
314
+ message: message || "Request reached endpoint but failed with transient runtime error."
315
+ };
316
+ }
317
+
318
+ return {
319
+ supported: false,
320
+ confirmed: false,
321
+ outcome: "unconfirmed",
322
+ message: message || "Could not confirm model support for this endpoint/format."
323
+ };
324
+ }
325
+
232
326
  function looksExpectedFormat(format, result) {
233
327
  if (format === FORMATS.CLAUDE) return looksClaude(result);
234
328
  return looksOpenAI(result);
@@ -266,16 +360,6 @@ function makeProbeHeaders(format, extraHeaders, authHeaders = {}) {
266
360
  return headers;
267
361
  }
268
362
 
269
- function modelLooksSupported(format, result) {
270
- if (result.ok) return true;
271
- if (!looksExpectedFormat(format, result)) return false;
272
- if (!authLooksValid(result)) return false;
273
-
274
- const message = getResultMessage(result);
275
- if (isUnsupportedModelMessage(message)) return false;
276
- return true;
277
- }
278
-
279
363
  async function probeModelForFormat({
280
364
  baseUrl,
281
365
  format,
@@ -296,28 +380,33 @@ async function probeModelForFormat({
296
380
  body: JSON.stringify(buildProbeRequest(format, modelId))
297
381
  }, timeoutMs);
298
382
 
299
- if (modelLooksSupported(format, result)) {
383
+ const classified = classifyModelProbeResult(format, result);
384
+ if (classified.supported) {
300
385
  return {
301
386
  supported: true,
387
+ confirmed: classified.confirmed,
388
+ outcome: classified.outcome,
302
389
  authType: variant.type,
303
390
  status: result.status,
304
- message: result.ok ? "ok" : truncateMessage(getResultMessage(result)),
391
+ message: classified.outcome === "ok"
392
+ ? "ok"
393
+ : truncateMessage(classified.message || getResultMessage(result)),
305
394
  error: result.error || null
306
395
  };
307
396
  }
308
397
 
309
- if (!authLooksValid(result)) {
398
+ if (classified.outcome === "auth-error") {
310
399
  continue;
311
400
  }
312
401
 
313
- // Endpoint/format looks valid and auth seems valid, model is likely not available here.
314
- const msg = getResultMessage(result);
315
- if (isUnsupportedModelMessage(msg)) {
402
+ if (classified.outcome === "format-mismatch" || classified.outcome === "model-unsupported" || classified.outcome === "network-error") {
316
403
  return {
317
404
  supported: false,
405
+ confirmed: false,
406
+ outcome: classified.outcome,
318
407
  authType: variant.type,
319
408
  status: result.status,
320
- message: truncateMessage(msg || "Model is not supported on this endpoint."),
409
+ message: truncateMessage(classified.message),
321
410
  error: result.error || null
322
411
  };
323
412
  }
@@ -325,6 +414,8 @@ async function probeModelForFormat({
325
414
 
326
415
  return {
327
416
  supported: false,
417
+ confirmed: false,
418
+ outcome: "unknown",
328
419
  authType: null,
329
420
  status: 0,
330
421
  message: "Could not validate model support for this endpoint/format.",
@@ -688,6 +779,8 @@ export async function probeProviderEndpointMatrix(options) {
688
779
  format,
689
780
  model: modelId,
690
781
  supported: check.supported,
782
+ confirmed: check.confirmed,
783
+ outcome: check.outcome,
691
784
  status: check.status,
692
785
  authType: check.authType,
693
786
  message: check.message,
@@ -700,7 +793,11 @@ export async function probeProviderEndpointMatrix(options) {
700
793
  format,
701
794
  model: modelId,
702
795
  supported: check.supported,
796
+ confirmed: check.confirmed,
797
+ outcome: check.outcome,
703
798
  status: check.status,
799
+ message: check.message,
800
+ error: check.error,
704
801
  completedChecks,
705
802
  totalChecks
706
803
  });