diff --git a/litellm/google_genai/adapters/transformation.py b/litellm/google_genai/adapters/transformation.py index 6a698bb6018..5c35754ec8e 100644 --- a/litellm/google_genai/adapters/transformation.py +++ b/litellm/google_genai/adapters/transformation.py @@ -1,12 +1,17 @@ import json from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence from types import MappingProxyType -from typing import Any, Final, TypeAlias, cast +from typing import Any, Final, TypeAlias, TypeVar, cast +from pydantic import JsonValue, TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict from litellm import verbose_logger -from litellm.litellm_core_utils.json_validation_rule import normalize_tool_schema +from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +from litellm.exceptions import BadRequestError +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.litellm_core_utils.get_supported_openai_params import get_supported_openai_params +from litellm.litellm_core_utils.json_validation_rule import normalize_json_schema_types, normalize_tool_schema from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionAssistantMessage, @@ -75,6 +80,7 @@ class _GenAIContentPart(TypedDict, total=False): class _GenAIFunctionDeclaration(TypedDict, total=False): name: ReadOnly[str] description: ReadOnly[str] + parameters: ReadOnly[object] parametersJsonSchema: ReadOnly[object] @@ -95,6 +101,62 @@ class _GenAISystemInstruction(TypedDict, total=False): _EMPTY_STR_MAPPING: Final[Mapping[str, str]] = MappingProxyType({}) +_RESPONSE_MIME_TYPE_KEYS: Final = ("responseMimeType", "response_mime_type") +_RESPONSE_SCHEMA_KEYS: Final = ("responseJsonSchema", "response_json_schema", "responseSchema", "response_schema") +_JSON_MIME_TYPE: Final = "application/json" +_GEMINI_ONLY_SCHEMA_KEYS: Final = frozenset({"propertyOrdering", "property_ordering"}) +_CONFIG_FIELDS: Final = TypeAdapter(Mapping[str, object]) +_JSON_OBJECT_SCHEMA: Final = TypeAdapter(dict[str, JsonValue]) +_Validated: Final = TypeVar("_Validated") + + +def _first_present(config: Mapping[str, object], keys: Sequence[str]) -> object | None: + return next((config[key] for key in keys if config.get(key) is not None), None) + + +def _validated(adapter: TypeAdapter[_Validated], value: object) -> _Validated | None: + try: + return adapter.validate_python(value) + except ValidationError: + return None + + +def _strip_gemini_only_schema_keys(schema: JsonValue, depth: int = 0) -> JsonValue: + if depth >= DEFAULT_MAX_RECURSE_DEPTH: + return schema + if isinstance(schema, list): + return [_strip_gemini_only_schema_keys(item, depth + 1) for item in schema] + if not isinstance(schema, dict): + return schema + return { + key: _strip_gemini_only_schema_keys(value, depth + 1) + for key, value in schema.items() + if key not in _GEMINI_ONLY_SCHEMA_KEYS + } + + +def _translate_response_format(config: object) -> Mapping[str, object] | None: + fields: Final = _validated(_CONFIG_FIELDS, config) + if fields is None or _first_present(fields, _RESPONSE_MIME_TYPE_KEYS) not in (None, _JSON_MIME_TYPE): + return None + schema: Final = _validated( + _JSON_OBJECT_SCHEMA, normalize_json_schema_types(_first_present(fields, _RESPONSE_SCHEMA_KEYS)) + ) + if schema is None or schema.get("type") != "object": + return None + return { + "type": "json_schema", + "json_schema": {"name": "response", "schema": _strip_gemini_only_schema_keys(schema)}, + } + + +def _deployment_supports_response_format(model: str, custom_llm_provider: str | None) -> bool: + try: + provider_model, provider, _, _ = get_llm_provider(model=model, custom_llm_provider=custom_llm_provider) + except BadRequestError: + return True + supported_params: Final = get_supported_openai_params(model=provider_model, custom_llm_provider=provider) + return supported_params is None or "response_format" in supported_params class GoogleGenAIStreamWrapper(AdapterCompletionStreamWrapper): @@ -314,6 +376,11 @@ class GoogleGenAIAdapter: pass if "stopSequences" in config: completion_request["stop"] = config["stopSequences"] + response_format: Final = _translate_response_format(config) + if response_format is not None and _deployment_supports_response_format( + model, litellm_params.custom_llm_provider if litellm_params else None + ): + completion_request["response_format"] = response_format # Handle tools transformation if tools: @@ -390,8 +457,9 @@ class GoogleGenAIAdapter: if "description" in func_decl: function_chunk["description"] = func_decl["description"] - if "parametersJsonSchema" in func_decl: - function_chunk["parameters"] = func_decl["parametersJsonSchema"] + parameters = _first_present(func_decl, ("parametersJsonSchema", "parameters")) + if parameters is not None: + function_chunk["parameters"] = parameters openai_tool: _JsonDict = {"type": "function", "function": function_chunk} openai_tools.append(openai_tool) @@ -582,14 +650,6 @@ class GoogleGenAIAdapter: ), } - # Add text field for convenience (common in Google GenAI responses) - text_content = "" - for part in parts: - if isinstance(part, dict) and "text" in part: - text_content += part["text"] - if text_content: - generate_content_response["text"] = text_content - return generate_content_response def translate_streaming_completion_to_generate_content( @@ -656,14 +716,6 @@ class GoogleGenAIAdapter: ) streaming_chunk["usageMetadata"] = usage_metadata - # Add text field for convenience (common in Google GenAI responses) - text_content = "" - for part in parts: - if isinstance(part, dict) and "text" in part: - text_content += part["text"] - if text_content: - streaming_chunk["text"] = text_content - return streaming_chunk def _transform_openai_message_to_google_genai_parts( diff --git a/tests/test_litellm/google_genai/test_google_genai_adapter.py b/tests/test_litellm/google_genai/test_google_genai_adapter.py index 81834451859..c5be1edf7a4 100644 --- a/tests/test_litellm/google_genai/test_google_genai_adapter.py +++ b/tests/test_litellm/google_genai/test_google_genai_adapter.py @@ -372,8 +372,7 @@ def test_completion_to_generate_content_with_tool_calls(): assert function_call["name"] == "get_weather" assert function_call["args"]["location"] == "San Francisco" - # Check text field - assert generate_content_response["text"] == "I'll check the weather for you." + assert "text" not in generate_content_response def test_streaming_tool_calls_transformation(): @@ -738,12 +737,7 @@ def test_completion_to_generate_content_transformation(): mock_response ) - # Verify the transformation - assert "text" in generate_content_response - assert ( - generate_content_response["text"] - == "Hello! I'm doing well, thank you for asking." - ) + assert "text" not in generate_content_response assert "candidates" in generate_content_response assert len(generate_content_response["candidates"]) == 1 @@ -1267,3 +1261,367 @@ def test_inline_data_backward_compatibility_text_only(): content, str ), "Content should be a string for text-only messages (backward compatibility)" assert content == "Hello, how are you?" + + +def test_tools_transformation_reads_parameters_declaration(): + """The google-genai SDK and REST callers send `parameters` (Gemini Schema types), not `parametersJsonSchema`""" + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + adapter = GoogleGenAIAdapter() + tools = [ + { + "functionDeclarations": [ + { + "name": "park_hours_lookup", + "description": "Look up park hours for a given date and park.", + "parameters": { + "type": "OBJECT", + "properties": { + "park_id": {"type": "STRING"}, + "date": {"type": "STRING"}, + }, + "required": ["park_id", "date"], + }, + } + ] + } + ] + + completion_request = adapter.translate_generate_content_to_completion( + model="gpt-4.1", + contents={"role": "user", "parts": [{"text": "When does EPCOT open?"}]}, + tools=tools, + ) + + assert completion_request["tools"][0]["function"]["parameters"] == { + "type": "object", + "properties": {"park_id": {"type": "string"}, "date": {"type": "string"}}, + "required": ["park_id", "date"], + } + + +def test_tools_transformation_prefers_parameters_json_schema_over_parameters(): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + adapter = GoogleGenAIAdapter() + tools = [ + { + "functionDeclarations": [ + { + "name": "lookup", + "parametersJsonSchema": {"type": "object", "properties": {"a": {"type": "string"}}}, + "parameters": {"type": "OBJECT", "properties": {"b": {"type": "STRING"}}}, + } + ] + } + ] + + completion_request = adapter.translate_generate_content_to_completion( + model="gpt-4.1", contents={"role": "user", "parts": [{"text": "hi"}]}, tools=tools + ) + + assert completion_request["tools"][0]["function"]["parameters"]["properties"] == {"a": {"type": "string"}} + + +PARK_TIP_GEMINI_SCHEMA = { + "type": "OBJECT", + "title": "ParkTipResponse", + "properties": { + "park_name": {"type": "STRING"}, + "highlights": {"type": "ARRAY", "items": {"type": "STRING"}}, + "confidence": {"type": "STRING", "enum": ["low", "medium", "high"]}, + }, + "required": ["park_name", "highlights", "confidence"], +} + +PARK_TIP_JSON_SCHEMA = { + "type": "object", + "title": "ParkTipResponse", + "properties": { + "park_name": {"type": "string"}, + "highlights": {"type": "array", "items": {"type": "string"}}, + "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, + }, + "required": ["park_name", "highlights", "confidence"], +} + + +@pytest.mark.parametrize("mime_type_key", ["response_mime_type", "responseMimeType"]) +@pytest.mark.parametrize( + "schema_key,schema", + [ + ("response_schema", PARK_TIP_GEMINI_SCHEMA), + ("responseSchema", PARK_TIP_GEMINI_SCHEMA), + ("response_json_schema", PARK_TIP_JSON_SCHEMA), + ("responseJsonSchema", PARK_TIP_JSON_SCHEMA), + ], +) +def test_response_schema_config_maps_to_json_schema_response_format(mime_type_key, schema_key, schema): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + adapter = GoogleGenAIAdapter() + config = {mime_type_key: "application/json", schema_key: schema} + + completion_request = adapter.translate_generate_content_to_completion( + model="gpt-4.1", contents={"role": "user", "parts": [{"text": "Summarize EPCOT"}]}, config=config + ) + + assert completion_request["response_format"] == { + "type": "json_schema", + "json_schema": {"name": "response", "schema": PARK_TIP_JSON_SCHEMA}, + } + + +def test_response_schema_drops_gemini_property_ordering_at_every_level(): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + sdk_pydantic_schema = { + "type": "OBJECT", + "title": "ParkTipResponse", + "propertyOrdering": ["park_name", "highlights"], + "properties": { + "park_name": {"type": "STRING", "title": "Park Name"}, + "highlights": { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "property_ordering": ["title", "detail"], + "properties": { + "title": {"type": "STRING"}, + "detail": {"type": "STRING", "nullable": True}, + }, + "required": ["title"], + }, + }, + }, + "required": ["park_name", "highlights"], + } + + completion_request = GoogleGenAIAdapter().translate_generate_content_to_completion( + model="claude-opus-5", + contents={"role": "user", "parts": [{"text": "Summarize EPCOT"}]}, + config={"responseMimeType": "application/json", "responseSchema": sdk_pydantic_schema}, + ) + + assert completion_request["response_format"]["json_schema"]["schema"] == { + "type": "object", + "title": "ParkTipResponse", + "properties": { + "park_name": {"type": "string", "title": "Park Name"}, + "highlights": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "detail": {"type": "string", "nullable": True}, + }, + "required": ["title"], + }, + }, + }, + "required": ["park_name", "highlights"], + } + + +def test_response_schema_without_mime_type_still_maps_to_json_schema(): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + adapter = GoogleGenAIAdapter() + + completion_request = adapter.translate_generate_content_to_completion( + model="gpt-4.1", + contents={"role": "user", "parts": [{"text": "Summarize EPCOT"}]}, + config={"responseSchema": PARK_TIP_GEMINI_SCHEMA}, + ) + + assert completion_request["response_format"]["type"] == "json_schema" + assert completion_request["response_format"]["json_schema"]["schema"] == PARK_TIP_JSON_SCHEMA + + +@pytest.mark.parametrize( + "config", + [ + {"temperature": 0.2}, + {"responseMimeType": "text/plain"}, + {"responseMimeType": "application/json"}, + {"response_mime_type": "application/json", "temperature": 0.2}, + {"responseMimeType": "text/x.enum", "responseSchema": {"type": "STRING", "enum": ["a", "b"]}}, + {"responseMimeType": "application/json", "responseSchema": {"type": "ARRAY", "items": {"type": "STRING"}}}, + {"responseMimeType": "application/json", "responseSchema": {"type": "STRING", "enum": ["a", "b"]}}, + {"responseMimeType": "application/json", "responseSchema": {"properties": {"a": {"type": "STRING"}}}}, + {"responseMimeType": "application/json", "responseSchema": None}, + ], +) +def test_output_config_outside_object_schema_leaves_response_format_unset(config): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + adapter = GoogleGenAIAdapter() + + completion_request = adapter.translate_generate_content_to_completion( + model="gpt-4.1", contents={"role": "user", "parts": [{"text": "Pick one"}]}, config=config + ) + + assert "response_format" not in completion_request + + +def test_response_schema_is_not_sent_to_deployment_without_response_format_support(): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + from litellm.types.router import GenericLiteLLMParams + + adapter = GoogleGenAIAdapter() + config = {"responseMimeType": "application/json", "responseSchema": PARK_TIP_GEMINI_SCHEMA, "temperature": 0.2} + + completion_request = adapter.translate_generate_content_to_completion( + model="openai/gpt-4", + contents={"role": "user", "parts": [{"text": "Summarize EPCOT"}]}, + config=config, + litellm_params=GenericLiteLLMParams(custom_llm_provider="openai"), + ) + + assert "response_format" not in completion_request + assert completion_request["temperature"] == 0.2 + + +def test_response_schema_is_sent_when_provider_cannot_be_resolved(): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + adapter = GoogleGenAIAdapter() + + completion_request = adapter.translate_generate_content_to_completion( + model="my-unmapped-deployment-alias", + contents={"role": "user", "parts": [{"text": "Summarize EPCOT"}]}, + config={"responseSchema": PARK_TIP_GEMINI_SCHEMA}, + ) + + assert completion_request["response_format"]["json_schema"]["schema"] == PARK_TIP_JSON_SCHEMA + + +def test_pydantic_generation_config_is_tolerated(): + from pydantic import BaseModel + + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + class SdkStyleConfig(BaseModel): + response_mime_type: str = "application/json" + response_schema: dict[str, object] = PARK_TIP_GEMINI_SCHEMA + + adapter = GoogleGenAIAdapter() + + completion_request = adapter.translate_generate_content_to_completion( + model="gpt-4.1", contents={"role": "user", "parts": [{"text": "hi"}]}, config=SdkStyleConfig() + ) + + assert completion_request["messages"] == [{"role": "user", "content": "hi"}] + assert "response_format" not in completion_request + + +def test_null_parameters_json_schema_falls_back_to_parameters(): + from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + adapter = GoogleGenAIAdapter() + tools = [ + { + "functionDeclarations": [ + { + "name": "lookup", + "parametersJsonSchema": None, + "parameters": {"type": "OBJECT", "properties": {"b": {"type": "STRING"}}}, + } + ] + } + ] + + completion_request = adapter.translate_generate_content_to_completion( + model="gpt-4.1", contents={"role": "user", "parts": [{"text": "hi"}]}, tools=tools + ) + + assert completion_request["tools"][0]["function"]["parameters"] == { + "type": "object", + "properties": {"b": {"type": "string"}}, + } + + +def test_streaming_chunk_has_no_top_level_text(): + from litellm.google_genai.adapters.transformation import ( + GoogleGenAIAdapter, + GoogleGenAIStreamWrapper, + ) + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + adapter = GoogleGenAIAdapter() + mock_response = ModelResponseStream( + id="test-streaming", + choices=[StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hello"))], + created=1234567890, + model="gpt-4.1", + object="chat.completion.chunk", + ) + + streaming_chunk = adapter.translate_streaming_completion_to_generate_content( + mock_response, GoogleGenAIStreamWrapper(completion_stream=None) + ) + + assert streaming_chunk["candidates"][0]["content"]["parts"] == [{"text": "Hello"}] + assert "text" not in streaming_chunk + + +@pytest.mark.asyncio +async def test_generate_content_sends_response_schema_and_tool_parameters_to_the_provider(respx_mock, monkeypatch): + import httpx + + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + route = respx_mock.post("https://api.openai.com/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + json={ + "id": "chatcmpl-park-tip", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4.1", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": '{"park_name": "EPCOT"}'}, + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + }, + ) + ) + + response = await agenerate_content( + model="openai/gpt-4.1", + contents=[{"role": "user", "parts": [{"text": "Summarize EPCOT. Do not call tools."}]}], + tools=[ + { + "functionDeclarations": [ + { + "name": "park_hours_lookup", + "parameters": { + "type": "OBJECT", + "properties": {"park_id": {"type": "STRING"}}, + "required": ["park_id"], + }, + } + ] + } + ], + generationConfig={"response_mime_type": "application/json", "response_schema": PARK_TIP_GEMINI_SCHEMA}, + api_key="sk-test", + ) + + provider_request = json.loads(route.calls.last.request.content) + + assert provider_request["response_format"] == { + "type": "json_schema", + "json_schema": {"name": "response", "schema": PARK_TIP_JSON_SCHEMA}, + } + assert provider_request["tools"][0]["function"]["parameters"] == { + "type": "object", + "properties": {"park_id": {"type": "string"}}, + "required": ["park_id"], + } + assert response["candidates"][0]["content"]["parts"] == [{"text": '{"park_name": "EPCOT"}'}] + assert "text" not in response