fix(vertex_ai): forward function_call id on Vertex Gemini 3+ tool turns

Vertex AI now accepts and returns `id` on functionCall and functionResponse parts for Gemini 3+ on the v1 endpoint, so the provider check added in #28324 is stale. It silently drops the id for every Vertex caller, which breaks strict tool-call matching

Gate the id on model version alone, which is what the code did before #28324 and what Google AI Studio already does. `_forward_gemini_function_call_id` no longer takes `custom_llm_provider`, and the decision is resolved once in `_gemini_convert_messages_with_history` and passed to both converters as a bool rather than re-derived independently in each. The context caching path is covered by the same change, since it already passes `model` and the gate needs nothing else

The `id` comments on `FunctionCall`, `FunctionResponse` and `HttpxFunctionCall` were also written by #28324 and asserted the opposite of current behaviour, so they are corrected here
This commit is contained in:
Lukas Geiger 2026-07-25 05:18:35 +00:00
parent 33b9524daf
commit acd414f186
5 changed files with 134 additions and 111 deletions

View file

@ -1266,7 +1266,7 @@ def _get_dummy_thought_signature() -> str:
def convert_to_gemini_tool_call_invoke(
message: ChatCompletionAssistantMessage,
model: Optional[str] = None,
custom_llm_provider: Optional[str] = None,
forward_function_call_id: bool = False,
) -> List[VertexPartType]:
"""
OpenAI tool invokes:
@ -1316,16 +1316,12 @@ def convert_to_gemini_tool_call_invoke(
VertexGeminiConfig,
)
forward_tool_call_id = bool(
model and VertexGeminiConfig._forward_gemini_function_call_id(model, custom_llm_provider)
)
if tool_calls is not None:
for idx, tool in enumerate(tool_calls):
if "function" in tool:
gemini_function_call: Optional[VertexFunctionCall] = _gemini_tool_call_invoke_helper(
function_call_params=tool["function"],
tool_call_id=(tool.get("id") if forward_tool_call_id else None),
tool_call_id=(tool.get("id") if forward_function_call_id else None),
)
if gemini_function_call is not None:
part_dict: VertexPartType = {"function_call": gemini_function_call}
@ -1377,8 +1373,7 @@ def convert_to_gemini_tool_call_invoke(
def convert_to_gemini_tool_call_result(
message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage],
last_message_with_tool_calls: Optional[dict],
model: Optional[str] = None,
custom_llm_provider: Optional[str] = None,
forward_function_call_id: bool = False,
) -> Union[VertexPartType, List[VertexPartType]]:
"""
OpenAI message with a tool result looks like:
@ -1500,14 +1495,8 @@ def convert_to_gemini_tool_call_result(
name = tool.get("function", {}).get("name", "")
# Echo the OpenAI tool_call_id on functionResponse (strip thought-signature suffix).
# Only Google AI Studio Gemini 3+ accepts `id` on function_response parts.
# Vertex AI and older Gemini models reject the field with HTTP 400.
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
gemini_call_id: Optional[str] = None
if model and VertexGeminiConfig._forward_gemini_function_call_id(model, custom_llm_provider):
if forward_function_call_id:
raw_tool_call_id = message.get("tool_call_id")
if raw_tool_call_id and isinstance(raw_tool_call_id, str):
stripped_id = raw_tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]

View file

@ -661,6 +661,10 @@ def _gemini_convert_messages_with_history(
vertex_project = litellm_params.get("vertex_project") or litellm_params.get("vertex_ai_project")
vertex_credentials = litellm_params.get("vertex_credentials") or litellm_params.get("vertex_ai_credentials")
from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig
forward_function_call_id = VertexGeminiConfig._forward_gemini_function_call_id(model or "")
try:
while msg_i < len(messages):
user_content: List[PartType] = []
@ -910,7 +914,7 @@ def _gemini_convert_messages_with_history(
gemini_tool_call_parts = convert_to_gemini_tool_call_invoke(
assistant_msg,
model=model,
custom_llm_provider=custom_llm_provider,
forward_function_call_id=forward_function_call_id,
)
## check if gemini_tool_call already exists in assistant_content
for gemini_tool_call_part in gemini_tool_call_parts:
@ -973,8 +977,7 @@ def _gemini_convert_messages_with_history(
_part = convert_to_gemini_tool_call_result(
messages[msg_i], # type: ignore
last_message_with_tool_calls, # type: ignore
model=model,
custom_llm_provider=custom_llm_provider,
forward_function_call_id=forward_function_call_id,
)
msg_i += 1
# Handle both single part and list of parts (for Computer Use with images)

View file

@ -289,15 +289,13 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return False
@staticmethod
def _forward_gemini_function_call_id(model: str, custom_llm_provider: Optional[str] = None) -> bool:
def _forward_gemini_function_call_id(model: str) -> bool:
"""
Whether to include `id` on function_call / function_response parts.
Gemini 3+ on Google AI Studio accepts (and returns) `id` for strict
tool-call matching. Vertex AI rejects the field with HTTP 400.
Gemini 3+ accepts (and returns) `id` for strict tool-call matching, on Vertex AI and
Google AI Studio alike. Older Gemini models reject the field with HTTP 400.
"""
if custom_llm_provider != "gemini":
return False
return VertexGeminiConfig._is_gemini_3_or_newer(model)
def _supports_penalty_parameters(self, model: str) -> bool:

View file

@ -16,7 +16,7 @@ GeminiEmbeddingInput = Union[EmbeddingInput, List[List[str]]]
class FunctionResponse(TypedDict, total=False):
# `id` correlates this response with the originating `functionCall` part.
# Supported on Google AI Studio Gemini 3.5+; Vertex AI rejects this field.
# Supported on Gemini 3+; older Gemini models reject this field.
id: str
name: Required[str]
response: Optional[dict]
@ -24,8 +24,8 @@ class FunctionResponse(TypedDict, total=False):
class FunctionCall(TypedDict, total=False):
# `id` correlates the corresponding `functionResponse` on Google AI Studio
# Gemini 3.5+. Vertex AI and older Gemini models omit/reject this field.
# `id` correlates the corresponding `functionResponse` on Gemini 3+.
# Older Gemini models omit/reject this field.
id: str
name: Required[str]
args: Optional[dict]
@ -58,8 +58,8 @@ class PartType(TypedDict, total=False):
class HttpxFunctionCall(TypedDict, total=False):
# `id` correlates the corresponding `functionResponse` on Google AI Studio
# Gemini 3.5+. Vertex AI and older Gemini models omit/reject this field.
# `id` correlates the corresponding `functionResponse` on Gemini 3+.
# Older Gemini models omit/reject this field.
id: str
name: Required[str]
args: dict

View file

@ -2273,82 +2273,8 @@ def test_is_gemini_3_or_newer():
assert VertexGeminiConfig._is_gemini_3_or_newer("") == False
def test_forward_gemini_function_call_id_vertex_vs_google_ai_studio():
"""Vertex AI rejects `id` on function_call/function_response; Google AI Studio accepts it on Gemini 3.5+."""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
model = "gemini-3.5-flash"
assert (
VertexGeminiConfig._forward_gemini_function_call_id(model, "vertex_ai") is False
)
assert (
VertexGeminiConfig._forward_gemini_function_call_id(model, "vertex_ai_beta")
is False
)
assert VertexGeminiConfig._forward_gemini_function_call_id(model, "gemini") is True
assert VertexGeminiConfig._forward_gemini_function_call_id(model, None) is False
assert (
VertexGeminiConfig._forward_gemini_function_call_id(
"gemini-2.5-flash", "gemini"
)
is False
)
def test_vertex_ai_gemini_35_tool_calls_omit_function_call_id():
"""Regression: Vertex must not send OpenAI tool_call id inside Gemini function_call parts."""
from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
messages = [
{"role": "user", "content": "Explore this directory"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_50e7e0fe0989464a89f188eda443",
"type": "function",
"function": {
"name": "read",
"arguments": '{"filePath": "/tmp"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_50e7e0fe0989464a89f188eda443",
"content": "ok",
},
]
contents = _gemini_convert_messages_with_history(
messages=messages,
model="gemini-3.5-flash",
custom_llm_provider="vertex_ai",
)
for content in contents:
for part in content.get("parts", []):
fc = part.get("function_call")
if fc is not None:
assert "id" not in fc, f"Vertex payload must not include id: {fc}"
fr = part.get("function_response")
if fr is not None:
assert "id" not in fr, f"Vertex payload must not include id: {fr}"
def test_google_ai_studio_gemini_35_tool_calls_include_function_call_id():
from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
tool_call_id = "call_50e7e0fe0989464a89f188eda443"
messages = [
def _tool_call_messages(tool_call_id: str):
return [
{"role": "user", "content": "hi"},
{
"role": "assistant",
@ -2371,12 +2297,8 @@ def test_google_ai_studio_gemini_35_tool_calls_include_function_call_id():
},
]
contents = _gemini_convert_messages_with_history(
messages=messages,
model="gemini-3.5-flash",
custom_llm_provider="gemini",
)
def _collect_function_call_ids(contents):
function_call_ids = []
function_response_ids = []
for content in contents:
@ -2387,9 +2309,120 @@ def test_google_ai_studio_gemini_35_tool_calls_include_function_call_id():
fr = part.get("function_response")
if fr is not None:
function_response_ids.append(fr.get("id"))
return function_call_ids, function_response_ids
assert function_call_ids == [tool_call_id]
assert function_response_ids == [tool_call_id]
def test_forward_gemini_function_call_id_is_gated_on_model_version_only():
"""Gemini 3+ takes `id` on Vertex AI and Google AI Studio alike; older models reject it."""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
assert VertexGeminiConfig._forward_gemini_function_call_id("gemini-3.5-flash") is True
assert VertexGeminiConfig._forward_gemini_function_call_id("gemini-3-pro") is True
assert VertexGeminiConfig._forward_gemini_function_call_id("gemini-2.5-flash") is False
assert VertexGeminiConfig._forward_gemini_function_call_id("gemini-2.0-flash") is False
@pytest.mark.parametrize("custom_llm_provider", ["vertex_ai", "vertex_ai_beta", "gemini"])
def test_gemini_35_tool_calls_include_function_call_id(custom_llm_provider):
"""Vertex AI accepts `id` on Gemini 3+, so it must be sent there and not just on AI Studio.
Both parts are asserted together: Vertex pairs a result to its call by id, so emitting one
side without the other would break strict tool-call matching.
"""
from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
tool_call_id = "call_50e7e0fe0989464a89f188eda443"
contents = _gemini_convert_messages_with_history(
messages=_tool_call_messages(tool_call_id),
model="gemini-3.5-flash",
custom_llm_provider=custom_llm_provider,
)
assert _collect_function_call_ids(contents) == ([tool_call_id], [tool_call_id])
@pytest.mark.parametrize("custom_llm_provider", ["vertex_ai", "gemini"])
def test_gemini_25_tool_calls_omit_function_call_id(custom_llm_provider):
"""Regression: models older than Gemini 3 reject `id`, so the key must be absent entirely."""
from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
contents = _gemini_convert_messages_with_history(
messages=_tool_call_messages("call_50e7e0fe0989464a89f188eda443"),
model="gemini-2.5-flash",
custom_llm_provider=custom_llm_provider,
)
for content in contents:
for part in content.get("parts", []):
fc = part.get("function_call")
if fc is not None:
assert "id" not in fc, f"gemini-2.5 payload must not include id: {fc}"
fr = part.get("function_response")
if fr is not None:
assert "id" not in fr, f"gemini-2.5 payload must not include id: {fr}"
def test_vertex_ai_forwarded_function_call_id_strips_thought_signature_suffix():
"""The thought signature rides along on the OpenAI id but must not reach Vertex.
Vertex now sees this code path for the first time, so the suffix has to be stripped here too.
"""
from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
bare_id = "call_50e7e0fe0989464a89f188eda443"
contents = _gemini_convert_messages_with_history(
messages=_tool_call_messages(f"{bare_id}{THOUGHT_SIGNATURE_SEPARATOR}sig123"),
model="gemini-3.5-flash",
custom_llm_provider="vertex_ai",
)
_, function_response_ids = _collect_function_call_ids(contents)
assert function_response_ids == [bare_id]
@pytest.mark.parametrize("model", ["gemini-3.5-flash", "gemini-2.5-flash"])
def test_tool_response_without_matching_tool_call_is_rejected(model):
"""An unpairable tool result must raise, not ship a functionResponse with no matching call."""
from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_50e7e0fe0989464a89f188eda443",
"type": "function",
"function": {
"name": "read",
"arguments": '{"filePath": "/tmp"}',
},
}
],
},
{"role": "tool", "content": "ok"},
]
with pytest.raises(Exception, match="Missing corresponding tool call"):
_gemini_convert_messages_with_history(
messages=messages,
model=model,
custom_llm_provider="vertex_ai",
)
def test_reasoning_effort_maps_to_thinking_level_gemini_3():