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/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
|
-
|
|
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
|
-
|
|
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 =
|
|
246
|
-
const concurrency =
|
|
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}
|
|
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 (
|
|
271
|
-
return true;
|
|
272
|
-
if (/^[a-z0-9][a-z0-9
|
|
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
|
+
}
|