smolcoder-plus 1.0.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/dist/agent.js +748 -0
  4. package/dist/attachments.js +158 -0
  5. package/dist/config.js +87 -0
  6. package/dist/context.js +498 -0
  7. package/dist/detect.js +474 -0
  8. package/dist/events.js +24 -0
  9. package/dist/history.js +9 -0
  10. package/dist/hosts.js +107 -0
  11. package/dist/index.js +391 -0
  12. package/dist/logo.js +48 -0
  13. package/dist/netscan.js +159 -0
  14. package/dist/network.js +193 -0
  15. package/dist/plan.js +102 -0
  16. package/dist/prompt.js +84 -0
  17. package/dist/providers/lmstudio.js +347 -0
  18. package/dist/providers/ollama.js +269 -0
  19. package/dist/providers/scheduler.js +57 -0
  20. package/dist/providers/transport.js +86 -0
  21. package/dist/providers/types.js +62 -0
  22. package/dist/sandbox.js +207 -0
  23. package/dist/session.js +639 -0
  24. package/dist/tools/check.js +193 -0
  25. package/dist/tools/fs-tools.js +431 -0
  26. package/dist/tools/index.js +260 -0
  27. package/dist/tools/search-worker.js +34 -0
  28. package/dist/tools/shell.js +186 -0
  29. package/dist/tools/tasks.js +147 -0
  30. package/dist/tools/web-search.js +155 -0
  31. package/dist/tui/editor.js +134 -0
  32. package/dist/tui/keys.js +145 -0
  33. package/dist/tui/tui.js +723 -0
  34. package/dist/ui.js +226 -0
  35. package/dist/util.js +91 -0
  36. package/dist/verification.js +71 -0
  37. package/dist/web/channel.js +260 -0
  38. package/dist/web/client.js +1010 -0
  39. package/dist/web/hub.js +952 -0
  40. package/dist/web/page.js +87 -0
  41. package/dist/web/store.js +199 -0
  42. package/dist/web/styles.js +333 -0
  43. package/dist/web/terminal.js +190 -0
  44. package/package.json +49 -0
@@ -0,0 +1,347 @@
1
+ "use strict";
2
+ // LM Studio adapter — standard OpenAI-compatible /v1/chat/completions with SSE
3
+ // streaming. The context window is whatever LM Studio loaded the model with;
4
+ // we detect it and budget within it (we cannot change it per request).
5
+ //
6
+ // Reasoning is the part that decides whether LM Studio feels fast or slow.
7
+ // LM Studio's API accepts reasoning_effort none|minimal|low|medium|high|xhigh,
8
+ // but each MODEL only supports a subset (read from /api/v1/models). A value
9
+ // the model does not support is silently replaced by the model's DEFAULT —
10
+ // which for current qwen3.x builds is "xhigh", the maximum. That is how a
11
+ // harness asking for "high" ends up with 8,000-token thinking bursts per tool
12
+ // call. So: "off" is sent as "none" (measured: fully disables thinking), and
13
+ // every other level is snapped to the nearest level the model really has.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.LmStudioProvider = void 0;
16
+ exports.toWire = toWire;
17
+ exports.mapEffort = mapEffort;
18
+ const types_1 = require("./types");
19
+ const transport_1 = require("./transport");
20
+ const scheduler_1 = require("./scheduler");
21
+ const attachments_1 = require("../attachments");
22
+ const util_1 = require("../util");
23
+ /** Exported for tests. */
24
+ function toWire(messages) {
25
+ return messages.map((m) => {
26
+ if (m.role === "assistant" && m.toolCalls?.length) {
27
+ return {
28
+ role: "assistant",
29
+ content: m.content || null,
30
+ tool_calls: m.toolCalls.map((tc) => ({
31
+ id: tc.id,
32
+ type: "function",
33
+ function: { name: tc.name, arguments: JSON.stringify(tc.args) },
34
+ })),
35
+ };
36
+ }
37
+ if (m.role === "tool") {
38
+ return { role: "tool", tool_call_id: m.toolCallId, content: m.content };
39
+ }
40
+ if (m.role === "user" && m.images?.length) {
41
+ // OpenAI-style content parts: the text, then each image as a data URL.
42
+ const parts = [{ type: "text", text: m.content }];
43
+ for (const ref of m.images) {
44
+ const url = (0, attachments_1.imageDataUrl)(ref);
45
+ if (url)
46
+ parts.push({ type: "image_url", image_url: { url } });
47
+ }
48
+ return { role: "user", content: parts.length > 1 ? parts : m.content };
49
+ }
50
+ return { role: m.role, content: m.content };
51
+ });
52
+ }
53
+ function toWireTools(tools) {
54
+ return tools.map((t) => ({
55
+ type: "function",
56
+ function: { name: t.name, description: t.description, parameters: t.parameters },
57
+ }));
58
+ }
59
+ /** Exported for tests. Map a smolcoder effort onto LM Studio's wire value,
60
+ * respecting what the model supports. Returns undefined for "leave it to the
61
+ * backend". */
62
+ function mapEffort(effort, info) {
63
+ if (effort === null)
64
+ return undefined;
65
+ if (effort === "off")
66
+ return "none";
67
+ const wireLevels = ["low", "medium", "high", "xhigh"];
68
+ if (!info || info.allowed.length === 0)
69
+ return effort;
70
+ // Model-supported levels that the API also accepts (the model list uses
71
+ // "off"/"on" too; those are not valid wire values).
72
+ const candidates = wireLevels.filter((l) => info.allowed.includes(l));
73
+ if (candidates.length === 0)
74
+ return effort;
75
+ if (candidates.includes(effort))
76
+ return effort;
77
+ const want = types_1.EFFORT_RANK[effort];
78
+ let best = candidates[0];
79
+ let bestDist = Infinity;
80
+ for (const cnd of candidates) {
81
+ const d = Math.abs(types_1.EFFORT_RANK[cnd] - want);
82
+ // Ties go to the LOWER level: on a local model the cheaper step wins.
83
+ if (d < bestDist || (d === bestDist && types_1.EFFORT_RANK[cnd] < types_1.EFFORT_RANK[best])) {
84
+ best = cnd;
85
+ bestDist = d;
86
+ }
87
+ }
88
+ return best;
89
+ }
90
+ class LmStudioProvider {
91
+ baseUrl;
92
+ modelId;
93
+ contextWindow;
94
+ reasoning;
95
+ vision;
96
+ replaysThinking = false;
97
+ label;
98
+ maxOutputTokens;
99
+ effort = null;
100
+ effortUnsupported = false;
101
+ constructor(baseUrl, modelId, contextWindow, maxOutputTokens = types_1.MAX_OUTPUT_TOKENS, reasoning,
102
+ /** Whether the model accepts images (LM Studio lists such models as "vlm"). */
103
+ vision) {
104
+ this.baseUrl = baseUrl;
105
+ this.modelId = modelId;
106
+ this.contextWindow = contextWindow;
107
+ this.reasoning = reasoning;
108
+ this.vision = vision;
109
+ this.label = `lmstudio · ${modelId}`;
110
+ this.maxOutputTokens = maxOutputTokens;
111
+ }
112
+ setEffort(effort) {
113
+ this.effort = effort;
114
+ this.effortUnsupported = false;
115
+ }
116
+ async loadedContextWindow() {
117
+ const data = await (0, util_1.tryFetchJson)(`${this.baseUrl}/api/v1/models`, undefined, 1500);
118
+ for (const model of data?.models ?? []) {
119
+ const instances = model.loaded_instances ?? [];
120
+ const instance = instances.find((m) => m.id === this.modelId) ?? (model.key === this.modelId ? instances[0] : undefined);
121
+ if (typeof instance?.config?.context_length === "number")
122
+ return instance.config.context_length;
123
+ }
124
+ return undefined;
125
+ }
126
+ effortLabel() {
127
+ if (this.effortUnsupported)
128
+ return this.effort ? `${this.effort} (ignored by this server)` : null;
129
+ if (this.effort === null) {
130
+ return this.reasoning?.default ? `default → ${this.reasoning.default}` : null;
131
+ }
132
+ const wire = mapEffort(this.effort, this.reasoning);
133
+ if (wire && wire !== this.effort && wire !== "none")
134
+ return `${this.effort} → ${wire}`;
135
+ return null;
136
+ }
137
+ async chat(messages, tools, opts = {}) {
138
+ return (0, scheduler_1.scheduleInference)(this.baseUrl, opts, (scheduled) => this.chatScheduled(messages, tools, scheduled));
139
+ }
140
+ async chatScheduled(messages, tools, opts) {
141
+ const effort = opts.effortOverride ?? this.effort;
142
+ let wireMessages = messages;
143
+ const base = {
144
+ model: this.modelId,
145
+ messages: toWire(wireMessages),
146
+ tools: tools.length ? toWireTools(tools) : undefined,
147
+ max_tokens: opts.maxTokens ?? this.maxOutputTokens,
148
+ };
149
+ const wireEffort = mapEffort(effort, this.reasoning);
150
+ if (wireEffort && !this.effortUnsupported) {
151
+ base.reasoning_effort = wireEffort;
152
+ }
153
+ else if (effort === "off" && /qwen/i.test(this.modelId)) {
154
+ // Older LM Studio builds without reasoning_effort: fall back to the
155
+ // qwen per-turn /no_think soft switch (older qwen3 models honor it).
156
+ wireMessages = messages.map((m) => ({ ...m }));
157
+ for (let i = wireMessages.length - 1; i >= 0; i--) {
158
+ if (wireMessages[i].role === "user") {
159
+ wireMessages[i].content = wireMessages[i].content + " /no_think";
160
+ break;
161
+ }
162
+ }
163
+ base.messages = toWire(wireMessages);
164
+ }
165
+ const started = { streaming: false };
166
+ try {
167
+ return await this.chain(base, opts, started);
168
+ }
169
+ catch (err) {
170
+ if (err?.name === "AbortError")
171
+ throw err;
172
+ if (started.streaming)
173
+ throw err; // tokens already shown — don't re-emit
174
+ const msg = String(err?.message ?? "");
175
+ // Only latch effortUnsupported on an actual param rejection (4xx naming
176
+ // it), never on a transient 5xx / dropped socket.
177
+ if (base.reasoning_effort && /returned 4\d\d/.test(msg) && /reasoning|effort/i.test(msg)) {
178
+ this.effortUnsupported = true;
179
+ delete base.reasoning_effort;
180
+ return await this.chain(base, opts, started);
181
+ }
182
+ throw err;
183
+ }
184
+ }
185
+ async chain(base, opts, started) {
186
+ try {
187
+ return await this.request({ ...base, stream: true, stream_options: { include_usage: true } }, opts, started);
188
+ }
189
+ catch (err) {
190
+ if (err?.name === "AbortError")
191
+ throw err;
192
+ if (started.streaming)
193
+ throw err;
194
+ const msg = String(err?.message ?? "");
195
+ // Only degrade the request shape on a pre-stream HTTP rejection; a
196
+ // transient error must propagate to chatWithRetry for backoff.
197
+ if (!/returned 4\d\d/.test(msg))
198
+ throw err;
199
+ // A reasoning_effort rejection must reach chat()'s handler, not be
200
+ // masked by the stream-shape fallback ladder.
201
+ if (/reasoning|effort/i.test(msg))
202
+ throw err;
203
+ try {
204
+ return await this.request({ ...base, stream: true }, opts, started);
205
+ }
206
+ catch (err2) {
207
+ if (err2?.name === "AbortError" || started.streaming)
208
+ throw err2;
209
+ if (!/returned 4\d\d/.test(String(err2?.message ?? "")))
210
+ throw err2;
211
+ return await this.request({ ...base, stream: false }, opts, started);
212
+ }
213
+ }
214
+ }
215
+ async request(body, opts, started) {
216
+ return (0, transport_1.withDeadline)(opts, (signal, activity) => this.readResponse(body, { ...opts, signal }, activity, started));
217
+ }
218
+ async readResponse(body, opts, activity, started) {
219
+ const t0 = Date.now();
220
+ const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
221
+ method: "POST",
222
+ headers: { "content-type": "application/json" },
223
+ body: JSON.stringify(body),
224
+ signal: opts.signal,
225
+ });
226
+ if (!res.ok) {
227
+ const text = await res.text().catch(() => "");
228
+ throw new Error(`LM Studio returned ${res.status}: ${text.slice(0, 300)}`);
229
+ }
230
+ if (body.stream === false) {
231
+ const data = await res.json();
232
+ if (data.error || !data.choices?.[0]?.message)
233
+ throw new Error(`LM Studio returned an invalid completion: ${data.error?.message ?? "missing message"}`);
234
+ const msg = data.choices?.[0]?.message ?? {};
235
+ const toolCalls = (msg.tool_calls ?? []).map((tc) => ({
236
+ id: tc.id || (0, types_1.nextCallId)(),
237
+ name: tc.function?.name ?? "",
238
+ ...(0, types_1.parseArgs)(tc.function?.arguments),
239
+ }));
240
+ if (started)
241
+ started.streaming = true;
242
+ if (msg.content)
243
+ opts.onToken?.(msg.content);
244
+ const content = msg.content ?? "";
245
+ const total = Date.now() - t0;
246
+ return {
247
+ content,
248
+ toolCalls,
249
+ promptTokens: data.usage?.prompt_tokens,
250
+ completionTokens: (0, types_1.estimateReplayTokens)(content, toolCalls),
251
+ generatedTokens: data.usage?.completion_tokens,
252
+ genTokPerSec: data.usage?.completion_tokens ? data.usage.completion_tokens / (total / 1000) : undefined,
253
+ truncated: data.choices?.[0]?.finish_reason === "length",
254
+ };
255
+ }
256
+ // SSE stream.
257
+ let content = "";
258
+ let thinking = "";
259
+ const partials = new Map();
260
+ let promptTokens;
261
+ let completionTokens;
262
+ let truncated = false;
263
+ let finished = false;
264
+ let firstTokAt = 0;
265
+ let lastTokAt = 0;
266
+ const handleLine = (rawLine) => {
267
+ const line = rawLine.trim();
268
+ if (!line.startsWith("data:"))
269
+ return;
270
+ const payload = line.slice(5).trim();
271
+ if (payload === "[DONE]")
272
+ return;
273
+ const chunk = (0, transport_1.streamJson)(payload);
274
+ if (started)
275
+ started.streaming = true; // committed — no safe re-request now
276
+ if (chunk.usage) {
277
+ promptTokens = chunk.usage.prompt_tokens ?? promptTokens;
278
+ completionTokens = chunk.usage.completion_tokens ?? completionTokens;
279
+ }
280
+ if (chunk.choices?.[0]?.finish_reason === "length")
281
+ truncated = true;
282
+ if (chunk.choices?.[0]?.finish_reason)
283
+ finished = true;
284
+ const delta = chunk.choices?.[0]?.delta;
285
+ if (!delta)
286
+ return;
287
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
288
+ let sawToken = false;
289
+ if (typeof reasoning === "string" && reasoning) {
290
+ thinking += reasoning;
291
+ opts.onThinking?.(reasoning);
292
+ sawToken = true;
293
+ }
294
+ if (delta.content) {
295
+ content += delta.content;
296
+ opts.onToken?.(delta.content);
297
+ sawToken = true;
298
+ }
299
+ if (Array.isArray(delta.tool_calls)) {
300
+ sawToken = true;
301
+ for (const tc of delta.tool_calls) {
302
+ const idx = tc.index ?? 0;
303
+ const p = partials.get(idx) ?? { id: "", name: "", args: "" };
304
+ if (tc.id)
305
+ p.id = tc.id;
306
+ if (tc.function?.name)
307
+ p.name += tc.function.name;
308
+ if (tc.function?.arguments)
309
+ p.args += tc.function.arguments;
310
+ partials.set(idx, p);
311
+ }
312
+ }
313
+ if (sawToken) {
314
+ const now = Date.now();
315
+ if (!firstTokAt)
316
+ firstTokAt = now;
317
+ lastTokAt = now;
318
+ }
319
+ };
320
+ for await (const line of (0, transport_1.responseLines)(res, activity))
321
+ handleLine(line);
322
+ if (!finished)
323
+ throw new Error("LM Studio stream ended before completion; the response was discarded");
324
+ const toolCalls = [...partials.entries()]
325
+ .sort((a, b) => a[0] - b[0])
326
+ .map(([, p]) => ({
327
+ id: p.id || (0, types_1.nextCallId)(),
328
+ name: p.name,
329
+ ...(0, types_1.parseArgs)(p.args),
330
+ }));
331
+ const genMs = lastTokAt > firstTokAt ? lastTokAt - firstTokAt : 0;
332
+ return {
333
+ content,
334
+ toolCalls,
335
+ thinking: thinking || undefined,
336
+ promptTokens,
337
+ // LM Studio does not replay reasoning into the next prompt, so only the
338
+ // visible reply and tool-call JSON count toward the next request.
339
+ completionTokens: (0, types_1.estimateReplayTokens)(content, toolCalls),
340
+ generatedTokens: completionTokens,
341
+ genTokPerSec: completionTokens && genMs > 0 ? completionTokens / (genMs / 1000) : undefined,
342
+ ttftMs: firstTokAt ? firstTokAt - t0 : undefined,
343
+ truncated,
344
+ };
345
+ }
346
+ }
347
+ exports.LmStudioProvider = LmStudioProvider;
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ // Ollama adapter — uses the NATIVE /api/chat endpoint, not the OpenAI-compat
3
+ // one, because only the native API lets us set num_ctx per request, pass
4
+ // thinking traces back, and read real timings (prompt/eval durations).
5
+ //
6
+ // Prompt-size savings that matter on a long tool loop with a thinking model:
7
+ // reasoning traces from assistant messages BEFORE the current user turn are
8
+ // not sent back (the qwen3-family templates drop them anyway); only the
9
+ // current turn's traces travel, which is what tool-call loops need.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.OllamaProvider = void 0;
12
+ exports.toWire = toWire;
13
+ const types_1 = require("./types");
14
+ const transport_1 = require("./transport");
15
+ const scheduler_1 = require("./scheduler");
16
+ const attachments_1 = require("../attachments");
17
+ const util_1 = require("../util");
18
+ /** Exported for tests. */
19
+ function toWire(messages) {
20
+ const keepThinkingFrom = (0, types_1.lastUserIndex)(messages);
21
+ return messages.map((m, i) => {
22
+ if (m.role === "assistant" && m.toolCalls?.length) {
23
+ return {
24
+ role: "assistant",
25
+ content: m.content ?? "",
26
+ ...(m.thinking && i > keepThinkingFrom ? { thinking: m.thinking } : {}),
27
+ tool_calls: m.toolCalls.map((tc) => ({
28
+ function: { name: tc.name, arguments: tc.args },
29
+ })),
30
+ };
31
+ }
32
+ if (m.role === "tool") {
33
+ return { role: "tool", content: m.content, tool_name: m.toolName };
34
+ }
35
+ if (m.role === "user" && m.images?.length) {
36
+ const images = m.images.map(attachments_1.imageBase64).filter((b) => b !== null);
37
+ return { role: "user", content: m.content, ...(images.length ? { images } : {}) };
38
+ }
39
+ return { role: m.role, content: m.content };
40
+ });
41
+ }
42
+ function toWireTools(tools) {
43
+ return tools.map((t) => ({
44
+ type: "function",
45
+ function: { name: t.name, description: t.description, parameters: t.parameters },
46
+ }));
47
+ }
48
+ /** Keep the model resident between tool calls and while the user reads or
49
+ * approves. Ollama's own default (5 min) unloads mid-session on any longer
50
+ * pause, and a reload of a 17 GB model costs 10-20 s plus a cold cache. */
51
+ const KEEP_ALIVE = process.env.SMOLCODER_KEEP_ALIVE || "30m";
52
+ class OllamaProvider {
53
+ baseUrl;
54
+ modelId;
55
+ contextWindow;
56
+ numCtx;
57
+ vision;
58
+ replaysThinking = true;
59
+ label;
60
+ maxOutputTokens;
61
+ effort = null;
62
+ thinkUnsupported = false;
63
+ /** null = unknown, tried lazily; false = this model only takes a boolean. */
64
+ levelsSupported = null;
65
+ constructor(baseUrl, modelId, contextWindow,
66
+ /** Explicit num_ctx to send; undefined = respect the server's configured context. */
67
+ numCtx, maxOutputTokens = types_1.MAX_OUTPUT_TOKENS,
68
+ /** Whether the model accepts images (from /api/show capabilities). */
69
+ vision) {
70
+ this.baseUrl = baseUrl;
71
+ this.modelId = modelId;
72
+ this.contextWindow = contextWindow;
73
+ this.numCtx = numCtx;
74
+ this.vision = vision;
75
+ this.label = `ollama · ${modelId}`;
76
+ this.maxOutputTokens = maxOutputTokens;
77
+ // Only gpt-oss is documented to take levels; everything else gets a
78
+ // boolean straight away instead of a wasted probe request.
79
+ if (!/gpt-oss/i.test(modelId))
80
+ this.levelsSupported = false;
81
+ }
82
+ setEffort(effort) {
83
+ this.effort = effort;
84
+ this.thinkUnsupported = false;
85
+ }
86
+ async loadedContextWindow() {
87
+ if (this.numCtx)
88
+ return this.numCtx;
89
+ const ps = await (0, util_1.tryFetchJson)(`${this.baseUrl}/api/ps`, undefined, 1500);
90
+ const model = ps?.models?.find((m) => m.name === this.modelId || m.model === this.modelId);
91
+ return typeof model?.context_length === "number" ? model.context_length : undefined;
92
+ }
93
+ effortLabel() {
94
+ if (this.effort === null || this.effort === "off")
95
+ return null;
96
+ if (this.thinkUnsupported)
97
+ return `${this.effort} (model has no thinking switch)`;
98
+ if (this.levelsSupported === false)
99
+ return `${this.effort} → thinking on`;
100
+ return null;
101
+ }
102
+ /** Ollama's think param: boolean for most reasoning models; gpt-oss accepts levels. */
103
+ thinkParam(effort) {
104
+ if (effort === null || this.thinkUnsupported)
105
+ return undefined;
106
+ if (effort === "off")
107
+ return false;
108
+ return this.levelsSupported === false ? true : effort;
109
+ }
110
+ async chat(messages, tools, opts = {}) {
111
+ return (0, scheduler_1.scheduleInference)(this.baseUrl, opts, (scheduled) => this.chatScheduled(messages, tools, scheduled));
112
+ }
113
+ async chatScheduled(messages, tools, opts) {
114
+ const effort = opts.effortOverride ?? this.effort;
115
+ const makeBody = (stream, think) => ({
116
+ model: this.modelId,
117
+ messages: toWire(messages),
118
+ tools: tools.length ? toWireTools(tools) : undefined,
119
+ stream,
120
+ keep_alive: KEEP_ALIVE,
121
+ ...(think !== undefined ? { think } : {}),
122
+ options: {
123
+ ...(this.numCtx ? { num_ctx: this.numCtx } : {}),
124
+ num_predict: opts.maxTokens ?? this.maxOutputTokens,
125
+ },
126
+ });
127
+ let think = this.thinkParam(effort);
128
+ const started = { streaming: false };
129
+ try {
130
+ return await this.request(makeBody(true, think), opts, started);
131
+ }
132
+ catch (err) {
133
+ if (err?.name === "AbortError")
134
+ throw err;
135
+ // Never re-request after tokens were already streamed to the UI — that
136
+ // double-emits. Let agent.ts's chatWithRetry handle mid-stream failures.
137
+ if (started.streaming)
138
+ throw err;
139
+ const msg = String(err?.message ?? "");
140
+ const paramRejected = /returned 4\d\d/.test(msg) && /think/i.test(msg);
141
+ // Only treat the think param as unsupported on an actual param rejection;
142
+ // a transient 5xx/network error must NOT permanently disable reasoning.
143
+ if (think !== undefined && paramRejected) {
144
+ if (typeof think === "string") {
145
+ // Levels rejected — this model takes a boolean. Same intent: on.
146
+ this.levelsSupported = false;
147
+ think = true;
148
+ try {
149
+ return await this.request(makeBody(true, think), opts, started);
150
+ }
151
+ catch (err2) {
152
+ if (err2?.name === "AbortError" || started.streaming)
153
+ throw err2;
154
+ const msg2 = String(err2?.message ?? "");
155
+ if (!(/returned 4\d\d/.test(msg2) && /think/i.test(msg2)))
156
+ throw err2;
157
+ }
158
+ }
159
+ this.thinkUnsupported = true;
160
+ think = undefined;
161
+ return await this.request(makeBody(true, undefined), opts, started);
162
+ }
163
+ // Older Ollama versions reject stream+tools together; retry non-streaming
164
+ // once, but only for a pre-stream rejection (not a transient error).
165
+ if (/returned 4\d\d/.test(msg)) {
166
+ return await this.request(makeBody(false, think), opts, started);
167
+ }
168
+ throw err;
169
+ }
170
+ }
171
+ async request(body, opts, started) {
172
+ return (0, transport_1.withDeadline)(opts, (signal, activity) => this.readResponse(body, { ...opts, signal }, activity, started));
173
+ }
174
+ async readResponse(body, opts, activity, started) {
175
+ const t0 = Date.now();
176
+ const res = await fetch(`${this.baseUrl}/api/chat`, {
177
+ method: "POST",
178
+ headers: { "content-type": "application/json" },
179
+ body: JSON.stringify(body),
180
+ signal: opts.signal,
181
+ });
182
+ if (!res.ok) {
183
+ const text = await res.text().catch(() => "");
184
+ throw new Error(`Ollama returned ${res.status}: ${text.slice(0, 300)}`);
185
+ }
186
+ let content = "";
187
+ let thinking = "";
188
+ const toolCalls = [];
189
+ let promptTokens;
190
+ let completionTokens;
191
+ let promptTokPerSec;
192
+ let genTokPerSec;
193
+ let truncated = false;
194
+ let finished = false;
195
+ let firstTokAt = 0;
196
+ const handleChunk = (chunk) => {
197
+ if (chunk.error)
198
+ throw new Error(`Ollama stream error: ${chunk.error}`);
199
+ if (started)
200
+ started.streaming = true; // committed — no safe re-request now
201
+ const msg = chunk.message;
202
+ if (msg?.thinking) {
203
+ thinking += msg.thinking;
204
+ opts.onThinking?.(msg.thinking);
205
+ if (!firstTokAt)
206
+ firstTokAt = Date.now();
207
+ }
208
+ if (msg?.content) {
209
+ content += msg.content;
210
+ opts.onToken?.(msg.content);
211
+ if (!firstTokAt)
212
+ firstTokAt = Date.now();
213
+ }
214
+ if (Array.isArray(msg?.tool_calls)) {
215
+ if (!firstTokAt)
216
+ firstTokAt = Date.now();
217
+ for (const tc of msg.tool_calls) {
218
+ const fn = tc.function ?? {};
219
+ toolCalls.push({
220
+ id: (0, types_1.nextCallId)(),
221
+ name: fn.name ?? "",
222
+ ...(0, types_1.parseArgs)(fn.arguments),
223
+ });
224
+ }
225
+ }
226
+ if (chunk.done) {
227
+ finished = true;
228
+ if (typeof chunk.prompt_eval_count === "number")
229
+ promptTokens = chunk.prompt_eval_count;
230
+ if (typeof chunk.eval_count === "number")
231
+ completionTokens = chunk.eval_count;
232
+ if (typeof chunk.prompt_eval_duration === "number" && chunk.prompt_eval_duration > 0 && promptTokens) {
233
+ promptTokPerSec = promptTokens / (chunk.prompt_eval_duration / 1e9);
234
+ }
235
+ if (typeof chunk.eval_duration === "number" && chunk.eval_duration > 0 && completionTokens) {
236
+ genTokPerSec = completionTokens / (chunk.eval_duration / 1e9);
237
+ }
238
+ if (chunk.done_reason === "length")
239
+ truncated = true;
240
+ }
241
+ };
242
+ if (body.stream === false) {
243
+ handleChunk(await res.json());
244
+ }
245
+ else {
246
+ for await (const line of (0, transport_1.responseLines)(res, activity)) {
247
+ if (line)
248
+ handleChunk((0, transport_1.streamJson)(line));
249
+ }
250
+ }
251
+ if (!finished)
252
+ throw new Error("Ollama stream ended before completion; the response was discarded");
253
+ return {
254
+ content,
255
+ toolCalls,
256
+ thinking: thinking || undefined,
257
+ promptTokens,
258
+ // Ollama replays this turn's thinking into the next prompt, so the full
259
+ // eval count is what the next request carries.
260
+ completionTokens,
261
+ generatedTokens: completionTokens,
262
+ promptTokPerSec,
263
+ genTokPerSec,
264
+ ttftMs: firstTokAt ? firstTokAt - t0 : undefined,
265
+ truncated,
266
+ };
267
+ }
268
+ }
269
+ exports.OllamaProvider = OllamaProvider;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scheduleInference = scheduleInference;
4
+ const servers = new Map();
5
+ /** One inference per server. Coding preempts optional summaries and titles,
6
+ * avoiding a second model/KV allocation just to do maintenance. */
7
+ function scheduleInference(server, opts, run) {
8
+ let state = servers.get(server);
9
+ if (!state) {
10
+ state = { queue: [] };
11
+ servers.set(server, state);
12
+ }
13
+ const queue = state;
14
+ if (opts.background && (queue.active || queue.queue.length))
15
+ return Promise.reject(new Error("Background inference deferred: server is busy"));
16
+ return new Promise((resolve, reject) => {
17
+ const controller = new AbortController();
18
+ const job = { background: !!opts.background, controller, start: () => { } };
19
+ const cancel = () => {
20
+ controller.abort(opts.signal?.reason);
21
+ const index = queue.queue.indexOf(job);
22
+ if (index >= 0) {
23
+ queue.queue.splice(index, 1);
24
+ opts.signal?.removeEventListener("abort", cancel);
25
+ reject(controller.signal.reason);
26
+ }
27
+ };
28
+ job.start = () => {
29
+ queue.active = job;
30
+ if (opts.signal?.aborted)
31
+ cancel();
32
+ Promise.resolve().then(() => {
33
+ if (controller.signal.aborted)
34
+ throw controller.signal.reason;
35
+ return run({ ...opts, signal: controller.signal });
36
+ }).then(resolve, reject).finally(() => {
37
+ opts.signal?.removeEventListener("abort", cancel);
38
+ queue.active = undefined;
39
+ const next = queue.queue.shift();
40
+ if (next)
41
+ next.start();
42
+ else
43
+ servers.delete(server);
44
+ });
45
+ };
46
+ opts.signal?.addEventListener("abort", cancel, { once: true });
47
+ if (queue.active) {
48
+ queue.queue.push(job);
49
+ if (!job.background && queue.active.background)
50
+ queue.active.controller.abort();
51
+ if (opts.signal?.aborted)
52
+ cancel();
53
+ }
54
+ else
55
+ job.start();
56
+ });
57
+ }