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/README.md +32 -0
- 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 +5 -0
- package/dist/hosts.js +3 -1
- package/dist/ollama-chat.js +307 -0
- package/dist/paths.js +4 -0
- package/dist/prompt.js +5 -157
- 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();
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|