mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
fix: support reasoning_details format for Gemini 3 models (#9506)
This commit is contained in:
parent
d96f939dca
commit
b531075626
4 changed files with 183 additions and 8 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue