From f5c06e6a0c8e64400ecca4f016a7f4a5f061a582 Mon Sep 17 00:00:00 2001 From: espinetandreu <116800856+espinetandreu@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:06:55 +0200 Subject: [PATCH] fix(gemini): propagate thoughtSignature in tool call results for Gemini 3 Gemini 3 thinking models require thoughtSignature fields on tool result parts to maintain the thinking chain across turns. Without this, multi-turn conversations with tool use fail on Gemini 3. Changes: - Add model parameter to convert_to_gemini_tool_call_result - Extract thought_signature from tool calls via _get_thought_signature_from_tool - Add thoughtSignature to function_response parts - For Gemini 3, nest inline_data inside functionResponse to avoid leaking internal transition tokens into user-visible text - Pass model= through from _gemini_convert_messages_with_history --- .../prompt_templates/factory.py | 22 ++- .../llms/vertex_ai/gemini/transformation.py | 2 +- .../test_gemini_thought_signature.py | 125 ++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/litellm_core_utils/prompt_templates/test_gemini_thought_signature.py diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index a037360c87d..b8aaeaa5545 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1468,6 +1468,7 @@ def convert_to_gemini_tool_call_invoke( def convert_to_gemini_tool_call_result( # noqa: PLR0915 message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage], last_message_with_tool_calls: Optional[dict], + model: Optional[str] = None, ) -> Union[VertexPartType, List[VertexPartType]]: """ OpenAI message with a tool result looks like: @@ -1596,8 +1597,9 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915 f"Failed to process file in tool response: {e}" ) name: Optional[str] = message.get("name", "") # type: ignore + thought_signature: Optional[str] = None - # Recover name from last message with tool calls + # Recover name and thought signature from last message with tool calls if last_message_with_tool_calls: tools = last_message_with_tool_calls.get("tool_calls", []) msg_tool_call_id = message.get("tool_call_id", None) @@ -1609,6 +1611,7 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915 and msg_tool_call_id == prev_tool_call_id ): name = tool.get("function", {}).get("name", "") + thought_signature = _get_thought_signature_from_tool(dict(tool), model=model) if not name: raise Exception( @@ -1642,13 +1645,28 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915 # Create part with function_response, and optionally inline_data for images (Computer Use) _part: VertexPartType = {"function_response": _function_response} + if thought_signature: + _part["thoughtSignature"] = thought_signature # For Computer Use, if we have images/files, we need separate parts: # - One part with function_response # - One part per inline_data item # Gemini's PartType is a oneof, so we can't have both in the same part if inline_data_list: - return [_part] + [{"inline_data": d} for d in inline_data_list] + # Gemini 3 "Thinking" models require nesting multimodal content inside functionResponse + # to avoid leaking internal transition tokens into the user-visible text. + is_gemini_3 = model is not None and "gemini-3" in model + if is_gemini_3: + _part["function_response"]["parts"] = [{"inline_data": d} for d in inline_data_list] # type: ignore + return _part + + # Backwards-compatible behavior for older Gemini models: + # Gemini's PartType is a oneof, so we can't have both function_response + inline_data in one part. + extra_parts: List[VertexPartType] = [{"inline_data": d} for d in inline_data_list] + for ep in extra_parts: + if thought_signature: + ep["thoughtSignature"] = thought_signature + return [_part] + extra_parts return _part diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 6157a384dc0..aaf03479289 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -587,7 +587,7 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 and messages[msg_i]["role"] in tool_call_message_roles ): _part = convert_to_gemini_tool_call_result( - messages[msg_i], last_message_with_tool_calls # type: ignore + messages[msg_i], last_message_with_tool_calls, model=model # type: ignore ) msg_i += 1 # Handle both single part and list of parts (for Computer Use with images) diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_gemini_thought_signature.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_gemini_thought_signature.py new file mode 100644 index 00000000000..84f511cac53 --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_gemini_thought_signature.py @@ -0,0 +1,125 @@ +def test_convert_gemini_tool_call_result_with_thought_signature(): + """ + Test that thought_signature from tool calls is propagated to the + function_response part when converting tool results for Gemini. + + Gemini 3 thinking models require thoughtSignature fields on tool + result parts to maintain the thinking chain. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_result, + ) + from litellm.types.llms.openai import ChatCompletionToolMessage + + message = ChatCompletionToolMessage( + role="tool", + tool_call_id="call_abc", + content='{"result": "ok"}', + ) + last_message_with_tool_calls = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": { + "name": "get_data", + "arguments": "{}", + "provider_specific_fields": { + "thought_signature": "dGhvdWdodA==", + }, + }, + } + ], + } + + result = convert_to_gemini_tool_call_result( + message=message, + last_message_with_tool_calls=last_message_with_tool_calls, + model="gemini-3-flash-preview", + ) + assert isinstance(result, dict) + assert "thoughtSignature" in result + assert result["thoughtSignature"] == "dGhvdWdodA==" + + +def test_convert_gemini_tool_call_result_without_thought_signature(): + """ + Test that tool results without thought_signature still work correctly + and do not include the thoughtSignature field. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_result, + ) + from litellm.types.llms.openai import ChatCompletionToolMessage + + message = ChatCompletionToolMessage( + role="tool", + tool_call_id="call_xyz", + content='{"value": 42}', + ) + last_message_with_tool_calls = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_xyz", + "type": "function", + "function": {"name": "compute", "arguments": "{}"}, + } + ], + } + + result = convert_to_gemini_tool_call_result( + message=message, + last_message_with_tool_calls=last_message_with_tool_calls, + model="gemini-2.5-flash", + ) + assert isinstance(result, dict) + assert "thoughtSignature" not in result + + +def test_convert_gemini_tool_call_result_gemini3_inline_data_nested(): + """ + Test that for Gemini 3 models, inline_data (images) in tool results + are nested inside functionResponse.parts instead of being returned + as separate parts. + + This prevents leaking internal transition tokens into user-visible text. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_result, + ) + from litellm.types.llms.openai import ChatCompletionToolMessage + + message = ChatCompletionToolMessage( + role="tool", + tool_call_id="call_img", + content=[ + {"type": "text", "text": "screenshot captured"}, + {"type": "image_url", "image_url": "data:image/jpeg;base64,/9j/4AAQ"}, + ], + ) + last_message_with_tool_calls = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_img", + "type": "function", + "function": {"name": "take_screenshot", "arguments": "{}"}, + } + ], + } + + result = convert_to_gemini_tool_call_result( + message=message, + last_message_with_tool_calls=last_message_with_tool_calls, + model="gemini-3-flash-preview", + ) + # For Gemini 3, should return a single part with nested parts inside functionResponse + assert isinstance(result, dict) + assert "function_response" in result + assert "parts" in result["function_response"] + assert any("inline_data" in p for p in result["function_response"]["parts"])