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"]