Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-gemini-function-response.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/typescript/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

Expand Down
20 changes: 14 additions & 6 deletions packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading