From f752005669df93fcf0e5c562f1dd015207171119 Mon Sep 17 00:00:00 2001 From: Julian von der Goltz Date: Fri, 13 Feb 2026 14:57:38 +0100 Subject: [PATCH] Update tests and transformation logic to standardize `functionCall` casing. Add test for parallel tool calls inheriting thought signature. --- .../prompt_templates/factory.py | 6 +- .../llms/vertex_ai/gemini/transformation.py | 28 +++++++++ .../test_thought_signature_in_tool_call_id.py | 2 +- .../test_vertex_ai_gemini_transformation.py | 61 ++++++++++++++++--- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 79d527d1eb8..162fa9cfd08 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1394,7 +1394,8 @@ def convert_to_gemini_tool_call_invoke( ) if gemini_function_call is not None: part_dict: VertexPartType = { - "function_call": gemini_function_call + # Gemini request payloads should use camelCase functionCall. + "functionCall": gemini_function_call } thought_signature = _get_thought_signature_from_tool( dict(tool), model=model @@ -1415,7 +1416,8 @@ def convert_to_gemini_tool_call_invoke( ) if gemini_function_call is not None: part_dict_function: VertexPartType = { - "function_call": gemini_function_call + # Gemini request payloads should use camelCase functionCall. + "functionCall": gemini_function_call } # Extract thought signature from function_call's provider_specific_fields diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index f56992a2502..3a4e52997de 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -985,8 +985,36 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 gemini_tool_call_parts = convert_to_gemini_tool_call_invoke( assistant_msg, model=model ) + + # Gemini validates thought signatures on function-call turns. + # If a signature exists for this assistant turn (e.g. from thinking_blocks), + # copy it to parallel function calls missing thoughtSignature. + turn_thought_signature = None + for existing_part in assistant_content: + if ( + isinstance(existing_part, dict) + and existing_part.get("thoughtSignature") + and ( + existing_part.get("functionCall") is not None + or existing_part.get("function_call") is not None + ) + ): + turn_thought_signature = existing_part.get( + "thoughtSignature" + ) + break + ## check if gemini_tool_call already exists in assistant_content for gemini_tool_call_part in gemini_tool_call_parts: + if ( + turn_thought_signature is not None + and isinstance(gemini_tool_call_part, dict) + and gemini_tool_call_part.get("thoughtSignature") is None + ): + gemini_tool_call_part["thoughtSignature"] = ( + turn_thought_signature + ) + if not check_if_part_exists_in_parts( assistant_content, gemini_tool_call_part, diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py b/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py index 208cba519f3..e68d3b9d940 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py @@ -176,7 +176,7 @@ def test_convert_to_gemini_with_embedded_signature(): # Verify thought signature is extracted and sent to Gemini assert len(gemini_parts) == 1 - assert "function_call" in gemini_parts[0] + assert "functionCall" in gemini_parts[0] assert "thoughtSignature" in gemini_parts[0] assert gemini_parts[0]["thoughtSignature"] == test_signature diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 263fb1c6e65..4dc5d995df8 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -453,12 +453,12 @@ def test_thought_signature_preservation_in_conversion(): # Verify thought signature is preserved in first function call part assert len(gemini_parts) == 2 - assert "function_call" in gemini_parts[0] + assert "functionCall" in gemini_parts[0] assert "thoughtSignature" in gemini_parts[0] assert gemini_parts[0]["thoughtSignature"] == test_signature # Verify second function call part does not have thought signature - assert "function_call" in gemini_parts[1] + assert "functionCall" in gemini_parts[1] assert "thoughtSignature" not in gemini_parts[1] @@ -522,6 +522,53 @@ def test_thought_signature_sequential_function_calls(): assert gemini_parts_step2[0]["thoughtSignature"] == signature_2 +def test_parallel_tool_calls_copy_thought_signature_from_thinking_block(): + """Test that parallel tool calls inherit turn thought signature from thinking_blocks.""" + messages = [ + { + "role": "assistant", + "content": None, + "thinking_blocks": [ + { + "type": "thinking", + "thinking": '{"functionCall":{"name":"get_current_temperature","args":{"location":"Paris"}}}', + "signature": "sig-turn-123", + } + ], + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_current_temperature", + "arguments": '{"location": "Paris"}', + }, + "index": 0, + }, + { + "id": "call_2", + "type": "function", + "function": { + "name": "get_current_temperature", + "arguments": '{"location": "London"}', + }, + "index": 1, + }, + ], + } + ] + + contents = _gemini_convert_messages_with_history(messages, model="gemini-2.5-flash") + + assert len(contents) == 1 + model_parts = contents[0]["parts"] + function_parts = [ + p for p in model_parts if p.get("functionCall") is not None or p.get("function_call") is not None + ] + assert len(function_parts) == 2 + assert all(p.get("thoughtSignature") == "sig-turn-123" for p in function_parts) + + def test_thought_signature_with_function_call_mode(): """Test thought signature extraction in function_call mode (is_function_call=True)""" from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( @@ -589,7 +636,7 @@ def test_dummy_signature_added_for_gemini_3_conversation_history(): # Verify dummy signature is added assert len(gemini_parts) == 1 - assert "function_call" in gemini_parts[0] + assert "functionCall" in gemini_parts[0] assert "thoughtSignature" in gemini_parts[0] # Verify it's the expected dummy signature (base64 encoded "skip_thought_signature_validator") @@ -630,7 +677,7 @@ def test_dummy_signature_not_added_for_gemini_2_5(): # Verify no dummy signature is added for non-gemini-3 models assert len(gemini_parts) == 1 - assert "function_call" in gemini_parts[0] + assert "functionCall" in gemini_parts[0] assert "thoughtSignature" not in gemini_parts[0] @@ -669,7 +716,7 @@ def test_dummy_signature_not_added_when_signature_exists(): # Verify real signature is preserved, not replaced with dummy assert len(gemini_parts) == 1 - assert "function_call" in gemini_parts[0] + assert "functionCall" in gemini_parts[0] assert "thoughtSignature" in gemini_parts[0] assert gemini_parts[0]["thoughtSignature"] == real_signature @@ -700,7 +747,7 @@ def test_dummy_signature_with_function_call_mode(): # Verify dummy signature is added assert len(gemini_parts) == 1 - assert "function_call" in gemini_parts[0] + assert "functionCall" in gemini_parts[0] assert "thoughtSignature" in gemini_parts[0] # Verify it's the expected dummy signature @@ -1938,7 +1985,7 @@ def test_function_response_has_user_role(): assert contents[0]["role"] == "user" assert contents[1]["role"] == "model" - assert "function_call" in contents[1]["parts"][0] + assert "functionCall" in contents[1]["parts"][0] # The critical assertion: function response must have role="user" assert contents[2]["role"] == "user"