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:
Mateo Wang 2026-05-12 23:26:34 -07:00 committed by GitHub
parent 431daa1479
commit 8eecf76d36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 283 additions and 0 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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.