@mijanlab/llmtest 1.0.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mijanur Rahman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # LLMtest — Node.js Beta
2
+
3
+ This beta ports the CLI runtime to Node.js so Python, pip, virtualenvs, and PyInstaller are not required.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 18+
8
+ - npm
9
+
10
+ ## One-line install (recommended)
11
+
12
+ Checks for Node.js 18+, installs it automatically if missing, then installs `llmtest` globally via npm.
13
+
14
+ macOS / Linux:
15
+
16
+ ```bash
17
+ curl -fsSL https://raw.githubusercontent.com/mijanlab/LLMtest/main/install.sh | bash
18
+ ```
19
+
20
+ Windows (PowerShell):
21
+
22
+ ```powershell
23
+ irm https://raw.githubusercontent.com/mijanlab/LLMtest/main/install.ps1 | iex
24
+ ```
25
+
26
+ Then run:
27
+
28
+ ```bash
29
+ llmtest
30
+ ```
31
+
32
+ Or:
33
+
34
+ ```bash
35
+ llmtest https://api.openai.com/v1 YOUR_API_KEY
36
+ ```
37
+
38
+ ## Manual npm installation
39
+
40
+ If you already have Node.js 18+ installed:
41
+
42
+ ```bash
43
+ npm i -g @mijanlab/llmtest@latest
44
+ ```
45
+
46
+ ## Install this ZIP locally (offline / pre-publish testing)
47
+
48
+ ```bash
49
+ unzip LLMtest-beta.zip
50
+ cd LLMtest-beta
51
+ npm install -g .
52
+ ```
53
+
54
+ ## Included beta features
55
+
56
+ - OpenAI-compatible `/models` discovery
57
+ - Streaming `/chat/completions` benchmark
58
+ - TTFT, total latency, and tokens/sec measurement
59
+ - Concurrent model testing
60
+ - Model filtering and run count
61
+ - JSON, CSV, Markdown, and HTML reports
62
+ - Interactive setup wizard
63
+ - `llmtest --update`
64
+ - `llmtest --uninstall`
65
+ - `llmtest` and `llm-test` executable aliases
66
+
67
+ ## Beta note
68
+
69
+ The current Python implementation on `main` is not modified by this ZIP. Validate provider compatibility and report parity before replacing the production branch.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@mijanlab/llmtest",
3
+ "version": "1.0.5",
4
+ "description": "Fast zero-dependency CLI for benchmarking OpenAI-compatible LLM endpoints",
5
+ "type": "module",
6
+ "bin": {
7
+ "llmtest": "src/cli.js",
8
+ "llm-test": "src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test",
12
+ "check": "node --check src/cli.js && node --check src/benchmark.js",
13
+ "pack:check": "npm pack --dry-run"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "files": [
19
+ "src/",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/mijanlab/LLMtest.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/mijanlab/LLMtest/issues"
29
+ },
30
+ "homepage": "https://github.com/mijanlab/LLMtest#readme",
31
+ "keywords": [
32
+ "llm",
33
+ "benchmark",
34
+ "openai",
35
+ "latency",
36
+ "ttft",
37
+ "throughput",
38
+ "cli"
39
+ ],
40
+ "author": "Mijanur Rahman",
41
+ "license": "MIT"
42
+ }
@@ -0,0 +1,313 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
5
+ export const C = {
6
+ reset: useColor ? '\x1b[0m' : '',
7
+ bold: useColor ? '\x1b[1m' : '',
8
+ dim: useColor ? '\x1b[2m' : '',
9
+ cyan: useColor ? '\x1b[36m' : '',
10
+ green: useColor ? '\x1b[32m' : '',
11
+ yellow: useColor ? '\x1b[33m' : '',
12
+ red: useColor ? '\x1b[31m' : '',
13
+ gray: useColor ? '\x1b[90m' : ''
14
+ };
15
+
16
+ export function normalizeUrls(rawEndpoint = '') {
17
+ const endpoint = String(rawEndpoint).trim().replace(/\/+$/, '');
18
+ if (!endpoint) return { modelsUrl: '', chatUrl: '' };
19
+ if (endpoint.endsWith('/chat/completions')) {
20
+ const base = endpoint.slice(0, -'/chat/completions'.length).replace(/\/+$/, '');
21
+ return { modelsUrl: `${base}/models`, chatUrl: endpoint };
22
+ }
23
+ if (endpoint.endsWith('/models')) {
24
+ const base = endpoint.slice(0, -'/models'.length).replace(/\/+$/, '');
25
+ return { modelsUrl: endpoint, chatUrl: `${base}/chat/completions` };
26
+ }
27
+ return { modelsUrl: `${endpoint}/models`, chatUrl: `${endpoint}/chat/completions` };
28
+ }
29
+
30
+ function headers(apiKey = '') {
31
+ const h = {
32
+ 'content-type': 'application/json',
33
+ 'http-referer': 'https://github.com/mijanlab/LLMtest',
34
+ 'x-title': 'LLM Benchmark Suite'
35
+ };
36
+ if (apiKey) h.authorization = `Bearer ${apiKey}`;
37
+ return h;
38
+ }
39
+
40
+ async function fetchWithTimeout(url, options = {}, timeoutSeconds = 35) {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutSeconds) * 1000);
43
+ try {
44
+ return await fetch(url, { ...options, signal: controller.signal });
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+
50
+ export async function fetchAvailableModels(modelsUrl, apiKey = '', timeout = 15) {
51
+ try {
52
+ const response = await fetchWithTimeout(modelsUrl, { headers: headers(apiKey) }, timeout);
53
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
54
+ const payload = await response.json();
55
+ const raw = Array.isArray(payload) ? payload : (payload?.data ?? payload?.models ?? []);
56
+ return raw.flatMap((item) => {
57
+ if (typeof item === 'string') return [{ id: item, name: item, is_free: false }];
58
+ if (!item || typeof item !== 'object') return [];
59
+ const id = item.id ?? item.name;
60
+ if (!id) return [];
61
+ const pricing = item.pricing ?? null;
62
+ const promptPrice = Number(pricing?.prompt ?? 1);
63
+ const completionPrice = Number(pricing?.completion ?? 1);
64
+ return [{
65
+ id,
66
+ name: item.name ?? id,
67
+ is_free: id.includes(':free') || Boolean(pricing && promptPrice === 0 && completionPrice === 0),
68
+ context_length: item.context_length ?? null
69
+ }];
70
+ });
71
+ } catch (error) {
72
+ console.error(` ${C.red}✖ Error discovering models:${C.reset} ${error.message}`);
73
+ return [];
74
+ }
75
+ }
76
+
77
+ export function estimateTokens(text = '') {
78
+ if (!text) return 0;
79
+ const tokens = text.match(/[\p{L}\p{N}_']+|[^\s\p{L}\p{N}_]/gu) ?? [];
80
+ return Math.max(1, tokens.length);
81
+ }
82
+
83
+ export function classifySkippableError(message = '') {
84
+ const value = message.toLowerCase();
85
+ const groups = [
86
+ [['402','payment required','insufficient_quota','insufficient_funds','insufficient funds','insufficient credit','out of credits','quota exceeded','billing','credits required'], 'Non-available fund / insufficient credits'],
87
+ [['429','rate limit','too many requests','rate_limit','throttled','quota_exceeded'], 'Rate limit exceeded (provider throttled)'],
88
+ [['403','forbidden','permission_denied','access denied','not allowed','restricted','region'], 'Provider restricted / access denied'],
89
+ [['not a chat model','unsupported model','invalid model','does not support chat','not found'], 'Unsupported model modality']
90
+ ];
91
+ for (const [needles, reason] of groups) {
92
+ if (needles.some((needle) => value.includes(needle))) return { skippable: true, reason };
93
+ }
94
+ return { skippable: false, reason: '' };
95
+ }
96
+
97
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
98
+
99
+ function extractText(obj) {
100
+ const choice = obj?.choices?.[0];
101
+ if (!choice) return '';
102
+ const delta = choice.delta ?? {};
103
+ return delta.content ?? delta.reasoning ?? delta.reasoning_content ?? delta.thought ?? delta.text ?? choice.text ?? choice.message?.content ?? choice.message?.reasoning ?? '';
104
+ }
105
+
106
+ export async function testSingleModelStreaming({ chatUrl, modelId, apiKey = '', prompt, systemPrompt, maxTokens = 60, timeout = 35 }) {
107
+ const messages = [];
108
+ if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
109
+ messages.push({ role: 'user', content: prompt });
110
+
111
+ let payload = { model: modelId, messages, stream: true, stream_options: { include_usage: true }, temperature: 0.2, max_tokens: maxTokens };
112
+
113
+ for (let attempt = 0; attempt < 3; attempt += 1) {
114
+ const start = performance.now();
115
+ let first = null;
116
+ let last = null;
117
+ let chunks = 0;
118
+ let usage = {};
119
+ let text = '';
120
+ try {
121
+ let response = await fetchWithTimeout(chatUrl, { method: 'POST', headers: headers(apiKey), body: JSON.stringify(payload) }, timeout);
122
+ if (response.status === 400 && payload.stream_options) {
123
+ payload = { ...payload };
124
+ delete payload.stream_options;
125
+ response = await fetchWithTimeout(chatUrl, { method: 'POST', headers: headers(apiKey), body: JSON.stringify(payload) }, timeout);
126
+ }
127
+
128
+ if ([429, 503, 529].includes(response.status) && attempt < 2) {
129
+ const retryAfter = Number(response.headers.get('retry-after'));
130
+ await sleep(Math.max(Number.isFinite(retryAfter) ? retryAfter * 1000 : 0, 2000 * (attempt + 1)));
131
+ continue;
132
+ }
133
+ if (!response.ok) {
134
+ const body = (await response.text()).slice(0, 200);
135
+ let message = body;
136
+ try { message = JSON.parse(body)?.error?.message ?? body; } catch {}
137
+ return { ok: false, error: `HTTP ${response.status}: ${message}` };
138
+ }
139
+ if (!response.body) return { ok: false, error: 'Provider returned an empty response body' };
140
+
141
+ const decoder = new TextDecoder();
142
+ let buffer = '';
143
+ for await (const chunk of response.body) {
144
+ buffer += decoder.decode(chunk, { stream: true });
145
+ const lines = buffer.split(/\r?\n/);
146
+ buffer = lines.pop() ?? '';
147
+ for (const line of lines) {
148
+ if (!line.startsWith('data:')) continue;
149
+ const data = line.slice(5).trim();
150
+ if (!data || data === '[DONE]') continue;
151
+ let obj;
152
+ try { obj = JSON.parse(data); } catch { continue; }
153
+ if (obj?.error) return { ok: false, error: `Stream Error: ${obj.error.message ?? JSON.stringify(obj.error)}` };
154
+ if (obj?.usage) usage = obj.usage;
155
+ const piece = extractText(obj);
156
+ if (piece !== '' && piece != null) {
157
+ const now = performance.now();
158
+ if (first == null) first = now;
159
+ last = now;
160
+ chunks += 1;
161
+ text += String(piece);
162
+ }
163
+ }
164
+ }
165
+ const end = performance.now();
166
+ text = text.trim();
167
+ if (first == null) {
168
+ if (!text && attempt < 2) { await sleep(1000); continue; }
169
+ if (!text) return { ok: false, error: 'No response text received from model' };
170
+ first = end;
171
+ last = end;
172
+ }
173
+ const ttft = (first - start) / 1000;
174
+ const total = (end - start) / 1000;
175
+ const generation = Math.max((end - first) / 1000, ((last ?? end) - first) / 1000, 0.001);
176
+ const completionTokens = Number(usage?.completion_tokens) > 0 ? Number(usage.completion_tokens) : estimateTokens(text);
177
+ return {
178
+ ok: true,
179
+ ttft,
180
+ total,
181
+ generation,
182
+ chunks,
183
+ tps: completionTokens > 0 ? completionTokens / generation : null,
184
+ completion_tokens: completionTokens,
185
+ prompt_tokens: usage?.prompt_tokens ?? null,
186
+ total_tokens: usage?.total_tokens ?? null,
187
+ preview: text.length > 80 ? `${text.slice(0, 80)}...` : text,
188
+ full_text: text
189
+ };
190
+ } catch (error) {
191
+ if (attempt < 2 && (/429|503|timeout|aborted/i.test(error.message))) {
192
+ await sleep(2000 * (attempt + 1));
193
+ continue;
194
+ }
195
+ return { ok: false, error: error.name === 'AbortError' ? `Request timed out after ${timeout}s` : error.message };
196
+ }
197
+ }
198
+ return { ok: false, error: 'Maximum retries exceeded' };
199
+ }
200
+
201
+ function mean(values) { return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null; }
202
+
203
+ export async function benchmarkModel(options) {
204
+ const details = [];
205
+ for (let run = 1; run <= options.runs; run += 1) {
206
+ details.push({ ...(await testSingleModelStreaming(options)), run });
207
+ }
208
+ const good = details.filter((r) => r.ok);
209
+ const errors = [...new Set(details.filter((r) => !r.ok && r.error).map((r) => r.error))];
210
+ let status = 'FAIL';
211
+ let skipReason = null;
212
+ if (good.length === options.runs) status = 'PASS';
213
+ else if (good.length > 0) status = 'PARTIAL';
214
+ else if (errors.length) {
215
+ const classified = errors.map(classifySkippableError);
216
+ if (classified.every((r) => r.skippable)) {
217
+ status = 'SKIPPED';
218
+ skipReason = classified[0].reason;
219
+ }
220
+ }
221
+ return {
222
+ model: options.modelId,
223
+ runs: options.runs,
224
+ success: good.length,
225
+ failed: options.runs - good.length,
226
+ status,
227
+ skip_reason: skipReason,
228
+ avg_ttft: mean(good.map((r) => r.ttft).filter((v) => v != null)),
229
+ avg_total: mean(good.map((r) => r.total).filter((v) => v != null)),
230
+ avg_tps: mean(good.map((r) => r.tps).filter((v) => v != null)),
231
+ sample_preview: good.find((r) => r.preview)?.preview ?? null,
232
+ errors,
233
+ details
234
+ };
235
+ }
236
+
237
+ function fmtTTFT(value) { return value == null ? '—' : value < 1 ? `${Math.round(value * 1000)} ms` : `${value.toFixed(3)} s`; }
238
+ function fmtTotal(value) { return value == null ? '—' : `${value.toFixed(3)} s`; }
239
+ function fmtTPS(value) { return value == null ? '—' : `${value.toFixed(1)} tok/s`; }
240
+ function pad(value, width) { const s = String(value); return s.length >= width ? s.slice(0, width) : s.padEnd(width); }
241
+
242
+ const STATUS_ICON = { PASS: '🟢', PARTIAL: '⚪', SKIPPED: '⚪', FAIL: '🔴' };
243
+ export function statusLabel(status) { return `${STATUS_ICON[status] ?? '⚪'} ${status}`; }
244
+
245
+ export function printTable(results) {
246
+ console.log('\n# Model ID Status TTFT Total Speed Output / Notes');
247
+ console.log('─'.repeat(114));
248
+ results.forEach((r, i) => {
249
+ const note = r.status === 'PASS' ? (r.sample_preview ?? '') : (r.skip_reason ?? r.errors?.join('; ') ?? 'Failed');
250
+ console.log(`${pad(i + 1, 3)} ${pad(r.model, 27)} ${pad(statusLabel(r.status), 14)} ${pad(fmtTTFT(r.avg_ttft), 10)} ${pad(fmtTotal(r.avg_total), 10)} ${pad(fmtTPS(r.avg_tps), 12)} ${String(note).replace(/\s+/g, ' ').slice(0, 34)}`);
251
+ });
252
+ }
253
+
254
+ export function printSummary(results, seconds) {
255
+ const passed = results.filter((r) => r.status === 'PASS');
256
+ const skipped = results.filter((r) => r.status === 'SKIPPED');
257
+ const failed = results.filter((r) => r.status === 'FAIL');
258
+ const fastest = passed.filter((r) => r.avg_ttft != null).sort((a,b) => a.avg_ttft - b.avg_ttft)[0];
259
+ const highest = passed.filter((r) => r.avg_tps != null).sort((a,b) => b.avg_tps - a.avg_tps)[0];
260
+ console.log(`\n${C.bold}📊 Benchmark Summary${C.reset}`);
261
+ console.log(` • Total Models Tested : ${results.length}`);
262
+ console.log(` • Working & Passed : ${passed.length}/${results.length}`);
263
+ if (skipped.length) console.log(` • Skipped / Throttled : ${skipped.length}`);
264
+ if (failed.length) console.log(` • Failed Execution : ${failed.length}`);
265
+ if (fastest) console.log(` • Fastest TTFT : ${fastest.model} (${fmtTTFT(fastest.avg_ttft)})`);
266
+ if (highest) console.log(` • Highest Speed : ${highest.model} (${fmtTPS(highest.avg_tps)})`);
267
+ if (seconds > 0) console.log(` • Total Time Elapsed : ${seconds.toFixed(2)}s`);
268
+ }
269
+
270
+ function csvEscape(value) {
271
+ const s = String(value ?? '');
272
+ return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
273
+ }
274
+
275
+ export function exportReports(results, endpoint, opts = {}) {
276
+ const created = [];
277
+ if (opts.json) {
278
+ fs.writeFileSync(opts.json, JSON.stringify({ endpoint, tested_at: Date.now() / 1000, results }, null, 2));
279
+ created.push(opts.json);
280
+ }
281
+ if (opts.csv) {
282
+ const rows = [['Model ID','Status','Success Runs','Total Runs','TTFT Seconds','Total Seconds','Tokens/Sec','Notes']];
283
+ for (const r of results) rows.push([r.model,r.status,r.success,r.runs,r.avg_ttft ?? '',r.avg_total ?? '',r.avg_tps ?? '',r.status === 'PASS' ? r.sample_preview ?? '' : r.skip_reason ?? r.errors?.join('; ') ?? '']);
284
+ fs.writeFileSync(opts.csv, rows.map((row) => row.map(csvEscape).join(',')).join('\n'));
285
+ created.push(opts.csv);
286
+ }
287
+ if (opts.md) {
288
+ const lines = [`# LLM Models Benchmark Report`, '', `- **Endpoint Tested**: \`${endpoint}\``, `- **Tested At**: ${new Date().toISOString()}`, '', '| # | Model ID | Status | TTFT | Total | Throughput |', '|---|---|---|---|---|---|'];
289
+ results.forEach((r, i) => lines.push(`| ${i + 1} | \`${r.model.replaceAll('|','\\|')}\` | ${r.status} | ${fmtTTFT(r.avg_ttft)} | ${fmtTotal(r.avg_total)} | ${fmtTPS(r.avg_tps)} |`));
290
+ fs.writeFileSync(opts.md, lines.join('\n'));
291
+ created.push(opts.md);
292
+ }
293
+ if (opts.html) {
294
+ const safe = (v) => String(v ?? '').replace(/[&<>"']/g, (ch) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
295
+ const rows = results.map((r) => `<tr><td>${safe(r.model)}</td><td>${safe(r.status)}</td><td>${safe(fmtTTFT(r.avg_ttft))}</td><td>${safe(fmtTotal(r.avg_total))}</td><td>${safe(fmtTPS(r.avg_tps))}</td></tr>`).join('');
296
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>llmtest report</title><style>
297
+ body{font-family:system-ui;background:#090d0a;color:#e8f5eb;margin:40px}
298
+ h1{color:#4ade80}
299
+ code{color:#86efac}
300
+ table{width:100%;border-collapse:collapse;margin-top:24px}
301
+ th,td{padding:10px;border-bottom:1px solid #26352a;text-align:left}
302
+ th{color:#86efac}
303
+ .pdf-btn{position:fixed;top:24px;right:24px;background:#4ade80;color:#052e16;border:none;border-radius:8px;padding:10px 18px;font-size:14px;font-weight:600;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.4)}
304
+ .pdf-btn:hover{background:#86efac}
305
+ @media print{.pdf-btn{display:none}body{background:#fff;color:#111}h1,th{color:#111}th,td{border-color:#ccc}}
306
+ </style></head><body>
307
+ <button class="pdf-btn" onclick="window.print()">⬇ Download as PDF</button>
308
+ <h1>⚡ llmtest</h1><p>Endpoint: <code>${safe(endpoint)}</code></p><p>Generated: ${safe(new Date().toISOString())}</p><table><thead><tr><th>Model</th><th>Status</th><th>TTFT</th><th>Total</th><th>Speed</th></tr></thead><tbody>${rows}</tbody></table></body></html>`;
309
+ fs.writeFileSync(opts.html, html);
310
+ created.push(opts.html);
311
+ }
312
+ return created.map((file) => path.resolve(file));
313
+ }
package/src/cli.js ADDED
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { stdin as input, stdout as output } from 'node:process';
7
+ import {
8
+ C, normalizeUrls, fetchAvailableModels, benchmarkModel,
9
+ printTable, printSummary, exportReports, statusLabel
10
+ } from './benchmark.js';
11
+
12
+ const VERSION = '1.0.5';
13
+ const banner = `\n${C.bold}LLM Speed & Latency Benchmark${C.reset}\n${C.cyan} LLMTEST v${VERSION}${C.reset}\n${C.gray}-------------------------------${C.reset}`;
14
+
15
+ function help() {
16
+ console.log(`\nUsage:\n llmtest [endpoint] [key] [filter] [options]\n\nOptions:\n -e, --endpoint <url> API base URL\n -k, --key <key> API key\n -f, --filter <text> Model ID filter; use "free" for free models\n -l, --limit <n> Limit models (0 = all)\n -r, --runs <n> Runs per model (default: 1)\n -c, --concurrency <n> Concurrent models (default: 3)\n -p, --prompt <text> Evaluation prompt\n -s, --system <text> System prompt\n --max-tokens <n> Max response tokens (default: 60)\n --timeout <sec> Request timeout (default: 35)\n --output-json <file> JSON report path\n --output-csv <file> CSV report path\n --output-md <file> Markdown report path\n --output-html <file> HTML report path\n --no-report Disable report files\n --report <json> Render reports from an existing JSON file\n --update npm install -g @mijanlab/llmtest@latest\n --uninstall npm uninstall -g @mijanlab/llmtest\n -v, --version Show version\n -h, --help Show help\n`);
17
+ }
18
+
19
+ function timestamp() {
20
+ return new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
21
+ }
22
+
23
+ function parseArgs(argv) {
24
+ const opts = { endpoint:'', key:'', filter:'', limit:0, runs:1, concurrency:3, prompt:'Say hello and describe your purpose in one brief sentence.', system:'You are a helpful assistant.', maxTokens:60, timeout:35, json:'', csv:'', md:'', html:'', noReport:false, report:'', update:false, uninstall:false, open:false };
25
+ const pos = [];
26
+ const aliases = { '-e':'endpoint','--endpoint':'endpoint','-k':'key','--key':'key','-f':'filter','--filter':'filter','-l':'limit','--limit':'limit','-r':'runs','--runs':'runs','-c':'concurrency','--concurrency':'concurrency','-p':'prompt','--prompt':'prompt','-s':'system','--system':'system','--max-tokens':'maxTokens','--timeout':'timeout','--output-json':'json','--output-csv':'csv','--csv':'csv','--output-md':'md','--output-html':'html','--html':'html','--report':'report','--render':'report' };
27
+ const numeric = new Set(['limit','runs','concurrency','maxTokens','timeout']);
28
+ for (let i=0;i<argv.length;i+=1) {
29
+ const a = argv[i];
30
+ if (a === '-h' || a === '--help') opts.help = true;
31
+ else if (a === '-v' || a === '--version') opts.version = true;
32
+ else if (a === '--no-report') opts.noReport = true;
33
+ else if (a === '--update') opts.update = true;
34
+ else if (a === '--uninstall') opts.uninstall = true;
35
+ else if (a === '--open') opts.open = true;
36
+ else if (aliases[a]) {
37
+ const key = aliases[a];
38
+ const value = argv[++i];
39
+ if (value == null) throw new Error(`Missing value for ${a}`);
40
+ opts[key] = numeric.has(key) ? Number(value) : value;
41
+ } else if (a.startsWith('-')) throw new Error(`Unknown option: ${a}`);
42
+ else pos.push(a);
43
+ }
44
+ opts.endpoint ||= pos[0] ?? '';
45
+ opts.key ||= pos[1] ?? '';
46
+ opts.filter ||= pos[2] ?? '';
47
+ if (/^(update|upgrade)$/i.test(opts.endpoint)) { opts.update = true; opts.endpoint = ''; }
48
+ if (/^(uninstall|remove)$/i.test(opts.endpoint)) { opts.uninstall = true; opts.endpoint = ''; }
49
+ if (/\.json$/i.test(opts.endpoint) && fs.existsSync(opts.endpoint)) { opts.report = opts.endpoint; opts.endpoint = ''; }
50
+ return opts;
51
+ }
52
+
53
+ function runNpm(args, label) {
54
+ console.log(`\n${C.cyan}→ Running: npm ${args.join(' ')}${C.reset}\n`);
55
+ const result = spawnSync('npm', args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', shell: process.platform === 'win32' });
56
+
57
+ if (result.error) {
58
+ console.error(`${C.red}✖ Could not run npm: ${result.error.message}${C.reset}`);
59
+ console.error(`${C.yellow} Make sure Node.js/npm is installed and on your PATH.${C.reset}`);
60
+ process.exit(1);
61
+ }
62
+
63
+ if (result.stdout) process.stdout.write(result.stdout);
64
+ if (result.stderr) process.stderr.write(result.stderr);
65
+
66
+ if (result.status !== 0) {
67
+ console.error(`\n${C.red}✖ ${label} failed (npm exit code ${result.status}).${C.reset}`);
68
+ if (/E404/.test(result.stderr ?? '')) {
69
+ console.error(`${C.yellow} Package not found on the npm registry (check your connection or the package name).${C.reset}`);
70
+ console.error(`${C.yellow} For now, test from a local copy: npm install -g . (run inside the package folder)${C.reset}`);
71
+ } else if (/EACCES|EPERM/.test(result.stderr ?? '')) {
72
+ console.error(`${C.yellow} Permission denied. Try running your terminal as Administrator (Windows) or with sudo (macOS/Linux).${C.reset}`);
73
+ }
74
+ process.exit(result.status ?? 1);
75
+ }
76
+
77
+ console.log(`\n${C.green}✔ ${label} completed.${C.reset}`);
78
+ process.exit(0);
79
+ }
80
+
81
+ async function wizard() {
82
+ const rl = createInterface({ input, output });
83
+ try {
84
+ console.log(`\n${C.cyan}⚡ Interactive Setup Wizard${C.reset}`);
85
+ const endpoint = (await rl.question('1. API Endpoint URL: ')).trim();
86
+ if (/^(update|upgrade)$/i.test(endpoint)) runNpm(['install','-g','@mijanlab/llmtest@latest'], 'Update');
87
+ if (/^(uninstall|remove)$/i.test(endpoint)) runNpm(['uninstall','-g','@mijanlab/llmtest'], 'Uninstall');
88
+ const key = (await rl.question('2. API Key [Enter if none]: ')).trim();
89
+ const filter = (await rl.question('3. Model Filter [Enter for all]: ')).trim();
90
+ const c = (await rl.question('4. Concurrency [default 3]: ')).trim();
91
+ return { endpoint, key, filter, concurrency: c ? Math.max(1, Number(c) || 3) : 3 };
92
+ } finally { rl.close(); }
93
+ }
94
+
95
+ async function runPool(items, concurrency, worker, onDone) {
96
+ let index = 0;
97
+ const runners = Array.from({ length: Math.max(1, Math.min(concurrency, items.length || 1)) }, async () => {
98
+ while (true) {
99
+ const current = index++;
100
+ if (current >= items.length) return;
101
+ const value = await worker(items[current]);
102
+ onDone(value, items[current]);
103
+ }
104
+ });
105
+ await Promise.all(runners);
106
+ }
107
+
108
+ function maybeOpen(file) {
109
+ if (!file) return;
110
+ const command = process.platform === 'darwin' ? ['open',[file]] : process.platform === 'win32' ? ['cmd',['/c','start','',file]] : ['xdg-open',[file]];
111
+ spawnSync(command[0], command[1], { stdio:'ignore', detached:true });
112
+ }
113
+
114
+ async function main() {
115
+ console.log(banner);
116
+ let args;
117
+ try { args = parseArgs(process.argv.slice(2)); } catch (e) { console.error(`${C.red}✖ ${e.message}${C.reset}`); help(); process.exitCode=2; return; }
118
+ if (args.help) { help(); return; }
119
+ if (args.version) { console.log(`llmtest ${VERSION}`); return; }
120
+ if (args.update) runNpm(['install','-g','@mijanlab/llmtest@latest'], 'Update');
121
+ if (args.uninstall) runNpm(['uninstall','-g','@mijanlab/llmtest'], 'Uninstall');
122
+
123
+ if (args.report) {
124
+ const data = JSON.parse(fs.readFileSync(args.report, 'utf8'));
125
+ const results = data.results ?? [];
126
+ printTable(results);
127
+ printSummary(results, 0);
128
+ if (!args.noReport) {
129
+ const ts = timestamp();
130
+ const created = exportReports(results, data.endpoint ?? 'Unknown Endpoint', {
131
+ html: args.html || `benchmark_report_${ts}.html`,
132
+ md: args.md || `benchmark_report_${ts}.md`,
133
+ csv: args.csv || `benchmark_report_${ts}.csv`
134
+ });
135
+ created.forEach((file) => console.log(` ✔ ${pathToFileURL(file).href}`));
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (!args.endpoint) Object.assign(args, await wizard());
141
+ if (!args.endpoint) { console.error(`${C.red}✖ No endpoint specified.${C.reset}`); return; }
142
+ const { modelsUrl, chatUrl } = normalizeUrls(args.endpoint);
143
+ const masked = args.key ? (args.key.length > 12 ? `${args.key.slice(0,6)}...${args.key.slice(-4)}` : '••••••••') : '(none)';
144
+ console.log(`${C.bold}Connecting to endpoint:${C.reset} ${C.cyan}${args.endpoint}${C.reset}`);
145
+ console.log(` • Models URL : ${modelsUrl}`);
146
+ console.log(` • Chat URL : ${chatUrl}`);
147
+ console.log(` • API Key : ${masked}\n`);
148
+
149
+ let models = await fetchAvailableModels(modelsUrl, args.key, args.timeout);
150
+ if (!models.length) { console.error(`${C.red}✖ No models discovered. Check endpoint URL and API key.${C.reset}`); process.exitCode=1; return; }
151
+ console.log(` ${C.green}✔ Discovered ${models.length} total models.${C.reset}`);
152
+ if (args.filter) {
153
+ models = args.filter.toLowerCase() === 'free' ? models.filter((m) => m.is_free || m.id.includes(':free')) : models.filter((m) => m.id.toLowerCase().includes(args.filter.toLowerCase()));
154
+ console.log(` ${C.cyan}✔ Filtered to ${models.length} models.${C.reset}`);
155
+ }
156
+ if (args.limit > 0) models = models.slice(0, args.limit);
157
+ if (!models.length) { console.error(`${C.yellow}⚠ No models match the filter.${C.reset}`); return; }
158
+
159
+ console.log(`\n${C.bold}🚀 Starting benchmark (${models.length} models | Concurrency: ${args.concurrency} | Runs: ${args.runs})...${C.reset}\n`);
160
+ const results = [];
161
+ const started = performance.now();
162
+ await runPool(models, Math.max(1,args.concurrency), (m) => benchmarkModel({ chatUrl, modelId:m.id, apiKey:args.key, prompt:args.prompt, systemPrompt:args.system, maxTokens:args.maxTokens, timeout:args.timeout, runs:Math.max(1,args.runs) }), (r) => {
163
+ results.push(r);
164
+ const ttft = r.avg_ttft == null ? '—' : r.avg_ttft < 1 ? `${Math.round(r.avg_ttft*1000)}ms` : `${r.avg_ttft.toFixed(2)}s`;
165
+ const tps = r.avg_tps == null ? '—' : `${r.avg_tps.toFixed(1)} tok/s`;
166
+ console.log(` [${String(results.length).padStart(2)}/${models.length}] ${statusLabel(r.status).padEnd(10)} ${r.model.slice(0,29).padEnd(29)} │ TTFT: ${ttft.padEnd(7)} │ Speed: ${tps}`);
167
+ });
168
+ const rank = { PASS: 0, PARTIAL: 1, SKIPPED: 2, FAIL: 3 };
169
+ results.sort((a,b) => ((rank[a.status] ?? 9) - (rank[b.status] ?? 9)) || ((a.avg_ttft ?? 999) - (b.avg_ttft ?? 999)));
170
+ const elapsed = (performance.now() - started) / 1000;
171
+ printTable(results);
172
+ printSummary(results, elapsed);
173
+ if (!args.noReport) {
174
+ const ts = timestamp();
175
+ args.json ||= `benchmark_report_${ts}.json`;
176
+ args.csv ||= `benchmark_report_${ts}.csv`;
177
+ args.md ||= `benchmark_report_${ts}.md`;
178
+ args.html ||= `benchmark_report_${ts}.html`;
179
+ const created = exportReports(results, args.endpoint, { json:args.json, csv:args.csv, md:args.md, html:args.html });
180
+ console.log(`\n${C.bold}📁 Exported Reports:${C.reset}`);
181
+ created.forEach((file) => console.log(` ✔ ${pathToFileURL(file).href}`));
182
+ if (args.open) maybeOpen(args.html);
183
+ }
184
+ console.log(`\n 💡 Next time: ${C.cyan}llmtest ${args.endpoint} <your_api_key>${args.filter ? ` ${args.filter}` : ''}${C.reset}\n`);
185
+ }
186
+
187
+ main().catch((error) => { console.error(`${C.red}✖ ${error.stack ?? error.message}${C.reset}`); process.exitCode = 1; });