فهرست منبع

Support tool calling in native ollama provider (#9696)

Co-authored-by: Roo Code <[email protected]>
Matt Rubens 1 ماه پیش
والد
کامیت
be7659461a

+ 1 - 0
packages/types/src/providers/ollama.ts

@@ -8,6 +8,7 @@ export const ollamaDefaultModelInfo: ModelInfo = {
 	contextWindow: 200_000,
 	contextWindow: 200_000,
 	supportsImages: true,
 	supportsImages: true,
 	supportsPromptCache: true,
 	supportsPromptCache: true,
+	supportsNativeTools: true,
 	inputPrice: 0,
 	inputPrice: 0,
 	outputPrice: 0,
 	outputPrice: 0,
 	cacheWritesPrice: 0,
 	cacheWritesPrice: 0,

+ 270 - 8
src/api/providers/__tests__/native-ollama.spec.ts

@@ -2,6 +2,7 @@
 
 
 import { NativeOllamaHandler } from "../native-ollama"
 import { NativeOllamaHandler } from "../native-ollama"
 import { ApiHandlerOptions } from "../../../shared/api"
 import { ApiHandlerOptions } from "../../../shared/api"
+import { getOllamaModels } from "../fetchers/ollama"
 
 
 // Mock the ollama package
 // Mock the ollama package
 const mockChat = vitest.fn()
 const mockChat = vitest.fn()
@@ -16,22 +17,27 @@ vitest.mock("ollama", () => {
 
 
 // Mock the getOllamaModels function
 // Mock the getOllamaModels function
 vitest.mock("../fetchers/ollama", () => ({
 vitest.mock("../fetchers/ollama", () => ({
-	getOllamaModels: vitest.fn().mockResolvedValue({
-		llama2: {
-			contextWindow: 4096,
-			maxTokens: 4096,
-			supportsImages: false,
-			supportsPromptCache: false,
-		},
-	}),
+	getOllamaModels: vitest.fn(),
 }))
 }))
 
 
+const mockGetOllamaModels = vitest.mocked(getOllamaModels)
+
 describe("NativeOllamaHandler", () => {
 describe("NativeOllamaHandler", () => {
 	let handler: NativeOllamaHandler
 	let handler: NativeOllamaHandler
 
 
 	beforeEach(() => {
 	beforeEach(() => {
 		vitest.clearAllMocks()
 		vitest.clearAllMocks()
 
 
+		// Default mock for getOllamaModels
+		mockGetOllamaModels.mockResolvedValue({
+			llama2: {
+				contextWindow: 4096,
+				maxTokens: 4096,
+				supportsImages: false,
+				supportsPromptCache: false,
+			},
+		})
+
 		const options: ApiHandlerOptions = {
 		const options: ApiHandlerOptions = {
 			apiModelId: "llama2",
 			apiModelId: "llama2",
 			ollamaModelId: "llama2",
 			ollamaModelId: "llama2",
@@ -257,4 +263,260 @@ describe("NativeOllamaHandler", () => {
 			expect(model.info).toBeDefined()
 			expect(model.info).toBeDefined()
 		})
 		})
 	})
 	})
+
+	describe("tool calling", () => {
+		it("should include tools when model supports native tools", async () => {
+			// Mock model with native tool support
+			mockGetOllamaModels.mockResolvedValue({
+				"llama3.2": {
+					contextWindow: 128000,
+					maxTokens: 4096,
+					supportsImages: true,
+					supportsPromptCache: false,
+					supportsNativeTools: true,
+				},
+			})
+
+			const options: ApiHandlerOptions = {
+				apiModelId: "llama3.2",
+				ollamaModelId: "llama3.2",
+				ollamaBaseUrl: "http://localhost:11434",
+			}
+
+			handler = new NativeOllamaHandler(options)
+
+			// Mock the chat response
+			mockChat.mockImplementation(async function* () {
+				yield { message: { content: "I will use the tool" } }
+			})
+
+			const tools = [
+				{
+					type: "function" as const,
+					function: {
+						name: "get_weather",
+						description: "Get the weather for a location",
+						parameters: {
+							type: "object",
+							properties: {
+								location: { type: "string", description: "The city name" },
+							},
+							required: ["location"],
+						},
+					},
+				},
+			]
+
+			const stream = handler.createMessage(
+				"System",
+				[{ role: "user" as const, content: "What's the weather?" }],
+				{ taskId: "test", tools },
+			)
+
+			// Consume the stream
+			for await (const _ of stream) {
+				// consume stream
+			}
+
+			// Verify tools were passed to the API
+			expect(mockChat).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"],
+								},
+							},
+						},
+					],
+				}),
+			)
+		})
+
+		it("should not include tools when model does not support native tools", async () => {
+			// Mock model without native tool support
+			mockGetOllamaModels.mockResolvedValue({
+				llama2: {
+					contextWindow: 4096,
+					maxTokens: 4096,
+					supportsImages: false,
+					supportsPromptCache: false,
+					supportsNativeTools: false,
+				},
+			})
+
+			// Mock the chat response
+			mockChat.mockImplementation(async function* () {
+				yield { message: { content: "Response without tools" } }
+			})
+
+			const tools = [
+				{
+					type: "function" as const,
+					function: {
+						name: "get_weather",
+						description: "Get the weather",
+						parameters: { type: "object", properties: {} },
+					},
+				},
+			]
+
+			const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }], {
+				taskId: "test",
+				tools,
+			})
+
+			// Consume the stream
+			for await (const _ of stream) {
+				// consume stream
+			}
+
+			// Verify tools were NOT passed
+			expect(mockChat).toHaveBeenCalledWith(
+				expect.not.objectContaining({
+					tools: expect.anything(),
+				}),
+			)
+		})
+
+		it("should not include tools when toolProtocol is xml", async () => {
+			// Mock model with native tool support
+			mockGetOllamaModels.mockResolvedValue({
+				"llama3.2": {
+					contextWindow: 128000,
+					maxTokens: 4096,
+					supportsImages: true,
+					supportsPromptCache: false,
+					supportsNativeTools: true,
+				},
+			})
+
+			const options: ApiHandlerOptions = {
+				apiModelId: "llama3.2",
+				ollamaModelId: "llama3.2",
+				ollamaBaseUrl: "http://localhost:11434",
+			}
+
+			handler = new NativeOllamaHandler(options)
+
+			// Mock the chat response
+			mockChat.mockImplementation(async function* () {
+				yield { message: { content: "Response" } }
+			})
+
+			const tools = [
+				{
+					type: "function" as const,
+					function: {
+						name: "get_weather",
+						description: "Get the weather",
+						parameters: { type: "object", properties: {} },
+					},
+				},
+			]
+
+			const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }], {
+				taskId: "test",
+				tools,
+				toolProtocol: "xml",
+			})
+
+			// Consume the stream
+			for await (const _ of stream) {
+				// consume stream
+			}
+
+			// Verify tools were NOT passed (XML protocol forces XML format)
+			expect(mockChat).toHaveBeenCalledWith(
+				expect.not.objectContaining({
+					tools: expect.anything(),
+				}),
+			)
+		})
+
+		it("should yield tool_call_partial when model returns tool calls", async () => {
+			// Mock model with native tool support
+			mockGetOllamaModels.mockResolvedValue({
+				"llama3.2": {
+					contextWindow: 128000,
+					maxTokens: 4096,
+					supportsImages: true,
+					supportsPromptCache: false,
+					supportsNativeTools: true,
+				},
+			})
+
+			const options: ApiHandlerOptions = {
+				apiModelId: "llama3.2",
+				ollamaModelId: "llama3.2",
+				ollamaBaseUrl: "http://localhost:11434",
+			}
+
+			handler = new NativeOllamaHandler(options)
+
+			// Mock the chat response with tool calls
+			mockChat.mockImplementation(async function* () {
+				yield {
+					message: {
+						content: "",
+						tool_calls: [
+							{
+								function: {
+									name: "get_weather",
+									arguments: { location: "San Francisco" },
+								},
+							},
+						],
+					},
+				}
+			})
+
+			const tools = [
+				{
+					type: "function" as const,
+					function: {
+						name: "get_weather",
+						description: "Get the weather for a location",
+						parameters: {
+							type: "object",
+							properties: {
+								location: { type: "string" },
+							},
+							required: ["location"],
+						},
+					},
+				},
+			]
+
+			const stream = handler.createMessage(
+				"System",
+				[{ role: "user" as const, content: "What's the weather in SF?" }],
+				{ taskId: "test", tools },
+			)
+
+			const results = []
+			for await (const chunk of stream) {
+				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",
+				name: "get_weather",
+				arguments: JSON.stringify({ location: "San Francisco" }),
+			})
+		})
+	})
 })
 })

+ 0 - 108
src/api/providers/__tests__/ollama-timeout.spec.ts

@@ -1,108 +0,0 @@
-// npx vitest run api/providers/__tests__/ollama-timeout.spec.ts
-
-import { OllamaHandler } from "../ollama"
-import { ApiHandlerOptions } from "../../../shared/api"
-
-// Mock the timeout config utility
-vitest.mock("../utils/timeout-config", () => ({
-	getApiRequestTimeout: vitest.fn(),
-}))
-
-import { getApiRequestTimeout } from "../utils/timeout-config"
-
-// Mock OpenAI
-const mockOpenAIConstructor = vitest.fn()
-vitest.mock("openai", () => {
-	return {
-		__esModule: true,
-		default: vitest.fn().mockImplementation((config) => {
-			mockOpenAIConstructor(config)
-			return {
-				chat: {
-					completions: {
-						create: vitest.fn(),
-					},
-				},
-			}
-		}),
-	}
-})
-
-describe("OllamaHandler timeout configuration", () => {
-	beforeEach(() => {
-		vitest.clearAllMocks()
-	})
-
-	it("should use default timeout of 600 seconds when no configuration is set", () => {
-		;(getApiRequestTimeout as any).mockReturnValue(600000)
-
-		const options: ApiHandlerOptions = {
-			apiModelId: "llama2",
-			ollamaModelId: "llama2",
-			ollamaBaseUrl: "http://localhost:11434",
-		}
-
-		new OllamaHandler(options)
-
-		expect(getApiRequestTimeout).toHaveBeenCalled()
-		expect(mockOpenAIConstructor).toHaveBeenCalledWith(
-			expect.objectContaining({
-				baseURL: "http://localhost:11434/v1",
-				apiKey: "ollama",
-				timeout: 600000, // 600 seconds in milliseconds
-			}),
-		)
-	})
-
-	it("should use custom timeout when configuration is set", () => {
-		;(getApiRequestTimeout as any).mockReturnValue(3600000) // 1 hour
-
-		const options: ApiHandlerOptions = {
-			apiModelId: "llama2",
-			ollamaModelId: "llama2",
-		}
-
-		new OllamaHandler(options)
-
-		expect(mockOpenAIConstructor).toHaveBeenCalledWith(
-			expect.objectContaining({
-				timeout: 3600000, // 3600 seconds in milliseconds
-			}),
-		)
-	})
-
-	it("should handle zero timeout (no timeout)", () => {
-		;(getApiRequestTimeout as any).mockReturnValue(0)
-
-		const options: ApiHandlerOptions = {
-			apiModelId: "llama2",
-			ollamaModelId: "llama2",
-			ollamaBaseUrl: "http://localhost:11434",
-		}
-
-		new OllamaHandler(options)
-
-		expect(mockOpenAIConstructor).toHaveBeenCalledWith(
-			expect.objectContaining({
-				timeout: 0, // No timeout
-			}),
-		)
-	})
-
-	it("should use default base URL when not provided", () => {
-		;(getApiRequestTimeout as any).mockReturnValue(600000)
-
-		const options: ApiHandlerOptions = {
-			apiModelId: "llama2",
-			ollamaModelId: "llama2",
-		}
-
-		new OllamaHandler(options)
-
-		expect(mockOpenAIConstructor).toHaveBeenCalledWith(
-			expect.objectContaining({
-				baseURL: "http://localhost:11434/v1",
-			}),
-		)
-	})
-})

