From d19f7bdee03922df67517e7c0bb336abecafd3b2 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 19 Nov 2025 17:27:41 -0700 Subject: [PATCH] fix(openrouter): Fix reasoning details preservation and schema validation - Re-index reasoning_details sequentially to prevent duplicate indices (fixes 400 error) - Ensure reasoning_details is omitted if empty to avoid invalid empty array error - Prevent creation of invalid empty text blocks from metadata-only updates - Force creation of text block in Task.ts if reasoning_details exist but text is empty, ensuring preservation --- .../transform/__tests__/openai-format.spec.ts | 121 ++++++++++++++++++ src/api/transform/openai-format.ts | 22 +++- src/core/task/Task.ts | 9 +- 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index bab655dcb5..a914383290 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -128,4 +128,125 @@ describe("convertToOpenAiMessages", () => { expect(toolMessage.tool_call_id).toBe("weather-123") expect(toolMessage.content).toBe("Current temperature in London: 20°C") }) + it("should handle assistant messages with reasoning details and preserve index field", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "I will now reason about this.", + // @ts-ignore + reasoning_details: [ + { + type: "reasoning.text", + text: "This is the first part", + index: 0, + signature: "sig1", + format: "format1", + }, + { + type: "reasoning.text", + text: " of the reasoning.", + index: 0, + signature: "sig1", + format: "format1", + }, + ], + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + expect(openAiMessages).toHaveLength(1) + + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + expect(assistantMessage.content).toBe("I will now reason about this.") + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0]).toEqual({ + type: "reasoning.text", + text: "This is the first part of the reasoning.", + signature: "sig1", + format: "format1", + index: 0, + }) + expect(assistantMessage.reasoning_details[0]).toHaveProperty("index") + }) + it("should not include reasoning_details if they consolidate to empty array", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Response.", + // @ts-ignore + reasoning_details: [ + { + index: 0, + // No text, no data + }, + ], + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + expect(openAiMessages).toHaveLength(1) + + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + expect(assistantMessage.reasoning_details).toBeUndefined() + }) + it("should re-index reasoning details sequentially", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Response.", + // @ts-ignore + reasoning_details: [ + { + type: "reasoning.text", + text: "Part 1", + index: 0, + format: "fmt", + }, + { + type: "reasoning.encrypted", + data: "data1", + index: 0, + format: "fmt", + }, + { + type: "reasoning.text", + text: "Part 2", + index: 5, // Gap in index + format: "fmt", + }, + ], + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as any + const reasoning = assistantMessage.reasoning_details + + expect(reasoning).toHaveLength(3) + expect(reasoning[0].index).toBe(0) + expect(reasoning[0].text).toBe("Part 1") + + expect(reasoning[1].index).toBe(1) + expect(reasoning[1].data).toBe("data1") + + expect(reasoning[2].index).toBe(2) + expect(reasoning[2].text).toBe("Part 2") + }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 3cbc675b1c..0debca1d6a 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -148,6 +148,10 @@ export function convertToOpenAiMessages( }, })) + // @ts-ignore-next-line + const consolidatedReasoning = + reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails) : undefined + openAiMessages.push({ role: "assistant", content, @@ -155,7 +159,7 @@ export function convertToOpenAiMessages( tool_calls: tool_calls.length > 0 ? tool_calls : undefined, // @ts-ignore-next-line reasoning_details: - reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails) : undefined, + consolidatedReasoning && consolidatedReasoning.length > 0 ? consolidatedReasoning : undefined, }) } } @@ -203,18 +207,21 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso // Consolidate each group const consolidated: ReasoningDetail[] = [] + let outputIndex = 0 - for (const [index, details] of groupedByIndex.entries()) { + for (const [_, details] of groupedByIndex.entries()) { // Concatenate all text parts let concatenatedText = "" + let hasText = false let signature: string | undefined let id: string | undefined let format = "unknown" let type = "reasoning.text" for (const detail of details) { - if (detail.text) { + if (detail.text !== undefined) { concatenatedText += detail.text + hasText = true } // Keep the signature from the last item that has one if (detail.signature) { @@ -233,15 +240,16 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso } } - // Create consolidated entry for text - if (concatenatedText) { + // Create consolidated entry for text if any text parts were found + // This avoids creating text entries for purely encrypted blocks or metadata-only updates that belong to encrypted blocks + if (hasText) { const consolidatedEntry: ReasoningDetail = { type: type, text: concatenatedText, signature: signature, id: id, format: format, - index: index, + index: outputIndex++, } consolidated.push(consolidatedEntry) } @@ -256,7 +264,7 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso signature: detail.signature, id: detail.id, format: detail.format, - index: index, + index: outputIndex++, } } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 487d181978..18294ff2ff 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2657,8 +2657,9 @@ export class Task extends EventEmitter implements TaskLike { // Check if we have any content to process (text or tool uses) const hasTextContent = assistantMessage.length > 0 const hasToolUses = this.assistantMessageContent.some((block) => block.type === "tool_use") + const hasReasoningDetails = reasoningDetails.length > 0 - if (hasTextContent || hasToolUses) { + if (hasTextContent || hasToolUses || hasReasoningDetails) { // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) @@ -2672,11 +2673,11 @@ export class Task extends EventEmitter implements TaskLike { // Build the assistant message content array const assistantContent: Array = [] - // Add text content if present - if (assistantMessage) { + // Add text content if present, or if we have reasoning details that need a text block to be attached to + if (assistantMessage || hasReasoningDetails) { assistantContent.push({ type: "text" as const, - text: assistantMessage, + text: assistantMessage, // This will be empty string if !assistantMessage, which is fine }) }