diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index b26569b28bb..709c9da089d 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -518,5 +518,109 @@ describe("NativeOllamaHandler", () => { arguments: JSON.stringify({ location: "San Francisco" }), }) }) + + it("should yield tool_call_end events after tool_call_partial chunks", 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 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" }, + }, + }, + ], + }, + } + }) + + 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"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "get_time", + description: "Get the current time in a timezone", + parameters: { + type: "object", + properties: { timezone: { type: "string" } }, + required: ["timezone"], + }, + }, + }, + ] + + const stream = handler.createMessage( + "System", + [{ role: "user" as const, content: "What's the weather and time in SF?" }], + { taskId: "test", tools }, + ) + + const results = [] + for await (const chunk of stream) { + results.push(chunk) + } + + // Should yield tool_call_partial chunks + const toolCallPartials = results.filter((r) => r.type === "tool_call_partial") + expect(toolCallPartials).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 + } + } + const firstEndIndex = results.findIndex((r) => r.type === "tool_call_end") + expect(firstEndIndex).toBeGreaterThan(lastPartialIndex) + }) }) }) diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index 23132c9d177..fd4e2e80b86 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -55,10 +55,71 @@ describe("Ollama Fetcher", () => { description: "Family: qwen3, Context: 40960, Size: 32.8B", }) }) + + it("should return null when capabilities does not include 'tools'", () => { + const modelDataWithoutTools = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: ["completion"], // No "tools" capability + } + + const parsedModel = parseOllamaModel(modelDataWithoutTools as any) + + // Models without tools capability are filtered out (return null) + expect(parsedModel).toBeNull() + }) + + it("should return model info when capabilities includes 'tools'", () => { + const modelDataWithTools = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: ["completion", "tools"], // Has "tools" capability + } + + const parsedModel = parseOllamaModel(modelDataWithTools as any) + + expect(parsedModel).not.toBeNull() + expect(parsedModel!.supportsNativeTools).toBe(true) + }) + + it("should return null when capabilities is undefined (no tool support)", () => { + const modelDataWithoutCapabilities = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: undefined, // No capabilities array + } + + const parsedModel = parseOllamaModel(modelDataWithoutCapabilities as any) + + // Models without explicit tools capability are filtered out + expect(parsedModel).toBeNull() + }) + + it("should return null when model has vision but no tools capability", () => { + const modelDataWithVision = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: ["completion", "vision"], + } + + const parsedModel = parseOllamaModel(modelDataWithVision as any) + + // No "tools" capability means filtered out + expect(parsedModel).toBeNull() + }) + + it("should return model with both vision and tools when both capabilities present", () => { + const modelDataWithBoth = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: ["completion", "vision", "tools"], + } + + const parsedModel = parseOllamaModel(modelDataWithBoth as any) + + expect(parsedModel).not.toBeNull() + expect(parsedModel!.supportsImages).toBe(true) + expect(parsedModel!.supportsNativeTools).toBe(true) + }) }) describe("getOllamaModels", () => { - it("should fetch model list from /api/tags and details for each model from /api/show", async () => { + it("should fetch model list from /api/tags and include models with tools capability", async () => { const baseUrl = "http://localhost:11434" const modelName = "devstral2to16:latest" @@ -99,7 +160,7 @@ describe("Ollama Fetcher", () => { "ollama.context_length": 4096, "some.other.info": "value", }, - capabilities: ["completion"], + capabilities: ["completion", "tools"], // Has tools capability } mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) @@ -122,6 +183,60 @@ describe("Ollama Fetcher", () => { expect(result[modelName]).toEqual(expectedParsedDetails) }) + it("should filter out models without tools capability", async () => { + const baseUrl = "http://localhost:11434" + const modelName = "no-tools-model:latest" + + const mockApiTagsResponse = { + models: [ + { + name: modelName, + model: modelName, + modified_at: "2025-06-03T09:23:22.610222878-04:00", + size: 14333928010, + digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", + details: { + family: "llama", + families: ["llama"], + format: "gguf", + parameter_size: "23.6B", + parent_model: "", + quantization_level: "Q4_K_M", + }, + }, + ], + } + const mockApiShowResponse = { + license: "Mock License", + modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", + parameters: "num_ctx 4096\nstop_token ", + template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", + modified_at: "2025-06-03T09:23:22.610222878-04:00", + details: { + parent_model: "", + format: "gguf", + family: "llama", + families: ["llama"], + parameter_size: "23.6B", + quantization_level: "Q4_K_M", + }, + model_info: { + "ollama.context_length": 4096, + "some.other.info": "value", + }, + capabilities: ["completion"], // No tools capability + } + + mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) + mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse }) + + const result = await getOllamaModels(baseUrl) + + // Model without tools capability should be filtered out + expect(Object.keys(result).length).toBe(0) + expect(result[modelName]).toBeUndefined() + }) + it("should return an empty list if the initial /api/tags call fails", async () => { const baseUrl = "http://localhost:11434" mockedAxios.get.mockRejectedValueOnce(new Error("Network error")) @@ -195,7 +310,7 @@ describe("Ollama Fetcher", () => { "ollama.context_length": 4096, "some.other.info": "value", }, - capabilities: ["completion"], + capabilities: ["completion", "tools"], // Has tools capability } mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) @@ -260,7 +375,7 @@ describe("Ollama Fetcher", () => { "ollama.context_length": 4096, "some.other.info": "value", }, - capabilities: ["completion"], + capabilities: ["completion", "tools"], // Has tools capability } mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 4bf43b6faf3..4ea0e396fed 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -37,17 +37,28 @@ type OllamaModelsResponse = z.infer type OllamaModelInfoResponse = z.infer -export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo => { +export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | null => { const contextKey = Object.keys(rawModel.model_info).find((k) => k.includes("context_length")) const contextWindow = contextKey && typeof rawModel.model_info[contextKey] === "number" ? rawModel.model_info[contextKey] : undefined + // Determine native tool support from capabilities array + // The capabilities array is populated by Ollama based on model metadata + const supportsNativeTools = rawModel.capabilities?.includes("tools") ?? false + + // Filter out models that don't support native tools + // This prevents users from selecting models that won't work properly with Roo Code's tool calling + if (!supportsNativeTools) { + return null + } + const modelInfo: ModelInfo = Object.assign({}, ollamaDefaultModelInfo, { description: `Family: ${rawModel.details.family}, Context: ${contextWindow}, Size: ${rawModel.details.parameter_size}`, contextWindow: contextWindow || ollamaDefaultModelInfo.contextWindow, supportsPromptCache: true, supportsImages: rawModel.capabilities?.includes("vision"), maxTokens: contextWindow || ollamaDefaultModelInfo.contextWindow, + supportsNativeTools: true, // Only models with tools capability reach this point }) return modelInfo @@ -89,7 +100,11 @@ export async function getOllamaModels( { headers }, ) .then((ollamaModelInfo) => { - models[ollamaModel.name] = parseOllamaModel(ollamaModelInfo.data) + const modelInfo = parseOllamaModel(ollamaModelInfo.data) + // Only include models that support native tools + if (modelInfo) { + models[ollamaModel.name] = modelInfo + } }), ) } diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 712b70445cc..f3271d65556 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -253,6 +253,8 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio 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) { @@ -268,6 +270,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio 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, @@ -295,6 +298,13 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio 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 {