This commit is contained in:
Dmitrii Komarov 2026-09-12 14:53:34 -04:00 committed by GitHub
commit 3898454f1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 224 additions and 23 deletions

View file

@ -698,6 +698,45 @@ def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False):
return parameters
def _convert_consts_to_enums(
schema: dict[str, object], # mutable-ok: Gemini schema is normalized in place
depth: int = 0,
) -> None:
"""
Converts 'const' to 'enum' only in schema fields (intentionally skips examples, etc)
"""
if depth > DEFAULT_MAX_RECURSE_DEPTH:
return
if "const" in schema:
const_value: Final = schema.pop("const") # rebind-ok: removes the unsupported keyword
enum_values: Final = [const_value] # mutable-ok: JSON Schema enum requires an array
schema["enum"] = enum_values # rebind-ok: replaces const with its singleton enum
for schema_map in (schema.get("$defs"), schema.get("properties")):
if not isinstance(schema_map, dict):
continue
for mapped_schema in schema_map.values():
if isinstance(mapped_schema, dict):
_convert_consts_to_enums(mapped_schema, depth + 1)
for direct_schema in (schema.get("additionalProperties"), schema.get("items")):
if isinstance(direct_schema, dict):
_convert_consts_to_enums(direct_schema, depth + 1)
for schema_list in (
schema.get("prefixItems"),
schema.get("anyOf"),
schema.get("oneOf"),
schema.get("allOf"),
):
if not isinstance(schema_list, list):
continue
for listed_schema in schema_list:
if isinstance(listed_schema, dict):
_convert_consts_to_enums(listed_schema, depth + 1)
def _build_json_schema(parameters: dict) -> dict:
"""
Build a JSON Schema for use with Gemini's responseJsonSchema parameter.
@ -707,6 +746,7 @@ def _build_json_schema(parameters: dict) -> dict:
- Does NOT add propertyOrdering
- Does NOT filter fields (allows additionalProperties)
- Preserves $defs/$ref (Gemini 2.0+ supports JSON Schema references natively)
- Converts const values to equivalent single-value enums
Parameters:
parameters: dict - the JSON schema to process
@ -714,13 +754,7 @@ def _build_json_schema(parameters: dict) -> dict:
Returns:
dict - the processed schema in standard JSON Schema format
"""
# Gemini 2.0+ with responseJsonSchema accepts standard JSON Schema as-is,
# including $ref, $defs, anyOf, etc. No transformations needed — the
# OpenAPI-specific fixes (unpack_defs, add_object_type, convert_anyof, etc.)
# are only required for responseSchema (Gemini 1.5) and can break valid
# JSON Schema by adding conflicting fields to $ref nodes.
# See: https://blog.google/technology/developers/gemini-api-structured-outputs/
_convert_consts_to_enums(parameters)
return parameters

View file

@ -87,6 +87,7 @@ from ..common_utils import (
VertexAIError,
_build_json_schema,
_build_vertex_schema,
_convert_consts_to_enums,
supports_response_json_schema,
)
from ..vertex_llm_base import VertexBase
@ -610,16 +611,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
for tool in value:
openai_function_object: ChatCompletionToolParamFunctionChunk | None = None
if "function" in tool: # tools list
_openai_function_object = ChatCompletionToolParamFunctionChunk(**tool["function"])
if (
"parameters" in _openai_function_object
and _openai_function_object["parameters"] is not None
and isinstance(_openai_function_object["parameters"], dict)
): # OPENAI accepts JSON Schema, Google accepts OpenAPI schema.
_openai_function_object["parameters"] = _build_vertex_schema(_openai_function_object["parameters"])
openai_function_object = _openai_function_object
openai_function_object = ChatCompletionToolParamFunctionChunk(**tool["function"])
elif "name" in tool: # functions list
openai_function_object = ChatCompletionToolParamFunctionChunk(**tool)
@ -679,13 +671,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
)
_description = openai_function_object.get("description", None)
_parameters = openai_function_object.get("parameters", None)
if isinstance(_parameters, str) and len(_parameters) == 0:
_parameters = {
"type": "object",
}
if _description is not None:
gtool_func_declaration["description"] = _description
if _parameters is not None:
if isinstance(_parameters, dict):
_convert_consts_to_enums(_parameters)
gtool_func_declaration["parameters"] = _build_vertex_schema(_parameters)
elif isinstance(_parameters, str) and len(_parameters) == 0:
gtool_func_declaration["parameters"] = {"type": "object"}
elif _parameters is not None:
gtool_func_declaration["parameters"] = _parameters
gtool_func_declarations.append(gtool_func_declaration)
else:

View file

@ -27,6 +27,7 @@ IGNORE_FUNCTIONS = [
"_extract_fields_recursive", # max depth set.
"_remove_json_schema_refs", # max depth set.,
"_convert_schema_types", # max depth set.,
"_convert_consts_to_enums", # max depth set.
"_fix_enum_empty_strings", # max depth set.,
"get_access_token", # max depth set.,
"_redact_base64", # max depth set.

View file

@ -2,7 +2,7 @@ import asyncio
import json
import re
from copy import deepcopy
from typing import Final, List, cast
from typing import Final, List, Literal, cast
from unittest.mock import MagicMock, patch
import pytest
@ -1219,6 +1219,34 @@ def test_vertex_ai_map_tools():
assert tools == new_tools
@pytest.mark.parametrize("legacy_functions", [False, True])
def test_gemini_map_tool_converts_pydantic_consts_to_enums(legacy_functions: bool):
class Operation(BaseModel):
kind: Literal["create"]
resource_name: str
class ToolInput(BaseModel):
status: Literal["pending"]
operation: Operation
function = {
"name": "perform_action",
"description": "Perform an action",
"parameters": ToolInput.model_json_schema(),
}
tools_input = [function] if legacy_functions else [{"type": "function", "function": function}]
tools = VertexGeminiConfig()._map_function(value=tools_input, optional_params={})
parameters = tools[0]["function_declarations"][0]["parameters"]
assert parameters["properties"]["status"]["enum"] == ["pending"]
assert parameters["properties"]["operation"]["properties"]["kind"]["enum"] == ["create"]
assert parameters["properties"]["operation"]["properties"]["resource_name"]["type"] == "string"
assert parameters["properties"]["operation"]["required"] == ["kind", "resource_name"]
assert "const" not in json.dumps(parameters)
assert "$ref" not in json.dumps(parameters)
def test_vertex_ai_map_tool_with_anyof():
"""
Related issue: https://github.com/BerriAI/litellm/issues/11164

View file

@ -1,6 +1,8 @@
from typing import Literal, Annotated, Union
from unittest.mock import patch
import pytest
from pydantic import BaseModel, Field
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
@ -298,6 +300,149 @@ def test_build_vertex_schema():
assert _build_vertex_schema(parameters) == expected_output
def test_build_json_schema_converts_nested_consts_to_enums_and_preserves_refs():
from litellm.llms.vertex_ai.common_utils import _build_json_schema
parameters = {
"$defs": {
"CreateAction": {
"properties": {
"kind": {"const": "create", "type": "string"},
"mode": {"enum": ["fast", "safe"], "type": "string"},
},
"required": ["kind"],
"type": "object",
},
"DeleteAction": {
"properties": {
"kind": {"const": "delete", "type": "string"},
},
"required": ["kind"],
"type": "object",
},
},
"properties": {
"actions": {
"items": {
"anyOf": [
{"$ref": "#/$defs/CreateAction"},
{"$ref": "#/$defs/DeleteAction"},
]
},
"type": "array",
},
"status": {"const": "pending", "type": "string"},
},
"type": "object",
}
result = _build_json_schema(parameters)
assert result == {
"$defs": {
"CreateAction": {
"properties": {
"kind": {"enum": ["create"], "type": "string"},
"mode": {"enum": ["fast", "safe"], "type": "string"},
},
"required": ["kind"],
"type": "object",
},
"DeleteAction": {
"properties": {
"kind": {"enum": ["delete"], "type": "string"},
},
"required": ["kind"],
"type": "object",
},
},
"properties": {
"actions": {
"items": {
"anyOf": [
{"$ref": "#/$defs/CreateAction"},
{"$ref": "#/$defs/DeleteAction"},
]
},
"type": "array",
},
"status": {"enum": ["pending"], "type": "string"},
},
"type": "object",
}
def test_build_json_schema_converts_nested_consts_to_enums_and_preserves_refs_with_pydantic():
from litellm.llms.vertex_ai.common_utils import _build_json_schema
class CreateAction(BaseModel):
kind: Literal["create"]
resource_name: str
class DeleteAction(BaseModel):
kind: Literal["delete"]
resource_id: str
Action = Annotated[
Union[CreateAction, DeleteAction],
Field(discriminator="kind"),
]
class ActionResponse(BaseModel):
status: Literal["pending"]
actions: list[Action]
pydantic_schema = ActionResponse.model_json_schema()
result = _build_json_schema(pydantic_schema)
assert result == {
"$defs": {
"CreateAction": {
"properties": {
"kind": {"enum": ["create"], "title": "Kind", "type": "string"},
"resource_name": {"title": "Resource Name", "type": "string"},
},
"required": ["kind", "resource_name"],
"title": "CreateAction",
"type": "object",
},
"DeleteAction": {
"properties": {
"kind": {"enum": ["delete"], "title": "Kind", "type": "string"},
"resource_id": {"title": "Resource Id", "type": "string"},
},
"required": ["kind", "resource_id"],
"title": "DeleteAction",
"type": "object",
}
},
"properties": {
"status": {"enum": ["pending"], "title": "Status", "type": "string"},
"actions": {
"items": {
"discriminator": {
"mapping": {
"create": "#/$defs/CreateAction",
"delete": "#/$defs/DeleteAction",
},
"propertyName": "kind",
},
"oneOf": [
{"$ref": "#/$defs/CreateAction"},
{"$ref": "#/$defs/DeleteAction"},
],
},
"title": "Actions",
"type": "array",
},
},
"required": ["status", "actions"],
"title": "ActionResponse",
"type": "object",
}
def test_process_items_with_excessive_nesting():
"""Test process_items with excessive nesting > max levels +1 deep."""
# generate a schema with excessive nesting