diff --git a/.changeset/fix-gemini-function-response.md b/.changeset/fix-gemini-function-response.md new file mode 100644 index 000000000..4c96862fd --- /dev/null +++ b/.changeset/fix-gemini-function-response.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-gemini': patch +--- + +Fix 400 error when sending tool results to Gemini API by removing redundant text part from functionResponse messages. Newer models (gemini-3.1-flash-lite, gemma-4) reject messages that mix text and functionResponse parts. diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index 844c4a12f..4430534c0 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -562,7 +562,7 @@ export class GeminiTextAdapter< for (const contentPart of msg.content) { parts.push(this.convertContentPartToGemini(contentPart)) } - } else if (msg.content) { + } else if (msg.content && msg.role !== 'tool') { parts.push({ text: msg.content }) } diff --git a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts index 8f1aabefb..d713cfc1f 100644 --- a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts @@ -394,12 +394,19 @@ describe('GeminiAdapter through AI', () => { expect(payload.contents[1].role).toBe('model') expect(payload.contents[2].role).toBe('user') - // Last user message should contain both functionResponse and text + // Last user message should contain functionResponse (no redundant text part + // for the tool result) and the follow-up user text const lastParts = payload.contents[2].parts const hasFunctionResponse = lastParts.some((p: any) => p.functionResponse) - const hasText = lastParts.some((p: any) => p.text === 'What about Paris?') + const hasFollowUp = lastParts.some( + (p: any) => p.text === 'What about Paris?', + ) + const hasToolResultText = lastParts.some( + (p: any) => p.text === '{"temp":72}', + ) expect(hasFunctionResponse).toBe(true) - expect(hasText).toBe(true) + expect(hasFollowUp).toBe(true) + expect(hasToolResultText).toBe(false) }) it('handles full multi-turn with duplicate tool results and empty model message', async () => { @@ -487,15 +494,16 @@ describe('GeminiAdapter through AI', () => { expect(payload.contents).toHaveLength(3) // Last user should have deduplicated functionResponses + follow-up text + // (no redundant text parts for tool results) const lastParts = payload.contents[2].parts const functionResponses = lastParts.filter((p: any) => p.functionResponse) // 2 unique tool call IDs, not 3 (duplicate removed) expect(functionResponses).toHaveLength(2) - const textParts = lastParts.filter( - (p: any) => p.text === "what's a good electric guitar?", - ) + const textParts = lastParts.filter((p: any) => p.text) + // Only the follow-up user message text, no tool result text parts expect(textParts).toHaveLength(1) + expect(textParts[0].text).toBe("what's a good electric guitar?") }) it('preserves thoughtSignature in functionCall parts when sending history back to Gemini', async () => {