diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d3197972862..ad966fde244 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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). diff --git a/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py b/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py index 1146e7dac0c..2df7d0b3dd3 100644 --- a/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py +++ b/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py @@ -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"] diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py index f780c056f64..31d95984996 100644 --- a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py @@ -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 diff --git a/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py b/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py index da084146894..8bae17ed8b7 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py @@ -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 diff --git a/tests/unit/types/test_litellm_params.py b/tests/unit/types/test_litellm_params.py index 788d405f99f..d2aed5853ae 100644 --- a/tests/unit/types/test_litellm_params.py +++ b/tests/unit/types/test_litellm_params.py @@ -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",