diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index c63142aad9..f1ef0b56ef 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -12,6 +12,8 @@ import { import type { ApiHandlerOptions, ModelRecord } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { TOOL_PROTOCOL } from "@roo-code/types" import { ApiStreamChunk } from "../transform/stream" import { convertToR1Format } from "../transform/r1-format" import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transform/caching/anthropic" @@ -87,6 +89,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH protected models: ModelRecord = {} protected endpoints: ModelRecord = {} private readonly providerName = "OpenRouter" + private currentReasoningDetails: any[] = [] constructor(options: ApiHandlerOptions) { super() @@ -124,6 +127,10 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } + getReasoningDetails(): any[] | undefined { + return this.currentReasoningDetails.length > 0 ? this.currentReasoningDetails : undefined + } + override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], @@ -133,11 +140,14 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH let { id: modelId, maxTokens, temperature, topP, reasoning } = model - // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro - // Preview even if you don't request them. This is not the default for + // Reset reasoning_details accumulator for this request + this.currentReasoningDetails = [] + + // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro models + // even if you don't request them. This is not the default for // other providers (including Gemini), so we need to explicitly disable - // i We should generalize this using the logic in `getModelParams`, but - // this is easier for now. + // them unless the user has explicitly configured reasoning. + // Note: Gemini 3 models use reasoning_details format and should not be excluded. if ( (modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") && typeof reasoning === "undefined" @@ -156,6 +166,43 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) } + // Process reasoning_details when switching models to Gemini for native tool call compatibility + const toolProtocol = resolveToolProtocol(this.options, model.info) + const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE + const isGemini = modelId.startsWith("google/gemini") + + // 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) { + const fakeEncrypted = toolCalls.map((tc, idx) => ({ + id: tc.id, + type: "reasoning.encrypted", + data: "skip_thought_signature_validator", + format: "google-gemini-v1", + index: (existingDetails?.length ?? 0) + idx, + })) + + return { + ...msg, + reasoning_details: [...(existingDetails ?? []), ...fakeEncrypted], + } + } + } + } + return msg + }) + } + // https://openrouter.ai/docs/features/prompt-caching // TODO: Add a `promptCacheStratey` field to `ModelInfo`. if (OPEN_ROUTER_PROMPT_CACHING_MODELS.has(modelId)) { @@ -202,6 +249,20 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH let lastUsage: CompletionUsage | undefined = undefined const toolCallAccumulator = new Map() + // Accumulator for reasoning_details: accumulate text by type-index key + const reasoningDetailsAccumulator = new Map< + string, + { + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + signature?: string + index: number + } + >() for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. @@ -215,7 +276,73 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const finishReason = chunk.choices[0]?.finish_reason if (delta) { - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + // Handle reasoning_details array format (used by Gemini 3, Claude, OpenAI o-series, etc.) + // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks + // Priority: Check for reasoning_details first, as it's the newer format + const deltaWithReasoning = delta as typeof delta & { + reasoning_details?: Array<{ + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + signature?: string + index?: number + }> + } + + if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { + for (const detail of deltaWithReasoning.reasoning_details) { + const index = detail.index ?? 0 + const key = `${detail.type}-${index}` + const existing = reasoningDetailsAccumulator.get(key) + + if (existing) { + // Accumulate text/summary/data for existing reasoning detail + if (detail.text !== undefined) { + existing.text = (existing.text || "") + detail.text + } + if (detail.summary !== undefined) { + existing.summary = (existing.summary || "") + detail.summary + } + if (detail.data !== undefined) { + existing.data = (existing.data || "") + detail.data + } + // Update other fields if provided + if (detail.id !== undefined) existing.id = detail.id + if (detail.format !== undefined) existing.format = detail.format + if (detail.signature !== undefined) existing.signature = detail.signature + } else { + // Start new reasoning detail accumulation + reasoningDetailsAccumulator.set(key, { + type: detail.type, + text: detail.text, + summary: detail.summary, + data: detail.data, + id: detail.id, + format: detail.format, + signature: detail.signature, + index, + }) + } + + // Yield text for display (still fragmented for live streaming) + let reasoningText: string | undefined + if (detail.type === "reasoning.text" && typeof detail.text === "string") { + reasoningText = detail.text + } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { + reasoningText = detail.summary + } + // Note: reasoning.encrypted types are intentionally skipped as they contain redacted content + + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + } + } else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + // Handle legacy reasoning format - only if reasoning_details is not present + // See: https://openrouter.ai/docs/use-cases/reasoning-tokens yield { type: "reasoning", text: delta.reasoning } } @@ -279,6 +406,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH toolCallAccumulator.clear() } + // After streaming completes, store the accumulated reasoning_details + if (reasoningDetailsAccumulator.size > 0) { + this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) + } + if (lastUsage) { yield { type: "usage", diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 134f9f2ed6..6a88491b7e 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -132,12 +132,21 @@ export function convertToOpenAiMessages( }, })) - openAiMessages.push({ + // Check if the message has reasoning_details (used by Gemini 3, etc.) + const messageWithDetails = anthropicMessage as any + const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = { role: "assistant", content, // Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty tool_calls: tool_calls.length > 0 ? tool_calls : undefined, - }) + } + + // Preserve reasoning_details if present (will be processed by provider if needed) + if (messageWithDetails.reasoning_details && Array.isArray(messageWithDetails.reasoning_details)) { + ;(baseMessage as any).reasoning_details = messageWithDetails.reasoning_details + } + + openAiMessages.push(baseMessage) } } } diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 1fa1e6df5b..2628568135 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -18,6 +18,8 @@ export type ApiMessage = Anthropic.MessageParam & { summary?: any[] encrypted_content?: string text?: string + // For OpenRouter reasoning_details array format (used by Gemini 3, etc.) + reasoning_details?: any[] } export async function readApiMessages({ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 275265efd5..9fc0d3992e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -676,6 +676,7 @@ export class Task extends EventEmitter implements TaskLike { getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined getThoughtSignature?: () => string | undefined getSummary?: () => any[] | undefined + getReasoningDetails?: () => any[] | undefined } if (message.role === "assistant") { @@ -683,6 +684,7 @@ export class Task extends EventEmitter implements TaskLike { const reasoningData = handler.getEncryptedContent?.() const thoughtSignature = handler.getThoughtSignature?.() const reasoningSummary = handler.getSummary?.() + const reasoningDetails = handler.getReasoningDetails?.() // Start from the original assistant message const messageWithTs: any = { @@ -691,8 +693,14 @@ export class Task extends EventEmitter implements TaskLike { ts: Date.now(), } + // Store reasoning_details array if present (for models like Gemini 3) + if (reasoningDetails) { + messageWithTs.reasoning_details = reasoningDetails + } + // Store reasoning: plain text (most providers) or encrypted (OpenAI Native) - if (reasoning) { + // Skip if reasoning_details already contains the reasoning (to avoid duplication) + if (reasoning && !reasoningDetails) { const reasoningBlock = { type: "reasoning", text: reasoning, @@ -3503,6 +3511,30 @@ export class Task extends EventEmitter implements TaskLike { const [first, ...rest] = contentArray + // Check if this message has reasoning_details (OpenRouter format for Gemini 3, etc.) + const msgWithDetails = msg + if (msgWithDetails.reasoning_details && Array.isArray(msgWithDetails.reasoning_details)) { + // Build the assistant message with reasoning_details + let assistantContent: Anthropic.Messages.MessageParam["content"] + + if (contentArray.length === 0) { + assistantContent = "" + } else if (contentArray.length === 1 && contentArray[0].type === "text") { + assistantContent = (contentArray[0] as Anthropic.Messages.TextBlockParam).text + } else { + assistantContent = contentArray + } + + // Create message with reasoning_details property + cleanConversationHistory.push({ + role: "assistant", + content: assistantContent, + reasoning_details: msgWithDetails.reasoning_details, + } as any) + + continue + } + // Embedded reasoning: encrypted (send) or plain text (skip) const hasEncryptedReasoning = first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string"