Merge pull request #42067 from BerriAI/litellm_genai_adapter_response_schema_tool_params

fix(google_genai): forward response schema and tool parameters through the generateContent adapter
This commit is contained in:
Mateo Wang 2026-09-19 19:43:32 -07:00 committed by GitHub
commit b4447096e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 440 additions and 28 deletions

View file

@ -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.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.litellm_core_utils.prompt_templates.common_utils import filter_value_from_dict
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,48 @@ 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")
_TOOL_PARAMETERS_KEYS: Final = ("parametersJsonSchema", "parameters")
_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 _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
for key in _GEMINI_ONLY_SCHEMA_KEYS:
filter_value_from_dict(schema, key)
return {"type": "json_schema", "json_schema": {"name": "response", "schema": 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 +362,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 +443,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 = _validated(_JSON_OBJECT_SCHEMA, _first_present(func_decl, _TOOL_PARAMETERS_KEYS))
if parameters is not None:
function_chunk["parameters"] = parameters
openai_tool: _JsonDict = {"type": "function", "function": function_chunk}
openai_tools.append(openai_tool)
@ -582,14 +636,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 +702,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(

View file

@ -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,383 @@ 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"],
}
assert sdk_pydantic_schema["propertyOrdering"] == ["park_name", "highlights"]
assert sdk_pydantic_schema["properties"]["highlights"]["items"]["property_ordering"] == ["title", "detail"]
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"}},
}
@pytest.mark.parametrize("parameters", [5, "", "x", [1], True])
def test_non_object_tool_parameters_are_dropped_instead_of_forwarded(parameters):
from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter
adapter = GoogleGenAIAdapter()
tools = [{"functionDeclarations": [{"name": "lookup", "description": "Look it up", "parameters": parameters}]}]
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"] == {"name": "lookup", "description": "Look it up"}
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