diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 2dab7c78be..1b5a503063 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -123,6 +123,26 @@ vitest.mock("../../providers/fetchers/modelCache", () => ({ outputPrice: 4, defaultToolProtocol: "native", }, + "google/gemini-2.5-pro": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsNativeTools: true, + inputPrice: 1.25, + outputPrice: 5, + defaultToolProtocol: "native", + }, + "gemini-2.5-flash": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsNativeTools: true, + inputPrice: 0.15, + outputPrice: 0.6, + defaultToolProtocol: "native", + }, } } return {} @@ -1066,4 +1086,294 @@ describe("RooHandler", () => { expect(endChunks[0].id).toBe("call_finish_test") }) }) + + describe("Gemini thought signature handling", () => { + it("should inject reasoning_details with reasoning.encrypted for Gemini models with tool calls", async () => { + const geminiHandler = new RooHandler({ + apiModelId: "google/gemini-2.5-pro", + }) + + // Messages with tool calls in history + const messagesWithToolCalls: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read the file test.ts" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll read the file for you." }, + { + type: "tool_use", + id: "call_123", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "file content here", + }, + ], + }, + { role: "user", content: "What's in the file?" }, + ] + + const stream = geminiHandler.createMessage(systemPrompt, messagesWithToolCalls) + for await (const _chunk of stream) { + // Consume stream + } + + // Verify the messages sent to the API + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + const messages = callArgs.messages + + // Find the assistant message with tool calls + const assistantMessage = messages.find( + (m: any) => m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0, + ) + + expect(assistantMessage).toBeDefined() + expect(assistantMessage.reasoning_details).toBeDefined() + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0]).toMatchObject({ + id: "call_123", + type: "reasoning.encrypted", + data: "skip_thought_signature_validator", + index: 0, + }) + }) + + it("should inject reasoning_details for Gemini model without google/ prefix", async () => { + const geminiHandler = new RooHandler({ + apiModelId: "gemini-2.5-flash", + }) + + // Messages with tool calls in history + const messagesWithToolCalls: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "List files" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_456", + name: "list_files", + input: { path: "." }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_456", + content: "file1.ts\nfile2.ts", + }, + ], + }, + ] + + const stream = geminiHandler.createMessage(systemPrompt, messagesWithToolCalls) + for await (const _chunk of stream) { + // Consume stream + } + + // Verify the messages sent to the API + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + const messages = callArgs.messages + + // Find the assistant message with tool calls + const assistantMessage = messages.find( + (m: any) => m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0, + ) + + expect(assistantMessage).toBeDefined() + expect(assistantMessage.reasoning_details).toBeDefined() + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted") + }) + + it("should not inject reasoning_details for non-Gemini models", async () => { + const nonGeminiHandler = new RooHandler({ + apiModelId: "anthropic/claude-haiku-4.5", + }) + + // Messages with tool calls in history + const messagesWithToolCalls: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read the file test.ts" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_789", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_789", + content: "file content here", + }, + ], + }, + ] + + const stream = nonGeminiHandler.createMessage(systemPrompt, messagesWithToolCalls) + for await (const _chunk of stream) { + // Consume stream + } + + // Verify the messages sent to the API + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + const messages = callArgs.messages + + // Find the assistant message with tool calls + const assistantMessage = messages.find( + (m: any) => m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0, + ) + + expect(assistantMessage).toBeDefined() + // Should NOT have reasoning_details injected + expect(assistantMessage.reasoning_details).toBeUndefined() + }) + + it("should not inject reasoning_details if already present with encrypted type", async () => { + const geminiHandler = new RooHandler({ + apiModelId: "google/gemini-2.5-pro", + }) + + // Messages with tool calls that already have reasoning_details + const messagesWithExistingDetails: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_existing", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + // @ts-expect-error - reasoning_details is not in the Anthropic type + reasoning_details: [ + { + type: "reasoning.encrypted", + data: "existing_signature", + format: "google-gemini-v1", + index: 0, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_existing", + content: "file content", + }, + ], + }, + ] + + const stream = geminiHandler.createMessage(systemPrompt, messagesWithExistingDetails) + for await (const _chunk of stream) { + // Consume stream + } + + // Verify the messages sent to the API + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + const messages = callArgs.messages + + // Find the assistant message with tool calls + const assistantMessage = messages.find( + (m: any) => m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0, + ) + + expect(assistantMessage).toBeDefined() + // Should keep existing reasoning_details without duplicating + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].data).toBe("existing_signature") + }) + + it("should handle multiple tool calls with individual reasoning_details entries", async () => { + const geminiHandler = new RooHandler({ + apiModelId: "google/gemini-2.5-pro", + }) + + // Messages with multiple tool calls + const messagesWithMultipleToolCalls: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Read both files" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_multi_1", + name: "read_file", + input: { path: "file1.ts" }, + }, + { + type: "tool_use", + id: "call_multi_2", + name: "read_file", + input: { path: "file2.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_multi_1", + content: "content 1", + }, + { + type: "tool_result", + tool_use_id: "call_multi_2", + content: "content 2", + }, + ], + }, + ] + + const stream = geminiHandler.createMessage(systemPrompt, messagesWithMultipleToolCalls) + for await (const _chunk of stream) { + // Consume stream + } + + // Verify the messages sent to the API + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + const messages = callArgs.messages + + // Find the assistant message with tool calls + const assistantMessage = messages.find( + (m: any) => m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0, + ) + + expect(assistantMessage).toBeDefined() + expect(assistantMessage.reasoning_details).toBeDefined() + expect(assistantMessage.reasoning_details).toHaveLength(2) + expect(assistantMessage.reasoning_details[0].id).toBe("call_multi_1") + expect(assistantMessage.reasoning_details[0].index).toBe(0) + expect(assistantMessage.reasoning_details[1].id).toBe("call_multi_2") + expect(assistantMessage.reasoning_details[1].index).toBe(1) + }) + }) }) diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 83eab87ef7..338444a7f3 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -1,7 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { rooDefaultModelId, getApiProtocol, type ImageGenerationApiMethod } from "@roo-code/types" +import { rooDefaultModelId, getApiProtocol, type ImageGenerationApiMethod, TOOL_PROTOCOL } from "@roo-code/types" import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" import { CloudService } from "@roo-code/cloud" @@ -12,6 +12,7 @@ import { getModelParams } from "../transform/model-params" import { convertToOpenAiMessages } from "../transform/openai-format" import type { RooReasoningParams } from "../transform/reasoning" import { getRooReasoning } from "../transform/reasoning" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" import type { ApiHandlerCreateMessageMetadata } from "../index" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" @@ -96,11 +97,66 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const max_tokens = params.maxTokens ?? undefined const temperature = params.temperature ?? this.defaultTemperature + let openAiMessages = [{ role: "system" as const, content: systemPrompt }, ...convertToOpenAiMessages(messages)] + + // Process reasoning_details when switching models to Gemini for native tool call compatibility + // This is the same approach used in OpenRouter - see openrouter.ts lines 246-280 + const toolProtocol = resolveToolProtocol(this.options, info) + const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE + const isGemini = model.startsWith("google/gemini") || model.includes("gemini") + + // For Gemini models: ensure content is always a string (not undefined) + // The Gemini API requires content to be a string, even if empty + if (isGemini) { + openAiMessages = openAiMessages.map((msg) => { + if (msg.role === "assistant" && msg.content === undefined) { + return { ...msg, content: "" } + } + return msg + }) + } + + // For Gemini with native protocol: inject fake reasoning.encrypted blocks for tool calls + // This is required when switching from other models to Gemini to satisfy API validation + if (isNativeProtocol && isGemini) { + openAiMessages = openAiMessages.map((msg) => { + if (msg.role === "assistant") { + const toolCalls = (msg as any).tool_calls as any[] | undefined + const existingDetails = (msg as any).reasoning_details as any[] | undefined + + // Only inject if there are tool calls and no existing encrypted reasoning + if (toolCalls && toolCalls.length > 0) { + const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false + + if (!hasEncrypted) { + // Filter tool calls that have valid IDs + const validToolCalls = toolCalls.filter((tc) => tc.id && typeof tc.id === "string") + + if (validToolCalls.length > 0) { + const fakeEncrypted = validToolCalls.map((tc, idx) => ({ + id: tc.id, + type: "reasoning.encrypted", + data: "skip_thought_signature_validator", + index: (existingDetails?.length ?? 0) + idx, + })) + + return { + ...msg, + reasoning_details: [...(existingDetails ?? []), ...fakeEncrypted], + } + } + } + } + } + return msg + }) + } + const rooParams: RooChatCompletionParams = { model, max_tokens, temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + messages: openAiMessages, stream: true, stream_options: { include_usage: true }, ...(reasoning && { reasoning }),