From 49288108aa4ee6ccb3562d065072c327ad77627c Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 14 Jan 2026 12:11:30 -0500 Subject: [PATCH] fix: filter out empty text blocks from user messages for Gemini compatibility Gemini models via OpenRouter require non-empty content in the 'parts' field. When user messages contain empty text blocks ({ type: 'text', text: '' }), the API rejects the request with 'must include at least one parts field'. This fix filters out empty text blocks when converting Anthropic messages to OpenAI format, ensuring only valid content is sent to the API. - Filter empty text blocks from user messages before creating OpenAI format - Preserve images (they always have base64 content) - Skip user message creation if all content blocks are empty - Add 3 test cases for the new filtering behavior Fixes: https://us.posthog.com/error_tracking/019bbc67-ebdc-7640-967c-df1c8f43172b --- .../transform/__tests__/openai-format.spec.ts | 92 +++++++++++++++++++ src/api/transform/openai-format.ts | 14 ++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 1523b59d3f1..84ea03647a0 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -327,6 +327,98 @@ describe("convertToOpenAiMessages", () => { expect(toolMessage.content).toBe("(empty)") }) + describe("empty text block filtering", () => { + it("should filter out empty text blocks from user messages (Gemini compatibility)", () => { + // This test ensures that user messages with empty text blocks are filtered out + // to prevent "must include at least one parts field" error from Gemini (via OpenRouter). + // Empty text blocks can occur in edge cases during message construction. + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "", // Empty text block should be filtered out + }, + { + type: "text", + text: "Hello, how are you?", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + expect(openAiMessages).toHaveLength(1) + expect(openAiMessages[0].role).toBe("user") + + const content = openAiMessages[0].content as Array<{ type: string; text?: string }> + // Should only have the non-empty text block + expect(content).toHaveLength(1) + expect(content[0]).toEqual({ type: "text", text: "Hello, how are you?" }) + }) + + it("should not create user message when all text blocks are empty (Gemini compatibility)", () => { + // If all text blocks are empty, no user message should be created + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "", // Empty + }, + { + type: "text", + text: "", // Also empty + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + // No messages should be created since all content is empty + expect(openAiMessages).toHaveLength(0) + }) + + it("should preserve image blocks when filtering empty text blocks", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "", // Empty text block should be filtered out + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "base64data", + }, + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + expect(openAiMessages).toHaveLength(1) + expect(openAiMessages[0].role).toBe("user") + + const content = openAiMessages[0].content as Array<{ + type: string + image_url?: { url: string } + }> + // Should only have the image block + expect(content).toHaveLength(1) + expect(content[0]).toEqual({ + type: "image_url", + image_url: { url: "data:image/png;base64,base64data" }, + }) + }) + }) + describe("mergeToolResultText option", () => { it("should merge text content into last tool message when mergeToolResultText is true", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 117b81e1d4e..a11e1270f9d 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -138,11 +138,17 @@ export function convertToOpenAiMessages( // } // Process non-tool messages - if (nonToolMessages.length > 0) { + // Filter out empty text blocks to prevent "must include at least one parts field" error + // from Gemini (via OpenRouter). Images always have content (base64 data). + const filteredNonToolMessages = nonToolMessages.filter( + (part) => part.type === "image" || (part.type === "text" && part.text), + ) + + if (filteredNonToolMessages.length > 0) { // Check if we should merge text into the last tool message // This is critical for reasoning/thinking models where a user message // after tool results causes the model to drop all previous reasoning_content - const hasOnlyTextContent = nonToolMessages.every((part) => part.type === "text") + const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text") const hasToolMessages = toolMessages.length > 0 const shouldMergeIntoToolMessage = options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent @@ -153,7 +159,7 @@ export function convertToOpenAiMessages( openAiMessages.length - 1 ] as OpenAI.Chat.ChatCompletionToolMessageParam if (lastToolMessage?.role === "tool") { - const additionalText = nonToolMessages + const additionalText = filteredNonToolMessages .map((part) => (part as Anthropic.TextBlockParam).text) .join("\n") lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` @@ -162,7 +168,7 @@ export function convertToOpenAiMessages( // Standard behavior: add user message with text/image content openAiMessages.push({ role: "user", - content: nonToolMessages.map((part) => { + content: filteredNonToolMessages.map((part) => { if (part.type === "image") { return { type: "image_url",