fix: extract tool calls from thinking content in OpenAI Compatible provider

- Add logic to detect when only thinking content exists without regular content
- Extract and parse tool calls embedded within thinking tags
- Support common tool call patterns in thinking content
- Add comprehensive tests for the new functionality

Fixes #9959
This commit is contained in:
Roo Code 2025-12-09 23:16:41 +00:00
parent 4608c979e0
commit abe46275fb
2 changed files with 547 additions and 0 deletions

View file

@ -0,0 +1,398 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import OpenAI from "openai"
import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
import type { ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../../shared/api"
// Create a concrete implementation for testing
class TestProvider extends BaseOpenAiCompatibleProvider<"test-model"> {
constructor(options: ApiHandlerOptions) {
super({
providerName: "TestProvider",
baseURL: "https://test.api.com",
defaultProviderModelId: "test-model",
providerModels: {
"test-model": {
maxTokens: 4096,
contextWindow: 8192,
supportsReasoningBinary: true,
} as ModelInfo,
},
apiKey: "test-key",
...options,
})
}
}
describe("BaseOpenAiCompatibleProvider - Tool Calls in Thinking", () => {
let provider: TestProvider
let mockCreate: ReturnType<typeof vi.fn>
beforeEach(() => {
mockCreate = vi.fn()
vi.spyOn(OpenAI.Chat.Completions.prototype, "create").mockImplementation(mockCreate)
})
it("should extract tool calls from thinking content when no regular content exists", async () => {
provider = new TestProvider({
apiKey: "test-key",
enableReasoningEffort: true,
})
// Mock a response where all content is in thinking tags with embedded tool calls
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
// First chunk: thinking content with tool calls
yield {
choices: [
{
delta: {
content: `<think>
L'utilisateur me demande de faire une review du code et de bien séparer les appels d'outils de mes pensées. Je vais analyser le fichier server/index.ts en profondeur et créer une todo list pour organiser ma revue complète du projet.
Je vais commencer par créer une todo list pour structurer ma revue, puis lire les autres fichiers importants du projet.
<update_todo_list>
<todos>
[ ] Lire et analyser la structure du projet
[ ] Examiner le fichier server/index.ts en détail
[ ] Analyser les composants React et leur architecture
[ ] Vérifier les types et interfaces
[ ] Identifier les problèmes de qualité de code
[ ] Détecter les redondances et code mort
[ ] Analyser la sécurité et les vulnérabilités
[ ] Évaluer la performance et l'efficacité
[ ] Vérifier la maintenabilité et la lisibilité
[ ] Rédiger le rapport de review complet
</todos>
</update_todo_list>
Maintenant, laissez-moi examiner les autres fichiers clés du projet pour avoir une vue d'ensemble complète :
<read_file>
<files>
[{"path": "src/App.tsx"}, {"path": "src/types/index.ts"}, {"path": "package.json"}]
</files>
</read_file>
</think>`,
},
},
],
}
// Final chunk with usage
yield {
usage: {
prompt_tokens: 100,
completion_tokens: 50,
},
}
},
}))
const messages = [{ role: "user" as const, content: "Review my code" }]
const chunks: any[] = []
for await (const chunk of provider.createMessage("System prompt", messages)) {
chunks.push(chunk)
}
// Should have reasoning chunks
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks.length).toBeGreaterThan(0)
// Should have extracted tool calls from thinking content
const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial")
expect(toolCallChunks.length).toBe(2) // update_todo_list and read_file
// Verify first tool call (update_todo_list)
const updateTodoCall = toolCallChunks.find((c) => c.name === "update_todo_list")
expect(updateTodoCall).toBeDefined()
expect(updateTodoCall.index).toBe(0)
expect(updateTodoCall.id).toMatch(/^tool_\d+_0$/)
// Verify second tool call (read_file)
const readFileCall = toolCallChunks.find((c) => c.name === "read_file")
expect(readFileCall).toBeDefined()
expect(readFileCall.index).toBe(1)
expect(readFileCall.id).toMatch(/^tool_\d+_1$/)
// Should have usage chunk
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
})
it("should not extract tool calls when regular content exists", async () => {
provider = new TestProvider({
apiKey: "test-key",
enableReasoningEffort: true,
})
// Mock a response with both thinking and regular content
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
// First chunk: thinking content
yield {
choices: [
{
delta: {
content: `<think>
I need to use the read_file tool to examine the code.
<read_file>
<files>[{"path": "test.ts"}]</files>
</read_file>
</think>Here is my analysis of your code:`,
},
},
],
}
// Second chunk: regular content
yield {
choices: [
{
delta: {
content: "Your code looks good overall.",
},
},
],
}
// Final chunk with usage
yield {
usage: {
prompt_tokens: 100,
completion_tokens: 50,
},
}
},
}))
const messages = [{ role: "user" as const, content: "Review my code" }]
const chunks: any[] = []
for await (const chunk of provider.createMessage("System prompt", messages)) {
chunks.push(chunk)
}
// Should have both reasoning and text chunks
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
const textChunks = chunks.filter((c) => c.type === "text")
expect(reasoningChunks.length).toBeGreaterThan(0)
expect(textChunks.length).toBeGreaterThan(0)
// Should NOT extract tool calls since regular content exists
const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial")
expect(toolCallChunks.length).toBe(0)
})
it("should handle tool calls that come through normal delta.tool_calls", async () => {
provider = new TestProvider({
apiKey: "test-key",
})
// Mock a response with tool calls in delta
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
// Thinking content without tool calls
yield {
choices: [
{
delta: {
content: "<think>I need to read a file</think>",
},
},
],
}
// Tool call through normal channel
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_123",
function: {
name: "read_file",
arguments: '{"files":[{"path":"test.ts"}]}',
},
},
],
},
},
],
}
// Final chunk with usage
yield {
usage: {
prompt_tokens: 100,
completion_tokens: 50,
},
}
},
}))
const messages = [{ role: "user" as const, content: "Review my code" }]
const chunks: any[] = []
for await (const chunk of provider.createMessage("System prompt", messages)) {
chunks.push(chunk)
}
// Should have reasoning chunk
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks.length).toBeGreaterThan(0)
// Should have tool call from normal channel (not extracted from thinking)
const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial")
expect(toolCallChunks.length).toBe(1)
expect(toolCallChunks[0].id).toBe("call_123") // Original ID preserved
})
it("should handle malformed tool calls in thinking gracefully", async () => {
provider = new TestProvider({
apiKey: "test-key",
enableReasoningEffort: true,
})
// Mock a response with malformed tool calls in thinking
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
content: `<think>
I'll use some tools:
<not_a_real_tool>
This is not a known tool
</not_a_real_tool>
<read_file>
This is malformed JSON content that can't be parsed properly {{{
</read_file>
</think>`,
},
},
],
}
// Final chunk with usage
yield {
usage: {
prompt_tokens: 100,
completion_tokens: 50,
},
}
},
}))
const messages = [{ role: "user" as const, content: "Test malformed" }]
const chunks: any[] = []
// Should not throw an error
for await (const chunk of provider.createMessage("System prompt", messages)) {
chunks.push(chunk)
}
// Should have reasoning chunks
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks.length).toBeGreaterThan(0)
// Should only extract the known tool (read_file), not the unknown one
const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial")
expect(toolCallChunks.length).toBe(1)
expect(toolCallChunks[0].name).toBe("read_file")
// The malformed content should be passed as-is in the files field (primary param for read_file)
const args = JSON.parse(toolCallChunks[0].arguments)
expect(args.files).toBeDefined()
expect(args.files).toMatch(/This is malformed JSON/)
})
it("should handle multiple tool calls in thinking content", async () => {
provider = new TestProvider({
apiKey: "test-key",
enableReasoningEffort: true,
})
// Mock a response with multiple tool calls
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
content: `<think>
First, I'll list the files:
<list_files>
<path>src</path>
<recursive>true</recursive>
</list_files>
Then search for patterns:
<search_files>
<path>src</path>
<regex>TODO</regex>
<file_pattern>*.ts</file_pattern>
</search_files>
Finally, execute a command:
<execute_command>
<command>npm test</command>
<cwd>.</cwd>
</execute_command>
</think>`,
},
},
],
}
// Final chunk with usage
yield {
usage: {
prompt_tokens: 100,
completion_tokens: 50,
},
}
},
}))
const messages = [{ role: "user" as const, content: "Analyze project" }]
const chunks: any[] = []
for await (const chunk of provider.createMessage("System prompt", messages)) {
chunks.push(chunk)
}
// Should extract all three tool calls
const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial")
expect(toolCallChunks.length).toBe(3)
// Verify tool names and indices
const toolNames = toolCallChunks.map((c) => c.name)
expect(toolNames).toEqual(["list_files", "search_files", "execute_command"])
// Verify indices are sequential
expect(toolCallChunks[0].index).toBe(0)
expect(toolCallChunks[1].index).toBe(1)
expect(toolCallChunks[2].index).toBe(2)
// Verify arguments are properly extracted
const listFilesArgs = JSON.parse(toolCallChunks[0].arguments)
expect(listFilesArgs.path).toBe("src")
expect(listFilesArgs.recursive).toBe("true")
const searchFilesArgs = JSON.parse(toolCallChunks[1].arguments)
expect(searchFilesArgs.path).toBe("src")
expect(searchFilesArgs.regex).toBe("TODO")
expect(searchFilesArgs.file_pattern).toBe("*.ts")
const executeCommandArgs = JSON.parse(toolCallChunks[2].arguments)
expect(executeCommandArgs.command).toBe("npm test")
expect(executeCommandArgs.cwd).toBe(".")
})
})

