@juspay/neurolink 9.68.19 → 9.68.21
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/CHANGELOG.md +4 -0
- package/dist/browser/neurolink.min.js +324 -385
- package/dist/factories/providerRegistry.js +2 -2
- package/dist/lib/factories/providerRegistry.js +2 -2
- package/dist/lib/providers/ollama.d.ts +55 -133
- package/dist/lib/providers/ollama.js +225 -1659
- package/dist/lib/types/providers.d.ts +1 -54
- package/dist/providers/ollama.d.ts +55 -133
- package/dist/providers/ollama.js +225 -1659
- package/dist/types/providers.d.ts +1 -54
- package/package.json +1 -4
|
@@ -1,1728 +1,294 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { BaseProvider } from "../core/baseProvider.js";
|
|
3
|
-
import { DEFAULT_MAX_STEPS } from "../core/constants.js";
|
|
1
|
+
import { OllamaModels } from "../constants/enums.js";
|
|
4
2
|
import { modelConfig } from "../core/modelConfiguration.js";
|
|
5
3
|
import { createProxyFetch } from "../proxy/proxyFetch.js";
|
|
6
|
-
import { logger } from "../utils/logger.js";
|
|
7
|
-
import { buildMultimodalMessagesArray } from "../utils/messageBuilder.js";
|
|
8
|
-
import { buildMultimodalOptions } from "../utils/multimodalOptionsBuilder.js";
|
|
9
|
-
import { estimateTokens } from "../utils/tokenEstimation.js";
|
|
10
4
|
import { InvalidModelError, NetworkError, ProviderError, } from "../types/index.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { TimeoutError } from "../utils/timeout.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { redactUrlCredentials } from "../utils/logSanitize.js";
|
|
7
|
+
import { createTimeoutController, parseTimeout, TimeoutError, } from "../utils/timeout.js";
|
|
8
|
+
import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
|
|
9
|
+
import { stripTrailingSlash } from "./openaiChatCompletionsClient.js";
|
|
10
|
+
// Ollama serves its OpenAI-compatible surface under `/v1` (/v1/chat/completions,
|
|
11
|
+
// /v1/models, /v1/embeddings). The base client appends `/chat/completions` to
|
|
12
|
+
// config.baseURL, so the base URL must already carry the `/v1` suffix.
|
|
13
|
+
const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1";
|
|
14
|
+
// Local Ollama ignores the Authorization header, but the base HTTP client
|
|
15
|
+
// requires a non-empty apiKey. A real key (OLLAMA_API_KEY) is only needed for
|
|
16
|
+
// Ollama Cloud / an auth-proxying reverse proxy.
|
|
17
|
+
const OLLAMA_PLACEHOLDER_KEY = "ollama";
|
|
18
|
+
// Default model must match the registry default (providerRegistry.ts advertises
|
|
19
|
+
// `OllamaModels.LLAMA3_2_LATEST`) so getConfiguration()/resolveModelName() agree
|
|
20
|
+
// with what the registry reports.
|
|
21
|
+
const DEFAULT_OLLAMA_MODEL = OllamaModels.LLAMA3_2_LATEST;
|
|
22
|
+
const FALLBACK_OLLAMA_MODEL = "llama3.1:8b";
|
|
23
|
+
const DEFAULT_EMBEDDING_MODEL = "nomic-embed-text";
|
|
24
|
+
const EMBEDDINGS_TIMEOUT_MS = 30_000;
|
|
25
|
+
const getDefaultOllamaModel = () => process.env.OLLAMA_MODEL || DEFAULT_OLLAMA_MODEL;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve and normalize the Ollama base URL to the OpenAI-compatible root.
|
|
28
|
+
* Precedence: credentials override → `OLLAMA_BASE_URL` → default. A bare host
|
|
29
|
+
* (the historical `OLLAMA_BASE_URL=http://localhost:11434` form) is accepted
|
|
30
|
+
* and gets `/v1` appended for backward compatibility; an already-`/v1` base is
|
|
31
|
+
* left untouched. Blank/whitespace overrides fall through (cohort guard).
|
|
32
|
+
*/
|
|
33
|
+
const resolveOllamaBaseURL = (override) => {
|
|
34
|
+
const raw = override?.trim() ||
|
|
35
|
+
process.env.OLLAMA_BASE_URL?.trim() ||
|
|
36
|
+
OLLAMA_DEFAULT_BASE_URL;
|
|
37
|
+
const trimmed = stripTrailingSlash(raw);
|
|
38
|
+
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
|
43
39
|
};
|
|
44
|
-
function isOllamaHttpError(error) {
|
|
45
|
-
return (error instanceof ProviderError &&
|
|
46
|
-
typeof error.statusCode === "number" &&
|
|
47
|
-
typeof error.responseBody === "string");
|
|
48
|
-
}
|
|
49
|
-
async function createOllamaHttpError(response) {
|
|
50
|
-
let responseBody = "";
|
|
51
|
-
try {
|
|
52
|
-
responseBody = (await response.text()).trim();
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// Ignore unreadable bodies
|
|
56
|
-
}
|
|
57
|
-
const suffix = responseBody ? ` - ${responseBody.slice(0, 500)}` : "";
|
|
58
|
-
const error = new ProviderError(`Ollama API error: ${response.status} ${response.statusText}${suffix}`, "ollama");
|
|
59
|
-
error.statusCode = response.status;
|
|
60
|
-
error.statusText = response.statusText;
|
|
61
|
-
error.responseBody = responseBody;
|
|
62
|
-
return error;
|
|
63
|
-
}
|
|
64
|
-
// Create proxy-aware fetch instance
|
|
65
|
-
const proxyFetch = createProxyFetch();
|
|
66
|
-
// Custom LanguageModel implementation for Ollama
|
|
67
|
-
class OllamaLanguageModel {
|
|
68
|
-
/**
|
|
69
|
-
* Specification version for the AI SDK LanguageModel interface.
|
|
70
|
-
* Uses "v2" for structural compatibility with AI SDK v6's `LanguageModelV2`.
|
|
71
|
-
* The AI SDK checks this field to determine which interface version to use.
|
|
72
|
-
*/
|
|
73
|
-
specificationVersion = "v2";
|
|
74
|
-
provider = "ollama";
|
|
75
|
-
modelId;
|
|
76
|
-
maxTokens;
|
|
77
|
-
supportsStreaming = true;
|
|
78
|
-
defaultObjectGenerationMode = "json";
|
|
79
|
-
/**
|
|
80
|
-
* Supported URL patterns by media type.
|
|
81
|
-
* Ollama runs locally and does not natively download URLs, so this is empty.
|
|
82
|
-
* Required by the LanguageModelV2 interface.
|
|
83
|
-
*/
|
|
84
|
-
supportedUrls = {};
|
|
85
|
-
baseUrl;
|
|
86
|
-
timeout;
|
|
87
|
-
constructor(modelId, baseUrl, timeout) {
|
|
88
|
-
this.modelId = modelId;
|
|
89
|
-
this.baseUrl = baseUrl;
|
|
90
|
-
this.timeout = timeout;
|
|
91
|
-
}
|
|
92
|
-
estimateTokenCount(text) {
|
|
93
|
-
return estimateTokens(text, "ollama");
|
|
94
|
-
}
|
|
95
|
-
convertMessagesToPrompt(messages) {
|
|
96
|
-
return messages
|
|
97
|
-
.map((msg) => {
|
|
98
|
-
if (typeof msg.content === "string") {
|
|
99
|
-
return `${msg.role}: ${msg.content}`;
|
|
100
|
-
}
|
|
101
|
-
return `${msg.role}: ${JSON.stringify(msg.content)}`;
|
|
102
|
-
})
|
|
103
|
-
.join("\n");
|
|
104
|
-
}
|
|
105
|
-
async doGenerate(options) {
|
|
106
|
-
// Vercel AI SDK passes messages via options.messages (same as stream mode)
|
|
107
|
-
// Check options.messages first, then fall back to options.prompt for backward compatibility
|
|
108
|
-
const messages = options
|
|
109
|
-
.messages ||
|
|
110
|
-
options
|
|
111
|
-
.prompt ||
|
|
112
|
-
[];
|
|
113
|
-
// Check if we should use OpenAI-compatible API
|
|
114
|
-
const useOpenAIMode = isOpenAICompatibleMode();
|
|
115
|
-
if (useOpenAIMode) {
|
|
116
|
-
// OpenAI-compatible mode: Use /v1/chat/completions
|
|
117
|
-
const requestBody = {
|
|
118
|
-
model: this.modelId,
|
|
119
|
-
messages,
|
|
120
|
-
temperature: options.temperature,
|
|
121
|
-
max_tokens: options.maxTokens,
|
|
122
|
-
stream: false,
|
|
123
|
-
};
|
|
124
|
-
if (logger.shouldLog("debug")) {
|
|
125
|
-
logger.debug("[OllamaLanguageModel] Using OpenAI-compatible API with messages:", JSON.stringify(messages, null, 2));
|
|
126
|
-
}
|
|
127
|
-
const response = await proxyFetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
128
|
-
method: "POST",
|
|
129
|
-
headers: { "Content-Type": "application/json" },
|
|
130
|
-
body: JSON.stringify(requestBody),
|
|
131
|
-
signal: createAbortSignalWithTimeout(this.timeout),
|
|
132
|
-
});
|
|
133
|
-
if (!response.ok) {
|
|
134
|
-
throw await createOllamaHttpError(response);
|
|
135
|
-
}
|
|
136
|
-
const data = await response.json();
|
|
137
|
-
logger.debug("[OllamaLanguageModel] OpenAI API Response:", JSON.stringify(data, null, 2));
|
|
138
|
-
const text = data.choices?.[0]?.message?.content || "";
|
|
139
|
-
const usage = data.usage || {};
|
|
140
|
-
const promptTokens = usage.prompt_tokens ??
|
|
141
|
-
this.estimateTokenCount(JSON.stringify(messages));
|
|
142
|
-
const completionTokens = usage.completion_tokens ?? this.estimateTokenCount(text);
|
|
143
|
-
return {
|
|
144
|
-
content: text ? [{ type: "text", text }] : [],
|
|
145
|
-
text,
|
|
146
|
-
usage: {
|
|
147
|
-
inputTokens: promptTokens,
|
|
148
|
-
outputTokens: completionTokens,
|
|
149
|
-
promptTokens,
|
|
150
|
-
completionTokens,
|
|
151
|
-
totalTokens: usage.total_tokens ?? promptTokens + completionTokens,
|
|
152
|
-
},
|
|
153
|
-
finishReason: data.choices?.[0]?.finish_reason ?? "stop",
|
|
154
|
-
warnings: [],
|
|
155
|
-
request: {
|
|
156
|
-
body: JSON.stringify(requestBody),
|
|
157
|
-
},
|
|
158
|
-
response: {
|
|
159
|
-
id: data.id,
|
|
160
|
-
modelId: data.model,
|
|
161
|
-
timestamp: new Date(),
|
|
162
|
-
headers: {},
|
|
163
|
-
body: data,
|
|
164
|
-
},
|
|
165
|
-
rawCall: {
|
|
166
|
-
rawPrompt: messages,
|
|
167
|
-
rawSettings: {
|
|
168
|
-
model: this.modelId,
|
|
169
|
-
temperature: options.temperature,
|
|
170
|
-
max_tokens: options.maxTokens,
|
|
171
|
-
},
|
|
172
|
-
},
|
|
173
|
-
rawResponse: {
|
|
174
|
-
headers: {},
|
|
175
|
-
},
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
// Native Ollama mode: Use /api/generate
|
|
180
|
-
const prompt = this.convertMessagesToPrompt(messages);
|
|
181
|
-
logger.debug("[OllamaLanguageModel] Using native API with prompt:", JSON.stringify(prompt));
|
|
182
|
-
const response = await proxyFetch(`${this.baseUrl}/api/generate`, {
|
|
183
|
-
method: "POST",
|
|
184
|
-
headers: { "Content-Type": "application/json" },
|
|
185
|
-
body: JSON.stringify({
|
|
186
|
-
model: this.modelId,
|
|
187
|
-
prompt,
|
|
188
|
-
stream: false,
|
|
189
|
-
system: messages.find((m) => m.role === "system")?.content,
|
|
190
|
-
options: {
|
|
191
|
-
temperature: options.temperature,
|
|
192
|
-
num_predict: options.maxTokens,
|
|
193
|
-
},
|
|
194
|
-
}),
|
|
195
|
-
signal: createAbortSignalWithTimeout(this.timeout),
|
|
196
|
-
});
|
|
197
|
-
if (!response.ok) {
|
|
198
|
-
throw await createOllamaHttpError(response);
|
|
199
|
-
}
|
|
200
|
-
const data = await response.json();
|
|
201
|
-
logger.debug("[OllamaLanguageModel] Native API Response:", JSON.stringify(data, null, 2));
|
|
202
|
-
const text = String(data.response ?? "");
|
|
203
|
-
const promptTokens = data.prompt_eval_count ?? this.estimateTokenCount(prompt);
|
|
204
|
-
const completionTokens = data.eval_count ?? this.estimateTokenCount(text);
|
|
205
|
-
const requestBody = {
|
|
206
|
-
model: this.modelId,
|
|
207
|
-
prompt,
|
|
208
|
-
stream: false,
|
|
209
|
-
system: messages.find((m) => m.role === "system")?.content,
|
|
210
|
-
options: {
|
|
211
|
-
temperature: options.temperature,
|
|
212
|
-
num_predict: options.maxTokens,
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
return {
|
|
216
|
-
content: text ? [{ type: "text", text }] : [],
|
|
217
|
-
text,
|
|
218
|
-
usage: {
|
|
219
|
-
inputTokens: promptTokens,
|
|
220
|
-
outputTokens: completionTokens,
|
|
221
|
-
promptTokens,
|
|
222
|
-
completionTokens,
|
|
223
|
-
totalTokens: promptTokens + completionTokens,
|
|
224
|
-
},
|
|
225
|
-
finishReason: data.done_reason ?? "stop",
|
|
226
|
-
warnings: [],
|
|
227
|
-
request: {
|
|
228
|
-
body: JSON.stringify(requestBody),
|
|
229
|
-
},
|
|
230
|
-
response: {
|
|
231
|
-
id: data.created_at,
|
|
232
|
-
modelId: this.modelId,
|
|
233
|
-
timestamp: data.created_at ? new Date(data.created_at) : new Date(),
|
|
234
|
-
headers: {},
|
|
235
|
-
body: data,
|
|
236
|
-
},
|
|
237
|
-
rawCall: {
|
|
238
|
-
rawPrompt: prompt,
|
|
239
|
-
rawSettings: {
|
|
240
|
-
model: this.modelId,
|
|
241
|
-
temperature: options.temperature,
|
|
242
|
-
num_predict: options.maxTokens,
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
rawResponse: {
|
|
246
|
-
headers: {},
|
|
247
|
-
},
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
async doStream(options) {
|
|
252
|
-
const messages = options
|
|
253
|
-
.messages || [];
|
|
254
|
-
// Check if we should use OpenAI-compatible API
|
|
255
|
-
const useOpenAIMode = isOpenAICompatibleMode();
|
|
256
|
-
if (useOpenAIMode) {
|
|
257
|
-
// OpenAI-compatible mode: Use /v1/chat/completions
|
|
258
|
-
const requestUrl = `${this.baseUrl}/v1/chat/completions`;
|
|
259
|
-
const requestBody = {
|
|
260
|
-
model: this.modelId,
|
|
261
|
-
messages,
|
|
262
|
-
temperature: options.temperature,
|
|
263
|
-
max_tokens: options.maxTokens,
|
|
264
|
-
stream: true,
|
|
265
|
-
};
|
|
266
|
-
logger.debug("[OllamaLanguageModel] doStream: Using OpenAI-compatible API", {
|
|
267
|
-
url: requestUrl,
|
|
268
|
-
baseUrl: this.baseUrl,
|
|
269
|
-
modelId: this.modelId,
|
|
270
|
-
requestBody: JSON.stringify(requestBody),
|
|
271
|
-
});
|
|
272
|
-
const response = await proxyFetch(requestUrl, {
|
|
273
|
-
method: "POST",
|
|
274
|
-
headers: { "Content-Type": "application/json" },
|
|
275
|
-
body: JSON.stringify(requestBody),
|
|
276
|
-
signal: createAbortSignalWithTimeout(this.timeout),
|
|
277
|
-
});
|
|
278
|
-
logger.debug("[OllamaLanguageModel] doStream: Response received", {
|
|
279
|
-
status: response.status,
|
|
280
|
-
statusText: response.statusText,
|
|
281
|
-
ok: response.ok,
|
|
282
|
-
});
|
|
283
|
-
if (!response.ok) {
|
|
284
|
-
throw await createOllamaHttpError(response);
|
|
285
|
-
}
|
|
286
|
-
const self = this;
|
|
287
|
-
return {
|
|
288
|
-
stream: new ReadableStream({
|
|
289
|
-
async start(controller) {
|
|
290
|
-
try {
|
|
291
|
-
for await (const chunk of self.parseOpenAIStreamResponse(response, messages)) {
|
|
292
|
-
controller.enqueue(chunk);
|
|
293
|
-
}
|
|
294
|
-
controller.close();
|
|
295
|
-
}
|
|
296
|
-
catch (error) {
|
|
297
|
-
controller.error(error);
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
}),
|
|
301
|
-
rawCall: {
|
|
302
|
-
rawPrompt: messages,
|
|
303
|
-
rawSettings: {
|
|
304
|
-
model: this.modelId,
|
|
305
|
-
temperature: options.temperature,
|
|
306
|
-
max_tokens: options.maxTokens,
|
|
307
|
-
},
|
|
308
|
-
},
|
|
309
|
-
rawResponse: {
|
|
310
|
-
headers: {},
|
|
311
|
-
},
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
// Native Ollama mode: Use /api/generate
|
|
316
|
-
const prompt = this.convertMessagesToPrompt(messages);
|
|
317
|
-
const requestUrl = `${this.baseUrl}/api/generate`;
|
|
318
|
-
const requestBody = {
|
|
319
|
-
model: this.modelId,
|
|
320
|
-
prompt,
|
|
321
|
-
stream: true,
|
|
322
|
-
system: messages.find((m) => m.role === "system")?.content,
|
|
323
|
-
options: {
|
|
324
|
-
temperature: options.temperature,
|
|
325
|
-
num_predict: options.maxTokens,
|
|
326
|
-
},
|
|
327
|
-
};
|
|
328
|
-
logger.debug("[OllamaLanguageModel] doStream: Using native API", {
|
|
329
|
-
url: requestUrl,
|
|
330
|
-
baseUrl: this.baseUrl,
|
|
331
|
-
modelId: this.modelId,
|
|
332
|
-
requestBody: JSON.stringify(requestBody),
|
|
333
|
-
});
|
|
334
|
-
const response = await proxyFetch(requestUrl, {
|
|
335
|
-
method: "POST",
|
|
336
|
-
headers: { "Content-Type": "application/json" },
|
|
337
|
-
body: JSON.stringify(requestBody),
|
|
338
|
-
signal: createAbortSignalWithTimeout(this.timeout),
|
|
339
|
-
});
|
|
340
|
-
logger.debug("[OllamaLanguageModel] doStream: Response received", {
|
|
341
|
-
status: response.status,
|
|
342
|
-
statusText: response.statusText,
|
|
343
|
-
ok: response.ok,
|
|
344
|
-
});
|
|
345
|
-
if (!response.ok) {
|
|
346
|
-
throw await createOllamaHttpError(response);
|
|
347
|
-
}
|
|
348
|
-
const self = this;
|
|
349
|
-
return {
|
|
350
|
-
stream: new ReadableStream({
|
|
351
|
-
async start(controller) {
|
|
352
|
-
try {
|
|
353
|
-
for await (const chunk of self.parseStreamResponse(response)) {
|
|
354
|
-
controller.enqueue(chunk);
|
|
355
|
-
}
|
|
356
|
-
controller.close();
|
|
357
|
-
}
|
|
358
|
-
catch (error) {
|
|
359
|
-
controller.error(error);
|
|
360
|
-
}
|
|
361
|
-
},
|
|
362
|
-
}),
|
|
363
|
-
rawCall: {
|
|
364
|
-
rawPrompt: messages,
|
|
365
|
-
rawSettings: {
|
|
366
|
-
model: this.modelId,
|
|
367
|
-
temperature: options.temperature,
|
|
368
|
-
num_predict: options.maxTokens,
|
|
369
|
-
},
|
|
370
|
-
},
|
|
371
|
-
rawResponse: {
|
|
372
|
-
headers: {},
|
|
373
|
-
},
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
async *parseStreamResponse(response) {
|
|
378
|
-
const reader = response.body?.getReader();
|
|
379
|
-
if (!reader) {
|
|
380
|
-
throw new Error("No response body");
|
|
381
|
-
}
|
|
382
|
-
const decoder = new TextDecoder();
|
|
383
|
-
let buffer = "";
|
|
384
|
-
try {
|
|
385
|
-
while (true) {
|
|
386
|
-
const { done, value } = await reader.read();
|
|
387
|
-
if (done) {
|
|
388
|
-
break;
|
|
389
|
-
}
|
|
390
|
-
buffer += decoder.decode(value, { stream: true });
|
|
391
|
-
const lines = buffer.split("\n");
|
|
392
|
-
buffer = lines.pop() || "";
|
|
393
|
-
for (const line of lines) {
|
|
394
|
-
if (line.trim()) {
|
|
395
|
-
try {
|
|
396
|
-
const data = JSON.parse(line);
|
|
397
|
-
if (data.response) {
|
|
398
|
-
yield {
|
|
399
|
-
type: "text-delta",
|
|
400
|
-
textDelta: data.response,
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
if (data.done) {
|
|
404
|
-
yield {
|
|
405
|
-
type: "finish",
|
|
406
|
-
finishReason: "stop",
|
|
407
|
-
usage: {
|
|
408
|
-
promptTokens: data.prompt_eval_count ||
|
|
409
|
-
this.estimateTokenCount(data.context || ""),
|
|
410
|
-
completionTokens: data.eval_count || 0,
|
|
411
|
-
},
|
|
412
|
-
};
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
catch (error) {
|
|
417
|
-
logger.error("Error parsing Ollama stream response", {
|
|
418
|
-
error,
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
finally {
|
|
426
|
-
reader.releaseLock();
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
async *parseOpenAIStreamResponse(response, messages) {
|
|
430
|
-
const reader = response.body?.getReader();
|
|
431
|
-
if (!reader) {
|
|
432
|
-
throw new Error("No response body");
|
|
433
|
-
}
|
|
434
|
-
const decoder = new TextDecoder();
|
|
435
|
-
let buffer = "";
|
|
436
|
-
// Estimate prompt tokens from messages (matches non-streaming behavior)
|
|
437
|
-
const totalPromptTokens = this.estimateTokenCount(JSON.stringify(messages));
|
|
438
|
-
// Accumulate full completion text; estimate tokens once at the end to avoid
|
|
439
|
-
// per-chunk rounding inflation that occurs when estimateTokenCount is called
|
|
440
|
-
// on every delta and the results are summed.
|
|
441
|
-
let completionText = "";
|
|
442
|
-
try {
|
|
443
|
-
while (true) {
|
|
444
|
-
const { done, value } = await reader.read();
|
|
445
|
-
if (done) {
|
|
446
|
-
break;
|
|
447
|
-
}
|
|
448
|
-
buffer += decoder.decode(value, { stream: true });
|
|
449
|
-
const lines = buffer.split("\n");
|
|
450
|
-
buffer = lines.pop() || "";
|
|
451
|
-
for (const line of lines) {
|
|
452
|
-
const trimmed = line.trim();
|
|
453
|
-
if (trimmed === "" || trimmed === "data: [DONE]") {
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
-
if (trimmed.startsWith("data: ")) {
|
|
457
|
-
try {
|
|
458
|
-
const jsonStr = trimmed.slice(6); // Remove "data: " prefix
|
|
459
|
-
const data = JSON.parse(jsonStr);
|
|
460
|
-
// Extract content delta
|
|
461
|
-
const content = data.choices?.[0]?.delta?.content;
|
|
462
|
-
if (content) {
|
|
463
|
-
yield {
|
|
464
|
-
type: "text-delta",
|
|
465
|
-
textDelta: content,
|
|
466
|
-
};
|
|
467
|
-
completionText += content;
|
|
468
|
-
}
|
|
469
|
-
// Check for finish
|
|
470
|
-
const finishReason = data.choices?.[0]?.finish_reason;
|
|
471
|
-
if (finishReason === "stop") {
|
|
472
|
-
// Prefer server-reported usage; fall back to a single estimate over
|
|
473
|
-
// the full accumulated text (avoids per-chunk rounding inflation).
|
|
474
|
-
const promptTokens = data.usage?.prompt_tokens || totalPromptTokens;
|
|
475
|
-
const completionTokens = data.usage?.completion_tokens ||
|
|
476
|
-
this.estimateTokenCount(completionText);
|
|
477
|
-
yield {
|
|
478
|
-
type: "finish",
|
|
479
|
-
finishReason: "stop",
|
|
480
|
-
usage: {
|
|
481
|
-
promptTokens,
|
|
482
|
-
completionTokens,
|
|
483
|
-
},
|
|
484
|
-
};
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
catch (error) {
|
|
489
|
-
logger.error("Error parsing OpenAI stream response", {
|
|
490
|
-
error,
|
|
491
|
-
line: trimmed,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
// If loop exits without explicit finish, yield final finish
|
|
498
|
-
yield {
|
|
499
|
-
type: "finish",
|
|
500
|
-
finishReason: "stop",
|
|
501
|
-
usage: {
|
|
502
|
-
promptTokens: totalPromptTokens,
|
|
503
|
-
completionTokens: this.estimateTokenCount(completionText),
|
|
504
|
-
},
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
finally {
|
|
508
|
-
reader.releaseLock();
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
40
|
/**
|
|
513
|
-
* Ollama Provider
|
|
41
|
+
* Ollama Provider — direct HTTP, no AI SDK.
|
|
514
42
|
*
|
|
515
|
-
*
|
|
43
|
+
* Wraps a local (or remote/Cloud) Ollama server via its OpenAI-compatible
|
|
44
|
+
* `/v1` API. All request / stream / multi-step tool-loop orchestration lives
|
|
45
|
+
* in `OpenAIChatCompletionsProvider`; this class declares configuration plus
|
|
46
|
+
* the Ollama-specific behaviour:
|
|
516
47
|
*
|
|
517
|
-
*
|
|
518
|
-
* -
|
|
519
|
-
* -
|
|
520
|
-
*
|
|
521
|
-
*
|
|
48
|
+
* 1. `/v1` base-URL normalization (accepts a bare `OLLAMA_BASE_URL` host).
|
|
49
|
+
* 2. No-auth-by-default with an optional `OLLAMA_API_KEY` for Ollama Cloud.
|
|
50
|
+
* 3. Configurable per-model tool gating via `OLLAMA_TOOL_CAPABLE_MODELS` /
|
|
51
|
+
* `modelConfig` (`supportsTools`).
|
|
52
|
+
* 4. Elevated request timeout for slow large local models (5-minute base
|
|
53
|
+
* default, overridable via `OLLAMA_TIMEOUT`).
|
|
54
|
+
* 5. Native `/v1/embeddings` (`embed` / `embedMany`).
|
|
55
|
+
* 6. Rich, actionable error mapping (`ollama serve` / `ollama pull` hints).
|
|
56
|
+
*
|
|
57
|
+
* Model discovery uses the base's `/v1/models` probe (Ollama supports it).
|
|
58
|
+
*
|
|
59
|
+
* @see https://docs.ollama.com/api/openai-compatibility
|
|
522
60
|
*/
|
|
523
|
-
export class OllamaProvider extends
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
// Initialize Ollama model
|
|
532
|
-
this.ollamaModel = new OllamaLanguageModel(this.modelName || getDefaultOllamaModel(), this.baseUrl, this.timeout);
|
|
533
|
-
logger.debug("Ollama BaseProvider v2 initialized", {
|
|
61
|
+
export class OllamaProvider extends OpenAIChatCompletionsProvider {
|
|
62
|
+
constructor(modelName, sdk, _region, credentials) {
|
|
63
|
+
const baseURL = resolveOllamaBaseURL(credentials?.baseURL);
|
|
64
|
+
const apiKey = credentials?.apiKey?.trim() ||
|
|
65
|
+
process.env.OLLAMA_API_KEY?.trim() ||
|
|
66
|
+
OLLAMA_PLACEHOLDER_KEY;
|
|
67
|
+
super("ollama", modelName, sdk, { baseURL, apiKey });
|
|
68
|
+
logger.debug("Ollama Provider initialized", {
|
|
534
69
|
modelName: this.modelName,
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
provider: this.providerName,
|
|
70
|
+
providerName: this.providerName,
|
|
71
|
+
baseURL: redactUrlCredentials(this.config.baseURL),
|
|
538
72
|
});
|
|
539
73
|
}
|
|
74
|
+
// ===========================================================================
|
|
75
|
+
// Abstract hooks (required)
|
|
76
|
+
// ===========================================================================
|
|
540
77
|
getProviderName() {
|
|
541
78
|
return "ollama";
|
|
542
79
|
}
|
|
543
80
|
getDefaultModel() {
|
|
544
81
|
return getDefaultOllamaModel();
|
|
545
82
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
*
|
|
549
|
-
* OllamaLanguageModel implements OllamaAsLanguageModel which is structurally
|
|
550
|
-
* compatible with LanguageModelV2 (specificationVersion "v2", modelId, provider,
|
|
551
|
-
* supportedUrls, doGenerate, doStream).
|
|
552
|
-
*/
|
|
553
|
-
getAISDKModel() {
|
|
554
|
-
const model = this.ollamaModel;
|
|
555
|
-
return model;
|
|
83
|
+
getFallbackModelName() {
|
|
84
|
+
return FALLBACK_OLLAMA_MODEL;
|
|
556
85
|
}
|
|
86
|
+
formatProviderError(error) {
|
|
87
|
+
if (error instanceof TimeoutError) {
|
|
88
|
+
return new NetworkError(`Ollama request timed out. The model may be loading or the request is too large.`, "ollama");
|
|
89
|
+
}
|
|
90
|
+
const errorRecord = error;
|
|
91
|
+
const message = typeof errorRecord?.message === "string"
|
|
92
|
+
? errorRecord.message
|
|
93
|
+
: "Unknown error";
|
|
94
|
+
const cause = errorRecord?.cause ?? {};
|
|
95
|
+
const code = (errorRecord?.code ?? cause?.code);
|
|
96
|
+
if (code === "ECONNREFUSED" ||
|
|
97
|
+
message.includes("ECONNREFUSED") ||
|
|
98
|
+
message.includes("Failed to fetch") ||
|
|
99
|
+
message.includes("fetch failed")) {
|
|
100
|
+
return new NetworkError(`Cannot connect to Ollama at ${redactUrlCredentials(this.config.baseURL)}. ` +
|
|
101
|
+
`Install Ollama (https://ollama.com), start it with 'ollama serve', then try again.`, "ollama");
|
|
102
|
+
}
|
|
103
|
+
// The base client (buildAPIError) attaches statusCode + responseBody to
|
|
104
|
+
// HTTP failures. Distinguish a genuine missing-model error (give 'ollama
|
|
105
|
+
// pull' guidance) from a bare endpoint-mismatch 404 (wrong base URL / not
|
|
106
|
+
// the OpenAI-compatible /v1 surface) so the advice is actionable. Match on
|
|
107
|
+
// wording, not a bare "404" substring, to avoid misclassifying unrelated
|
|
108
|
+
// messages that merely contain those digits.
|
|
109
|
+
const statusCode = typeof errorRecord?.statusCode === "number"
|
|
110
|
+
? errorRecord.statusCode
|
|
111
|
+
: undefined;
|
|
112
|
+
const responseBody = typeof errorRecord?.responseBody === "string"
|
|
113
|
+
? errorRecord.responseBody
|
|
114
|
+
: "";
|
|
115
|
+
const haystack = `${message} ${responseBody}`.toLowerCase();
|
|
116
|
+
const looksLikeMissingModel = haystack.includes("model_not_found") ||
|
|
117
|
+
(haystack.includes("model") && haystack.includes("not found"));
|
|
118
|
+
if (looksLikeMissingModel) {
|
|
119
|
+
return new InvalidModelError(`Ollama model '${this.modelName}' is not available locally. ` +
|
|
120
|
+
`Pull it first with 'ollama pull ${this.modelName}' (or try '${FALLBACK_OLLAMA_MODEL}'); ` +
|
|
121
|
+
`list installed models with 'ollama list'.`, "ollama");
|
|
122
|
+
}
|
|
123
|
+
if (statusCode === 404 || haystack.includes("status 404")) {
|
|
124
|
+
return new ProviderError(`Ollama returned HTTP 404 from ${redactUrlCredentials(this.config.baseURL)}. ` +
|
|
125
|
+
`Verify the base URL serves the OpenAI-compatible /v1 API and that the ` +
|
|
126
|
+
`model is installed ('ollama list').`, "ollama");
|
|
127
|
+
}
|
|
128
|
+
return new ProviderError(`Ollama error: ${message}`, "ollama");
|
|
129
|
+
}
|
|
130
|
+
// ===========================================================================
|
|
131
|
+
// Optional hooks — Ollama-specific behaviour
|
|
132
|
+
// ===========================================================================
|
|
557
133
|
/**
|
|
558
|
-
* Ollama
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* **Configuration Options:**
|
|
564
|
-
* - Environment variable: OLLAMA_TOOL_CAPABLE_MODELS (comma-separated list)
|
|
565
|
-
* - Configuration file: providers.ollama.modelBehavior.toolCapableModels
|
|
566
|
-
* - Fallback: Default list of known tool-capable models
|
|
567
|
-
*
|
|
568
|
-
* **Implementation Features:**
|
|
569
|
-
* - Direct Ollama API integration (/v1/chat/completions)
|
|
570
|
-
* - Automatic tool schema conversion to Ollama format
|
|
571
|
-
* - Streaming tool calls with incremental response parsing
|
|
572
|
-
* - Model compatibility validation and fallback handling
|
|
573
|
-
*
|
|
574
|
-
* @returns true for supported models, false for unsupported models
|
|
134
|
+
* Ollama proxies many local models with varying tool support. When
|
|
135
|
+
* `OLLAMA_TOOL_CAPABLE_MODELS` (or `modelConfig`'s
|
|
136
|
+
* `modelBehavior.toolCapableModels`) is configured, gate tools on a
|
|
137
|
+
* substring match against the current model; with no list configured,
|
|
138
|
+
* assume tools are supported (don't disable on absent evidence).
|
|
575
139
|
*/
|
|
576
140
|
supportsTools() {
|
|
577
141
|
const modelName = (this.modelName ?? getDefaultOllamaModel()).toLowerCase();
|
|
578
|
-
// Get tool-capable models from configuration
|
|
579
142
|
const ollamaConfig = modelConfig.getProviderConfiguration("ollama");
|
|
580
143
|
const toolCapableModels = ollamaConfig?.modelBehavior?.toolCapableModels || [];
|
|
581
|
-
// Only disable tools if we have positive evidence the model doesn't support them
|
|
582
|
-
// If toolCapableModels config is empty, assume tools are supported (don't make assumptions)
|
|
583
144
|
if (toolCapableModels.length === 0) {
|
|
584
|
-
logger.debug("Ollama tool calling enabled", {
|
|
585
|
-
model: this.modelName,
|
|
586
|
-
reason: "No tool-capable config defined, assuming tools supported",
|
|
587
|
-
baseUrl: this.baseUrl,
|
|
588
|
-
});
|
|
589
145
|
return true;
|
|
590
146
|
}
|
|
591
|
-
// Config exists - check if current model matches tool-capable model patterns
|
|
592
147
|
const isToolCapable = toolCapableModels.some((capableModel) => modelName.includes(capableModel.toLowerCase()));
|
|
593
|
-
if (isToolCapable) {
|
|
594
|
-
logger.debug("Ollama tool calling
|
|
148
|
+
if (!isToolCapable) {
|
|
149
|
+
logger.debug("Ollama tool calling disabled", {
|
|
595
150
|
model: this.modelName,
|
|
596
|
-
reason: "Model in
|
|
597
|
-
baseUrl: this.baseUrl,
|
|
598
|
-
configuredModels: toolCapableModels.length,
|
|
151
|
+
reason: "Model not in OLLAMA_TOOL_CAPABLE_MODELS list",
|
|
599
152
|
});
|
|
600
|
-
return true;
|
|
601
153
|
}
|
|
602
|
-
|
|
603
|
-
logger.debug("Ollama tool calling disabled", {
|
|
604
|
-
model: this.modelName,
|
|
605
|
-
reason: "Model not in tool-capable list",
|
|
606
|
-
suggestion: "Consider using llama3.1:8b-instruct, mistral:7b-instruct, or hermes3:8b for tool calling",
|
|
607
|
-
availableToolModels: toolCapableModels.slice(0, 3), // Show first 3 for brevity
|
|
608
|
-
});
|
|
609
|
-
return false;
|
|
154
|
+
return isToolCapable;
|
|
610
155
|
}
|
|
611
156
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
157
|
+
* Local models are slow; the base already defaults Ollama to a 5-minute
|
|
158
|
+
* timeout and honors a per-call `options.timeout`. Preserve the legacy
|
|
159
|
+
* `OLLAMA_TIMEOUT` env override for callers who relied on it, applied only
|
|
160
|
+
* when no explicit per-call timeout is set. Parsed with the shared
|
|
161
|
+
* `parseTimeout` so both millisecond numbers ("240000") and duration strings
|
|
162
|
+
* ("4m", "30s") work; a malformed value is ignored in favour of the default.
|
|
614
163
|
*/
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
if (
|
|
622
|
-
|
|
623
|
-
? typedContent.image.replace(/^data:image\/\w+;base64,/, "")
|
|
624
|
-
: Buffer.from(typedContent.image).toString("base64");
|
|
625
|
-
images.push(imageData);
|
|
164
|
+
getTimeout(options) {
|
|
165
|
+
if (options.timeout === undefined || options.timeout === null) {
|
|
166
|
+
const envTimeout = process.env.OLLAMA_TIMEOUT?.trim();
|
|
167
|
+
if (envTimeout) {
|
|
168
|
+
try {
|
|
169
|
+
const parsed = parseTimeout(envTimeout);
|
|
170
|
+
if (parsed !== undefined && parsed > 0) {
|
|
171
|
+
return parsed;
|
|
626
172
|
}
|
|
627
173
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
}
|
|
632
|
-
/**
|
|
633
|
-
* Convert multimodal messages to Ollama chat format
|
|
634
|
-
* Extracts text content and handles images separately
|
|
635
|
-
*/
|
|
636
|
-
convertToOllamaMessages(messages) {
|
|
637
|
-
return messages.map((msg) => {
|
|
638
|
-
let textContent = "";
|
|
639
|
-
const images = [];
|
|
640
|
-
if (typeof msg.content === "string") {
|
|
641
|
-
textContent = msg.content;
|
|
642
|
-
}
|
|
643
|
-
else if (Array.isArray(msg.content)) {
|
|
644
|
-
for (const content of msg.content) {
|
|
645
|
-
const typedContent = content;
|
|
646
|
-
if (typedContent.type === "text" && typedContent.text) {
|
|
647
|
-
textContent += typedContent.text;
|
|
648
|
-
}
|
|
649
|
-
else if (typedContent.type === "image" && typedContent.image) {
|
|
650
|
-
const imageData = typeof typedContent.image === "string"
|
|
651
|
-
? typedContent.image.replace(/^data:image\/\w+;base64,/, "")
|
|
652
|
-
: Buffer.from(typedContent.image).toString("base64");
|
|
653
|
-
images.push(imageData);
|
|
654
|
-
}
|
|
174
|
+
catch {
|
|
175
|
+
logger.debug(`Ignoring invalid OLLAMA_TIMEOUT='${envTimeout}'; using the default. ` +
|
|
176
|
+
`Use a millisecond number (240000) or a duration like '4m' / '30s'.`);
|
|
655
177
|
}
|
|
656
178
|
}
|
|
657
|
-
const ollamaMsg = {
|
|
658
|
-
role: (msg.role === "system" ? "system" : msg.role),
|
|
659
|
-
content: textContent,
|
|
660
|
-
};
|
|
661
|
-
if (images.length > 0) {
|
|
662
|
-
ollamaMsg.images = images;
|
|
663
|
-
}
|
|
664
|
-
return ollamaMsg;
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
// executeGenerate removed - BaseProvider handles all generation with tools
|
|
668
|
-
async executeStream(options, analysisSchema) {
|
|
669
|
-
try {
|
|
670
|
-
this.validateStreamOptions(options);
|
|
671
|
-
await this.checkOllamaHealth();
|
|
672
|
-
// Check if tools are supported and provided
|
|
673
|
-
const modelSupportsTools = this.supportsTools();
|
|
674
|
-
const hasTools = options.tools && Object.keys(options.tools).length > 0;
|
|
675
|
-
if (modelSupportsTools && hasTools) {
|
|
676
|
-
// Use chat API with tools for tool-capable models
|
|
677
|
-
return this.executeStreamWithTools(options, analysisSchema);
|
|
678
|
-
}
|
|
679
|
-
else {
|
|
680
|
-
// Use generate API for non-tool scenarios
|
|
681
|
-
return this.executeStreamWithoutTools(options, analysisSchema);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
catch (error) {
|
|
685
|
-
throw this.handleProviderError(error);
|
|
686
179
|
}
|
|
180
|
+
return super.getTimeout(options);
|
|
687
181
|
}
|
|
688
182
|
/**
|
|
689
|
-
*
|
|
690
|
-
*
|
|
183
|
+
* Health check: probe `/v1/models` and require at least one installed model.
|
|
184
|
+
* A reachable server with zero models would let `resolveModelName()` fall
|
|
185
|
+
* back to a model that the first real request can't serve, so report unusable.
|
|
691
186
|
*/
|
|
692
|
-
async
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
[ATTR.NL_HAS_TOOLS]: true,
|
|
701
|
-
[ATTR.NL_STREAM_MODE]: true,
|
|
702
|
-
},
|
|
703
|
-
}, async (span) => {
|
|
704
|
-
const startTime = Date.now();
|
|
705
|
-
const maxIterations = options.maxSteps || DEFAULT_MAX_STEPS;
|
|
706
|
-
let iteration = 0;
|
|
707
|
-
// Get all available tools (direct + MCP + external)
|
|
708
|
-
// BaseProvider.stream() pre-merges base tools + external tools into options.tools
|
|
709
|
-
const allTools = options.tools || (await this.getAllTools());
|
|
710
|
-
// Convert tools to Ollama format
|
|
711
|
-
const ollamaTools = this.convertToolsToOllamaFormat(allTools);
|
|
712
|
-
span.setAttribute(ATTR.NL_TOOL_COUNT, ollamaTools.length);
|
|
713
|
-
// Validate that PDFs are not provided
|
|
714
|
-
if (options.input?.pdfFiles && options.input.pdfFiles.length > 0) {
|
|
715
|
-
throw this.handleProviderError(new Error("PDF inputs are not supported by OllamaProvider. " +
|
|
716
|
-
"Please remove PDFs or use a supported provider (OpenAI, Anthropic, Google Vertex AI, etc.)."));
|
|
717
|
-
}
|
|
718
|
-
// Initialize conversation history
|
|
719
|
-
const conversationHistory = [];
|
|
720
|
-
// Build initial messages
|
|
721
|
-
const hasMultimodalInput = !!(options.input?.images?.length ||
|
|
722
|
-
options.input?.content?.length ||
|
|
723
|
-
options.input?.files?.length ||
|
|
724
|
-
options.input?.csvFiles?.length);
|
|
725
|
-
if (hasMultimodalInput) {
|
|
726
|
-
logger.debug(`Ollama: Detected multimodal input, using multimodal message builder`, {
|
|
727
|
-
hasImages: !!options.input?.images?.length,
|
|
728
|
-
imageCount: options.input?.images?.length || 0,
|
|
729
|
-
});
|
|
730
|
-
const multimodalOptions = buildMultimodalOptions(options, this.providerName, this.modelName);
|
|
731
|
-
const multimodalMessages = await buildMultimodalMessagesArray(multimodalOptions, this.providerName, this.modelName);
|
|
732
|
-
conversationHistory.push(...this.convertToOllamaMessages(multimodalMessages));
|
|
733
|
-
}
|
|
734
|
-
else {
|
|
735
|
-
if (options.systemPrompt) {
|
|
736
|
-
conversationHistory.push({
|
|
737
|
-
role: "system",
|
|
738
|
-
content: options.systemPrompt,
|
|
739
|
-
});
|
|
740
|
-
}
|
|
741
|
-
conversationHistory.push({
|
|
742
|
-
role: "user",
|
|
743
|
-
content: options.input.text ?? "",
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
// Capture instance references before the stream for use in the finally block.
|
|
747
|
-
const ollamaNeurolink = this.neurolink;
|
|
748
|
-
const ollamaProviderName = this.providerName;
|
|
749
|
-
const ollamaModelName = this.modelName || FALLBACK_OLLAMA_MODEL;
|
|
750
|
-
// Conversation loop for multi-step tool execution
|
|
751
|
-
let totalInputTokens = 0;
|
|
752
|
-
let totalOutputTokens = 0;
|
|
753
|
-
let lastFinishReason;
|
|
754
|
-
let ollamaStreamErrored = false;
|
|
755
|
-
const stream = new ReadableStream({
|
|
756
|
-
start: async (controller) => {
|
|
757
|
-
try {
|
|
758
|
-
while (iteration < maxIterations) {
|
|
759
|
-
logger.debug(`[OllamaProvider] Conversation iteration ${iteration + 1}/${maxIterations}`);
|
|
760
|
-
// Make API request — request usage in stream_options so
|
|
761
|
-
// Pipeline B gets real token counts for Langfuse cost dashboards.
|
|
762
|
-
const response = await proxyFetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
763
|
-
method: "POST",
|
|
764
|
-
headers: { "Content-Type": "application/json" },
|
|
765
|
-
body: JSON.stringify({
|
|
766
|
-
model: this.modelName || FALLBACK_OLLAMA_MODEL,
|
|
767
|
-
messages: conversationHistory,
|
|
768
|
-
tools: ollamaTools,
|
|
769
|
-
tool_choice: "auto",
|
|
770
|
-
stream: true,
|
|
771
|
-
stream_options: { include_usage: true },
|
|
772
|
-
temperature: options.temperature,
|
|
773
|
-
max_tokens: options.maxTokens,
|
|
774
|
-
}),
|
|
775
|
-
signal: createAbortSignalWithTimeout(this.timeout),
|
|
776
|
-
});
|
|
777
|
-
if (!response.ok) {
|
|
778
|
-
throw this.handleProviderError(await createOllamaHttpError(response));
|
|
779
|
-
}
|
|
780
|
-
// Process response stream
|
|
781
|
-
const { content, toolCalls, finishReason, usage } = await this.processOllamaResponse(response, controller);
|
|
782
|
-
// Accumulate usage across iterations for Pipeline B
|
|
783
|
-
if (usage) {
|
|
784
|
-
totalInputTokens += usage.input;
|
|
785
|
-
totalOutputTokens += usage.output;
|
|
786
|
-
}
|
|
787
|
-
if (finishReason) {
|
|
788
|
-
lastFinishReason = finishReason;
|
|
789
|
-
}
|
|
790
|
-
// Add assistant message to history
|
|
791
|
-
const assistantMessage = {
|
|
792
|
-
role: "assistant",
|
|
793
|
-
content: content || "",
|
|
794
|
-
};
|
|
795
|
-
if (toolCalls && toolCalls.length > 0) {
|
|
796
|
-
assistantMessage.tool_calls = toolCalls;
|
|
797
|
-
}
|
|
798
|
-
conversationHistory.push(assistantMessage);
|
|
799
|
-
// Check finish reason
|
|
800
|
-
if (finishReason === "stop" || !finishReason) {
|
|
801
|
-
// Conversation complete
|
|
802
|
-
span.setAttribute(ATTR.GEN_AI_FINISH_REASON, finishReason || "stop");
|
|
803
|
-
controller.close();
|
|
804
|
-
break;
|
|
805
|
-
}
|
|
806
|
-
else if (finishReason === "tool_calls" &&
|
|
807
|
-
toolCalls &&
|
|
808
|
-
toolCalls.length > 0) {
|
|
809
|
-
// Execute tools
|
|
810
|
-
logger.debug(`[OllamaProvider] Executing ${toolCalls.length} tools`);
|
|
811
|
-
for (const tc of toolCalls) {
|
|
812
|
-
span.addEvent("tool_call", {
|
|
813
|
-
[ATTR.GEN_AI_TOOL_NAME]: tc.function.name,
|
|
814
|
-
});
|
|
815
|
-
}
|
|
816
|
-
const toolResults = await this.executeOllamaTools(toolCalls, options);
|
|
817
|
-
// Add tool results to conversation
|
|
818
|
-
const toolMessage = {
|
|
819
|
-
role: "tool",
|
|
820
|
-
content: JSON.stringify(toolResults),
|
|
821
|
-
};
|
|
822
|
-
conversationHistory.push(toolMessage);
|
|
823
|
-
iteration++;
|
|
824
|
-
}
|
|
825
|
-
else if (finishReason === "length") {
|
|
826
|
-
// Max tokens reached, continue conversation
|
|
827
|
-
logger.debug(`[OllamaProvider] Max tokens reached, continuing`);
|
|
828
|
-
conversationHistory.push({
|
|
829
|
-
role: "user",
|
|
830
|
-
content: "Please continue.",
|
|
831
|
-
});
|
|
832
|
-
iteration++;
|
|
833
|
-
}
|
|
834
|
-
else {
|
|
835
|
-
// Unknown finish reason, end conversation
|
|
836
|
-
logger.warn(`[OllamaProvider] Unknown finish reason: ${finishReason}`);
|
|
837
|
-
span.setAttribute(ATTR.GEN_AI_FINISH_REASON, finishReason);
|
|
838
|
-
controller.close();
|
|
839
|
-
break;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
if (iteration >= maxIterations) {
|
|
843
|
-
ollamaStreamErrored = true;
|
|
844
|
-
controller.error(new Error(`Ollama conversation exceeded maximum iterations (${maxIterations})`));
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
catch (error) {
|
|
848
|
-
ollamaStreamErrored = true;
|
|
849
|
-
controller.error(error);
|
|
850
|
-
}
|
|
851
|
-
finally {
|
|
852
|
-
// Resolve analytics with accumulated token counts so Pipeline A
|
|
853
|
-
// and Pipeline B both get real usage data from Ollama.
|
|
854
|
-
const aggregatedUsage = {
|
|
855
|
-
input: totalInputTokens,
|
|
856
|
-
output: totalOutputTokens,
|
|
857
|
-
total: totalInputTokens + totalOutputTokens,
|
|
858
|
-
};
|
|
859
|
-
resolveAnalytics(createAnalytics(this.providerName, this.modelName || FALLBACK_OLLAMA_MODEL, { usage: aggregatedUsage }, Date.now() - startTime, {
|
|
860
|
-
requestId: `ollama-stream-${Date.now()}`,
|
|
861
|
-
streamingMode: true,
|
|
862
|
-
iterations: iteration,
|
|
863
|
-
}));
|
|
864
|
-
// Emit generation:end so Pipeline B (Langfuse) creates a GENERATION
|
|
865
|
-
// observation. Ollama bypasses the Vercel AI SDK so
|
|
866
|
-
// experimental_telemetry is never injected; we emit manually.
|
|
867
|
-
const ollamaEmitter = ollamaNeurolink?.getEventEmitter();
|
|
868
|
-
if (ollamaEmitter) {
|
|
869
|
-
// Collect accumulated text from conversation history
|
|
870
|
-
const accumulatedContent = conversationHistory
|
|
871
|
-
.filter((m) => m.role === "assistant")
|
|
872
|
-
.map((m) => m.content)
|
|
873
|
-
.join("");
|
|
874
|
-
ollamaEmitter.emit("generation:end", {
|
|
875
|
-
provider: ollamaProviderName,
|
|
876
|
-
responseTime: Date.now() - startTime,
|
|
877
|
-
timestamp: Date.now(),
|
|
878
|
-
result: {
|
|
879
|
-
content: accumulatedContent,
|
|
880
|
-
usage: aggregatedUsage,
|
|
881
|
-
model: ollamaModelName,
|
|
882
|
-
provider: ollamaProviderName,
|
|
883
|
-
finishReason: ollamaStreamErrored
|
|
884
|
-
? "error"
|
|
885
|
-
: (lastFinishReason ?? "stop"),
|
|
886
|
-
},
|
|
887
|
-
success: !ollamaStreamErrored,
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
}
|
|
187
|
+
async validateConfiguration() {
|
|
188
|
+
try {
|
|
189
|
+
const url = `${stripTrailingSlash(this.config.baseURL)}/models`;
|
|
190
|
+
const proxyFetch = createProxyFetch();
|
|
191
|
+
const r = await proxyFetch(url, {
|
|
192
|
+
headers: {
|
|
193
|
+
...this.getAuthHeaders(),
|
|
194
|
+
"Content-Type": "application/json",
|
|
891
195
|
},
|
|
196
|
+
signal: AbortSignal.timeout(5000),
|
|
892
197
|
});
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
// rather than values captured before the tool-loop executes.
|
|
896
|
-
let resolveAnalytics;
|
|
897
|
-
const analyticsPromise = new Promise((resolve) => {
|
|
898
|
-
resolveAnalytics = resolve;
|
|
899
|
-
});
|
|
900
|
-
return {
|
|
901
|
-
stream: this.convertToAsyncIterable(stream),
|
|
902
|
-
provider: this.providerName,
|
|
903
|
-
model: this.modelName || FALLBACK_OLLAMA_MODEL,
|
|
904
|
-
analytics: analyticsPromise,
|
|
905
|
-
metadata: {
|
|
906
|
-
startTime,
|
|
907
|
-
streamId: `ollama-${Date.now()}`,
|
|
908
|
-
},
|
|
909
|
-
};
|
|
910
|
-
}, (r) => r.stream, (r, wrapped) => ({ ...r, stream: wrapped }));
|
|
911
|
-
}
|
|
912
|
-
/**
|
|
913
|
-
* Execute streaming without tools using the generate API
|
|
914
|
-
* Fallback for non-tool scenarios or when chat API is unavailable
|
|
915
|
-
*/
|
|
916
|
-
async executeStreamWithoutTools(options, _analysisSchema) {
|
|
917
|
-
return withClientStreamSpan({
|
|
918
|
-
name: "neurolink.provider.stream",
|
|
919
|
-
tracer: tracers.provider,
|
|
920
|
-
attributes: {
|
|
921
|
-
[ATTR.GEN_AI_SYSTEM]: "ollama",
|
|
922
|
-
[ATTR.GEN_AI_MODEL]: this.modelName || FALLBACK_OLLAMA_MODEL,
|
|
923
|
-
[ATTR.GEN_AI_OPERATION]: "stream",
|
|
924
|
-
[ATTR.NL_HAS_TOOLS]: false,
|
|
925
|
-
[ATTR.NL_STREAM_MODE]: true,
|
|
926
|
-
},
|
|
927
|
-
}, async () => {
|
|
928
|
-
// Validate that PDFs are not provided
|
|
929
|
-
if (options.input?.pdfFiles && options.input.pdfFiles.length > 0) {
|
|
930
|
-
throw this.handleProviderError(new Error("PDF inputs are not supported by OllamaProvider. " +
|
|
931
|
-
"Please remove PDFs or use a supported provider (OpenAI, Anthropic, Google Vertex AI, etc.)."));
|
|
198
|
+
if (!r.ok) {
|
|
199
|
+
return false;
|
|
932
200
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
options.input?.content?.length ||
|
|
936
|
-
options.input?.files?.length ||
|
|
937
|
-
options.input?.csvFiles?.length);
|
|
938
|
-
const useOpenAIMode = isOpenAICompatibleMode();
|
|
939
|
-
if (useOpenAIMode) {
|
|
940
|
-
// OpenAI-compatible mode: Use /v1/chat/completions with messages
|
|
941
|
-
logger.debug(`Ollama (OpenAI mode): Building messages for streaming`);
|
|
942
|
-
const messages = [];
|
|
943
|
-
if (options.systemPrompt) {
|
|
944
|
-
messages.push({ role: "system", content: options.systemPrompt });
|
|
945
|
-
}
|
|
946
|
-
if (hasMultimodalInput) {
|
|
947
|
-
const multimodalOptions = buildMultimodalOptions(options, this.providerName, this.modelName);
|
|
948
|
-
const multimodalMessages = await buildMultimodalMessagesArray(multimodalOptions, this.providerName, this.modelName);
|
|
949
|
-
// Convert multimodal messages to text (OpenAI-compatible mode doesn't support images in /v1/chat/completions for Ollama)
|
|
950
|
-
const content = multimodalMessages
|
|
951
|
-
.map((msg) => typeof msg.content === "string" ? msg.content : "")
|
|
952
|
-
.join("\n");
|
|
953
|
-
messages.push({ role: "user", content });
|
|
954
|
-
}
|
|
955
|
-
else {
|
|
956
|
-
messages.push({ role: "user", content: options.input.text ?? "" });
|
|
957
|
-
}
|
|
958
|
-
const requestUrl = `${this.baseUrl}/v1/chat/completions`;
|
|
959
|
-
const requestBody = {
|
|
960
|
-
model: this.modelName || FALLBACK_OLLAMA_MODEL,
|
|
961
|
-
messages,
|
|
962
|
-
temperature: options.temperature,
|
|
963
|
-
max_tokens: options.maxTokens,
|
|
964
|
-
stream: true,
|
|
965
|
-
};
|
|
966
|
-
logger.debug(`[Ollama OpenAI Mode] About to fetch:`, {
|
|
967
|
-
url: requestUrl,
|
|
968
|
-
baseUrl: this.baseUrl,
|
|
969
|
-
modelName: this.modelName,
|
|
970
|
-
requestBody: JSON.stringify(requestBody),
|
|
971
|
-
});
|
|
972
|
-
const response = await proxyFetch(requestUrl, {
|
|
973
|
-
method: "POST",
|
|
974
|
-
headers: { "Content-Type": "application/json" },
|
|
975
|
-
body: JSON.stringify(requestBody),
|
|
976
|
-
signal: createAbortSignalWithTimeout(this.timeout),
|
|
977
|
-
});
|
|
978
|
-
logger.debug(`[Ollama OpenAI Mode] Response received:`, {
|
|
979
|
-
status: response.status,
|
|
980
|
-
statusText: response.statusText,
|
|
981
|
-
ok: response.ok,
|
|
982
|
-
});
|
|
983
|
-
if (!response.ok) {
|
|
984
|
-
throw this.handleProviderError(await createOllamaHttpError(response));
|
|
985
|
-
}
|
|
986
|
-
// Transform to async generator for OpenAI-compatible format
|
|
987
|
-
const self = this;
|
|
988
|
-
const transformedStream = async function* () {
|
|
989
|
-
const generator = self.createOpenAIStream(response);
|
|
990
|
-
for await (const chunk of generator) {
|
|
991
|
-
yield chunk;
|
|
992
|
-
}
|
|
993
|
-
};
|
|
994
|
-
return {
|
|
995
|
-
stream: transformedStream(),
|
|
996
|
-
provider: self.providerName,
|
|
997
|
-
model: self.modelName,
|
|
998
|
-
};
|
|
999
|
-
}
|
|
1000
|
-
else {
|
|
1001
|
-
// Native Ollama mode: Use /api/generate
|
|
1002
|
-
let prompt = options.input.text;
|
|
1003
|
-
let images;
|
|
1004
|
-
if (hasMultimodalInput) {
|
|
1005
|
-
logger.debug(`Ollama (native mode): Detected multimodal input`, {
|
|
1006
|
-
hasImages: !!options.input?.images?.length,
|
|
1007
|
-
imageCount: options.input?.images?.length || 0,
|
|
1008
|
-
});
|
|
1009
|
-
const multimodalOptions = buildMultimodalOptions(options, this.providerName, this.modelName);
|
|
1010
|
-
const multimodalMessages = await buildMultimodalMessagesArray(multimodalOptions, this.providerName, this.modelName);
|
|
1011
|
-
// Extract text from messages for prompt
|
|
1012
|
-
prompt = multimodalMessages
|
|
1013
|
-
.map((msg) => typeof msg.content === "string" ? msg.content : "")
|
|
1014
|
-
.join("\n");
|
|
1015
|
-
// Extract images
|
|
1016
|
-
images = this.extractImagesFromMessages(multimodalMessages);
|
|
1017
|
-
}
|
|
1018
|
-
const requestBody = {
|
|
1019
|
-
model: this.modelName || FALLBACK_OLLAMA_MODEL,
|
|
1020
|
-
prompt,
|
|
1021
|
-
system: options.systemPrompt,
|
|
1022
|
-
stream: true,
|
|
1023
|
-
options: {
|
|
1024
|
-
temperature: options.temperature,
|
|
1025
|
-
num_predict: options.maxTokens,
|
|
1026
|
-
},
|
|
1027
|
-
};
|
|
1028
|
-
if (images && images.length > 0) {
|
|
1029
|
-
requestBody.images = images;
|
|
1030
|
-
}
|
|
1031
|
-
const requestUrl = `${this.baseUrl}/api/generate`;
|
|
1032
|
-
logger.debug(`[Ollama Native Mode] About to fetch:`, {
|
|
1033
|
-
url: requestUrl,
|
|
1034
|
-
baseUrl: this.baseUrl,
|
|
1035
|
-
modelName: this.modelName,
|
|
1036
|
-
requestBody: JSON.stringify(requestBody),
|
|
1037
|
-
});
|
|
1038
|
-
const response = await proxyFetch(requestUrl, {
|
|
1039
|
-
method: "POST",
|
|
1040
|
-
headers: { "Content-Type": "application/json" },
|
|
1041
|
-
body: JSON.stringify(requestBody),
|
|
1042
|
-
signal: createAbortSignalWithTimeout(this.timeout),
|
|
1043
|
-
});
|
|
1044
|
-
logger.debug(`[Ollama Native Mode] Response received:`, {
|
|
1045
|
-
status: response.status,
|
|
1046
|
-
statusText: response.statusText,
|
|
1047
|
-
ok: response.ok,
|
|
1048
|
-
});
|
|
1049
|
-
if (!response.ok) {
|
|
1050
|
-
throw this.handleProviderError(await createOllamaHttpError(response));
|
|
1051
|
-
}
|
|
1052
|
-
// Transform to async generator to match other providers
|
|
1053
|
-
const self = this;
|
|
1054
|
-
const transformedStream = async function* () {
|
|
1055
|
-
const generator = self.createOllamaStream(response);
|
|
1056
|
-
for await (const chunk of generator) {
|
|
1057
|
-
yield chunk;
|
|
1058
|
-
}
|
|
1059
|
-
};
|
|
1060
|
-
return {
|
|
1061
|
-
stream: transformedStream(),
|
|
1062
|
-
provider: this.providerName,
|
|
1063
|
-
model: this.modelName,
|
|
1064
|
-
};
|
|
1065
|
-
}
|
|
1066
|
-
}, (r) => r.stream, (r, wrapped) => ({ ...r, stream: wrapped }));
|
|
1067
|
-
}
|
|
1068
|
-
/**
|
|
1069
|
-
* Convert AI SDK tools format to Ollama's function calling format
|
|
1070
|
-
*/
|
|
1071
|
-
convertToolsToOllamaFormat(tools) {
|
|
1072
|
-
if (!tools || typeof tools !== "object") {
|
|
1073
|
-
return [];
|
|
201
|
+
const data = (await r.json().catch(() => null));
|
|
202
|
+
return Boolean(data?.data?.some((m) => typeof m?.id === "string" && m.id.trim().length > 0));
|
|
1074
203
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
parameters: tool.parameters ||
|
|
1082
|
-
tool.function?.parameters || {
|
|
1083
|
-
type: "object",
|
|
1084
|
-
properties: {},
|
|
1085
|
-
required: [],
|
|
1086
|
-
},
|
|
1087
|
-
},
|
|
1088
|
-
}));
|
|
1089
|
-
}
|
|
1090
|
-
/**
|
|
1091
|
-
* Parse tool calls from Ollama API response
|
|
1092
|
-
*/
|
|
1093
|
-
parseToolCalls(rawToolCalls) {
|
|
1094
|
-
if (!Array.isArray(rawToolCalls)) {
|
|
1095
|
-
return [];
|
|
204
|
+
catch (error) {
|
|
205
|
+
logger.debug("Ollama validateConfiguration probe failed", {
|
|
206
|
+
baseURL: redactUrlCredentials(this.config.baseURL),
|
|
207
|
+
error: error instanceof Error ? error.message : String(error),
|
|
208
|
+
});
|
|
209
|
+
return false;
|
|
1096
210
|
}
|
|
1097
|
-
return rawToolCalls
|
|
1098
|
-
.map((call) => {
|
|
1099
|
-
const callObj = call;
|
|
1100
|
-
if (!callObj.function?.name) {
|
|
1101
|
-
return null;
|
|
1102
|
-
}
|
|
1103
|
-
return {
|
|
1104
|
-
id: callObj.id ||
|
|
1105
|
-
`tool_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
|
|
1106
|
-
type: "function",
|
|
1107
|
-
function: {
|
|
1108
|
-
name: callObj.function.name,
|
|
1109
|
-
arguments: callObj.function.arguments || "{}",
|
|
1110
|
-
},
|
|
1111
|
-
};
|
|
1112
|
-
})
|
|
1113
|
-
.filter((call) => call !== null);
|
|
1114
211
|
}
|
|
1115
|
-
|
|
1116
|
-
* Process Ollama streaming response and stream content to controller
|
|
1117
|
-
* Returns aggregated content, tool calls, and finish reason
|
|
1118
|
-
*/
|
|
1119
|
-
async processOllamaResponse(response, controller) {
|
|
1120
|
-
const reader = response.body?.getReader();
|
|
1121
|
-
if (!reader) {
|
|
1122
|
-
throw new Error("No response body from Ollama");
|
|
1123
|
-
}
|
|
1124
|
-
const decoder = new TextDecoder();
|
|
1125
|
-
let buffer = "";
|
|
1126
|
-
let aggregatedContent = "";
|
|
1127
|
-
let aggregatedToolCalls = [];
|
|
1128
|
-
let finalFinishReason;
|
|
1129
|
-
let finalUsage;
|
|
1130
|
-
try {
|
|
1131
|
-
while (true) {
|
|
1132
|
-
const { done, value } = await reader.read();
|
|
1133
|
-
if (done) {
|
|
1134
|
-
break;
|
|
1135
|
-
}
|
|
1136
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1137
|
-
const lines = buffer.split("\n");
|
|
1138
|
-
buffer = lines.pop() || "";
|
|
1139
|
-
for (const line of lines) {
|
|
1140
|
-
if (line.trim() && line.startsWith("data: ")) {
|
|
1141
|
-
const dataLine = line.slice(6); // Remove "data: " prefix
|
|
1142
|
-
if (dataLine === "[DONE]") {
|
|
1143
|
-
break;
|
|
1144
|
-
}
|
|
1145
|
-
try {
|
|
1146
|
-
const parsed = JSON.parse(dataLine);
|
|
1147
|
-
// OpenAI-compatible usage chunk (Ollama may include usage
|
|
1148
|
-
// in the final chunk when stream_options.include_usage is set,
|
|
1149
|
-
// or as a standalone chunk with empty choices).
|
|
1150
|
-
const parsedUsage = parsed.usage;
|
|
1151
|
-
if (parsedUsage) {
|
|
1152
|
-
const input = parsedUsage.prompt_tokens ?? 0;
|
|
1153
|
-
const output = parsedUsage.completion_tokens ?? 0;
|
|
1154
|
-
finalUsage = {
|
|
1155
|
-
input,
|
|
1156
|
-
output,
|
|
1157
|
-
total: parsedUsage.total_tokens ?? input + output,
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
const processed = this.processOllamaStreamData(parsed);
|
|
1161
|
-
if (!processed) {
|
|
1162
|
-
continue;
|
|
1163
|
-
}
|
|
1164
|
-
// Stream content to controller
|
|
1165
|
-
if (processed.content) {
|
|
1166
|
-
aggregatedContent += processed.content;
|
|
1167
|
-
controller.enqueue({
|
|
1168
|
-
content: processed.content,
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
// Collect tool calls
|
|
1172
|
-
if (processed.toolCalls && processed.toolCalls.length > 0) {
|
|
1173
|
-
aggregatedToolCalls = [
|
|
1174
|
-
...aggregatedToolCalls,
|
|
1175
|
-
...processed.toolCalls,
|
|
1176
|
-
];
|
|
1177
|
-
}
|
|
1178
|
-
// Update finish reason
|
|
1179
|
-
if (processed.finishReason) {
|
|
1180
|
-
finalFinishReason = processed.finishReason;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
catch (parseError) {
|
|
1184
|
-
logger.warn(`[OllamaProvider] Failed to parse stream chunk: ${dataLine}`, { error: parseError });
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
finally {
|
|
1191
|
-
reader.releaseLock();
|
|
1192
|
-
}
|
|
212
|
+
getConfiguration() {
|
|
1193
213
|
return {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
214
|
+
provider: this.providerName,
|
|
215
|
+
model: this.modelName || this.resolvedModel || getDefaultOllamaModel(),
|
|
216
|
+
defaultModel: getDefaultOllamaModel(),
|
|
217
|
+
baseURL: this.config.baseURL,
|
|
1198
218
|
};
|
|
1199
219
|
}
|
|
220
|
+
// ===========================================================================
|
|
221
|
+
// Embeddings — native POST /v1/embeddings
|
|
222
|
+
// ===========================================================================
|
|
1200
223
|
/**
|
|
1201
|
-
*
|
|
224
|
+
* Generate an embedding for a single text input via native /v1/embeddings.
|
|
225
|
+
* Uses `OLLAMA_EMBEDDING_MODEL` (default `nomic-embed-text`); the embedding
|
|
226
|
+
* model must be pulled locally (`ollama pull nomic-embed-text`).
|
|
1202
227
|
*/
|
|
1203
|
-
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
if (delta?.content && typeof delta.content === "string") {
|
|
1210
|
-
content += delta.content;
|
|
1211
|
-
}
|
|
1212
|
-
// Return tool calls for execution instead of formatting as text
|
|
1213
|
-
if (delta?.tool_calls) {
|
|
1214
|
-
const toolCalls = this.parseToolCalls(delta.tool_calls);
|
|
1215
|
-
return {
|
|
1216
|
-
toolCalls,
|
|
1217
|
-
finishReason,
|
|
1218
|
-
shouldReturn: !!finishReason,
|
|
1219
|
-
};
|
|
1220
|
-
}
|
|
1221
|
-
// Also check for tool calls in the message field (some responses include it there)
|
|
1222
|
-
if (choices?.[0]?.message?.tool_calls) {
|
|
1223
|
-
const toolCalls = this.parseToolCalls(choices[0].message.tool_calls);
|
|
1224
|
-
return {
|
|
1225
|
-
toolCalls,
|
|
1226
|
-
finishReason,
|
|
1227
|
-
shouldReturn: !!finishReason,
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
const shouldReturn = !!finishReason;
|
|
1231
|
-
return content
|
|
1232
|
-
? { content, finishReason, shouldReturn }
|
|
1233
|
-
: { finishReason, shouldReturn };
|
|
228
|
+
async embed(text, modelName) {
|
|
229
|
+
const embeddingModel = modelName ||
|
|
230
|
+
process.env.OLLAMA_EMBEDDING_MODEL ||
|
|
231
|
+
DEFAULT_EMBEDDING_MODEL;
|
|
232
|
+
const [embedding] = await this.callEmbeddings(embeddingModel, [text], "embed");
|
|
233
|
+
return embedding;
|
|
1234
234
|
}
|
|
1235
235
|
/**
|
|
1236
|
-
*
|
|
236
|
+
* Generate embeddings for multiple text inputs via native /v1/embeddings.
|
|
1237
237
|
*/
|
|
1238
|
-
async
|
|
1239
|
-
const
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
238
|
+
async embedMany(texts, modelName) {
|
|
239
|
+
const embeddingModel = modelName ||
|
|
240
|
+
process.env.OLLAMA_EMBEDDING_MODEL ||
|
|
241
|
+
DEFAULT_EMBEDDING_MODEL;
|
|
242
|
+
return this.callEmbeddings(embeddingModel, texts, "embedMany");
|
|
243
|
+
}
|
|
244
|
+
async callEmbeddings(modelName, input, operation) {
|
|
245
|
+
const url = `${stripTrailingSlash(this.config.baseURL)}/embeddings`;
|
|
246
|
+
const fetchImpl = createProxyFetch();
|
|
247
|
+
const timeoutController = createTimeoutController(EMBEDDINGS_TIMEOUT_MS, this.providerName, "generate");
|
|
1245
248
|
try {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
}
|
|
1260
|
-
try {
|
|
1261
|
-
const data = JSON.parse(dataLine);
|
|
1262
|
-
const result = this.processOllamaStreamData(data);
|
|
1263
|
-
if (result?.content) {
|
|
1264
|
-
yield { content: result.content };
|
|
1265
|
-
}
|
|
1266
|
-
if (result?.shouldReturn) {
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
catch (error) {
|
|
1271
|
-
logger.error("Error parsing Ollama stream response", {
|
|
1272
|
-
error,
|
|
1273
|
-
});
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
finally {
|
|
1280
|
-
reader.releaseLock();
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
/**
|
|
1284
|
-
* Format tool calls for display when tools aren't executed directly
|
|
1285
|
-
*/
|
|
1286
|
-
formatToolCallForDisplay(toolCalls) {
|
|
1287
|
-
if (!toolCalls || toolCalls.length === 0) {
|
|
1288
|
-
return "";
|
|
1289
|
-
}
|
|
1290
|
-
const descriptions = toolCalls.map((call) => {
|
|
1291
|
-
const functionName = call.function?.name || "unknown_function";
|
|
1292
|
-
let args = {};
|
|
1293
|
-
if (call.function?.arguments) {
|
|
1294
|
-
try {
|
|
1295
|
-
args = JSON.parse(call.function.arguments);
|
|
1296
|
-
}
|
|
1297
|
-
catch (error) {
|
|
1298
|
-
// If arguments are malformed, preserve for debugging while marking as invalid
|
|
1299
|
-
logger.warn?.("Malformed tool call arguments: " + call.function.arguments);
|
|
1300
|
-
args = {
|
|
1301
|
-
_malformed: true,
|
|
1302
|
-
_originalArguments: call.function.arguments,
|
|
1303
|
-
_error: error instanceof Error ? error.message : String(error),
|
|
1304
|
-
};
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
return `\n[Tool Call: ${functionName}(${JSON.stringify(args)})]`;
|
|
1308
|
-
});
|
|
1309
|
-
return descriptions.join("");
|
|
1310
|
-
}
|
|
1311
|
-
/**
|
|
1312
|
-
* Convert AI SDK tools to ToolDefinition format
|
|
1313
|
-
*/
|
|
1314
|
-
convertAISDKToolsToToolDefinitions(aiTools) {
|
|
1315
|
-
const result = {};
|
|
1316
|
-
for (const [name, tool] of Object.entries(aiTools)) {
|
|
1317
|
-
if ("description" in tool && tool.description) {
|
|
1318
|
-
// AI SDK v6 uses `inputSchema`; legacy tools may still use `parameters`
|
|
1319
|
-
const toolSchema = "inputSchema" in tool
|
|
1320
|
-
? tool.inputSchema
|
|
1321
|
-
: "parameters" in tool
|
|
1322
|
-
? tool
|
|
1323
|
-
.parameters
|
|
1324
|
-
: undefined;
|
|
1325
|
-
result[name] = {
|
|
1326
|
-
description: tool.description,
|
|
1327
|
-
parameters: toolSchema,
|
|
1328
|
-
execute: async (params) => {
|
|
1329
|
-
if ("execute" in tool && tool.execute) {
|
|
1330
|
-
const result = await tool.execute(params, {
|
|
1331
|
-
toolCallId: `tool_${Date.now()}`,
|
|
1332
|
-
messages: [],
|
|
1333
|
-
});
|
|
1334
|
-
return {
|
|
1335
|
-
success: true,
|
|
1336
|
-
data: result,
|
|
1337
|
-
};
|
|
1338
|
-
}
|
|
1339
|
-
throw new Error(`Tool ${name} has no execute method`);
|
|
1340
|
-
},
|
|
1341
|
-
};
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
return result;
|
|
1345
|
-
}
|
|
1346
|
-
/**
|
|
1347
|
-
* Execute a single tool and return the result
|
|
1348
|
-
*/
|
|
1349
|
-
async executeSingleTool(toolName, args, _toolCallId) {
|
|
1350
|
-
logger.debug(`[OllamaProvider] Executing single tool: ${toolName}`, {
|
|
1351
|
-
args,
|
|
1352
|
-
});
|
|
1353
|
-
try {
|
|
1354
|
-
// Use BaseProvider's tool execution mechanism
|
|
1355
|
-
const aiTools = await this.getAllTools();
|
|
1356
|
-
const tools = this.convertAISDKToolsToToolDefinitions(aiTools);
|
|
1357
|
-
if (!tools[toolName]) {
|
|
1358
|
-
throw new Error(`Tool not found: ${toolName}`);
|
|
1359
|
-
}
|
|
1360
|
-
const tool = tools[toolName];
|
|
1361
|
-
if (!tool || !tool.execute) {
|
|
1362
|
-
throw new Error(`Tool ${toolName} does not have execute method`);
|
|
1363
|
-
}
|
|
1364
|
-
const toolInput = args || {};
|
|
1365
|
-
// Convert Record<string, unknown> to ToolArgs by filtering out non-JsonValue types
|
|
1366
|
-
const toolArgs = {};
|
|
1367
|
-
for (const [key, value] of Object.entries(toolInput)) {
|
|
1368
|
-
// Only include values that are JsonValue compatible
|
|
1369
|
-
if (value === null ||
|
|
1370
|
-
typeof value === "string" ||
|
|
1371
|
-
typeof value === "number" ||
|
|
1372
|
-
typeof value === "boolean" ||
|
|
1373
|
-
(typeof value === "object" && value !== null)) {
|
|
1374
|
-
toolArgs[key] = value;
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
const result = await tool.execute(toolArgs);
|
|
1378
|
-
logger.debug(`[OllamaProvider] Tool execution result:`, {
|
|
1379
|
-
toolName,
|
|
1380
|
-
result,
|
|
1381
|
-
});
|
|
1382
|
-
// Handle ToolResult type
|
|
1383
|
-
if (result && typeof result === "object" && "success" in result) {
|
|
1384
|
-
if (result.success && result.data !== undefined) {
|
|
1385
|
-
if (typeof result.data === "string") {
|
|
1386
|
-
return result.data;
|
|
1387
|
-
}
|
|
1388
|
-
else if (typeof result.data === "object") {
|
|
1389
|
-
return JSON.stringify(result.data, null, 2);
|
|
1390
|
-
}
|
|
1391
|
-
else {
|
|
1392
|
-
return String(result.data);
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
else if (result.error) {
|
|
1396
|
-
const errorMessage = typeof result.error === "string"
|
|
1397
|
-
? result.error
|
|
1398
|
-
: result.error.message || "Tool execution failed";
|
|
1399
|
-
throw new Error(errorMessage);
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
// Fallback for non-ToolResult return types
|
|
1403
|
-
if (typeof result === "string") {
|
|
1404
|
-
return result;
|
|
1405
|
-
}
|
|
1406
|
-
else if (typeof result === "object") {
|
|
1407
|
-
return JSON.stringify(result, null, 2);
|
|
1408
|
-
}
|
|
1409
|
-
else {
|
|
1410
|
-
return String(result);
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
catch (error) {
|
|
1414
|
-
logger.error(`[OllamaProvider] Tool execution error:`, {
|
|
1415
|
-
toolName,
|
|
1416
|
-
error,
|
|
1417
|
-
});
|
|
1418
|
-
throw error;
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
/**
|
|
1422
|
-
* Execute tools and format results for Ollama API
|
|
1423
|
-
* Similar to Bedrock's executeStreamTools but for Ollama format
|
|
1424
|
-
*/
|
|
1425
|
-
async executeOllamaTools(toolCalls, options) {
|
|
1426
|
-
const toolResults = [];
|
|
1427
|
-
const toolCallsForStorage = [];
|
|
1428
|
-
const toolResultsForStorage = [];
|
|
1429
|
-
logger.debug(`[OllamaProvider] Executing ${toolCalls.length} tool calls`);
|
|
1430
|
-
for (const call of toolCalls) {
|
|
1431
|
-
logger.debug(`[OllamaProvider] Executing tool: ${call.function.name}`);
|
|
1432
|
-
// Parse arguments
|
|
1433
|
-
let args;
|
|
1434
|
-
try {
|
|
1435
|
-
args = JSON.parse(call.function.arguments);
|
|
1436
|
-
}
|
|
1437
|
-
catch (error) {
|
|
1438
|
-
logger.error(`[OllamaProvider] Failed to parse tool arguments: ${call.function.arguments}`, { error });
|
|
1439
|
-
args = {};
|
|
1440
|
-
}
|
|
1441
|
-
// Track tool call for storage
|
|
1442
|
-
toolCallsForStorage.push({
|
|
1443
|
-
type: "tool-call",
|
|
1444
|
-
toolCallId: call.id,
|
|
1445
|
-
toolName: call.function.name,
|
|
1446
|
-
args,
|
|
1447
|
-
});
|
|
1448
|
-
try {
|
|
1449
|
-
// Execute tool using existing tool framework
|
|
1450
|
-
const result = await this.executeSingleTool(call.function.name, args, call.id);
|
|
1451
|
-
logger.debug(`[OllamaProvider] Tool execution successful: ${call.function.name}`);
|
|
1452
|
-
// Track result for storage
|
|
1453
|
-
toolResultsForStorage.push({
|
|
1454
|
-
type: "tool-result",
|
|
1455
|
-
toolCallId: call.id,
|
|
1456
|
-
toolName: call.function.name,
|
|
1457
|
-
result,
|
|
1458
|
-
});
|
|
1459
|
-
// Format for Ollama API
|
|
1460
|
-
toolResults.push({
|
|
1461
|
-
tool_call_id: call.id,
|
|
1462
|
-
content: JSON.stringify(result),
|
|
1463
|
-
});
|
|
1464
|
-
}
|
|
1465
|
-
catch (error) {
|
|
1466
|
-
logger.error(`[OllamaProvider] Tool execution failed: ${call.function.name}`, { error });
|
|
1467
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1468
|
-
// Track failed result
|
|
1469
|
-
toolResultsForStorage.push({
|
|
1470
|
-
type: "tool-result",
|
|
1471
|
-
toolCallId: call.id,
|
|
1472
|
-
toolName: call.function.name,
|
|
1473
|
-
result: { error: errorMessage },
|
|
1474
|
-
});
|
|
1475
|
-
// Format error for Ollama API
|
|
1476
|
-
toolResults.push({
|
|
1477
|
-
tool_call_id: call.id,
|
|
1478
|
-
content: JSON.stringify({ error: errorMessage }),
|
|
1479
|
-
});
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
// Emit tool:end for each completed tool result so Pipeline B
|
|
1483
|
-
// captures telemetry for Ollama-driven tool calls (gap S2).
|
|
1484
|
-
emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResultsForStorage.map((tr) => {
|
|
1485
|
-
const hasError = tr.result && typeof tr.result === "object" && "error" in tr.result;
|
|
1486
|
-
return {
|
|
1487
|
-
toolName: tr.toolName,
|
|
1488
|
-
result: tr.result,
|
|
1489
|
-
error: hasError
|
|
1490
|
-
? String(tr.result.error)
|
|
1491
|
-
: undefined,
|
|
1492
|
-
};
|
|
1493
|
-
}));
|
|
1494
|
-
// Store tool executions (similar to Bedrock)
|
|
1495
|
-
this.handleToolExecutionStorage(toolCallsForStorage, toolResultsForStorage, options, new Date()).catch((error) => {
|
|
1496
|
-
logger.warn("[OllamaProvider] Failed to store tool executions", {
|
|
1497
|
-
provider: this.providerName,
|
|
1498
|
-
error: error instanceof Error ? error.message : String(error),
|
|
249
|
+
const res = await fetchImpl(url, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: {
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
...this.getAuthHeaders(),
|
|
254
|
+
},
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
model: modelName,
|
|
257
|
+
input: input.length === 1 ? input[0] : input,
|
|
258
|
+
}),
|
|
259
|
+
...(timeoutController?.controller.signal
|
|
260
|
+
? { signal: timeoutController.controller.signal }
|
|
261
|
+
: {}),
|
|
1499
262
|
});
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
const reader = stream.getReader();
|
|
1510
|
-
try {
|
|
1511
|
-
while (true) {
|
|
1512
|
-
const { done, value } = await reader.read();
|
|
1513
|
-
if (done) {
|
|
1514
|
-
break;
|
|
1515
|
-
}
|
|
1516
|
-
yield value;
|
|
263
|
+
if (!res.ok) {
|
|
264
|
+
const bodyText = await res.text().catch(() => "");
|
|
265
|
+
// Guard JSON.parse: a reverse proxy / gateway in front of Ollama can
|
|
266
|
+
// return a non-JSON error body (HTML 5xx page, plain text). Degrade to
|
|
267
|
+
// the status-based fallback message instead of throwing a SyntaxError.
|
|
268
|
+
let parsed;
|
|
269
|
+
if (bodyText) {
|
|
270
|
+
try {
|
|
271
|
+
parsed = JSON.parse(bodyText);
|
|
1517
272
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
reader.releaseLock();
|
|
1521
|
-
}
|
|
1522
|
-
},
|
|
1523
|
-
};
|
|
1524
|
-
}
|
|
1525
|
-
/**
|
|
1526
|
-
* Create stream generator for Ollama generate API (non-tool mode)
|
|
1527
|
-
*/
|
|
1528
|
-
async *createOllamaStream(response) {
|
|
1529
|
-
const reader = response.body?.getReader();
|
|
1530
|
-
if (!reader) {
|
|
1531
|
-
throw new Error("No response body");
|
|
1532
|
-
}
|
|
1533
|
-
const decoder = new TextDecoder();
|
|
1534
|
-
let buffer = "";
|
|
1535
|
-
try {
|
|
1536
|
-
while (true) {
|
|
1537
|
-
const { done, value } = await reader.read();
|
|
1538
|
-
if (done) {
|
|
1539
|
-
break;
|
|
1540
|
-
}
|
|
1541
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1542
|
-
const lines = buffer.split("\n");
|
|
1543
|
-
buffer = lines.pop() || "";
|
|
1544
|
-
for (const line of lines) {
|
|
1545
|
-
if (line.trim()) {
|
|
1546
|
-
try {
|
|
1547
|
-
const data = JSON.parse(line);
|
|
1548
|
-
if (data.response) {
|
|
1549
|
-
yield { content: data.response };
|
|
1550
|
-
}
|
|
1551
|
-
if (data.done) {
|
|
1552
|
-
return;
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
catch (error) {
|
|
1556
|
-
logger.error("Error parsing Ollama stream response", {
|
|
1557
|
-
error,
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
273
|
+
catch {
|
|
274
|
+
parsed = undefined;
|
|
1560
275
|
}
|
|
1561
276
|
}
|
|
277
|
+
throw this.formatProviderError(new Error(parsed?.error?.message ||
|
|
278
|
+
`Ollama ${operation} failed with status ${res.status}`));
|
|
1562
279
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
const reader = response.body?.getReader();
|
|
1570
|
-
if (!reader) {
|
|
1571
|
-
throw new Error("No response body");
|
|
1572
|
-
}
|
|
1573
|
-
const decoder = new TextDecoder();
|
|
1574
|
-
let buffer = "";
|
|
1575
|
-
try {
|
|
1576
|
-
while (true) {
|
|
1577
|
-
const { done, value } = await reader.read();
|
|
1578
|
-
if (done) {
|
|
1579
|
-
break;
|
|
1580
|
-
}
|
|
1581
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1582
|
-
const lines = buffer.split("\n");
|
|
1583
|
-
buffer = lines.pop() || "";
|
|
1584
|
-
for (const line of lines) {
|
|
1585
|
-
const trimmedLine = line.trim();
|
|
1586
|
-
if (!trimmedLine || trimmedLine === "data: [DONE]") {
|
|
1587
|
-
continue;
|
|
1588
|
-
}
|
|
1589
|
-
if (trimmedLine.startsWith("data: ")) {
|
|
1590
|
-
try {
|
|
1591
|
-
const jsonStr = trimmedLine.slice(6); // Remove "data: " prefix
|
|
1592
|
-
const data = JSON.parse(jsonStr);
|
|
1593
|
-
const content = data.choices?.[0]?.delta?.content;
|
|
1594
|
-
if (content) {
|
|
1595
|
-
yield { content };
|
|
1596
|
-
}
|
|
1597
|
-
if (data.choices?.[0]?.finish_reason) {
|
|
1598
|
-
return;
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
catch (error) {
|
|
1602
|
-
logger.error("Error parsing OpenAI stream response", {
|
|
1603
|
-
error,
|
|
1604
|
-
line: trimmedLine,
|
|
1605
|
-
});
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
280
|
+
const json = (await res.json());
|
|
281
|
+
const embeddings = (json.data ?? [])
|
|
282
|
+
.map((row) => row.embedding)
|
|
283
|
+
.filter((e) => Array.isArray(e));
|
|
284
|
+
if (embeddings.length === 0) {
|
|
285
|
+
throw new ProviderError(`Ollama ${operation} returned no embeddings`, "ollama");
|
|
1609
286
|
}
|
|
287
|
+
return embeddings;
|
|
1610
288
|
}
|
|
1611
289
|
finally {
|
|
1612
|
-
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
formatProviderError(error) {
|
|
1616
|
-
if (error instanceof TimeoutError) {
|
|
1617
|
-
return new TimeoutError(`Ollama request timed out. The model might be loading or the request is too complex.`, this.timeout);
|
|
1618
|
-
}
|
|
1619
|
-
if (error.message?.includes("ECONNREFUSED") ||
|
|
1620
|
-
error.message?.includes("fetch failed")) {
|
|
1621
|
-
return new NetworkError(`❌ Ollama Service Not Running\n\nCannot connect to Ollama at ${this.baseUrl}\n\n🔧 Steps to Fix:\n1. Install Ollama: https://ollama.ai/\n2. Start Ollama service: 'ollama serve'\n3. Verify it's running: 'curl ${this.baseUrl}/api/version'\n4. Try again`, this.providerName);
|
|
1622
|
-
}
|
|
1623
|
-
if (error.message?.includes("model") &&
|
|
1624
|
-
error.message?.includes("not found")) {
|
|
1625
|
-
return new InvalidModelError(`❌ Ollama Model Not Found\n\nModel '${this.modelName}' is not available locally.\n\n🔧 Install Model:\n1. Run: ollama pull ${this.modelName}\n2. Or try a different model:\n - ollama pull ${FALLBACK_OLLAMA_MODEL}\n - ollama pull mistral:latest\n - ollama pull codellama:latest\n\n🔧 List Available Models:\nollama list`, this.providerName);
|
|
1626
|
-
}
|
|
1627
|
-
const errMsg = error.message ?? "";
|
|
1628
|
-
const httpStatus = isOllamaHttpError(error) ? error.statusCode : undefined;
|
|
1629
|
-
const responseBody = isOllamaHttpError(error) ? error.responseBody : "";
|
|
1630
|
-
if (httpStatus === 404 &&
|
|
1631
|
-
(responseBody.toLowerCase().includes("model") ||
|
|
1632
|
-
responseBody.toLowerCase().includes("not found") ||
|
|
1633
|
-
errMsg.toLowerCase().includes("model") ||
|
|
1634
|
-
errMsg.toLowerCase().includes("not found"))) {
|
|
1635
|
-
return new InvalidModelError(`❌ Ollama Returned HTTP 404\n\nThis usually means the configured model '${this.modelName}' is not installed locally, although a bad base URL or incompatible API mode can also cause it.\n\n🔧 Check:\n1. Verify the model exists: 'ollama list'\n2. Pull it if missing: 'ollama pull ${this.modelName}'\n3. Verify the service is healthy: 'curl ${this.baseUrl}/api/version'\n4. If you use OpenAI-compatible mode, confirm the base URL serves /v1/chat/completions`, this.providerName);
|
|
1636
|
-
}
|
|
1637
|
-
if (httpStatus === 404) {
|
|
1638
|
-
return new ProviderError(`❌ Ollama Endpoint Returned HTTP 404\n\nThe configured base URL (${this.baseUrl}) did not serve the expected Ollama endpoint for model '${this.modelName}'. This is usually a configuration or API-mode mismatch rather than a missing model.\n\n🔧 Check:\n1. Verify the base URL: ${this.baseUrl}\n2. For native Ollama mode, confirm /api/generate exists\n3. For OpenAI-compatible mode, confirm /v1/chat/completions exists\n4. If the model is missing, the response body should explicitly say so`, this.providerName);
|
|
290
|
+
timeoutController?.cleanup();
|
|
1639
291
|
}
|
|
1640
|
-
return new ProviderError(`❌ Ollama Provider Error\n\n${error.message || "Unknown error occurred"}\n\n🔧 Troubleshooting:\n1. Check if Ollama service is running\n2. Verify model is installed: 'ollama list'\n3. Check network connectivity to ${this.baseUrl}\n4. Review Ollama logs for details`, this.providerName);
|
|
1641
|
-
}
|
|
1642
|
-
/**
|
|
1643
|
-
* Check if Ollama service is healthy and accessible
|
|
1644
|
-
*/
|
|
1645
|
-
async checkOllamaHealth() {
|
|
1646
|
-
try {
|
|
1647
|
-
// Use traditional AbortController for better compatibility
|
|
1648
|
-
const controller = new AbortController();
|
|
1649
|
-
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
1650
|
-
const response = await proxyFetch(`${this.baseUrl}/api/version`, {
|
|
1651
|
-
method: "GET",
|
|
1652
|
-
signal: controller.signal,
|
|
1653
|
-
});
|
|
1654
|
-
clearTimeout(timeoutId);
|
|
1655
|
-
if (!response.ok) {
|
|
1656
|
-
throw new Error(`Ollama health check failed: ${response.status}`);
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
catch (error) {
|
|
1660
|
-
if (error instanceof Error && error.message.includes("ECONNREFUSED")) {
|
|
1661
|
-
throw new Error(`❌ Ollama Service Not Running\n\nCannot connect to Ollama service.\n\n🔧 Start Ollama:\n1. Run: ollama serve\n2. Or start Ollama app\n3. Verify: curl ${this.baseUrl}/api/version`, { cause: error });
|
|
1662
|
-
}
|
|
1663
|
-
throw error;
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
/**
|
|
1667
|
-
* Get available models from Ollama
|
|
1668
|
-
*/
|
|
1669
|
-
async getAvailableModels() {
|
|
1670
|
-
try {
|
|
1671
|
-
const response = await proxyFetch(`${this.baseUrl}/api/tags`);
|
|
1672
|
-
if (!response.ok) {
|
|
1673
|
-
throw new Error(`Failed to fetch models: ${response.status}`);
|
|
1674
|
-
}
|
|
1675
|
-
const data = await response.json();
|
|
1676
|
-
return data.models?.map((model) => model.name) || [];
|
|
1677
|
-
}
|
|
1678
|
-
catch (error) {
|
|
1679
|
-
logger.warn("Failed to fetch Ollama models:", error);
|
|
1680
|
-
return [];
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
/**
|
|
1684
|
-
* Check if a specific model is available
|
|
1685
|
-
*/
|
|
1686
|
-
async isModelAvailable(modelName) {
|
|
1687
|
-
const models = await this.getAvailableModels();
|
|
1688
|
-
return models.includes(modelName);
|
|
1689
|
-
}
|
|
1690
|
-
/**
|
|
1691
|
-
* Get recommendations for tool-calling capable Ollama models
|
|
1692
|
-
* Provides guidance for users who want to use function calling locally
|
|
1693
|
-
*/
|
|
1694
|
-
static getToolCallingRecommendations() {
|
|
1695
|
-
return {
|
|
1696
|
-
recommended: [
|
|
1697
|
-
"llama3.1:8b-instruct",
|
|
1698
|
-
"mistral:7b-instruct-v0.3",
|
|
1699
|
-
"hermes3:8b-llama3.1",
|
|
1700
|
-
"codellama:34b-instruct",
|
|
1701
|
-
"firefunction-v2:70b",
|
|
1702
|
-
],
|
|
1703
|
-
performance: {
|
|
1704
|
-
"llama3.1:8b-instruct": { speed: 3, quality: 3, size: "4.6GB" },
|
|
1705
|
-
"mistral:7b-instruct-v0.3": { speed: 3, quality: 2, size: "4.1GB" },
|
|
1706
|
-
"hermes3:8b-llama3.1": { speed: 3, quality: 3, size: "4.6GB" },
|
|
1707
|
-
"codellama:34b-instruct": { speed: 1, quality: 3, size: "19GB" },
|
|
1708
|
-
"firefunction-v2:70b": { speed: 1, quality: 3, size: "40GB" },
|
|
1709
|
-
},
|
|
1710
|
-
notes: {
|
|
1711
|
-
"llama3.1:8b-instruct": "Best balance of speed, quality, and tool calling capability",
|
|
1712
|
-
"mistral:7b-instruct-v0.3": "Lightweight with reliable function calling",
|
|
1713
|
-
"hermes3:8b-llama3.1": "Specialized for tool execution and reasoning",
|
|
1714
|
-
"codellama:34b-instruct": "Excellent for code-related tool calling, requires more resources",
|
|
1715
|
-
"firefunction-v2:70b": "Optimized specifically for function calling, requires high-end hardware",
|
|
1716
|
-
},
|
|
1717
|
-
installation: {
|
|
1718
|
-
"llama3.1:8b-instruct": "ollama pull llama3.1:8b-instruct",
|
|
1719
|
-
"mistral:7b-instruct-v0.3": "ollama pull mistral:7b-instruct-v0.3",
|
|
1720
|
-
"hermes3:8b-llama3.1": "ollama pull hermes3:8b-llama3.1",
|
|
1721
|
-
"codellama:34b-instruct": "ollama pull codellama:34b-instruct",
|
|
1722
|
-
"firefunction-v2:70b": "ollama pull firefunction-v2:70b",
|
|
1723
|
-
},
|
|
1724
|
-
};
|
|
1725
292
|
}
|
|
1726
293
|
}
|
|
1727
|
-
export default OllamaProvider;
|
|
1728
294
|
//# sourceMappingURL=ollama.js.map
|