mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: support openrouter reasoning_details
This commit is contained in:
parent
8472bbb43b
commit
cd4e50e986
5 changed files with 177 additions and 1 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
10
src/utils/model-utils.ts
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue