diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py
index 6cd3aceb079..cd27b4c362a 100644
--- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py
+++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py
@@ -480,6 +480,62 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
else:
return None
+ @staticmethod
+ def _resolve_search_tool_conflict(
+ gtool_func_declarations: list,
+ googleSearch: Optional[dict],
+ googleSearchRetrieval: Optional[dict],
+ enterpriseWebSearch: Optional[dict],
+ urlContext: Optional[dict],
+ optional_params: dict,
+ ) -> tuple:
+ """
+ Resolve Vertex AI constraint: multiple Tool objects in a request must
+ ALL be search tools. When function declarations are mixed with search
+ tools, drop search tools to avoid 400 error.
+
+ Skip when include_server_side_tool_invocations is enabled (Gemini 3+
+ supports tool combination natively).
+
+ Note: code_execution, computerUse, and googleMaps are NOT search tools
+ and CAN coexist with function declarations, so they are preserved.
+
+ Ref: https://github.com/BerriAI/litellm/issues/23337
+
+ Returns:
+ tuple of (googleSearch, googleSearchRetrieval, enterpriseWebSearch, urlContext)
+ """
+ has_search_tools = any(
+ v is not None
+ for v in [
+ googleSearch,
+ googleSearchRetrieval,
+ enterpriseWebSearch,
+ urlContext,
+ ]
+ )
+ server_side_tool_invocations = optional_params.get(
+ "include_server_side_tool_invocations", False
+ )
+ if (
+ gtool_func_declarations
+ and has_search_tools
+ and not server_side_tool_invocations
+ ):
+ verbose_logger.warning(
+ "Vertex AI does not support mixing function declarations with "
+ "search tools (googleSearch, enterpriseWebSearch, urlContext, "
+ "googleSearchRetrieval) in the same request. Dropping search "
+ "tools and keeping function declarations. To use search tools, "
+ "send a request without function calling tools."
+ )
+ googleSearch = None
+ googleSearchRetrieval = None
+ enterpriseWebSearch = None
+ urlContext = None
+
+ return googleSearch, googleSearchRetrieval, enterpriseWebSearch, urlContext
+
def _map_function( # noqa: PLR0915
self, value: List[dict], optional_params: dict
) -> List[Tools]:
@@ -512,9 +568,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
value = _remove_strict_from_schema(value)
for tool in value:
- openai_function_object: Optional[
- ChatCompletionToolParamFunctionChunk
- ] = None
+ openai_function_object: Optional[ChatCompletionToolParamFunctionChunk] = (
+ None
+ )
if "function" in tool: # tools list
_openai_function_object = ChatCompletionToolParamFunctionChunk( # type: ignore
**tool["function"]
@@ -633,43 +689,19 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
# per Vertex AI API spec: "A Tool object should contain exactly one type of Tool"
_tools_list: List[Tools] = []
- # Vertex AI constraint: multiple Tool objects in a request must ALL be
- # search tools. Mixing function declarations with search tools in the
- # same request causes a 400 error:
- # "Multiple tools are supported only when they are all search tools."
- # When both are present (e.g. deployment config has search tools and
- # user request adds function calling tools via MCP), drop search tools
- # and keep function declarations.
- # Ref: https://github.com/BerriAI/litellm/issues/23337
- has_search_tools = any(
- v is not None
- for v in [
- googleSearch,
- googleSearchRetrieval,
- enterpriseWebSearch,
- urlContext,
- ]
+ (
+ googleSearch,
+ googleSearchRetrieval,
+ enterpriseWebSearch,
+ urlContext,
+ ) = self._resolve_search_tool_conflict(
+ gtool_func_declarations=gtool_func_declarations,
+ googleSearch=googleSearch,
+ googleSearchRetrieval=googleSearchRetrieval,
+ enterpriseWebSearch=enterpriseWebSearch,
+ urlContext=urlContext,
+ optional_params=optional_params,
)
- # Skip this check when include_server_side_tool_invocations is enabled
- # (Gemini 3+ supports tool combination natively via PR #24073).
- server_side_tool_invocations = optional_params.get(
- "include_server_side_tool_invocations", False
- )
- if gtool_func_declarations and has_search_tools and not server_side_tool_invocations:
- verbose_logger.warning(
- "Vertex AI does not support mixing function declarations with "
- "search tools (googleSearch, enterpriseWebSearch, urlContext, "
- "googleSearchRetrieval) in the same request. Dropping search "
- "tools and keeping function declarations. To use search tools, "
- "send a request without function calling tools."
- )
- googleSearch = None
- googleSearchRetrieval = None
- enterpriseWebSearch = None
- urlContext = None
- # Note: code_execution, computerUse, and googleMaps are NOT search
- # tools and CAN coexist with function declarations in separate Tool
- # objects, so they are intentionally preserved here.
# Function declarations can be grouped together in one Tool
if gtool_func_declarations:
@@ -684,15 +716,15 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
_tools_list.append(search_tool)
if googleSearchRetrieval is not None:
retrieval_tool = Tools()
- retrieval_tool[
- VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value
- ] = googleSearchRetrieval
+ retrieval_tool[VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value] = (
+ googleSearchRetrieval
+ )
_tools_list.append(retrieval_tool)
if enterpriseWebSearch is not None:
enterprise_tool = Tools()
- enterprise_tool[
- VertexToolName.ENTERPRISE_WEB_SEARCH.value
- ] = enterpriseWebSearch
+ enterprise_tool[VertexToolName.ENTERPRISE_WEB_SEARCH.value] = (
+ enterpriseWebSearch
+ )
_tools_list.append(enterprise_tool)
if code_execution is not None:
code_tool = Tools()
@@ -1139,16 +1171,16 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
param_description="thinking_budget",
)
if VertexGeminiConfig._is_gemini_3_or_newer(model):
- optional_params[
- "thinkingConfig"
- ] = VertexGeminiConfig._map_reasoning_effort_to_thinking_level(
- effort_value, model
+ optional_params["thinkingConfig"] = (
+ VertexGeminiConfig._map_reasoning_effort_to_thinking_level(
+ effort_value, model
+ )
)
else:
- optional_params[
- "thinkingConfig"
- ] = VertexGeminiConfig._map_reasoning_effort_to_thinking_budget(
- effort_value, model
+ optional_params["thinkingConfig"] = (
+ VertexGeminiConfig._map_reasoning_effort_to_thinking_budget(
+ effort_value, model
+ )
)
elif param == "thinking":
# Validate no conflict with thinking_level
@@ -1157,11 +1189,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
param_name="thinking",
param_description="thinking_budget",
)
- optional_params[
- "thinkingConfig"
- ] = VertexGeminiConfig._map_thinking_param(
- cast(AnthropicThinkingParam, value),
- model=model,
+ optional_params["thinkingConfig"] = (
+ VertexGeminiConfig._map_thinking_param(
+ cast(AnthropicThinkingParam, value),
+ model=model,
+ )
)
elif param == "modalities" and isinstance(value, list):
response_modalities = self.map_response_modalities(value)
@@ -1585,10 +1617,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
_tool_response_chunk["provider_specific_fields"] = { # type: ignore
"thought_signature": thought_signature
}
- _tool_response_chunk[
- "id"
- ] = _encode_tool_call_id_with_signature(
- _tool_response_chunk["id"] or "", thought_signature
+ _tool_response_chunk["id"] = (
+ _encode_tool_call_id_with_signature(
+ _tool_response_chunk["id"] or "", thought_signature
+ )
)
_tools.append(_tool_response_chunk)
cumulative_tool_call_idx += 1
@@ -2435,28 +2467,28 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
## ADD METADATA TO RESPONSE ##
setattr(model_response, "vertex_ai_grounding_metadata", grounding_metadata)
- model_response._hidden_params[
- "vertex_ai_grounding_metadata"
- ] = grounding_metadata
+ model_response._hidden_params["vertex_ai_grounding_metadata"] = (
+ grounding_metadata
+ )
setattr(
model_response, "vertex_ai_url_context_metadata", url_context_metadata
)
- model_response._hidden_params[
- "vertex_ai_url_context_metadata"
- ] = url_context_metadata
+ model_response._hidden_params["vertex_ai_url_context_metadata"] = (
+ url_context_metadata
+ )
setattr(model_response, "vertex_ai_safety_results", safety_ratings)
- model_response._hidden_params[
- "vertex_ai_safety_results"
- ] = safety_ratings # older approach - maintaining to prevent regressions
+ model_response._hidden_params["vertex_ai_safety_results"] = (
+ safety_ratings # older approach - maintaining to prevent regressions
+ )
## ADD CITATION METADATA ##
setattr(model_response, "vertex_ai_citation_metadata", citation_metadata)
- model_response._hidden_params[
- "vertex_ai_citation_metadata"
- ] = citation_metadata # older approach - maintaining to prevent regressions
+ model_response._hidden_params["vertex_ai_citation_metadata"] = (
+ citation_metadata # older approach - maintaining to prevent regressions
+ )
## ADD TRAFFIC TYPE ##
traffic_type = completion_response.get("usageMetadata", {}).get(
@@ -3164,7 +3196,12 @@ class ModelResponseIterator:
setattr(model_response, "vertex_ai_safety_ratings", safety_ratings) # type: ignore
setattr(model_response, "vertex_ai_citation_metadata", citation_metadata) # type: ignore
- return grounding_metadata, url_context_metadata, safety_ratings, citation_metadata
+ return (
+ grounding_metadata,
+ url_context_metadata,
+ safety_ratings,
+ citation_metadata,
+ )
def _apply_stream_usage_metadata(
self,
@@ -3189,9 +3226,9 @@ class ModelResponseIterator:
traffic_type = processed_chunk.get("usageMetadata", {}).get("trafficType")
if traffic_type:
- model_response._hidden_params.setdefault(
- "provider_specific_fields", {}
- )["traffic_type"] = traffic_type
+ model_response._hidden_params.setdefault("provider_specific_fields", {})[
+ "traffic_type"
+ ] = traffic_type
service_tier = self.response_headers.get("x-gemini-service-tier")
if service_tier:
diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py
index 2e719b212f7..a0979664943 100644
--- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py
+++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py
@@ -237,7 +237,9 @@ def test_vertex_ai_response_json_schema_preserves_refs_for_gemini_2():
# $defs and $ref should be preserved (not unpacked)
assert "response_json_schema" in transformed_request
result_schema = transformed_request["response_json_schema"]
- assert "$defs" in result_schema, "responseJsonSchema should preserve $defs for Gemini 2.0+"
+ assert (
+ "$defs" in result_schema
+ ), "responseJsonSchema should preserve $defs for Gemini 2.0+"
def test_vertex_ai_get_json_schema_preserves_refs_for_nested_pydantic():
@@ -317,14 +319,22 @@ def test_vertex_ai_response_json_schema_for_gemini_2():
# Types should be lowercase (standard JSON Schema format)
assert transformed_request["response_json_schema"]["type"] == "object"
- assert transformed_request["response_json_schema"]["properties"]["name"]["type"] == "string"
- assert transformed_request["response_json_schema"]["properties"]["age"]["type"] == "integer"
+ assert (
+ transformed_request["response_json_schema"]["properties"]["name"]["type"]
+ == "string"
+ )
+ assert (
+ transformed_request["response_json_schema"]["properties"]["age"]["type"]
+ == "integer"
+ )
# Should NOT have propertyOrdering (not needed for responseJsonSchema)
assert "propertyOrdering" not in transformed_request["response_json_schema"]
# additionalProperties should be preserved (supported by responseJsonSchema)
- assert transformed_request["response_json_schema"].get("additionalProperties") == False
+ assert (
+ transformed_request["response_json_schema"].get("additionalProperties") == False
+ )
def test_vertex_ai_response_schema_for_old_models():
@@ -581,7 +591,7 @@ def test_streaming_chunk_with_tool_calls_and_thought_includes_reasoning_content(
"args": {"timezone": "America/New_York"},
},
"thoughtSignature": "EsEDCr4DAdHtim...", # Just a token, not reasoning
- }
+ },
]
},
"finishReason": "STOP",
@@ -600,12 +610,18 @@ def test_streaming_chunk_with_tool_calls_and_thought_includes_reasoning_content(
streaming_chunk = iterator.chunk_parser(chunk)
# Verify reasoning_content comes from the thought: true part
- assert streaming_chunk.choices[0].delta.reasoning_content == "Let me think about how to get the time..."
+ assert (
+ streaming_chunk.choices[0].delta.reasoning_content
+ == "Let me think about how to get the time..."
+ )
# Verify tool calls are also present
assert streaming_chunk.choices[0].delta.tool_calls is not None
assert len(streaming_chunk.choices[0].delta.tool_calls) == 1
- assert streaming_chunk.choices[0].delta.tool_calls[0].function.name == "get_current_time"
+ assert (
+ streaming_chunk.choices[0].delta.tool_calls[0].function.name
+ == "get_current_time"
+ )
def test_streaming_chunk_with_tool_calls_no_thought_no_reasoning_content():
@@ -653,12 +669,15 @@ def test_streaming_chunk_with_tool_calls_no_thought_no_reasoning_content():
streaming_chunk = iterator.chunk_parser(chunk)
# reasoning_content should be None - thoughtSignature alone does NOT mean reasoning
- assert getattr(streaming_chunk.choices[0].delta, 'reasoning_content', None) is None
+ assert getattr(streaming_chunk.choices[0].delta, "reasoning_content", None) is None
# Tool calls should still work
assert streaming_chunk.choices[0].delta.tool_calls is not None
assert len(streaming_chunk.choices[0].delta.tool_calls) == 1
- assert streaming_chunk.choices[0].delta.tool_calls[0].function.name == "get_current_time"
+ assert (
+ streaming_chunk.choices[0].delta.tool_calls[0].function.name
+ == "get_current_time"
+ )
def test_check_finish_reason():
@@ -711,7 +730,10 @@ def test_vertex_ai_usage_metadata_response_token_count():
"promptTokenCount": 66,
"responseTokenCount": 74,
"totalTokenCount": 131,
- "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 57}, {"modality": "IMAGE", "tokenCount": 9}],
+ "promptTokensDetails": [
+ {"modality": "TEXT", "tokenCount": 57},
+ {"modality": "IMAGE", "tokenCount": 9},
+ ],
"responseTokensDetails": [{"modality": "TEXT", "tokenCount": 74}],
}
usage_metadata = UsageMetadata(**usage_metadata)
@@ -741,9 +763,9 @@ def test_vertex_ai_usage_metadata_with_image_tokens():
"promptTokensDetails": [{"modality": "TEXT", "tokenCount": 14}],
"candidatesTokensDetails": [
{"modality": "IMAGE", "tokenCount": 1120},
- {"modality": "TEXT", "tokenCount": 322} # 1442 - 1120 = 322
+ {"modality": "TEXT", "tokenCount": 322}, # 1442 - 1120 = 322
],
- "thoughtsTokenCount": 158
+ "thoughtsTokenCount": 158,
}
usage_metadata = UsageMetadata(**usage_metadata)
result = v._calculate_usage(completion_response={"usageMetadata": usage_metadata})
@@ -785,7 +807,7 @@ def test_vertex_ai_usage_metadata_with_image_tokens_auto_calculated_text():
{"modality": "IMAGE", "tokenCount": 1120}
# TEXT modality omitted - should be auto-calculated
],
- "thoughtsTokenCount": 158
+ "thoughtsTokenCount": 158,
}
usage_metadata = UsageMetadata(**usage_metadata)
result = v._calculate_usage(completion_response={"usageMetadata": usage_metadata})
@@ -809,13 +831,13 @@ def test_vertex_ai_usage_metadata_with_image_tokens_auto_calculated_text():
def test_vertex_ai_usage_metadata_with_image_tokens_in_prompt():
"""Test promptTokensDetails with IMAGE modality for multimodal inputs
-
+
This test verifies the fix for issue #18182 where image_tokens were missing
from prompt_tokens_details when calling Gemini models with image inputs.
-
+
Example scenario: User sends a text prompt + image, and Gemini generates an image response.
The promptTokensDetails should include both TEXT and IMAGE token counts.
-
+
In this test case, candidatesTokenCount is INCLUSIVE of thoughtsTokenCount because:
promptTokenCount (533) + candidatesTokenCount (1337) = totalTokenCount (1870)
"""
@@ -826,31 +848,29 @@ def test_vertex_ai_usage_metadata_with_image_tokens_in_prompt():
"totalTokenCount": 1870,
"promptTokensDetails": [
{"modality": "IMAGE", "tokenCount": 527},
- {"modality": "TEXT", "tokenCount": 6}
+ {"modality": "TEXT", "tokenCount": 6},
],
- "candidatesTokensDetails": [
- {"modality": "IMAGE", "tokenCount": 1120}
- ],
- "thoughtsTokenCount": 217
+ "candidatesTokensDetails": [{"modality": "IMAGE", "tokenCount": 1120}],
+ "thoughtsTokenCount": 217,
}
usage_metadata = UsageMetadata(**usage_metadata)
result = v._calculate_usage(completion_response={"usageMetadata": usage_metadata})
print("result", result)
-
+
# Verify basic token counts
assert result.prompt_tokens == 533
# candidatesTokenCount is INCLUSIVE, so completion_tokens = candidatesTokenCount
assert result.completion_tokens == 1337
assert result.total_tokens == 1870
-
+
# Verify prompt_tokens_details includes both text and image tokens
assert result.prompt_tokens_details.text_tokens == 6
assert result.prompt_tokens_details.image_tokens == 527
-
+
# Verify completion_tokens_details
assert result.completion_tokens_details.image_tokens == 1120
assert result.completion_tokens_details.reasoning_tokens == 217
-
+
# Verify the math: prompt_tokens = text + image
# 533 = 6 (text) + 527 (image)
assert (
@@ -916,13 +936,17 @@ def test_vertex_ai_map_thinking_param_with_budget_tokens_0():
def test_vertex_ai_map_tools():
v = VertexGeminiConfig()
optional_params = {}
- tools = v._map_function(value=[{"code_execution": {}}], optional_params=optional_params)
+ tools = v._map_function(
+ value=[{"code_execution": {}}], optional_params=optional_params
+ )
assert len(tools) == 1
assert tools[0]["code_execution"] == {}
print(tools)
new_optional_params = {}
- new_tools = v._map_function(value=[{"codeExecution": {}}], optional_params=new_optional_params)
+ new_tools = v._map_function(
+ value=[{"codeExecution": {}}], optional_params=new_optional_params
+ )
assert len(new_tools) == 1
print("new_tools", new_tools)
assert new_tools[0]["code_execution"] == {}
@@ -1088,7 +1112,13 @@ def test_vertex_ai_streaming_usage_web_search_calculation():
{
"content": {"parts": [{"text": "Hello"}]},
"groundingMetadata": [
- {"webSearchQueries": ["", "What is the capital of France?", "Capital of France"]}
+ {
+ "webSearchQueries": [
+ "",
+ "What is the capital of France?",
+ "Capital of France",
+ ]
+ }
],
}
],
@@ -1432,7 +1462,7 @@ def test_vertex_ai_process_candidates_with_grounding_metadata():
def test_vertex_ai_tool_call_id_format():
"""
Test that tool call IDs have the correct format and length.
-
+
The ID should be in format 'call_' + 28 hex characters (total 33 characters).
This test verifies the fix for keeping the code line under 40 characters.
"""
@@ -1449,12 +1479,7 @@ def test_vertex_ai_tool_call_id_format():
"args": {"location": "San Francisco", "unit": "celsius"},
}
),
- HttpxPartType(
- functionCall={
- "name": "get_time",
- "args": {"timezone": "PST"}
- }
- ),
+ HttpxPartType(functionCall={"name": "get_time", "args": {"timezone": "PST"}}),
]
function, tools, updated_idx = VertexGeminiConfig._transform_parts(
@@ -1469,19 +1494,27 @@ def test_vertex_ai_tool_call_id_format():
# Test ID format for both tool calls
for tool in tools:
tool_id = tool["id"]
-
+
# Should start with 'call_'
- assert tool_id.startswith("call_"), f"ID should start with 'call_', got: {tool_id}"
-
+ assert tool_id.startswith(
+ "call_"
+ ), f"ID should start with 'call_', got: {tool_id}"
+
# Should have exactly 33 total characters (call_ + 28 hex chars)
- assert len(tool_id) == 33, f"ID should be 33 characters long, got {len(tool_id)}: {tool_id}"
-
+ assert (
+ len(tool_id) == 33
+ ), f"ID should be 33 characters long, got {len(tool_id)}: {tool_id}"
+
# The part after 'call_' should be 28 hex characters
hex_part = tool_id[5:] # Remove 'call_' prefix
- assert len(hex_part) == 28, f"Hex part should be 28 characters, got {len(hex_part)}: {hex_part}"
-
+ assert (
+ len(hex_part) == 28
+ ), f"Hex part should be 28 characters, got {len(hex_part)}: {hex_part}"
+
# Should only contain valid hex characters
- assert re.match(r'^[0-9a-f]{28}$', hex_part), f"Should contain only lowercase hex chars, got: {hex_part}"
+ assert re.match(
+ r"^[0-9a-f]{28}$", hex_part
+ ), f"Should contain only lowercase hex chars, got: {hex_part}"
# Verify IDs are unique
assert tools[0]["id"] != tools[1]["id"], "Tool call IDs should be unique"
@@ -1496,15 +1529,17 @@ def test_vertex_ai_tool_call_id_format():
)
if test_tools:
ids_generated.add(test_tools[0]["id"])
-
+
# All generated IDs should be unique
- assert len(ids_generated) == 10, f"All 10 IDs should be unique, got {len(ids_generated)} unique IDs"
+ assert (
+ len(ids_generated) == 10
+ ), f"All 10 IDs should be unique, got {len(ids_generated)} unique IDs"
def test_vertex_ai_code_line_length():
"""
Test that the specific code line generating tool call IDs is within character limit.
-
+
This is a meta-test to ensure the code change meets the 40-character requirement.
"""
import inspect
@@ -1514,45 +1549,49 @@ def test_vertex_ai_code_line_length():
)
# Get the source code of the _transform_parts method
- source_lines = inspect.getsource(VertexGeminiConfig._transform_parts).split('\n')
-
+ source_lines = inspect.getsource(VertexGeminiConfig._transform_parts).split("\n")
+
# Find the line that generates the ID
id_line = None
for line in source_lines:
- if '"id": f"call_' in line and 'uuid.uuid4().hex[:28]' in line:
+ if '"id": f"call_' in line and "uuid.uuid4().hex[:28]" in line:
id_line = line.strip() # Remove indentation for length check
break
-
+
assert id_line is not None, "Could not find the ID generation line in source code"
-
+
# Check that the line is 40 characters or less (excluding indentation)
line_length = len(id_line)
- assert line_length <= 40, f"ID generation line is {line_length} characters, should be ≤40: {id_line}"
-
+ assert (
+ line_length <= 40
+ ), f"ID generation line is {line_length} characters, should be ≤40: {id_line}"
+
# Verify it contains the expected UUID format
- assert 'uuid.uuid4().hex[:28]' in id_line, f"Line should contain shortened UUID format: {id_line}"
+ assert (
+ "uuid.uuid4().hex[:28]" in id_line
+ ), f"Line should contain shortened UUID format: {id_line}"
def test_vertex_ai_map_google_maps_tool_simple():
"""
Test googleMaps tool transformation without location data.
-
+
Input:
value=[{"googleMaps": {"enableWidget": "ENABLE_WIDGET"}}]
optional_params={}
-
+
Expected Output:
tools=[{"googleMaps": {"enableWidget": "ENABLE_WIDGET"}}]
optional_params={} (unchanged)
"""
v = VertexGeminiConfig()
optional_params = {}
-
+
tools = v._map_function(
value=[{"googleMaps": {"enableWidget": "ENABLE_WIDGET"}}],
- optional_params=optional_params
+ optional_params=optional_params,
)
-
+
assert len(tools) == 1
assert "googleMaps" in tools[0]
assert tools[0]["googleMaps"]["enableWidget"] == "ENABLE_WIDGET"
@@ -1563,7 +1602,7 @@ def test_vertex_ai_map_google_maps_tool_with_location():
"""
Test googleMaps tool transformation with location data.
Verifies latitude/longitude/languageCode are extracted to toolConfig.retrievalConfig.
-
+
Input:
value=[{
"googleMaps": {
@@ -1574,7 +1613,7 @@ def test_vertex_ai_map_google_maps_tool_with_location():
}
}]
optional_params={}
-
+
Expected Output:
tools=[{
"googleMaps": {"enableWidget": "ENABLE_WIDGET"}
@@ -1593,40 +1632,43 @@ def test_vertex_ai_map_google_maps_tool_with_location():
"""
v = VertexGeminiConfig()
optional_params = {}
-
+
tools = v._map_function(
- value=[{
- "googleMaps": {
- "enableWidget": "ENABLE_WIDGET",
- "latitude": 37.7749,
- "longitude": -122.4194,
- "languageCode": "en_US"
+ value=[
+ {
+ "googleMaps": {
+ "enableWidget": "ENABLE_WIDGET",
+ "latitude": 37.7749,
+ "longitude": -122.4194,
+ "languageCode": "en_US",
+ }
}
- }],
- optional_params=optional_params
+ ],
+ optional_params=optional_params,
)
-
+
assert len(tools) == 1
assert "googleMaps" in tools[0]
-
+
google_maps_tool = tools[0]["googleMaps"]
assert google_maps_tool["enableWidget"] == "ENABLE_WIDGET"
assert "latitude" not in google_maps_tool
assert "longitude" not in google_maps_tool
assert "languageCode" not in google_maps_tool
-
+
assert "toolConfig" in optional_params
assert "retrievalConfig" in optional_params["toolConfig"]
-
+
retrieval_config = optional_params["toolConfig"]["retrievalConfig"]
assert retrieval_config["latLng"]["latitude"] == 37.7749
assert retrieval_config["latLng"]["longitude"] == -122.4194
assert retrieval_config["languageCode"] == "en_US"
+
def test_vertex_ai_penalty_parameters_validation():
"""
Test that penalty parameters are properly validated for different Gemini models.
-
+
This test ensures that:
1. Models that don't support penalty parameters (like preview models) filter them out
2. Models that support penalty parameters include them in the request
@@ -1641,14 +1683,19 @@ def test_vertex_ai_penalty_parameters_validation():
for model, should_support in test_cases:
# Test _supports_penalty_parameters method
- assert v._supports_penalty_parameters(model) == should_support, \
- f"Model {model} penalty support should be {should_support}"
+ assert (
+ v._supports_penalty_parameters(model) == should_support
+ ), f"Model {model} penalty support should be {should_support}"
# Test get_supported_openai_params method
supported_params = v.get_supported_openai_params(model)
- has_penalty_params = "frequency_penalty" in supported_params and "presence_penalty" in supported_params
- assert has_penalty_params == should_support, \
- f"Model {model} should {'include' if should_support else 'exclude'} penalty params in supported list"
+ has_penalty_params = (
+ "frequency_penalty" in supported_params
+ and "presence_penalty" in supported_params
+ )
+ assert (
+ has_penalty_params == should_support
+ ), f"Model {model} should {'include' if should_support else 'exclude'} penalty params in supported list"
# Test parameter mapping for unsupported model
model = "gemini-2.5-pro-preview-06-05"
@@ -1656,7 +1703,7 @@ def test_vertex_ai_penalty_parameters_validation():
"temperature": 0.7,
"frequency_penalty": 0.5,
"presence_penalty": 0.3,
- "max_tokens": 100
+ "max_tokens": 100,
}
optional_params = {}
@@ -1664,12 +1711,16 @@ def test_vertex_ai_penalty_parameters_validation():
non_default_params=non_default_params,
optional_params=optional_params,
model=model,
- drop_params=False
+ drop_params=False,
)
# Penalty parameters should be filtered out for unsupported models
- assert "frequency_penalty" not in result, "frequency_penalty should be filtered out for unsupported model"
- assert "presence_penalty" not in result, "presence_penalty should be filtered out for unsupported model"
+ assert (
+ "frequency_penalty" not in result
+ ), "frequency_penalty should be filtered out for unsupported model"
+ assert (
+ "presence_penalty" not in result
+ ), "presence_penalty should be filtered out for unsupported model"
# Other parameters should still be included
assert "temperature" in result, "temperature should still be included"
@@ -1681,7 +1732,7 @@ def test_vertex_ai_penalty_parameters_validation():
def test_vertex_ai_gemini_3_penalty_parameters_unsupported():
"""
Test that penalty parameters are not supported for Gemini 3 models.
-
+
This test ensures that:
1. Gemini 3 models do not support penalty parameters
2. Penalty parameters are excluded from supported params list for Gemini 3 models
@@ -1698,22 +1749,25 @@ def test_vertex_ai_gemini_3_penalty_parameters_unsupported():
for model in gemini_3_models:
# Test _supports_penalty_parameters method
- assert v._supports_penalty_parameters(model) == False, \
- f"Gemini 3 model {model} should not support penalty parameters"
+ assert (
+ v._supports_penalty_parameters(model) == False
+ ), f"Gemini 3 model {model} should not support penalty parameters"
# Test get_supported_openai_params method
supported_params = v.get_supported_openai_params(model)
- assert "frequency_penalty" not in supported_params, \
- f"frequency_penalty should not be in supported params for {model}"
- assert "presence_penalty" not in supported_params, \
- f"presence_penalty should not be in supported params for {model}"
+ assert (
+ "frequency_penalty" not in supported_params
+ ), f"frequency_penalty should not be in supported params for {model}"
+ assert (
+ "presence_penalty" not in supported_params
+ ), f"presence_penalty should not be in supported params for {model}"
# Test parameter mapping - penalty params should be filtered out
non_default_params = {
"temperature": 0.7,
"frequency_penalty": 0.5,
"presence_penalty": 0.3,
- "max_tokens": 100
+ "max_tokens": 100,
}
optional_params = {}
@@ -1721,39 +1775,46 @@ def test_vertex_ai_gemini_3_penalty_parameters_unsupported():
non_default_params=non_default_params,
optional_params=optional_params,
model=model,
- drop_params=False
+ drop_params=False,
)
# Penalty parameters should be filtered out for Gemini 3 models
- assert "frequency_penalty" not in result, \
- f"frequency_penalty should be filtered out for Gemini 3 model {model}"
- assert "presence_penalty" not in result, \
- f"presence_penalty should be filtered out for Gemini 3 model {model}"
+ assert (
+ "frequency_penalty" not in result
+ ), f"frequency_penalty should be filtered out for Gemini 3 model {model}"
+ assert (
+ "presence_penalty" not in result
+ ), f"presence_penalty should be filtered out for Gemini 3 model {model}"
# Other parameters should still be included
- assert "temperature" in result, \
- f"temperature should still be included for Gemini 3 model {model}"
- assert "max_output_tokens" in result, \
- f"max_output_tokens should still be included for Gemini 3 model {model}"
+ assert (
+ "temperature" in result
+ ), f"temperature should still be included for Gemini 3 model {model}"
+ assert (
+ "max_output_tokens" in result
+ ), f"max_output_tokens should still be included for Gemini 3 model {model}"
assert result["temperature"] == 0.7
assert result["max_output_tokens"] == 100
# Test that non-Gemini 3 models still support penalty parameters (if they're not in the unsupported list)
non_gemini_3_model = "gemini-2.5-pro"
- assert v._supports_penalty_parameters(non_gemini_3_model) == True, \
- f"Non-Gemini 3 model {non_gemini_3_model} should support penalty parameters"
-
+ assert (
+ v._supports_penalty_parameters(non_gemini_3_model) == True
+ ), f"Non-Gemini 3 model {non_gemini_3_model} should support penalty parameters"
+
supported_params = v.get_supported_openai_params(non_gemini_3_model)
- assert "frequency_penalty" in supported_params, \
- f"frequency_penalty should be in supported params for {non_gemini_3_model}"
- assert "presence_penalty" in supported_params, \
- f"presence_penalty should be in supported params for {non_gemini_3_model}"
+ assert (
+ "frequency_penalty" in supported_params
+ ), f"frequency_penalty should be in supported params for {non_gemini_3_model}"
+ assert (
+ "presence_penalty" in supported_params
+ ), f"presence_penalty should be in supported params for {non_gemini_3_model}"
def test_vertex_ai_annotation_streaming_events():
"""
Test that annotation events are properly emitted during streaming for Vertex AI Gemini.
-
+
This test verifies:
1. Grounding metadata is converted to annotations in streaming chunks
2. Annotations are included in the delta of streaming chunks
@@ -1776,7 +1837,7 @@ def test_vertex_ai_annotation_streaming_events():
"groundingMetadata": {
"webSearchQueries": ["weather San Francisco today"],
"searchEntryPoint": {
- "renderedContent": '
Search results
'
+ "renderedContent": "Search results
"
},
"groundingChunks": [
{
@@ -1817,7 +1878,7 @@ def test_vertex_ai_annotation_streaming_events():
# Verify the chunk was parsed correctly
assert streaming_chunk.choices is not None
assert len(streaming_chunk.choices) == 1
-
+
# Check that annotations are present in the delta
delta = streaming_chunk.choices[0].delta
assert hasattr(delta, "annotations")
@@ -1870,7 +1931,7 @@ async def test_vertex_ai_streaming_bad_request_is_not_wrapped():
def test_vertex_ai_annotation_conversion():
"""
Test the conversion of Vertex AI grounding metadata to OpenAI annotations.
-
+
This test verifies the _convert_grounding_metadata_to_annotations method
correctly transforms grounding metadata into the expected format.
"""
@@ -1881,9 +1942,7 @@ def test_vertex_ai_annotation_conversion():
# Sample grounding metadata as returned by Vertex AI
grounding_metadata = {
"webSearchQueries": ["weather San Francisco", "current time San Francisco"],
- "searchEntryPoint": {
- "renderedContent": 'Search interface
'
- },
+ "searchEntryPoint": {"renderedContent": "Search interface
"},
"groundingChunks": [
{
"web": {
@@ -1898,7 +1957,7 @@ def test_vertex_ai_annotation_conversion():
"title": "Current time in San Francisco, CA",
"domain": "google.com",
}
- }
+ },
],
"groundingSupports": [
{
@@ -1927,12 +1986,14 @@ def test_vertex_ai_annotation_conversion():
},
"groundingChunkIndices": [1],
"confidenceScores": [0.92],
- }
+ },
],
}
# Convert grounding metadata to annotations
- content_text = "The weather in San Francisco is currently 72°F and the time is 2:30 PM"
+ content_text = (
+ "The weather in San Francisco is currently 72°F and the time is 2:30 PM"
+ )
annotations = VertexGeminiConfig._convert_grounding_metadata_to_annotations(
[grounding_metadata], content_text
)
@@ -1968,7 +2029,7 @@ def test_vertex_ai_annotation_conversion():
def test_vertex_ai_annotation_empty_grounding_metadata():
"""
Test handling of empty or missing grounding metadata.
-
+
This test ensures the annotation conversion handles edge cases gracefully.
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
@@ -2006,6 +2067,7 @@ def test_vertex_ai_annotation_empty_grounding_metadata():
# ==================== Gemini 3 Pro Preview Tests ====================
+
def test_is_gemini_3_or_newer():
"""Test the _is_gemini_3_or_newer method for version detection"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
@@ -2016,8 +2078,13 @@ def test_is_gemini_3_or_newer():
assert VertexGeminiConfig._is_gemini_3_or_newer("gemini-3-pro-preview") == True
assert VertexGeminiConfig._is_gemini_3_or_newer("gemini-3-flash") == True
assert VertexGeminiConfig._is_gemini_3_or_newer("gemini-3-pro") == True
- assert VertexGeminiConfig._is_gemini_3_or_newer("vertex_ai/gemini-3-pro-preview") == True
- assert VertexGeminiConfig._is_gemini_3_or_newer("gemini/gemini-3-pro-preview") == True
+ assert (
+ VertexGeminiConfig._is_gemini_3_or_newer("vertex_ai/gemini-3-pro-preview")
+ == True
+ )
+ assert (
+ VertexGeminiConfig._is_gemini_3_or_newer("gemini/gemini-3-pro-preview") == True
+ )
# Gemini 2.5 and older models
assert VertexGeminiConfig._is_gemini_3_or_newer("gemini-2.5-pro") == False
@@ -2209,8 +2276,12 @@ def test_media_resolution_from_detail_parameter():
)
# Test detail -> media_resolution enum mapping
- assert _convert_detail_to_media_resolution_enum("low") == {"level": "MEDIA_RESOLUTION_LOW"}
- assert _convert_detail_to_media_resolution_enum("high") == {"level": "MEDIA_RESOLUTION_HIGH"}
+ assert _convert_detail_to_media_resolution_enum("low") == {
+ "level": "MEDIA_RESOLUTION_LOW"
+ }
+ assert _convert_detail_to_media_resolution_enum("high") == {
+ "level": "MEDIA_RESOLUTION_HIGH"
+ }
assert _convert_detail_to_media_resolution_enum("auto") is None
assert _convert_detail_to_media_resolution_enum(None) is None
@@ -2223,19 +2294,16 @@ def test_media_resolution_from_detail_parameter():
"content": [
{
"type": "image_url",
- "image_url": {
- "url": base64_image,
- "detail": "high"
- }
+ "image_url": {"url": base64_image, "detail": "high"},
}
- ]
+ ],
}
]
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-3-pro-preview"
)
-
+
# Verify media_resolution is set at the Part level (not inside inline_data)
assert len(contents) == 1
assert len(contents[0]["parts"]) >= 1
@@ -2266,19 +2334,16 @@ def test_media_resolution_low_detail():
"content": [
{
"type": "image_url",
- "image_url": {
- "url": base64_image,
- "detail": "low"
- }
+ "image_url": {"url": base64_image, "detail": "low"},
}
- ]
+ ],
}
]
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-3-pro-preview"
)
-
+
# Find the part with inline_data
image_part = None
for part in contents[0]["parts"]:
@@ -2300,7 +2365,7 @@ def test_media_resolution_auto_detail():
# Using a minimal valid base64-encoded 1x1 PNG
base64_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
-
+
# Test with auto
messages_auto = [
{
@@ -2308,12 +2373,9 @@ def test_media_resolution_auto_detail():
"content": [
{
"type": "image_url",
- "image_url": {
- "url": base64_image,
- "detail": "auto"
- }
+ "image_url": {"url": base64_image, "detail": "auto"},
}
- ]
+ ],
}
]
@@ -2333,14 +2395,7 @@ def test_media_resolution_auto_detail():
messages_none = [
{
"role": "user",
- "content": [
- {
- "type": "image_url",
- "image_url": {
- "url": base64_image
- }
- }
- ]
+ "content": [{"type": "image_url", "image_url": {"url": base64_image}}],
}
]
@@ -2366,48 +2421,39 @@ def test_media_resolution_per_part():
# Using minimal valid base64-encoded 1x1 PNGs
base64_image1 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
base64_image2 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
-
+
messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
- "image_url": {
- "url": base64_image1,
- "detail": "low"
- }
- },
- {
- "type": "text",
- "text": "Compare these images"
+ "image_url": {"url": base64_image1, "detail": "low"},
},
+ {"type": "text", "text": "Compare these images"},
{
"type": "image_url",
- "image_url": {
- "url": base64_image2,
- "detail": "high"
- }
- }
- ]
+ "image_url": {"url": base64_image2, "detail": "high"},
+ },
+ ],
}
]
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-3-pro-preview"
)
-
+
# Should have one content with multiple parts
assert len(contents) == 1
assert len(contents[0]["parts"]) == 3 # image1, text, image2
-
+
# First image should have low resolution (first part is the image)
image1_part = contents[0]["parts"][0]
assert "inline_data" in image1_part
# media_resolution should be at the Part level, not inside inline_data
assert "media_resolution" in image1_part
assert image1_part["media_resolution"] == {"level": "MEDIA_RESOLUTION_LOW"}
-
+
# Second image should have high resolution (third part is the second image)
image2_part = contents[0]["parts"][2]
assert "inline_data" in image2_part
@@ -2544,7 +2590,9 @@ def test_gemini_image_models_excluded_from_thinking():
)
# None of these should have thinkingConfig
- assert "thinkingConfig" not in result, f"Model {model} should not have thinkingConfig"
+ assert (
+ "thinkingConfig" not in result
+ ), f"Model {model} should not have thinkingConfig"
def test_partial_json_chunk_after_first_chunk():
@@ -2575,7 +2623,9 @@ def test_partial_json_chunk_after_first_chunk():
first_chunk = '{"candidates": [{"content": {"parts": [{"text": "Hello"}]}}]}'
result1 = iterator.handle_valid_json_chunk(first_chunk)
assert result1 is not None, "First complete chunk should parse OK"
- assert iterator.sent_first_chunk is True, "sent_first_chunk should be True after first chunk"
+ assert (
+ iterator.sent_first_chunk is True
+ ), "sent_first_chunk should be True after first chunk"
# Later chunk arrives PARTIAL (simulating network fragmentation)
partial_chunk = '{"candidates": [{"content":'
@@ -2583,7 +2633,9 @@ def test_partial_json_chunk_after_first_chunk():
# Should switch to accumulation mode instead of crashing
assert result2 is None, "Partial chunk should return None while accumulating"
- assert iterator.chunk_type == "accumulated_json", "Should switch to accumulated_json mode"
+ assert (
+ iterator.chunk_type == "accumulated_json"
+ ), "Should switch to accumulated_json mode"
def test_partial_json_chunk_on_first_chunk():
@@ -2603,8 +2655,9 @@ def test_partial_json_chunk_on_first_chunk():
result = iterator.handle_valid_json_chunk(partial)
assert result is None, "Partial first chunk should return None"
- assert iterator.chunk_type == "accumulated_json", "Should switch to accumulated_json mode"
-
+ assert (
+ iterator.chunk_type == "accumulated_json"
+ ), "Should switch to accumulated_json mode"
def test_google_ai_studio_presence_penalty_supported():
@@ -2617,6 +2670,8 @@ def test_google_ai_studio_presence_penalty_supported():
supported_params = config.get_supported_openai_params(model="gemini-2.0-flash")
assert "presence_penalty" in supported_params
+
+
# ==================== Tool Type Separation Tests ====================
# These tests verify that each Tool object contains exactly one type per Vertex AI API spec
# Ref: https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1beta1/Tool
@@ -2658,7 +2713,7 @@ def test_vertex_ai_multiple_tool_types_separate_objects():
{"enterpriseWebSearch": {}},
{"url_context": {}},
],
- optional_params=optional_params
+ optional_params=optional_params,
)
# Should have 2 separate Tool objects
@@ -2668,11 +2723,17 @@ def test_vertex_ai_multiple_tool_types_separate_objects():
tool_types_in_first = [k for k in tools[0].keys()]
tool_types_in_second = [k for k in tools[1].keys()]
- assert len(tool_types_in_first) == 1, f"First Tool should have exactly 1 type, got {tool_types_in_first}"
- assert len(tool_types_in_second) == 1, f"Second Tool should have exactly 1 type, got {tool_types_in_second}"
+ assert (
+ len(tool_types_in_first) == 1
+ ), f"First Tool should have exactly 1 type, got {tool_types_in_first}"
+ assert (
+ len(tool_types_in_second) == 1
+ ), f"Second Tool should have exactly 1 type, got {tool_types_in_second}"
# Verify the correct tool types are present
- assert "enterpriseWebSearch" in tools[0], "First Tool should contain enterpriseWebSearch"
+ assert (
+ "enterpriseWebSearch" in tools[0]
+ ), "First Tool should contain enterpriseWebSearch"
assert "url_context" in tools[1], "Second Tool should contain url_context"
@@ -2705,11 +2766,14 @@ def test_vertex_ai_function_declarations_with_other_tools_separate():
tools = v._map_function(
value=[
- {"type": "function", "function": {"name": "get_weather", "description": "Get weather"}},
+ {
+ "type": "function",
+ "function": {"name": "get_weather", "description": "Get weather"},
+ },
{"googleSearch": {}},
{"code_execution": {}},
],
- optional_params=optional_params
+ optional_params=optional_params,
)
# Should have 2 Tool objects: function declarations + code_execution
@@ -2748,8 +2812,7 @@ def test_vertex_ai_single_tool_type_still_works():
optional_params = {}
tools = v._map_function(
- value=[{"code_execution": {}}],
- optional_params=optional_params
+ value=[{"code_execution": {}}], optional_params=optional_params
)
assert len(tools) == 1
@@ -2917,13 +2980,16 @@ def test_vertex_ai_openai_web_search_tool_transformation():
# Test web_search transformation
tools = v._map_function(
- value=[{"type": "web_search"}],
- optional_params=optional_params
+ value=[{"type": "web_search"}], optional_params=optional_params
)
assert len(tools) == 1, f"Expected 1 Tool object, got {len(tools)}"
- assert "googleSearch" in tools[0], f"Expected googleSearch in tool, got {tools[0].keys()}"
- assert tools[0]["googleSearch"] == {}, f"Expected empty googleSearch config, got {tools[0]['googleSearch']}"
+ assert (
+ "googleSearch" in tools[0]
+ ), f"Expected googleSearch in tool, got {tools[0].keys()}"
+ assert (
+ tools[0]["googleSearch"] == {}
+ ), f"Expected empty googleSearch config, got {tools[0]['googleSearch']}"
def test_vertex_ai_openai_web_search_preview_tool_transformation():
@@ -2941,13 +3007,16 @@ def test_vertex_ai_openai_web_search_preview_tool_transformation():
# Test web_search_preview transformation
tools = v._map_function(
- value=[{"type": "web_search_preview"}],
- optional_params=optional_params
+ value=[{"type": "web_search_preview"}], optional_params=optional_params
)
assert len(tools) == 1, f"Expected 1 Tool object, got {len(tools)}"
- assert "googleSearch" in tools[0], f"Expected googleSearch in tool, got {tools[0].keys()}"
- assert tools[0]["googleSearch"] == {}, f"Expected empty googleSearch config, got {tools[0]['googleSearch']}"
+ assert (
+ "googleSearch" in tools[0]
+ ), f"Expected googleSearch in tool, got {tools[0].keys()}"
+ assert (
+ tools[0]["googleSearch"] == {}
+ ), f"Expected empty googleSearch config, got {tools[0]['googleSearch']}"
def test_vertex_ai_openai_web_search_with_function_tools():
@@ -2973,9 +3042,12 @@ def test_vertex_ai_openai_web_search_with_function_tools():
tools = v._map_function(
value=[
{"type": "web_search"},
- {"type": "function", "function": {"name": "get_weather", "description": "Get weather"}},
+ {
+ "type": "function",
+ "function": {"name": "get_weather", "description": "Get weather"},
+ },
],
- optional_params=optional_params
+ optional_params=optional_params,
)
# Should have 1 Tool object: function declarations only
@@ -3015,14 +3087,22 @@ def test_vertex_ai_multiple_function_declarations_grouped():
tools = v._map_function(
value=[
- {"type": "function", "function": {"name": "func1", "description": "First function"}},
- {"type": "function", "function": {"name": "func2", "description": "Second function"}},
+ {
+ "type": "function",
+ "function": {"name": "func1", "description": "First function"},
+ },
+ {
+ "type": "function",
+ "function": {"name": "func2", "description": "Second function"},
+ },
],
- optional_params=optional_params
+ optional_params=optional_params,
)
# Should have only 1 Tool object (function declarations grouped)
- assert len(tools) == 1, f"Expected 1 Tool object for grouped functions, got {len(tools)}"
+ assert (
+ len(tools) == 1
+ ), f"Expected 1 Tool object for grouped functions, got {len(tools)}"
# Should contain function_declarations with 2 functions
assert "function_declarations" in tools[0]
@@ -3106,27 +3186,27 @@ def test_gemini_token_usage_standard_response():
def test_gemini_image_gen_usage_metadata_prompt_vs_completion_separation():
"""
Test that image generation models correctly separate prompt and completion token details.
-
+
This is a regression test for the bug where prompt_tokens_details.image_tokens
was incorrectly set to the completion's image token count instead of 0.
-
+
Scenario: Text-only prompt generates an image response
- Input: Text prompt (no images)
- Output: Generated image + text description
-
+
Expected behavior:
- prompt_tokens_details.image_tokens should be 0 (text-only input)
- completion_tokens_details.image_tokens should be 1290 (generated image)
-
+
Bug behavior (before fix):
- prompt_tokens_details.image_tokens was 1290 (incorrect!)
- completion_tokens_details.image_tokens was 1290 (correct)
-
+
The bug was caused by reusing the same variables (image_tokens, audio_tokens, text_tokens)
for both prompt and completion token details.
"""
v = VertexGeminiConfig()
-
+
# Simulate Gemini image generation model response metadata
# User sends text-only prompt, model generates image + text
usage_metadata_dict = {
@@ -3134,39 +3214,40 @@ def test_gemini_image_gen_usage_metadata_prompt_vs_completion_separation():
"candidatesTokenCount": 1290,
"totalTokenCount": 1391,
# Prompt is text-only (no image tokens in input)
- "promptTokensDetails": [
- {"modality": "TEXT", "tokenCount": 101}
- ],
+ "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 101}],
# Response contains generated image + text
- "candidatesTokensDetails": [
- {"modality": "IMAGE", "tokenCount": 1290}
- ],
+ "candidatesTokensDetails": [{"modality": "IMAGE", "tokenCount": 1290}],
}
-
+
completion_response = {"usageMetadata": usage_metadata_dict}
result = v._calculate_usage(completion_response=completion_response)
-
+
# Verify basic token counts
assert result.prompt_tokens == 101
assert result.completion_tokens == 1290
assert result.total_tokens == 1391
-
+
# CRITICAL: Prompt tokens details should show NO image tokens (text-only input)
- assert result.prompt_tokens_details.text_tokens == 101, \
- "Prompt text tokens should be 101"
- assert result.prompt_tokens_details.image_tokens is None, \
- "Prompt image tokens should be None (text-only input, no images in prompt)"
- assert result.prompt_tokens_details.audio_tokens is None, \
- "Prompt audio tokens should be None"
-
+ assert (
+ result.prompt_tokens_details.text_tokens == 101
+ ), "Prompt text tokens should be 101"
+ assert (
+ result.prompt_tokens_details.image_tokens is None
+ ), "Prompt image tokens should be None (text-only input, no images in prompt)"
+ assert (
+ result.prompt_tokens_details.audio_tokens is None
+ ), "Prompt audio tokens should be None"
+
# Completion tokens details should show the generated image tokens
- assert result.completion_tokens_details.image_tokens == 1290, \
- "Completion image tokens should be 1290 (generated image)"
-
+ assert (
+ result.completion_tokens_details.image_tokens == 1290
+ ), "Completion image tokens should be 1290 (generated image)"
+
# Verify text_tokens is auto-calculated for completion
# candidatesTokenCount (1290) - image_tokens (1290) = 0
- assert result.completion_tokens_details.text_tokens == 0, \
- "Completion text tokens should be 0 (image-only response)"
+ assert (
+ result.completion_tokens_details.text_tokens == 0
+ ), "Completion text tokens should be 0 (image-only response)"
def test_file_object_detail_parameter():
@@ -3185,10 +3266,10 @@ def test_file_object_detail_parameter():
"file": {
"file_id": "https://example.com/video.mp4",
"format": "video/mp4",
- "detail": "low"
- }
- }
- ]
+ "detail": "low",
+ },
+ },
+ ],
}
]
@@ -3208,7 +3289,9 @@ def test_file_object_detail_parameter():
break
assert file_part is not None, "File part should exist"
- assert "media_resolution" in file_part, "media_resolution should be set for file objects"
+ assert (
+ "media_resolution" in file_part
+ ), "media_resolution should be set for file objects"
assert file_part["media_resolution"] == {"level": "MEDIA_RESOLUTION_LOW"}
@@ -3228,10 +3311,10 @@ def test_video_metadata_fps():
"file": {
"file_id": "gs://bucket/video.mp4",
"format": "video/mp4",
- "video_metadata": {"fps": 5}
- }
- }
- ]
+ "video_metadata": {"fps": 5},
+ },
+ },
+ ],
}
]
@@ -3270,11 +3353,11 @@ def test_video_metadata_complete():
"video_metadata": {
"start_offset": "10s",
"end_offset": "60s",
- "fps": 5
- }
- }
- }
- ]
+ "fps": 5,
+ },
+ },
+ },
+ ],
}
]
@@ -3316,10 +3399,10 @@ def test_detail_and_video_metadata_combined():
"file_id": "https://example.com/video.mp4",
"format": "video/mp4",
"detail": "high",
- "video_metadata": {"fps": 10}
- }
- }
- ]
+ "video_metadata": {"fps": 10},
+ },
+ },
+ ],
}
]
@@ -3349,10 +3432,18 @@ def test_new_detail_levels():
)
# Test mapping function
- assert _convert_detail_to_media_resolution_enum("low") == {"level": "MEDIA_RESOLUTION_LOW"}
- assert _convert_detail_to_media_resolution_enum("medium") == {"level": "MEDIA_RESOLUTION_MEDIUM"}
- assert _convert_detail_to_media_resolution_enum("high") == {"level": "MEDIA_RESOLUTION_HIGH"}
- assert _convert_detail_to_media_resolution_enum("ultra_high") == {"level": "MEDIA_RESOLUTION_ULTRA_HIGH"}
+ assert _convert_detail_to_media_resolution_enum("low") == {
+ "level": "MEDIA_RESOLUTION_LOW"
+ }
+ assert _convert_detail_to_media_resolution_enum("medium") == {
+ "level": "MEDIA_RESOLUTION_MEDIUM"
+ }
+ assert _convert_detail_to_media_resolution_enum("high") == {
+ "level": "MEDIA_RESOLUTION_HIGH"
+ }
+ assert _convert_detail_to_media_resolution_enum("ultra_high") == {
+ "level": "MEDIA_RESOLUTION_ULTRA_HIGH"
+ }
# Test with actual message transformation
messages = [
@@ -3364,10 +3455,10 @@ def test_new_detail_levels():
"file": {
"file_id": "https://example.com/video.mp4",
"format": "video/mp4",
- "detail": "medium"
- }
+ "detail": "medium",
+ },
}
- ]
+ ],
}
]
@@ -3401,10 +3492,10 @@ def test_video_metadata_only_for_gemini_3():
"file_id": "https://example.com/video.mp4",
"format": "video/mp4",
"detail": "high",
- "video_metadata": {"fps": 5}
- }
+ "video_metadata": {"fps": 5},
+ },
}
- ]
+ ],
}
]
@@ -3420,8 +3511,12 @@ def test_video_metadata_only_for_gemini_3():
break
assert file_part_1_5 is not None
- assert "media_resolution" not in file_part_1_5, "Gemini 1.5 should not have media_resolution"
- assert "video_metadata" not in file_part_1_5, "Gemini 1.5 should not have video_metadata"
+ assert (
+ "media_resolution" not in file_part_1_5
+ ), "Gemini 1.5 should not have media_resolution"
+ assert (
+ "video_metadata" not in file_part_1_5
+ ), "Gemini 1.5 should not have video_metadata"
# Test with Gemini 3 (should have both)
contents_3 = _gemini_convert_messages_with_history(
@@ -3439,7 +3534,6 @@ def test_video_metadata_only_for_gemini_3():
assert "video_metadata" in file_part_3, "Gemini 3 should have video_metadata"
-
def test_chunk_parser_handles_prompt_feedback_block():
"""Test chunk_parser correctly handles promptFeedback.blockReason"""
from unittest.mock import Mock
@@ -3452,19 +3546,17 @@ def test_chunk_parser_handles_prompt_feedback_block():
blocked_chunk = {
"promptFeedback": {
"blockReason": "PROHIBITED_CONTENT",
- "blockReasonMessage": "The prompt is blocked due to prohibited contents"
+ "blockReasonMessage": "The prompt is blocked due to prohibited contents",
},
"responseId": "test_response_id",
- "modelVersion": "gemini-3-pro-preview"
+ "modelVersion": "gemini-3-pro-preview",
}
logging_obj = Mock()
logging_obj.optional_params = {}
streaming_obj = ModelResponseIterator(
- streaming_response=iter([]),
- sync_stream=True,
- logging_obj=logging_obj
+ streaming_response=iter([]), sync_stream=True, logging_obj=logging_obj
)
# Act
@@ -3473,7 +3565,9 @@ def test_chunk_parser_handles_prompt_feedback_block():
# Assert
assert result is not None, "Result should not be None"
assert len(result.choices) == 1, "Should have exactly one choice"
- assert result.choices[0].finish_reason == "content_filter", f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
+ assert (
+ result.choices[0].finish_reason == "content_filter"
+ ), f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
assert result.choices[0].delta.content is None, "content should be None"
@@ -3489,7 +3583,7 @@ def test_chunk_parser_handles_prompt_feedback_safety_block():
blocked_chunk = {
"promptFeedback": {
"blockReason": "SAFETY",
- "blockReasonMessage": "The prompt is blocked due to safety concerns"
+ "blockReasonMessage": "The prompt is blocked due to safety concerns",
},
"responseId": "test_safety_response_id",
}
@@ -3498,9 +3592,7 @@ def test_chunk_parser_handles_prompt_feedback_safety_block():
logging_obj.optional_params = {}
streaming_obj = ModelResponseIterator(
- streaming_response=iter([]),
- sync_stream=True,
- logging_obj=logging_obj
+ streaming_response=iter([]), sync_stream=True, logging_obj=logging_obj
)
# Act
@@ -3524,24 +3616,22 @@ def test_chunk_parser_handles_prompt_feedback_block_with_usage():
blocked_chunk = {
"promptFeedback": {
"blockReason": "PROHIBITED_CONTENT",
- "blockReasonMessage": "The prompt is blocked due to prohibited contents"
+ "blockReasonMessage": "The prompt is blocked due to prohibited contents",
},
"responseId": "test_response_id_with_usage",
"modelVersion": "gemini-3-pro-preview",
"usageMetadata": {
"promptTokenCount": 8175,
"candidatesTokenCount": 0,
- "totalTokenCount": 8175
- }
+ "totalTokenCount": 8175,
+ },
}
logging_obj = Mock()
logging_obj.optional_params = {}
streaming_obj = ModelResponseIterator(
- streaming_response=iter([]),
- sync_stream=True,
- logging_obj=logging_obj
+ streaming_response=iter([]), sync_stream=True, logging_obj=logging_obj
)
# Act
@@ -3550,15 +3640,23 @@ def test_chunk_parser_handles_prompt_feedback_block_with_usage():
# Assert - 验证 content_filter 响应和 usage 都被正确处理
assert result is not None, "Result should not be None"
assert len(result.choices) == 1, "Should have exactly one choice"
- assert result.choices[0].finish_reason == "content_filter", f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
+ assert (
+ result.choices[0].finish_reason == "content_filter"
+ ), f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
assert result.choices[0].delta.content is None, "content should be None"
# 验证 usage 信息被正确提取
assert hasattr(result, "usage"), "result should have usage attribute"
assert result.usage is not None, "usage should not be None"
- assert result.usage.prompt_tokens == 8175, f"prompt_tokens should be 8175, got {result.usage.prompt_tokens}"
- assert result.usage.completion_tokens == 0, f"completion_tokens should be 0, got {result.usage.completion_tokens}"
- assert result.usage.total_tokens == 8175, f"total_tokens should be 8175, got {result.usage.total_tokens}"
+ assert (
+ result.usage.prompt_tokens == 8175
+ ), f"prompt_tokens should be 8175, got {result.usage.prompt_tokens}"
+ assert (
+ result.usage.completion_tokens == 0
+ ), f"completion_tokens should be 0, got {result.usage.completion_tokens}"
+ assert (
+ result.usage.total_tokens == 8175
+ ), f"total_tokens should be 8175, got {result.usage.total_tokens}"
def test_vertex_ai_traffic_type_preserved_in_hidden_params_streaming():
@@ -3582,7 +3680,9 @@ def test_vertex_ai_traffic_type_preserved_in_hidden_params_streaming():
)
result = iterator.chunk_parser(chunk)
- assert result._hidden_params["provider_specific_fields"]["traffic_type"] == "ON_DEMAND"
+ assert (
+ result._hidden_params["provider_specific_fields"]["traffic_type"] == "ON_DEMAND"
+ )
def test_vertex_ai_traffic_type_preserved_in_hidden_params_non_streaming():
@@ -3621,7 +3721,10 @@ def test_vertex_ai_traffic_type_preserved_in_hidden_params_non_streaming():
encoding=None,
)
- assert result._hidden_params["provider_specific_fields"]["traffic_type"] == "PROVISIONED_THROUGHPUT"
+ assert (
+ result._hidden_params["provider_specific_fields"]["traffic_type"]
+ == "PROVISIONED_THROUGHPUT"
+ )
def test_vertex_ai_service_tier_streaming():
@@ -3635,8 +3738,8 @@ def test_vertex_ai_service_tier_streaming():
}
iterator = ModelResponseIterator(
- streaming_response=[],
- sync_stream=True,
+ streaming_response=[],
+ sync_stream=True,
logging_obj=MagicMock(),
response_headers={"x-gemini-service-tier": "FLEX"},
)
@@ -3646,7 +3749,11 @@ def test_vertex_ai_service_tier_streaming():
# But definitely set when usageMetadata is present
chunk_with_usage = {
"candidates": [{"content": {"parts": [{"text": "hi"}]}}],
- "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}
+ "usageMetadata": {
+ "promptTokenCount": 1,
+ "candidatesTokenCount": 1,
+ "totalTokenCount": 2,
+ },
}
result_with_usage = iterator.chunk_parser(chunk_with_usage)
assert result_with_usage.service_tier == "flex"
@@ -3701,7 +3808,9 @@ def test_vertex_ai_traffic_type_surfaced_in_responses_api():
from litellm.types.utils import Choices, Message
model_response = ModelResponse()
- model_response._hidden_params["provider_specific_fields"] = {"traffic_type": "ON_DEMAND"}
+ model_response._hidden_params["provider_specific_fields"] = {
+ "traffic_type": "ON_DEMAND"
+ }
model_response.choices = [
Choices(
message=Message(content="Hello", role="assistant"),
@@ -3716,7 +3825,9 @@ def test_vertex_ai_traffic_type_surfaced_in_responses_api():
responses_api_request={},
)
- assert responses_api_response.provider_specific_fields["traffic_type"] == "ON_DEMAND"
+ assert (
+ responses_api_response.provider_specific_fields["traffic_type"] == "ON_DEMAND"
+ )
def test_vertex_ai_web_search_options_parameter():
@@ -3749,8 +3860,12 @@ def test_vertex_ai_web_search_options_parameter():
_tools = v._map_web_search_options(web_search_options)
# Verify the tool is a googleSearch tool
- assert "googleSearch" in _tools, f"Expected googleSearch in tool, got {_tools.keys()}"
- assert _tools["googleSearch"] == {}, f"Expected empty googleSearch config, got {_tools['googleSearch']}"
+ assert (
+ "googleSearch" in _tools
+ ), f"Expected googleSearch in tool, got {_tools.keys()}"
+ assert (
+ _tools["googleSearch"] == {}
+ ), f"Expected empty googleSearch config, got {_tools['googleSearch']}"
def test_vertex_ai_web_search_options_in_map_openai_params():
@@ -3773,14 +3888,14 @@ def test_vertex_ai_web_search_options_in_map_openai_params():
v = VertexGeminiConfig()
# Simulate optional_params passed to map_openai_params
- optional_params = {
- "web_search_options": {}
- }
+ optional_params = {"web_search_options": {}}
# Call the transformation that happens in map_openai_params
# Lines 1075-1079 in vertex_and_google_ai_studio_gemini.py (after fix)
web_search_value = optional_params.get("web_search_options")
- if isinstance(web_search_value, dict): # Fixed: removed 'value and' check to support empty dicts
+ if isinstance(
+ web_search_value, dict
+ ): # Fixed: removed 'value and' check to support empty dicts
_tools = v._map_web_search_options(web_search_value)
# Simulate _add_tools_to_optional_params
optional_params = v._add_tools_to_optional_params(optional_params, [_tools])
@@ -3792,8 +3907,12 @@ def test_vertex_ai_web_search_options_in_map_openai_params():
assert "tools" in optional_params, "tools should be added to optional_params"
assert len(optional_params["tools"]) == 1, "Should have exactly one tool"
assert "googleSearch" in optional_params["tools"][0], "Tool should be googleSearch"
- assert optional_params["tools"][0]["googleSearch"] == {}, "googleSearch should be empty config"
- assert "web_search_options" not in optional_params, "web_search_options should be removed after transformation"
+ assert (
+ optional_params["tools"][0]["googleSearch"] == {}
+ ), "googleSearch should be empty config"
+ assert (
+ "web_search_options" not in optional_params
+ ), "web_search_options should be removed after transformation"
def test_vertex_ai_service_tier_in_map_openai_params():
@@ -3803,7 +3922,7 @@ def test_vertex_ai_service_tier_in_map_openai_params():
)
v = VertexGeminiConfig()
-
+
# Test pass-through
optional_params = {}
non_default_params = {"service_tier": "FLEX"}
@@ -3881,19 +4000,24 @@ def test_vertex_ai_usage_metadata_with_video_tokens_in_prompt():
# Verify prompt token details include video tokens
assert result.prompt_tokens_details is not None
- assert result.prompt_tokens_details.video_tokens == 10240, \
- "Prompt video tokens should be 10240"
- assert result.prompt_tokens_details.text_tokens == 9, \
- "Prompt text tokens should be 9"
- assert result.prompt_tokens_details.audio_tokens == 200, \
- "Prompt audio tokens should be 200"
+ assert (
+ result.prompt_tokens_details.video_tokens == 10240
+ ), "Prompt video tokens should be 10240"
+ assert (
+ result.prompt_tokens_details.text_tokens == 9
+ ), "Prompt text tokens should be 9"
+ assert (
+ result.prompt_tokens_details.audio_tokens == 200
+ ), "Prompt audio tokens should be 200"
# Verify completion token details
assert result.completion_tokens_details is not None
- assert result.completion_tokens_details.text_tokens == 79, \
- "Completion text tokens should be 79"
- assert result.completion_tokens_details.video_tokens is None, \
- "Completion video tokens should be None (text-only response)"
+ assert (
+ result.completion_tokens_details.text_tokens == 79
+ ), "Completion text tokens should be 79"
+ assert (
+ result.completion_tokens_details.video_tokens is None
+ ), "Completion video tokens should be None (text-only response)"
def test_vertex_ai_usage_metadata_with_video_tokens_in_candidates():
@@ -3923,14 +4047,17 @@ def test_vertex_ai_usage_metadata_with_video_tokens_in_candidates():
assert result.completion_tokens == 10330
assert result.completion_tokens_details is not None
- assert result.completion_tokens_details.video_tokens == 10240, \
- "Completion video tokens should be 10240"
- assert result.completion_tokens_details.text_tokens == 90, \
- "Completion text tokens should be 90"
+ assert (
+ result.completion_tokens_details.video_tokens == 10240
+ ), "Completion video tokens should be 10240"
+ assert (
+ result.completion_tokens_details.text_tokens == 90
+ ), "Completion text tokens should be 90"
# Verify prompt side has no video tokens
- assert result.prompt_tokens_details.video_tokens is None, \
- "Prompt video tokens should be None (text-only input)"
+ assert (
+ result.prompt_tokens_details.video_tokens is None
+ ), "Prompt video tokens should be None (text-only input)"
def test_vertex_ai_usage_metadata_video_tokens_auto_calculated_text():
@@ -3956,8 +4083,9 @@ def test_vertex_ai_usage_metadata_video_tokens_auto_calculated_text():
assert result.completion_tokens_details.video_tokens == 10240
# text = 10330 - 10240 = 90
- assert result.completion_tokens_details.text_tokens == 90, \
- "text_tokens should be auto-calculated as candidatesTokenCount - video_tokens"
+ assert (
+ result.completion_tokens_details.text_tokens == 90
+ ), "text_tokens should be auto-calculated as candidatesTokenCount - video_tokens"
def test_vertex_ai_usage_metadata_video_tokens_with_caching():
@@ -3988,8 +4116,9 @@ def test_vertex_ai_usage_metadata_video_tokens_with_caching():
result = v._calculate_usage(completion_response=completion_response)
# video tokens should be reduced by cached amount: 10240 - 5120 = 5120
- assert result.prompt_tokens_details.video_tokens == 5120, \
- "Prompt video tokens should be 10240 - 5120 (cached) = 5120"
+ assert (
+ result.prompt_tokens_details.video_tokens == 5120
+ ), "Prompt video tokens should be 10240 - 5120 (cached) = 5120"
assert result.prompt_tokens_details.text_tokens == 9
assert result.prompt_tokens_details.audio_tokens == 200