diff --git a/litellm/llms/gemini/google_genai/transformation.py b/litellm/llms/gemini/google_genai/transformation.py index 7c4c7dba62..ee201af7e1 100644 --- a/litellm/llms/gemini/google_genai/transformation.py +++ b/litellm/llms/gemini/google_genai/transformation.py @@ -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, diff --git a/litellm/llms/vertex_ai/google_genai/transformation.py b/litellm/llms/vertex_ai/google_genai/transformation.py index d7a4ceeb3e..c1120d9ab8 100644 --- a/litellm/llms/vertex_ai/google_genai/transformation.py +++ b/litellm/llms/vertex_ai/google_genai/transformation.py @@ -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, diff --git a/tests/test_litellm/google_genai/test_google_genai_transformation.py b/tests/test_litellm/google_genai/test_google_genai_transformation.py index 908a68110f..6b0cd500a8 100644 --- a/tests/test_litellm/google_genai/test_google_genai_transformation.py +++ b/tests/test_litellm/google_genai/test_google_genai_transformation.py @@ -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.