+ 0 - 178
src/api/providers/__tests__/ollama.spec.ts

@@ -1,178 +0,0 @@
-// npx vitest run api/providers/__tests__/ollama.spec.ts
-
-import { Anthropic } from "@anthropic-ai/sdk"
-
-import { OllamaHandler } from "../ollama"
-import { ApiHandlerOptions } from "../../../shared/api"
-
-const mockCreate = vitest.fn()
-
-vitest.mock("openai", () => {
-	return {
-		__esModule: true,
-		default: vitest.fn().mockImplementation(() => ({
-			chat: {
-				completions: {
-					create: mockCreate.mockImplementation(async (options) => {
-						if (!options.stream) {
-							return {
-								id: "test-completion",
-								choices: [
-									{
-										message: { role: "assistant", content: "Test response" },
-										finish_reason: "stop",
-										index: 0,
-									},
-								],
-								usage: {
-									prompt_tokens: 10,
-									completion_tokens: 5,
-									total_tokens: 15,
-								},
-							}
-						}
-
-						return {
-							[Symbol.asyncIterator]: async function* () {
-								yield {
-									choices: [
-										{
-											delta: { content: "Test response" },
-											index: 0,
-										},
-									],
-									usage: null,
-								}
-								yield {
-									choices: [
-										{
-											delta: {},
-											index: 0,
-										},
-									],
-									usage: {
-										prompt_tokens: 10,
-										completion_tokens: 5,
-										total_tokens: 15,
-									},
-								}
-							},
-						}
-					}),
-				},
-			},
-		})),
-	}
-})
-
-describe("OllamaHandler", () => {
-	let handler: OllamaHandler
-	let mockOptions: ApiHandlerOptions
-
-	beforeEach(() => {
-		mockOptions = {
-			apiModelId: "llama2",
-			ollamaModelId: "llama2",
-			ollamaBaseUrl: "http://localhost:11434/v1",
-		}
-		handler = new OllamaHandler(mockOptions)
-		mockCreate.mockClear()
-	})
-
-	describe("constructor", () => {
-		it("should initialize with provided options", () => {
-			expect(handler).toBeInstanceOf(OllamaHandler)
-			expect(handler.getModel().id).toBe(mockOptions.ollamaModelId)
-		})
-
-		it("should use default base URL if not provided", () => {
-			const handlerWithoutUrl = new OllamaHandler({
-				apiModelId: "llama2",
-				ollamaModelId: "llama2",
-			})
-			expect(handlerWithoutUrl).toBeInstanceOf(OllamaHandler)
-		})
-
-		it("should use API key when provided", () => {
-			const handlerWithApiKey = new OllamaHandler({
-				apiModelId: "llama2",
-				ollamaModelId: "llama2",
-				ollamaBaseUrl: "https://ollama.com",
-				ollamaApiKey: "test-api-key",
-			})
-			expect(handlerWithApiKey).toBeInstanceOf(OllamaHandler)
-			// The API key will be used in the Authorization header
-		})
-	})
-
-	describe("createMessage", () => {
-		const systemPrompt = "You are a helpful assistant."
-		const messages: Anthropic.Messages.MessageParam[] = [
-			{
-				role: "user",
-				content: "Hello!",
-			},
-		]
-
-		it("should handle streaming responses", async () => {
-			const stream = handler.createMessage(systemPrompt, messages)
-			const chunks: any[] = []
-			for await (const chunk of stream) {
-				chunks.push(chunk)
-			}
-
-			expect(chunks.length).toBeGreaterThan(0)
-			const textChunks = chunks.filter((chunk) => chunk.type === "text")
-			expect(textChunks).toHaveLength(1)
-			expect(textChunks[0].text).toBe("Test response")
-		})
-
-		it("should handle API errors", async () => {
-			mockCreate.mockRejectedValueOnce(new Error("API Error"))
-
-			const stream = handler.createMessage(systemPrompt, messages)
-
-			await expect(async () => {
-				for await (const _chunk of stream) {
-					// Should not reach here
-				}
-			}).rejects.toThrow("API Error")
-		})
-	})
-
-	describe("completePrompt", () => {
-		it("should complete prompt successfully", async () => {
-			const result = await handler.completePrompt("Test prompt")
-			expect(result).toBe("Test response")
-			expect(mockCreate).toHaveBeenCalledWith({
-				model: mockOptions.ollamaModelId,
-				messages: [{ role: "user", content: "Test prompt" }],
-				temperature: 0,
-				stream: false,
-			})
-		})
-
-		it("should handle API errors", async () => {
-			mockCreate.mockRejectedValueOnce(new Error("API Error"))
-			await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Ollama completion error: API Error")
-		})
-
-		it("should handle empty response", async () => {
-			mockCreate.mockResolvedValueOnce({
-				choices: [{ message: { content: "" } }],
-			})
-			const result = await handler.completePrompt("Test prompt")
-			expect(result).toBe("")
-		})
-	})
-
-	describe("getModel", () => {
-		it("should return model info", () => {
-			const modelInfo = handler.getModel()
-			expect(modelInfo.id).toBe(mockOptions.ollamaModelId)
-			expect(modelInfo.info).toBeDefined()
-			expect(modelInfo.info.maxTokens).toBe(-1)
-			expect(modelInfo.info.contextWindow).toBe(128_000)
-		})
-	})
-})

