mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
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:
parent
33b9524daf
commit
acd414f186
5 changed files with 134 additions and 111 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue