@sisu-ai/adapter-ollama 9.0.3 → 10.0.1

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 CHANGED
@@ -15,14 +15,28 @@ npm i @sisu-ai/adapter-ollama
15
15
 
16
16
  - Start Ollama locally: `ollama serve`
17
17
  - Pull a tools-capable model: `ollama pull llama3.1:latest`
18
+ - Base URL env: `BASE_URL`
19
+
20
+ ## Transport and compatibility
21
+
22
+ - The adapter now uses the official `ollama` JavaScript client for chat transport.
23
+ - Public Sisu behavior remains stable: normalized messages/tool-calls, streaming events, and image URL-to-base64 preprocessing.
24
+ - `GenerateOptions.toolChoice` semantics are normalized at the adapter layer:
25
+ - `auto` / `required` keeps all declared tools available to the model
26
+ - `none` omits tools for that call
27
+ - named tool choice narrows the sent tool list to that tool
28
+ - Cancellation is propagated for request execution and image preprocessing fetches.
18
29
 
19
30
 
20
31
  ## Usage
21
32
  ```ts
22
- import { ollamaAdapter } from '@sisu-ai/adapter-ollama';
33
+ import { ollamaAdapter, ollamaEmbeddings } from '@sisu-ai/adapter-ollama';
23
34
 
24
35
  const model = ollamaAdapter({ model: 'llama3.1' });
25
36
  // or with custom base URL: { baseUrl: 'http://localhost:11435' }
37
+
38
+ const embeddings = ollamaEmbeddings({ model: 'embeddinggemma' });
39
+ const vectors = await embeddings.embed(['first text', 'second text']);
26
40
  ```
27
41
 
28
42
  ## Images (Vision)
@@ -103,8 +117,8 @@ await app.handler()(ctx);
103
117
 
104
118
  ## Notes
105
119
  - Tool choice forcing is model-dependent; current loop asks for tools on first turn and plain completion on second.
106
- - Streaming can be added via Ollama's streaming API if desired.
107
- - Env: `OLLAMA_BASE_URL` or `BASE_URL` can override the base URL (or pass `baseUrl` in code). Examples may also support a CLI flag `--base-url` to override env.
120
+ - Streaming is supported and mapped to Sisu `token` + final `assistant_message` events.
121
+ - Env: `BASE_URL` overrides the base URL (or pass `baseUrl` in code). Examples may also support a CLI flag `--base-url` to override env.
108
122
 
109
123
 
110
124
  # Community & Support
@@ -152,9 +166,9 @@ Discover what you can do through examples or documentation. Check it out at http
152
166
  - [@sisu-ai/tool-azure-blob](packages/tools/azure-blob/README.md)
153
167
  - [@sisu-ai/tool-extract-urls](packages/tools/extract-urls/README.md)
154
168
  - [@sisu-ai/tool-github-projects](packages/tools/github-projects/README.md)
169
+ - [@sisu-ai/tool-rag](packages/tools/rag/README.md)
155
170
  - [@sisu-ai/tool-summarize-text](packages/tools/summarize-text/README.md)
156
171
  - [@sisu-ai/tool-terminal](packages/tools/terminal/README.md)
157
- - [@sisu-ai/tool-vec-chroma](packages/tools/vec-chroma/README.md)
158
172
  - [@sisu-ai/tool-web-fetch](packages/tools/web-fetch/README.md)
159
173
  - [@sisu-ai/tool-web-search-duckduckgo](packages/tools/web-search-duckduckgo/README.md)
160
174
  - [@sisu-ai/tool-web-search-google](packages/tools/web-search-google/README.md)
@@ -162,6 +176,19 @@ Discover what you can do through examples or documentation. Check it out at http
162
176
  - [@sisu-ai/tool-wikipedia](packages/tools/wikipedia/README.md)
163
177
  </details>
164
178
 
