From bcd8e752f403a7e7b48069fcfd5b4e187f5e8c59 Mon Sep 17 00:00:00 2001 From: dmitriik Date: Wed, 9 Sep 2026 11:50:38 +0200 Subject: [PATCH 1/3] fix(vertex_ai): convert schema consts to enums --- litellm/llms/vertex_ai/common_utils.py | 47 +++++- .../code_coverage_tests/recursive_detector.py | 1 + .../vertex_ai/test_vertex_ai_common_utils.py | 145 ++++++++++++++++++ 3 files changed, 186 insertions(+), 7 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 14aebcaabaf..51e104d0a94 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -698,6 +698,44 @@ def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False): return parameters +def _convert_consts_to_enums( + schema: dict[str, object], # mutable-ok: response 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"), + ): + 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 +745,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 +753,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 diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index e9f87ba6cae..9998ec0c8e2 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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. diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index c206fcec420..a99ffa29a1f 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -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 From 0bb8fd1b583bed562e4833608205a2f3f339dae9 Mon Sep 17 00:00:00 2001 From: dmitriik Date: Wed, 9 Sep 2026 17:22:25 +0200 Subject: [PATCH 2/3] fix(vertex_ai): normalize Gemini tool schema consts --- litellm/llms/vertex_ai/common_utils.py | 2 +- .../vertex_and_google_ai_studio_gemini.py | 23 +++++--------- ...test_vertex_and_google_ai_studio_gemini.py | 30 ++++++++++++++++++- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 51e104d0a94..f298d6f4a12 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -699,7 +699,7 @@ def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False): def _convert_consts_to_enums( - schema: dict[str, object], # mutable-ok: response schema is normalized in place + schema: dict[str, object], # mutable-ok: Gemini schema is normalized in place depth: int = 0, ) -> None: """ diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 69fe5678de9..5d3db82f94b 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -86,6 +86,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 @@ -594,16 +595,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) @@ -663,13 +655,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: diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index d2788408e09..359e6de539a 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -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 From 40bd7a46ac7aef18d8e0ee8a3f4290e41b1b5224 Mon Sep 17 00:00:00 2001 From: dmitriik Date: Fri, 11 Sep 2026 17:36:03 +0200 Subject: [PATCH 3/3] fix(vertex_ai): add allOf to traverse in JSON schema --- litellm/llms/vertex_ai/common_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index f298d6f4a12..f8b59138d66 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -728,6 +728,7 @@ def _convert_consts_to_enums( schema.get("prefixItems"), schema.get("anyOf"), schema.get("oneOf"), + schema.get("allOf"), ): if not isinstance(schema_list, list): continue