fix(gemini): clarify thought-signature design; add sibling-thought-part test

Addresses greptile P1 + P2 review comments:

P1 — Clarify that _thought_signatures collects ONLY from thought parts
(thought==True) and that a functionCall part's own thoughtSignature is
consumed separately and never bleeds into sibling calls. Added a more
detailed comment in _transform_parts explaining the design.

P2 — Added test_sibling_thought_part_propagates_to_all_parallel_calls:
covers the regression scenario where Gemini returns a separate thought
part before multiple parallel functionCall parts, and both function calls
must receive the thought signature so multi-turn context is preserved.

Local test run: pytest tests/test_litellm/llms/vertex_ai/gemini/
test_thought_signature_in_tool_call_id.py → 12 passed.

Bahtya
This commit is contained in:
Bahtya 2026-04-09 18:31:09 +08:00 • committed by Bahtya
parent dd86a5d56f
commit 504cd98fd1
2 changed files with 60 additions and 4 deletions

View file

@ -1585,10 +1585,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
]:
function: Optional[ChatCompletionToolCallFunctionChunk] = None
_tools: List[ChatCompletionToolCallChunk] = []
# Collect thoughtSignature values only from thought parts (thought: true).
# Gemini may place thoughtSignature on a separate thought part rather than
# the functionCall part. We exclude functionCall parts to avoid bleeding
# signatures across parallel function calls.
# Collect thoughtSignature values ONLY from thought parts (thought: true).
# Gemini may return: [{thought: true, thoughtSignature: "..."}, {functionCall: A}, ...]
# In this layout the signature belongs to ALL function calls in the turn (it
# encodes the model's reasoning context for multi-turn preservation), so we
# propagate it to every parallel call via the fallback below.
# We intentionally exclude functionCall parts here — a signature on a
# functionCall part is already consumed via `part.get("thoughtSignature")`
# in the loop and must NOT bleed into sibling calls.
_thought_signatures = [
p.get("thoughtSignature")
for p in parts

View file

@ -285,3 +285,55 @@ def test_parallel_tool_calls_with_signatures(enable_preview_features):
assert THOUGHT_SIGNATURE_SEPARATOR not in tools[1]["id"]
sig2 = _get_thought_signature_from_tool({"id": tools[1]["id"], "type": "function"})
assert sig2 is None
def test_sibling_thought_part_propagates_to_all_parallel_calls() -> None:
"""
Regression test for the scenario this PR addresses.
Gemini may return a structure like:
[{thought: true, thoughtSignature: "..."}, {functionCall: A}, {functionCall: B}]
The thoughtSignature is on a *sibling* thought part, not on the functionCall parts.
It represents the model's reasoning for the entire turn and should be propagated to
ALL parallel function calls so that multi-turn context is preserved on the next turn.
This also verifies that a functionCall part's OWN thoughtSignature (a separate scenario
where Gemini puts the signature directly on the functionCall) does NOT bleed into
sibling calls — _thought_signatures only collects from parts where thought==True,
so functionCall-level signatures stay scoped to their own call.
"""
thought_signature = "ErcN4Ad4qImqBhBo8LkVzGuqK7Y="
# Gemini response: one thought part with signature, two parallel function calls
parts_sibling_thought = [
HttpxPartType(
thought=True,
thoughtSignature=thought_signature,
),
HttpxPartType(
functionCall={"name": "get_temperature", "args": {"location": "Paris"}},
),
HttpxPartType(
functionCall={"name": "get_temperature", "args": {"location": "London"}},
),
]
function, tools, _ = VertexGeminiConfig._transform_parts(
parts=parts_sibling_thought,
cumulative_tool_call_idx=0,
is_function_call=False,
)
assert tools is not None
assert len(tools) == 2
# Both function calls must carry the thought signature from the sibling thought part.
for i, tool in enumerate(tools):
sig = tool.get("provider_specific_fields", {}).get("thought_signature")
assert sig == thought_signature, (
f"tool[{i}] expected thought_signature={thought_signature!r}, got {sig!r}"
)
assert THOUGHT_SIGNATURE_SEPARATOR in tool["id"], (
f"tool[{i}] thought signature should be embedded in ID"
)