179
+ <details>
180
+ <summary>All RAG packages</summary>
181
+
182
+ - [@sisu-ai/rag-core](packages/rag/core/README.md)
183
+ </details>
184
+
185
+ <details>
186
+ <summary>All vector packages</summary>
187
+
188
+ - [@sisu-ai/vector-core](packages/vector/core/README.md)
189
+ - [@sisu-ai/vector-chroma](packages/vector/chroma/README.md)
190
+ </details>
191
+
165
192
  <details>
166
193
  <summary>All examples</summary>
167
194
 
package/dist/index.d.ts CHANGED
@@ -1,7 +1,13 @@
1
- import type { LLM } from "@sisu-ai/core";
1
+ import type { LLM, EmbeddingsProvider } from "@sisu-ai/core";
2
2
  export interface OllamaAdapterOptions {
3
3
  model: string;
4
4
  baseUrl?: string;
5
5
  headers?: Record<string, string>;
6
6
  }
7
+ export interface OllamaEmbeddingsOptions {
8
+ model: string;
9
+ baseUrl?: string;
10
+ headers?: Record<string, string>;
11
+ }
12
+ export declare function ollamaEmbeddings(opts: OllamaEmbeddingsOptions): EmbeddingsProvider;
7
13
  export declare function ollamaAdapter(opts: OllamaAdapterOptions): LLM;