+ 2 - 0
src/api/providers/fetchers/__tests__/ollama.test.ts

@@ -22,6 +22,7 @@ describe("Ollama Fetcher", () => {
 				contextWindow: 40960,
 				contextWindow: 40960,
 				supportsImages: false,
 				supportsImages: false,
 				supportsPromptCache: true,
 				supportsPromptCache: true,
+				supportsNativeTools: true,
 				inputPrice: 0,
 				inputPrice: 0,
 				outputPrice: 0,
 				outputPrice: 0,
 				cacheWritesPrice: 0,
 				cacheWritesPrice: 0,
@@ -46,6 +47,7 @@ describe("Ollama Fetcher", () => {
 				contextWindow: 40960,
 				contextWindow: 40960,
 				supportsImages: false,
 				supportsImages: false,
 				supportsPromptCache: true,
 				supportsPromptCache: true,
+				supportsNativeTools: true,
 				inputPrice: 0,
 				inputPrice: 0,
 				outputPrice: 0,
 				outputPrice: 0,
 				cacheWritesPrice: 0,
 				cacheWritesPrice: 0,

+ 0 - 1
src/api/providers/index.ts

@@ -17,7 +17,6 @@ export { IOIntelligenceHandler } from "./io-intelligence"
 export { LiteLLMHandler } from "./lite-llm"
 export { LiteLLMHandler } from "./lite-llm"
 export { LmStudioHandler } from "./lm-studio"
 export { LmStudioHandler } from "./lm-studio"
 export { MistralHandler } from "./mistral"
 export { MistralHandler } from "./mistral"
-export { OllamaHandler } from "./ollama"
 export { OpenAiNativeHandler } from "./openai-native"
 export { OpenAiNativeHandler } from "./openai-native"
 export { OpenAiHandler } from "./openai"
 export { OpenAiHandler } from "./openai"
 export { OpenRouterHandler } from "./openrouter"
 export { OpenRouterHandler } from "./openrouter"

+ 63 - 3
src/api/providers/native-ollama.ts

@@ -1,5 +1,6 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import { Anthropic } from "@anthropic-ai/sdk"
-import { Message, Ollama, type Config as OllamaOptions } from "ollama"
+import OpenAI from "openai"
+import { Message, Ollama, Tool as OllamaTool, type Config as OllamaOptions } from "ollama"
 import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
 import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
 import { ApiStream } from "../transform/stream"
 import { ApiStream } from "../transform/stream"
 import { BaseProvider } from "./base-provider"
 import { BaseProvider } from "./base-provider"
@@ -93,7 +94,7 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa
 					})
 					})
 				}
 				}
 			} else if (anthropicMessage.role === "assistant") {
 			} else if (anthropicMessage.role === "assistant") {
-				const { nonToolMessages } = anthropicMessage.content.reduce<{
+				const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
 					nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
 					nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
 					toolMessages: Anthropic.ToolUseBlockParam[]
 					toolMessages: Anthropic.ToolUseBlockParam[]
 				}>(
 				}>(
@@ -121,9 +122,21 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa
 						.join("\n")
 						.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({
 				ollamaMessages.push({
 					role: "assistant",
 					role: "assistant",
 					content,
 					content,
+					tool_calls: toolCalls,
 				})
 				})
 			}
 			}
 		}
 		}
