fix(gemini): pair a tool result with its call when the client re-adds the thought signature to the call id

Strands, which the AgentCore harness runs, puts provider_specific_fields.thought_signature back on the
assistant tool call id as <id>__thought__<signature> while its tool result keeps the bare id, so the Gemini
conversion found no matching call and answered 500. Ids now match with the signature suffix stripped.

Also adds thought_signature_in_tool_call_id to the litellm_params inventory test and gives the pass-through
tests' mocked logging objects the litellm_params a real Logging object always has.
This commit is contained in:
mateo-berri 2026-10-20 18:36:41 -07:00
parent c535022057
commit d26b776e5e
5 changed files with 61 additions and 1 deletions

View file

@ -1261,6 +1261,13 @@ def encode_tool_call_id_with_signature(tool_call_id: str, thought_signature: str
_encode_tool_call_id_with_signature = encode_tool_call_id_with_signature
def _same_tool_call(tool_call_id: str, other_tool_call_id: str) -> bool:
return (
tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
== other_tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
)
def embeds_thought_signature_in_tool_call_id(litellm_params: Mapping[str, object]) -> bool:
return litellm_params.get("thought_signature_in_tool_call_id") is not False
@ -1609,7 +1616,13 @@ def convert_to_gemini_tool_call_result(
msg_tool_call_id: Final = message.get("tool_call_id", None)
for tool in tools:
prev_tool_call_id = tool.get("id", None)
if msg_tool_call_id and prev_tool_call_id and msg_tool_call_id == prev_tool_call_id:
if (
isinstance(msg_tool_call_id, str)
and isinstance(prev_tool_call_id, str)
and msg_tool_call_id
and prev_tool_call_id
and _same_tool_call(msg_tool_call_id, prev_tool_call_id)
):
name = tool.get("function", {}).get("name", "")
# Echo the OpenAI tool_call_id on functionResponse (strip thought-signature suffix).

View file

@ -430,3 +430,47 @@ def test_bounded_tool_call_id_round_trips_to_gemini(client_keeps_provider_fields
assert len(replayed_signatures) == 1
assert (replayed_signatures[0] == LONG_SIGNATURE) is client_keeps_provider_fields
assert replayed_signatures[0]
def test_tool_result_with_bare_id_matches_call_whose_id_the_client_re_signed():
bare_id: Final = "call_601401"
with respx.mock:
route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock(
return_value=httpx.Response(
200,
json={
"candidates": [
{"content": {"role": "model", "parts": [{"text": "Sunny"}]}, "finishReason": "STOP", "index": 0}
],
"usageMetadata": {"promptTokenCount": 9, "candidatesTokenCount": 1, "totalTokenCount": 10},
},
)
)
litellm.completion(
model="gemini/gemini-3.5-flash",
messages=[
USER_TURN,
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": encode_tool_call_id_with_signature(bare_id, LONG_SIGNATURE),
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
}
],
},
{"role": "tool", "tool_call_id": bare_id, "content": '{"temp_c": 21}'},
],
tools=[WEATHER_TOOL],
api_key="test-gemini-key",
thought_signature_in_tool_call_id=False,
)
sent_body: Final = json.loads(route.calls.last.request.content)
parts: Final = list(chain.from_iterable(turn["parts"] for turn in sent_body["contents"]))
function_calls: Final = [part for part in parts if "function_call" in part]
function_responses: Final = [part["function_response"] for part in parts if "function_response" in part]
assert [part["thoughtSignature"] for part in function_calls] == [LONG_SIGNATURE]
assert [response["name"] for response in function_responses] == ["get_weather"]

View file

@ -83,6 +83,7 @@ class TestGeminiPassthroughLoggingHandler:
mock_logging_obj = MagicMock(spec=LiteLLMLoggingObj)
mock_logging_obj.model_call_details = {}
mock_logging_obj.optional_params = {}
mock_logging_obj.litellm_params = {}
mock_logging_obj.litellm_call_id = "test-call-id-123"
return mock_logging_obj

View file

@ -83,6 +83,7 @@ def _logging_obj() -> LiteLLMLoggingObj:
logging_obj = MagicMock(spec=LiteLLMLoggingObj)
logging_obj.model_call_details = {}
logging_obj.optional_params = {}
logging_obj.litellm_params = {}
logging_obj.litellm_call_id = "test-call-id"
return logging_obj

View file

@ -217,6 +217,7 @@ OPTION_NAMES: Final = (
"assistant_continue_message",
"disable_add_transform_inline_image_block",
"merge_reasoning_content_in_choices",
"thought_signature_in_tool_call_id",
"enable_json_schema_validation",
"complete_response",
"stream_chunk_size",