Skip to content

fix(litellm): strip embedded thought_signature from tool call id#6460

Open
saakshigupta2002 wants to merge 2 commits into
google:mainfrom
saakshigupta2002:fix/litellm-strip-thought-signature-from-tool-call-id
Open

fix(litellm): strip embedded thought_signature from tool call id#6460
saakshigupta2002 wants to merge 2 commits into
google:mainfrom
saakshigupta2002:fix/litellm-strip-thought-signature-from-tool-call-id

Conversation

@saakshigupta2002

Copy link
Copy Markdown

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

N/A — this PR resolves the existing issue linked above.

Problem:

Gemini "thinking" models attach a thought_signature to their function-call
parts. Depending on the provider path, that signature can arrive in one of
three places, which _extract_thought_signature_from_tool_call already handles:

  1. extra_content.google.thought_signature (OpenAI-compatible API)
  2. provider_specific_fields on the tool call or its function (Vertex)
  3. Embedded in the tool call ID via the __thought__ separator
    (call_789__thought__<base64-signature>) — the shape some upstream proxy
    paths return for Gemini 2.5 thinking models.

For paths 1 and 2 the tool call ID is never touched. For path 3, however,
_message_to_generate_content_response in
src/google/adk/models/lite_llm.py extracted the signature onto
part.thought_signature but then assigned the entire, unsplit ID
(including the __thought__<signature> suffix) to part.function_call.id:

thought_signature = _extract_thought_signature_from_tool_call(tool_call)
part = types.Part.from_function_call(...)
part.function_call.id = tool_call.id          # <-- bug: suffix never stripped
if thought_signature:
    part.thought_signature = thought_signature

Consequences:

  • function_call.id becomes a several-hundred-character string containing raw,
    non-URL-safe base64 characters (/, +).

  • That corrupted ID is persisted verbatim into session history (e.g. a
    DatabaseSessionService-backed events row).

  • On any later turn where the authoring agent's own history is replayed, the
    malformed ID is sent back to the model as a literal tool_call_id and
    rejected by OpenAI-compatible endpoints / proxies:

    litellm.BadRequestError: Error code: 422 -
    {"error":"Messages[6].ToolCallID: Tool Call ID required on tool calls"}
    
  • Because the corrupted ID is now a permanent part of the session's event
    history, every subsequent turn on that thread fails identically,
    permanently breaking the conversation.

This is consistent with the official guidance that a thought signature is a
separate field, distinct from the function-call identifier, and must be
preserved as such across turns
(Gemini thinking guide — thought signatures).
The bug was introduced by the change that added thought_signature
preservation itself; it is not a regression of previously correct behavior.

Solution:

Strip any embedded __thought__ suffix from the tool call ID before assigning
it to function_call.id, so the persisted ID is the clean, original identifier
— while the signature continues to be preserved separately on
part.thought_signature.

A small helper mirrors the existing extraction logic (splitting on the first
__thought__ separator, exactly as _extract_thought_signature_from_tool_call
does):

def _strip_thought_signature_from_tool_call_id(
    tool_call_id: Optional[str],
) -> Optional[str]:
  if not tool_call_id:
    return tool_call_id
  return tool_call_id.split(_THOUGHT_SIGNATURE_SEPARATOR, 1)[0]

and is applied at the assignment site:

part.function_call.id = _strip_thought_signature_from_tool_call_id(
    tool_call.id
)

This solution was chosen because:

  • It fixes the corruption at the source (inbound response conversion), so no
    malformed ID is ever persisted, rather than papering over it downstream.
  • It is fully backward compatible: IDs without the separator are returned
    unchanged, so the extra_content and provider_specific_fields paths and all
    plain tool-call IDs are unaffected.
  • It keeps behavior symmetric with the existing signature-extraction path and
    leaves outbound LiteLLM re-embedding untouched.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Added to tests/unittests/models/test_litellm.py:

  • test_message_to_generate_content_response_strips_thought_signature_from_id
    — the core regression: an embedded-ID tool call yields a clean
    function_call.id (call_789) while thought_signature is still preserved.
  • test_strip_thought_signature_from_tool_call_id_removes_suffix
  • test_strip_thought_signature_from_tool_call_id_leaves_plain_id
  • test_strip_thought_signature_from_tool_call_id_handles_empty (None / "")
  • test_strip_thought_signature_from_tool_call_id_splits_once

The core regression test was verified to fail before the fix (proving it
reproduces the bug) and pass after the fix.

Summary of passed pytest results:

# Targeted (thought_signature-related):
$ pytest tests/unittests/models/test_litellm.py -k "thought_signature or strip_thought" -q
17 passed, 335 deselected

# Full lite_llm suite (no regressions):
$ pytest tests/unittests/models/test_litellm.py -q
352 passed

Manual End-to-End (E2E) Tests:

The failure is in pure data-handling logic, so it is fully reproducible without
a live model. Minimal reproduction:

import base64
from google.adk.models.lite_llm import _message_to_generate_content_response
from litellm.types.utils import (
    ChatCompletionAssistantMessage,
    ChatCompletionMessageToolCall,
    Function,
)

sig = base64.b64encode(b"embedded_sig").decode()
msg = ChatCompletionAssistantMessage(
    role="assistant",
    content=None,
    tool_calls=[
        ChatCompletionMessageToolCall(
            type="function",
            id=f"call_789__thought__{sig}",
            function=Function(name="test_function", arguments="{}"),
        )
    ],
)

part = _message_to_generate_content_response(msg).content.parts[0]
print(part.function_call.id)      # before: 'call_789__thought__<sig>'  after: 'call_789'
print(part.thought_signature)     # b'embedded_sig' (preserved in both cases)

