mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
fix: filter Ollama models without native tool support (#10735)
This commit is contained in:
parent
245e0f66c6
commit
bbf3196837
4 changed files with 250 additions and 6 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue