Repository navigation
fix(google_genai): forward response schema and tool parameters through the generateContent adapter #42067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(google_genai): forward response schema and tool parameters through the generateContent adapter #42067
Changes from all commits
fba179f
47ebfa1
5eb967d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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 @@ 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: | ||
|
|
@@ -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) | ||
|
|
@@ -582,14 +636,6 @@ def translate_completion_to_generate_content( | |
| ), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. The top-level |
||
| } | ||
|
|
||
| # 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 @@ 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( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
response_format, leaving downstream generation unconstrained despite the caller's structured-output requestThere was a problem hiding this comment.
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
There was a problem hiding this comment.
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.