fix: preserve reasoning_details shape to prevent malformed responses (#10313)

This commit is contained in:
Hannes Rudolph 2025-12-23 14:58:35 -07:00 committed by GitHub
parent ded6486a46
commit 53e1ff0058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 451 additions and 28 deletions

View file

@ -363,7 +363,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,
{
@ -378,6 +379,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) {
@ -440,22 +446,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
@ -490,7 +502,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())
}

View file

@ -146,7 +146,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
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<string> {
}
>()
// 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<string> {
}
// 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<string> {
}
}
// 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())
}

View file

@ -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: '<xai:function_call name="update_todo_list">\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(
'<xai:function_call name="update_todo_list">\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")
})
})
})

View file

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