Просмотр исходного кода

fix: support reasoning_details format for Gemini 3 models (#9506)

Daniel 1 месяц назад
Родитель
Сommit
b531075626

+ 137 - 5
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<number, { id: string; name: string; arguments: string }>()
+		// 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",

+ 11 - 2
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)
 			}
 		}
 	}

+ 2 - 0
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({

+ 33 - 1
src/core/task/Task.ts

@@ -679,6 +679,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined
 			getThoughtSignature?: () => string | undefined
 			getSummary?: () => any[] | undefined
+			getReasoningDetails?: () => any[] | undefined
 		}
 
 		if (message.role === "assistant") {
@@ -686,6 +687,7 @@ export class Task extends EventEmitter<TaskEvents> 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 = {
@@ -694,8 +696,14 @@ export class Task extends EventEmitter<TaskEvents> 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,
@@ -3521,6 +3529,30 @@ export class Task extends EventEmitter<TaskEvents> 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"