Before the fix, the printed ID retains the __thought__<signature> suffix;
after the fix it is the clean call_789, and the signature is still present on
part.thought_signature.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

  • Files changed:
    • src/google/adk/models/lite_llm.py — new
      _strip_thought_signature_from_tool_call_id helper and its use in
      _message_to_generate_content_response.
    • tests/unittests/models/test_litellm.py — regression and unit tests.
  • Scope is intentionally minimal: only the inbound ID assignment is changed. The
    extra_content and provider_specific_fields extraction paths, and the
    outbound tool-call serialization, are left unchanged.

@google-cla

google-cla Bot commented Jul 23, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@adk-bot adk-bot added the models [Component] This issue is related to model support label Jul 23, 2026

@gnanirahulnutakki gnanirahulnutakki 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.

Reviewed exact head 96aac45a. The focused thought-signature tests pass (17/17) and the full LiteLLM model suite passes (352/352). I found one persistence-safety edge where an undecodable __thought__ suffix still causes the opaque tool-call ID to be truncated; details and a minimal reproducer are inline.

Comment thread src/google/adk/models/lite_llm.py Outdated
"""
if not tool_call_id:
return tool_call_id
return tool_call_id.split(_THOUGHT_SIGNATURE_SEPARATOR, 1)[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please only strip the suffix when it was successfully recognized as an embedded signature. _extract_thought_signature_from_tool_call() rejects malformed base64, but this helper truncates every ID containing the separator. At this head I reproduced call_keep__thought__not-base64 becoming function_call.id == "call_keep" while part.thought_signature is None. That mutates the opaque ID before it is persisted even though no signature was extracted; the Gemini guidance also requires returning the exact function-call ID in the response. Could the ID and signature be parsed together (or the raw ID retained when decoding fails), with a malformed-suffix regression test?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Please only strip the suffix when it was successfully recognized as an embedded signature. _extract_thought_signature_from_tool_call() rejects malformed base64, but this helper truncates every ID containing the separator. At this head I reproduced call_keep__thought__not-base64 becoming function_call.id == "call_keep" while part.thought_signature is None. That mutates the opaque ID before it is persisted even though no signature was extracted; the Gemini guidance also requires returning the exact function-call ID in the response. Could the ID and signature be parsed together (or the raw ID retained when decoding fails), with a malformed-suffix regression test?

Good catch, thanks! You're right the separator can be part of the id itself. I changed it to only strip when the suffix actually decodes as a signature, otherwise the id is left alone. call_keep__thought__not-base64 stays intact now (signature None) and I added tests for it.

When a Gemini thinking model exposes its thought_signature only embedded in
the tool call id via the __thought__ separator,
_message_to_generate_content_response extracted the signature onto
part.thought_signature but assigned the full, unsplit id (including the
__thought__<signature> suffix) to part.function_call.id.

That corrupted id is persisted to session history and, on later turns, replayed
to the model as a literal tool_call_id, which OpenAI-compatible endpoints reject
(422 'Tool Call ID required on tool calls'). Because the malformed id is now
part of the event history, every subsequent turn on the thread fails
identically, permanently breaking the conversation.

Strip the embedded suffix so function_call.id is the clean, original id, while
still preserving the signature separately on part.thought_signature. The
extra_content and provider_specific_fields paths are unchanged. Adds regression
and unit tests.

Fixes google#6454
…nature

Address review feedback: _strip_thought_signature_from_tool_call_id previously
truncated any id containing the __thought__ separator, even when the suffix was
not a valid base64 signature. Since the separator can legitimately appear inside
an opaque tool call id, that mutated ids from which no signature was actually
extracted (e.g. 'call_keep__thought__not-base64' became 'call_keep' while
thought_signature stayed None), contradicting the Gemini requirement to echo
back the exact function-call id.

Now the suffix is decoded first (mirroring _extract_thought_signature_from_tool_call);
the id is only stripped when the suffix is a genuine signature, otherwise it is
returned unchanged. Adds malformed-suffix regression tests at both the helper
and _message_to_generate_content_response levels.
@saakshigupta2002
saakshigupta2002 force-pushed the fix/litellm-strip-thought-signature-from-tool-call-id branch from 2b44e59 to 15bbca8 Compare July 24, 2026 12:19

@gnanirahulnutakki gnanirahulnutakki 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.

Re-reviewed exact head 15bbca823e48. The malformed-base64 case is fixed, and the seven focused tests pass locally. One empty-signature boundary remains inline. Separately, uv run pyink --check src/google/adk/models/lite_llm.py tests/unittests/models/test_litellm.py reports that the test file still needs formatting.

base_id, _, encoded_signature = tool_call_id.partition(
_THOUGHT_SIGNATURE_SEPARATOR
)
if _decode_thought_signature(encoded_signature) is 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.

[P2] Preserve IDs when the decoded signature is empty

The malformed-base64 case is fixed, but an empty suffix still breaks the same invariant. base64.b64decode("") returns b"", so call_keep__thought__ reaches return base_id here and becomes call_keep; later _message_to_generate_content_response() uses if thought_signature:, so that empty value is not persisted on part.thought_signature. I reproduced _decode_thought_signature("") == b"" and _strip_thought_signature_from_tool_call_id("call_keep__thought__") == "call_keep" on this head. Please retain the raw ID when the decoded value is empty (or parse once and base stripping on the same value that will actually be stored), and add this boundary as a regression test.

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

Labels

models [Component] This issue is related to model support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

422 Error: Tool Call ID required on tool calls

4 participants