mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
b8f7d61400
commit
f5c06e6a0c
3 changed files with 146 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
Loading…
Add table
Reference in a new issue