ollanet 0.1.1 → 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/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ Usage:
16
16
  ollanet prompt <machine|ip> [model] <prompt...>
17
17
  ollanet prompt --chat <hash> <prompt...>
18
18
  ollanet chats [--json] [--id <hash>]
19
+ ollanet bench <machine|ip> [model...] [--all] [options]
19
20
 
20
21
  Examples:
21
22
  ollanet scan
@@ -24,6 +25,8 @@ Examples:
24
25
  ollanet prompt 192.168.1.50 gemma4:12b "Hello"
25
26
  ollanet prompt --chat a1b2c3d4e5f6 "Tell me more"
26
27
  ollanet chats
28
+ ollanet bench localhost --all
29
+ ollanet bench localhost llama3.2:1b --runs 3
27
30
 
28
31
  Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
29
32
  (override with OLLANET_CONFIG)
@@ -68,6 +71,12 @@ async function main() {
68
71
  await run();
69
72
  return;
70
73
  }
74
+ case "bench":
75
+ case "benchmark": {
76
+ const { main: run } = await import("./bench.js");
77
+ await run();
78
+ return;
79
+ }
71
80
  default:
72
81
  console.error(`Unknown command: ${cmd}\n`);
73
82
  process.stderr.write(HELP);
package/dist/config.js CHANGED
@@ -94,6 +94,9 @@ export function normalizeSettings(input) {
94
94
  const numCtx = asFiniteNumber(src.num_ctx ?? src.numCtx);
95
95
  if (numCtx != null)
96
96
  out.num_ctx = Math.trunc(numCtx);
97
+ const seed = asFiniteNumber(src.seed);
98
+ if (seed != null)
99
+ out.seed = Math.trunc(seed);
97
100
  const keepAlive = src.keep_alive ?? src.keepAlive;
98
101
  if (typeof keepAlive === "string" && keepAlive.trim()) {
99
102
  out.keep_alive = keepAlive.trim();
@@ -196,6 +199,8 @@ export function mergeSettings(...layers) {
196
199
  out.num_predict = layer.num_predict;
197
200
  if (layer.num_ctx != null)
198
201
  out.num_ctx = layer.num_ctx;
202
+ if (layer.seed != null)
203
+ out.seed = layer.seed;
199
204
  if (layer.keep_alive != null)
200
205
  out.keep_alive = layer.keep_alive;
201
206
  if (layer.format !== undefined)
package/dist/hosts.js CHANGED
@@ -108,7 +108,9 @@ function parseHostEntry(entry) {
108
108
  const name = (entry.name ?? address).trim();
109
109
  return makeHost({
110
110
  hostname: name,
111
- dnsName: address.includes(".") ? address : "",
111
+ // An IPv4 literal is not a DNS name; treating it as one makes shortName()
112
+ // return the first octet ("192") instead of the configured name.
113
+ dnsName: !/^\d{1,3}(\.\d{1,3}){3}$/.test(address) && address.includes(".") ? address : "",
112
114
  ip: address,
113
115
  source: "config",
114
116
  online: true,
@@ -0,0 +1,307 @@
1
+ function buildOptions(settings) {
2
+ const options = {};
3
+ if (settings.temperature != null)
4
+ options.temperature = settings.temperature;
5
+ if (settings.num_predict != null)
6
+ options.num_predict = settings.num_predict;
7
+ if (settings.num_ctx != null)
8
+ options.num_ctx = settings.num_ctx;
9
+ if (settings.seed != null)
10
+ options.seed = settings.seed;
11
+ return options;
12
+ }
13
+ function parseChunk(url, raw) {
14
+ try {
15
+ return JSON.parse(raw);
16
+ }
17
+ catch {
18
+ throw new Error(`Non-JSON response from ${url} (is something else on this port?): ${raw.slice(0, 120)}`);
19
+ }
20
+ }
21
+ /** POST /api/chat with optional streaming and AbortSignal timeout. */
22
+ export async function ollamaChat(opts) {
23
+ const url = `${opts.baseUrl}/api/chat`;
24
+ // Default think:false — thinking models otherwise burn num_predict on hidden reasoning.
25
+ const think = opts.settings.think === true;
26
+ const body = {
27
+ model: opts.model,
28
+ messages: opts.messages,
29
+ stream: opts.stream,
30
+ think,
31
+ };
32
+ if (opts.settings.keep_alive != null)
33
+ body.keep_alive = opts.settings.keep_alive;
34
+ if (opts.settings.format !== undefined)
35
+ body.format = opts.settings.format;
36
+ const options = buildOptions(opts.settings);
37
+ if (Object.keys(options).length > 0)
38
+ body.options = options;
39
+ const controller = new AbortController();
40
+ const timer = opts.timeoutMs > 0 ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined;
41
+ let res;
42
+ try {
43
+ res = await fetch(url, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
46
+ body: JSON.stringify(body),
47
+ signal: controller.signal,
48
+ });
49
+ }
50
+ catch (err) {
51
+ if (timer)
52
+ clearTimeout(timer);
53
+ const name = err instanceof Error ? err.name : "";
54
+ if (name === "AbortError") {
55
+ throw new Error(`Prompt timed out after ${opts.timeoutMs}ms (${url})`);
56
+ }
57
+ throw err;
58
+ }
59
+ if (!res.ok) {
60
+ if (timer)
61
+ clearTimeout(timer);
62
+ const text = await res.text().catch(() => "");
63
+ throw new Error(`Ollama HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
64
+ }
65
+ try {
66
+ if (!opts.stream) {
67
+ const chunk = parseChunk(url, await res.text());
68
+ if (chunk.error)
69
+ throw new Error(chunk.error);
70
+ const content = chunk.message?.content ?? "";
71
+ const thinking = chunk.message?.thinking ?? "";
72
+ if (opts.writeStdout) {
73
+ if (think && thinking) {
74
+ process.stderr.write(`${thinking}\n---\n`);
75
+ }
76
+ const visible = content || thinking;
77
+ if (visible)
78
+ process.stdout.write(`${visible}\n`);
79
+ }
80
+ return { content: content || thinking, thinking, chunk };
81
+ }
82
+ if (!res.body)
83
+ throw new Error("No response body from Ollama");
84
+ const reader = res.body.getReader();
85
+ const decoder = new TextDecoder();
86
+ let buffer = "";
87
+ let last = {};
88
+ let content = "";
89
+ let thinking = "";
90
+ let thinkingHeader = false;
91
+ const handleChunk = (chunk) => {
92
+ if (chunk.error)
93
+ throw new Error(chunk.error);
94
+ const thinkPiece = chunk.message?.thinking ?? "";
95
+ const contentPiece = chunk.message?.content ?? "";
96
+ if (thinkPiece) {
97
+ thinking += thinkPiece;
98
+ if (opts.writeStdout && think) {
99
+ if (!thinkingHeader) {
100
+ process.stderr.write("[thinking]\n");
101
+ thinkingHeader = true;
102
+ }
103
+ process.stderr.write(thinkPiece);
104
+ }
105
+ }
106
+ if (contentPiece) {
107
+ content += contentPiece;
108
+ if (opts.writeStdout) {
109
+ if (thinkingHeader) {
110
+ process.stderr.write("\n---\n");
111
+ thinkingHeader = false;
112
+ }
113
+ process.stdout.write(contentPiece);
114
+ }
115
+ }
116
+ last = chunk;
117
+ };
118
+ while (true) {
119
+ const { done, value } = await reader.read();
120
+ if (done)
121
+ break;
122
+ buffer += decoder.decode(value, { stream: true });
123
+ const lines = buffer.split("\n");
124
+ buffer = lines.pop() ?? "";
125
+ for (const line of lines) {
126
+ const trimmed = line.trim();
127
+ if (!trimmed)
128
+ continue;
129
+ handleChunk(parseChunk(url, trimmed));
130
+ }
131
+ }
132
+ const trailing = buffer.trim();
133
+ if (trailing) {
134
+ handleChunk(parseChunk(url, trailing));
135
+ }
136
+ if (opts.writeStdout && !content && thinking) {
137
+ if (think)
138
+ process.stderr.write("\n---\n");
139
+ process.stdout.write(`${thinking}\n`);
140
+ }
141
+ else if (opts.writeStdout && content.length > 0 && !content.endsWith("\n")) {
142
+ process.stdout.write("\n");
143
+ }
144
+ return { content: content || thinking, thinking, chunk: last };
145
+ }
146
+ catch (err) {
147
+ const name = err instanceof Error ? err.name : "";
148
+ if (name === "AbortError") {
149
+ throw new Error(`Prompt timed out after ${opts.timeoutMs}ms (${url})`);
150
+ }
151
+ throw err;
152
+ }
153
+ finally {
154
+ if (timer)
155
+ clearTimeout(timer);
156
+ }
157
+ }
158
+ export async function ollamaTags(baseUrl, timeoutMs) {
159
+ const url = `${baseUrl}/api/tags`;
160
+ const body = await ollamaGetJson(url, timeoutMs);
161
+ return body.models ?? [];
162
+ }
163
+ export async function ollamaShow(baseUrl, model, timeoutMs) {
164
+ const url = `${baseUrl}/api/show`;
165
+ return ollamaPostJson(url, { model }, timeoutMs);
166
+ }
167
+ export async function ollamaVersion(baseUrl, timeoutMs) {
168
+ try {
169
+ const body = await ollamaGetJson(`${baseUrl}/api/version`, timeoutMs);
170
+ return body.version;
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ }
176
+ export async function ollamaPs(baseUrl, timeoutMs) {
177
+ try {
178
+ const body = await ollamaGetJson(`${baseUrl}/api/ps`, timeoutMs);
179
+ return body.models ?? [];
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ }
185
+ function modelKey(name) {
186
+ return name.trim().toLowerCase();
187
+ }
188
+ export function psHasModel(models, name) {
189
+ const key = modelKey(name);
190
+ return models.some((m) => {
191
+ const n = (m.name ?? m.model ?? "").toLowerCase();
192
+ return n === key || n.startsWith(`${key}:`) || key.startsWith(`${n}:`);
193
+ });
194
+ }
195
+ function findPsModel(models, name) {
196
+ const key = modelKey(name);
197
+ return models.find((m) => {
198
+ const n = (m.name ?? m.model ?? "").toLowerCase();
199
+ return n === key || n.startsWith(`${key}:`) || key.startsWith(`${n}:`);
200
+ });
201
+ }
202
+ export function vramForModel(models, name) {
203
+ return findPsModel(models, name)?.size_vram;
204
+ }
205
+ export function contextLengthForModel(models, name) {
206
+ return findPsModel(models, name)?.context_length;
207
+ }
208
+ /**
209
+ * Capability filter: omitempty / empty means "unknown → treat as completion".
210
+ * Only skip when a non-empty capabilities array is present and lacks completion.
211
+ */
212
+ export function isCompletionCapable(capabilities) {
213
+ if (capabilities == null || capabilities.length === 0)
214
+ return true;
215
+ return capabilities.includes("completion");
216
+ }
217
+ /**
218
+ * Vision-capable models (moondream, llava, …) often fail a text-only suite.
219
+ * Only true when capabilities are present and include vision — absent/empty → false
220
+ * (don't skip unknown older servers).
221
+ */
222
+ export function isVisionCapable(capabilities) {
223
+ if (capabilities == null || capabilities.length === 0)
224
+ return false;
225
+ return capabilities.includes("vision");
226
+ }
227
+ /** True when capabilities are known and lack thinking (absent/empty → no warning). */
228
+ export function shouldWarnNoThinking(capabilities) {
229
+ if (capabilities == null || capabilities.length === 0)
230
+ return false;
231
+ return !capabilities.includes("thinking");
232
+ }
233
+ /** Poll /api/ps until model is absent (or timeout). Returns elapsed ms. */
234
+ export async function waitUntilUnloaded(baseUrl, model, timeoutMs, pollMs = 200) {
235
+ const started = Date.now();
236
+ while (Date.now() - started < timeoutMs) {
237
+ const models = await ollamaPs(baseUrl, Math.min(5000, timeoutMs));
238
+ if (!psHasModel(models, model)) {
239
+ return { unloaded: true, waitedMs: Date.now() - started };
240
+ }
241
+ await new Promise((r) => setTimeout(r, pollMs));
242
+ }
243
+ return { unloaded: false, waitedMs: Date.now() - started };
244
+ }
245
+ /** Request unload via keep_alive: 0 (no-op-ish if already unloaded after a short chat). */
246
+ export async function ollamaUnload(baseUrl, model, timeoutMs) {
247
+ await ollamaChat({
248
+ baseUrl,
249
+ model,
250
+ messages: [{ role: "user", content: "ping" }],
251
+ stream: false,
252
+ writeStdout: false,
253
+ timeoutMs,
254
+ settings: { keep_alive: 0, num_predict: 1, think: false },
255
+ });
256
+ }
257
+ async function ollamaGetJson(url, timeoutMs) {
258
+ const controller = new AbortController();
259
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
260
+ try {
261
+ const res = await fetch(url, {
262
+ headers: { Accept: "application/json" },
263
+ signal: controller.signal,
264
+ });
265
+ if (!res.ok) {
266
+ throw new Error(`Ollama HTTP ${res.status} ${res.statusText} (${url})`);
267
+ }
268
+ const text = await res.text();
269
+ try {
270
+ return JSON.parse(text);
271
+ }
272
+ catch {
273
+ throw new Error(`Non-JSON response from ${url} (is something else on this port?): ${text.slice(0, 120)}`);
274
+ }
275
+ }
276
+ finally {
277
+ if (timer)
278
+ clearTimeout(timer);
279
+ }
280
+ }
281
+ async function ollamaPostJson(url, body, timeoutMs) {
282
+ const controller = new AbortController();
283
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
284
+ try {
285
+ const res = await fetch(url, {
286
+ method: "POST",
287
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
288
+ body: JSON.stringify(body),
289
+ signal: controller.signal,
290
+ });
291
+ if (!res.ok) {
292
+ const text = await res.text().catch(() => "");
293
+ throw new Error(`Ollama HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""} (${url})`);
294
+ }
295
+ const text = await res.text();
296
+ try {
297
+ return JSON.parse(text);
298
+ }
299
+ catch {
300
+ throw new Error(`Non-JSON response from ${url} (is something else on this port?): ${text.slice(0, 120)}`);
301
+ }
302
+ }
303
+ finally {
304
+ if (timer)
305
+ clearTimeout(timer);
306
+ }
307
+ }
package/dist/paths.js CHANGED
@@ -22,3 +22,7 @@ export function defaultConfigPath() {
22
22
  export function defaultResponsesDir() {
23
23
  return IS_INSTALLED ? path.join(USER_DIR, "responses") : projectPath("responses");
24
24
  }
25
+ /** Default benchmark results dir (mirrors responses layout). */
26
+ export function defaultBenchmarksDir() {
27
+ return IS_INSTALLED ? path.join(USER_DIR, "benchmarks") : projectPath("benchmarks");
28
+ }
package/dist/prompt.js CHANGED
@@ -16,6 +16,7 @@ import { stdin as stdinStream } from "node:process";
16
16
  import { appendMessage, cleanTopic, createChat, loadChat, saveChat, toApiMessages, topicFromPrompt, } from "./chat-store.js";
17
17
  import { configPath, defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, parseFormat, settingsFromEnv, settingsSummary, } from "./config.js";
18
18
  import { discoverHosts, envInt, findDiscoveredHost, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
19
+ import { ollamaChat } from "./ollama-chat.js";
19
20
  /** Prompt/chat HTTP timeout (ms). 0 = no timeout. Separate from scan's OLLAMA_TIMEOUT_MS. */
20
21
  const PROMPT_TIMEOUT_MS = envInt("OLLAMA_PROMPT_TIMEOUT_MS", 600_000);
21
22
  function usage() {
@@ -269,169 +270,15 @@ async function readStdinIfPiped() {
269
270
  }
270
271
  return Buffer.concat(chunks).toString("utf8").trim();
271
272
  }
272
- function buildOptions(settings) {
273
- const options = {};
274
- if (settings.temperature != null)
275
- options.temperature = settings.temperature;
276
- if (settings.num_predict != null)
277
- options.num_predict = settings.num_predict;
278
- if (settings.num_ctx != null)
279
- options.num_ctx = settings.num_ctx;
280
- return options;
281
- }
282
- async function runChat(opts) {
283
- const url = `${opts.baseUrl}/api/chat`;
284
- // Default think:false — thinking models otherwise burn num_predict on hidden reasoning.
285
- const think = opts.settings.think === true;
286
- const body = {
287
- model: opts.model,
288
- messages: opts.messages,
289
- stream: opts.stream,
290
- think,
291
- };
292
- if (opts.settings.keep_alive != null)
293
- body.keep_alive = opts.settings.keep_alive;
294
- if (opts.settings.format !== undefined)
295
- body.format = opts.settings.format;
296
- const options = buildOptions(opts.settings);
297
- if (Object.keys(options).length > 0)
298
- body.options = options;
299
- const controller = new AbortController();
300
- const timer = PROMPT_TIMEOUT_MS > 0 ? setTimeout(() => controller.abort(), PROMPT_TIMEOUT_MS) : undefined;
301
- let res;
302
- try {
303
- res = await fetch(url, {
304
- method: "POST",
305
- headers: { "Content-Type": "application/json", Accept: "application/json" },
306
- body: JSON.stringify(body),
307
- signal: controller.signal,
308
- });
309
- }
310
- catch (err) {
311
- if (timer)
312
- clearTimeout(timer);
313
- const name = err instanceof Error ? err.name : "";
314
- if (name === "AbortError") {
315
- throw new Error(`Prompt timed out after ${PROMPT_TIMEOUT_MS}ms (${url})`);
316
- }
317
- throw err;
318
- }
319
- if (!res.ok) {
320
- if (timer)
321
- clearTimeout(timer);
322
- const text = await res.text().catch(() => "");
323
- throw new Error(`Ollama HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
324
- }
325
- const parseChunk = (raw) => {
326
- try {
327
- return JSON.parse(raw);
328
- }
329
- catch {
330
- throw new Error(`Non-JSON response from ${url} (is something else on this port?): ${raw.slice(0, 120)}`);
331
- }
332
- };
333
- try {
334
- if (!opts.stream) {
335
- const chunk = parseChunk(await res.text());
336
- if (chunk.error)
337
- throw new Error(chunk.error);
338
- const content = chunk.message?.content ?? "";
339
- const thinking = chunk.message?.thinking ?? "";
340
- if (opts.writeStdout) {
341
- if (think && thinking) {
342
- process.stderr.write(`${thinking}\n---\n`);
343
- }
344
- const visible = content || thinking;
345
- if (visible)
346
- process.stdout.write(`${visible}\n`);
347
- }
348
- return { content: content || thinking, thinking, chunk };
349
- }
350
- if (!res.body)
351
- throw new Error("No response body from Ollama");
352
- const reader = res.body.getReader();
353
- const decoder = new TextDecoder();
354
- let buffer = "";
355
- let last = {};
356
- let content = "";
357
- let thinking = "";
358
- let thinkingHeader = false;
359
- const handleChunk = (chunk) => {
360
- if (chunk.error)
361
- throw new Error(chunk.error);
362
- const thinkPiece = chunk.message?.thinking ?? "";
363
- const contentPiece = chunk.message?.content ?? "";
364
- if (thinkPiece) {
365
- thinking += thinkPiece;
366
- if (opts.writeStdout && think) {
367
- if (!thinkingHeader) {
368
- process.stderr.write("[thinking]\n");
369
- thinkingHeader = true;
370
- }
371
- process.stderr.write(thinkPiece);
372
- }
373
- }
374
- if (contentPiece) {
375
- content += contentPiece;
376
- if (opts.writeStdout) {
377
- if (thinkingHeader) {
378
- process.stderr.write("\n---\n");
379
- thinkingHeader = false;
380
- }
381
- process.stdout.write(contentPiece);
382
- }
383
- }
384
- last = chunk;
385
- };
386
- while (true) {
387
- const { done, value } = await reader.read();
388
- if (done)
389
- break;
390
- buffer += decoder.decode(value, { stream: true });
391
- const lines = buffer.split("\n");
392
- buffer = lines.pop() ?? "";
393
- for (const line of lines) {
394
- const trimmed = line.trim();
395
- if (!trimmed)
396
- continue;
397
- handleChunk(parseChunk(trimmed));
398
- }
399
- }
400
- const trailing = buffer.trim();
401
- if (trailing) {
402
- handleChunk(parseChunk(trailing));
403
- }
404
- // If the model only emitted thinking (budget exhausted), surface it on stdout.
405
- if (opts.writeStdout && !content && thinking) {
406
- if (think)
407
- process.stderr.write("\n---\n");
408
- process.stdout.write(`${thinking}\n`);
409
- }
410
- else if (opts.writeStdout && content.length > 0 && !content.endsWith("\n")) {
411
- process.stdout.write("\n");
412
- }
413
- return { content: content || thinking, thinking, chunk: last };
414
- }
415
- catch (err) {
416
- const name = err instanceof Error ? err.name : "";
417
- if (name === "AbortError") {
418
- throw new Error(`Prompt timed out after ${PROMPT_TIMEOUT_MS}ms (${url})`);
419
- }
420
- throw err;
421
- }
422
- finally {
423
- if (timer)
424
- clearTimeout(timer);
425
- }
426
- }
427
273
  async function generateTopic(opts) {
428
274
  const fallback = topicFromPrompt(opts.prompt);
429
275
  try {
430
- const { content } = await runChat({
276
+ const { content } = await ollamaChat({
431
277
  baseUrl: opts.baseUrl,
432
278
  model: opts.model,
433
279
  stream: false,
434
280
  writeStdout: false,
281
+ timeoutMs: PROMPT_TIMEOUT_MS,
435
282
  settings: mergeSettings(opts.settings, {
436
283
  temperature: 0.2,
437
284
  num_predict: 24,
@@ -550,12 +397,13 @@ export async function main() {
550
397
  const chatTag = parsed.chatId ? ` chat=${chat.id}` : "";
551
398
  console.error(`→ ${shortName(host)} (${host.ip}) model=${model} [${src}]${chatTag}${extras ? ` ${extras}` : ""}`);
552
399
  }
553
- const { content, thinking, chunk } = await runChat({
400
+ const { content, thinking, chunk } = await ollamaChat({
554
401
  baseUrl,
555
402
  model,
556
403
  messages: toApiMessages(chat),
557
404
  stream: json ? false : stream,
558
405
  writeStdout: !json,
406
+ timeoutMs: PROMPT_TIMEOUT_MS,
559
407
  settings,
560
408
  });
561
409
  appendMessage(chat, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ollanet",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,8 +18,11 @@
18
18
  "scan": "tsx src/cli.ts scan",
19
19
  "prompt": "tsx src/cli.ts prompt",
20
20
  "chats": "tsx src/cli.ts chats",
21
+ "bench": "tsx src/cli.ts bench",
21
22
  "start": "tsx src/cli.ts scan",
22
- "typecheck": "tsc --noEmit"
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "tsc -p tsconfig.build.json && node --test test/*.test.mjs",
25
+ "test:only": "node --test test/*.test.mjs"
23
26
  },
24
27
  "keywords": [
25
28
  "ollama",