@juspay/neurolink 9.68.18 → 9.68.20

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.
@@ -1,1728 +1,294 @@
1
- import { createAnalytics } from "../core/analytics.js";
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 { tracers, ATTR, withClientStreamSpan } from "../telemetry/index.js";
12
- import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
13
- import { TimeoutError } from "../utils/timeout.js";
14
- // Model version constants (configurable via environment)
15
- const DEFAULT_OLLAMA_MODEL = process.env.OLLAMA_MODEL || "llama3.1:8b";
16
- const FALLBACK_OLLAMA_MODEL = "llama3.2:latest"; // Used when primary model fails
17
- // Configuration helpers
18
- const getOllamaBaseUrl = () => {
19
- return process.env.OLLAMA_BASE_URL || "http://localhost:11434";
20
- };
21
- const isOpenAICompatibleMode = () => {
22
- // Enable OpenAI-compatible API mode (/v1/chat/completions) instead of native Ollama API (/api/generate)
23
- // Useful for Ollama deployments that only support OpenAI-compatible routes (e.g., breezehq.dev)
24
- return process.env.OLLAMA_OPENAI_COMPATIBLE === "true";
25
- };
26
- // Create AbortController with timeout for better compatibility
27
- const createAbortSignalWithTimeout = (timeoutMs) => {
28
- const controller = new AbortController();
29
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
30
- // Clear timeout if signal is aborted through other means
31
- controller.signal.addEventListener("abort", () => {
32
- clearTimeout(timeoutId);
33
- });
34
- return controller.signal;
35
- };
36
- const getDefaultOllamaModel = () => {
37
- return process.env.OLLAMA_MODEL || DEFAULT_OLLAMA_MODEL;
38
- };
39
- const getOllamaTimeout = () => {
40
- // Increased default timeout to 240000ms (4 minutes) to support slower native API responses
41
- // especially for larger models like aliafshar/gemma3-it-qat-tools:latest (12.2B parameters)
42
- return parseInt(process.env.OLLAMA_TIMEOUT || "240000", 10);
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 v2 - BaseProvider Implementation
41
+ * Ollama Provider direct HTTP, no AI SDK.
514
42
  *
515
- * PHASE 3.7: BaseProvider wrap around existing custom Ollama implementation
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
- * Features:
518
- * - Extends BaseProvider for shared functionality
519
- * - Preserves custom OllamaLanguageModel implementation
520
- * - Local model management and health checking
521
- * - Enhanced error handling with Ollama-specific guidance
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 BaseProvider {
524
- ollamaModel;
525
- baseUrl;
526
- timeout;
527
- constructor(modelName, credentials) {
528
- super(modelName, "ollama");
529
- this.baseUrl = credentials?.baseURL ?? getOllamaBaseUrl();
530
- this.timeout = getOllamaTimeout();
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
- baseUrl: this.baseUrl,
536
- timeout: this.timeout,
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
- * Returns the Vercel AI SDK model instance for Ollama.
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 Tool Calling Support (Enhanced 2025)
559
- *
560
- * Uses configurable model list from ModelConfiguration instead of hardcoded values.
561
- * Tool-capable models can be configured via OLLAMA_TOOL_CAPABLE_MODELS environment variable.
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 enabled", {
148
+ if (!isToolCapable) {
149
+ logger.debug("Ollama tool calling disabled", {
595
150
  model: this.modelName,
596
- reason: "Model in tool-capable list",
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
- // Config exists and model is NOT in list - disable tools
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
- * Extract images from multimodal messages for Ollama API
613
- * Returns array of base64-encoded images
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
- extractImagesFromMessages(messages) {
616
- const images = [];
617
- for (const msg of messages) {
618
- if (Array.isArray(msg.content)) {
619
- for (const content of msg.content) {
620
- const typedContent = content;
621
- if (typedContent.type === "image" && typedContent.image) {
622
- const imageData = typeof typedContent.image === "string"
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
- return images;
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
- * Execute streaming with Ollama's function calling support
690
- * Uses conversation loop to handle multi-step tool execution
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 executeStreamWithTools(options, _analysisSchema) {
693
- return withClientStreamSpan({
694
- name: "neurolink.provider.stream",
695
- tracer: tracers.provider,
696
- attributes: {
697
- [ATTR.GEN_AI_SYSTEM]: "ollama",
698
- [ATTR.GEN_AI_MODEL]: this.modelName || FALLBACK_OLLAMA_MODEL,
699
- [ATTR.GEN_AI_OPERATION]: "stream",
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
- // Defer analytics resolution until the stream's start callback finishes.
894
- // This ensures responseTime and iteration reflect the actual completed values
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
- // Check for multimodal input
934
- const hasMultimodalInput = !!(options.input?.images?.length ||
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
- const toolsArray = Array.isArray(tools) ? tools : Object.values(tools);
1076
- return toolsArray.map((tool) => ({
1077
- type: "function",
1078
- function: {
1079
- name: tool.name || tool.function?.name,
1080
- description: tool.description || tool.function?.description,
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
- content: aggregatedContent || undefined,
1195
- toolCalls: aggregatedToolCalls.length > 0 ? aggregatedToolCalls : undefined,
1196
- finishReason: finalFinishReason,
1197
- usage: finalUsage,
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
- * Process individual stream data chunk from Ollama
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
- processOllamaStreamData(data) {
1204
- const dataRecord = data;
1205
- const choices = dataRecord.choices;
1206
- const delta = choices?.[0]?.delta;
1207
- const finishReason = choices?.[0]?.finish_reason;
1208
- let content = "";
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
- * Create stream generator for Ollama chat API with tool call support
236
+ * Generate embeddings for multiple text inputs via native /v1/embeddings.
1237
237
  */
1238
- async *createOllamaChatStream(response, _tools) {
1239
- const reader = response.body?.getReader();
1240
- if (!reader) {
1241
- throw new Error("No response body");
1242
- }
1243
- const decoder = new TextDecoder();
1244
- let buffer = "";
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
- while (true) {
1247
- const { done, value } = await reader.read();
1248
- if (done) {
1249
- break;
1250
- }
1251
- buffer += decoder.decode(value, { stream: true });
1252
- const lines = buffer.split("\n");
1253
- buffer = lines.pop() || "";
1254
- for (const line of lines) {
1255
- if (line.trim() && line.startsWith("data: ")) {
1256
- const dataLine = line.slice(6); // Remove "data: " prefix
1257
- if (dataLine === "[DONE]") {
1258
- return;
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
- return toolResults;
1502
- }
1503
- /**
1504
- * Convert ReadableStream to AsyncIterable for compatibility with StreamResult interface
1505
- */
1506
- convertToAsyncIterable(stream) {
1507
- return {
1508
- async *[Symbol.asyncIterator]() {
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
- finally {
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
- finally {
1565
- reader.releaseLock();
1566
- }
1567
- }
1568
- async *createOpenAIStream(response) {
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
- reader.releaseLock();
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