diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index c63142aad9..f14b25b855 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -11,6 +11,7 @@ import { import type { ApiHandlerOptions, ModelRecord } from "../../shared/api" +import { shouldSkipReasoningForModel } from "../../utils/model-utils" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStreamChunk } from "../transform/stream" import { convertToR1Format } from "../transform/r1-format" @@ -260,6 +261,22 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH toolCallAccumulator.clear() } + // OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model + // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks + if ( + delta && + "reasoning_details" in delta && + delta.reasoning_details && + // @ts-ignore-next-line + delta.reasoning_details.length && // exists and non-0 + !shouldSkipReasoningForModel(this.options.openRouterModelId) + ) { + yield { + type: "reasoning_details", + reasoning_details: delta.reasoning_details, + } + } + if (chunk.usage) { lastUsage = chunk.usage } diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 134f9f2ed6..3cbc675b1c 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -110,7 +110,23 @@ export function convertToOpenAiMessages( // Process non-tool messages let content: string | undefined + const reasoningDetails: any[] = [] if (nonToolMessages.length > 0) { + nonToolMessages.forEach((part) => { + // @ts-ignore-next-line + if (part.type === "text" && part.reasoning_details) { + // @ts-ignore-next-line + if (Array.isArray(part.reasoning_details)) { + // @ts-ignore-next-line + reasoningDetails.push(...part.reasoning_details) + } else { + // @ts-ignore-next-line + reasoningDetails.push(part.reasoning_details) + } + // @ts-ignore-next-line + // delete part.reasoning_details + } + }) content = nonToolMessages .map((part) => { if (part.type === "image") { @@ -137,6 +153,9 @@ export function convertToOpenAiMessages( 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, + // @ts-ignore-next-line + reasoning_details: + reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails) : undefined, }) } } @@ -144,3 +163,107 @@ export function convertToOpenAiMessages( return openAiMessages } + +// Type for OpenRouter's reasoning detail elements +// https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response +type ReasoningDetail = { + // https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types + type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text" + text?: string + data?: string // Encrypted reasoning data + signature?: string | null + id?: string | null // Unique identifier for the reasoning detail + /* + The format of the reasoning detail, with possible values: + "unknown" - Format is not specified + "openai-responses-v1" - OpenAI responses format version 1 + "anthropic-claude-v1" - Anthropic Claude format version 1 (default) + */ + format: string //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1" + index?: number // Sequential index of the reasoning detail +} + +// Helper function to convert reasoning_details array to the format OpenRouter API expects +// Takes an array of reasoning detail objects and consolidates them by index +function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] { + if (!reasoningDetails || reasoningDetails.length === 0) { + return [] + } + + // Group by index + const groupedByIndex = new Map() + + for (const detail of reasoningDetails) { + const index = detail.index ?? 0 + if (!groupedByIndex.has(index)) { + groupedByIndex.set(index, []) + } + groupedByIndex.get(index)!.push(detail) + } + + // Consolidate each group + const consolidated: ReasoningDetail[] = [] + + for (const [index, details] of groupedByIndex.entries()) { + // Concatenate all text parts + let concatenatedText = "" + let signature: string | undefined + let id: string | undefined + let format = "unknown" + let type = "reasoning.text" + + for (const detail of details) { + if (detail.text) { + concatenatedText += detail.text + } + // Keep the signature from the last item that has one + if (detail.signature) { + signature = detail.signature + } + // Keep the id from the last item that has one + if (detail.id) { + id = detail.id + } + // Keep format and type from any item (they should all be the same) + if (detail.format) { + format = detail.format + } + if (detail.type) { + type = detail.type + } + } + + // Create consolidated entry for text + if (concatenatedText) { + const consolidatedEntry: ReasoningDetail = { + type: type, + text: concatenatedText, + signature: signature, + id: id, + format: format, + index: index, + } + consolidated.push(consolidatedEntry) + } + + // For encrypted chunks (data), only keep the last one + let lastDataEntry: ReasoningDetail | undefined + for (const detail of details) { + if (detail.data) { + lastDataEntry = { + type: detail.type, + data: detail.data, + signature: detail.signature, + id: detail.id, + format: detail.format, + index: index, + } + } + } + if (lastDataEntry) { + consolidated.push(lastDataEntry) + } + } + + return consolidated +} diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index cd6c3a56a7..55a72e6e85 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -4,6 +4,7 @@ export type ApiStreamChunk = | ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk + | ApiStreamReasoningDetailsChunk | ApiStreamGroundingChunk | ApiStreamToolCallChunk | ApiStreamError @@ -24,6 +25,11 @@ export interface ApiStreamReasoningChunk { text: string } +export interface ApiStreamReasoningDetailsChunk { + type: "reasoning_details" + reasoning_details: any // OpenRouter specific format +} + export interface ApiStreamUsageChunk { type: "usage" inputTokens: number diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c0de86aa95..c04f7f4706 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2193,6 +2193,7 @@ export class Task extends EventEmitter implements TaskLike { const stream = this.attemptApiRequest() let assistantMessage = "" let reasoningMessage = "" + const reasoningDetails = [] let pendingGroundingSources: GroundingSource[] = [] this.isStreaming = true @@ -2225,6 +2226,15 @@ export class Task extends EventEmitter implements TaskLike { await this.say("reasoning", formattedReasoning, undefined, true) break } + // for cline/openrouter providers + case "reasoning_details": + // reasoning_details may be an array of 0 or 1 items depending on how openrouter returns it + if (Array.isArray(chunk.reasoning_details)) { + reasoningDetails.push(...chunk.reasoning_details) + } else { + reasoningDetails.push(chunk.reasoning_details) + } + break case "usage": inputTokens += chunk.inputTokens outputTokens += chunk.outputTokens @@ -2692,7 +2702,17 @@ export class Task extends EventEmitter implements TaskLike { await this.addToApiConversationHistory( { role: "assistant", - content: assistantContent, + content: assistantContent.map((block) => { + if (block.type === "text" && reasoningDetails.length > 0) { + return { + ...block, + // reasoning_details only exists for cline/openrouter providers + // @ts-ignore-next-line (reasoning_details is not a valid property for TextBlockParam) + reasoning_details: reasoningDetails, + } + } + return block + }), }, reasoningMessage || undefined, ) diff --git a/src/utils/model-utils.ts b/src/utils/model-utils.ts new file mode 100644 index 0000000000..fff0ae942e --- /dev/null +++ b/src/utils/model-utils.ts @@ -0,0 +1,10 @@ +/** + * Determines if reasoning content should be skipped for a given model + * Currently skips reasoning for Grok-4 models since they only display "thinking" without useful information + */ +export function shouldSkipReasoningForModel(modelId?: string): boolean { + if (!modelId) { + return false + } + return modelId.includes("grok-4") +}