feat: support openrouter reasoning_details

This commit is contained in:
miposhka 2025-11-08 19:46:41 +08:00 committed by Hannes Rudolph
parent 8472bbb43b
commit cd4e50e986
5 changed files with 177 additions and 1 deletions

View file

@ -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
}

View file

@ -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<number, ReasoningDetail[]>()
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
}

View file

@ -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

View file

@ -2193,6 +2193,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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,
)

10
src/utils/model-utils.ts Normal file
View file

@ -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")
}