From 6916852b18c3335951c1d5b9f92b934131268d82 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 23 Dec 2025 19:09:41 +0000 Subject: [PATCH] fix: preserve id for reasoning.encrypted blocks in OpenAI message transform Gemini 3 models require the id field to be preserved for reasoning.encrypted blocks to link thought signatures to function calls. Without this id, the API returns an error about missing thought_signature in functionCall parts. The id field is now only stripped for non-encrypted reasoning types (like reasoning.text and reasoning.summary), which are used internally for accumulation. --- .../transform/__tests__/openai-format.spec.ts | 144 ++++++++++++++++++ src/api/transform/openai-format.ts | 9 +- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 29fd712c84..342f00f5c4 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -401,4 +401,148 @@ describe("convertToOpenAiMessages", () => { expect(openAiMessages[0].role).toBe("user") }) }) + + describe("reasoning_details handling", () => { + it("should strip id from reasoning.text blocks", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "I will help you.", + }, + ], + reasoning_details: [ + { + type: "reasoning.text", + text: "Let me think about this...", + format: "google-gemini-v1", + index: 0, + id: "internal_accumulation_id", + }, + ], + } as any, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as any + + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0]).toEqual({ + type: "reasoning.text", + text: "Let me think about this...", + format: "google-gemini-v1", + index: 0, + }) + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + }) + + it("should preserve id for reasoning.encrypted blocks (required by Gemini 3)", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool_new_task_k7LktBScQZtG5uIJYm6g", + name: "new_task", + input: { mode: "code", message: "test" }, + }, + ], + reasoning_details: [ + { + type: "reasoning.text", + text: "Let me think...", + format: "google-gemini-v1", + index: 0, + id: "internal_id_to_strip", + }, + { + type: "reasoning.encrypted", + data: "encrypted_thought_signature_data", + id: "tool_new_task_k7LktBScQZtG5uIJYm6g", + format: "google-gemini-v1", + index: 0, + }, + ], + } as any, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as any + + expect(assistantMessage.reasoning_details).toHaveLength(2) + + // reasoning.text should have id stripped + expect(assistantMessage.reasoning_details[0]).toEqual({ + type: "reasoning.text", + text: "Let me think...", + format: "google-gemini-v1", + index: 0, + }) + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + + // reasoning.encrypted should preserve id (required for tool call thought signatures) + expect(assistantMessage.reasoning_details[1]).toEqual({ + type: "reasoning.encrypted", + data: "encrypted_thought_signature_data", + id: "tool_new_task_k7LktBScQZtG5uIJYm6g", + format: "google-gemini-v1", + index: 0, + }) + }) + + it("should handle reasoning_details without id field", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Response text", + }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "Summary of reasoning", + format: "google-gemini-v1", + index: 0, + }, + ], + } as any, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as any + + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0]).toEqual({ + type: "reasoning.summary", + summary: "Summary of reasoning", + format: "google-gemini-v1", + index: 0, + }) + }) + + it("should not add reasoning_details if not present", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Simple response", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as any + + expect(assistantMessage.reasoning_details).toBeUndefined() + }) + }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index e481864034..a3195d7e2e 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -190,9 +190,16 @@ export function convertToOpenAiMessages( } // 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 + // Strip the id field from reasoning details as it's only used internally for accumulation, + // EXCEPT for reasoning.encrypted which requires the id (tool call ID) to link thought + // signatures to function calls (required by Gemini 3 models via OpenRouter) if (messageWithDetails.reasoning_details && Array.isArray(messageWithDetails.reasoning_details)) { baseMessage.reasoning_details = messageWithDetails.reasoning_details.map((detail: any) => { + // Keep id for reasoning.encrypted (tool call thought signatures) + // Strip id for other types (used internally for accumulation) + if (detail.type === "reasoning.encrypted") { + return detail + } const { id, ...rest } = detail return rest })