Summary
Multi-turn tool calling with GeminiLanguageModel fails with HTTP 400 on Gemini 2.5+ models. Gemini attaches a thought_signature to each functionCall part and requires it to be echoed back verbatim when function results are returned. GeminiPart's decoder explicitly drops it, so the follow-up request is rejected and the tool loop can never complete.
Reproduction
- Create a
GeminiLanguageModel (default init) with a thinking-capable model — reproduced on Gemini 2.5.
- Create a
LanguageModelSession with at least one Tool.
- Call
respond(to:) with a prompt that requires the tool.
The first request succeeds and the tool executes; the second request (returning the function result) fails.
Actual result
{"error":{"code":400,"status":"INVALID_ARGUMENT","message":
"Function call is missing a thought_signature in functionCall parts.
This is required for tools to work correctly, and missing thought_signature
may lead to degraded model performance.
Additional data, function call `default_api:web_search`, position 2.
Please refer to https://ai.google.dev/gemini-api/docs/thought-signatures for more details."}}
Root cause
Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift — GeminiPart.init(from:):
} else if container.contains(.functionCall) {
// Note: thoughtSignature may be present but is ignored
self = .functionCall(try container.decode(GeminiFunctionCall.self, forKey: .functionCall))
}
CodingKeys declares case thoughtSignature, but it is never decoded, never stored, and never re-encoded. When the request is rebuilt from the transcript, only GeminiFunctionCall(name:args:) is constructed, so there is nowhere for the signature to live.
This code is unchanged on main (verified against 0.9.0 — the file has not been modified since 226542d, 2026-03-23).
Why consumers cannot work around it
The signature never reaches any type visible to library consumers. Unlike the Anthropic path — where the transcript passed to a follow-up session is consumer-constructed and can be adjusted — Gemini requests are assembled entirely inside GeminiLanguageModel, so there is no injection point.
Related: .disabled does not actually disable thinking
The natural workaround is to turn thinking off, since no thinking means no signature. That currently doesn't work through the public API:
if case .disabled = thinking {
thinkingConfig["includeThoughts"] = .bool(false)
} else {
thinkingConfig["includeThoughts"] = .bool(true)
if let budget = thinking.budgetValue { thinkingConfig["thinkingBudget"] = .int(budget) }
}
.disabled only sets includeThoughts: false, which suppresses thought summaries — the model still thinks and still emits signatures. thinkingBudget is never sent on that branch, so Thinking.budgetValue's case .disabled: return 0 is unreachable here. Consumers must pass .budget(0) to get thinkingBudget: 0 onto the wire, which reads as a bug given the case is named .disabled.
(This workaround is also limited: Gemini 2.5 Pro has a minimum thinking budget and Gemini 3 uses thinking_level, so budget 0 is not universally available.)
Suggested fix
Preserve thoughtSignature on GeminiPart.functionCall when decoding, carry it through the transcript representation, and re-encode it on subsequent requests. Google's guidance is that the field must be returned unchanged.
Separately, consider making .disabled send thinkingBudget: 0 so the case matches its name.
Context
This affects every client that manages conversation history itself; several other projects have hit the same wall:
Environment: AnyLanguageModel 0.9.0, Swift 6.1, macOS/iOS 27.
Summary
Multi-turn tool calling with
GeminiLanguageModelfails with HTTP 400 on Gemini 2.5+ models. Gemini attaches athought_signatureto eachfunctionCallpart and requires it to be echoed back verbatim when function results are returned.GeminiPart's decoder explicitly drops it, so the follow-up request is rejected and the tool loop can never complete.Reproduction
GeminiLanguageModel(default init) with a thinking-capable model — reproduced on Gemini 2.5.LanguageModelSessionwith at least oneTool.respond(to:)with a prompt that requires the tool.The first request succeeds and the tool executes; the second request (returning the function result) fails.
Actual result
{"error":{"code":400,"status":"INVALID_ARGUMENT","message": "Function call is missing a thought_signature in functionCall parts. This is required for tools to work correctly, and missing thought_signature may lead to degraded model performance. Additional data, function call `default_api:web_search`, position 2. Please refer to https://ai.google.dev/gemini-api/docs/thought-signatures for more details."}}Root cause
Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift—GeminiPart.init(from:):CodingKeysdeclarescase thoughtSignature, but it is never decoded, never stored, and never re-encoded. When the request is rebuilt from the transcript, onlyGeminiFunctionCall(name:args:)is constructed, so there is nowhere for the signature to live.This code is unchanged on
main(verified against 0.9.0 — the file has not been modified since 226542d, 2026-03-23).Why consumers cannot work around it
The signature never reaches any type visible to library consumers. Unlike the Anthropic path — where the transcript passed to a follow-up session is consumer-constructed and can be adjusted — Gemini requests are assembled entirely inside
GeminiLanguageModel, so there is no injection point.Related:
.disableddoes not actually disable thinkingThe natural workaround is to turn thinking off, since no thinking means no signature. That currently doesn't work through the public API:
.disabledonly setsincludeThoughts: false, which suppresses thought summaries — the model still thinks and still emits signatures.thinkingBudgetis never sent on that branch, soThinking.budgetValue'scase .disabled: return 0is unreachable here. Consumers must pass.budget(0)to getthinkingBudget: 0onto the wire, which reads as a bug given the case is named.disabled.(This workaround is also limited: Gemini 2.5 Pro has a minimum thinking budget and Gemini 3 uses
thinking_level, so budget 0 is not universally available.)Suggested fix
Preserve
thoughtSignatureonGeminiPart.functionCallwhen decoding, carry it through the transcript representation, and re-encode it on subsequent requests. Google's guidance is that the field must be returned unchanged.Separately, consider making
.disabledsendthinkingBudget: 0so the case matches its name.Context
This affects every client that manages conversation history itself; several other projects have hit the same wall:
Environment: AnyLanguageModel 0.9.0, Swift 6.1, macOS/iOS 27.