fix(vertex_ai): only fall back to a placeholder thought signature on the first parallel function call

Gemini returns a thoughtSignature on the first function call of a parallel
batch and leaves the siblings bare. When replaying that assistant turn,
litellm gave every unsigned call the skip_thought_signature_validator
placeholder, so a three-call turn went back with three signatures where
Gemini had produced one. Keep the placeholder for the first call only and
forward the siblings with whatever signature they actually carry, which is
usually none.
This commit is contained in:
ljogeiger 2026-08-19 22:08:42 +00:00
parent da7a10ebbd
commit aa832d81e9
2 changed files with 126 additions and 12 deletions

View file

@ -1200,13 +1200,14 @@ def _encode_tool_call_id_with_signature(tool_call_id: str, thought_signature: st
return tool_call_id
def _get_thought_signature_from_tool(tool: dict, model: str | None = None) -> str | None:
def _get_thought_signature_from_tool(tool: dict) -> str | None:
"""Extract thought signature from tool call's provider_specific_fields.
If not provided try to extract thought signature from tool call id
Checks both tool.provider_specific_fields and tool.function.provider_specific_fields.
If no signature is found and model is gemini-3, returns a dummy signature.
Returns None when the tool call carries no signature; callers decide whether a
placeholder signature is needed.
"""
# First check tool's provider_specific_fields
provider_fields: Final = tool.get("provider_specific_fields") or {}
@ -1236,13 +1237,6 @@ def _get_thought_signature_from_tool(tool: dict, model: str | None = None) -> st
if len(parts) == 2:
_, signature = parts
return signature
# If no signature found and model is gemini-3, return dummy signature
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
if model and VertexGeminiConfig._is_gemini_3_or_newer(model):
return _get_dummy_thought_signature()
return None
@ -1312,8 +1306,10 @@ def convert_to_gemini_tool_call_invoke(
VertexGeminiConfig,
)
needs_dummy_signature: Final = model is not None and VertexGeminiConfig._is_gemini_3_or_newer(model)
if tool_calls is not None:
for idx, tool in enumerate(tool_calls):
for tool in tool_calls:
if "function" in tool:
gemini_function_call: VertexFunctionCall | None = _gemini_tool_call_invoke_helper(
function_call_params=tool["function"],
@ -1321,7 +1317,10 @@ def convert_to_gemini_tool_call_invoke(
)
if gemini_function_call is not None:
part_dict: VertexPartType = {"function_call": gemini_function_call}
thought_signature = _get_thought_signature_from_tool(dict(tool), model=model)
thought_signature = _get_thought_signature_from_tool(dict(tool))
is_first_function_call = len(_parts_list) == 0
if not thought_signature and is_first_function_call and needs_dummy_signature:
thought_signature = _get_dummy_thought_signature()
if thought_signature:
part_dict["thoughtSignature"] = thought_signature
@ -1344,7 +1343,7 @@ def convert_to_gemini_tool_call_invoke(
thought_signature = provider_fields.get("thought_signature")
# If no signature found and model is gemini-3, use dummy signature
if not thought_signature and model and VertexGeminiConfig._is_gemini_3_or_newer(model):
if not thought_signature and needs_dummy_signature:
thought_signature = _get_dummy_thought_signature()
if thought_signature:

View file

@ -1,3 +1,5 @@
import base64
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_result,
)
@ -784,6 +786,119 @@ def test_dummy_signature_with_function_call_mode():
assert gemini_parts[0]["thoughtSignature"] == expected_dummy
def _parallel_tool_calls(*signatures):
return [
{
"id": f"call_{idx}",
"type": "function",
"function": {
"name": f"tool_{idx}",
"arguments": '{"location": "Paris"}',
**(
{"provider_specific_fields": {"thought_signature": signature}}
if signature is not None
else {}
),
},
"index": idx,
}
for idx, signature in enumerate(signatures)
]
REAL_THOUGHT_SIGNATURE = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n"
def test_dummy_signature_only_on_first_parallel_tool_call():
"""Gemini only returns a thought signature on the first of N parallel function calls.
The sibling calls carry no signature, so replaying them must not fabricate one.
"""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
gemini_parts = convert_to_gemini_tool_call_invoke(
{
"role": "assistant",
"content": None,
"tool_calls": _parallel_tool_calls(None, None, None),
},
model="gemini-3-pro-preview",
)
expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode(
"utf-8"
)
assert len(gemini_parts) == 3
assert gemini_parts[0]["thoughtSignature"] == expected_dummy
assert "thoughtSignature" not in gemini_parts[1]
assert "thoughtSignature" not in gemini_parts[2]
def test_real_signature_on_first_parallel_tool_call_leaves_siblings_empty():
"""The real signature from Gemini rides on the first call; siblings stay signature-free."""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
gemini_parts = convert_to_gemini_tool_call_invoke(
{
"role": "assistant",
"content": None,
"tool_calls": _parallel_tool_calls(REAL_THOUGHT_SIGNATURE, None, None),
},
model="gemini-3-pro-preview",
)
assert len(gemini_parts) == 3
assert gemini_parts[0]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE
assert "thoughtSignature" not in gemini_parts[1]
assert "thoughtSignature" not in gemini_parts[2]
def test_real_signature_on_later_parallel_tool_call_is_preserved():
"""A signature attached to a non-first call is still forwarded as-is."""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
gemini_parts = convert_to_gemini_tool_call_invoke(
{
"role": "assistant",
"content": None,
"tool_calls": _parallel_tool_calls(None, REAL_THOUGHT_SIGNATURE),
},
model="gemini-3-pro-preview",
)
expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode(
"utf-8"
)
assert len(gemini_parts) == 2
assert gemini_parts[0]["thoughtSignature"] == expected_dummy
assert gemini_parts[1]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE
def test_no_signatures_on_parallel_tool_calls_for_gemini_2_5():
"""Non-gemini-3 models never get a placeholder signature, on any call."""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
gemini_parts = convert_to_gemini_tool_call_invoke(
{
"role": "assistant",
"content": None,
"tool_calls": _parallel_tool_calls(None, None),
},
model="gemini-2.5-flash",
)
assert len(gemini_parts) == 2
assert all("thoughtSignature" not in part for part in gemini_parts)
# Tests for media_resolution (detail parameter) handling - Issue #17084
class TestMediaResolution:
"""Tests for media_resolution handling in Gemini 2.x models"""