Browse Source

refactor: migrate NativeOllamaHandler to AI SDK (#11355)

Daniel 1 day ago
parent
commit
cdd1a4dabf
4 changed files with 372 additions and 544 deletions
  1. 41 36
      pnpm-lock.yaml
  2. 203 189
      src/api/providers/__tests__/native-ollama.spec.ts
  3. 127 318
      src/api/providers/native-ollama.ts
  4. 1 1
      src/package.json

+ 41 - 36
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([email protected]([email protected]))([email protected])
       openai:
         specifier: ^5.12.2
         version: 5.12.2([email protected])([email protected])
@@ -8565,8 +8565,12 @@ packages:
     resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
     engines: {node: '>= 0.4'}
 
-  [email protected]:
-    resolution: {integrity: sha512-q5LmPtk6GLFouS+3aURIVl+qcAOPC4+Msmx7uBb3pd+fxI55WnGjmLZ0yijI/CYy79x0QPGx3BwC3u5zv9fBvQ==}
+  [email protected]:
+    resolution: {integrity: sha512-gtvqJcw1z9O/uYXgXdNAbm8roTJekJlZs5UMU62iElrsz/osUOGSuQxnSb5fU1SDPDWlZJ2xKotrAZU7+p2kqg==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      ai: ^5.0.0 || ^6.0.0
+      zod: 3.25.76
 
   [email protected]:
     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
 
-  [email protected]:
-    resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
-
   [email protected]:
     resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
     engines: {node: '>=18'}
@@ -12581,7 +12582,7 @@ snapshots:
 
   '@kwsites/[email protected]':
     dependencies:
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
     transitivePeerDependencies:
       - supports-color
 
@@ -13044,7 +13045,7 @@ snapshots:
 
   '@puppeteer/[email protected]':
     dependencies:
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
       extract-zip: 2.0.1
       progress: 2.0.3
       proxy-agent: 6.5.0
@@ -13057,7 +13058,7 @@ snapshots:
 
   '@puppeteer/[email protected]':
     dependencies:
-      debug: 4.4.1([email protected])
+      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([email protected])
+      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
 
+  [email protected]:
+    dependencies:
+      ms: 2.1.3
+
   [email protected]([email protected]):
     dependencies:
       ms: 2.1.3
@@ -16254,8 +16259,7 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected]:
-    optional: true
+  [email protected]: {}
 
   [email protected]: {}
 
@@ -16920,7 +16924,7 @@ snapshots:
       content-type: 1.0.5
       cookie: 0.7.2
       cookie-signature: 1.2.2
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
       encodeurl: 2.0.0
       escape-html: 1.0.3
       etag: 1.8.1
@@ -16956,7 +16960,7 @@ snapshots:
 
   [email protected]:
     dependencies:
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
       get-stream: 5.2.0
       yauzl: 2.10.0
     optionalDependencies:
@@ -17057,7 +17061,7 @@ snapshots:
 
   [email protected]:
     dependencies:
-      debug: 4.4.1([email protected])
+      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([email protected])
+      debug: 4.4.1
     transitivePeerDependencies:
       - supports-color
 
@@ -17580,14 +17584,14 @@ snapshots:
   [email protected]:
     dependencies:
       agent-base: 7.1.3
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
     transitivePeerDependencies:
       - supports-color
 
   [email protected]:
     dependencies:
       agent-base: 7.1.3
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
     transitivePeerDependencies:
       - supports-color
 
@@ -18313,7 +18317,7 @@ snapshots:
 
   [email protected]:
     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
 
-  [email protected].17:
+  ollama-ai-provider-v2@3.0.4([email protected]([email protected]5.76))([email protected]):
     dependencies:
-      whatwg-fetch: 3.6.20
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.14([email protected])
+      ai: 6.0.77([email protected])
+      zod: 3.25.76
 
   [email protected]:
     dependencies:
@@ -19462,7 +19469,7 @@ snapshots:
     dependencies:
       '@tootallnate/quickjs-emscripten': 0.23.0
       agent-base: 7.1.3
-      debug: 4.4.1([email protected])
+      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:
   [email protected]:
     dependencies:
       agent-base: 7.1.3
-      debug: 4.4.1([email protected])
+      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([email protected])
-      debug: 4.4.1([email protected])
+      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([email protected])
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
       devtools-protocol: 0.0.1452169
       typed-query-selector: 2.12.0
       ws: 8.18.2
@@ -20390,7 +20397,7 @@ snapshots:
 
   [email protected]:
     dependencies:
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
       depd: 2.0.0
       is-promise: 4.0.0
       parseurl: 1.3.3
@@ -20507,7 +20514,7 @@ snapshots:
 
   [email protected]:
     dependencies:
-      debug: 4.4.1([email protected])
+      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([email protected])
+      debug: 4.4.1
     transitivePeerDependencies:
       - supports-color
 
@@ -20728,7 +20735,7 @@ snapshots:
   [email protected]:
     dependencies:
       agent-base: 7.1.3
-      debug: 4.4.1([email protected])
+      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([email protected])
+      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:
   [email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
     dependencies:
       cac: 6.7.14
-      debug: 4.4.1([email protected])
+      debug: 4.4.1
       es-module-lexer: 1.7.0
       pathe: 2.0.3
       vite: 6.3.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
@@ -21806,7 +21813,7 @@ snapshots:
       '@vitest/spy': 3.2.4
       '@vitest/utils': 3.2.4
       chai: 5.2.0
-      debug: 4.4.1([email protected])
+      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
 
-  [email protected]: {}
-
   [email protected]: {}
 
   [email protected]:

+ 203 - 189
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<typeof import("ai")>()
 	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: { options: { 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: "<think>Let me think" } }
-				yield { message: { content: " about this</think>" } }
-				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,70 +168,67 @@ 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)
+
+			expect(mockStreamText).toHaveBeenCalledWith(
+				expect.objectContaining({
+					providerOptions: { ollama: { think: true } },
+				}),
+			)
 		})
 	})
 
 	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: { options: { num_ctx: 4096 } } },
 				}),
 			)
 		})