@@ -165,6 +178,28 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
 		return this.client
 		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
+		}
+
+		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"],
+				},
+			}))
+	}
+
 	override async *createMessage(
 	override async *createMessage(
 		systemPrompt: string,
 		systemPrompt: string,
 		messages: Anthropic.Messages.MessageParam[],
 		messages: Anthropic.Messages.MessageParam[],
@@ -188,6 +223,11 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
 				}) as const,
 				}) as const,
 		)
 		)
 
 
+		// Check if we should use native tool calling
+		const supportsNativeTools = modelInfo.supportsNativeTools ?? false
+		const useNativeTools =
+			supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml"
+
 		try {
 		try {
 			// Build options object conditionally
 			// Build options object conditionally
 			const chatOptions: OllamaChatOptions = {
 			const chatOptions: OllamaChatOptions = {
@@ -205,20 +245,40 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
 				messages: ollamaMessages,
 				messages: ollamaMessages,
 				stream: true,
 				stream: true,
 				options: chatOptions,
 				options: chatOptions,
+				// Native tool calling support
+				...(useNativeTools && { tools: this.convertToolsToOllama(metadata.tools) }),
 			})
 			})
 
 
 			let totalInputTokens = 0
 			let totalInputTokens = 0
 			let totalOutputTokens = 0
 			let totalOutputTokens = 0