View file

@ -129,6 +129,8 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
)
let lastUsage: OpenAI.CompletionUsage | undefined
let thinkingContent = ""
let hasRegularContent = false
for await (const chunk of stream) {
// Check for provider-specific error responses (e.g., MiniMax base_resp)
@ -143,6 +145,13 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
// Track if we have regular content outside thinking tags
if (processedChunk.type === "text") {
hasRegularContent = true
} else if (processedChunk.type === "reasoning") {
// Accumulate thinking content for later processing
thinkingContent += processedChunk.text
}
yield processedChunk
}
}
@ -152,6 +161,7 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
if (key in delta) {
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
if (reasoning_content?.trim()) {
thinkingContent += reasoning_content
yield { type: "reasoning", text: reasoning_content }
}
break
@ -161,6 +171,7 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
// Emit raw tool call chunks - NativeToolCallParser handles state management
if (delta?.tool_calls) {
hasRegularContent = true // Tool calls count as regular content
for (const toolCall of delta.tool_calls) {
yield {
type: "tool_call_partial",
@ -183,8 +194,146 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
// Process any remaining content
for (const processedChunk of matcher.final()) {
if (processedChunk.type === "text") {
hasRegularContent = true
} else if (processedChunk.type === "reasoning") {
thinkingContent += processedChunk.text
}
yield processedChunk
}
// If we only have thinking content and no regular content/tool calls,
// try to extract tool calls from the thinking content
if (!hasRegularContent && thinkingContent) {
yield* this.extractToolCallsFromThinking(thinkingContent)
}
}
/**
* Extract tool calls from thinking content when no regular content exists.
* This handles cases where models like kimi-k2-thinking embed tool calls
* within <think> tags.
*/
private *extractToolCallsFromThinking(thinkingContent: string): Generator<any> {
// Look for tool call patterns in the thinking content
// Common patterns include XML-like tags for tool calls
const toolCallPatterns = [
// Pattern 1: <tool_name>...</tool_name>
/<(\w+)>([\s\S]*?)<\/\1>/g,
// Pattern 2: <tool_name param="value" />
/<(\w+)\s+([^>]+)\/>/g,
]
let toolCallIndex = 0
for (const pattern of toolCallPatterns) {
let match
while ((match = pattern.exec(thinkingContent)) !== null) {
const toolName = match[1]
const content = match[2] || ""
// Check if this looks like a known tool call
if (this.isKnownTool(toolName)) {
// Generate a unique ID for this tool call
const toolCallId = `tool_${Date.now()}_${toolCallIndex}`
// Try to parse arguments from the content
let args = {}
try {
// First try to parse as JSON
if (content.trim().startsWith("{")) {
args = JSON.parse(content)
} else {
// Try to extract structured data from the content
args = this.parseToolArguments(toolName, content)
}
} catch (e) {
// If parsing fails, pass the raw content
args = { content: content.trim() }
}
// Emit tool call partial chunks
yield {
type: "tool_call_partial",
index: toolCallIndex,
id: toolCallId,
name: toolName,
arguments: JSON.stringify(args),
}
toolCallIndex++
}
}
}
}
/**
* Check if a string matches a known tool name.
*/
private isKnownTool(name: string): boolean {
const knownTools = [
"read_file",
"write_to_file",
"apply_diff",
"execute_command",
"list_files",
"search_files",
"ask_followup_question",
"attempt_completion",
"update_todo_list",
"list_code_definition_names",
"use_mcp_tool",
"switch_mode",
"new_task",
"fetch_instructions",
]
return knownTools.includes(name.toLowerCase())
}
/**
* Parse tool arguments from content string.
*/
private parseToolArguments(toolName: string, content: string): any {
// Try to extract structured arguments from content
const args: any = {}
// Look for common parameter patterns
// Pattern: <param>value</param>
const paramPattern = /<(\w+)>([\s\S]*?)<\/\1>/g
let paramMatch
while ((paramMatch = paramPattern.exec(content)) !== null) {
const paramName = paramMatch[1]
const paramValue = paramMatch[2]
args[paramName] = paramValue
}
// If no structured params found, use content as the main parameter
if (Object.keys(args).length === 0) {
if (content.trim()) {
// Map to the primary parameter for each tool
const primaryParams: Record<string, string> = {
read_file: "files",
write_to_file: "content",
apply_diff: "diff",
execute_command: "command",
list_files: "path",
search_files: "regex",
ask_followup_question: "question",
attempt_completion: "result",
update_todo_list: "todos",
}
const primaryParam = primaryParams[toolName.toLowerCase()]
if (primaryParam) {
args[primaryParam] = content.trim()
} else {
// Fallback: use 'content' as a generic parameter name
args["content"] = content.trim()
}
}
}
return args
}
protected processUsageMetrics(usage: any, modelInfo?: any): ApiStreamUsageChunk {