From f868592c0575b9ce163fc4d9c966b3341d0ec4a9 Mon Sep 17 00:00:00 2001 From: Ben Clarke Date: Sat, 15 Aug 2026 22:53:14 +0100 Subject: [PATCH 1/2] fix: strip embedded thought signature from LiteLLM tool call ids 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 #6742 --- src/google/adk/models/lite_llm.py | 9 ++++++++- tests/unittests/models/test_litellm.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index a854c0104b..26ca4bbffc 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -2395,7 +2395,14 @@ def _message_to_generate_content_response( raise ValueError( "Function-call part factory returned no function call" ) - function_call.id = tool_call.id + # The signature is carried on the part, so strip it back out of the id. + # Leaving it there appends a few hundred characters of base64 to every + # consumer of `function_call_id`, and `_content_to_message_param` + # re-attaches it to the outgoing tool call from `thought_signature` + # anyway. + function_call.id = tool_call.id.split(_THOUGHT_SIGNATURE_SEPARATOR, 1)[ + 0 + ] if thought_signature: part.thought_signature = thought_signature parts.append(part) diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 68b0e74c17..70306ea63b 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -2977,6 +2977,30 @@ def test_message_to_generate_content_response_preserves_thought_signature(): assert fc_part.thought_signature == b"round_trip_sig" +def test_message_to_generate_content_response_strips_signature_from_id(): + """An id carrying an embedded signature is split before it reaches the part.""" + sig_b64 = base64.b64encode(b"embedded_sig").decode("utf-8") + message = ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + type="function", + id=f"call_ts_2{_THOUGHT_SIGNATURE_SEPARATOR}{sig_b64}", + function=Function( + name="load_skill", + arguments='{"skill": "my_skill"}', + ), + ) + ], + ) + + response = _message_to_generate_content_response(message) + fc_part = response.content.parts[0] + assert fc_part.function_call.id == "call_ts_2" + assert fc_part.thought_signature == b"embedded_sig" + + def test_message_to_generate_content_response_no_thought_signature(): """Parts without thought_signature have thought_signature=None.""" message = ChatCompletionAssistantMessage( From 86dfed1de741528a892ea8f8c0c11b95f87920b4 Mon Sep 17 00:00:00 2001 From: Ben Clarke Date: Tue, 25 Aug 2026 12:35:00 +0100 Subject: [PATCH 2/2] fix: split the tool call id only when its suffix decodes as a signature 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. --- src/google/adk/models/lite_llm.py | 14 ++- tests/unittests/models/test_litellm.py | 116 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 26ca4bbffc..f9f7b24d68 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -2399,10 +2399,16 @@ def _message_to_generate_content_response( # Leaving it there appends a few hundred characters of base64 to every # consumer of `function_call_id`, and `_content_to_message_param` # re-attaches it to the outgoing tool call from `thought_signature` - # anyway. - function_call.id = tool_call.id.split(_THOUGHT_SIGNATURE_SEPARATOR, 1)[ - 0 - ] + # anyway. Only an id whose suffix decodes as a signature is LiteLLM's + # own; every other id is the provider's and stays opaque. + call_id, separator, suffix = tool_call.id.partition( + _THOUGHT_SIGNATURE_SEPARATOR + ) + function_call.id = ( + call_id + if separator and _decode_thought_signature(suffix) + else tool_call.id + ) if thought_signature: part.thought_signature = thought_signature parts.append(part) diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 70306ea63b..3c011988f2 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -3001,6 +3001,122 @@ def test_message_to_generate_content_response_strips_signature_from_id(): assert fc_part.thought_signature == b"embedded_sig" +def test_message_to_generate_content_response_keeps_a_non_signature_id_whole(): + """A provider id is opaque, so a separator whose suffix is not a signature stays.""" + message = ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + type="function", + id=f"job{_THOUGHT_SIGNATURE_SEPARATOR}not-base64", + function=Function(name="load_skill", arguments="{}"), + ) + ], + ) + + response = _message_to_generate_content_response(message) + fc_part = response.content.parts[0] + assert ( + fc_part.function_call.id == f"job{_THOUGHT_SIGNATURE_SEPARATOR}not-base64" + ) + assert fc_part.thought_signature is None + + +def test_message_to_generate_content_response_keeps_the_id_when_the_signature_came_from_extra_content(): + """The id is only LiteLLM's to split when its own suffix decodes as the signature.""" + sig_b64 = base64.b64encode(b"channel_sig").decode("utf-8") + message = ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + type="function", + id=f"job{_THOUGHT_SIGNATURE_SEPARATOR}not-base64", + function=Function(name="load_skill", arguments="{}"), + extra_content={"google": {"thought_signature": sig_b64}}, + ) + ], + ) + + response = _message_to_generate_content_response(message) + fc_part = response.content.parts[0] + assert ( + fc_part.function_call.id == f"job{_THOUGHT_SIGNATURE_SEPARATOR}not-base64" + ) + assert fc_part.thought_signature == b"channel_sig" + + +@pytest.mark.asyncio +async def test_embedded_signature_round_trips_from_a_clean_id(): + """The repro end to end: the id is cleaned inbound and the signature still goes back.""" + sig_b64 = base64.b64encode(b"embedded_sig").decode("utf-8") + message = ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + type="function", + id=f"call_abc{_THOUGHT_SIGNATURE_SEPARATOR}{sig_b64}", + function=Function(name="load_skill", arguments='{"skill": "s"}'), + ) + ], + ) + + response = _message_to_generate_content_response(message) + outbound = await _content_to_message_param(response.content) + + tool_call = outbound["tool_calls"][0] + assert tool_call["id"] == "call_abc" + assert tool_call["provider_specific_fields"]["thought_signature"] == sig_b64 + assert tool_call["extra_content"]["google"]["thought_signature"] == sig_b64 + + +@pytest.mark.asyncio +async def test_generate_content_async_cleans_an_embedded_signature_id(): + """The reported path end to end: a Gemini tool call arrives with a clean id.""" + sig_b64 = base64.b64encode(b"embedded_sig").decode("utf-8") + model_response = ModelResponse( + model="test_model", + choices=[ + Choices( + message=ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + type="function", + id=f"call_abc{_THOUGHT_SIGNATURE_SEPARATOR}{sig_b64}", + function=Function( + name="test_function", + arguments='{"test_arg": "test_value"}', + ), + ) + ], + ) + ) + ], + ) + llm = LiteLlm( + model="test_model", + llm_client=MockLLMClient( + AsyncMock(return_value=model_response), + Mock(return_value=model_response), + ), + ) + + responses = [ + response + async for response in llm.generate_content_async( + LLM_REQUEST_WITH_FUNCTION_DECLARATION + ) + ] + + part = responses[0].content.parts[0] + assert part.function_call.id == "call_abc" + assert part.thought_signature == b"embedded_sig" + + def test_message_to_generate_content_response_no_thought_signature(): """Parts without thought_signature have thought_signature=None.""" message = ChatCompletionAssistantMessage(