Skip to content
48 changes: 48 additions & 0 deletions litellm/llms/gemini/google_genai/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,6 +12,10 @@
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

Expand Down Expand Up @@ -302,6 +307,47 @@ async def get_auth_token_and_url(
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)
generate_content_config_dict["responseJsonSchema"] = 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,
Expand All @@ -315,6 +361,8 @@ def transform_generate_content_request(
GenerateContentRequestDict,
)

self._normalize_response_schema(generate_content_config_dict, model)

typed_generate_content_request = GenerateContentRequestDict(
model=model,
contents=contents,
Expand Down
3 changes: 3 additions & 0 deletions litellm/llms/vertex_ai/google_genai/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ def transform_generate_content_request(
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,
Expand Down
227 changes: 227 additions & 0 deletions tests/test_litellm/google_genai/test_google_genai_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading