Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
78 changes: 58 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.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,
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,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":

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
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):
Expand Down Expand Up @@ -314,6 +362,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 +443,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 = _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)
Expand Down Expand Up @@ -582,14 +636,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 +702,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