ollanet 0.1.0 → 0.2.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.
- package/README.md +38 -2
- package/dist/bench-store.js +20 -0
- package/dist/bench-suite.js +149 -0
- package/dist/bench.js +781 -0
- package/dist/cli.js +9 -0
- package/dist/config.js +22 -0
- package/dist/hosts.js +56 -16
- package/dist/ollama-chat.js +307 -0
- package/dist/paths.js +4 -0
- package/dist/prompt.js +133 -126
- package/dist/scan.js +3 -3
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Scan for models, fire prompts, and continue conversations later by a short hash.
|
|
|
6
6
|
|
|
7
7
|
## Origin
|
|
8
8
|
|
|
9
|
-
This started as a
|
|
9
|
+
This started as a personal little script. I had Ollama running on a laptop on my Tailscale network and wanted an easy way to discover it, poke at the models, and actually talk to them from another machine — without babysitting IPs or opening a browser. One scan/prompt tool later, chats by hash showed up, LAN discovery crept in, and the thing grew into **ollanet**: a tiny CLI for using Ollama over whatever network you already have.
|
|
10
10
|
|
|
11
11
|
## Related: Finetuna
|
|
12
12
|
|
|
@@ -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
|
|
|
@@ -127,6 +128,7 @@ ollanet prompt --chat <hash> <prompt...>
|
|
|
127
128
|
| `--num-ctx <n>` | Context window |
|
|
128
129
|
| `--keep-alive <value>` | Keep model loaded (`5m`, `0`, `-1`, …) |
|
|
129
130
|
| `--format json\|<schema>` | JSON mode / schema |
|
|
131
|
+
| `--think` / `--no-think` | Enable/disable model thinking (default **off** so replies aren’t empty on qwen3-style models) |
|
|
130
132
|
| `--no-stream` | Buffer the full reply |
|
|
131
133
|
| `--no-save` | Do not write a transcript |
|
|
132
134
|
| `--json` | Emit JSON (includes chat id when saved) |
|
|
@@ -138,6 +140,25 @@ Machine can be a discovered name, hostname, FQDN, or IP (`192.168.1.50`, `host:1
|
|
|
138
140
|
- `--json` — list summary JSON
|
|
139
141
|
- `--id <hash>` — show one chat (full JSON with `--json`)
|
|
140
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 embedding models and vision-capable models (text suite; use `--include-vision` to
|
|
155
|
+
include them). Saves `benchmarks/<id>.json` (or `~/.ollanet/benchmarks/` when installed).
|
|
156
|
+
See [`docs/bench-spec.md`](docs/bench-spec.md) for measurement rules.
|
|
157
|
+
|
|
158
|
+
`quick` has only two scored checks (`ping`, `math`), so `pass` is a coarse
|
|
159
|
+
liveness/sanity gauge (`0/2`, `1/2`, `2/2`) — not “50% broken.” Use `--suite full`
|
|
160
|
+
when you care more about the quality signal.
|
|
161
|
+
|
|
141
162
|
## Discovery
|
|
142
163
|
|
|
143
164
|
ollanet combines several sources (deduped by `ip:port`):
|
|
@@ -174,7 +195,8 @@ Edit `~/.ollanet/config.json` (installed) or `config.json` (checkout) — or poi
|
|
|
174
195
|
"defaults": {
|
|
175
196
|
"temperature": 0.7,
|
|
176
197
|
"num_predict": 512,
|
|
177
|
-
"keep_alive": "5m"
|
|
198
|
+
"keep_alive": "5m",
|
|
199
|
+
"think": false
|
|
178
200
|
},
|
|
179
201
|
"machineDefaults": {
|
|
180
202
|
"studio": { "num_ctx": 16384 }
|
|
@@ -200,7 +222,11 @@ Edit `~/.ollanet/config.json` (installed) or `config.json` (checkout) — or poi
|
|
|
200
222
|
| `OLLAMA_KEEP_ALIVE` | Default keep-alive |
|
|
201
223
|
| `OLLAMA_FORMAT` | Default format (`json` or schema JSON) |
|
|
202
224
|
| `OLLAMA_SYSTEM` | Default system prompt |
|
|
225
|
+
| `OLLANET_THINK` / `OLLAMA_THINK` | Enable thinking (`true`/`false`; default off) |
|
|
203
226
|
| `OLLAMA_TIMEOUT_MS` | HTTP scan/probe timeout |
|
|
227
|
+
| `OLLAMA_PROMPT_TIMEOUT_MS` | Prompt/chat HTTP timeout (default `600000`; `0` = none) |
|
|
228
|
+
| `OLLAMA_BENCH_TIMEOUT_MS` | Per-case bench timeout (default `60000`) |
|
|
229
|
+
| `OLLANET_BENCHMARKS_DIR` | Benchmark result directory |
|
|
204
230
|
| `OLLAMA_CONCURRENCY` | Scan concurrency |
|
|
205
231
|
|
|
206
232
|
## Chat transcripts
|
|
@@ -229,9 +255,15 @@ ollanet/
|
|
|
229
255
|
chat-store.ts
|
|
230
256
|
config.ts
|
|
231
257
|
paths.ts # checkout vs installed path resolution
|
|
258
|
+
ollama-chat.ts # shared /api/chat (+ tags/show/ps helpers)
|
|
259
|
+
bench.ts # ollanet bench
|
|
260
|
+
bench-suite.ts
|
|
261
|
+
bench-store.ts
|
|
232
262
|
dist/ # compiled output (published to npm)
|
|
263
|
+
docs/bench-spec.md # bench measurement spec
|
|
233
264
|
config.json
|
|
234
265
|
responses/ # gitignored chat history
|
|
266
|
+
benchmarks/ # gitignored bench results
|
|
235
267
|
```
|
|
236
268
|
|
|
237
269
|
## Development
|
|
@@ -239,10 +271,14 @@ ollanet/
|
|
|
239
271
|
```bash
|
|
240
272
|
pnpm install # builds dist/ via prepare
|
|
241
273
|
pnpm typecheck
|
|
274
|
+
pnpm test # build dist/, then run node:test suite
|
|
242
275
|
pnpm ollanet -- scan # runs from source via tsx
|
|
243
276
|
pnpm build # compile dist/ manually
|
|
244
277
|
```
|
|
245
278
|
|
|
279
|
+
Tests use Node’s built-in test runner against `dist/` (what gets published), with an
|
|
280
|
+
in-process mock Ollama server — no real models or Tailscale required.
|
|
281
|
+
|
|
246
282
|
## License
|
|
247
283
|
|
|
248
284
|
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
|
+
}
|