Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 72 additions & 20 deletions litellm/google_genai/adapters/transformation.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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]


Expand All @@ -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":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Response schemas silently dropped Array, string, and enum schemas return no response_format, leaving downstream generation unconstrained despite the caller's structured-output request

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenAI requires an object root, so wrapping needs an unwrap on both response paths; non-object roots keep merge-base behavior and are tracked separately

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. Since OpenAI requires an object-root response_format, forwarding array/string/enum roots would require wrapping them and then unwrapping the model output on both the non-streaming and streaming response paths, which is outside this focused fix. Keeping those roots on merge-base behavior and tracking them separately is reasonable; I’m resolving this comment.

return None
return {
"type": "json_schema",
"json_schema": {"name": "response", "schema": _strip_gemini_only_schema_keys(schema)},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Schema enforcement weakened Google response schemas reach the provider without strict: true, so downstream responses may violate the caller's requested shape

Suggested change
"json_schema": {"name": "response", "schema": _strip_gemini_only_schema_keys(schema)},
"json_schema": {
"name": "response",
"schema": _strip_gemini_only_schema_keys(schema),
"strict": True,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenAI strict mode rejects schemas lacking additionalProperties false or every property required, which Gemini dialect schemas never carry; strict would break working calls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. OpenAI strict structured outputs require additionalProperties: false and every object property to be listed in required (including nested objects). Gemini schemas do not necessarily include those constraints, so adding strict: true here could turn currently valid requests into 400s. Since this adapter does not rewrite schemas into OpenAI's strict subset, strict should remain unset. I'm withdrawing this comment; no change is needed.

}


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):
Expand Down Expand Up @@ -314,6 +376,11 @@ def translate_generate_content_to_completion(
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:
Expand Down Expand Up @@ -390,8 +457,9 @@ def _transform_google_genai_tools_to_openai(

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)
Expand Down Expand Up @@ -582,14 +650,6 @@ def translate_completion_to_generate_content(
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Response field removed Both response modes drop text, breaking consumers without the required compatibility flag; updated tests now mask the regression by requiring its absence

Rule Used: What: avoid backwards-incompatible changes without user-controlled flags Why: This breaks current behaviour for users using existing functionality Example of BAD: this PR (#22164) introduced run_post_custom... (source)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Gemini API defines no top-level text field; the SDK computes .text client-side from candidates parts, so nothing consumes it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The top-level text field is not part of the Gemini generateContent response contract, and the google-genai SDK derives .text from candidates[].content.parts[]. Removing this adapter-only convenience field therefore does not break the supported Gemini response shape or SDK behavior. I’m withdrawing this finding.

}

# 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(
Expand Down Expand Up @@ -656,14 +716,6 @@ def translate_streaming_completion_to_generate_content(
)
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(
Expand Down
Loading
Loading