diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 5b8c29c337..5b5d30e398 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -361,7 +361,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } let lastUsage: CompletionUsage | undefined = undefined - // Accumulator for reasoning_details: accumulate text by type-index key + // Accumulator for reasoning_details FROM the API. + // We preserve the original shape of reasoning_details to prevent malformed responses. const reasoningDetailsAccumulator = new Map< string, { @@ -376,6 +377,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } >() + // Track whether we've yielded displayable text from reasoning_details. + // When reasoning_details has displayable content (reasoning.text or reasoning.summary), + // we skip yielding the top-level reasoning field to avoid duplicate display. + let hasYieldedReasoningFromDetails = false + for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. if ("error" in chunk) { @@ -438,22 +444,28 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Yield text for display (still fragmented for live streaming) + // Only reasoning.text and reasoning.summary have displayable content + // reasoning.encrypted is intentionally skipped as it contains redacted content 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) { + hasYieldedReasoningFromDetails = true 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 } + } + + // Handle top-level reasoning field for UI display. + // Skip if we've already yielded from reasoning_details to avoid duplicate display. + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning } + } } // Emit raw tool call chunks - NativeToolCallParser handles state management @@ -488,7 +500,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - // After streaming completes, store the accumulated reasoning_details + // After streaming completes, store ONLY the reasoning_details we received from the API. if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) } diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index ebc174cf46..893f644c7b 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -146,7 +146,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const stream = await this.createStream(systemPrompt, messages, metadata, { headers }) let lastUsage: RooUsage | undefined = undefined - // Accumulator for reasoning_details: accumulate text by type-index key + // Accumulator for reasoning_details FROM the API. + // We preserve the original shape of reasoning_details to prevent malformed responses. const reasoningDetailsAccumulator = new Map< string, { @@ -161,6 +162,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } >() + // Track whether we've yielded displayable text from reasoning_details. + // When reasoning_details has displayable content (reasoning.text or reasoning.summary), + // we skip yielding the top-level reasoning field to avoid duplicate display. + let hasYieldedReasoningFromDetails = false + for await (const chunk of stream) { const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason @@ -223,29 +229,32 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } // Yield text for display (still fragmented for live streaming) + // Only reasoning.text and reasoning.summary have displayable content + // reasoning.encrypted is intentionally skipped as it contains redacted content 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) { + hasYieldedReasoningFromDetails = true 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 - yield { - type: "reasoning", - text: delta.reasoning, + } + + // Handle top-level reasoning field for UI display. + // Skip if we've already yielded from reasoning_details to avoid duplicate display. + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning } } } else if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") { // Also check for reasoning_content for backward compatibility - yield { - type: "reasoning", - text: delta.reasoning_content, + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning_content } } } @@ -282,7 +291,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // After streaming completes, store the accumulated reasoning_details + // After streaming completes, store ONLY the reasoning_details we received from the API. if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) } diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 29fd712c84..2e7f61c9f3 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -401,4 +401,371 @@ describe("convertToOpenAiMessages", () => { expect(openAiMessages[0].role).toBe("user") }) }) + + describe("reasoning_details transformation", () => { + it("should preserve reasoning_details when assistant content is a string", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Why don't scientists trust atoms? Because they make up everything!", + reasoning_details: [ + { + type: "reasoning.summary", + summary: "The user asked for a joke.", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data_here", + id: "rs_abc", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + expect(assistantMessage.content).toBe("Why don't scientists trust atoms? Because they make up everything!") + expect(assistantMessage.reasoning_details).toHaveLength(2) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") + expect(assistantMessage.reasoning_details[1].id).toBe("rs_abc") + }) + + it("should strip id from openai-responses-v1 blocks even when assistant content is a string", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Ok.", + reasoning_details: [ + { + type: "reasoning.summary", + id: "rs_should_be_stripped", + format: "openai-responses-v1", + index: 0, + summary: "internal", + data: "gAAAAA...", + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + }) + + it("should pass through all reasoning_details without extracting to top-level reasoning", () => { + // This simulates the stored format after receiving from xAI/Roo API + // The provider (roo.ts) now consolidates all reasoning into reasoning_details + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "I'll help you with that." }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: '\n\n## Reviewing task progress', + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", + id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + + // Should NOT have top-level reasoning field - we only use reasoning_details now + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details preserving all fields + expect(assistantMessage.reasoning_details).toHaveLength(2) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[0].summary).toBe( + '\n\n## Reviewing task progress', + ) + expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") + expect(assistantMessage.reasoning_details[1].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") + expect(assistantMessage.reasoning_details[1].data).toBe( + "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", + ) + }) + + it("should strip id from openai-responses-v1 blocks to avoid 404 errors (store: false)", () => { + // IMPORTANT: OpenAI's API returns a 404 error when we send back an `id` for + // reasoning blocks with format "openai-responses-v1" because we don't use + // `store: true` (we handle conversation state client-side). The error message is: + // "'{id}' not found. Items are not persisted when `store` is set to false." + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_Tb4KVEmEpEAA8W1QcxjyD5Nh", + name: "attempt_completion", + input: { + result: "Why did the developer go broke?\n\nBecause they used up all their cache.", + }, + }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + id: "rs_0de1fb80387fb36501694ad8d71c3081949934e6bb177e5ec5", + format: "openai-responses-v1", + index: 0, + summary: "It looks like I need to make sure I'm using the tool every time.", + data: "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field - we only use reasoning_details now + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through reasoning_details preserving most fields BUT stripping id + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + // id should be STRIPPED for openai-responses-v1 format to avoid 404 errors + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + expect(assistantMessage.reasoning_details[0].summary).toBe( + "It looks like I need to make sure I'm using the tool every time.", + ) + expect(assistantMessage.reasoning_details[0].data).toBe( + "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", + ) + expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") + + // Should have tool_calls + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls[0].id).toBe("call_Tb4KVEmEpEAA8W1QcxjyD5Nh") + }) + + it("should preserve id for non-openai-responses-v1 formats (e.g., xai-responses-v1)", () => { + // For other formats like xai-responses-v1, we should preserve the id + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response" }], + reasoning_details: [ + { + type: "reasoning.encrypted", + id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", + format: "xai-responses-v1", + data: "encrypted_data_here", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should preserve id for xai-responses-v1 format + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") + expect(assistantMessage.reasoning_details[0].format).toBe("xai-responses-v1") + }) + + it("should handle assistant messages with tool_calls and reasoning_details", () => { + // This simulates a message with both tool calls and reasoning + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_62462410", + name: "read_file", + input: { files: [{ path: "alphametics.go" }] }, + }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "## Reading the file to understand the structure", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data_here", + id: "rs_12345", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(2) + + // Should have tool_calls + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls[0].id).toBe("call_62462410") + expect(assistantMessage.tool_calls[0].function.name).toBe("read_file") + }) + + it("should pass through reasoning_details with only encrypted blocks", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response text" }], + reasoning_details: [ + { + type: "reasoning.encrypted", + data: "encrypted_data", + id: "rs_only_encrypted", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should still pass through reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted") + }) + + it("should pass through reasoning_details even when only summary blocks exist (no encrypted)", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response text" }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "Just a summary, no encrypted content", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through reasoning_details preserving the summary block + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[0].summary).toBe("Just a summary, no encrypted content") + }) + + it("should handle messages without reasoning_details", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Simple response" }], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should not have reasoning or reasoning_details + expect(assistantMessage.reasoning).toBeUndefined() + expect(assistantMessage.reasoning_details).toBeUndefined() + }) + + it("should pass through multiple reasoning_details blocks preserving all fields", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response" }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "First part of thinking. ", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.summary", + summary: "Second part of thinking.", + format: "xai-responses-v1", + index: 1, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data", + id: "rs_multi", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(3) + expect(assistantMessage.reasoning_details[0].summary).toBe("First part of thinking. ") + expect(assistantMessage.reasoning_details[1].summary).toBe("Second part of thinking.") + expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data") + }) + }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index e481864034..de48d27a3f 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -27,12 +27,47 @@ export function convertToOpenAiMessages( ): OpenAI.Chat.ChatCompletionMessageParam[] { const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] + const mapReasoningDetails = (details: unknown): any[] | undefined => { + if (!Array.isArray(details)) { + return undefined + } + + return details.map((detail: any) => { + // Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API + // requires `store: true` to persist reasoning blocks. Since we manage + // conversation state client-side, we don't use `store: true`, and sending + // back the `id` field causes a 404 error. + if (detail?.format === "openai-responses-v1" && detail?.id) { + const { id, ...rest } = detail + return rest + } + return detail + }) + } + // Use provided normalization function or identity function const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id) for (const anthropicMessage of anthropicMessages) { if (typeof anthropicMessage.content === "string") { - openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content }) + // Some upstream transforms (e.g. [`Task.buildCleanConversationHistory()`](src/core/task/Task.ts:4048)) + // will convert a single text block into a string for compactness. + // If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.), + // we must preserve it here as well. + const messageWithDetails = anthropicMessage as any + const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { + role: anthropicMessage.role, + content: anthropicMessage.content, + } + + if (anthropicMessage.role === "assistant") { + const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) + if (mapped) { + ;(baseMessage as any).reasoning_details = mapped + } + } + + openAiMessages.push(baseMessage) } else { // image_url.url is base64 encoded image data // ensure it contains the content-type of the image: data:image/png;base64, @@ -178,24 +213,24 @@ export function convertToOpenAiMessages( }, })) - // Check if the message has reasoning_details (used by Gemini 3, etc.) + // Check if the message has reasoning_details (used by Gemini 3, xAI, etc.) const messageWithDetails = anthropicMessage as any // Build message with reasoning_details BEFORE tool_calls to preserve // the order expected by providers like Roo. Property order matters // when sending messages back to some APIs. - const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { reasoning_details?: any[] } = { + const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { + reasoning_details?: any[] + } = { role: "assistant", content, } - // Add reasoning_details first (before tool_calls) to preserve provider-expected order - // Strip the id field from each reasoning detail as it's only used internally for accumulation - if (messageWithDetails.reasoning_details && Array.isArray(messageWithDetails.reasoning_details)) { - baseMessage.reasoning_details = messageWithDetails.reasoning_details.map((detail: any) => { - const { id, ...rest } = detail - return rest - }) + // Pass through reasoning_details to preserve the original shape from the API. + // The `id` field is stripped from openai-responses-v1 blocks (see mapReasoningDetails). + const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) + if (mapped) { + baseMessage.reasoning_details = mapped } // Add tool_calls after reasoning_details