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
This commit is contained in:
espinetandreu 2026-04-15 00:06:55 +02:00
parent b8f7d61400
commit f5c06e6a0c
3 changed files with 146 additions and 3 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"])