+			// Track tool calls across chunks (Ollama may send complete tool_calls in final chunk)
+			let toolCallIndex = 0
 
 
 			try {
 			try {
 				for await (const chunk of stream) {
 				for await (const chunk of stream) {
-					if (typeof chunk.message.content === "string") {
+					if (typeof chunk.message.content === "string" && chunk.message.content.length > 0) {
 						// Process content through matcher for reasoning detection
 						// Process content through matcher for reasoning detection
 						for (const matcherChunk of matcher.update(chunk.message.content)) {
 						for (const matcherChunk of matcher.update(chunk.message.content)) {
 							yield matcherChunk
 							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}`
+							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
 					// Handle token usage if available
 					if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
 					if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
 						if (chunk.prompt_eval_count) {
 						if (chunk.prompt_eval_count) {

+ 0 - 137
src/api/providers/ollama.ts

@@ -1,137 +0,0 @@
-import { Anthropic } from "@anthropic-ai/sdk"
-import OpenAI from "openai"
-
-import { type ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
-
-import type { ApiHandlerOptions } from "../../shared/api"
-
-import { XmlMatcher } from "../../utils/xml-matcher"
-
-import { convertToOpenAiMessages } from "../transform/openai-format"
-import { convertToR1Format } from "../transform/r1-format"
-import { ApiStream } from "../transform/stream"
-
-import { BaseProvider } from "./base-provider"
-import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
-import { getApiRequestTimeout } from "./utils/timeout-config"
-import { handleOpenAIError } from "./utils/openai-error-handler"
-
-type CompletionUsage = OpenAI.Chat.Completions.ChatCompletionChunk["usage"]
-
-export class OllamaHandler extends BaseProvider implements SingleCompletionHandler {
-	protected options: ApiHandlerOptions
-	private client: OpenAI
-	private readonly providerName = "Ollama"
-
-	constructor(options: ApiHandlerOptions) {
-		super()
-		this.options = options
-
-		// Use the API key if provided (for Ollama cloud or authenticated instances)
-		// Otherwise use "ollama" as a placeholder for local instances
-		const apiKey = this.options.ollamaApiKey || "ollama"
-
-		const headers: Record<string, string> = {}
-		if (this.options.ollamaApiKey) {
-			headers["Authorization"] = `Bearer ${this.options.ollamaApiKey}`
-		}
-
-		this.client = new OpenAI({
-			baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1",
-			apiKey: apiKey,
-			timeout: getApiRequestTimeout(),
-			defaultHeaders: headers,
-		})
-	}
-
-	override async *createMessage(
-		systemPrompt: string,
-		messages: Anthropic.Messages.MessageParam[],
-		metadata?: ApiHandlerCreateMessageMetadata,
-	): ApiStream {
-		const modelId = this.getModel().id
-		const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
-		const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
-			{ role: "system", content: systemPrompt },
-			...(useR1Format ? convertToR1Format(messages) : convertToOpenAiMessages(messages)),
-		]
-
-		let stream
-		try {
-			stream = await this.client.chat.completions.create({
-				model: this.getModel().id,
-				messages: openAiMessages,
-				temperature: this.options.modelTemperature ?? 0,
-				stream: true,
-				stream_options: { include_usage: true },
-			})
-		} catch (error) {
-			throw handleOpenAIError(error, this.providerName)
-		}
-		const matcher = new XmlMatcher(
-			"think",
-			(chunk) =>
-				({
-					type: chunk.matched ? "reasoning" : "text",
-					text: chunk.data,
-				}) as const,
-		)
-		let lastUsage: CompletionUsage | undefined
-		for await (const chunk of stream) {
-			const delta = chunk.choices[0]?.delta
-
-			if (delta?.content) {
-				for (const matcherChunk of matcher.update(delta.content)) {
-					yield matcherChunk
-				}
-			}
-			if (chunk.usage) {
-				lastUsage = chunk.usage
-			}
-		}
-		for (const chunk of matcher.final()) {
-			yield chunk
-		}
-
-		if (lastUsage) {
-			yield {
-				type: "usage",
-				inputTokens: lastUsage?.prompt_tokens || 0,
-				outputTokens: lastUsage?.completion_tokens || 0,
-			}
-		}
-	}
-
-	override getModel(): { id: string; info: ModelInfo } {
-		return {
-			id: this.options.ollamaModelId || "",
-			info: openAiModelInfoSaneDefaults,
-		}
-	}
-
-	async completePrompt(prompt: string): Promise<string> {
-		try {
-			const modelId = this.getModel().id
-			const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
-			let response
-			try {
-				response = await this.client.chat.completions.create({
-					model: this.getModel().id,
-					messages: useR1Format
-						? convertToR1Format([{ role: "user", content: prompt }])
-						: [{ role: "user", content: prompt }],
-					temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
-					stream: false,
-				})
-			} catch (error) {
-				throw handleOpenAIError(error, this.providerName)
-			}
-			return response.choices[0]?.message.content || ""
-		} catch (error) {
-			if (error instanceof Error) {
-				throw new Error(`Ollama completion error: ${error.message}`)
-			}
-			throw error
-		}
-	}
-}