mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge 40bd7a46ac into 0c98afa780
This commit is contained in:
commit
3898454f1c
5 changed files with 224 additions and 23 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue