From abe46275fbb36ddc626b3fabf4bb1a1d6ca08d86 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 9 Dec 2025 23:16:41 +0000 Subject: [PATCH] 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 --- ...e-openai-compatible-thinking-tools.spec.ts | 398 ++++++++++++++++++ .../base-openai-compatible-provider.ts | 149 +++++++ 2 files changed, 547 insertions(+) create mode 100644 src/api/providers/__tests__/base-openai-compatible-thinking-tools.spec.ts diff --git a/src/api/providers/__tests__/base-openai-compatible-thinking-tools.spec.ts b/src/api/providers/__tests__/base-openai-compatible-thinking-tools.spec.ts new file mode 100644 index 0000000000..1e647b3c0a --- /dev/null +++ b/src/api/providers/__tests__/base-openai-compatible-thinking-tools.spec.ts @@ -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 + + 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: ` +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. + + + +[ ] 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 + + + +Maintenant, laissez-moi examiner les autres fichiers clés du projet pour avoir une vue d'ensemble complète : + + + +[{"path": "src/App.tsx"}, {"path": "src/types/index.ts"}, {"path": "package.json"}] + + +`, + }, + }, + ], + } + + // 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: ` +I need to use the read_file tool to examine the code. + +[{"path": "test.ts"}] + +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: "I need to read a file", + }, + }, + ], + } + + // 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: ` +I'll use some tools: + +This is not a known tool + + + +This is malformed JSON content that can't be parsed properly {{{ + +`, + }, + }, + ], + } + + // 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: ` +First, I'll list the files: + +src +true + + +Then search for patterns: + +src +TODO +*.ts + + +Finally, execute a command: + +npm test +. + +`, + }, + }, + ], + } + + // 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(".") + }) +}) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 92b9558c45..00373d9c9f 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -129,6 +129,8 @@ export abstract class BaseOpenAiCompatibleProvider ) 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 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 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 // 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 // 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 tags. + */ + private *extractToolCallsFromThinking(thinkingContent: string): Generator { + // Look for tool call patterns in the thinking content + // Common patterns include XML-like tags for tool calls + const toolCallPatterns = [ + // Pattern 1: ... + /<(\w+)>([\s\S]*?)<\/\1>/g, + // Pattern 2: + /<(\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: value + 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 = { + 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 {