@khanglvm/llm-router 1.0.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.

Potentially problematic release.


This version of @khanglvm/llm-router might be problematic. Click here for more details.

@@ -0,0 +1,612 @@
1
+ #!/usr/bin/env node
2
+
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promises as fs } from "node:fs";
6
+ import { spawn } from "node:child_process";
7
+ import { getDefaultConfigPath } from "../src/node/config-store.js";
8
+
9
+ const CLI_ENTRY = path.resolve("src/cli-entry.js");
10
+ const DEFAULT_TIMEOUT_MS = 90_000;
11
+ const DEFAULT_PORT = 8787;
12
+ const DEFAULT_HOST = "127.0.0.1";
13
+ const DEFAULT_REQUEST_TEXT = "Reply with exactly: OK";
14
+ const DEFAULT_MAX_TOKENS = 16;
15
+
16
+ function parseArgs(argv) {
17
+ const args = {};
18
+ const positional = [];
19
+
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const token = argv[i];
22
+ if (!token.startsWith("--")) {
23
+ positional.push(token);
24
+ continue;
25
+ }
26
+
27
+ const body = token.slice(2);
28
+ const idx = body.indexOf("=");
29
+ let key = body;
30
+ let value = true;
31
+ if (idx >= 0) {
32
+ key = body.slice(0, idx);
33
+ value = body.slice(idx + 1);
34
+ } else {
35
+ const next = argv[i + 1];
36
+ if (next && !next.startsWith("--")) {
37
+ value = next;
38
+ i += 1;
39
+ }
40
+ }
41
+
42
+ if (args[key] === undefined) {
43
+ args[key] = value;
44
+ continue;
45
+ }
46
+ args[key] = Array.isArray(args[key]) ? [...args[key], value] : [args[key], value];
47
+ }
48
+
49
+ return { args, positional };
50
+ }
51
+
52
+ function stripOuterQuotes(value) {
53
+ const text = String(value || "").trim();
54
+ if ((text.startsWith("\"") && text.endsWith("\"")) || (text.startsWith("'") && text.endsWith("'"))) {
55
+ return text.slice(1, -1);
56
+ }
57
+ return text;
58
+ }
59
+
60
+ function splitCsv(value) {
61
+ if (!value) return [];
62
+ return String(value)
63
+ .split(",")
64
+ .map((item) => item.trim())
65
+ .filter(Boolean);
66
+ }
67
+
68
+ function toInteger(value, fallback) {
69
+ const parsed = Number.parseInt(String(value ?? ""), 10);
70
+ return Number.isFinite(parsed) ? parsed : fallback;
71
+ }
72
+
73
+ function toBoolean(value, fallback = false) {
74
+ if (value === undefined || value === null || value === "") return fallback;
75
+ if (typeof value === "boolean") return value;
76
+ const normalized = String(value).trim().toLowerCase();
77
+ if (["1", "true", "yes", "y"].includes(normalized)) return true;
78
+ if (["0", "false", "no", "n"].includes(normalized)) return false;
79
+ return fallback;
80
+ }
81
+
82
+ function asArray(value) {
83
+ if (value === undefined || value === null) return [];
84
+ return Array.isArray(value) ? value : [value];
85
+ }
86
+
87
+ function parseJsonObject(value, fieldName) {
88
+ if (!value) return {};
89
+ if (typeof value === "object" && !Array.isArray(value)) return value;
90
+ try {
91
+ const parsed = JSON.parse(String(value));
92
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
93
+ throw new Error("must be a JSON object");
94
+ }
95
+ return parsed;
96
+ } catch (error) {
97
+ throw new Error(`${fieldName} must be a JSON object. ${error instanceof Error ? error.message : String(error)}`);
98
+ }
99
+ }
100
+
101
+ function slugifyProviderId(value, fallback = "provider") {
102
+ const slug = String(value || fallback)
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]+/g, "-")
105
+ .replace(/^-+|-+$/g, "");
106
+ return slug || fallback;
107
+ }
108
+
109
+ async function fileExists(filePath) {
110
+ try {
111
+ await fs.access(filePath);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ async function readEnvFile(filePath) {
119
+ const raw = await fs.readFile(filePath, "utf8");
120
+ const env = {};
121
+ for (const line of raw.split(/\r?\n/)) {
122
+ const trimmed = line.trim();
123
+ if (!trimmed || trimmed.startsWith("#")) continue;
124
+ const idx = trimmed.indexOf("=");
125
+ if (idx <= 0) continue;
126
+ const key = trimmed.slice(0, idx).trim();
127
+ const value = stripOuterQuotes(trimmed.slice(idx + 1).trim());
128
+ env[key] = value;
129
+ }
130
+ return env;
131
+ }
132
+
133
+ function parseProvidersFromJson(args) {
134
+ const jsonValues = asArray(args["providers-json"]);
135
+ const providers = [];
136
+
137
+ for (const raw of jsonValues) {
138
+ if (typeof raw !== "string" || !raw.trim()) continue;
139
+ const parsed = JSON.parse(raw);
140
+ if (!Array.isArray(parsed)) {
141
+ throw new Error("--providers-json must be a JSON array.");
142
+ }
143
+ providers.push(...parsed);
144
+ }
145
+
146
+ return providers;
147
+ }
148
+
149
+ async function parseProvidersFromJsonFile(args) {
150
+ const fileValue = args["providers-file"];
151
+ if (!fileValue) return [];
152
+ const filePath = path.resolve(String(fileValue));
153
+ const raw = await fs.readFile(filePath, "utf8");
154
+ const parsed = JSON.parse(raw);
155
+ if (!Array.isArray(parsed)) {
156
+ throw new Error("--providers-file must contain a JSON array.");
157
+ }
158
+ return parsed;
159
+ }
160
+
161
+ function parseProvidersFromEnvMap(envMap, providerKeysRaw) {
162
+ const keys = splitCsv(providerKeysRaw || envMap.LLM_ROUTER_TEST_PROVIDER_KEYS);
163
+ if (keys.length === 0) return [];
164
+
165
+ return keys.map((keyRaw) => {
166
+ const key = keyRaw.trim().toUpperCase();
167
+ const prefix = `LLM_ROUTER_TEST_${key}_`;
168
+ const id = envMap[`${prefix}PROVIDER_ID`] || envMap[`${prefix}ID`] || key.toLowerCase();
169
+ const name = envMap[`${prefix}NAME`] || id;
170
+ const apiKey = envMap[`${prefix}API_KEY`] || "";
171
+ const openaiBaseUrl = envMap[`${prefix}OPENAI_BASE_URL`] || "";
172
+ const claudeBaseUrl = envMap[`${prefix}CLAUDE_BASE_URL`] || "";
173
+ const models = splitCsv(envMap[`${prefix}MODELS`]);
174
+ const openaiHeaders = parseJsonObject(envMap[`${prefix}OPENAI_HEADERS_JSON`] || "", `${prefix}OPENAI_HEADERS_JSON`);
175
+ const claudeHeaders = parseJsonObject(envMap[`${prefix}CLAUDE_HEADERS_JSON`] || "", `${prefix}CLAUDE_HEADERS_JSON`);
176
+ const headers = parseJsonObject(envMap[`${prefix}HEADERS_JSON`] || "", `${prefix}HEADERS_JSON`);
177
+
178
+ return {
179
+ id,
180
+ name,
181
+ apiKey,
182
+ openaiBaseUrl,
183
+ claudeBaseUrl,
184
+ models,
185
+ openaiHeaders,
186
+ claudeHeaders,
187
+ headers
188
+ };
189
+ });
190
+ }
191
+
192
+ function normalizeProviderSpec(raw) {
193
+ if (!raw || typeof raw !== "object") return null;
194
+ const id = slugifyProviderId(raw.id || raw.providerId || raw.name);
195
+ const name = String(raw.name || id);
196
+ const apiKey = String(raw.apiKey || "").trim();
197
+ const openaiBaseUrl = String(raw.openaiBaseUrl || raw.openaiEndpoint || "").trim().replace(/\/+$/, "");
198
+ const claudeBaseUrl = String(raw.claudeBaseUrl || raw.anthropicBaseUrl || raw.anthropicEndpoint || "").trim().replace(/\/+$/, "");
199
+ const models = Array.isArray(raw.models) ? raw.models : splitCsv(raw.models);
200
+ const modelIds = models.map((model) => String(model).trim()).filter(Boolean);
201
+ const headers = parseJsonObject(raw.headers || "", `providers[${id}].headers`);
202
+ const openaiHeaders = parseJsonObject(raw.openaiHeaders || "", `providers[${id}].openaiHeaders`);
203
+ const claudeHeaders = parseJsonObject(raw.claudeHeaders || "", `providers[${id}].claudeHeaders`);
204
+
205
+ if (!id) throw new Error("Provider id is required.");
206
+ if (!apiKey) throw new Error(`Provider '${id}' is missing apiKey.`);
207
+ if (!openaiBaseUrl && !claudeBaseUrl) {
208
+ throw new Error(`Provider '${id}' requires openaiBaseUrl or claudeBaseUrl.`);
209
+ }
210
+ if (modelIds.length === 0) {
211
+ throw new Error(`Provider '${id}' requires at least one model.`);
212
+ }
213
+
214
+ return {
215
+ id,
216
+ name,
217
+ apiKey,
218
+ openaiBaseUrl,
219
+ claudeBaseUrl,
220
+ models: [...new Set(modelIds)],
221
+ headers,
222
+ openaiHeaders,
223
+ claudeHeaders
224
+ };
225
+ }
226
+
227
+ function buildConfigTargets(providers) {
228
+ const targets = [];
229
+ for (const provider of providers) {
230
+ const baseId = slugifyProviderId(provider.id);
231
+ if (provider.openaiBaseUrl) {
232
+ targets.push({
233
+ logicalProviderId: provider.id,
234
+ providerId: `${baseId}-openai`,
235
+ name: `${provider.name} OpenAI`,
236
+ format: "openai",
237
+ baseUrl: provider.openaiBaseUrl,
238
+ apiKey: provider.apiKey,
239
+ models: provider.models,
240
+ headers: { ...(provider.headers || {}), ...(provider.openaiHeaders || {}) }
241
+ });
242
+ }
243
+ if (provider.claudeBaseUrl) {
244
+ targets.push({
245
+ logicalProviderId: provider.id,
246
+ providerId: `${baseId}-claude`,
247
+ name: `${provider.name} Claude`,
248
+ format: "claude",
249
+ baseUrl: provider.claudeBaseUrl,
250
+ apiKey: provider.apiKey,
251
+ models: provider.models,
252
+ headers: { ...(provider.headers || {}), ...(provider.claudeHeaders || {}) }
253
+ });
254
+ }
255
+ }
256
+ return targets;
257
+ }
258
+
259
+ function maskSecret(secret) {
260
+ if (!secret) return "";
261
+ const value = String(secret);
262
+ if (value.length <= 8) return "*".repeat(value.length);
263
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
264
+ }
265
+
266
+ function runNodeCli(args, { cwd = process.cwd(), label = "cli" } = {}) {
267
+ return new Promise((resolve, reject) => {
268
+ const child = spawn(process.execPath, [CLI_ENTRY, ...args], {
269
+ cwd,
270
+ env: process.env,
271
+ stdio: ["ignore", "pipe", "pipe"]
272
+ });
273
+
274
+ let stdout = "";
275
+ let stderr = "";
276
+
277
+ child.stdout?.on("data", (chunk) => {
278
+ const text = chunk.toString();
279
+ stdout += text;
280
+ process.stdout.write(`[${label}] ${text}`);
281
+ });
282
+
283
+ child.stderr?.on("data", (chunk) => {
284
+ const text = chunk.toString();
285
+ stderr += text;
286
+ process.stderr.write(`[${label}] ${text}`);
287
+ });
288
+
289
+ child.on("error", reject);
290
+ child.on("close", (code) => {
291
+ resolve({
292
+ code: code ?? 1,
293
+ ok: code === 0,
294
+ stdout,
295
+ stderr
296
+ });
297
+ });
298
+ });
299
+ }
300
+
301
+ async function waitForHealth(baseUrl, timeoutMs) {
302
+ const startedAt = Date.now();
303
+ const healthUrl = `${baseUrl}/health`;
304
+
305
+ while (Date.now() - startedAt < timeoutMs) {
306
+ try {
307
+ const response = await fetch(healthUrl, {
308
+ method: "GET",
309
+ signal: AbortSignal.timeout(3_000)
310
+ });
311
+ if (response.ok) return true;
312
+ } catch {
313
+ // Keep polling until timeout.
314
+ }
315
+ await new Promise((resolve) => setTimeout(resolve, 400));
316
+ }
317
+
318
+ return false;
319
+ }
320
+
321
+ async function startServer({ host, port }) {
322
+ const child = spawn(process.execPath, [CLI_ENTRY, "start", `--host=${host}`, `--port=${port}`, "--watch-config=false"], {
323
+ cwd: process.cwd(),
324
+ env: process.env,
325
+ stdio: ["ignore", "pipe", "pipe"]
326
+ });
327
+
328
+ child.stdout?.on("data", (chunk) => {
329
+ process.stdout.write(`[start] ${chunk.toString()}`);
330
+ });
331
+ child.stderr?.on("data", (chunk) => {
332
+ process.stderr.write(`[start] ${chunk.toString()}`);
333
+ });
334
+
335
+ child.on("error", (error) => {
336
+ process.stderr.write(`[start] failed: ${error instanceof Error ? error.message : String(error)}\n`);
337
+ });
338
+
339
+ return child;
340
+ }
341
+
342
+ async function stopServer(child) {
343
+ if (!child || child.exitCode !== null || child.killed) return;
344
+ await new Promise((resolve) => {
345
+ const timer = setTimeout(() => {
346
+ child.kill("SIGKILL");
347
+ }, 4_000);
348
+ child.once("close", () => {
349
+ clearTimeout(timer);
350
+ resolve();
351
+ });
352
+ child.kill("SIGINT");
353
+ });
354
+ }
355
+
356
+ function summarizeBody(body) {
357
+ if (!body || typeof body !== "object") return "";
358
+ if (Array.isArray(body?.choices) && body.choices[0]?.message?.content) {
359
+ return String(body.choices[0].message.content).slice(0, 120);
360
+ }
361
+ if (Array.isArray(body?.content)) {
362
+ const textBlock = body.content.find((item) => item?.type === "text" && typeof item.text === "string");
363
+ if (textBlock?.text) return textBlock.text.slice(0, 120);
364
+ }
365
+ if (body?.error?.message) return String(body.error.message).slice(0, 160);
366
+ if (body?.message) return String(body.message).slice(0, 160);
367
+ return JSON.stringify(body).slice(0, 160);
368
+ }
369
+
370
+ async function callOpenAI(baseUrl, model, requestText, maxTokens, timeoutMs) {
371
+ const startedAt = Date.now();
372
+ const response = await fetch(`${baseUrl}/openai/v1/chat/completions`, {
373
+ method: "POST",
374
+ headers: { "content-type": "application/json" },
375
+ body: JSON.stringify({
376
+ model,
377
+ messages: [{ role: "user", content: requestText }],
378
+ max_tokens: maxTokens,
379
+ temperature: 0,
380
+ stream: false
381
+ }),
382
+ signal: AbortSignal.timeout(timeoutMs)
383
+ });
384
+ const text = await response.text();
385
+ const body = text ? JSON.parse(text) : null;
386
+ return {
387
+ ok: response.ok,
388
+ status: response.status,
389
+ elapsedMs: Date.now() - startedAt,
390
+ summary: summarizeBody(body)
391
+ };
392
+ }
393
+
394
+ async function callClaude(baseUrl, model, requestText, maxTokens, timeoutMs) {
395
+ const startedAt = Date.now();
396
+ const response = await fetch(`${baseUrl}/anthropic/v1/messages`, {
397
+ method: "POST",
398
+ headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
399
+ body: JSON.stringify({
400
+ model,
401
+ max_tokens: maxTokens,
402
+ messages: [{ role: "user", content: requestText }]
403
+ }),
404
+ signal: AbortSignal.timeout(timeoutMs)
405
+ });
406
+ const text = await response.text();
407
+ const body = text ? JSON.parse(text) : null;
408
+ return {
409
+ ok: response.ok,
410
+ status: response.status,
411
+ elapsedMs: Date.now() - startedAt,
412
+ summary: summarizeBody(body)
413
+ };
414
+ }
415
+
416
+ async function backupConfig(configPath) {
417
+ const existed = await fileExists(configPath);
418
+ if (!existed) return { existed: false, backupPath: null };
419
+
420
+ const backupPath = path.join(
421
+ os.tmpdir(),
422
+ `.llm-router.json.backup.${Date.now()}.${process.pid}`
423
+ );
424
+ await fs.copyFile(configPath, backupPath);
425
+ return { existed: true, backupPath };
426
+ }
427
+
428
+ async function restoreConfig(configPath, backup) {
429
+ if (backup.existed && backup.backupPath) {
430
+ await fs.rm(configPath, { force: true });
431
+ await fs.copyFile(backup.backupPath, configPath);
432
+ await fs.rm(backup.backupPath, { force: true });
433
+ return "restored";
434
+ }
435
+ await fs.rm(configPath, { force: true });
436
+ return "removed";
437
+ }
438
+
439
+ function printUsage() {
440
+ console.log([
441
+ "Provider smoke suite",
442
+ "",
443
+ "Usage:",
444
+ " node scripts/provider-smoke-suite.mjs --providers-json='[...]'",
445
+ " node scripts/provider-smoke-suite.mjs --providers-file=./providers.json",
446
+ " node scripts/provider-smoke-suite.mjs --env-file=.env.test-suite --provider-keys=RAMCLOUDS,ZAI",
447
+ "",
448
+ "Provider JSON shape:",
449
+ " [{",
450
+ ' "id": "ramclouds",',
451
+ ' "name": "RamClouds",',
452
+ ' "apiKey": "sk-...",',
453
+ ' "openaiBaseUrl": "https://example.com/v1",',
454
+ ' "claudeBaseUrl": "https://example.com",',
455
+ ' "models": ["model-a", "model-b"]',
456
+ " }]",
457
+ "",
458
+ "Options:",
459
+ " --host=127.0.0.1",
460
+ " --port=8787",
461
+ " --timeout-ms=90000",
462
+ " --max-tokens=16",
463
+ " --request-text='Reply with exactly: OK'",
464
+ " --skip-probe=true|false (default: true)",
465
+ " --provider-keys=KEY1,KEY2 (used with --env-file)"
466
+ ].join("\n"));
467
+ }
468
+
469
+ async function main() {
470
+ const { args } = parseArgs(process.argv.slice(2));
471
+ if (args.help || args.h) {
472
+ printUsage();
473
+ return 0;
474
+ }
475
+
476
+ const providers = [];
477
+ providers.push(...parseProvidersFromJson(args));
478
+ providers.push(...await parseProvidersFromJsonFile(args));
479
+ let envMap = null;
480
+
481
+ if (args["env-file"]) {
482
+ envMap = await readEnvFile(path.resolve(String(args["env-file"])));
483
+ providers.push(...parseProvidersFromEnvMap(envMap, args["provider-keys"]));
484
+ }
485
+
486
+ const host = String(args.host || envMap?.LLM_ROUTER_TEST_HOST || DEFAULT_HOST);
487
+ const port = toInteger(args.port ?? envMap?.LLM_ROUTER_TEST_PORT, DEFAULT_PORT);
488
+ const timeoutMs = toInteger(args["timeout-ms"] ?? envMap?.LLM_ROUTER_TEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
489
+ const maxTokens = toInteger(args["max-tokens"] ?? envMap?.LLM_ROUTER_TEST_MAX_TOKENS, DEFAULT_MAX_TOKENS);
490
+ const requestText = String(args["request-text"] || envMap?.LLM_ROUTER_TEST_REQUEST_TEXT || DEFAULT_REQUEST_TEXT);
491
+ const skipProbe = toBoolean(args["skip-probe"] ?? envMap?.LLM_ROUTER_TEST_SKIP_PROBE, true);
492
+
493
+ const normalizedProviders = providers
494
+ .map(normalizeProviderSpec)
495
+ .filter(Boolean);
496
+
497
+ if (normalizedProviders.length === 0) {
498
+ throw new Error("No providers were supplied. Pass --providers-json, --providers-file, or --env-file + --provider-keys.");
499
+ }
500
+
501
+ const targets = buildConfigTargets(normalizedProviders);
502
+ if (targets.length === 0) {
503
+ throw new Error("No config targets generated from provider specs.");
504
+ }
505
+
506
+ const configPath = getDefaultConfigPath();
507
+ const backup = await backupConfig(configPath);
508
+ const baseUrl = `http://${host}:${port}`;
509
+ let server = null;
510
+ const results = [];
511
+
512
+ console.log(`Using config path: ${configPath}`);
513
+ console.log(`Providers in suite: ${normalizedProviders.map((item) => item.id).join(", ")}`);
514
+ console.log(`Config targets: ${targets.map((item) => `${item.providerId}:${item.format}`).join(", ")}`);
515
+
516
+ try {
517
+ await fs.rm(configPath, { force: true });
518
+
519
+ for (const target of targets) {
520
+ const configArgs = [
521
+ "config",
522
+ "--operation=upsert-provider",
523
+ `--provider-id=${target.providerId}`,
524
+ `--name=${target.name}`,
525
+ `--base-url=${target.baseUrl}`,
526
+ `--api-key=${target.apiKey}`,
527
+ `--models=${target.models.join(",")}`,
528
+ `--format=${target.format}`,
529
+ `--skip-probe=${skipProbe ? "true" : "false"}`
530
+ ];
531
+ if (target.headers && Object.keys(target.headers).length > 0) {
532
+ configArgs.push(`--headers=${JSON.stringify(target.headers)}`);
533
+ }
534
+
535
+ console.log(`\n[config] provider=${target.providerId} format=${target.format} baseUrl=${target.baseUrl} apiKey=${maskSecret(target.apiKey)}`);
536
+ const configResult = await runNodeCli(configArgs, { label: `config:${target.providerId}` });
537
+ if (!configResult.ok) {
538
+ throw new Error(`Config failed for ${target.providerId} with exit code ${configResult.code}.`);
539
+ }
540
+ }
541
+
542
+ server = await startServer({ host, port });
543
+ const healthy = await waitForHealth(baseUrl, timeoutMs);
544
+ if (!healthy) {
545
+ throw new Error(`Local server did not become healthy on ${baseUrl} within ${timeoutMs} ms.`);
546
+ }
547
+
548
+ for (const target of targets) {
549
+ for (const modelId of target.models) {
550
+ const qualifiedModel = `${target.providerId}/${modelId}`;
551
+ const requestType = target.format === "claude" ? "claude" : "openai";
552
+ try {
553
+ const response = requestType === "claude"
554
+ ? await callClaude(baseUrl, qualifiedModel, requestText, maxTokens, timeoutMs)
555
+ : await callOpenAI(baseUrl, qualifiedModel, requestText, maxTokens, timeoutMs);
556
+
557
+ results.push({
558
+ ok: response.ok,
559
+ logicalProviderId: target.logicalProviderId,
560
+ providerId: target.providerId,
561
+ requestType,
562
+ modelId,
563
+ status: response.status,
564
+ elapsedMs: response.elapsedMs,
565
+ summary: response.summary
566
+ });
567
+ } catch (error) {
568
+ results.push({
569
+ ok: false,
570
+ logicalProviderId: target.logicalProviderId,
571
+ providerId: target.providerId,
572
+ requestType,
573
+ modelId,
574
+ status: 0,
575
+ elapsedMs: 0,
576
+ summary: error instanceof Error ? error.message : String(error)
577
+ });
578
+ }
579
+ }
580
+ }
581
+ } finally {
582
+ await stopServer(server);
583
+ const restoreState = await restoreConfig(configPath, backup);
584
+ console.log(`\n[cleanup] ${restoreState === "restored" ? "Original ~/.llm-router.json restored." : "Test ~/.llm-router.json removed (no original file existed)."}`);
585
+ }
586
+
587
+ console.log("\n=== Provider Smoke Suite Report ===");
588
+ for (const item of results) {
589
+ const status = item.ok ? "PASS" : "FAIL";
590
+ console.log(
591
+ `${status} provider=${item.providerId} logical=${item.logicalProviderId} request=${item.requestType} model=${item.modelId} status=${item.status} elapsed=${item.elapsedMs}ms`
592
+ );
593
+ if (item.summary) {
594
+ console.log(` response: ${item.summary}`);
595
+ }
596
+ }
597
+
598
+ const failed = results.filter((item) => !item.ok);
599
+ const passed = results.length - failed.length;
600
+ console.log(`\nSummary: ${passed}/${results.length} passed, ${failed.length} failed.`);
601
+
602
+ return failed.length === 0 ? 0 : 1;
603
+ }
604
+
605
+ main()
606
+ .then((code) => {
607
+ process.exitCode = code;
608
+ })
609
+ .catch((error) => {
610
+ console.error(error instanceof Error ? error.message : String(error));
611
+ process.exitCode = 1;
612
+ });