fix(gemini): normalize response_schema on native generateContent (#27775)
* fix(gemini): normalize response_schema on native generateContent
The /v1beta/models/{model}:generateContent passthrough forwarded
generationConfig.response_schema verbatim, so schemas containing $defs,
$ref, anyOf-with-null, default, or title were rejected by Gemini even
though /chat/completions already handles them.
GoogleGenAIConfig.transform_generate_content_request now calls a new
_normalize_response_schema helper that mirrors the chat/completions
path: Gemini 2.0+ models get the schema promoted to responseJsonSchema
via _build_json_schema (preserving $defs/$ref natively), older models
keep responseSchema but the schema is flattened with
_build_vertex_schema. VertexAIGoogleGenAIConfig (which overrides the
transform entirely) calls the same helper before building the request.
* fix(gemini): preserve caller-supplied responseJsonSchema when responseSchema co-present
Previously, when both responseJsonSchema and responseSchema were present
on Gemini 2.0+, _normalize_response_schema processed responseJsonSchema
first (no-op normalization) then unconditionally promoted responseSchema
to responseJsonSchema, clobbering the caller-supplied value.
Now skip the promotion (and drop the redundant responseSchema) when the
caller already supplied responseJsonSchema.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* chore: strip restating comments from response-schema normalize
Drop the docstring on _normalize_response_schema and the two inline
comments that just restated what the surrounding code/asserts already
say. Function name + variable names carry the intent; PR description
covers the why-it-exists context.
* perf(gemini): drop redundant deepcopy on responseJsonSchema normalize
_build_json_schema is a no-op (returns its argument unchanged), so the
deepcopy + round-trip on the responseJsonSchema branch allocated a full
schema copy on every request with no observable effect. Forward the
caller's value as-is, and just move the popped responseSchema value when
promoting on Gemini 2.0+.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* style: remove unneeded comment
* fix(gemini): drop unsupported responseJsonSchema for older models
* test(gemini): add parity test between native and chat schema normalization
Per @Sameerlite review: lock the two Gemini schema-normalization paths
together. If either GoogleGenAIConfig._normalize_response_schema (native
generateContent) or VertexGeminiConfig.apply_response_schema_transformation
(/chat/completions) drifts, the parity test fails — forcing both to be
updated together.
* fix(google_genai): preserve key naming convention in _normalize_response_schema
When the input schema key is snake_case (response_schema), the promoted
JSON schema key should also be snake_case (response_json_schema) instead
of mixing in camelCase (responseJsonSchema). This matters for the Vertex
AI google_genai path which converts all keys to snake_case before
calling _normalize_response_schema.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
431daa1479
commit
8eecf76d36
@ -2,6 +2,7 @@
|
||||
Transformation for Calling Google models in their native format.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast
|
||||
|
||||
import httpx
|
||||
@ -11,6 +12,10 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
from litellm.llms.base_llm.google_genai.transformation import (
|
||||
BaseGoogleGenAIGenerateContentConfig,
|
||||
)
|
||||
from litellm.llms.vertex_ai.common_utils import (
|
||||
_build_vertex_schema,
|
||||
supports_response_json_schema,
|
||||
)
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
@ -302,6 +307,52 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_response_schema(
|
||||
generate_content_config_dict: Dict, model: str
|
||||
) -> None:
|
||||
schema_key = next(
|
||||
(
|
||||
k
|
||||
for k in ("responseSchema", "response_schema")
|
||||
if k in generate_content_config_dict
|
||||
),
|
||||
None,
|
||||
)
|
||||
json_schema_key = next(
|
||||
(
|
||||
k
|
||||
for k in ("responseJsonSchema", "response_json_schema")
|
||||
if k in generate_content_config_dict
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if schema_key is None:
|
||||
return
|
||||
|
||||
value = generate_content_config_dict[schema_key]
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
|
||||
if supports_response_json_schema(model):
|
||||
if json_schema_key is not None:
|
||||
generate_content_config_dict.pop(schema_key)
|
||||
return
|
||||
generate_content_config_dict.pop(schema_key)
|
||||
new_json_schema_key = (
|
||||
"response_json_schema"
|
||||
if schema_key == "response_schema"
|
||||
else "responseJsonSchema"
|
||||
)
|
||||
generate_content_config_dict[new_json_schema_key] = value
|
||||
else:
|
||||
if json_schema_key is not None:
|
||||
generate_content_config_dict.pop(json_schema_key)
|
||||
generate_content_config_dict[schema_key] = _build_vertex_schema(
|
||||
parameters=deepcopy(value), add_property_ordering=True
|
||||
)
|
||||
|
||||
def transform_generate_content_request(
|
||||
self,
|
||||
model: str,
|
||||
@ -315,6 +366,8 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
|
||||
GenerateContentRequestDict,
|
||||
)
|
||||
|
||||
self._normalize_response_schema(generate_content_config_dict, model)
|
||||
|
||||
typed_generate_content_request = GenerateContentRequestDict(
|
||||
model=model,
|
||||
contents=contents,
|
||||
|
||||
@ -79,6 +79,9 @@ class VertexAIGoogleGenAIConfig(GoogleGenAIConfig):
|
||||
Transform the generate content request for Vertex AI.
|
||||
Since Vertex AI natively supports Google GenAI format, we can pass most fields directly.
|
||||
"""
|
||||
if generate_content_config_dict:
|
||||
self._normalize_response_schema(generate_content_config_dict, model)
|
||||
|
||||
# Build the request in Google GenAI format that Vertex AI expects
|
||||
result = {
|
||||
"model": model,
|
||||
|
||||
@ -335,6 +335,233 @@ def test_transform_generate_content_request_system_instruction_with_tools():
|
||||
assert result["model"] == "gemini-3-flash-preview"
|
||||
|
||||
|
||||
def test_transform_generate_content_request_normalizes_response_schema_2_5():
|
||||
"""For Gemini 2.0+, ``response_schema`` with ``$defs``/``$ref`` should be
|
||||
promoted to ``responseJsonSchema`` (which Gemini 2.0+ accepts natively),
|
||||
not forwarded as ``responseSchema`` (which rejects ``$defs``)."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
schema = {
|
||||
"$defs": {
|
||||
"Highlight": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"detail": {"type": "string"},
|
||||
},
|
||||
"required": ["title"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"park_name": {"type": "string"},
|
||||
"highlights": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Highlight"},
|
||||
},
|
||||
},
|
||||
"required": ["park_name", "highlights"],
|
||||
}
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini-2.5-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
tools=None,
|
||||
generate_content_config_dict={
|
||||
"responseMimeType": "application/json",
|
||||
"responseSchema": schema,
|
||||
},
|
||||
system_instruction=None,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert "responseSchema" not in gen_config
|
||||
assert "responseJsonSchema" in gen_config
|
||||
normalized = gen_config["responseJsonSchema"]
|
||||
assert "$defs" in normalized
|
||||
assert normalized["properties"]["highlights"]["items"] == {
|
||||
"$ref": "#/$defs/Highlight"
|
||||
}
|
||||
|
||||
|
||||
def test_transform_generate_content_request_flattens_response_schema_1_5():
|
||||
"""For Gemini 1.5, ``responseSchema`` is kept but flattened via
|
||||
``_build_vertex_schema`` so ``$defs``/``$ref`` are unpacked."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
schema = {
|
||||
"$defs": {
|
||||
"Highlight": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"highlights": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Highlight"},
|
||||
}
|
||||
},
|
||||
"required": ["highlights"],
|
||||
}
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini-1.5-pro",
|
||||
contents=[{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
tools=None,
|
||||
generate_content_config_dict={"responseSchema": schema},
|
||||
system_instruction=None,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert "responseSchema" in gen_config
|
||||
assert "responseJsonSchema" not in gen_config
|
||||
normalized = gen_config["responseSchema"]
|
||||
assert "$defs" not in normalized
|
||||
items = normalized["properties"]["highlights"]["items"]
|
||||
assert "$ref" not in items
|
||||
assert "title" in items["properties"]
|
||||
assert items["properties"]["title"]["type"].lower() == "string"
|
||||
|
||||
|
||||
def test_transform_generate_content_request_passes_through_response_json_schema():
|
||||
"""If the caller already used ``responseJsonSchema``, it should be
|
||||
preserved (Gemini 2.0+ accepts standard JSON Schema as-is)."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini-2.5-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
tools=None,
|
||||
generate_content_config_dict={"responseJsonSchema": schema},
|
||||
system_instruction=None,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert gen_config["responseJsonSchema"] == schema
|
||||
assert "responseSchema" not in gen_config
|
||||
|
||||
|
||||
def test_transform_generate_content_request_preserves_response_json_schema_when_response_schema_co_present():
|
||||
"""When both ``responseJsonSchema`` and ``responseSchema`` are supplied on
|
||||
Gemini 2.0+, the caller's ``responseJsonSchema`` must win — the
|
||||
``responseSchema`` value must not clobber it."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
caller_json_schema = {
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string"}},
|
||||
"required": ["answer"],
|
||||
}
|
||||
redundant_response_schema = {
|
||||
"type": "object",
|
||||
"properties": {"other": {"type": "string"}},
|
||||
}
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini-2.5-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
tools=None,
|
||||
generate_content_config_dict={
|
||||
"responseJsonSchema": caller_json_schema,
|
||||
"responseSchema": redundant_response_schema,
|
||||
},
|
||||
system_instruction=None,
|
||||
)
|
||||
|
||||
gen_config = result["generationConfig"]
|
||||
assert "responseSchema" not in gen_config
|
||||
assert gen_config["responseJsonSchema"] == caller_json_schema
|
||||
|
||||
|
||||
def test_response_schema_normalization_parity_across_chat_and_native_paths():
|
||||
"""Parity guard between the two Gemini schema-normalization paths.
|
||||
|
||||
The native ``generateContent`` path (``_normalize_response_schema``) must
|
||||
produce the same normalized schema as the ``/chat/completions`` path
|
||||
(``apply_response_schema_transformation``) for the same input schema, on
|
||||
both Gemini 2.0+ and Gemini 1.5. If either implementation drifts, this
|
||||
test fails — forcing both paths to be updated together.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
||||
schema = {
|
||||
"$defs": {
|
||||
"Highlight": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"detail": {"type": "string"},
|
||||
},
|
||||
"required": ["title"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"park_name": {"type": "string"},
|
||||
"highlights": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Highlight"},
|
||||
},
|
||||
"rating": {"anyOf": [{"type": "number"}, {"type": "null"}]},
|
||||
},
|
||||
"required": ["park_name", "highlights"],
|
||||
}
|
||||
|
||||
native_config = GoogleGenAIConfig()
|
||||
chat_config = VertexGeminiConfig()
|
||||
|
||||
for model, native_out_key, chat_out_key in (
|
||||
("gemini-2.5-flash-lite", "responseJsonSchema", "response_json_schema"),
|
||||
("gemini-1.5-pro", "responseSchema", "response_schema"),
|
||||
):
|
||||
native_dict = {"responseSchema": deepcopy(schema)}
|
||||
native_config._normalize_response_schema(native_dict, model)
|
||||
|
||||
chat_optional_params: dict = {}
|
||||
chat_config.apply_response_schema_transformation(
|
||||
value={"type": "json_schema", "response_schema": deepcopy(schema)},
|
||||
optional_params=chat_optional_params,
|
||||
model=model,
|
||||
)
|
||||
|
||||
assert native_dict[native_out_key] == chat_optional_params[chat_out_key], (
|
||||
f"Schema normalization drifted between native generateContent and "
|
||||
f"chat/completions paths for {model}. Update both "
|
||||
f"GoogleGenAIConfig._normalize_response_schema and "
|
||||
f"VertexGeminiConfig.apply_response_schema_transformation together."
|
||||
)
|
||||
|
||||
|
||||
def test_transform_generate_content_request_without_schema_unchanged():
|
||||
"""No schema in config → no normalization side effects."""
|
||||
config = GoogleGenAIConfig()
|
||||
|
||||
result = config.transform_generate_content_request(
|
||||
model="gemini-2.5-flash-lite",
|
||||
contents=[{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
tools=None,
|
||||
generate_content_config_dict={"temperature": 0.7},
|
||||
system_instruction=None,
|
||||
)
|
||||
|
||||
assert result["generationConfig"]["temperature"] == 0.7
|
||||
assert "responseSchema" not in result["generationConfig"]
|
||||
assert "responseJsonSchema" not in result["generationConfig"]
|
||||
|
||||
|
||||
def test_validate_environment_with_dict_api_key():
|
||||
"""
|
||||
Test that validate_environment correctly handles api_key as a dict.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user