Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/api/providers/__tests__/native-ollama.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
123 changes: 119 additions & 4 deletions src/api/providers/fetchers/__tests__/ollama.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 })
Expand All @@ -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"))
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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 })
Expand Down
19 changes: 17 additions & 2 deletions src/api/providers/fetchers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}),
)
}
Expand Down
10 changes: 10 additions & 0 deletions src/api/providers/native-ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading