fix(vertex_ai): omit function_call id on Vertex Gemini 3.5+ tool turns (#28324)

* fix(vertex_ai): omit function_call id on Vertex Gemini 3.5+ tool turns

Vertex AI rejects `id` on function_call/function_response parts; only Google AI Studio accepts it for Gemini 3.5+ strict tool matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(vertex_ai): forward custom_llm_provider in context caching

Pass custom_llm_provider through to _gemini_convert_messages_with_history
in the context caching path so Gemini 3.5+ tool-call `id` forwarding
behaves consistently between cached and non-cached completions on Google
AI Studio.

Co-authored-by: Claude <claude@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
(cherry picked from commit fecf212d70)
This commit is contained in:
Sameer Kankute
2026-05-20 18:50:59 -07:00
committed by Yuneng Jiang
parent c621f58fff
commit 75c72c51e2
7 changed files with 188 additions and 21 deletions
@@ -1344,6 +1344,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,
) -> List[VertexPartType]:
"""
OpenAI tool invokes:
@@ -1394,7 +1395,10 @@ def convert_to_gemini_tool_call_invoke(
)
forward_tool_call_id = bool(
model and VertexGeminiConfig._is_gemini_3_or_newer(model)
model
and VertexGeminiConfig._forward_gemini_function_call_id(
model, custom_llm_provider
)
)
if tool_calls is not None:
@@ -1475,6 +1479,7 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage],
last_message_with_tool_calls: Optional[dict],
model: Optional[str] = None,
custom_llm_provider: Optional[str] = None,
) -> Union[VertexPartType, List[VertexPartType]]:
"""
OpenAI message with a tool result looks like:
@@ -1616,14 +1621,16 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
name = tool.get("function", {}).get("name", "")
# Echo the OpenAI tool_call_id on functionResponse (strip thought-signature suffix).
# Only Gemini 3+ accepts (and returns) an `id` on function_response parts;
# older Gemini models reject the field with a 400.
# 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._is_gemini_3_or_newer(model):
if model and VertexGeminiConfig._forward_gemini_function_call_id(
model, custom_llm_provider
):
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]
+5 -1
View File
@@ -151,4 +151,8 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
except Exception:
# If conversion fails, leave as is and let the API handle it
pass
return _gemini_convert_messages_with_history(messages=messages, model=model)
return _gemini_convert_messages_with_history(
messages=messages,
model=model,
custom_llm_provider="gemini",
)
@@ -174,7 +174,9 @@ def transform_openai_messages_to_gemini_context_caching(
)
transformed_messages = _gemini_convert_messages_with_history(
messages=new_messages, model=model
messages=new_messages,
model=model,
custom_llm_provider=custom_llm_provider,
)
model_name = "models/{}".format(model)
@@ -311,6 +311,7 @@ def check_if_part_exists_in_parts(
def _gemini_convert_messages_with_history( # noqa: PLR0915
messages: List[AllMessageValues],
model: Optional[str] = None,
custom_llm_provider: Optional[str] = None,
) -> List[ContentType]:
"""
Converts given messages from OpenAI format to Gemini format
@@ -548,7 +549,9 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
or assistant_msg.get("function_call") is not None
): # support assistant tool invoke conversion
gemini_tool_call_parts = convert_to_gemini_tool_call_invoke(
assistant_msg, model=model
assistant_msg,
model=model,
custom_llm_provider=custom_llm_provider,
)
## check if gemini_tool_call already exists in assistant_content
for gemini_tool_call_part in gemini_tool_call_parts:
@@ -610,6 +613,7 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
messages[msg_i], # type: ignore
last_message_with_tool_calls, # type: ignore
model=model,
custom_llm_provider=custom_llm_provider,
)
msg_i += 1
# Handle both single part and list of parts (for Computer Use with images)
@@ -289,6 +289,20 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return True
return False
@staticmethod
def _forward_gemini_function_call_id(
model: str, custom_llm_provider: Optional[str] = None
) -> 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.
"""
if custom_llm_provider != "gemini":
return False
return VertexGeminiConfig._is_gemini_3_or_newer(model)
def _supports_penalty_parameters(self, model: str) -> bool:
# Gemini 3 models do not support penalty parameters
if VertexGeminiConfig._is_gemini_3_or_newer(model):
@@ -2645,7 +2659,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
def _transform_messages(
self, messages: List[AllMessageValues], model: Optional[str] = None
) -> List[ContentType]:
return _gemini_convert_messages_with_history(messages=messages, model=model)
return _gemini_convert_messages_with_history(
messages=messages,
model=model,
custom_llm_provider="vertex_ai",
)
def get_error_class(
self, error_message: str, status_code: int, headers: Union[Dict, httpx.Headers]
+5 -5
View File
@@ -16,15 +16,15 @@ GeminiEmbeddingInput = Union[EmbeddingInput, List[List[str]]]
class FunctionResponse(TypedDict, total=False):
# `id` correlates this response with the originating `functionCall` part.
# Required by Gemini 3.5+ for strict function-calling response matching.
# Supported on Google AI Studio Gemini 3.5+; Vertex AI rejects this field.
id: str
name: Required[str]
response: Optional[dict]
class FunctionCall(TypedDict, total=False):
# `id` is returned by Gemini 3.5+ to correlate the corresponding
# `functionResponse`. Older Gemini models omit this field.
# `id` correlates the corresponding `functionResponse` on Google AI Studio
# Gemini 3.5+. Vertex AI and older Gemini models omit/reject this field.
id: str
name: Required[str]
args: Optional[dict]
@@ -52,8 +52,8 @@ class PartType(TypedDict, total=False):
class HttpxFunctionCall(TypedDict, total=False):
# `id` is returned by Gemini 3.5+ to correlate the corresponding
# `functionResponse`. Older Gemini models omit this field.
# `id` correlates the corresponding `functionResponse` on Google AI Studio
# Gemini 3.5+. Vertex AI and older Gemini models omit/reject this field.
id: str
name: Required[str]
args: dict
@@ -2097,6 +2097,125 @@ 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 = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": tool_call_id,
"type": "function",
"function": {
"name": "read",
"arguments": '{"filePath": "/tmp"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": "ok",
},
]
contents = _gemini_convert_messages_with_history(
messages=messages,
model="gemini-3.5-flash",
custom_llm_provider="gemini",
)
function_call_ids = []
function_response_ids = []
for content in contents:
for part in content.get("parts", []):
fc = part.get("function_call")
if fc is not None:
function_call_ids.append(fc.get("id"))
fr = part.get("function_response")
if fr is not None:
function_response_ids.append(fr.get("id"))
assert function_call_ids == [tool_call_id]
assert function_response_ids == [tool_call_id]
def test_reasoning_effort_maps_to_thinking_level_gemini_3():
"""Test that reasoning_effort maps to thinking_level AND includeThoughts for Gemini 3+ models"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
@@ -3531,7 +3650,12 @@ def test_video_metadata_supported_for_all_gemini_models():
}
]
for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"]:
for model in [
"gemini-1.5-pro",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-pro-preview",
]:
contents = _gemini_convert_messages_with_history(messages=messages, model=model)
file_part = None
@@ -3541,19 +3665,25 @@ def test_video_metadata_supported_for_all_gemini_models():
break
assert file_part is not None, f"{model}: file part should exist"
assert "video_metadata" in file_part, f"{model}: video_metadata should be present"
assert (
"video_metadata" in file_part
), f"{model}: video_metadata should be present"
assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5"
# Per-part media_resolution is Gemini 3+ only; 2.x uses generation_config global
for model in ["gemini-3-pro-preview"]:
contents = _gemini_convert_messages_with_history(messages=messages, model=model)
file_part = next(p for p in contents[0]["parts"] if "file_data" in p)
assert "media_resolution" in file_part, f"{model}: media_resolution should be present"
assert (
"media_resolution" in file_part
), f"{model}: media_resolution should be present"
for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"]:
contents = _gemini_convert_messages_with_history(messages=messages, model=model)
file_part = next(p for p in contents[0]["parts"] if "file_data" in p)
assert "media_resolution" not in file_part, f"{model}: per-part media_resolution should not be set"
assert (
"media_resolution" not in file_part
), f"{model}: per-part media_resolution should not be set"
def test_chunk_parser_handles_prompt_feedback_block():
@@ -4186,8 +4316,9 @@ def test_vertex_ai_usage_metadata_with_document_tokens_in_prompt():
# DOCUMENT tokens should be included in text_tokens: 8 (TEXT) + 774 (DOCUMENT) = 782
assert result.prompt_tokens_details is not None
assert result.prompt_tokens_details.text_tokens == 782, \
"DOCUMENT modality tokens should be added to text_tokens (8 TEXT + 774 DOCUMENT = 782)"
assert (
result.prompt_tokens_details.text_tokens == 782
), "DOCUMENT modality tokens should be added to text_tokens (8 TEXT + 774 DOCUMENT = 782)"
# Verify completion token details
assert result.completion_tokens_details is not None
@@ -4222,8 +4353,9 @@ def test_vertex_ai_usage_metadata_with_document_tokens_cached():
# DOCUMENT cached tokens map to cached_text_tokens, so:
# text_tokens = (8 TEXT + 774 DOCUMENT) - 400 cached = 382
assert result.prompt_tokens_details.text_tokens == 382, \
"text_tokens should be (8 + 774) - 400 cached = 382"
assert (
result.prompt_tokens_details.text_tokens == 382
), "text_tokens should be (8 + 774) - 400 cached = 382"
assert result.prompt_tokens_details.cached_tokens == 400