@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 +2 -2
- package/src/cli/router-module.js +493 -27
- package/src/node/provider-probe.js +115 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/ai-router",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
21
|
+
"@levu/snap": "^0.3.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"wrangler": "^3.0.0"
|
package/src/cli/router-module.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
1265
|
-
{ name: "endpoints", required: false, description: "
|
|
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: "
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
398
|
+
if (classified.outcome === "auth-error") {
|
|
310
399
|
continue;
|
|
311
400
|
}
|
|
312
401
|
|
|
313
|
-
|
|
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(
|
|
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
|
});
|