Skip to content

fix(anthropic,gemini): preserve provider-specific reasoning signatures across turns#1153

Open
tbille wants to merge 3 commits into
mainfrom
fix/preserve-reasoning-signatures-1149
Open

fix(anthropic,gemini): preserve provider-specific reasoning signatures across turns#1153
tbille wants to merge 3 commits into
mainfrom
fix/preserve-reasoning-signatures-1149

Conversation

@tbille

@tbille tbille commented Jun 30, 2026

Copy link
Copy Markdown
Member

Description

Both Gemini and Anthropic encrypt their internal extended-reasoning trace and
expose it via a "signature" that the API requires to be replayed unmodified
on subsequent turns:

  • Gemini: Part.thought_signature
  • Anthropic: ThinkingBlock.signature / SignatureDelta.signature (streaming)

Neither value was being captured in any-llm's streaming conversion path.
Anthropic's signature additionally was not captured at all in the
non-streaming path, and was never replayed on outgoing requests, so any
multi-turn conversation using extended thinking + tool calls lost the
original reasoning trace on the next turn (matching the "weird thinking
loops" symptom reported in the issue).

Changes

  • any_llm/types/completion.py: add extra_content to ChatCompletionMessage
    and ChoiceDelta (mirroring the existing tool-call-level extra_content)
    to carry provider-specific, message-level metadata. Add a
    ChoiceDeltaToolCall subclass with extra_content so streaming tool-call
    deltas can carry the same per-call metadata as the non-streaming
    ChatCompletionMessageFunctionToolCall (e.g. Gemini's thought_signature).
  • gemini/utils.py: capture thought_signature on streaming function-call
    deltas, matching the existing non-streaming behavior.
  • anthropic/utils.py:
    • capture the thinking block signature in both streaming
      (signature_delta event) and non-streaming responses.
    • replay it as an Anthropic thinking content block when continuing a
      conversation that used extended thinking, both alongside a tool call and
      in plain assistant turns, per Anthropic's docs on preserving thinking
      blocks
      .

PR Type

  • 🐛 Bug Fix

Relevant issues

Fixes #1149

Checklist

  • I understand the code I am submitting.
  • I have added unit tests that prove my fix/feature works
  • I have run this code locally and verified it fixes the issue.
  • New and existing tests pass locally
  • Documentation was updated where necessary
  • I have read and followed the contribution guidelines
  • AI Usage:
    • No AI was used.
    • AI was used for drafting/refactoring.
    • This is fully AI-generated.

AI Usage Information

  • AI Model used: Claude Sonnet 5

  • AI Developer Tool used: OpenCode

  • Any other info you'd like to share: Implemented end-to-end (analysis, fix, and tests) by an AI agent based on the linked issue.

  • I am an AI Agent filling out this form (check box if true)

Summary by CodeRabbit

  • New Features
    • Preserved provider-specific metadata across chat turns, including Anthropic extended-thinking signatures and Gemini tool-call thought signatures.
    • Streaming and final completion outputs now expose an extra_content field so this metadata can reach the client consistently.
  • Bug Fixes
    • Improved Anthropic thinking replay for both text and tool-call outputs, including correct handling when signatures are missing, empty, or malformed.
  • Tests
    • Expanded unit coverage for Anthropic and Gemini message and streaming conversions, including new signature/delta cases.

@tbille tbille temporarily deployed to integration-tests June 30, 2026 22:29 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds extra_content to shared completion types and updates Anthropic and Gemini conversion paths to preserve encrypted reasoning signatures across streaming and non-streaming handling. Unit tests cover signature capture, replay, and missing-data cases.

Changes

Thought signature preservation

Layer / File(s) Summary
Extend completion types with extra_content
src/any_llm/types/completion.py
ChatCompletionMessage and streaming delta types gain extra_content; ChoiceDeltaToolCall becomes a subclass so provider metadata can be carried in streamed tool calls.
Anthropic signature capture and replay
src/any_llm/providers/anthropic/utils.py
Anthropic signatures are extracted from extra_content, preserved in streaming and response conversion, and rebuilt into thinking blocks for assistant messages and tool-call content.
Anthropic signature replay tests
tests/unit/providers/test_anthropic_provider.py
Tests cover streaming signature deltas, response conversion, replay into assistant content, helper edge cases, and filtering when signature metadata is absent or malformed.
Gemini thought_signature streaming support
src/any_llm/providers/gemini/utils.py, tests/unit/providers/test_gemini_provider.py
Gemini thought signatures are base64-encoded into extra_content for non-streaming and streaming tool calls, with tests covering present and absent signatures.

Suggested reviewers: njbrake

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarises the main change: preserving provider-specific reasoning signatures across turns.
Description check ✅ Passed The description follows the template and includes the required sections, issue reference, checklist, and AI usage details.
Linked Issues check ✅ Passed The PR captures and replays Gemini thought signatures and Anthropic thinking signatures in streaming and non-streaming paths as requested by #1149.
Out of Scope Changes check ✅ Passed The changes remain focused on provider metadata preservation, internal type support, and related tests.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/preserve-reasoning-signatures-1149

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.07692% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/any_llm/providers/anthropic/utils.py 97.29% 0 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
src/any_llm/providers/gemini/utils.py 82.85% <100.00%> (-3.92%) ⬇️
src/any_llm/types/completion.py 97.56% <100.00%> (+0.12%) ⬆️
src/any_llm/providers/anthropic/utils.py 89.31% <97.29%> (-4.66%) ⬇️

... and 36 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/any_llm/providers/anthropic/utils.py`:
- Around line 201-212: The assistant message conversion in anthropic/utils.py
needs to handle `content=None` before building `content_blocks`, since
`_build_anthropic_thinking_block` can produce a thinking block even when
`message["content"]` is null. Update the `elif message["role"] == "assistant"`
branch to explicitly treat `None` like empty content in the `message` handling
logic, alongside the existing string and iterable cases, so the `content_blocks`
construction in the assistant conversion path never splats a null value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: bdbd21e0-5415-4f7b-a756-a90ed51e151f

📥 Commits

Reviewing files that changed from the base of the PR and between 02c9fc4 and ec6a7e0.

📒 Files selected for processing (5)
  • src/any_llm/providers/anthropic/utils.py
  • src/any_llm/providers/gemini/utils.py
  • src/any_llm/types/completion.py
  • tests/unit/providers/test_anthropic_provider.py
  • tests/unit/providers/test_gemini_provider.py

Comment thread src/any_llm/providers/anthropic/utils.py
@tbille tbille temporarily deployed to integration-tests July 1, 2026 08:04 — with GitHub Actions Inactive
@tbille

tbille commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Addressed the CodeRabbit review comment: guarded against content=None (and added explicit list handling) when replaying the thinking block for a reasoning-only assistant turn, plus added coverage for the remaining new branches (non-string signature, missing reasoning text, list-shaped content, empty-string content, empty non-streaming signature).

Note: the run-linter CI failure is unrelated to this PR — it's a pre-existing pre-commit-uv environment bug (AssertionError: BUG: expected environment for python to be healthy immediately after install) that's also failing on main (e.g. #1152) and already has a fix in flight via #1158 (bump pre-commit-uv 4.2.1 → 4.2.2). uv run pre-commit run --all-files passes cleanly locally on this branch.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/any_llm/providers/anthropic/utils.py (1)

60-66: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Treat empty Anthropic signatures as absent.

Line 65 still returns "" as a valid signature, but Line 1025 in the new regression suite already defines the empty string as missing. That split means a caller can replay extra_content={"anthropic": {"signature": ""}} into a thinking block with an empty signature, which violates the Anthropic request contract on the next turn.

Suggested fix
 def _extract_anthropic_thinking_signature(message: dict[str, Any]) -> str | None:
     """Extract the encrypted thinking signature stored on a message's extra_content, if any."""
     extra_content = message.get("extra_content")
     if isinstance(extra_content, dict) and isinstance(anthropic_extra := extra_content.get("anthropic"), dict):
         signature = anthropic_extra.get("signature")
-        if isinstance(signature, str):
+        if isinstance(signature, str) and signature:
             return signature
     return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/any_llm/providers/anthropic/utils.py` around lines 60 - 66, The helper
_extract_anthropic_thinking_signature currently treats an empty string as a
valid Anthropic signature. Update this function so only non-empty string
signatures are returned, and return None for empty values from
extra_content["anthropic"]["signature"]. This keeps the signature handling
consistent with the regression suite and prevents replaying an empty signature
into the next thinking block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/any_llm/providers/anthropic/utils.py`:
- Around line 60-66: The helper _extract_anthropic_thinking_signature currently
treats an empty string as a valid Anthropic signature. Update this function so
only non-empty string signatures are returned, and return None for empty values
from extra_content["anthropic"]["signature"]. This keeps the signature handling
consistent with the regression suite and prevents replaying an empty signature
into the next thinking block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ddc89df8-bfef-4f52-96d3-8f2dc429ec94

📥 Commits

Reviewing files that changed from the base of the PR and between ec6a7e0 and 4f77d67.

📒 Files selected for processing (2)
  • src/any_llm/providers/anthropic/utils.py
  • tests/unit/providers/test_anthropic_provider.py

tbille added 2 commits July 1, 2026 10:17
…s across turns

Both Gemini and Anthropic encrypt their internal reasoning trace and expose
it as a signature that must be replayed unmodified on subsequent turns
(Gemini's Part.thought_signature, Anthropic's ThinkingBlock/SignatureDelta
signature). Neither value was being captured in any-llm's streaming
conversion, and Anthropic's signature wasn't captured at all (streaming or
non-streaming), nor replayed on outgoing requests, which can lead to
degraded or repeated reasoning in multi-turn tool-use conversations.

- Add extra_content to ChatCompletionMessage and ChoiceDelta (mirroring the
  existing tool-call-level extra_content) to carry provider-specific,
  message-level metadata such as Anthropic's thinking signature.
- Add a ChoiceDeltaToolCall subclass with extra_content so streaming tool
  call deltas can carry the same per-call metadata as the non-streaming
  ChatCompletionMessageFunctionToolCall (e.g. Gemini's thought_signature).
- Gemini: capture thought_signature on streaming function-call deltas,
  matching the existing non-streaming behavior.
- Anthropic: capture the thinking block signature in both streaming
  (signature_delta) and non-streaming responses, and replay it as an
  Anthropic 'thinking' content block when continuing a conversation that
  used extended thinking (with or without an accompanying tool call).

Fixes #1149
…king blocks

CodeRabbit flagged that a reasoning-only assistant turn (content=None, no
tool_calls) would crash with a TypeError when replaying the thinking block,
since the code assumed any non-string content was an iterable list. Handle
None explicitly and only extend content_blocks for actual list content.

Also adds coverage for the remaining new branches: non-string signatures,
missing/malformed reasoning text, list-shaped existing content, empty-string
content, and an empty (falsy) thinking signature in the non-streaming path.
@tbille tbille force-pushed the fix/preserve-reasoning-signatures-1149 branch from 4f77d67 to ea606fc Compare July 1, 2026 08:18
@tbille tbille temporarily deployed to integration-tests July 1, 2026 08:18 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/any_llm/providers/gemini/utils.py`:
- Around line 408-414: Extract the duplicated thought_signature-to-extra_content
encoding logic into a shared helper in the Gemini utils module, and update both
the streaming path and _convert_response_to_response_dict to call it. Keep the
helper responsible for checking thought_signature, base64-encoding bytes, and
returning the google extra_content payload so both call sites stay consistent
and avoid drift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: af99abc5-836c-469a-ba95-e47fe640ed1c

📥 Commits

Reviewing files that changed from the base of the PR and between 4f77d67 and ea606fc.

📒 Files selected for processing (5)
  • src/any_llm/providers/anthropic/utils.py
  • src/any_llm/providers/gemini/utils.py
  • src/any_llm/types/completion.py
  • tests/unit/providers/test_anthropic_provider.py
  • tests/unit/providers/test_gemini_provider.py

Comment thread src/any_llm/providers/gemini/utils.py
CodeRabbit nitpick: the thought_signature-to-extra_content encoding logic
was duplicated identically between the non-streaming
(_convert_response_to_response_dict) and streaming
(_create_openai_chunk_from_google_chunk) conversion paths. Extract it into
a shared helper so the two paths can't drift.
@tbille tbille deployed to integration-tests July 1, 2026 08:30 — with GitHub Actions Active
@tbille

tbille commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Addressed CodeRabbit's latest nitpick: extracted a shared _thought_signature_extra_content helper in gemini/utils.py so the streaming and non-streaming thought_signature encoding logic can't drift apart (previously duplicated identically in both places).

Also: the branch has since been rebased onto main, which now includes #1158 (pre-commit-uv bump), so the previously-unrelated run-linter CI flake is resolved. All checks are now green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/any_llm/providers/gemini/utils.py`:
- Around line 279-288: The helper `_thought_signature_extra_content` is using a
generic `Any` lookup for `thought_signature` even though it only handles
`google.genai.types.Part` objects. Update the function signature to use
`types.Part` and read `part.thought_signature` directly, keeping the existing
bytes check and base64 payload shape so both response conversion paths remain
consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c5220628-552e-44fe-99de-7b42475ccd1a

📥 Commits

Reviewing files that changed from the base of the PR and between ea606fc and 5646d95.

📒 Files selected for processing (1)
  • src/any_llm/providers/gemini/utils.py

Comment on lines +279 to +288
def _thought_signature_extra_content(part: Any) -> dict[str, Any] | None:
"""Build the OpenAI-compatible extra_content payload for a Gemini thought_signature, if present.

Shared by both the non-streaming (_convert_response_to_response_dict) and streaming
(_create_openai_chunk_from_google_chunk) conversion paths so they stay in sync.
"""
thought_signature = getattr(part, "thought_signature", None)
if thought_signature is not None and isinstance(thought_signature, bytes):
return {"google": {"thought_signature": base64.b64encode(thought_signature).decode("utf-8")}}
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the reported lines.
sed -n '240,320p' src/any_llm/providers/gemini/utils.py

# Find where types.Part is imported and used in this file.
rg -n "types\.Part|from .* import .*types|import .*types" src/any_llm/providers/gemini/utils.py

# Locate the definition of Part in the repository, if present.
fd -a "types.py" src any_llm . | sed -n '1,40p'
rg -n "class Part|def .*thought_signature|thought_signature" src any_llm . -g '!**/.git/**'

Repository: mozilla-ai/any-llm

Length of output: 11963


🌐 Web query:

google genai types.Part thought_signature attribute documented Part thought_signature bytes

💡 Result:

In the Google Gen AI ecosystem, thought_signature is a field used to maintain reasoning continuity and state for models that support thinking [1][2]. It acts as an opaque, encrypted representation of the model's internal reasoning process [2][3]. Regarding the Part object and the thought_signature data type: 1. Data Type: The thought_signature attribute is generally handled as a string [4][5]. While some SDKs (notably Go) may define the type internally as []byte [6], it is functionally an opaque string provided by the API [4]. Developers are advised that if an SDK uses []byte, standard JSON marshaling might incorrectly encode the value (e.g., base64 encoding a bypass string), which can cause the signature to become non-functional [6]. The Python and JavaScript SDKs typically handle this as a string, allowing for straightforward pass-through of the value [4][6][5]. 2. Purpose and Usage: - Preservation: It is used to preserve the Gemini reasoning state in multi-turn and multi-step conversations, particularly when function calling is involved [1][2]. - Requirements: You must include all Part objects from previous messages exactly as they were returned by the model [2][3]. You should not merge, modify, or omit parts that contain these signatures, as this can degrade reasoning quality or trigger 400 errors in strict validation scenarios (such as with the first functionCall part in a turn) [1][2]. - Non-Function Call Parts: Gemini may also return thought_signatures in the final Part of a response (e.g., text parts) even when no function call occurs [1][2]. While returning these is not strictly enforced with a blocking error, it is recommended to ensure high-quality reasoning and performance [1][2]. 3. Documentation: The field is documented as an optional attribute within the Part interface (e.g., Part.thoughtSignature) [4][5]. It is explicitly described as an opaque signature designed to be reused in subsequent requests to maintain context [4][7][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '80,150p' src/any_llm/types/completion.py
sed -n '860,930p' tests/unit/providers/test_gemini_provider.py
sed -n '980,1065p' tests/unit/providers/test_gemini_provider.py

Repository: mozilla-ai/any-llm

Length of output: 8962


🌐 Web query:

python-genai Part thought_signature attribute type bytes docs

💡 Result:

In the Google Gen AI Python SDK (google-genai), the thought_signature attribute of the Part class is defined as bytes [1][2]. Key details regarding this attribute include: - Purpose: It is an opaque, encrypted representation of the model's internal reasoning (thought) process [3][4][5]. It is required to maintain reasoning continuity across multi-turn interactions, particularly when using thinking-enabled models [3][6][7]. - API Behavior: When Gemini generates content (such as function calls or text), it may include a thought_signature in the Part [3][6]. If the model response contains a functionCall, including the thought_signature when sending the conversation history back is mandatory; omitting it often results in a 400 error [3][6][8]. - SDK Implementation: The SDK models this field within the Part class as Optional[bytes] [1][2]. While the underlying REST API often represents this signature as a string (frequently base64-encoded) [9][5], the Python SDK types it as bytes [1][2]. - Handling: When using standard chat history features or appending the full model response to the history, the official SDK typically handles the attachment of the thought_signature automatically behind the scenes [6]. However, users have reported issues where the signature may not be correctly passed back in certain scenarios (such as specific streaming patterns or multi-turn function calls), requiring careful management of the content history [10][8]. Documentation on this field can be found in the SDK source code types definitions [11][2] and the Gemini API documentation regarding thinking models [3][6][4].

Citations:


Use types.Part here and access thought_signature directly src/any_llm/providers/gemini/utils.py:279

The helper only receives google.genai.types.Part objects, and thought_signature is a declared field there, so part: types.Part plus part.thought_signature is the cleaner type-safe form.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 279-279: Dynamically typed expressions (typing.Any) are disallowed in part

(ANN401)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/any_llm/providers/gemini/utils.py` around lines 279 - 288, The helper
`_thought_signature_extra_content` is using a generic `Any` lookup for
`thought_signature` even though it only handles `google.genai.types.Part`
objects. Update the function signature to use `types.Part` and read
`part.thought_signature` directly, keeping the existing bytes check and base64
payload shape so both response conversion paths remain consistent.

Sources: Coding guidelines, Linters/SAST tools

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Missing specific provider thought signature

1 participant