@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 +31 -4
- package/dist/index.d.ts +7 -1
- package/dist/index.js +151 -98
- package/package.json +8 -2
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
|
|
107
|
-
- 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(["
|
|
4
|
-
const baseUrl = (opts.baseUrl
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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 ?? [])
|
|
103
|
-
const mapped = await mapMessagesWithImages();
|
|
104
|
-
const
|
|
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
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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": "
|
|
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.
|
|
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",
|