diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 52345e1ff40..7ff61254298 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -684,6 +684,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"]] @@ -697,6 +700,13 @@ 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. + 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) + GEMINI_MAX_ENUM_CHARACTERS: Final = 2000 _GEMINI_EXACT_INT_MAX: Final = 2**53 - 1 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 32d68249858..a7ac0cd76a1 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 @@ -2728,3 +2728,69 @@ async def test_vertex_ai_token_counter_routes_like_generate_content( ) assert (None if result is None else result.total_tokens) == vertex_counted_tokens + + +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"]