Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 137 additions & 5 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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[],
Expand All @@ -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"
Expand All @@ -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)) {
Expand Down Expand Up @@ -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.
Expand All @@ -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 }
}

Expand Down Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions src/api/transform/openai-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/task-persistence/apiMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
34 changes: 33 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,13 +676,15 @@ 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") {
const responseId = handler.getResponseId?.()
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 = {
Expand All @@ -691,8 +693,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,
Expand Down Expand Up @@ -3503,6 +3511,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"
Expand Down
Loading