fix: strip embedded thought signature from LiteLLM tool call ids - #6743
fix: strip embedded thought signature from LiteLLM tool call ids#6743benclarkeio wants to merge 2 commits into
Conversation
LiteLLM embeds a Gemini `thought_signature` in the tool call id, separated by `__thought__`. `_message_to_generate_content_response` already lifts that signature onto `part.thought_signature`, but then assigned the raw id to `part.function_call.id`, so every consumer of `function_call_id` saw a few hundred characters of base64 appended to the real id. - Split the separator off the id before assigning it to the function call - Leave `thought_signature` extraction unchanged, so nothing is lost The round trip is unaffected: `_content_to_message_param` re-attaches the signature to the outgoing tool call from `part.thought_signature` via `provider_specific_fields` and `extra_content.google.thought_signature`, and `_extract_thought_signature_from_tool_call` reads both of those before it falls back to the id-embedded form. Both sides of the call/response pairing are generated from ADK's own stored parts, so ids stay matched. Fixes google#6742
varunbiluri
left a comment
There was a problem hiding this comment.
The ID should only be shortened when the suffix was actually accepted as the embedded thought signature. As written, every provider-issued ID containing __thought__ is truncated, even when _decode_thought_signature() rejects the suffix (or when the signature was sourced from extra_content / provider_specific_fields). Tool-call IDs are otherwise opaque, so an ID such as job__thought__not-base64 becomes job, which can break response pairing or collide with another call. Please have the extraction path return the cleaned ID only when it successfully decodes the ID-embedded form (or otherwise gate this assignment on successful decoding specifically from that fallback), and add coverage showing a non-signature ID containing the separator is preserved.
An id is opaque, so one that merely contains the separator is the provider's own and stays whole. Gate the split on the same decode the extraction path uses, and keep the id when the signature arrived through another channel.
|
Good catch — pushed in 86dfed1. The split is now gated on the same decode the extraction path uses: partition on the separator, keep the head only when the suffix decodes as a signature, otherwise leave the id exactly as the provider sent it. So I kept the gate at the assignment rather than changing what Tests added:
On pairing specifically — that one was safe either way, since both the assistant tool call and the tool message's |
Fixes #6742
Problem
LiteLLM embeds a Gemini
thought_signatureinside the tool call id, separated by__thought__(see_THOUGHT_SIGNATURE_SEPARATOR)._message_to_generate_content_responsealready extracts that signature ontopart.thought_signature, but then assigns the raw id to the function call:So every consumer of
function_call_idgets several hundred characters of base64 glued onto the real id:It leaks into logs, into any UI that displays a tool call id, and into anything that derives a name from one.
Fix
Split the separator off before assigning the id. The signature is already preserved on the part, so nothing is lost.
Why the round trip still works
_content_to_message_paramre-attaches the signature to the outgoing tool call frompart.thought_signature, via bothprovider_specific_fieldsandextra_content.google.thought_signature._extract_thought_signature_from_tool_callchecks those two locations before falling back to the id-embedded form, which exists only for providers that drop the other channels.tool_callsentry and thetoolmessage'stool_call_id) are generated from ADK's own stored parts, so stripping consistently keeps them matched.Testing
Added
test_message_to_generate_content_response_strips_signature_from_id, asserting the id is split and the signature still lands on the part.tests/unittests/models/test_litellm.pypasses in full (387 passed). Formatted withpyinkper the repo config.Note
This replaces #6599, which was opened from the same work but carried a co-author trailer on its commit. Rebased onto current
main(the surrounding code now goes through a localfunction_callvariable) and reopened clean.