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/README.md CHANGED
@@ -102,6 +102,7 @@ ollanet chats --id a1b2c3d4e5f6
102
102
  | `ollanet scan` | Probe known/discovered hosts for Ollama + list models |
103
103
  | `ollanet prompt …` | Send a prompt / continue a chat |
104
104
  | `ollanet chats` | List or inspect saved transcripts |
105
+ | `ollanet bench …` | Benchmark models for tok/s + lightweight quality checks |
105
106
 
106
107
  ### `scan` options
107
108
 
@@ -139,6 +140,26 @@ Machine can be a discovered name, hostname, FQDN, or IP (`192.168.1.50`, `host:1
139
140
  - `--json` — list summary JSON
140
141
  - `--id <hash>` — show one chat (full JSON with `--json`)
141
142
 
143
+ ### `bench`
144
+
145
+ ```bash
146
+ ollanet bench <machine|ip> [model...]
147
+ ollanet bench <machine|ip> --all
148
+ ollanet bench localhost gemma4:12b --runs 5 --cold-load --json
149
+ ```
150
+
151
+ Runs a built-in suite (`quick` default, or `--suite full`) against one or more **completion**
152
+ models: instruction checks once, throughput case repeated (`--runs`, default 3) with
153
+ pinned `num_predict` / `seed` / `temperature`. Reports median tok/s + spread. Under `--all`,
154
+ skips non-completion models (embeddings); multimodal `completion`+`vision` models stay in
155
+ (early-stop / pass_rate handle text-suite failures). Use `--exclude-vision` to drop them.
156
+ Saves `benchmarks/<id>.json` (or `~/.ollanet/benchmarks/` when installed).
157
+ See [`docs/bench-spec.md`](docs/bench-spec.md) for measurement rules.
158
+
159
+ `quick` has only two scored checks (`ping`, `math`), so `pass` is a coarse
160
+ liveness/sanity gauge (`0/2`, `1/2`, `2/2`) — not “50% broken.” Use `--suite full`
161
+ when you care more about the quality signal.
162
+
142
163
  ## Discovery
143
164
 
144
165
  ollanet combines several sources (deduped by `ip:port`):
@@ -205,6 +226,8 @@ Edit `~/.ollanet/config.json` (installed) or `config.json` (checkout) — or poi
205
226
  | `OLLANET_THINK` / `OLLAMA_THINK` | Enable thinking (`true`/`false`; default off) |
206
227
  | `OLLAMA_TIMEOUT_MS` | HTTP scan/probe timeout |
207
228
  | `OLLAMA_PROMPT_TIMEOUT_MS` | Prompt/chat HTTP timeout (default `600000`; `0` = none) |
229
+ | `OLLAMA_BENCH_TIMEOUT_MS` | Per-case bench timeout (default `60000`) |
230
+ | `OLLANET_BENCHMARKS_DIR` | Benchmark result directory |
208
231
  | `OLLAMA_CONCURRENCY` | Scan concurrency |
209
232
 
210
233
  ## Chat transcripts
@@ -233,9 +256,15 @@ ollanet/
233
256
  chat-store.ts
234
257
  config.ts
235
258
  paths.ts # checkout vs installed path resolution
259
+ ollama-chat.ts # shared /api/chat (+ tags/show/ps helpers)
260
+ bench.ts # ollanet bench
261
+ bench-suite.ts
262
+ bench-store.ts
236
263
  dist/ # compiled output (published to npm)
264
+ docs/bench-spec.md # bench measurement spec
237
265
  config.json
238
266
  responses/ # gitignored chat history
267
+ benchmarks/ # gitignored bench results
239
268
  ```
240
269
 
241
270
  ## Development
@@ -243,10 +272,14 @@ ollanet/
243
272
  ```bash
244
273
  pnpm install # builds dist/ via prepare
245
274
  pnpm typecheck
275
+ pnpm test # build dist/, then run node:test suite
246
276
  pnpm ollanet -- scan # runs from source via tsx
247
277
  pnpm build # compile dist/ manually
248
278
  ```
249
279
 
280
+ Tests use Node’s built-in test runner against `dist/` (what gets published), with an
281
+ in-process mock Ollama server — no real models or Tailscale required.
282
+
250
283
  ## License
251
284
 
252
285
  MIT
@@ -0,0 +1,20 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { defaultBenchmarksDir } from "./paths.js";
5
+ const BENCH_DIR = process.env.OLLANET_BENCHMARKS_DIR ?? defaultBenchmarksDir();
6
+ export function benchmarksDir() {
7
+ return BENCH_DIR;
8
+ }
9
+ export function newBenchId() {
10
+ return randomBytes(6).toString("hex");
11
+ }
12
+ export async function saveBenchmark(result) {
13
+ await mkdir(BENCH_DIR, { recursive: true });
14
+ const id = result && typeof result === "object" && "id" in result && typeof result.id === "string"
15
+ ? result.id
16
+ : newBenchId();
17
+ const file = path.join(BENCH_DIR, `${id}.json`);
18
+ await writeFile(file, `${JSON.stringify(result, null, 2)}\n`, "utf8");
19
+ return file;
20
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Built-in ollanet bench suites, checkers, and revision hashes.
3
+ */
4
+ import { createHash } from "node:crypto";
5
+ const MATH_ANSWER = 323;
6
+ const REASON_ANSWER = 42;
7
+ function lastTokenNormalized(content) {
8
+ const tokens = content.trim().split(/\s+/).filter(Boolean);
9
+ const last = tokens[tokens.length - 1] ?? "";
10
+ return last.toLowerCase().replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, "");
11
+ }
12
+ function integersInLastLine(content) {
13
+ const lines = content
14
+ .split(/\r?\n/)
15
+ .map((l) => l.trim())
16
+ .filter(Boolean);
17
+ const last = lines[lines.length - 1] ?? content.trim();
18
+ const matches = last.match(/-?\d+/g) ?? [];
19
+ return matches.map((m) => Number(m)).filter((n) => Number.isFinite(n));
20
+ }
21
+ function stripCodeFences(content) {
22
+ const trimmed = content.trim();
23
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
24
+ return (fenced?.[1] ?? trimmed).trim();
25
+ }
26
+ function checkPing(content) {
27
+ const token = lastTokenNormalized(content);
28
+ return token === "ok"
29
+ ? { ok: true, detail: token }
30
+ : { ok: false, detail: `last token=${JSON.stringify(token)}` };
31
+ }
32
+ function checkExpectedInt(expected) {
33
+ return (content) => {
34
+ const ints = integersInLastLine(content);
35
+ return ints.includes(expected)
36
+ ? { ok: true, detail: String(expected) }
37
+ : { ok: false, detail: `ints=${JSON.stringify(ints)}` };
38
+ };
39
+ }
40
+ function checkHaikuLive(content) {
41
+ const lines = content
42
+ .split(/\r?\n/)
43
+ .map((l) => l.trim())
44
+ .filter(Boolean);
45
+ const ok = content.trim().length > 0 && lines.length >= 3;
46
+ return ok ? { ok: true, detail: `${lines.length} lines` } : { ok: false, detail: `${lines.length} lines` };
47
+ }
48
+ function checkJsonStructure(content) {
49
+ try {
50
+ const parsed = JSON.parse(stripCodeFences(content));
51
+ const animal = String(parsed.animal ?? "").toLowerCase();
52
+ const legs = Number(parsed.legs);
53
+ const ok = animal.includes("ferret") && legs === 4;
54
+ return ok
55
+ ? { ok: true }
56
+ : { ok: false, detail: `got animal=${JSON.stringify(parsed.animal)} legs=${JSON.stringify(parsed.legs)}` };
57
+ }
58
+ catch (err) {
59
+ return { ok: false, detail: err instanceof Error ? err.message : "parse error" };
60
+ }
61
+ }
62
+ const QUICK_CASES = [
63
+ {
64
+ id: "ping",
65
+ role: "check",
66
+ prompt: "Reply with exactly: OK",
67
+ num_predict: 16,
68
+ check: checkPing,
69
+ },
70
+ {
71
+ id: "math",
72
+ role: "check",
73
+ prompt: "What is 17 * 19? Reply with only the number.",
74
+ num_predict: 32,
75
+ check: checkExpectedInt(MATH_ANSWER),
76
+ },
77
+ {
78
+ id: "haiku",
79
+ role: "live",
80
+ prompt: "Write a haiku about ferrets.",
81
+ num_predict: 64,
82
+ check: checkHaikuLive,
83
+ },
84
+ {
85
+ id: "throughput",
86
+ role: "throughput",
87
+ // Enumerative on purpose: at temperature 0, prose prompts often EOS well
88
+ // before num_predict. Counting resists early stop so done_reason=length.
89
+ prompt: "Count from 1 to 400. Write one integer per line and nothing else. " +
90
+ "Do not stop early. Do not write words.",
91
+ // num_predict pinned by runner
92
+ },
93
+ ];
94
+ const FULL_EXTRA = [
95
+ {
96
+ id: "json",
97
+ role: "check",
98
+ prompt: 'Return a JSON object describing an animal that is a ferret and has 4 legs. ' +
99
+ 'Use keys "animal" (string) and "legs" (number).',
100
+ num_predict: 64,
101
+ format: "json",
102
+ check: checkJsonStructure,
103
+ },
104
+ {
105
+ id: "reason",
106
+ 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
+ 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
+ check: checkExpectedInt(REASON_ANSWER),
114
+ },
115
+ ];
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
+ export function getSuiteCases(suite) {
120
+ if (suite === "full")
121
+ return [...QUICK_CASES, ...FULL_EXTRA];
122
+ return [...QUICK_CASES];
123
+ }
124
+ export function suiteRevision(suite) {
125
+ const cases = getSuiteCases(suite);
126
+ const canonical = cases.map((c) => `${c.id}\n${c.role}\n${c.prompt}`).join("\n---\n");
127
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
128
+ }
129
+ export function comparabilityKey(opts) {
130
+ const payload = [
131
+ suiteRevision(opts.suite),
132
+ `num_predict=${opts.throughputNumPredict}`,
133
+ `seed=${opts.seed}`,
134
+ `temperature=${opts.temperature}`,
135
+ `think=${opts.think ? 1 : 0}`,
136
+ `num_ctx=${opts.numCtx ?? ""}`,
137
+ ].join("|");
138
+ return createHash("sha256").update(payload).digest("hex").slice(0, 16);
139
+ }
140
+ export function median(values) {
141
+ if (values.length === 0)
142
+ return undefined;
143
+ const sorted = [...values].sort((a, b) => a - b);
144
+ const mid = Math.floor(sorted.length / 2);
145
+ if (sorted.length % 2 === 0) {
146
+ return (sorted[mid - 1] + sorted[mid]) / 2;
147
+ }
148
+ return sorted[mid];
149
+ }