Skip to content

Gemini tool calling fails with HTTP 400: thought_signature is discarded when decoding functionCall parts #203

Description

@NoWaY233851

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

  1. Create a GeminiLanguageModel (default init) with a thinking-capable model — reproduced on Gemini 2.5.
  2. Create a LanguageModelSession with at least one Tool.
  3. 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.swiftGeminiPart.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions