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/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();
@@ -107,6 +110,16 @@ export function normalizeSettings(input) {
107
110
  if (typeof src.system === "string" && src.system.trim()) {
108
111
  out.system = src.system;
109
112
  }
113
+ const think = src.think;
114
+ if (typeof think === "boolean")
115
+ out.think = think;
116
+ else if (typeof think === "string") {
117
+ const v = think.trim().toLowerCase();
118
+ if (["1", "true", "yes", "on"].includes(v))
119
+ out.think = true;
120
+ if (["0", "false", "no", "off"].includes(v))
121
+ out.think = false;
122
+ }
110
123
  return out;
111
124
  }
112
125
  function asFiniteNumber(value) {
@@ -186,12 +199,16 @@ export function mergeSettings(...layers) {
186
199
  out.num_predict = layer.num_predict;
187
200
  if (layer.num_ctx != null)
188
201
  out.num_ctx = layer.num_ctx;
202
+ if (layer.seed != null)
203
+ out.seed = layer.seed;
189
204
  if (layer.keep_alive != null)
190
205
  out.keep_alive = layer.keep_alive;
191
206
  if (layer.format !== undefined)
192
207
  out.format = layer.format;
193
208
  if (layer.system != null)
194
209
  out.system = layer.system;
210
+ if (layer.think != null)
211
+ out.think = layer.think;
195
212
  }
196
213
  return out;
197
214
  }
@@ -203,6 +220,7 @@ export function settingsFromEnv() {
203
220
  keep_alive: process.env.OLLAMA_KEEP_ALIVE,
204
221
  format: process.env.OLLAMA_FORMAT,
205
222
  system: process.env.OLLAMA_SYSTEM,
223
+ think: process.env.OLLANET_THINK ?? process.env.OLLAMA_THINK,
206
224
  });
207
225
  }
208
226
  export function settingsSummary(settings) {
@@ -218,6 +236,10 @@ export function settingsSummary(settings) {
218
236
  if (settings.format !== undefined) {
219
237
  parts.push(settings.format === "json" ? "format=json" : "format=<schema>");
220
238
  }
239
+ if (settings.think === true)
240
+ parts.push("think=on");
241
+ if (settings.think === false)
242
+ parts.push("think=off");
221
243
  return parts.join(" ");
222
244
  }
223
245
  export function configPath() {
package/dist/hosts.js CHANGED
@@ -3,7 +3,18 @@ import net from "node:net";
3
3
  import os from "node:os";
4
4
  import { promisify } from "node:util";
5
5
  const execFileAsync = promisify(execFile);
6
- export const OLLAMA_PORT = Number(process.env.OLLAMA_PORT ?? 11434);
6
+ /** Parse a required numeric env var; invalid values throw instead of becoming NaN. */
7
+ export function envInt(name, fallback) {
8
+ const raw = process.env[name];
9
+ if (raw == null || String(raw).trim() === "")
10
+ return fallback;
11
+ const n = Number(raw);
12
+ if (!Number.isFinite(n)) {
13
+ throw new Error(`Invalid ${name}="${raw}" (expected a number)`);
14
+ }
15
+ return Math.trunc(n);
16
+ }
17
+ export const OLLAMA_PORT = envInt("OLLAMA_PORT", 11434);
7
18
  function cleanDnsName(dnsName) {
8
19
  if (!dnsName)
9
20
  return "";
@@ -97,7 +108,9 @@ function parseHostEntry(entry) {
97
108
  const name = (entry.name ?? address).trim();
98
109
  return makeHost({
99
110
  hostname: name,
100
- 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 : "",
101
114
  ip: address,
102
115
  source: "config",
103
116
  online: true,
@@ -242,8 +255,8 @@ async function tcpOpen(ip, port, timeoutMs) {
242
255
  });
243
256
  }
244
257
  async function lanScanTargets(cidrs, port) {
245
- const timeoutMs = Number(process.env.OLLANET_LAN_TIMEOUT_MS ?? 200);
246
- const concurrency = Number(process.env.OLLANET_LAN_CONCURRENCY ?? 64);
258
+ const timeoutMs = envInt("OLLANET_LAN_TIMEOUT_MS", 200);
259
+ const concurrency = Math.max(1, envInt("OLLANET_LAN_CONCURRENCY", 64));
247
260
  const ips = [...new Set(cidrs.flatMap((cidr) => cidrToIps(cidr)))];
248
261
  if (ips.length === 0)
249
262
  return [];
@@ -261,15 +274,21 @@ async function lanScanTargets(cidrs, port) {
261
274
  port,
262
275
  }));
263
276
  }
277
+ /**
278
+ * True for IPs, FQDNs, and host:port — not bare words.
279
+ * Bare words must match a discovered host so typos get a helpful error.
280
+ */
264
281
  function looksLikeAddress(query) {
265
282
  const q = query.trim();
266
- if (!q)
283
+ if (!q || /\s/.test(q))
267
284
  return false;
268
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(q))
285
+ if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(q))
286
+ return true;
287
+ if (/^\[[0-9a-f:]+\](:\d+)?$/i.test(q))
269
288
  return true;
270
- if (q.includes(":") && !q.includes(" "))
271
- return true; // hostname:port or ipv6-ish
272
- if (/^[a-z0-9][a-z0-9._-]*$/i.test(q))
289
+ if (/^[a-z0-9][a-z0-9._-]*:\d+$/i.test(q))
290
+ return true;
291
+ if (q.includes(".") && /^[a-z0-9][a-z0-9.-]+$/i.test(q))
273
292
  return true;
274
293
  return false;
275
294
  }
@@ -286,16 +305,23 @@ function parseDirectAddress(query) {
286
305
  hostname = ip;
287
306
  port = Number(ipv4WithPort[2]);
288
307
  }
289
- else if (!q.includes("://") && q.includes(".") === false && q.includes(":")) {
290
- // host:port without dots (rare) — skip to avoid mangling IPv6
291
- }
292
308
  else {
309
+ // host:port with or without dots. Requires a single colon + numeric port so
310
+ // bare IPv6 (multiple colons) is left alone.
293
311
  const hostPort = q.match(/^([a-z0-9][a-z0-9._-]*):(\d+)$/i);
294
312
  if (hostPort) {
295
313
  hostname = hostPort[1];
296
314
  ip = hostname;
297
315
  port = Number(hostPort[2]);
298
316
  }
317
+ else {
318
+ const bracketed = q.match(/^\[([0-9a-f:]+)\]:(\d+)$/i);
319
+ if (bracketed) {
320
+ ip = bracketed[1];
321
+ hostname = ip;
322
+ port = Number(bracketed[2]);
323
+ }
324
+ }
299
325
  }
300
326
  return makeHost({
301
327
  hostname,
@@ -358,16 +384,30 @@ export async function discoverHosts(opts = {}) {
358
384
  : sources.join("+") || "local");
359
385
  return { hosts, sources, networkLabel };
360
386
  }
387
+ function hostNameMatches(host, q) {
388
+ const names = [host.hostname, host.dnsName, shortName(host), host.ip].map((n) => n.toLowerCase());
389
+ return names.includes(q) || names.some((n) => n.startsWith(`${q}.`));
390
+ }
391
+ /**
392
+ * Match a query against already-discovered hosts only (no direct-address fallback).
393
+ * Used when peeling an optional leading hostname before a prompt.
394
+ */
395
+ export function findDiscoveredHost(targets, query) {
396
+ const q = query.trim().toLowerCase().replace(/\.$/, "");
397
+ if (!q)
398
+ return undefined;
399
+ const matches = targets.filter((host) => hostNameMatches(host, q));
400
+ if (matches.length === 1)
401
+ return matches[0];
402
+ return undefined;
403
+ }
361
404
  /** Resolve a machine name against discovered hosts, or treat it as a direct address. */
362
405
  export function resolveHost(targets, query) {
363
406
  const q = query.trim().toLowerCase().replace(/\.$/, "");
364
407
  if (!q) {
365
408
  throw new Error("Machine name is empty.");
366
409
  }
367
- const matches = targets.filter((host) => {
368
- const names = [host.hostname, host.dnsName, shortName(host), host.ip].map((n) => n.toLowerCase());
369
- return names.includes(q) || names.some((n) => n.startsWith(`${q}.`));
370
- });
410
+ const matches = targets.filter((host) => hostNameMatches(host, q));
371
411
  if (matches.length === 1)
372
412
  return matches[0];
373
413
  if (matches.length > 1) {
@@ -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
+ }