Skip to content

Commit 96aac45

Browse files
fix(litellm): strip embedded thought_signature from tool call id
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 #6454
1 parent 13bef9c commit 96aac45

2 files changed

Lines changed: 95 additions & 1 deletion

File tree

src/google/adk/models/lite_llm.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,33 @@ def _extract_thought_signature_from_tool_call(
987987
if len(parts) == 2:
988988
return _decode_thought_signature(parts[1])
989989

990+
991+
def _strip_thought_signature_from_tool_call_id(
992+
tool_call_id: Optional[str],
993+
) -> Optional[str]:
994+
"""Strips an embedded thought_signature suffix from a tool call ID.
995+
996+
Some Gemini thinking model paths embed the thought_signature in the tool
997+
call ID via the ``__thought__`` separator (see
998+
``_extract_thought_signature_from_tool_call``). The signature is surfaced
999+
separately on ``part.thought_signature``, so the ID assigned to
1000+
``function_call.id`` must be the clean, original ID without the suffix.
1001+
Otherwise the corrupted ID is persisted to session history and later
1002+
replayed to the model as a ``tool_call_id``, which downstream
1003+
OpenAI-compatible endpoints reject.
1004+
1005+
Args:
1006+
tool_call_id: The raw tool call ID, which may contain an embedded
1007+
thought_signature.
1008+
1009+
Returns:
1010+
The tool call ID with any ``__thought__`` suffix removed, or the value
1011+
unchanged when no separator is present.
1012+
"""
1013+
if not tool_call_id:
1014+
return tool_call_id
1015+
return tool_call_id.split(_THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
1016+
9901017
return None
9911018

9921019

@@ -2182,7 +2209,12 @@ def _message_to_generate_content_response(
21822209
name=tool_call.function.name,
21832210
args=_parse_tool_call_arguments(tool_call.function.arguments),
21842211
)
2185-
part.function_call.id = tool_call.id
2212+
# Strip any embedded thought_signature suffix so the persisted
2213+
# function_call.id stays the clean, original tool call ID. The
2214+
# signature is preserved separately on part.thought_signature.
2215+
part.function_call.id = _strip_thought_signature_from_tool_call_id(
2216+
tool_call.id
2217+
)
21862218
if thought_signature:
21872219
part.thought_signature = thought_signature
21882220
parts.append(part)

tests/unittests/models/test_litellm.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from google.adk.models.lite_llm import _safe_json_serialize
5858
from google.adk.models.lite_llm import _schema_to_dict
5959
from google.adk.models.lite_llm import _split_message_content_and_tool_calls
60+
from google.adk.models.lite_llm import _strip_thought_signature_from_tool_call_id
6061
from google.adk.models.lite_llm import _THOUGHT_SIGNATURE_SEPARATOR
6162
from google.adk.models.lite_llm import _to_litellm_response_format
6263
from google.adk.models.lite_llm import _to_litellm_role
@@ -2937,6 +2938,67 @@ def test_message_to_generate_content_response_no_thought_signature():
29372938
assert fc_part.thought_signature is None
29382939

29392940

2941+
def test_message_to_generate_content_response_strips_thought_signature_from_id():
2942+
"""function_call.id is cleaned when the signature is embedded in the ID.
2943+
2944+
Regression test: when the thought_signature is only available embedded in
2945+
the tool call ID (the __thought__ fallback), the original code assigned the
2946+
entire unsplit ID to function_call.id, corrupting session history and
2947+
breaking every subsequent turn once the malformed ID is replayed as a
2948+
tool_call_id.
2949+
"""
2950+
sig_b64 = base64.b64encode(b"embedded_sig").decode("utf-8")
2951+
embedded_id = f"call_789{_THOUGHT_SIGNATURE_SEPARATOR}{sig_b64}"
2952+
message = ChatCompletionAssistantMessage(
2953+
role="assistant",
2954+
content=None,
2955+
tool_calls=[
2956+
ChatCompletionMessageToolCall(
2957+
type="function",
2958+
id=embedded_id,
2959+
function=Function(
2960+
name="test_function",
2961+
arguments='{"test_arg": "test_value"}',
2962+
),
2963+
)
2964+
],
2965+
)
2966+
2967+
response = _message_to_generate_content_response(message)
2968+
fc_part = response.content.parts[0]
2969+
# The ID is stripped back to the clean, original tool call ID ...
2970+
assert fc_part.function_call.id == "call_789"
2971+
assert _THOUGHT_SIGNATURE_SEPARATOR not in fc_part.function_call.id
2972+
# ... and the signature is still preserved separately.
2973+
assert fc_part.thought_signature == b"embedded_sig"
2974+
2975+
2976+
def test_strip_thought_signature_from_tool_call_id_removes_suffix():
2977+
"""The embedded __thought__ suffix is removed from the ID."""
2978+
sig_b64 = base64.b64encode(b"embedded_sig").decode("utf-8")
2979+
tool_call_id = f"call_789{_THOUGHT_SIGNATURE_SEPARATOR}{sig_b64}"
2980+
assert _strip_thought_signature_from_tool_call_id(tool_call_id) == "call_789"
2981+
2982+
2983+
def test_strip_thought_signature_from_tool_call_id_leaves_plain_id():
2984+
"""A plain ID with no separator is returned unchanged."""
2985+
assert _strip_thought_signature_from_tool_call_id("call_plain") == "call_plain"
2986+
2987+
2988+
@pytest.mark.parametrize("value", [None, ""])
2989+
def test_strip_thought_signature_from_tool_call_id_handles_empty(value):
2990+
"""None and empty IDs are returned unchanged."""
2991+
assert _strip_thought_signature_from_tool_call_id(value) == value
2992+
2993+
2994+
def test_strip_thought_signature_from_tool_call_id_splits_once():
2995+
"""Only the first separator is used, mirroring signature extraction."""
2996+
tool_call_id = (
2997+
f"call_1{_THOUGHT_SIGNATURE_SEPARATOR}sig{_THOUGHT_SIGNATURE_SEPARATOR}x"
2998+
)
2999+
assert _strip_thought_signature_from_tool_call_id(tool_call_id) == "call_1"
3000+
3001+
29403002
@pytest.mark.asyncio
29413003
async def test_content_to_message_param_preserves_thought_signature():
29423004
"""thought_signature on Part is emitted on both tool call metadata paths."""

0 commit comments

Comments
 (0)