From fe1a9f5390960ed940cb071336fcbcd48aaea226 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Sat, 22 Nov 2025 11:50:58 -0500 Subject: [PATCH 1/3] fix: support reasoning_details format for Gemini 3 models - Add OPEN_ROUTER_REASONING_DETAILS_MODELS set to track models using reasoning_details array format - Accumulate and store full reasoning_details array during streaming - Add getReasoningDetails() method to OpenRouterHandler - Store reasoning_details on ApiMessage type and persist to conversation history - Set preserveReasoning: true for models in the set - Preserve reasoning_details when converting messages to OpenAI format - Send reasoning_details back to API on subsequent requests for tool calling workflows Fixes upstream issue from https://github.com/cline/cline/issues/7551 Follows OpenRouter docs: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks --- packages/types/src/providers/openrouter.ts | 5 ++ src/api/providers/fetchers/openrouter.ts | 7 +++ src/api/providers/openrouter.ts | 56 ++++++++++++++++++++-- src/api/transform/openai-format.ts | 13 ++++- src/core/task-persistence/apiMessages.ts | 2 + src/core/task/Task.ts | 31 ++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts index 22285fe6f5..6c36502dff 100644 --- a/packages/types/src/providers/openrouter.ts +++ b/packages/types/src/providers/openrouter.ts @@ -83,3 +83,8 @@ export const OPEN_ROUTER_REASONING_BUDGET_MODELS = new Set([ "anthropic/claude-3.7-sonnet:thinking", "google/gemini-2.5-flash-preview-05-20:thinking", ]) + +// Models that use the reasoning_details array format instead of the legacy reasoning string format +// These models return reasoning in delta.reasoning_details[] structure +// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks +export const OPEN_ROUTER_REASONING_DETAILS_MODELS = new Set(["google/gemini-3-pro-preview"]) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index 38d3c52fa9..b0e4f41d19 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -6,6 +6,7 @@ import { isModelParameter, OPEN_ROUTER_REASONING_BUDGET_MODELS, OPEN_ROUTER_REQUIRED_REASONING_BUDGET_MODELS, + OPEN_ROUTER_REASONING_DETAILS_MODELS, anthropicModels, } from "@roo-code/types" @@ -266,5 +267,11 @@ export const parseOpenRouterModel = ({ modelInfo.maxTokens = 32768 } + // Enable preserveReasoning for models that use reasoning_details array format + // This ensures reasoning_details are stored and sent back in multi-turn conversations + if (OPEN_ROUTER_REASONING_DETAILS_MODELS.has(id)) { + modelInfo.preserveReasoning = true + } + return modelInfo } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index c63142aad9..8a0b90b3d9 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -6,6 +6,7 @@ import { openRouterDefaultModelInfo, OPENROUTER_DEFAULT_PROVIDER_NAME, OPEN_ROUTER_PROMPT_CACHING_MODELS, + OPEN_ROUTER_REASONING_DETAILS_MODELS, DEEP_SEEK_DEFAULT_TEMPERATURE, } from "@roo-code/types" @@ -87,6 +88,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 +126,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 +139,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" @@ -215,10 +224,49 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const finishReason = chunk.choices[0]?.finish_reason if (delta) { + // Handle reasoning tokens - supports both legacy string format and new reasoning_details array format + // See: https://openrouter.ai/docs/use-cases/reasoning-tokens if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { yield { type: "reasoning", text: delta.reasoning } } + // Handle reasoning_details array format for models that use it (Gemini 3, etc.) + // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks + if (OPEN_ROUTER_REASONING_DETAILS_MODELS.has(modelId)) { + const deltaWithReasoning = delta as typeof delta & { + reasoning_details?: Array<{ + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + index?: number + }> + } + + if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { + for (const detail of deltaWithReasoning.reasoning_details) { + // Store the full reasoning detail object for later use + this.currentReasoningDetails.push(detail) + + let reasoningText: string | undefined + + // Extract text based on reasoning detail type for display + 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 } + } + } + } + } + // Check for tool calls in delta if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 134f9f2ed6..9371b51290 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 + 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..788b4b32b8 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,6 +693,11 @@ 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) { const reasoningBlock = { @@ -3503,6 +3510,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" From 7939d0606c519b6fe78bdc5418f2eae4932b58a5 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Sat, 22 Nov 2025 12:02:42 -0500 Subject: [PATCH 2/3] fix: add minimax-m2 to reasoning_details models for OpenRouter --- packages/types/src/providers/openrouter.ts | 5 -- src/api/providers/fetchers/openrouter.ts | 7 --- src/api/providers/openrouter.ts | 55 ++++++++++------------ 3 files changed, 26 insertions(+), 41 deletions(-) diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts index 6c36502dff..22285fe6f5 100644 --- a/packages/types/src/providers/openrouter.ts +++ b/packages/types/src/providers/openrouter.ts @@ -83,8 +83,3 @@ export const OPEN_ROUTER_REASONING_BUDGET_MODELS = new Set([ "anthropic/claude-3.7-sonnet:thinking", "google/gemini-2.5-flash-preview-05-20:thinking", ]) - -// Models that use the reasoning_details array format instead of the legacy reasoning string format -// These models return reasoning in delta.reasoning_details[] structure -// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks -export const OPEN_ROUTER_REASONING_DETAILS_MODELS = new Set(["google/gemini-3-pro-preview"]) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index b0e4f41d19..38d3c52fa9 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -6,7 +6,6 @@ import { isModelParameter, OPEN_ROUTER_REASONING_BUDGET_MODELS, OPEN_ROUTER_REQUIRED_REASONING_BUDGET_MODELS, - OPEN_ROUTER_REASONING_DETAILS_MODELS, anthropicModels, } from "@roo-code/types" @@ -267,11 +266,5 @@ export const parseOpenRouterModel = ({ modelInfo.maxTokens = 32768 } - // Enable preserveReasoning for models that use reasoning_details array format - // This ensures reasoning_details are stored and sent back in multi-turn conversations - if (OPEN_ROUTER_REASONING_DETAILS_MODELS.has(id)) { - modelInfo.preserveReasoning = true - } - return modelInfo } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 8a0b90b3d9..c37dda5364 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -6,7 +6,6 @@ import { openRouterDefaultModelInfo, OPENROUTER_DEFAULT_PROVIDER_NAME, OPEN_ROUTER_PROMPT_CACHING_MODELS, - OPEN_ROUTER_REASONING_DETAILS_MODELS, DEEP_SEEK_DEFAULT_TEMPERATURE, } from "@roo-code/types" @@ -230,39 +229,37 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH yield { type: "reasoning", text: delta.reasoning } } - // Handle reasoning_details array format for models that use it (Gemini 3, etc.) + // 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 - if (OPEN_ROUTER_REASONING_DETAILS_MODELS.has(modelId)) { - const deltaWithReasoning = delta as typeof delta & { - reasoning_details?: Array<{ - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - index?: number - }> - } + const deltaWithReasoning = delta as typeof delta & { + reasoning_details?: Array<{ + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + index?: number + }> + } - if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { - for (const detail of deltaWithReasoning.reasoning_details) { - // Store the full reasoning detail object for later use - this.currentReasoningDetails.push(detail) + if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { + for (const detail of deltaWithReasoning.reasoning_details) { + // Store the full reasoning detail object for later use + this.currentReasoningDetails.push(detail) - let reasoningText: string | undefined + let reasoningText: string | undefined - // Extract text based on reasoning detail type for display - 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 + // Extract text based on reasoning detail type for display + 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 } - } + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } } } } From 6b741a2d4cb6e51a2fcb4128049b6609a5a238ea Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Sun, 23 Nov 2025 15:41:55 -0500 Subject: [PATCH 3/3] fix: resolve reasoning duplication and accumulate reasoning_details properly - Accumulate reasoning_details chunks into complete objects (not fragments) - Prevent double storage: only store reasoning_details OR reasoning block, never both - Remove debug logging from openai-format.ts - Inject fake reasoning.encrypted blocks for Gemini tool calls when switching models - Fix priority-based format handling: check reasoning_details first, fall back to legacy reasoning --- src/api/providers/openrouter.ts | 107 ++++++++++++++++++++++++++--- src/api/transform/openai-format.ts | 2 +- src/core/task/Task.ts | 3 +- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index c37dda5364..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" @@ -164,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)) { @@ -210,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. @@ -223,14 +276,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const finishReason = chunk.choices[0]?.finish_reason if (delta) { - // Handle reasoning tokens - supports both legacy string format and new reasoning_details array format - // See: https://openrouter.ai/docs/use-cases/reasoning-tokens - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - yield { type: "reasoning", text: delta.reasoning } - } - // 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 @@ -239,18 +287,48 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH 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) { - // Store the full reasoning detail object for later use - this.currentReasoningDetails.push(detail) + const index = detail.index ?? 0 + const key = `${detail.type}-${index}` + const existing = reasoningDetailsAccumulator.get(key) - let reasoningText: string | undefined + 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, + }) + } - // Extract text based on reasoning detail type for display + // 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") { @@ -262,6 +340,10 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH 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 } } // Check for tool calls in delta @@ -324,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 9371b51290..6a88491b7e 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -141,7 +141,7 @@ export function convertToOpenAiMessages( tool_calls: tool_calls.length > 0 ? tool_calls : undefined, } - // Preserve reasoning_details if present + // 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 } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 788b4b32b8..9fc0d3992e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -699,7 +699,8 @@ export class Task extends EventEmitter implements TaskLike { } // 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,