mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
feat(gemini): use responseFormat for googleMaps + structured output
The Gemini API rejects googleMaps tool combined with responseMimeType: 'application/json' (400 error). This converts to the newer responseFormat structure which supports googleMaps + structured JSON output with full grounding metadata. Only applies to the gemini/ provider path (generativelanguage.googleapis.com), not vertex_ai/ which does not support the responseFormat field.
This commit is contained in:
parent
76bf280d0a
commit
89f6bf9287
4 changed files with 245 additions and 0 deletions
|
|
@ -95,6 +95,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
|
|||
"automatic_function_calling",
|
||||
"thinking_config",
|
||||
"image_config",
|
||||
"response_format",
|
||||
]
|
||||
|
||||
def map_generate_content_optional_params(
|
||||
|
|
@ -353,6 +354,63 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
|
|||
parameters=deepcopy(value), add_property_ordering=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_google_maps_tool(tools: Optional[Any]) -> bool:
|
||||
"""Check if googleMaps is present in the tools list."""
|
||||
from litellm.llms.vertex_ai.gemini.transformation import _has_google_maps_tool
|
||||
|
||||
return _has_google_maps_tool(tools)
|
||||
|
||||
@staticmethod
|
||||
def _use_response_format_for_google_maps(
|
||||
generate_content_config_dict: Dict,
|
||||
) -> None:
|
||||
"""
|
||||
Convert responseMimeType + responseJsonSchema/responseSchema to the newer
|
||||
responseFormat structure when googleMaps tool is present.
|
||||
|
||||
The Gemini API rejects googleMaps + responseMimeType: 'application/json'
|
||||
with error "Google Maps tool with a response mime type: 'application/json'
|
||||
is unsupported". The newer responseFormat structure works correctly.
|
||||
"""
|
||||
mime_key = next(
|
||||
(
|
||||
k
|
||||
for k in ("responseMimeType", "response_mime_type")
|
||||
if k in generate_content_config_dict
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mime_key is None:
|
||||
return
|
||||
|
||||
mime_value = generate_content_config_dict[mime_key]
|
||||
if mime_value != "application/json":
|
||||
return
|
||||
|
||||
schema_key = next(
|
||||
(
|
||||
k
|
||||
for k in (
|
||||
"responseJsonSchema",
|
||||
"response_json_schema",
|
||||
"responseSchema",
|
||||
"response_schema",
|
||||
)
|
||||
if k in generate_content_config_dict
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
schema = generate_content_config_dict.pop(schema_key) if schema_key else None
|
||||
generate_content_config_dict.pop(mime_key)
|
||||
|
||||
response_format: Dict[str, Any] = {"text": {"mimeType": "APPLICATION_JSON"}}
|
||||
if schema is not None:
|
||||
response_format["text"]["schema"] = schema
|
||||
|
||||
generate_content_config_dict["responseFormat"] = response_format
|
||||
|
||||
def transform_generate_content_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -368,6 +426,9 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
|
|||
|
||||
self._normalize_response_schema(generate_content_config_dict, model)
|
||||
|
||||
if self._has_google_maps_tool(tools):
|
||||
self._use_response_format_for_google_maps(generate_content_config_dict)
|
||||
|
||||
typed_generate_content_request = GenerateContentRequestDict(
|
||||
model=model,
|
||||
contents=contents,
|
||||
|
|
|
|||
|
|
@ -1109,6 +1109,17 @@ def _pop_and_merge_extra_body(data: RequestBody, optional_params: dict) -> None:
|
|||
data_dict[k] = v
|
||||
|
||||
|
||||
def _has_google_maps_tool(tools: Optional[Any]) -> bool:
|
||||
"""Check if googleMaps is present in the tools list."""
|
||||
if not tools:
|
||||
return False
|
||||
if isinstance(tools, list):
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and "googleMaps" in tool:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _transform_request_body( # noqa: PLR0915
|
||||
messages: List[AllMessageValues],
|
||||
model: str,
|
||||
|
|
@ -1197,6 +1208,23 @@ def _transform_request_body( # noqa: PLR0915
|
|||
"level"
|
||||
]
|
||||
|
||||
# Gemini API rejects googleMaps + responseMimeType: 'application/json'.
|
||||
# Convert to the newer responseFormat structure which supports this combination.
|
||||
if (
|
||||
custom_llm_provider == "gemini"
|
||||
and generation_config is not None
|
||||
and _has_google_maps_tool(tools)
|
||||
and generation_config.get("response_mime_type") == "application/json"
|
||||
):
|
||||
schema = generation_config.pop("response_json_schema", None)
|
||||
if schema is None:
|
||||
schema = generation_config.pop("response_schema", None)
|
||||
generation_config.pop("response_mime_type", None)
|
||||
response_format_value: dict = {"text": {"mimeType": "APPLICATION_JSON"}}
|
||||
if schema is not None:
|
||||
response_format_value["text"]["schema"] = schema
|
||||
generation_config["responseFormat"] = response_format_value
|
||||
|
||||
data = RequestBody(contents=content)
|
||||
# Vertex rejects system_instruction/tools/toolConfig alongside cachedContent.
|
||||
# Treat dropping these fields as a request mutation guarded by modify_params.
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ class GenerationConfig(TypedDict, total=False):
|
|||
response_mime_type: Literal["text/plain", "application/json"]
|
||||
response_schema: dict
|
||||
response_json_schema: dict
|
||||
responseFormat: dict
|
||||
seed: int
|
||||
responseLogprobs: bool
|
||||
logprobs: int
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"""
|
||||
Test to verify the Google GenAI transformation logic for generateContent parameters
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
|
@ -644,3 +645,157 @@ def test_validate_environment_with_extra_headers():
|
|||
assert "X-Custom-Header" in result, "Extra headers should be merged"
|
||||
assert result["X-Custom-Header"] == "custom-value"
|
||||
assert "Content-Type" in result
|
||||
|
||||
|
||||
def test_google_maps_tool_uses_response_format():
|
||||
"""
|
||||
Test that when googleMaps is in tools and structured output is requested,
|
||||
the config uses responseFormat instead of responseMimeType + responseJsonSchema.
|
||||
|
||||
The Gemini API rejects googleMaps + responseMimeType: 'application/json' with:
|
||||
"Google Maps tool with a response mime type: 'application/json' is unsupported"
|
||||
"""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
generate_content_config_dict = {
|
||||
"responseMimeType": "application/json",
|
||||
"responseJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"places": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"cid": {"type": "number"},
|
||||
"rating": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "rating", "cid"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["places"],
|
||||
},
|
||||
"temperature": 1.0,
|
||||
}
|
||||
|
||||
tools = [{"googleMaps": {}}]
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini/gemini-3.1-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "Find restaurants"}]}],
|
||||
tools=tools,
|
||||
generate_content_config_dict=generate_content_config_dict,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
|
||||
assert "responseMimeType" not in gen_config
|
||||
assert "responseJsonSchema" not in gen_config
|
||||
assert "responseFormat" in gen_config
|
||||
assert gen_config["responseFormat"]["text"]["mimeType"] == "APPLICATION_JSON"
|
||||
assert "schema" in gen_config["responseFormat"]["text"]
|
||||
assert gen_config["responseFormat"]["text"]["schema"]["type"] == "object"
|
||||
assert gen_config["temperature"] == 1.0
|
||||
|
||||
|
||||
def test_google_maps_tool_with_enable_widget_uses_response_format():
|
||||
"""Test responseFormat is used when googleMaps has enableWidget: true."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
generate_content_config_dict = {
|
||||
"responseMimeType": "application/json",
|
||||
"responseJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {"places": {"type": "array"}},
|
||||
},
|
||||
}
|
||||
|
||||
tools = [{"googleMaps": {"enableWidget": True}}]
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini/gemini-3.1-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "Find restaurants"}]}],
|
||||
tools=tools,
|
||||
generate_content_config_dict=generate_content_config_dict,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert "responseMimeType" not in gen_config
|
||||
assert "responseFormat" in gen_config
|
||||
assert gen_config["responseFormat"]["text"]["mimeType"] == "APPLICATION_JSON"
|
||||
|
||||
|
||||
def test_no_google_maps_keeps_response_mime_type():
|
||||
"""Test that without googleMaps, responseMimeType is preserved unchanged."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
generate_content_config_dict = {
|
||||
"responseMimeType": "application/json",
|
||||
"responseJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
},
|
||||
"temperature": 0.5,
|
||||
}
|
||||
|
||||
tools = [{"googleSearch": {}}]
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini/gemini-3.1-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "Search something"}]}],
|
||||
tools=tools,
|
||||
generate_content_config_dict=generate_content_config_dict,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert "responseMimeType" in gen_config
|
||||
assert gen_config["responseMimeType"] == "application/json"
|
||||
assert "responseFormat" not in gen_config
|
||||
|
||||
|
||||
def test_google_maps_without_structured_output_unchanged():
|
||||
"""Test that googleMaps without response schema doesn't add responseFormat."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
generate_content_config_dict = {
|
||||
"temperature": 1.0,
|
||||
}
|
||||
|
||||
tools = [{"googleMaps": {}}]
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini/gemini-3.1-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "Find restaurants"}]}],
|
||||
tools=tools,
|
||||
generate_content_config_dict=generate_content_config_dict,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert "responseFormat" not in gen_config
|
||||
assert "responseMimeType" not in gen_config
|
||||
assert gen_config["temperature"] == 1.0
|
||||
|
||||
|
||||
def test_google_maps_with_text_plain_mime_type_unchanged():
|
||||
"""Test that googleMaps + text/plain responseMimeType is not converted."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
generate_content_config_dict = {
|
||||
"responseMimeType": "text/plain",
|
||||
"temperature": 1.0,
|
||||
}
|
||||
|
||||
tools = [{"googleMaps": {}}]
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini/gemini-3.1-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "Find restaurants"}]}],
|
||||
tools=tools,
|
||||
generate_content_config_dict=generate_content_config_dict,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert gen_config["responseMimeType"] == "text/plain"
|
||||
assert "responseFormat" not in gen_config
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue