fix(gemini): preserve $ref in JSON Schema for Gemini 2.0+ to avoid nesting depth errors

This commit is contained in:
Chesars 2026-02-19 16:51:58 -03:00
parent 2c8fcf854a
commit 3fe331ed7d
4 changed files with 130 additions and 31 deletions

View file

@ -524,7 +524,7 @@ def _build_json_schema(parameters: dict) -> dict:
- Does NOT convert types to uppercase (keeps standard JSON Schema format)
- Does NOT add propertyOrdering
- Does NOT filter fields (allows additionalProperties)
- Still unpacks $defs/$ref (Gemini doesn't support JSON Schema references)
- Preserves $defs/$ref (Gemini 2.0+ supports JSON Schema references natively)
Parameters:
parameters: dict - the JSON schema to process
@ -532,24 +532,12 @@ def _build_json_schema(parameters: dict) -> dict:
Returns:
dict - the processed schema in standard JSON Schema format
"""
# Unpack $defs references (Gemini doesn't support $ref)
defs = parameters.pop("$defs", {})
for name, value in defs.items():
unpack_defs(value, defs)
unpack_defs(parameters, defs)
# Convert anyOf with null to nullable
convert_anyof_null_to_nullable(parameters)
# Handle empty strings in enum values - Gemini doesn't accept empty strings in enums
_fix_enum_empty_strings(parameters)
# Remove enums for non-string typed fields (Gemini requires enum only on strings)
_fix_enum_types(parameters)
# Handle empty items objects
process_items(parameters)
add_object_type(parameters)
# Gemini 2.0+ with responseJsonSchema accepts standard JSON Schema as-is,
# including $ref, $defs, anyOf, etc. No transformations needed — the
# OpenAPI-specific fixes (unpack_defs, add_object_type, convert_anyof, etc.)
# are only required for responseSchema (Gemini 1.5) and can break valid
# JSON Schema by adding conflicting fields to $ref nodes.
# See: https://blog.google/technology/developers/gemini-api-structured-outputs/
return parameters

View file

@ -14,6 +14,7 @@ from typing import (
Literal,
Optional,
Tuple,
Type,
Union,
cast,
)
@ -106,6 +107,8 @@ from .transformation import (
)
if TYPE_CHECKING:
from pydantic import BaseModel
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.utils import ModelResponseStream, StreamingChoices
@ -226,6 +229,47 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
def get_config(cls):
return super().get_config()
def get_json_schema_from_pydantic_object(
self, response_format: Optional[Union[Type["BaseModel"], dict]]
) -> Optional[dict]:
"""
Override to use Pydantic's model_json_schema() instead of OpenAI's
to_strict_json_schema().
OpenAI's to_strict_json_schema() inlines all $ref references, which
dramatically increases schema nesting depth and causes Gemini to reject
schemas with 'exceeds maximum allowed nesting depth' errors.
Pydantic's model_json_schema() preserves $ref/$defs, keeping the schema
compact. Gemini 2.0+ (responseJsonSchema) natively supports $ref, and
Gemini 1.5 (responseSchema) handles unpacking via _build_vertex_schema.
See: https://github.com/BerriAI/litellm/issues/21014
"""
from pydantic import BaseModel as _BaseModel
if response_format is None:
return None
if isinstance(response_format, dict):
return response_format
if isinstance(response_format, type) and issubclass(
response_format, _BaseModel
):
schema = response_format.model_json_schema()
return {
"type": "json_schema",
"json_schema": {
"schema": schema,
"name": response_format.__name__,
"strict": True,
},
}
# Fallback: delegate to parent for unknown types
return super().get_json_schema_from_pydantic_object(response_format)
@staticmethod
def _is_gemini_3_or_newer(model: str) -> bool:
"""

View file

@ -3865,18 +3865,6 @@ def get_optional_params( # noqa: PLR0915
):
passed_params = locals().copy()
special_params = passed_params.pop("kwargs")
non_default_params = pre_process_non_default_params(
passed_params=passed_params,
special_params=special_params,
custom_llm_provider=custom_llm_provider,
additional_drop_params=additional_drop_params,
model=model,
)
optional_params = pre_process_optional_params(
passed_params=passed_params,
non_default_params=non_default_params,
custom_llm_provider=custom_llm_provider,
)
provider_config: Optional[BaseConfig] = None
if custom_llm_provider is not None and custom_llm_provider in [
provider.value for provider in LlmProviders
@ -3884,6 +3872,19 @@ def get_optional_params( # noqa: PLR0915
provider_config = ProviderConfigManager.get_provider_chat_config(
model=model, provider=LlmProviders(custom_llm_provider)
)
non_default_params = pre_process_non_default_params(
passed_params=passed_params,
special_params=special_params,
custom_llm_provider=custom_llm_provider,
additional_drop_params=additional_drop_params,
model=model,
provider_config=provider_config,
)
optional_params = pre_process_optional_params(
passed_params=passed_params,
non_default_params=non_default_params,
custom_llm_provider=custom_llm_provider,
)
def _check_valid_arg(supported_params: List[str]):
"""

View file

@ -210,6 +210,72 @@ def test_vertex_ai_response_schema_defs():
}
def test_vertex_ai_response_json_schema_preserves_refs_for_gemini_2():
"""
Test that $defs and $ref are preserved for Gemini 2.0+ models using responseJsonSchema.
Gemini 2.0+ supports standard JSON Schema with $ref/$defs natively.
Unpacking them inflates nesting depth and can exceed Gemini's limit.
"""
v = VertexGeminiConfig()
schema = cast(dict, v.get_json_schema_from_pydantic_object(MathReasoning))
# Pydantic generates $defs with $ref — verify our test input has them
assert "$defs" in schema["json_schema"]["schema"]
transformed_request = v.map_openai_params(
non_default_params={
"messages": [{"role": "user", "content": "Hello, world!"}],
"response_format": schema,
},
optional_params={},
model="gemini-2.5-flash", # Gemini 2.0+ uses responseJsonSchema
drop_params=False,
)
# $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+"
def test_vertex_ai_get_json_schema_preserves_refs_for_nested_pydantic():
"""
Test that get_json_schema_from_pydantic_object uses model_json_schema()
(which preserves $ref/$defs) instead of OpenAI's to_strict_json_schema()
(which inlines all $ref, inflating nesting depth).
This is the root cause fix for https://github.com/BerriAI/litellm/issues/21014
"""
from pydantic import Field
class Inner(BaseModel):
value: str = Field(description="A value")
class Outer(BaseModel):
first: Inner = Field(description="First inner")
second: Inner = Field(description="Second inner")
# VertexGeminiConfig override should preserve $ref
config = VertexGeminiConfig()
result = config.get_json_schema_from_pydantic_object(Outer)
assert result is not None
schema = result["json_schema"]["schema"]
schema_str = json.dumps(schema)
# model_json_schema() produces $ref/$defs; to_strict_json_schema() inlines them
assert "$defs" in schema, "Schema should have $defs (not inlined)"
assert "$ref" in schema_str, "Schema should have $ref references (not inlined)"
# GoogleAIStudioGeminiConfig inherits the same behavior
gemini_config = GoogleAIStudioGeminiConfig()
result2 = gemini_config.get_json_schema_from_pydantic_object(Outer)
schema2 = result2["json_schema"]["schema"]
assert "$defs" in schema2, "GoogleAIStudioGeminiConfig should also preserve $defs"
def test_vertex_ai_response_json_schema_for_gemini_2():
"""
Test that Gemini 2.0+ models automatically use responseJsonSchema.