ollanet 0.1.1 → 0.2.1

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/dist/bench.js ADDED
@@ -0,0 +1,789 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ollanet bench — speed + lightweight quality suite against Ollama models.
4
+ */
5
+ import { defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
6
+ import { comparabilityKey, getSuiteCases, median, suiteRevision, } from "./bench-suite.js";
7
+ import { benchmarksDir, newBenchId, saveBenchmark } from "./bench-store.js";
8
+ import { readFile } from "node:fs/promises";
9
+ import { discoverHosts, envInt, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
10
+ import { projectPath } from "./paths.js";
11
+ import { contextLengthForModel, isCompletionCapable, isVisionCapable, ollamaChat, ollamaShow, ollamaTags, ollamaUnload, ollamaVersion, ollamaPs, psHasModel, shouldWarnNoThinking, vramForModel, waitUntilUnloaded, } from "./ollama-chat.js";
12
+ const MODEL_COL = 34;
13
+ const BENCH_TIMEOUT_MS = envInt("OLLAMA_BENCH_TIMEOUT_MS", 60_000);
14
+ const UNLOAD_WAIT_MS = envInt("OLLAMA_BENCH_UNLOAD_WAIT_MS", 60_000);
15
+ function usage() {
16
+ console.error(`Usage:
17
+ ollanet bench <machine|ip> [model...] [options]
18
+ ollanet bench <machine|ip> --all [options]
19
+
20
+ Options:
21
+ --all Every completion-capable model from /api/tags
22
+ --exclude-vision With --all, skip models that advertise vision
23
+ --suite quick|full Prompt suite (default quick)
24
+ --runs <n> Throughput repeats (default 3)
25
+ --warmup / --no-warmup
26
+ --cold-load Measure cold load via unload + /api/ps
27
+ --num-predict <n> Pin throughput length (default 256)
28
+ --num-ctx <n>
29
+ --keep-alive <value>
30
+ --think / --no-think
31
+ --judge --judge-model <name>
32
+ --json Machine-readable stdout
33
+ --save / --no-save
34
+ --fail-fast
35
+ --fail-on-error Exit 2 if any model/case errored
36
+
37
+ See docs/bench-spec.md for measurement rules.`);
38
+ process.exit(1);
39
+ }
40
+ function takeValue(args, flag) {
41
+ const value = args.shift();
42
+ if (!value) {
43
+ console.error(`${flag} requires a value`);
44
+ usage();
45
+ }
46
+ return value;
47
+ }
48
+ function parseNumberFlag(raw, flag) {
49
+ const n = Number(raw);
50
+ if (!Number.isFinite(n)) {
51
+ console.error(`${flag} must be a number (got "${raw}")`);
52
+ usage();
53
+ }
54
+ return n;
55
+ }
56
+ function isLoopback(host) {
57
+ return (host.source === "localhost" ||
58
+ host.ip === "127.0.0.1" ||
59
+ host.ip === "::1" ||
60
+ shortName(host).toLowerCase() === "localhost");
61
+ }
62
+ function resolveModelName(tags, query) {
63
+ const q = query.toLowerCase();
64
+ const exact = tags.find((t) => t.name.toLowerCase() === q);
65
+ if (exact)
66
+ return exact.name;
67
+ const prefix = tags.find((t) => t.name.toLowerCase().startsWith(`${q}:`) || q.startsWith(`${t.name.toLowerCase()}:`));
68
+ return prefix?.name;
69
+ }
70
+ function tokPerSec(chunk) {
71
+ if (chunk.eval_count == null || chunk.eval_duration == null || chunk.eval_duration <= 0) {
72
+ return undefined;
73
+ }
74
+ return chunk.eval_count / (chunk.eval_duration / 1e9);
75
+ }
76
+ function parseJudgeScore(content) {
77
+ const m = content.trim().match(/\b([1-5])\b/);
78
+ if (!m)
79
+ return undefined;
80
+ return Number(m[1]);
81
+ }
82
+ function parseArgs(argv) {
83
+ const args = [...argv];
84
+ let suite = "quick";
85
+ let runs = 3;
86
+ let all = false;
87
+ let excludeVision = false;
88
+ let warmup = true;
89
+ let coldLoad = false;
90
+ let json = false;
91
+ let save = true;
92
+ let failFast = false;
93
+ let failOnError = false;
94
+ let judge = false;
95
+ let judgeModel;
96
+ let throughputNumPredict = 256;
97
+ const settings = {};
98
+ const positional = [];
99
+ while (args.length > 0) {
100
+ const arg = args.shift();
101
+ if (arg === "--" || arg === "--help" || arg === "-h")
102
+ usage();
103
+ if (arg === "--all") {
104
+ all = true;
105
+ continue;
106
+ }
107
+ if (arg === "--exclude-vision") {
108
+ excludeVision = true;
109
+ continue;
110
+ }
111
+ // Back-compat no-op: 0.2.0 defaulted to skipping vision; prefer --exclude-vision.
112
+ if (arg === "--include-vision") {
113
+ excludeVision = false;
114
+ continue;
115
+ }
116
+ if (arg === "--json") {
117
+ json = true;
118
+ continue;
119
+ }
120
+ if (arg === "--save") {
121
+ save = true;
122
+ continue;
123
+ }
124
+ if (arg === "--no-save") {
125
+ save = false;
126
+ continue;
127
+ }
128
+ if (arg === "--warmup") {
129
+ warmup = true;
130
+ continue;
131
+ }
132
+ if (arg === "--no-warmup") {
133
+ warmup = false;
134
+ continue;
135
+ }
136
+ if (arg === "--cold-load") {
137
+ coldLoad = true;
138
+ continue;
139
+ }
140
+ if (arg === "--fail-fast") {
141
+ failFast = true;
142
+ continue;
143
+ }
144
+ if (arg === "--fail-on-error") {
145
+ failOnError = true;
146
+ continue;
147
+ }
148
+ if (arg === "--think") {
149
+ settings.think = true;
150
+ continue;
151
+ }
152
+ if (arg === "--no-think") {
153
+ settings.think = false;
154
+ continue;
155
+ }
156
+ if (arg === "--judge") {
157
+ judge = true;
158
+ continue;
159
+ }
160
+ if (arg === "--suite" || arg.startsWith("--suite=")) {
161
+ const raw = arg.includes("=") ? arg.slice("--suite=".length) : takeValue(args, "--suite");
162
+ if (raw !== "quick" && raw !== "full") {
163
+ console.error(`Unknown suite "${raw}" (use quick|full)`);
164
+ usage();
165
+ }
166
+ suite = raw;
167
+ continue;
168
+ }
169
+ if (arg === "--runs" || arg.startsWith("--runs=")) {
170
+ const raw = arg.includes("=") ? arg.slice("--runs=".length) : takeValue(args, "--runs");
171
+ runs = Math.max(1, Math.trunc(parseNumberFlag(raw, "--runs")));
172
+ continue;
173
+ }
174
+ if (arg === "--num-predict" || arg.startsWith("--num-predict=")) {
175
+ const raw = arg.includes("=")
176
+ ? arg.slice("--num-predict=".length)
177
+ : takeValue(args, "--num-predict");
178
+ throughputNumPredict = Math.trunc(parseNumberFlag(raw, "--num-predict"));
179
+ continue;
180
+ }
181
+ if (arg === "--num-ctx" || arg.startsWith("--num-ctx=")) {
182
+ const raw = arg.includes("=") ? arg.slice("--num-ctx=".length) : takeValue(args, "--num-ctx");
183
+ settings.num_ctx = Math.trunc(parseNumberFlag(raw, "--num-ctx"));
184
+ continue;
185
+ }
186
+ if (arg === "--keep-alive" || arg.startsWith("--keep-alive=")) {
187
+ const raw = arg.includes("=")
188
+ ? arg.slice("--keep-alive=".length)
189
+ : takeValue(args, "--keep-alive");
190
+ const asNum = Number(raw);
191
+ settings.keep_alive =
192
+ raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
193
+ continue;
194
+ }
195
+ if (arg === "--judge-model" || arg.startsWith("--judge-model=")) {
196
+ judgeModel = arg.includes("=")
197
+ ? arg.slice("--judge-model=".length)
198
+ : takeValue(args, "--judge-model");
199
+ continue;
200
+ }
201
+ if (arg.startsWith("-")) {
202
+ console.error(`Unknown flag: ${arg}`);
203
+ usage();
204
+ }
205
+ positional.push(arg);
206
+ }
207
+ if (positional.length < 1)
208
+ usage();
209
+ const machine = positional[0];
210
+ const models = positional.slice(1);
211
+ if (judge && !judgeModel) {
212
+ console.error("--judge requires --judge-model <name> (no default).");
213
+ process.exit(1);
214
+ }
215
+ if (throughputNumPredict < 64) {
216
+ console.error(`Warning: --num-predict ${throughputNumPredict} is very short; tok/s will be mostly overhead. Prefer ≥64 (default 256).`);
217
+ }
218
+ return {
219
+ machine,
220
+ models,
221
+ all,
222
+ excludeVision,
223
+ suite,
224
+ runs,
225
+ warmup,
226
+ coldLoad,
227
+ json,
228
+ save,
229
+ failFast,
230
+ failOnError,
231
+ judge,
232
+ judgeModel,
233
+ throughputNumPredict,
234
+ settings,
235
+ };
236
+ }
237
+ /**
238
+ * Raw capabilities from /api/show. `undefined` = key absent / show failed
239
+ * (omitempty) → treat as completion-capable. Empty array is also permissive.
240
+ */
241
+ async function fetchCapabilities(baseUrl, name) {
242
+ try {
243
+ const shown = await ollamaShow(baseUrl, name, Math.min(BENCH_TIMEOUT_MS, 10_000));
244
+ if (!shown || !("capabilities" in shown) || shown.capabilities == null) {
245
+ return undefined;
246
+ }
247
+ return shown.capabilities;
248
+ }
249
+ catch {
250
+ return undefined;
251
+ }
252
+ }
253
+ async function runColdLoad(baseUrl, model, keepAlive) {
254
+ const started = Date.now();
255
+ try {
256
+ let ps = await ollamaPs(baseUrl, 5000);
257
+ if (psHasModel(ps, model)) {
258
+ await ollamaUnload(baseUrl, model, BENCH_TIMEOUT_MS);
259
+ const waited = await waitUntilUnloaded(baseUrl, model, UNLOAD_WAIT_MS);
260
+ if (!waited.unloaded) {
261
+ return { load_ms: 0, error: `unload timed out after ${waited.waitedMs}ms`, ps_poll_ms: waited.waitedMs };
262
+ }
263
+ }
264
+ const wall0 = Date.now();
265
+ const { chunk } = await ollamaChat({
266
+ baseUrl,
267
+ model,
268
+ messages: [{ role: "user", content: "x" }],
269
+ stream: false,
270
+ writeStdout: false,
271
+ timeoutMs: BENCH_TIMEOUT_MS,
272
+ settings: {
273
+ temperature: 0,
274
+ seed: 0,
275
+ num_predict: 1,
276
+ think: false,
277
+ keep_alive: keepAlive ?? "5m",
278
+ },
279
+ });
280
+ const loadNs = chunk.load_duration ?? 0;
281
+ const loadMs = loadNs / 1e6;
282
+ return {
283
+ load_ms: loadMs,
284
+ ollama: {
285
+ load_duration: chunk.load_duration,
286
+ eval_count: chunk.eval_count,
287
+ total_duration: chunk.total_duration,
288
+ },
289
+ ps_poll_ms: Date.now() - started - (Date.now() - wall0),
290
+ };
291
+ }
292
+ catch (err) {
293
+ return {
294
+ load_ms: 0,
295
+ error: err instanceof Error ? err.message : String(err),
296
+ ps_poll_ms: Date.now() - started,
297
+ };
298
+ }
299
+ }
300
+ async function runCaseAttempt(opts) {
301
+ const numPredict = opts.caseDef.role === "throughput"
302
+ ? opts.throughputNumPredict
303
+ : (opts.caseDef.num_predict ?? 32);
304
+ const settings = mergeSettings(opts.settings, {
305
+ temperature: 0,
306
+ seed: 0,
307
+ num_predict: numPredict,
308
+ think: opts.settings.think === true,
309
+ ...(opts.caseDef.format ? { format: opts.caseDef.format } : {}),
310
+ });
311
+ const wall0 = Date.now();
312
+ try {
313
+ const { content, chunk } = await ollamaChat({
314
+ baseUrl: opts.baseUrl,
315
+ model: opts.model,
316
+ messages: [{ role: "user", content: opts.caseDef.prompt }],
317
+ stream: false,
318
+ writeStdout: false,
319
+ timeoutMs: BENCH_TIMEOUT_MS,
320
+ settings,
321
+ });
322
+ const wallMs = Date.now() - wall0;
323
+ const doneReason = chunk.done_reason;
324
+ const earlyStop = opts.caseDef.role === "throughput" && doneReason != null && doneReason !== "length";
325
+ const tok_s = opts.caseDef.role === "throughput" ? tokPerSec(chunk) : undefined;
326
+ let quality;
327
+ if (opts.caseDef.check) {
328
+ const result = opts.caseDef.check(content);
329
+ quality = {
330
+ pass: result.ok,
331
+ checks: [{ id: opts.caseDef.id, ok: result.ok, detail: result.detail }],
332
+ };
333
+ }
334
+ let judge_score;
335
+ if (opts.judgeModel && opts.caseDef.role === "check" && quality?.pass) {
336
+ try {
337
+ const judged = await ollamaChat({
338
+ baseUrl: opts.baseUrl,
339
+ model: opts.judgeModel,
340
+ stream: false,
341
+ writeStdout: false,
342
+ timeoutMs: BENCH_TIMEOUT_MS,
343
+ settings: { temperature: 0, seed: 0, num_predict: 8, think: false },
344
+ messages: [
345
+ {
346
+ role: "user",
347
+ content: "Score the assistant reply from 1-5 for correctness and instruction-following. " +
348
+ "Reply with only the integer.\n\n" +
349
+ `User: ${opts.caseDef.prompt}\nAssistant: ${content}`,
350
+ },
351
+ ],
352
+ });
353
+ judge_score = parseJudgeScore(judged.content);
354
+ }
355
+ catch {
356
+ // omit score
357
+ }
358
+ }
359
+ return {
360
+ run: opts.run,
361
+ wall_ms: wallMs,
362
+ tok_s,
363
+ done_reason: doneReason,
364
+ early_stop: earlyStop || undefined,
365
+ quality,
366
+ content,
367
+ judge_score,
368
+ ollama: {
369
+ eval_count: chunk.eval_count,
370
+ eval_duration: chunk.eval_duration,
371
+ load_duration: chunk.load_duration,
372
+ total_duration: chunk.total_duration,
373
+ prompt_eval_count: chunk.prompt_eval_count,
374
+ done_reason: chunk.done_reason,
375
+ },
376
+ };
377
+ }
378
+ catch (err) {
379
+ return {
380
+ run: opts.run,
381
+ wall_ms: Date.now() - wall0,
382
+ error: err instanceof Error ? err.message : String(err),
383
+ };
384
+ }
385
+ }
386
+ function summarizeModel(cases, cold, selfJudge) {
387
+ const throughput = cases.find((c) => c.id === "throughput");
388
+ const fullLen = throughput?.attempts.filter((a) => !a.error && !a.early_stop && a.tok_s != null) ?? [];
389
+ const toks = fullLen.map((a) => a.tok_s);
390
+ const earlyStopCount = throughput?.attempts.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length"))
391
+ .length ?? 0;
392
+ const checkAttempts = cases
393
+ .filter((c) => c.role === "check")
394
+ .flatMap((c) => c.attempts)
395
+ .filter((a) => !a.error && a.quality);
396
+ const passed = checkAttempts.filter((a) => a.quality?.pass).length;
397
+ const passRate = checkAttempts.length > 0 ? passed / checkAttempts.length : null;
398
+ return {
399
+ tok_s_median: median(toks) ?? null,
400
+ tok_s_min: toks.length ? Math.min(...toks) : null,
401
+ tok_s_max: toks.length ? Math.max(...toks) : null,
402
+ early_stop_count: earlyStopCount,
403
+ pass_rate: passRate,
404
+ load_ms: cold && cold.error == null && cold.load_ms > 0 ? cold.load_ms : cold?.error ? null : cold?.load_ms ?? null,
405
+ self_judge: selfJudge,
406
+ };
407
+ }
408
+ function printTable(machine, suite, runs, revision, compKey, models, skipped, coldLoad) {
409
+ const lines = [];
410
+ lines.push(`Bench: ${models.length} completion model(s) × suite=${suite} × runs=${runs}`);
411
+ lines.push(`comparability_key=${compKey} suite_revision=${revision} timeout: ${BENCH_TIMEOUT_MS}ms/case`);
412
+ if (coldLoad)
413
+ lines.push("Cold-load: on");
414
+ lines.push("");
415
+ const header = coldLoad
416
+ ? pad("Model", MODEL_COL) +
417
+ pad("tok/s (med)", 12) +
418
+ pad("spread", 12) +
419
+ pad("load", 8) +
420
+ pad("pass", 8) +
421
+ "notes"
422
+ : pad("Model", MODEL_COL) +
423
+ pad("tok/s (med)", 12) +
424
+ pad("spread", 12) +
425
+ pad("pass", 8) +
426
+ "notes";
427
+ lines.push(header);
428
+ const sorted = [...models].sort((a, b) => {
429
+ const pa = a.summary.pass_rate;
430
+ const pb = b.summary.pass_rate;
431
+ if (pa == null && pb == null) {
432
+ /* both unknown */
433
+ }
434
+ else if (pa == null)
435
+ return 1;
436
+ else if (pb == null)
437
+ return -1;
438
+ else if (pb !== pa)
439
+ return pb - pa;
440
+ // Unavailable tok/s (all early-stop / errors) intentionally sinks below any
441
+ // defined median, including 0 — same pass_rate cohort, then speed.
442
+ const ta = a.summary.tok_s_median;
443
+ const tb = b.summary.tok_s_median;
444
+ if (ta == null && tb == null)
445
+ return 0;
446
+ if (ta == null)
447
+ return 1;
448
+ if (tb == null)
449
+ return -1;
450
+ return tb - ta;
451
+ });
452
+ for (const m of sorted) {
453
+ const tok = m.summary.tok_s_median != null ? m.summary.tok_s_median.toFixed(1) : "—";
454
+ // Match median precision — toFixed(0) on min/max can print "83.8 84–86".
455
+ const spread = m.summary.tok_s_min != null && m.summary.tok_s_max != null
456
+ ? `${m.summary.tok_s_min.toFixed(1)}–${m.summary.tok_s_max.toFixed(1)}`
457
+ : "—";
458
+ const pass = m.summary.pass_rate != null
459
+ ? `${Math.round(m.summary.pass_rate * checkDenom(m))}/${checkDenom(m)}`
460
+ : "—";
461
+ const notes = [];
462
+ if (m.error)
463
+ notes.push(m.error.slice(0, 60));
464
+ if (m.summary.early_stop_count > 0)
465
+ notes.push(`${m.summary.early_stop_count}× early-stop`);
466
+ if (m.summary.self_judge)
467
+ notes.push("self-judge");
468
+ const load = m.summary.load_ms != null && m.summary.load_ms > 0
469
+ ? `${(m.summary.load_ms / 1000).toFixed(1)}s`
470
+ : "—";
471
+ const row = coldLoad
472
+ ? pad(m.name, MODEL_COL) +
473
+ pad(tok, 12) +
474
+ pad(spread, 12) +
475
+ pad(load, 8) +
476
+ pad(pass, 8) +
477
+ notes.join("; ")
478
+ : pad(m.name, MODEL_COL) +
479
+ pad(tok, 12) +
480
+ pad(spread, 12) +
481
+ pad(pass, 8) +
482
+ notes.join("; ");
483
+ lines.push(row);
484
+ }
485
+ if (skipped.length > 0) {
486
+ lines.push("");
487
+ const byReason = (reason) => skipped.filter((s) => s.reason === reason);
488
+ const embed = byReason("non-completion");
489
+ const vision = byReason("vision");
490
+ if (embed.length) {
491
+ lines.push(`Skipped ${embed.length} non-completion model(s): ${embed.map((s) => s.name).join(", ")}`);
492
+ }
493
+ if (vision.length) {
494
+ lines.push(`Skipped ${vision.length} vision model(s): ${vision.map((s) => s.name).join(", ")}` +
495
+ " (--exclude-vision)");
496
+ }
497
+ }
498
+ process.stdout.write(`${lines.join("\n")}\n`);
499
+ }
500
+ function checkDenom(m) {
501
+ return m.cases.filter((c) => c.role === "check").flatMap((c) => c.attempts).length || 0;
502
+ }
503
+ function pad(s, n) {
504
+ return s.length >= n ? `${s.slice(0, n - 1)} ` : s + " ".repeat(n - s.length);
505
+ }
506
+ export async function main() {
507
+ const parsed = parseArgs(process.argv.slice(2));
508
+ const config = await loadConfig();
509
+ const { hosts: targets } = await discoverHosts({
510
+ hosts: config.hosts,
511
+ discovery: config.discovery,
512
+ });
513
+ const host = resolveHost(targets, parsed.machine);
514
+ const baseUrl = ollamaBaseUrl(host);
515
+ const machineLabel = shortName(host);
516
+ const tags = await ollamaTags(baseUrl, Math.min(BENCH_TIMEOUT_MS, 15_000));
517
+ if (tags.length === 0) {
518
+ throw new Error(`No models on ${machineLabel} (${baseUrl})`);
519
+ }
520
+ const tagNames = tags.map((t) => t.name);
521
+ const skipped = [];
522
+ let selected = [];
523
+ if (parsed.all) {
524
+ for (const t of tags) {
525
+ const caps = await fetchCapabilities(baseUrl, t.name);
526
+ if (!isCompletionCapable(caps)) {
527
+ skipped.push({ name: t.name, reason: "non-completion", capabilities: caps ?? [] });
528
+ continue;
529
+ }
530
+ // Default: completion only (spec). Vision+completion models (gemma3, etc.)
531
+ // stay in --all; early-stop / pass_rate report text-suite failures.
532
+ // Opt out of vision-capable models with --exclude-vision.
533
+ if (parsed.excludeVision && isVisionCapable(caps)) {
534
+ skipped.push({ name: t.name, reason: "vision", capabilities: caps ?? [] });
535
+ continue;
536
+ }
537
+ selected.push(t.name);
538
+ }
539
+ }
540
+ else if (parsed.models.length > 0) {
541
+ for (const q of parsed.models) {
542
+ const resolved = resolveModelName(tags, q);
543
+ if (!resolved) {
544
+ console.error(`Unknown model "${q}". Available: ${tagNames.join(", ")}`);
545
+ process.exit(1);
546
+ }
547
+ const caps = await fetchCapabilities(baseUrl, resolved);
548
+ if (!isCompletionCapable(caps)) {
549
+ console.error(`Model "${resolved}" is not completion-capable (${(caps ?? []).join(", ") || "none"}).`);
550
+ process.exit(1);
551
+ }
552
+ selected.push(resolved);
553
+ }
554
+ }
555
+ else {
556
+ const def = defaultModelForHost(config, host);
557
+ if (!def) {
558
+ console.error(`No model specified for "${machineLabel}". Pass model names, use --all, or set defaultModels.`);
559
+ process.exit(1);
560
+ }
561
+ const name = resolveModelName(tags, def);
562
+ if (!name) {
563
+ console.error(`Default model "${def}" not found on host. Available: ${tagNames.join(", ")}`);
564
+ process.exit(1);
565
+ }
566
+ const caps = await fetchCapabilities(baseUrl, name);
567
+ if (!isCompletionCapable(caps)) {
568
+ console.error(`Default model "${name}" is not completion-capable.`);
569
+ process.exit(1);
570
+ }
571
+ selected = [name];
572
+ }
573
+ if (selected.length === 0) {
574
+ console.error("No completion models to benchmark.");
575
+ process.exit(1);
576
+ }
577
+ let judgeModel;
578
+ if (parsed.judge && parsed.judgeModel) {
579
+ judgeModel = resolveModelName(tags, parsed.judgeModel);
580
+ if (!judgeModel) {
581
+ console.error(`Judge model "${parsed.judgeModel}" not found.`);
582
+ process.exit(1);
583
+ }
584
+ }
585
+ const baseSettings = mergeSettings(config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), parsed.settings, { think: parsed.settings.think === true ? true : false });
586
+ const revision = suiteRevision(parsed.suite);
587
+ const compKey = comparabilityKey({
588
+ suite: parsed.suite,
589
+ throughputNumPredict: parsed.throughputNumPredict,
590
+ seed: 0,
591
+ temperature: 0,
592
+ think: baseSettings.think === true,
593
+ numCtx: baseSettings.num_ctx ?? null,
594
+ });
595
+ const suiteCases = getSuiteCases(parsed.suite);
596
+ const caseCount = selected.length *
597
+ (suiteCases.filter((c) => c.role !== "throughput").length +
598
+ suiteCases.filter((c) => c.role === "throughput").length * parsed.runs);
599
+ if (!parsed.json) {
600
+ const skipBits = [
601
+ skipped.filter((s) => s.reason === "non-completion").length
602
+ ? `${skipped.filter((s) => s.reason === "non-completion").length} embedding/other`
603
+ : "",
604
+ skipped.filter((s) => s.reason === "vision").length
605
+ ? `${skipped.filter((s) => s.reason === "vision").length} vision`
606
+ : "",
607
+ ].filter(Boolean);
608
+ console.error(`Bench: ${selected.length} completion model(s)` +
609
+ (skipBits.length ? ` (skipped ${skipBits.join(", ")})` : "") +
610
+ ` × suite=${parsed.suite} × runs=${parsed.runs}`);
611
+ console.error(`Cases: ~${caseCount} chat calls timeout: ${BENCH_TIMEOUT_MS}ms/case` +
612
+ ` worst-case ceiling: ~${Math.ceil((caseCount * BENCH_TIMEOUT_MS) / 60000)}m if every call times out`);
613
+ if (!isLoopback(host)) {
614
+ console.error(`Note: benchmarking unloads models on ${machineLabel} between runs` +
615
+ (parsed.coldLoad ? " (and for --cold-load)" : "") +
616
+ ".");
617
+ }
618
+ console.error("Proceeding…");
619
+ }
620
+ if (parsed.settings.think === true) {
621
+ for (const name of selected) {
622
+ const caps = await fetchCapabilities(baseUrl, name);
623
+ if (shouldWarnNoThinking(caps)) {
624
+ console.error(`Warning: ${name} has no thinking capability; --think may 400.`);
625
+ }
626
+ }
627
+ }
628
+ const ollamaVer = await ollamaVersion(baseUrl, 5000);
629
+ const modelResults = [];
630
+ let anyError = false;
631
+ for (let mi = 0; mi < selected.length; mi += 1) {
632
+ const model = selected[mi];
633
+ const info = tags.find((t) => t.name === model);
634
+ const caps = await fetchCapabilities(baseUrl, model);
635
+ const selfJudge = Boolean(judgeModel && judgeModel === model);
636
+ if (!parsed.json) {
637
+ console.error(`\n→ ${model} (${mi + 1}/${selected.length})`);
638
+ }
639
+ const cold = parsed.coldLoad
640
+ ? await runColdLoad(baseUrl, model, baseSettings.keep_alive)
641
+ : null;
642
+ if (cold?.error)
643
+ anyError = true;
644
+ if (parsed.warmup) {
645
+ try {
646
+ await ollamaChat({
647
+ baseUrl,
648
+ model,
649
+ messages: [{ role: "user", content: "hi" }],
650
+ stream: false,
651
+ writeStdout: false,
652
+ timeoutMs: BENCH_TIMEOUT_MS,
653
+ settings: mergeSettings(baseSettings, {
654
+ temperature: 0,
655
+ seed: 0,
656
+ num_predict: 8,
657
+ think: false,
658
+ }),
659
+ });
660
+ }
661
+ catch {
662
+ // warmup failure will likely repeat on cases
663
+ }
664
+ }
665
+ const cases = [];
666
+ let modelError;
667
+ for (const caseDef of suiteCases) {
668
+ const attempts = [];
669
+ const reps = caseDef.role === "throughput" ? parsed.runs : 1;
670
+ for (let r = 1; r <= reps; r += 1) {
671
+ const attempt = await runCaseAttempt({
672
+ baseUrl,
673
+ model,
674
+ caseDef,
675
+ run: r,
676
+ settings: baseSettings,
677
+ throughputNumPredict: parsed.throughputNumPredict,
678
+ judgeModel,
679
+ selfJudge,
680
+ });
681
+ attempts.push(attempt);
682
+ if (attempt.error) {
683
+ anyError = true;
684
+ if (!modelError)
685
+ modelError = attempt.error;
686
+ if (parsed.failFast)
687
+ break;
688
+ }
689
+ }
690
+ cases.push({ id: caseDef.id, role: caseDef.role, attempts });
691
+ if (parsed.failFast && modelError)
692
+ break;
693
+ }
694
+ // Capture VRAM + context while still loaded (after timed cases, before unload).
695
+ const ps = await ollamaPs(baseUrl, 5000);
696
+ const summary = summarizeModel(cases, cold, selfJudge);
697
+ if (cold && !cold.error && cold.load_ms > 0) {
698
+ summary.load_ms = cold.load_ms;
699
+ }
700
+ else if (!parsed.coldLoad) {
701
+ summary.load_ms = null;
702
+ }
703
+ else if (cold?.error) {
704
+ summary.load_ms = null;
705
+ }
706
+ modelResults.push({
707
+ name: model,
708
+ digest: info?.digest,
709
+ parameter_size: info?.details?.parameter_size,
710
+ quantization_level: info?.details?.quantization_level,
711
+ ...(caps ? { capabilities: caps } : {}),
712
+ size_vram: vramForModel(ps, model),
713
+ context_length: contextLengthForModel(ps, model),
714
+ cold_load: cold,
715
+ summary,
716
+ error: modelError,
717
+ cases,
718
+ });
719
+ if (parsed.failFast && modelError)
720
+ break;
721
+ // Unload before next model when ≥2 models remain after this one / multi-model run
722
+ if (selected.length >= 2 && mi < selected.length - 1) {
723
+ try {
724
+ await ollamaUnload(baseUrl, model, BENCH_TIMEOUT_MS);
725
+ await waitUntilUnloaded(baseUrl, model, UNLOAD_WAIT_MS);
726
+ }
727
+ catch {
728
+ // best-effort
729
+ }
730
+ }
731
+ }
732
+ let ollanetVersion = "0.2.0";
733
+ try {
734
+ const pkg = JSON.parse(await readFile(projectPath("package.json"), "utf8"));
735
+ if (pkg.version)
736
+ ollanetVersion = pkg.version;
737
+ }
738
+ catch {
739
+ // keep fallback
740
+ }
741
+ const result = {
742
+ id: newBenchId(),
743
+ version: 1,
744
+ created_at: new Date().toISOString(),
745
+ ollanet: ollanetVersion,
746
+ suite: parsed.suite,
747
+ suite_revision: revision,
748
+ comparability_key: compKey,
749
+ runs: parsed.runs,
750
+ machine: machineLabel,
751
+ host: { ip: host.ip, port: host.port },
752
+ ollama_version: ollamaVer,
753
+ settings: {
754
+ temperature: 0,
755
+ seed: 0,
756
+ throughput_num_predict: parsed.throughputNumPredict,
757
+ num_ctx: baseSettings.num_ctx ?? null,
758
+ warmup: parsed.warmup,
759
+ cold_load: parsed.coldLoad,
760
+ think: baseSettings.think === true,
761
+ bench_timeout_ms: BENCH_TIMEOUT_MS,
762
+ },
763
+ skipped_models: skipped,
764
+ judge: judgeModel
765
+ ? {
766
+ name: judgeModel,
767
+ digest: tags.find((t) => t.name === judgeModel)?.digest,
768
+ }
769
+ : null,
770
+ models: modelResults,
771
+ };
772
+ if (parsed.save) {
773
+ const file = await saveBenchmark(result);
774
+ if (!parsed.json)
775
+ console.error(`saved ${file}`);
776
+ }
777
+ if (parsed.json) {
778
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
779
+ }
780
+ else {
781
+ process.stdout.write("\n");
782
+ printTable(machineLabel, parsed.suite, parsed.runs, revision, compKey, modelResults, skipped, parsed.coldLoad);
783
+ if (parsed.save)
784
+ console.error(`benchmarks dir: ${benchmarksDir()}`);
785
+ }
786
+ if (parsed.failOnError && anyError) {
787
+ process.exitCode = 2;
788
+ }
789
+ }