@@ -230,7 +238,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 +262,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 +292,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 +317,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 +349,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 +370,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 +395,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 +424,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 +466,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 +516,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 +553,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 +605,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)
 		})
 	})
 })

+ 127 - 318
src/api/providers/native-ollama.ts

@@ -1,203 +1,84 @@
 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<string, unknown>,
-								},
-							}))
-						: 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<typeof createOllama>
 	protected models: Record<string, ModelInfo> = {}
 
 	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
+	}
+
+	async fetchModel() {
+		this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey)
+		return this.getModel()
+	}
+
+	protected getLanguageModel() {
+		const { id } = this.getModel()
+		return this.provider(id)
 	}
 
 	/**
-	 * 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.
+	 * Build ollama-specific providerOptions based on model settings.
+	 * The ollama-ai-provider-v2 schema expects:
+	 *   { ollama: { think?: boolean, options?: { num_ctx?: number, ... } } }
 	 */
-	private convertToolsToOllama(tools: OpenAI.Chat.ChatCompletionTool[] | undefined): OllamaTool[] | undefined {
-		if (!tools || tools.length === 0) {
+	private buildProviderOptions(useR1Format: boolean): Record<string, any> | undefined {
+		const ollamaOpts: Record<string, any> = {}
+
+		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 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"],
-				},
-			}))
+		return { ollama: ollamaOpts }
 	}
 
 	override async *createMessage(
@@ -205,174 +86,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 languageModel = this.getLanguageModel()
 
-		const matcher = new TagMatcher(
-			"think",
-			(chunk) =>
-				({
-					type: chunk.matched ? "reasoning" : "text",
-					text: chunk.data,
-				}) as const,
-		)
+		const aiSdkMessages = convertToAiSdkMessages(messages)
 
-		try {
-			// Build options object conditionally
-			const chatOptions: OllamaChatOptions = {
-				temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
-			}
+		const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
+		const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
 
-			// Only include num_ctx if explicitly set via ollamaNumCtx
-			if (this.options.ollamaNumCtx !== undefined) {
-				chatOptions.num_ctx = this.options.ollamaNumCtx
-			}
+		const providerOptions = this.buildProviderOptions(useR1Format)
 
-			// 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 requestOptions: Parameters<typeof streamText>[0] = {
+			model: languageModel,
+			system: systemPrompt,
+			messages: aiSdkMessages,
+			temperature,
+			tools: aiSdkTools,
+			toolChoice: mapToolChoice(metadata?.tool_choice),
+			...(providerOptions && { providerOptions }),
+		}
 
-			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[] = []
-
-			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++
-						}
-					}
-
-					// 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,
-					}
+			const usage = await result.usage
+			if (usage) {
+				yield {
+					type: "usage",
+					inputTokens: usage.inputTokens || 0,
+					outputTokens: usage.outputTokens || 0,
 				}
-			} 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}`,
-				)
 			}
-
-			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<string> {
 		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),
-			}
+			const languageModel = this.getLanguageModel()
 
-			// Only include num_ctx if explicitly set via ollamaNumCtx
-			if (this.options.ollamaNumCtx !== undefined) {
-				chatOptions.num_ctx = this.options.ollamaNumCtx
-			}
+			const providerOptions = this.buildProviderOptions(useR1Format)
 
-			const response = await client.chat({
-				model: modelId,
-				messages: [{ role: "user", content: prompt }],
-				stream: false,
-				options: chatOptions,
+			const { text } = await generateText({
+				model: languageModel,
+				prompt,
+				temperature,
+				...(providerOptions && { providerOptions }),
 			})
 
-			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
+	}
 }

+ 1 - 1
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",