From 5be6a018cefd39734c679bbfcc4dd5f92f3e1851 Mon Sep 17 00:00:00 2001 From: Ambuj Upadhyay Date: Sun, 6 Sep 2026 07:36:45 +0530 Subject: [PATCH 1/2] fix(vertex): sanitise empty-string enums under anyOf _fix_enum_empty_strings recursed into properties and items but not anyOf. convert_anyof_null_to_nullable runs first, so an Optional[Literal["", ...]] field is already an anyOf branch by the time the sanitiser sees it - the empty string survived on optional fields while the identical enum was fixed on required ones, and went out in the functionDeclarations payload. Adds the anyOf arm, mirroring _fix_enum_types, plus the same isinstance guard that function already has (without it a non-dict branch would raise). Co-Authored-By: Claude Opus 5 (1M context) --- litellm/llms/vertex_ai/common_utils.py | 11 ++++ .../vertex_ai/test_vertex_ai_common_utils.py | 64 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 6d050d5a856..63015de855d 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -620,6 +620,9 @@ def _fix_enum_empty_strings(schema, depth=0): if depth > DEFAULT_MAX_RECURSE_DEPTH: raise ValueError(f"Max depth of {DEFAULT_MAX_RECURSE_DEPTH} exceeded while processing schema.") + if not isinstance(schema, dict): + return + if "enum" in schema and isinstance(schema["enum"], list): schema["enum"] = [None if value == "" else value for value in schema["enum"]] @@ -633,6 +636,14 @@ def _fix_enum_empty_strings(schema, depth=0): if items is not None: _fix_enum_empty_strings(items, depth=depth + 1) + # An Optional[Literal[...]] arrives here as an `anyOf` branch, because + # convert_anyof_null_to_nullable runs first. Without this arm the empty string + # survives on optional fields while the same enum is fixed on required ones. + anyof: Final = schema.get("anyOf", None) + if anyof is not None and isinstance(anyof, list): + for item in anyof: + _fix_enum_empty_strings(item, depth=depth + 1) + def _fix_enum_types(schema, depth=0): """Remove `enum` fields when the schema type is not string. diff --git a/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py index 04a7ee451c4..ba5a5a23cc6 100644 --- a/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -1755,3 +1755,67 @@ def test_get_vertex_ai_lyria_model_info_is_none_for_non_lyria_speech_models(mode assert get_vertex_ai_lyria_model_info(model=model) is None +def test_fix_enum_empty_strings_nullable_enum(): + """An Optional[Literal["", ...]] field must be sanitised like a required one. + + convert_anyof_null_to_nullable runs before _fix_enum_empty_strings, so a nullable + enum has already become an `anyOf` branch by the time the sanitiser sees it. Without + recursion into `anyOf` the empty string survives on the optional field only. + """ + from litellm.llms.vertex_ai.common_utils import _fix_enum_empty_strings + + schema = { + "type": "object", + "properties": { + "mode": {"anyOf": [{"type": "string", "enum": ["", "fast", "slow"]}, {"type": "null"}]}, + "plain_mode": {"type": "string", "enum": ["", "fast", "slow"]}, + }, + } + + _fix_enum_empty_strings(schema) + + assert schema["properties"]["mode"]["anyOf"][0]["enum"] == [None, "fast", "slow"] + assert schema["properties"]["plain_mode"]["enum"] == [None, "fast", "slow"] + + +def test_fix_enum_empty_strings_anyof_without_a_null_branch(): + """The gap is not limited to nullable fields - any enum under `anyOf` was missed.""" + from litellm.llms.vertex_ai.common_utils import _fix_enum_empty_strings + + schema = { + "type": "object", + "properties": {"m": {"anyOf": [{"type": "string", "enum": ["", "a"]}, {"type": "integer"}]}}, + } + + _fix_enum_empty_strings(schema) + + assert schema["properties"]["m"]["anyOf"][0]["enum"] == [None, "a"] + + +def test_fix_enum_empty_strings_tolerates_a_non_dict_anyof_branch(): + """A malformed branch must not raise, matching _fix_enum_types' isinstance guard.""" + from litellm.llms.vertex_ai.common_utils import _fix_enum_empty_strings + + schema = {"anyOf": ["string", {"type": "string", "enum": ["", "a"]}]} + + _fix_enum_empty_strings(schema) + + assert schema["anyOf"][1]["enum"] == [None, "a"] + + +def test_build_vertex_schema_sanitises_a_nullable_enum_end_to_end(): + """The whole pipeline, as a tool schema reaches it from pydantic.""" + from litellm.llms.vertex_ai.common_utils import _build_vertex_schema + + parameters = { + "type": "object", + "properties": { + "mode": {"anyOf": [{"type": "string", "enum": ["", "fast", "slow"]}, {"type": "null"}]}, + "plain_mode": {"type": "string", "enum": ["", "fast", "slow"]}, + }, + } + + built = _build_vertex_schema(parameters) + + assert "" not in built["properties"]["mode"]["anyOf"][0]["enum"] + assert "" not in built["properties"]["plain_mode"]["enum"] From b6a4384e7bd6471e40a481720cb7a424696c4f01 Mon Sep 17 00:00:00 2001 From: Ambuj Upadhyay <34904987+lets-order-some-fries@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:53:54 +0530 Subject: [PATCH 2/2] fix(vertex): keep the basedpyright gate level for the anyOf enum arm The anyOf arm read its branches through schema.get on the unannotated schema dict, so after merging main it added one reportUnknownMemberType, one reportUnknownArgumentType and two reportUnknownVariableType errors, and scripts/type_check_gate.py holds each rule at its merge-base count Test the key and the list type the way the enum check above already does, and read the branches as list[object] with the cast-ok idiom used elsewhere in this file. No behaviour change, the four regression tests still pass --- litellm/llms/vertex_ai/common_utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 025d0b9c6e0..bc1fb36ad87 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -672,9 +672,8 @@ def _fix_enum_empty_strings(schema, depth=0): # An Optional[Literal[...]] arrives here as an `anyOf` branch, because # convert_anyof_null_to_nullable runs first. Without this arm the empty string # survives on optional fields while the same enum is fixed on required ones. - anyof: Final = schema.get("anyOf", None) - if anyof is not None and isinstance(anyof, list): - for item in anyof: + if "anyOf" in schema and isinstance(schema["anyOf"], list): + for item in cast(list[object], schema["anyOf"]): # cast-ok: an untyped JSON list _fix_enum_empty_strings(item, depth=depth + 1)