Compare commits

...
4 changed files with 135 additions and 8 deletions
+20
View File
@@ -160,6 +160,26 @@ export class ClineHandler implements ApiHandler {
}
}
/*
OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
- The reasoning_details array in each chunk may contain one or more reasoning objects
- For encrypted reasoning, the content may appear as [REDACTED] in streaming responses
- The complete reasoning sequence is built by concatenating all chunks in order
See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
*/
if (
"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 (!didOutputUsage && chunk.usage) {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
+94 -2
View File
@@ -121,7 +121,15 @@ export function convertToOpenAiMessages(
// @ts-ignore-next-line
if (part.type === "text" && part.reasoning_details) {
// @ts-ignore-next-line
reasoningDetails.push(part.reasoning_details)
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
@@ -151,7 +159,7 @@ export function convertToOpenAiMessages(
// 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 ? reasoningDetails : undefined,
reasoning_details: reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails) : undefined,
})
}
}
@@ -160,6 +168,90 @@ 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
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
const consolidatedEntry: ReasoningDetail = {
type: type,
text: concatenatedText,
signature: signature,
id: id,
format: format,
index: index,
}
consolidated.push(consolidatedEntry)
}
return consolidated
}
// Convert OpenAI response to Anthropic format
export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.ChatCompletion): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
+6 -1
View File
@@ -2057,7 +2057,12 @@ export class Task {
break
// for cline/openrouter providers
case "reasoning_details":
reasoningDetails.push(chunk.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
// for anthropic providers
case "ant_thinking":
@@ -61,12 +61,27 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
// Store the number of options for telemetry
const options = parsePartialArrayString(optionsRaw || "[]")
const sharedMessage = {
response: response,
options: options,
}
// Auto-switch to Act mode while in yolo mode
if (config.mode === "plan" && config.yoloModeToggled && !needsMoreExploration) {
// Trigger automatic mode switch
const switchSuccessful = await config.callbacks.switchToActMode()
if (switchSuccessful) {
// Complete the plan mode response tool call (this is a unique case where we auto-respond to the user with an ask response)
const lastPlanMessage = findLast(config.messageState.getClineMessages(), (m: any) => m.ask === this.name)
if (lastPlanMessage) {
lastPlanMessage.text = JSON.stringify({
...sharedMessage,
} satisfies ClinePlanModeResponse)
lastPlanMessage.partial = false
await config.messageState.saveClineMessagesAndUpdateHistory()
}
// we dont need to process any text, options, files or other content here
return formatResponse.toolResult(`[The user has switched to ACT MODE, so you may now proceed with the task.]`)
} else {
@@ -77,11 +92,6 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
// Set awaiting plan response state
config.taskState.isAwaitingPlanResponse = true
const sharedMessage = {
response: response,
options: options,
}
// Ask for user response
let {
text,