fix: filter Ollama models without native tool support (#10735)

This commit is contained in:
Daniel 2026-01-15 23:01:25 -05:00 committed by GitHub
parent 245e0f66c6
commit bbf3196837
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 250 additions and 6 deletions

View file

@ -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)
})
})
})

View file

@ -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 <eos>",
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 })

View file

@ -37,17 +37,28 @@ type OllamaModelsResponse = z.infer<typeof OllamaModelsResponseSchema>
type OllamaModelInfoResponse = z.infer<typeof OllamaModelInfoResponseSchema>
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
}
}),
)
}

View file

@ -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 {