package/dist/index.js CHANGED
@@ -1,26 +1,53 @@
1
- import { firstConfigValue } from "@sisu-ai/core";
1
+ import { createEmbeddingsClient, firstConfigValue } from "@sisu-ai/core";
2
+ import { Ollama, } from "ollama";
3
+ function resolveBaseUrl(explicitBaseUrl, envBaseUrl, fallback) {
4
+ const candidate = explicitBaseUrl || envBaseUrl;
5
+ return (candidate && candidate !== "/" ? candidate : fallback).replace(/\/$/, "");
6
+ }
7
+ export function ollamaEmbeddings(opts) {
8
+ if (!opts.model) {
9
+ throw new Error("[ollamaEmbeddings] model is required");
10
+ }
11
+ const envBase = firstConfigValue(["BASE_URL", "OLLAMA_BASE_URL"]);
12
+ const baseUrl = resolveBaseUrl(opts.baseUrl, envBase, "http://localhost:11434");
13
+ return createEmbeddingsClient({
14
+ baseUrl,
15
+ path: "/api/embed",
16
+ headers: opts.headers,
17
+ model: opts.model,
18
+ clientName: "ollamaEmbeddings",
19
+ parseResponse: (raw) => {
20
+ const parsed = JSON.parse(raw);
21
+ return parsed.embeddings ?? [];
22
+ },
23
+ });
24
+ }
2
25
  export function ollamaAdapter(opts) {
3
- const envBase = firstConfigValue(["OLLAMA_BASE_URL", "BASE_URL"]);
4
- const baseUrl = (opts.baseUrl ?? envBase ?? "http://localhost:11434").replace(/\/$/, "");
26
+ const envBase = firstConfigValue(["BASE_URL", "OLLAMA_BASE_URL"]);
27
+ const baseUrl = resolveBaseUrl(opts.baseUrl, envBase, "http://localhost:11434");
5
28
  const modelName = `ollama:${opts.model}`;
29
+ const client = new Ollama({
30
+ host: baseUrl,
31
+ headers: opts.headers,
32
+ });
6
33
  const generate = ((messages, genOpts) => {
7
34
  // Map messages to Ollama format; include assistant tool_calls and tool messages
8
- async function mapMessagesWithImages() {
35
+ async function mapMessagesWithImages(signal) {
9
36
  const out = [];
10
37
  for (const m of messages) {
11
- const base = { role: m.role };
38
+ const base = { role: m.role, content: "" };
12
39
  const anyM = m;
13
40
  if (m.role === "assistant" && Array.isArray(anyM.tool_calls)) {
14
41
  base.tool_calls = anyM.tool_calls.map((tc) => ({
15
- id: tc.id,
16
- type: "function",
17
- function: { name: tc.name, arguments: tc.arguments ?? {} },
42
+ function: {
43
+ name: tc.name ?? "",
44
+ arguments: normalizeToolCallArguments(tc.arguments),
45
+ },
18
46
  }));
19
47
  const ti = buildTextAndImages(anyM);
20
- base.content =
21
- ti.content ?? (m.content !== undefined ? m.content : null);
48
+ base.content = ti.content ?? String(m.content ?? "");
22
49
  if (ti.images?.length)
23
- base.images = await toBase64Images(ti.images);
50
+ base.images = await toBase64Images(ti.images, signal);
24
51
  }
25
52
  else if (m.role === "tool") {
26
53
  base.content = String(m.content ?? "");
@@ -31,9 +58,9 @@ export function ollamaAdapter(opts) {
31
58
  }
32
59
  else {
33
60
  const ti = buildTextAndImages(anyM);
34
- base.content = ti.content ?? m.content ?? "";
61
+ base.content = ti.content ?? String(m.content ?? "");
35
62
  if (ti.images?.length)
36
- base.images = await toBase64Images(ti.images);
63
+ base.images = await toBase64Images(ti.images, signal);
37
64
  if (m.name)
38
65
  base.name = m.name;
39
66
  }
@@ -43,94 +70,54 @@ export function ollamaAdapter(opts) {
43
70
  }
44
71
  if (genOpts?.stream === true) {
45
72
  return (async function* () {
46
- const toolsParam = (genOpts?.tools ?? []).map(toOllamaTool);
47
- const mapped = await mapMessagesWithImages();
48
- const baseBody = {
49
- model: opts.model,
50
- messages: mapped,
51
- };
52
- if (toolsParam.length)
53
- baseBody.tools = toolsParam;
54
- const res = await fetch(`${baseUrl}/api/chat`, {
55
- method: "POST",
56
- headers: {
57
- "Content-Type": "application/json",
58
- Accept: "application/json",
59
- ...(opts.headers ?? {}),
60
- },
61
- body: JSON.stringify({ ...baseBody, stream: true }),
62
- });
63
- if (!res.ok || !res.body) {
64
- const err = await res.text();
65
- throw new Error(`Ollama API error: ${res.status} ${res.statusText} — ${String(err).slice(0, 500)}`);
66
- }
67
- const decoder = new TextDecoder();
68
- let buf = "";
69
- let full = "";
70
- for await (const chunk of res.body) {
71
- const piece = typeof chunk === "string" ? chunk : decoder.decode(chunk);
72
- buf += piece;
73
- const lines = buf.split("\n");
74
- buf = lines.pop() ?? "";
75
- for (const line of lines) {
76
- if (!line.trim())
77
- continue;
78
- try {
79
- const j = JSON.parse(line);
80
- if (j.done) {
81
- yield {
82
- type: "assistant_message",
83
- message: { role: "assistant", content: full },
84
- };
85
- return;
86
- }
87
- const token = j.message?.content;
88
- if (typeof token === "string" && token) {
89
- full += token;
90
- yield { type: "token", token };
91
- }
73
+ try {
74
+ throwIfAborted(genOpts?.signal);
75
+ const toolsParam = buildOllamaTools(genOpts?.tools ?? [], genOpts?.toolChoice);
76
+ const mapped = await mapMessagesWithImages(genOpts?.signal);
77
+ const request = {
78
+ model: opts.model,
79
+ messages: mapped,
80
+ stream: true,
81
+ };
82
+ if (toolsParam.length)
83
+ request.tools = [...toolsParam];
84
+ const stream = await withAbortSignal(() => client.chat(request), genOpts?.signal);
85
+ let full = "";
86
+ for await (const j of stream) {
87
+ throwIfAborted(genOpts?.signal);
88
+ if (j.done) {
89
+ yield {
90
+ type: "assistant_message",
91
+ message: { role: "assistant", content: full },
92
+ };
93
+ return;
92
94
  }
93
- catch (e) {
94
- console.error("[DEBUG_LLM] stream_parse_error", { error: e });
95
+ const token = j.message?.content;
96
+ if (typeof token === "string" && token) {
97
+ full += token;
98
+ yield { type: "token", token };
95
99
  }
96
100
  }
97
101
  }
102
+ catch (error) {
103
+ throw mapOllamaError(error);
104
+ }
98
105
  })();
99
106
  }
100
107
  // Non-stream path
101
108
  return (async () => {
102
- const toolsParam = (genOpts?.tools ?? []).map(toOllamaTool);
103
- const mapped = await mapMessagesWithImages();
104
- const baseBody = {
109
+ const toolsParam = buildOllamaTools(genOpts?.tools ?? [], genOpts?.toolChoice);
110
+ const mapped = await mapMessagesWithImages(genOpts?.signal);
111
+ const request = {
105
112
  model: opts.model,
106
113
  messages: mapped,
114
+ stream: false,
107
115
  };
108
116
  if (toolsParam.length)
109
- baseBody.tools = toolsParam;
110
- const res = await fetch(`${baseUrl}/api/chat`, {
111
- method: "POST",
112
- headers: {
113
- "Content-Type": "application/json",
114
- Accept: "application/json",
115
- ...(opts.headers ?? {}),
116
- },
117
- body: JSON.stringify({ ...baseBody, stream: false }),
118
- });
119
- const raw = await res.text();
120
- if (!res.ok) {
121
- let details = raw;
122
- try {
123
- const j = JSON.parse(raw);
124
- details = j.error ?? j.message ?? raw;
125
- }
126
- catch (e) {
127
- console.error("[DEBUG_LLM] request_error", { error: e });
128
- }
129
- throw new Error(`Ollama API error: ${res.status} ${res.statusText} — ${String(details).slice(0, 500)}`);
130
- }
131
- const data = raw ? JSON.parse(raw) : {};
132
- const choice = data
133
- .message ?? {};
117
+ request.tools = [...toolsParam];
118
+ const data = await withAbortSignal(() => client.chat(request), genOpts?.signal);
119
+ const choice = data.message ??
120
+ {};
134
121
  const content = choice.content;
135
122
  const tcs = Array.isArray(choice.tool_calls)
136
123
  ? choice.tool_calls
@@ -147,7 +134,9 @@ export function ollamaAdapter(opts) {
147
134
  ...(tcs ? { tool_calls: tcs } : {}),
148
135
  };
149
136
  return { message: out };
150
- })();
137
+ })().catch((error) => {
138
+ throw mapOllamaError(error);
139
+ });
151
140
  });
152
141
  return {
153
142
  name: modelName,
@@ -155,6 +144,14 @@ export function ollamaAdapter(opts) {
155
144
  generate,
156
145
  };
157
146
  }
147
+ function mapOllamaError(error) {
148
+ if (error instanceof Error && error.name === "AbortError") {
149
+ return error;
150
+ }
151
+ return error instanceof Error
152
+ ? new Error(`Ollama API error: ${error.message.slice(0, 500)}`)
153
+ : new Error(`Ollama API error: ${String(error).slice(0, 500)}`);
154
+ }
158
155
  function toOllamaTool(tool) {
159
156
  return {
160
157
  type: "function",
@@ -165,6 +162,30 @@ function toOllamaTool(tool) {
165
162
  },
166
163
  };
167
164
  }
165
+ function normalizeToolCallArguments(args) {
166
+ if (args && typeof args === "object" && !Array.isArray(args)) {
167
+ return args;
168
+ }
169
+ return {};
170
+ }
171
+ function buildOllamaTools(tools, toolChoice) {
172
+ const mapped = tools.map(toOllamaTool);
173
+ if (!mapped.length)
174
+ return mapped;
175
+ if (!toolChoice || toolChoice === "auto" || toolChoice === "required") {
176
+ return mapped;
177
+ }
178
+ if (toolChoice === "none")
179
+ return [];
180
+ const selected = typeof toolChoice === "string"
181
+ ? toolChoice
182
+ : typeof toolChoice === "object" && typeof toolChoice.name === "string"
183
+ ? toolChoice.name
184
+ : undefined;
185
+ if (!selected)
186
+ return mapped;
187
+ return mapped.filter((tool) => tool.function.name === selected);
188
+ }
168
189
  function toJsonSchema(schema) {
169
190
  if (!schema)
170
191
  return { type: "object" };
@@ -279,12 +300,6 @@ function buildTextAndImages(m) {
279
300
  const content = typeof obj.content === "string" ? obj.content : undefined;
280
301
  return { content, images: images.length ? images : undefined };
281
302
  }
282
- async function toBase64Images(images) {
283
- const out = [];
284
- for (const src of images)
285
- out.push(await toBase64(src));
286
- return out;
287
- }
288
303
  function isHttpUrl(s) {
289
304
  return /^https?:\/\//i.test(s);
290
305
  }
@@ -303,11 +318,18 @@ function isProbablyBase64(s) {
303
318
  return false;
304
319
  return /^[A-Za-z0-9+/]+={0,2}$/.test(s);
305
320
  }
306
- async function toBase64(src) {
321
+ async function toBase64Images(images, signal) {
322
+ const out = [];
323
+ for (const src of images)
324
+ out.push(await toBase64(src, signal));
325
+ return out;
326
+ }
327
+ async function toBase64(src, signal) {
328
+ throwIfAborted(signal);
307
329
  if (isDataUrl(src))
308
330
  return fromDataUrl(src);
309
331
  if (isHttpUrl(src)) {
310
- const res = await fetch(src);
332
+ const res = await fetch(src, { signal });
311
333
  if (!res.ok)
312
334
  throw new Error(`Failed to fetch image: ${res.status} ${res.statusText}`);
313
335
  const buf = Buffer.from(await res.arrayBuffer());
@@ -315,3 +337,34 @@ async function toBase64(src) {
315
337
  }
316
338
  return isProbablyBase64(src) ? src : src;
317
339
  }
340
+ function createAbortError() {
341
+ const DomExceptionCtor = globalThis.DOMException;
342
+ if (typeof DomExceptionCtor === "function") {
343
+ return new DomExceptionCtor("The operation was aborted.", "AbortError");
344
+ }
345
+ const error = new Error("The operation was aborted.");
346
+ error.name = "AbortError";
347
+ return error;
348
+ }
349
+ function throwIfAborted(signal) {
350
+ if (signal?.aborted)
351
+ throw createAbortError();
352
+ }
353
+ async function withAbortSignal(promiseFactory, signal) {
354
+ throwIfAborted(signal);
355
+ if (!signal)
356
+ return promiseFactory();
357
+ return new Promise((resolve, reject) => {
358
+ const onAbort = () => reject(createAbortError());
359
+ signal.addEventListener("abort", onAbort, { once: true });
360
+ promiseFactory()
361
+ .then((value) => {
362
+ signal.removeEventListener("abort", onAbort);
363
+ resolve(value);
364
+ })
365
+ .catch((error) => {
366
+ signal.removeEventListener("abort", onAbort);
367
+ reject(error);
368
+ });
369
+ });
370
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sisu-ai/adapter-ollama",
3
- "version": "9.0.3",
3
+ "version": "10.0.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,10 @@
21
21
  "url": "https://github.com/finger-gun/sisu/issues"
22
22
  },
23
23
  "peerDependencies": {
24
- "@sisu-ai/core": "^2.3.3"
24
+ "@sisu-ai/core": "^2.4.0"
25
+ },
26
+ "devDependencies": {
27
+ "@sisu-ai/core": "2.4.0"
25
28
  },
26
29
  "keywords": [
27
30
  "sisu",
@@ -31,6 +34,9 @@
31
34
  "adapter",
32
35
  "ollama"
33
36
  ],
37
+ "dependencies": {
38
+ "ollama": "^0.6.3"
39
+ },
34
40
  "scripts": {
35
41
  "build": "tsc -b",
36
42
  "clean": "rm -rf dist",