ollanet 0.6.2 → 0.6.3

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/README.md CHANGED
@@ -192,7 +192,7 @@ optimizeDeps: { exclude: ["ollanet"] },
192
192
  | `ollanet pull <machine> <model>` | Ask that host to download / update a library model |
193
193
  | `ollanet show <machine> <model>` | Inspect Modelfile / params / capabilities (`[tuned]` when it looks like Finetuna) |
194
194
  | `ollanet rm <machine> <model> --yes` | Delete a model from that host’s disk |
195
- | `ollanet ps [machine]` | Models loaded in VRAM (omit machine = every discovered host) |
195
+ | `ollanet ps [machine]` | Models loaded in VRAM, with CPU/GPU % (omit machine = every discovered host) |
196
196
  | `ollanet prompt …` | Send a prompt / continue a chat (`--file` for `.txt` / `.md`) |
197
197
  | `ollanet compare …` | Same prompt on 2–5 models; summary + `compares/<id>.md` |
198
198
  | `ollanet chats` | List or inspect saved transcripts |
@@ -340,10 +340,12 @@ ollanet bench localhost gemma4:12b --hot --runs 5
340
340
  ```
341
341
 
342
342
  Runs a built-in suite (`quick` default, or `--suite full`) against one or more **completion**
343
- models: instruction checks once, throughput case repeated (`--runs`, default 3) with
343
+ models: instruction checks once, 256-token throughput repeated (`--runs`, default 3) with
344
344
  pinned `num_predict` / `seed` / `temperature`. Reports median tok/s + spread.
345
- `--hot` discards the first throughput run and leaves models loaded so the counted
346
- repeats are already warm (Finetuna steady-state). Under `--all`,
345
+ `--hot` discards the first 256-token run and leaves models loaded so the counted
346
+ repeats are already warm (Finetuna steady-state). `--suite full` also runs json/reason
347
+ checks and **one** 1024-token prose generation (`tok/s long`) — closer to a long chat
348
+ reply. Under `--all`,
347
349
  skips non-completion models (embeddings); multimodal `completion`+`vision` models stay in
348
350
  (early-stop / pass_rate handle text-suite failures). Use `--exclude-vision` to drop them.
349
351
  Saves `benchmarks/<id>.json` (or `~/.ollanet/benchmarks/` when installed).
@@ -351,7 +353,7 @@ See [`docs/bench-spec.md`](docs/bench-spec.md) for measurement rules.
351
353
 
352
354
  `quick` has only two scored checks (`ping`, `math`), so `pass` is a coarse
353
355
  liveness/sanity gauge (`0/2`, `1/2`, `2/2`) — not “50% broken.” Use `--suite full`
354
- when you care more about the quality signal.
356
+ when you care more about the quality signal or want the long tok/s column.
355
357
 
356
358
  ## Discovery
357
359
 
@@ -3,14 +3,21 @@ export interface BenchCase {
3
3
  id: string;
4
4
  role: CaseRole;
5
5
  prompt: string;
6
- /** Cap for this case; throughput uses settings.throughput_num_predict. */
6
+ /** Cap for this case. Peak throughput uses `--num-predict` when omitted. */
7
7
  num_predict?: number;
8
+ /**
9
+ * Throughput attempts. Omit to use `--runs` (and `--hot` discard).
10
+ * Set `1` for a single large shot that does not repeat.
11
+ */
12
+ repeats?: number;
8
13
  format?: "json";
9
14
  check?: (content: string) => {
10
15
  ok: boolean;
11
16
  detail?: string;
12
17
  };
13
18
  }
19
+ /** Pinned length for `full`'s prose throughput case (`throughput_long`). */
20
+ export declare const LONG_THROUGHPUT_NUM_PREDICT = 1024;
14
21
  export type SuiteName = "quick" | "full";
15
22
  export declare function getSuiteCases(suite: SuiteName): BenchCase[];
16
23
  export declare function suiteRevision(suite: SuiteName): string;
@@ -2,6 +2,8 @@
2
2
  * Built-in ollanet bench suites, checkers, and revision hashes.
3
3
  */
4
4
  import { createHash } from "node:crypto";
5
+ /** Pinned length for `full`'s prose throughput case (`throughput_long`). */
6
+ export const LONG_THROUGHPUT_NUM_PREDICT = 1024;
5
7
  const MATH_ANSWER = 323;
6
8
  const REASON_ANSWER = 42;
7
9
  function lastTokenNormalized(content) {
@@ -104,18 +106,23 @@ const FULL_EXTRA = [
104
106
  {
105
107
  id: "reason",
106
108
  role: "check",
107
- prompt: "A box has 10 apples. You eat 3, then buy 5 more, then give away 2 times as many as you ate. " +
108
- "How many apples are left? Reply with only the final number.",
109
+ prompt: "Start with 40. Add 7. Subtract 5. Reply with only the final number.",
109
110
  num_predict: 48,
110
- // 10 - 3 + 5 - 6 = 6... wait: gave away 2*3=6, so 10-3+5-6=6. Spec said 42 as placeholder.
111
- // Use a problem that clearly ends at 42:
112
- // Actually rewrite for 42: "Start with 40. Add 7. Subtract 5. Reply with only the number." => 42
113
111
  check: checkExpectedInt(REASON_ANSWER),
114
112
  },
113
+ {
114
+ id: "throughput_long",
115
+ role: "throughput",
116
+ // Chat-shaped tokens (markdown, lists, a table) at a long pin. Anti-EOS
117
+ // so done_reason=length; one shot (not --runs) so full stays usable.
118
+ prompt: "Write a detailed trade-off briefing for a product team choosing a web UI stack. " +
119
+ "Compare at least two options. Use markdown headings, bullet lists, and at least one table. " +
120
+ "Keep adding sections (testing, hiring, migration, failure modes, cost) without wrapping up. " +
121
+ "Do not write a closing recommendation that ends the piece. Do not stop early.",
122
+ num_predict: LONG_THROUGHPUT_NUM_PREDICT,
123
+ repeats: 1,
124
+ },
115
125
  ];
116
- // Fix reason prompt to match REASON_ANSWER
117
- FULL_EXTRA[FULL_EXTRA.length - 1].prompt =
118
- "Start with 40. Add 7. Subtract 5. Reply with only the final number.";
119
126
  export function getSuiteCases(suite) {
120
127
  if (suite === "full")
121
128
  return [...QUICK_CASES, ...FULL_EXTRA];
package/dist/bench.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * ollanet bench — speed + lightweight quality suite against Ollama models.
3
3
  */
4
4
  import { defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
5
- import { comparabilityKey, getSuiteCases, median, suiteRevision, } from "./bench-suite.js";
5
+ import { comparabilityKey, getSuiteCases, LONG_THROUGHPUT_NUM_PREDICT, median, suiteRevision, } from "./bench-suite.js";
6
6
  import { benchmarksDir, newBenchId, saveBenchmark } from "./bench-store.js";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
@@ -23,19 +23,20 @@ Examples:
23
23
  ollanet bench studio gemma3:12b --hot --runs 5
24
24
  ollanet help bench
25
25
 
26
- Fixed suite (not compare). Checks run once; throughput repeats --runs times.
26
+ Fixed suite (not compare). Checks run once; 256-token throughput repeats --runs times.
27
27
  Median tok/s excludes early-stops. Unload is between models, not between repeats.
28
+ full adds json/reason checks plus one 1024-token prose generation (tok/s long).
28
29
 
29
30
  Options:
30
31
  --all Every completion-capable model from /api/tags
31
32
  --exclude-vision With --all, skip models that advertise vision
32
33
  --suite quick|full Prompt suite (default quick)
33
- --runs <n> Counted throughput repeats (default 3)
34
- --hot Discard first throughput run; keep models loaded
34
+ --runs <n> Counted 256-token throughput repeats (default 3)
35
+ --hot Discard first 256-token throughput run; keep models loaded
35
36
  --warmup Short discarded call before timed cases (default on)
36
37
  --no-warmup Skip the short warmup (--hot implies this)
37
38
  --cold-load Measure cold load via unload + /api/ps
38
- --num-predict <n> Pin throughput length (default 256)
39
+ --num-predict <n> Pin 256-token throughput length (default 256; not the long case)
39
40
  --num-ctx <n> Context window override
40
41
  --keep-alive <value> Keep model loaded (e.g. 5m, 0, -1)
41
42
  --think / --no-think Thinking tokens (default off)
@@ -65,6 +66,26 @@ function resolveModelName(tags, query) {
65
66
  const prefix = tags.find((t) => t.name.toLowerCase().startsWith(`${q}:`) || q.startsWith(`${t.name.toLowerCase()}:`));
66
67
  return prefix?.name;
67
68
  }
69
+ function pinnedNumPredict(caseDef, throughputNumPredict) {
70
+ if (caseDef.role === "throughput") {
71
+ return caseDef.num_predict ?? throughputNumPredict;
72
+ }
73
+ return caseDef.num_predict ?? 32;
74
+ }
75
+ /** At least the global timeout; longer pins need room to finish at ~8 tok/s. */
76
+ function attemptTimeoutMs(numPredict) {
77
+ return Math.max(BENCH_TIMEOUT_MS, Math.ceil(numPredict / 8) * 1000);
78
+ }
79
+ function caseReps(caseDef, runs, hot) {
80
+ if (caseDef.role !== "throughput")
81
+ return 1;
82
+ if (caseDef.repeats != null)
83
+ return caseDef.repeats;
84
+ return runs + (hot ? 1 : 0);
85
+ }
86
+ function discardFirstThroughput(caseDef, hot) {
87
+ return hot && caseDef.role === "throughput" && caseDef.repeats == null;
88
+ }
68
89
  function tokPerSec(chunk) {
69
90
  if (chunk.eval_count == null || chunk.eval_duration == null || chunk.eval_duration <= 0) {
70
91
  return undefined;
@@ -285,9 +306,7 @@ async function runColdLoad(baseUrl, model, keepAlive) {
285
306
  }
286
307
  }
287
308
  async function runCaseAttempt(opts) {
288
- const numPredict = opts.caseDef.role === "throughput"
289
- ? opts.throughputNumPredict
290
- : (opts.caseDef.num_predict ?? 32);
309
+ const numPredict = pinnedNumPredict(opts.caseDef, opts.throughputNumPredict);
291
310
  const settings = mergeSettings(opts.settings, {
292
311
  temperature: 0,
293
312
  seed: 0,
@@ -303,7 +322,7 @@ async function runCaseAttempt(opts) {
303
322
  messages: [{ role: "user", content: opts.caseDef.prompt }],
304
323
  stream: false,
305
324
  writeStdout: false,
306
- timeoutMs: BENCH_TIMEOUT_MS,
325
+ timeoutMs: attemptTimeoutMs(numPredict),
307
326
  settings,
308
327
  });
309
328
  const wallMs = Date.now() - wall0;
@@ -373,12 +392,23 @@ async function runCaseAttempt(opts) {
373
392
  function countedAttempts(attempts) {
374
393
  return attempts.filter((a) => !a.discarded);
375
394
  }
376
- function summarizeModel(cases, cold, selfJudge) {
377
- const throughput = cases.find((c) => c.id === "throughput");
378
- const counted = countedAttempts(throughput?.attempts ?? []);
395
+ function speedFrom(cases, id) {
396
+ const counted = countedAttempts(cases.find((c) => c.id === id)?.attempts ?? []);
379
397
  const fullLen = counted.filter((a) => !a.error && !a.early_stop && a.tok_s != null);
380
398
  const toks = fullLen.map((a) => a.tok_s);
381
- const earlyStopCount = counted.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length")).length;
399
+ return {
400
+ median: median(toks) ?? null,
401
+ min: toks.length ? Math.min(...toks) : null,
402
+ max: toks.length ? Math.max(...toks) : null,
403
+ };
404
+ }
405
+ function summarizeModel(cases, cold, selfJudge) {
406
+ const peak = speedFrom(cases, "throughput");
407
+ const long = speedFrom(cases, "throughput_long");
408
+ const throughputAttempts = cases
409
+ .filter((c) => c.role === "throughput")
410
+ .flatMap((c) => countedAttempts(c.attempts));
411
+ const earlyStopCount = throughputAttempts.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length")).length;
382
412
  const checkAttempts = cases
383
413
  .filter((c) => c.role === "check")
384
414
  .flatMap((c) => c.attempts)
@@ -386,9 +416,12 @@ function summarizeModel(cases, cold, selfJudge) {
386
416
  const passed = checkAttempts.filter((a) => a.quality?.pass).length;
387
417
  const passRate = checkAttempts.length > 0 ? passed / checkAttempts.length : null;
388
418
  return {
389
- tok_s_median: median(toks) ?? null,
390
- tok_s_min: toks.length ? Math.min(...toks) : null,
391
- tok_s_max: toks.length ? Math.max(...toks) : null,
419
+ tok_s_median: peak.median,
420
+ tok_s_min: peak.min,
421
+ tok_s_max: peak.max,
422
+ tok_s_long_median: long.median,
423
+ tok_s_long_min: long.min,
424
+ tok_s_long_max: long.max,
392
425
  early_stop_count: earlyStopCount,
393
426
  pass_rate: passRate,
394
427
  load_ms: cold && cold.error == null && cold.load_ms > 0 ? cold.load_ms : cold?.error ? null : cold?.load_ms ?? null,
@@ -404,17 +437,22 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
404
437
  lines.push("Cold-load: on");
405
438
  if (hot)
406
439
  lines.push("Hot: first throughput run discarded; models stay loaded");
440
+ if (suite === "full")
441
+ lines.push("full: tok/s long is one 1024-token prose generation");
407
442
  lines.push("");
443
+ const showLong = suite === "full";
408
444
  const header = coldLoad
409
445
  ? pad("Model", MODEL_COL) +
410
446
  pad("tok/s (med)", 12) +
411
447
  pad("spread", 12) +
448
+ (showLong ? pad("tok/s long", 12) : "") +
412
449
  pad("load", 8) +
413
450
  pad("pass", 8) +
414
451
  "notes"
415
452
  : pad("Model", MODEL_COL) +
416
453
  pad("tok/s (med)", 12) +
417
454
  pad("spread", 12) +
455
+ (showLong ? pad("tok/s long", 12) : "") +
418
456
  pad("pass", 8) +
419
457
  "notes";
420
458
  lines.push(header);
@@ -448,6 +486,7 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
448
486
  const spread = m.summary.tok_s_min != null && m.summary.tok_s_max != null
449
487
  ? `${m.summary.tok_s_min.toFixed(1)}–${m.summary.tok_s_max.toFixed(1)}`
450
488
  : "—";
489
+ const tokLong = m.summary.tok_s_long_median != null ? m.summary.tok_s_long_median.toFixed(1) : "—";
451
490
  const pass = m.summary.pass_rate != null
452
491
  ? `${Math.round(m.summary.pass_rate * checkDenom(m))}/${checkDenom(m)}`
453
492
  : "—";
@@ -467,12 +506,14 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
467
506
  ? pad(m.name, MODEL_COL) +
468
507
  pad(tok, 12) +
469
508
  pad(spread, 12) +
509
+ (showLong ? pad(tokLong, 12) : "") +
470
510
  pad(load, 8) +
471
511
  pad(pass, 8) +
472
512
  notes.join("; ")
473
513
  : pad(m.name, MODEL_COL) +
474
514
  pad(tok, 12) +
475
515
  pad(spread, 12) +
516
+ (showLong ? pad(tokLong, 12) : "") +
476
517
  pad(pass, 8) +
477
518
  notes.join("; ");
478
519
  lines.push(row);
@@ -585,10 +626,13 @@ export async function main() {
585
626
  hot: parsed.hot,
586
627
  });
587
628
  const suiteCases = getSuiteCases(parsed.suite);
588
- const throughputReps = parsed.runs + (parsed.hot ? 1 : 0);
589
629
  const caseCount = selected.length *
590
- (suiteCases.filter((c) => c.role !== "throughput").length +
591
- suiteCases.filter((c) => c.role === "throughput").length * throughputReps);
630
+ suiteCases.reduce((n, c) => n + caseReps(c, parsed.runs, parsed.hot), 0);
631
+ const worstCaseMs = selected.length *
632
+ suiteCases.reduce((n, c) => {
633
+ const pin = pinnedNumPredict(c, parsed.throughputNumPredict);
634
+ return n + caseReps(c, parsed.runs, parsed.hot) * attemptTimeoutMs(pin);
635
+ }, 0);
592
636
  if (!parsed.json) {
593
637
  const skipBits = [
594
638
  skipped.filter((s) => s.reason === "non-completion").length
@@ -603,7 +647,10 @@ export async function main() {
603
647
  ` × suite=${parsed.suite} × runs=${parsed.runs}` +
604
648
  (parsed.hot ? " (hot: +1 discarded)" : ""));
605
649
  console.error(`Cases: ~${caseCount} chat calls timeout: ${BENCH_TIMEOUT_MS}ms/case` +
606
- ` worst-case ceiling: ~${Math.ceil((caseCount * BENCH_TIMEOUT_MS) / 60000)}m if every call times out`);
650
+ (parsed.suite === "full"
651
+ ? ` (long case ≥${attemptTimeoutMs(LONG_THROUGHPUT_NUM_PREDICT)}ms)`
652
+ : "") +
653
+ ` worst-case ceiling: ~${Math.ceil(worstCaseMs / 60000)}m if every call times out`);
607
654
  if (!parsed.hot && !isLoopback(host) && (selected.length >= 2 || parsed.coldLoad)) {
608
655
  const bits = [
609
656
  selected.length >= 2 ? "unloads each model before the next" : "",
@@ -665,8 +712,8 @@ export async function main() {
665
712
  let modelError;
666
713
  for (const caseDef of suiteCases) {
667
714
  const attempts = [];
668
- const discardFirst = parsed.hot && caseDef.role === "throughput";
669
- const reps = caseDef.role === "throughput" ? parsed.runs + (discardFirst ? 1 : 0) : 1;
715
+ const discardFirst = discardFirstThroughput(caseDef, parsed.hot);
716
+ const reps = caseReps(caseDef, parsed.runs, parsed.hot);
670
717
  for (let i = 0; i < reps; i += 1) {
671
718
  const discarded = discardFirst && i === 0;
672
719
  const attempt = await runCaseAttempt({
@@ -758,6 +805,7 @@ export async function main() {
758
805
  temperature: 0,
759
806
  seed: 0,
760
807
  throughput_num_predict: parsed.throughputNumPredict,
808
+ throughput_long_num_predict: parsed.suite === "full" ? LONG_THROUGHPUT_NUM_PREDICT : null,
761
809
  num_ctx: baseSettings.num_ctx ?? null,
762
810
  warmup: parsed.warmup,
763
811
  hot: parsed.hot,
package/dist/mcp.js CHANGED
@@ -200,7 +200,7 @@ const TOOLS = [
200
200
  {
201
201
  name: "ollanet_ps",
202
202
  description: "List models currently loaded in VRAM. Optional machine; omit to probe every discovered host. " +
203
- "scan is on-disk inventory; ps is what is resident right now.",
203
+ "scan is on-disk inventory; ps is what is resident right now, with CPU/GPU %.",
204
204
  inputSchema: {
205
205
  type: "object",
206
206
  properties: {
@@ -74,6 +74,8 @@ export declare function ollamaVersion(baseUrl: string, timeoutMs: number): Promi
74
74
  export interface PsModel {
75
75
  name?: string;
76
76
  model?: string;
77
+ /** Total resident bytes (VRAM + CPU/RAM). */
78
+ size?: number;
77
79
  size_vram?: number;
78
80
  context_length?: number;
79
81
  digest?: string;
package/dist/ps.d.ts CHANGED
@@ -8,7 +8,12 @@ import { type AppConfig } from "./config.js";
8
8
  export declare function helpText(): string;
9
9
  export interface LoadedModel {
10
10
  name: string;
11
+ size?: number;
11
12
  size_vram?: number;
13
+ cpu_percent?: number;
14
+ gpu_percent?: number;
15
+ /** Same wording as `ollama ps` PROCESSOR (e.g. "100% GPU", "83%/17% CPU/GPU"). */
16
+ processor?: string;
12
17
  context_length?: number;
13
18
  digest?: string;
14
19
  tuned: boolean;
@@ -28,5 +33,11 @@ export interface PsOptions {
28
33
  export interface PsResult {
29
34
  hosts: LoadedHost[];
30
35
  }
36
+ /** Match `ollama ps` PROCESSOR: CPU% from (size - size_vram) / size. */
37
+ export declare function processorSplit(size?: number, sizeVram?: number): {
38
+ cpu_percent?: number;
39
+ gpu_percent?: number;
40
+ processor?: string;
41
+ };
31
42
  export declare function listLoaded(opts?: PsOptions): Promise<PsResult>;
32
43
  export declare function main(): Promise<void>;
package/dist/ps.js CHANGED
@@ -23,7 +23,8 @@ Examples:
23
23
  ollanet ps studio
24
24
  ollanet ps studio --json
25
25
 
26
- scan lists models on disk. ps lists what is loaded in VRAM right now.
26
+ scan lists models on disk. ps lists what is loaded and the CPU/GPU split
27
+ (same rule as ollama ps: size_vram / size).
27
28
 
28
29
  Options:
29
30
  --machine <name> One host (default: every discovered host)
@@ -76,11 +77,31 @@ function formatBytes(bytes) {
76
77
  }
77
78
  return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
78
79
  }
80
+ /** Match `ollama ps` PROCESSOR: CPU% from (size - size_vram) / size. */
81
+ export function processorSplit(size, sizeVram) {
82
+ const total = size ?? 0;
83
+ const vram = sizeVram ?? 0;
84
+ if (vram === 0 && total === 0)
85
+ return {};
86
+ if (vram === 0)
87
+ return { cpu_percent: 100, gpu_percent: 0, processor: "100% CPU" };
88
+ if (total > 0 && vram === total) {
89
+ return { cpu_percent: 0, gpu_percent: 100, processor: "100% GPU" };
90
+ }
91
+ if (vram > total || total === 0)
92
+ return { processor: "Unknown" };
93
+ const cpu = Math.round(((total - vram) / total) * 100);
94
+ const gpu = 100 - cpu;
95
+ return { cpu_percent: cpu, gpu_percent: gpu, processor: `${cpu}%/${gpu}% CPU/GPU` };
96
+ }
79
97
  function toLoaded(m) {
80
98
  const name = (m.name ?? m.model ?? "").trim();
99
+ const split = processorSplit(m.size, m.size_vram);
81
100
  return {
82
101
  name,
102
+ size: m.size,
83
103
  size_vram: m.size_vram,
104
+ ...split,
84
105
  context_length: m.context_length,
85
106
  digest: m.digest,
86
107
  tuned: looksTuned(name),
@@ -143,7 +164,7 @@ function printPs(result) {
143
164
  const vram = formatBytes(m.size_vram);
144
165
  const ctx = m.context_length != null ? `ctx ${m.context_length}` : "";
145
166
  const tag = m.tuned ? " [tuned]" : "";
146
- const meta = [vram ? `VRAM ${vram}` : "", ctx].filter(Boolean).join(" ");
167
+ const meta = [vram ? `VRAM ${vram}` : "", m.processor, ctx].filter(Boolean).join(" ");
147
168
  process.stdout.write(meta ? ` ${m.name} ${meta}${tag}\n` : ` ${m.name}${tag}\n`);
148
169
  }
149
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ollanet",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Discover and talk to Ollama on any reachable network — CLI, MCP, and a Node library",
5
5
  "type": "module",
6
6
  "bin": {