@sisu-ai/adapter-ollama 4.0.2 → 4.1.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 +73 -6
- package/dist/index.js +138 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,8 +16,6 @@ npm i @sisu-ai/adapter-ollama
|
|
|
16
16
|
- Start Ollama locally: `ollama serve`
|
|
17
17
|
- Pull a tools-capable model: `ollama pull llama3.1:latest`
|
|
18
18
|
|
|
19
|
-
## Documentation
|
|
20
|
-
Discover what you can do through examples or documentation. Check it out at https://github.com/finger-gun/sisu
|
|
21
19
|
|
|
22
20
|
## Usage
|
|
23
21
|
```ts
|
|
@@ -25,14 +23,83 @@ import { ollamaAdapter } from '@sisu-ai/adapter-ollama';
|
|
|
25
23
|
|
|
26
24
|
const model = ollamaAdapter({ model: 'llama3.1' });
|
|
27
25
|
// or with custom base URL: { baseUrl: 'http://localhost:11435' }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Images (Vision)
|
|
29
|
+
- Accepts multi-part `content` arrays with `type: 'text' | 'image_url'` and convenience fields like `images`/`image_url`.
|
|
30
|
+
- The adapter maps these to Ollama's expected shape by sending `content` as a string and `images` as a string array on the message.
|
|
31
|
+
- If an image value is an `http(s)` URL, the adapter fetches it and inlines it as base64 automatically. Data URLs are supported; raw base64 strings pass through.
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
Content parts (adapter maps to `images[]` under the hood and auto-fetches URLs):
|
|
34
|
+
```ts
|
|
35
|
+
const messages: any[] = [
|
|
36
|
+
{ role: 'user', content: [
|
|
37
|
+
{ type: 'text', text: 'What is in this image?' },
|
|
38
|
+
{ type: 'image_url', image_url: { url: 'https://example.com/pic.jpg' } },
|
|
39
|
+
] }
|
|
40
|
+
];
|
|
41
|
+
const res = await model.generate(messages, { toolChoice: 'none' });
|
|
30
42
|
```
|
|
31
43
|
|
|
44
|
+
Convenience shape:
|
|
45
|
+
```ts
|
|
46
|
+
const messages: any[] = [
|
|
47
|
+
{ role: 'user', content: 'Describe the image.', images: ['https://example.com/pic.jpg'] },
|
|
48
|
+
];
|
|
49
|
+
const res = await model.generate(messages, { toolChoice: 'none' });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Normalizing Ollama API
|
|
53
|
+
- Providers such as OpenAI vision models accepts `image_url` parts with `url` pointing to a remote image; the provider dereferences the URL.
|
|
54
|
+
- Ollama expects each message to include `images: string[]` of base64-encoded image data; it does not dereference remote URLs.
|
|
55
|
+
- This adapter keeps the authoring experience consistent by accepting OpenAI-style parts and convenience URLs, and performs URL→base64 conversion for you.
|
|
56
|
+
|
|
57
|
+
### Accepted image formats
|
|
58
|
+
- Base64 string: `images: ["<base64>"]` (preferred/default for Ollama)
|
|
59
|
+
- Data URL: `images: ["data:image/png;base64,<base64>"]` or in parts via `{ type: 'image_url', image_url: { url: 'data:...' } }`
|
|
60
|
+
- Remote URL (convenience): `{ type: 'image_url', image_url: { url: 'https://...' } }` or `images: ['https://...']` — adapter fetches and inlines as base64.
|
|
61
|
+
|
|
62
|
+
Note: URL fetching happens from your runtime. If your environment blocks outbound HTTP, either provide base64 directly or host images where your runtime can reach them.
|
|
63
|
+
|
|
32
64
|
## Tools
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
65
|
+
- Define tools as small, named functions with a zod schema.
|
|
66
|
+
- Register them on your agent and add the tool-calling middleware — the adapter handles the wire format to/from Ollama.
|
|
67
|
+
- Under the hood, the adapter sends your tool schemas to the model, maps model “function calls” back to your handlers, and includes tool results for follow‑up turns.
|
|
68
|
+
|
|
69
|
+
Quick start with tools
|
|
70
|
+
```ts
|
|
71
|
+
import { Agent, InMemoryKV, NullStream, SimpleTools, createConsoleLogger, type Ctx, type Tool } from '@sisu-ai/core';
|
|
72
|
+
import { registerTools } from '@sisu-ai/mw-register-tools';
|
|
73
|
+
import { toolCalling } from '@sisu-ai/mw-tool-calling';
|
|
74
|
+
import { z } from 'zod';
|
|
75
|
+
import { ollamaAdapter } from '@sisu-ai/adapter-ollama';
|
|
76
|
+
|
|
77
|
+
const sum: Tool<{ a: number; b: number }> = {
|
|
78
|
+
name: 'sum',
|
|
79
|
+
description: 'Add two numbers',
|
|
80
|
+
schema: z.object({ a: z.number(), b: z.number() }),
|
|
81
|
+
handler: async ({ a, b }) => ({ result: a + b }),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const model = ollamaAdapter({ model: 'llama3.1' });
|
|
85
|
+
const ctx: Ctx = {
|
|
86
|
+
input: 'Use the sum tool to add 3 and 7, then explain.',
|
|
87
|
+
messages: [{ role: 'system', content: 'You are helpful.' }],
|
|
88
|
+
model,
|
|
89
|
+
tools: new SimpleTools(),
|
|
90
|
+
memory: new InMemoryKV(),
|
|
91
|
+
stream: new NullStream(),
|
|
92
|
+
state: {},
|
|
93
|
+
signal: new AbortController().signal,
|
|
94
|
+
log: createConsoleLogger(),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const app = new Agent()
|
|
98
|
+
.use(registerTools([sum])) // make tools available
|
|
99
|
+
.use(toolCalling); // let the model pick tools, run them, and finalize
|
|
100
|
+
|
|
101
|
+
await app.handler()(ctx);
|
|
102
|
+
```
|
|
36
103
|
|
|
37
104
|
## Notes
|
|
38
105
|
- Tool choice forcing is model-dependent; current loop asks for tools on first turn and plain completion on second.
|
package/dist/index.js
CHANGED
|
@@ -5,30 +5,44 @@ export function ollamaAdapter(opts) {
|
|
|
5
5
|
const modelName = `ollama:${opts.model}`;
|
|
6
6
|
function generate(messages, genOpts) {
|
|
7
7
|
// Map messages to Ollama format; include assistant tool_calls and tool messages
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
base
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
8
|
+
async function mapMessagesWithImages() {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const m of messages) {
|
|
11
|
+
const base = { role: m.role };
|
|
12
|
+
const anyM = m;
|
|
13
|
+
if (m.role === 'assistant' && Array.isArray(anyM.tool_calls)) {
|
|
14
|
+
base.tool_calls = anyM.tool_calls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: (tc.arguments ?? {}) } }));
|
|
15
|
+
const ti = buildTextAndImages(anyM);
|
|
16
|
+
base.content = ti.content ?? (m.content !== undefined ? m.content : null);
|
|
17
|
+
if (ti.images?.length)
|
|
18
|
+
base.images = await toBase64Images(ti.images);
|
|
19
|
+
}
|
|
20
|
+
else if (m.role === 'tool') {
|
|
21
|
+
base.content = String(m.content ?? '');
|
|
22
|
+
if (m.tool_call_id)
|
|
23
|
+
base.tool_call_id = m.tool_call_id;
|
|
24
|
+
if (m.name && !m.tool_call_id)
|
|
25
|
+
base.name = m.name;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const ti = buildTextAndImages(anyM);
|
|
29
|
+
base.content = ti.content ?? (m.content ?? '');
|
|
30
|
+
if (ti.images?.length)
|
|
31
|
+
base.images = await toBase64Images(ti.images);
|
|
32
|
+
if (m.name)
|
|
33
|
+
base.name = m.name;
|
|
34
|
+
}
|
|
35
|
+
out.push(base);
|
|
23
36
|
}
|
|
24
|
-
return
|
|
25
|
-
}
|
|
26
|
-
const toolsParam = (genOpts?.tools ?? []).map(toOllamaTool);
|
|
27
|
-
const baseBody = { model: opts.model, messages: mapped };
|
|
28
|
-
if (toolsParam.length)
|
|
29
|
-
baseBody.tools = toolsParam;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
30
39
|
if (genOpts?.stream === true) {
|
|
31
40
|
return (async function* () {
|
|
41
|
+
const toolsParam = (genOpts?.tools ?? []).map(toOllamaTool);
|
|
42
|
+
const mapped = await mapMessagesWithImages();
|
|
43
|
+
const baseBody = { model: opts.model, messages: mapped };
|
|
44
|
+
if (toolsParam.length)
|
|
45
|
+
baseBody.tools = toolsParam;
|
|
32
46
|
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
33
47
|
method: 'POST',
|
|
34
48
|
headers: {
|
|
@@ -74,6 +88,11 @@ export function ollamaAdapter(opts) {
|
|
|
74
88
|
}
|
|
75
89
|
// Non-stream path
|
|
76
90
|
return (async () => {
|
|
91
|
+
const toolsParam = (genOpts?.tools ?? []).map(toOllamaTool);
|
|
92
|
+
const mapped = await mapMessagesWithImages();
|
|
93
|
+
const baseBody = { model: opts.model, messages: mapped };
|
|
94
|
+
if (toolsParam.length)
|
|
95
|
+
baseBody.tools = toolsParam;
|
|
77
96
|
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
78
97
|
method: 'POST',
|
|
79
98
|
headers: {
|
|
@@ -97,11 +116,11 @@ export function ollamaAdapter(opts) {
|
|
|
97
116
|
}
|
|
98
117
|
const data = raw ? JSON.parse(raw) : {};
|
|
99
118
|
const choice = data?.message ?? {};
|
|
100
|
-
const content = choice?.content
|
|
119
|
+
const content = choice?.content;
|
|
101
120
|
const tcs = Array.isArray(choice?.tool_calls)
|
|
102
121
|
? choice.tool_calls.map((tc) => ({ id: tc.id, name: tc.function?.name, arguments: safeJson(tc.function?.arguments) }))
|
|
103
122
|
: undefined;
|
|
104
|
-
const out = { role: 'assistant', content:
|
|
123
|
+
const out = { role: 'assistant', content: content ?? '' };
|
|
105
124
|
if (tcs)
|
|
106
125
|
out.tool_calls = tcs;
|
|
107
126
|
return { message: out };
|
|
@@ -161,3 +180,99 @@ function safeJson(s) {
|
|
|
161
180
|
return s;
|
|
162
181
|
}
|
|
163
182
|
}
|
|
183
|
+
// Accept OpenAI-style content parts or convenience fields and map to
|
|
184
|
+
// Ollama's expected shape: { content: string, images?: string[] }
|
|
185
|
+
function buildTextAndImages(m) {
|
|
186
|
+
if (!m || typeof m !== 'object')
|
|
187
|
+
return {};
|
|
188
|
+
const obj = m;
|
|
189
|
+
// If content is parts, normalize
|
|
190
|
+
if (Array.isArray(obj.content) || Array.isArray(obj.contentParts)) {
|
|
191
|
+
const parts = Array.isArray(obj.content) ? obj.content : obj.contentParts;
|
|
192
|
+
const texts = [];
|
|
193
|
+
const images = [];
|
|
194
|
+
for (const p of parts) {
|
|
195
|
+
if (typeof p === 'string') {
|
|
196
|
+
texts.push(p);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!p || typeof p !== 'object')
|
|
200
|
+
continue;
|
|
201
|
+
const po = p;
|
|
202
|
+
const t = po.type;
|
|
203
|
+
if (t === 'text' && typeof po.text === 'string') {
|
|
204
|
+
texts.push(po.text);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (t === 'image_url') {
|
|
208
|
+
const iu = po.image_url;
|
|
209
|
+
if (typeof iu === 'string')
|
|
210
|
+
images.push(iu);
|
|
211
|
+
else if (iu && typeof iu === 'object' && typeof iu.url === 'string')
|
|
212
|
+
images.push(String(iu.url));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (t === 'image' && typeof po.url === 'string') {
|
|
216
|
+
images.push(String(po.url));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (typeof po.image_url === 'string') {
|
|
220
|
+
images.push(String(po.image_url));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (typeof po.image === 'string') {
|
|
224
|
+
images.push(String(po.image));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { content: texts.join('\n\n'), images: images.length ? images : undefined };
|
|
229
|
+
}
|
|
230
|
+
// Otherwise, use content string (if any) and collect convenience images
|
|
231
|
+
const images = [];
|
|
232
|
+
if (Array.isArray(obj.images))
|
|
233
|
+
images.push(...obj.images);
|
|
234
|
+
if (Array.isArray(obj.image_urls))
|
|
235
|
+
images.push(...obj.image_urls);
|
|
236
|
+
if (typeof obj.image_url === 'string')
|
|
237
|
+
images.push(obj.image_url);
|
|
238
|
+
if (typeof obj.image === 'string')
|
|
239
|
+
images.push(obj.image);
|
|
240
|
+
const content = typeof obj.content === 'string' ? obj.content : undefined;
|
|
241
|
+
return { content, images: images.length ? images : undefined };
|
|
242
|
+
}
|
|
243
|
+
async function toBase64Images(images) {
|
|
244
|
+
const out = [];
|
|
245
|
+
for (const src of images)
|
|
246
|
+
out.push(await toBase64(src));
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
function isHttpUrl(s) {
|
|
250
|
+
return /^https?:\/\//i.test(s);
|
|
251
|
+
}
|
|
252
|
+
function isDataUrl(s) {
|
|
253
|
+
return /^data:/i.test(s);
|
|
254
|
+
}
|
|
255
|
+
function fromDataUrl(s) {
|
|
256
|
+
const i = s.indexOf(',');
|
|
257
|
+
return i >= 0 ? s.slice(i + 1) : '';
|
|
258
|
+
}
|
|
259
|
+
function isProbablyBase64(s) {
|
|
260
|
+
if (!s || /[:\/]/.test(s))
|
|
261
|
+
return false; // exclude URLs
|
|
262
|
+
// Basic base64 check: valid chars and length % 4 == 0
|
|
263
|
+
if (s.length % 4 !== 0)
|
|
264
|
+
return false;
|
|
265
|
+
return /^[A-Za-z0-9+/]+={0,2}$/.test(s);
|
|
266
|
+
}
|
|
267
|
+
async function toBase64(src) {
|
|
268
|
+
if (isDataUrl(src))
|
|
269
|
+
return fromDataUrl(src);
|
|
270
|
+
if (isHttpUrl(src)) {
|
|
271
|
+
const res = await fetch(src);
|
|
272
|
+
if (!res.ok)
|
|
273
|
+
throw new Error(`Failed to fetch image: ${res.status} ${res.statusText}`);
|
|
274
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
275
|
+
return buf.toString('base64');
|
|
276
|
+
}
|
|
277
|
+
return isProbablyBase64(src) ? src : src;
|
|
278
|
+
}
|