From 6d362350e06804a674d87e2081252fd87cb00cd8 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 9 Feb 2026 18:49:17 -0500 Subject: [PATCH 1/2] refactor: migrate NativeOllamaHandler to AI SDK Replace direct `ollama` npm package usage with `ollama-ai-provider-v2` AI SDK community provider, following the same pattern as other migrated providers (DeepSeek, LM Studio, etc.). Changes: - Use `createOllama` from `ollama-ai-provider-v2` instead of `Ollama` class - Replace `client.chat({ stream: true })` with `streamText()` + `processAiSdkStreamPart()` - Replace `client.chat({ stream: false })` with `generateText()` - Use `convertToAiSdkMessages` instead of custom `convertToOllamaMessages` - Use AI SDK's built-in tool support via `convertToolsForAiSdk` - Pass `num_ctx` via `providerOptions.ollama` - Preserve Ollama-specific error handling (ECONNREFUSED, 404) - Override `isAiSdkProvider()` to return true - Remove unused `convertToOllamaMessages` function - Remove `ollama` npm package (no longer used) - Add `ollama-ai-provider-v2` package - Update all tests to mock AI SDK instead of ollama package --- pnpm-lock.yaml | 77 ++-- .../providers/__tests__/native-ollama.spec.ts | 386 ++++++++-------- src/api/providers/native-ollama.ts | 434 +++++------------- src/package.json | 2 +- 4 files changed, 349 insertions(+), 550 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e643479a6f..78c1b161775 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -905,9 +905,9 @@ importers: node-ipc: specifier: ^12.0.0 version: 12.0.0 - ollama: - specifier: ^0.5.17 - version: 0.5.17 + ollama-ai-provider-v2: + specifier: ^3.0.4 + version: 3.0.4(ai@6.0.77(zod@3.25.76))(zod@3.25.76) openai: specifier: ^5.12.2 version: 5.12.2(ws@8.18.3)(zod@3.25.76) @@ -8565,8 +8565,12 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - ollama@0.5.17: - resolution: {integrity: sha512-q5LmPtk6GLFouS+3aURIVl+qcAOPC4+Msmx7uBb3pd+fxI55WnGjmLZ0yijI/CYy79x0QPGx3BwC3u5zv9fBvQ==} + ollama-ai-provider-v2@3.0.4: + resolution: {integrity: sha512-gtvqJcw1z9O/uYXgXdNAbm8roTJekJlZs5UMU62iElrsz/osUOGSuQxnSb5fU1SDPDWlZJ2xKotrAZU7+p2kqg==} + engines: {node: '>=18'} + peerDependencies: + ai: ^5.0.0 || ^6.0.0 + zod: 3.25.76 on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} @@ -10744,9 +10748,6 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - whatwg-fetch@3.6.20: - resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} - whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -12581,7 +12582,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -13044,7 +13045,7 @@ snapshots: '@puppeteer/browsers@2.10.5': dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -13057,7 +13058,7 @@ snapshots: '@puppeteer/browsers@2.6.1': dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -15436,7 +15437,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -16168,6 +16169,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1: + dependencies: + ms: 2.1.3 + debug@4.4.1(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -16254,8 +16259,7 @@ snapshots: detect-libc@2.0.4: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -16920,7 +16924,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -16956,7 +16960,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -17057,7 +17061,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -17285,7 +17289,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -17580,14 +17584,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -18313,7 +18317,7 @@ snapshots: lightningcss@1.30.1: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 optionalDependencies: lightningcss-darwin-arm64: 1.30.1 lightningcss-darwin-x64: 1.30.1 @@ -19298,9 +19302,12 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - ollama@0.5.17: + ollama-ai-provider-v2@3.0.4(ai@6.0.77(zod@3.25.76))(zod@3.25.76): dependencies: - whatwg-fetch: 3.6.20 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + ai: 6.0.77(zod@3.25.76) + zod: 3.25.76 on-finished@2.4.1: dependencies: @@ -19462,7 +19469,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 get-uri: 6.0.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -19814,7 +19821,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -19855,7 +19862,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.6.1 chromium-bidi: 0.11.0(devtools-protocol@0.0.1367902) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 devtools-protocol: 0.0.1367902 typed-query-selector: 2.12.0 ws: 8.18.2 @@ -19869,7 +19876,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.10.5 chromium-bidi: 5.1.0(devtools-protocol@0.0.1452169) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 devtools-protocol: 0.0.1452169 typed-query-selector: 2.12.0 ws: 8.18.2 @@ -20390,7 +20397,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -20507,7 +20514,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -20679,7 +20686,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -20728,7 +20735,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 socks: 2.8.4 transitivePeerDependencies: - supports-color @@ -21238,7 +21245,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 esbuild: 0.25.9 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -21639,7 +21646,7 @@ snapshots: vite-node@3.2.4(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0): dependencies: cac: 6.7.14 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) @@ -21806,7 +21813,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 @@ -21983,8 +21990,6 @@ snapshots: dependencies: iconv-lite: 0.6.3 - whatwg-fetch@3.6.20: {} - whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 73327a3012c..b19599fc0f8 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -1,34 +1,46 @@ // npx vitest run api/providers/__tests__/native-ollama.spec.ts -import { NativeOllamaHandler } from "../native-ollama" -import { ApiHandlerOptions } from "../../../shared/api" -import { getOllamaModels } from "../fetchers/ollama" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -// Mock the ollama package -const mockChat = vitest.fn() -vitest.mock("ollama", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - Ollama: vitest.fn().mockImplementation(() => ({ - chat: mockChat, - })), - Message: vitest.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("ollama-ai-provider-v2", () => ({ + createOllama: vi.fn(() => { + return vi.fn(() => ({ + modelId: "llama2", + provider: "ollama", + })) + }), +})) + // Mock the getOllamaModels function -vitest.mock("../fetchers/ollama", () => ({ - getOllamaModels: vitest.fn(), +vi.mock("../fetchers/ollama", () => ({ + getOllamaModels: vi.fn(), })) -const mockGetOllamaModels = vitest.mocked(getOllamaModels) +import { NativeOllamaHandler } from "../native-ollama" +import { ApiHandlerOptions } from "../../../shared/api" +import { getOllamaModels } from "../fetchers/ollama" + +const mockGetOllamaModels = vi.mocked(getOllamaModels) describe("NativeOllamaHandler", () => { let handler: NativeOllamaHandler beforeEach(() => { - vitest.clearAllMocks() + vi.clearAllMocks() - // Default mock for getOllamaModels mockGetOllamaModels.mockResolvedValue({ llama2: { contextWindow: 4096, @@ -49,18 +61,14 @@ describe("NativeOllamaHandler", () => { describe("createMessage", () => { it("should stream messages from Ollama", async () => { - // Mock the chat response as an async generator - mockChat.mockImplementation(async function* () { - yield { - message: { content: "Hello" }, - eval_count: undefined, - prompt_eval_count: undefined, - } - yield { - message: { content: " world" }, - eval_count: 2, - prompt_eval_count: 10, - } + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 2 }), }) const systemPrompt = "You are a helpful assistant" @@ -79,57 +87,57 @@ describe("NativeOllamaHandler", () => { expect(results[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 2 }) }) - it("should not include num_ctx by default", async () => { - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response" } } + it("should not include providerOptions by default (no num_ctx)", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) - // Consume the stream for await (const _ of stream) { // consume stream } - // Verify that num_ctx was NOT included in the options - expect(mockChat).toHaveBeenCalledWith( - expect.objectContaining({ - options: expect.not.objectContaining({ - num_ctx: expect.anything(), - }), + expect(mockStreamText).toHaveBeenCalledWith( + expect.not.objectContaining({ + providerOptions: expect.anything(), }), ) }) - it("should include num_ctx when explicitly set via ollamaNumCtx", async () => { + it("should include num_ctx via providerOptions when explicitly set via ollamaNumCtx", async () => { const options: ApiHandlerOptions = { apiModelId: "llama2", ollamaModelId: "llama2", ollamaBaseUrl: "http://localhost:11434", - ollamaNumCtx: 8192, // Explicitly set num_ctx + ollamaNumCtx: 8192, } handler = new NativeOllamaHandler(options) - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response" } } + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) - // Consume the stream for await (const _ of stream) { // consume stream } - // Verify that num_ctx was included with the specified value - expect(mockChat).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.objectContaining({ - num_ctx: 8192, - }), + providerOptions: { ollama: { num_ctx: 8192 } }, }), ) }) @@ -143,11 +151,14 @@ describe("NativeOllamaHandler", () => { handler = new NativeOllamaHandler(options) - // Mock response with thinking tags - mockChat.mockImplementation(async function* () { - yield { message: { content: "Let me think" } } - yield { message: { content: " about this" } } - yield { message: { content: "The answer is 42" } } + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "Let me think about this" } + yield { type: "text-delta", text: "The answer is 42" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Question?" }]) @@ -157,7 +168,6 @@ describe("NativeOllamaHandler", () => { results.push(chunk) } - // Should detect reasoning vs regular text expect(results.some((r) => r.type === "reasoning")).toBe(true) expect(results.some((r) => r.type === "text")).toBe(true) }) @@ -165,62 +175,54 @@ describe("NativeOllamaHandler", () => { describe("completePrompt", () => { it("should complete a prompt without streaming", async () => { - mockChat.mockResolvedValue({ - message: { content: "This is the response" }, + mockGenerateText.mockResolvedValue({ + text: "This is the response", }) const result = await handler.completePrompt("Tell me a joke") - expect(mockChat).toHaveBeenCalledWith({ - model: "llama2", - messages: [{ role: "user", content: "Tell me a joke" }], - stream: false, - options: { + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Tell me a joke", temperature: 0, - }, - }) + }), + ) expect(result).toBe("This is the response") }) - it("should not include num_ctx in completePrompt by default", async () => { - mockChat.mockResolvedValue({ - message: { content: "Response" }, + it("should not include providerOptions in completePrompt by default", async () => { + mockGenerateText.mockResolvedValue({ + text: "Response", }) await handler.completePrompt("Test prompt") - // Verify that num_ctx was NOT included in the options - expect(mockChat).toHaveBeenCalledWith( - expect.objectContaining({ - options: expect.not.objectContaining({ - num_ctx: expect.anything(), - }), + expect(mockGenerateText).toHaveBeenCalledWith( + expect.not.objectContaining({ + providerOptions: expect.anything(), }), ) }) - it("should include num_ctx in completePrompt when explicitly set", async () => { + it("should include num_ctx via providerOptions in completePrompt when explicitly set", async () => { const options: ApiHandlerOptions = { apiModelId: "llama2", ollamaModelId: "llama2", ollamaBaseUrl: "http://localhost:11434", - ollamaNumCtx: 4096, // Explicitly set num_ctx + ollamaNumCtx: 4096, } handler = new NativeOllamaHandler(options) - mockChat.mockResolvedValue({ - message: { content: "Response" }, + mockGenerateText.mockResolvedValue({ + text: "Response", }) await handler.completePrompt("Test prompt") - // Verify that num_ctx was included with the specified value - expect(mockChat).toHaveBeenCalledWith( + expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.objectContaining({ - num_ctx: 4096, - }), + providerOptions: { ollama: { num_ctx: 4096 } }, }), ) }) @@ -230,7 +232,17 @@ describe("NativeOllamaHandler", () => { it("should handle connection refused errors", async () => { const error = new Error("ECONNREFUSED") as any error.code = "ECONNREFUSED" - mockChat.mockRejectedValue(error) + + const mockFullStream = { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.reject(error), + }), + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) @@ -244,7 +256,17 @@ describe("NativeOllamaHandler", () => { it("should handle model not found errors", async () => { const error = new Error("Not found") as any error.status = 404 - mockChat.mockRejectedValue(error) + + const mockFullStream = { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.reject(error), + }), + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) @@ -264,9 +286,14 @@ describe("NativeOllamaHandler", () => { }) }) + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) + describe("tool calling", () => { - it("should include tools when tools are provided", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. + it("should pass tools via AI SDK when tools are provided", async () => { mockGetOllamaModels.mockResolvedValue({ "llama3.2": { contextWindow: 128000, @@ -284,9 +311,13 @@ describe("NativeOllamaHandler", () => { handler = new NativeOllamaHandler(options) - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "I will use the tool" } } + async function* mockFullStream() { + yield { type: "text-delta", text: "I will use the tool" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const tools = [ @@ -312,36 +343,18 @@ describe("NativeOllamaHandler", () => { { taskId: "test", tools }, ) - // Consume the stream for await (const _ of stream) { // consume stream } - // Verify tools were passed to the API - expect(mockChat).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tools: [ - { - type: "function", - function: { - name: "get_weather", - description: "Get the weather for a location", - parameters: { - type: "object", - properties: { - location: { type: "string", description: "The city name" }, - }, - required: ["location"], - }, - }, - }, - ], + tools: expect.any(Object), }), ) }) - it("should include tools even when model metadata doesn't advertise tool support", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. + it("should pass tools even when model metadata doesn't advertise tool support", async () => { mockGetOllamaModels.mockResolvedValue({ llama2: { contextWindow: 4096, @@ -351,9 +364,13 @@ describe("NativeOllamaHandler", () => { }, }) - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response without tools" } } + async function* mockFullStream() { + yield { type: "text-delta", text: "Response without tools" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const tools = [ @@ -372,21 +389,18 @@ describe("NativeOllamaHandler", () => { tools, }) - // Consume the stream for await (const _ of stream) { // consume stream } - // Verify tools were passed - expect(mockChat).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tools: expect.any(Array), + tools: expect.any(Object), }), ) }) it("should not include tools when no tools are provided", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. mockGetOllamaModels.mockResolvedValue({ "llama3.2": { contextWindow: 128000, @@ -404,30 +418,31 @@ describe("NativeOllamaHandler", () => { handler = new NativeOllamaHandler(options) - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response" } } + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }], { taskId: "test", }) - // Consume the stream for await (const _ of stream) { // consume stream } - // Verify tools were NOT passed - expect(mockChat).toHaveBeenCalledWith( - expect.not.objectContaining({ - tools: expect.anything(), + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: undefined, }), ) }) - it("should yield tool_call_partial when model returns tool calls", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. + it("should yield tool call events when model returns tool calls", async () => { mockGetOllamaModels.mockResolvedValue({ "llama3.2": { contextWindow: 128000, @@ -445,21 +460,26 @@ describe("NativeOllamaHandler", () => { handler = new NativeOllamaHandler(options) - // Mock the chat response with tool calls - mockChat.mockImplementation(async function* () { + async function* mockFullStream() { yield { - message: { - content: "", - tool_calls: [ - { - function: { - name: "get_weather", - arguments: { location: "San Francisco" }, - }, - }, - ], - }, + type: "tool-input-start", + id: "tool-call-1", + toolName: "get_weather", } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"location":"San Francisco"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const tools = [ @@ -490,20 +510,26 @@ describe("NativeOllamaHandler", () => { results.push(chunk) } - // Should yield a tool_call_partial chunk - const toolCallChunk = results.find((r) => r.type === "tool_call_partial") - expect(toolCallChunk).toBeDefined() - expect(toolCallChunk).toEqual({ - type: "tool_call_partial", - index: 0, - id: "ollama-tool-0", + const toolCallStart = results.find((r) => r.type === "tool_call_start") + expect(toolCallStart).toBeDefined() + expect(toolCallStart).toEqual({ + type: "tool_call_start", + id: "tool-call-1", name: "get_weather", - arguments: JSON.stringify({ location: "San Francisco" }), + }) + + const toolCallDelta = results.find((r) => r.type === "tool_call_delta") + expect(toolCallDelta).toBeDefined() + + const toolCallEnd = results.find((r) => r.type === "tool_call_end") + expect(toolCallEnd).toBeDefined() + expect(toolCallEnd).toEqual({ + type: "tool_call_end", + id: "tool-call-1", }) }) - it("should yield tool_call_end events after tool_call_partial chunks", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. + it("should yield tool_call_end events after tool_call_start for multiple tools", async () => { mockGetOllamaModels.mockResolvedValue({ "llama3.2": { contextWindow: 128000, @@ -521,27 +547,18 @@ describe("NativeOllamaHandler", () => { handler = new NativeOllamaHandler(options) - // Mock the chat response with multiple tool calls - mockChat.mockImplementation(async function* () { - yield { - message: { - content: "", - tool_calls: [ - { - function: { - name: "get_weather", - arguments: { location: "San Francisco" }, - }, - }, - { - function: { - name: "get_time", - arguments: { timezone: "PST" }, - }, - }, - ], - }, - } + async function* mockFullStream() { + yield { type: "tool-input-start", id: "tool-0", toolName: "get_weather" } + yield { type: "tool-input-delta", id: "tool-0", delta: '{"location":"SF"}' } + yield { type: "tool-input-end", id: "tool-0" } + yield { type: "tool-input-start", id: "tool-1", toolName: "get_time" } + yield { type: "tool-input-delta", id: "tool-1", delta: '{"timezone":"PST"}' } + yield { type: "tool-input-end", id: "tool-1" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) const tools = [ @@ -582,27 +599,18 @@ describe("NativeOllamaHandler", () => { results.push(chunk) } - // Should yield tool_call_partial chunks - const toolCallPartials = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallPartials).toHaveLength(2) + const toolCallStarts = results.filter((r) => r.type === "tool_call_start") + expect(toolCallStarts).toHaveLength(2) - // Should yield tool_call_end events for each tool call const toolCallEnds = results.filter((r) => r.type === "tool_call_end") expect(toolCallEnds).toHaveLength(2) - expect(toolCallEnds[0]).toEqual({ type: "tool_call_end", id: "ollama-tool-0" }) - expect(toolCallEnds[1]).toEqual({ type: "tool_call_end", id: "ollama-tool-1" }) - - // tool_call_end should come after tool_call_partial - // Find the last tool_call_partial index - let lastPartialIndex = -1 - for (let i = results.length - 1; i >= 0; i--) { - if (results[i].type === "tool_call_partial") { - lastPartialIndex = i - break - } - } + expect(toolCallEnds[0]).toEqual({ type: "tool_call_end", id: "tool-0" }) + expect(toolCallEnds[1]).toEqual({ type: "tool_call_end", id: "tool-1" }) + + // tool_call_end should come after corresponding tool_call_start + const firstStartIndex = results.findIndex((r) => r.type === "tool_call_start") const firstEndIndex = results.findIndex((r) => r.type === "tool_call_end") - expect(firstEndIndex).toBeGreaterThan(lastPartialIndex) + expect(firstEndIndex).toBeGreaterThan(firstStartIndex) }) }) }) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 99c1dc03cfa..6f33943da2f 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -1,203 +1,61 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" -import { Message, Ollama, Tool as OllamaTool, type Config as OllamaOptions } from "ollama" +import { createOllama } from "ollama-ai-provider-v2" +import { streamText, generateText, ToolSet } from "ai" + import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" + import { BaseProvider } from "./base-provider" -import type { ApiHandlerOptions } from "../../shared/api" import { getOllamaModels } from "./fetchers/ollama" -import { TagMatcher } from "../../utils/tag-matcher" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -interface OllamaChatOptions { - temperature: number - num_ctx?: number -} - -function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { - const ollamaMessages: Message[] = [] - - for (const anthropicMessage of anthropicMessages) { - if (typeof anthropicMessage.content === "string") { - ollamaMessages.push({ - role: anthropicMessage.role, - content: anthropicMessage.content, - }) - } else { - if (anthropicMessage.role === "user") { - const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ - nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] - toolMessages: Anthropic.ToolResultBlockParam[] - }>( - (acc, part) => { - if (part.type === "tool_result") { - acc.toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - acc.nonToolMessages.push(part) - } - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - // Process tool result messages FIRST since they must follow the tool use messages - const toolResultImages: string[] = [] - toolMessages.forEach((toolMessage) => { - // The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility. - let content: string - - if (typeof toolMessage.content === "string") { - content = toolMessage.content - } else { - content = - toolMessage.content - ?.map((part) => { - if (part.type === "image") { - // Handle base64 images only (Anthropic SDK uses base64) - // Ollama expects raw base64 strings, not data URLs - if ("source" in part && part.source.type === "base64") { - toolResultImages.push(part.source.data) - } - return "(see following user message for image)" - } - return part.text - }) - .join("\n") ?? "" - } - ollamaMessages.push({ - role: "user", - images: toolResultImages.length > 0 ? toolResultImages : undefined, - content: content, - }) - }) - - // Process non-tool messages - if (nonToolMessages.length > 0) { - // Separate text and images for Ollama - const textContent = nonToolMessages - .filter((part) => part.type === "text") - .map((part) => part.text) - .join("\n") - - const imageData: string[] = [] - nonToolMessages.forEach((part) => { - if (part.type === "image" && "source" in part && part.source.type === "base64") { - // Ollama expects raw base64 strings, not data URLs - imageData.push(part.source.data) - } - }) - - ollamaMessages.push({ - role: "user", - content: textContent, - images: imageData.length > 0 ? imageData : undefined, - }) - } - } else if (anthropicMessage.role === "assistant") { - const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ - nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] - toolMessages: Anthropic.ToolUseBlockParam[] - }>( - (acc, part) => { - if (part.type === "tool_use") { - acc.toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - acc.nonToolMessages.push(part) - } // assistant cannot send tool_result messages - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - // Process non-tool messages - let content: string = "" - if (nonToolMessages.length > 0) { - content = nonToolMessages - .map((part) => { - if (part.type === "image") { - return "" // impossible as the assistant cannot send images - } - return part.text - }) - .join("\n") - } - - // Convert tool_use blocks to Ollama tool_calls format - const toolCalls = - toolMessages.length > 0 - ? toolMessages.map((tool) => ({ - function: { - name: tool.name, - arguments: tool.input as Record, - }, - })) - : undefined - - ollamaMessages.push({ - role: "assistant", - content, - tool_calls: toolCalls, - }) - } - } - } - - return ollamaMessages -} - +/** + * NativeOllamaHandler using the ollama-ai-provider-v2 AI SDK community provider. + * Communicates with Ollama via its HTTP API through the AI SDK abstraction. + */ export class NativeOllamaHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: Ollama | undefined + protected provider: ReturnType protected models: Record = {} constructor(options: ApiHandlerOptions) { super() this.options = options - } - private ensureClient(): Ollama { - if (!this.client) { - try { - const clientOptions: OllamaOptions = { - host: this.options.ollamaBaseUrl || "http://localhost:11434", - // Note: The ollama npm package handles timeouts internally - } + const baseUrl = options.ollamaBaseUrl || "http://localhost:11434" - // Add API key if provided (for Ollama cloud or authenticated instances) - if (this.options.ollamaApiKey) { - clientOptions.headers = { - Authorization: `Bearer ${this.options.ollamaApiKey}`, - } - } + this.provider = createOllama({ + baseURL: `${baseUrl}/api`, + headers: options.ollamaApiKey ? { Authorization: `Bearer ${options.ollamaApiKey}` } : undefined, + }) + } - this.client = new Ollama(clientOptions) - } catch (error: any) { - throw new Error(`Error creating Ollama client: ${error.message}`) - } + override getModel(): { id: string; info: ModelInfo } { + const modelId = this.options.ollamaModelId || "" + return { + id: modelId, + info: this.models[modelId] || openAiModelInfoSaneDefaults, } - return this.client } - /** - * Converts OpenAI-format tools to Ollama's native tool format. - * This allows NativeOllamaHandler to use the same tool definitions - * that are passed to OpenAI-compatible providers. - */ - private convertToolsToOllama(tools: OpenAI.Chat.ChatCompletionTool[] | undefined): OllamaTool[] | undefined { - if (!tools || tools.length === 0) { - return undefined - } + async fetchModel() { + this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey) + return this.getModel() + } - return tools - .filter((tool): tool is OpenAI.Chat.ChatCompletionTool & { type: "function" } => tool.type === "function") - .map((tool) => ({ - type: tool.type, - function: { - name: tool.function.name, - description: tool.function.description, - parameters: tool.function.parameters as OllamaTool["function"]["parameters"], - }, - })) + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) } override async *createMessage( @@ -205,174 +63,102 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const client = this.ensureClient() - const { id: modelId } = await this.fetchModel() + await this.fetchModel() + const { id: modelId } = this.getModel() const useR1Format = modelId.toLowerCase().includes("deepseek-r1") + const temperature = this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0) - const ollamaMessages: Message[] = [ - { role: "system", content: systemPrompt }, - ...convertToOllamaMessages(messages), - ] - - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) + const languageModel = this.getLanguageModel() - try { - // Build options object conditionally - const chatOptions: OllamaChatOptions = { - temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), - } - - // Only include num_ctx if explicitly set via ollamaNumCtx - if (this.options.ollamaNumCtx !== undefined) { - chatOptions.num_ctx = this.options.ollamaNumCtx - } - - // Create the actual API request promise - const stream = await client.chat({ - model: modelId, - messages: ollamaMessages, - stream: true, - options: chatOptions, - tools: this.convertToolsToOllama(metadata?.tools), - }) + const aiSdkMessages = convertToAiSdkMessages(messages) - let totalInputTokens = 0 - let totalOutputTokens = 0 - // Track tool calls across chunks (Ollama may send complete tool_calls in final chunk) - let toolCallIndex = 0 - // Track tool call IDs for emitting end events - const toolCallIds: string[] = [] + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - try { - for await (const chunk of stream) { - if (typeof chunk.message.content === "string" && chunk.message.content.length > 0) { - // Process content through matcher for reasoning detection - for (const matcherChunk of matcher.update(chunk.message.content)) { - yield matcherChunk - } - } - - // Handle tool calls - emit partial chunks for NativeToolCallParser compatibility - if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) { - for (const toolCall of chunk.message.tool_calls) { - // Generate a unique ID for this tool call - const toolCallId = `ollama-tool-${toolCallIndex}` - toolCallIds.push(toolCallId) - yield { - type: "tool_call_partial", - index: toolCallIndex, - id: toolCallId, - name: toolCall.function.name, - arguments: JSON.stringify(toolCall.function.arguments), - } - toolCallIndex++ - } - } + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + ...(this.options.ollamaNumCtx !== undefined && { + providerOptions: { ollama: { num_ctx: this.options.ollamaNumCtx } } as any, + }), + } - // Handle token usage if available - if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) { - if (chunk.prompt_eval_count) { - totalInputTokens = chunk.prompt_eval_count - } - if (chunk.eval_count) { - totalOutputTokens = chunk.eval_count - } - } - } + const result = streamText(requestOptions) - // Yield any remaining content from the matcher - for (const chunk of matcher.final()) { + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { yield chunk } - - for (const toolCallId of toolCallIds) { - yield { - type: "tool_call_end", - id: toolCallId, - } - } - - // Yield usage information if available - if (totalInputTokens > 0 || totalOutputTokens > 0) { - yield { - type: "usage", - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - } - } - } catch (streamError: any) { - console.error("Error processing Ollama stream:", streamError) - throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`) } - } catch (error: any) { - // Enhance error reporting - const statusCode = error.status || error.statusCode - const errorMessage = error.message || "Unknown error" - if (error.code === "ECONNREFUSED") { - throw new Error( - `Ollama service is not running at ${this.options.ollamaBaseUrl || "http://localhost:11434"}. Please start Ollama first.`, - ) - } else if (statusCode === 404) { - throw new Error( - `Model ${this.getModel().id} not found in Ollama. Please pull the model first with: ollama pull ${this.getModel().id}`, - ) + const usage = await result.usage + if (usage) { + yield { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + } } - - console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`) - throw error - } - } - - async fetchModel() { - this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey) - return this.getModel() - } - - override getModel(): { id: string; info: ModelInfo } { - const modelId = this.options.ollamaModelId || "" - return { - id: modelId, - info: this.models[modelId] || openAiModelInfoSaneDefaults, + } catch (error) { + this.handleOllamaError(error, modelId) } } async completePrompt(prompt: string): Promise { try { - const client = this.ensureClient() - const { id: modelId } = await this.fetchModel() + await this.fetchModel() + const { id: modelId } = this.getModel() const useR1Format = modelId.toLowerCase().includes("deepseek-r1") + const temperature = this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0) - // Build options object conditionally - const chatOptions: OllamaChatOptions = { - temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), - } - - // Only include num_ctx if explicitly set via ollamaNumCtx - if (this.options.ollamaNumCtx !== undefined) { - chatOptions.num_ctx = this.options.ollamaNumCtx - } + const languageModel = this.getLanguageModel() - const response = await client.chat({ - model: modelId, - messages: [{ role: "user", content: prompt }], - stream: false, - options: chatOptions, + const { text } = await generateText({ + model: languageModel, + prompt, + temperature, + ...(this.options.ollamaNumCtx !== undefined && { + providerOptions: { ollama: { num_ctx: this.options.ollamaNumCtx } } as any, + }), }) - return response.message?.content || "" + return text } catch (error) { - if (error instanceof Error) { - throw new Error(`Ollama completion error: ${error.message}`) - } - throw error + const { id: modelId } = this.getModel() + this.handleOllamaError(error, modelId) + } + } + + /** + * Handle Ollama-specific errors (ECONNREFUSED, 404) with user-friendly messages, + * falling back to the standard AI SDK error handler. + */ + private handleOllamaError(error: unknown, modelId: string): never { + const anyError = error as any + const errorMessage = anyError?.message || "" + const statusCode = anyError?.status || anyError?.statusCode || anyError?.lastError?.status + + if (anyError?.code === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { + throw new Error( + `Ollama service is not running at ${this.options.ollamaBaseUrl || "http://localhost:11434"}. Please start Ollama first.`, + ) } + + if (statusCode === 404 || errorMessage.includes("404")) { + throw new Error( + `Model ${modelId} not found in Ollama. Please pull the model first with: ollama pull ${modelId}`, + ) + } + + throw handleAiSdkError(error, "Ollama") + } + + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/package.json b/src/package.json index 213e864a5da..27c07f8c8a8 100644 --- a/src/package.json +++ b/src/package.json @@ -503,7 +503,7 @@ "monaco-vscode-textmate-theme-converter": "^0.1.7", "node-cache": "^5.1.2", "node-ipc": "^12.0.0", - "ollama": "^0.5.17", + "ollama-ai-provider-v2": "^3.0.4", "openai": "^5.12.2", "os-name": "^6.0.0", "p-limit": "^6.2.0", From 9911c03acdb21172bd30a540fc4b22bf998497f3 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 10 Feb 2026 12:19:24 -0500 Subject: [PATCH 2/2] fix: correct providerOptions schema for ollama-ai-provider-v2 - Nest num_ctx inside options object per ollamaProviderOptions schema - Pass think: true for DeepSeek R1 models to get structured reasoning events - Extract buildProviderOptions() helper for both createMessage and completePrompt - Update test assertions to match corrected schema --- .../providers/__tests__/native-ollama.spec.ts | 10 ++++-- src/api/providers/native-ollama.ts | 35 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index b19599fc0f8..df6f7cb8413 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -137,7 +137,7 @@ describe("NativeOllamaHandler", () => { expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - providerOptions: { ollama: { num_ctx: 8192 } }, + providerOptions: { ollama: { options: { num_ctx: 8192 } } }, }), ) }) @@ -170,6 +170,12 @@ describe("NativeOllamaHandler", () => { expect(results.some((r) => r.type === "reasoning")).toBe(true) expect(results.some((r) => r.type === "text")).toBe(true) + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { ollama: { think: true } }, + }), + ) }) }) @@ -222,7 +228,7 @@ describe("NativeOllamaHandler", () => { expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - providerOptions: { ollama: { num_ctx: 4096 } }, + providerOptions: { ollama: { options: { num_ctx: 4096 } } }, }), ) }) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 6f33943da2f..8a4a618ac2e 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -58,6 +58,29 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return this.provider(id) } + /** + * Build ollama-specific providerOptions based on model settings. + * The ollama-ai-provider-v2 schema expects: + * { ollama: { think?: boolean, options?: { num_ctx?: number, ... } } } + */ + private buildProviderOptions(useR1Format: boolean): Record | undefined { + const ollamaOpts: Record = {} + + if (useR1Format) { + ollamaOpts.think = true + } + + if (this.options.ollamaNumCtx !== undefined) { + ollamaOpts.options = { num_ctx: this.options.ollamaNumCtx } + } + + if (Object.keys(ollamaOpts).length === 0) { + return undefined + } + + return { ollama: ollamaOpts } + } + override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], @@ -75,6 +98,8 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + const providerOptions = this.buildProviderOptions(useR1Format) + const requestOptions: Parameters[0] = { model: languageModel, system: systemPrompt, @@ -82,9 +107,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio temperature, tools: aiSdkTools, toolChoice: mapToolChoice(metadata?.tool_choice), - ...(this.options.ollamaNumCtx !== undefined && { - providerOptions: { ollama: { num_ctx: this.options.ollamaNumCtx } } as any, - }), + ...(providerOptions && { providerOptions }), } const result = streamText(requestOptions) @@ -118,13 +141,13 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const languageModel = this.getLanguageModel() + const providerOptions = this.buildProviderOptions(useR1Format) + const { text } = await generateText({ model: languageModel, prompt, temperature, - ...(this.options.ollamaNumCtx !== undefined && { - providerOptions: { ollama: { num_ctx: this.options.ollamaNumCtx } } as any, - }), + ...(providerOptions && { providerOptions }), }) return text