From f99823bf26c74f5cdf58ad2c7de7d444e031acf6 Mon Sep 17 00:00:00 2001 From: Bharadwaj Pendyala Date: Fri, 14 Aug 2026 09:41:53 -0500 Subject: [PATCH 1/4] fix(vertex_ai): normalize tuple-form JSON Schema items before the Vertex transform --- litellm/llms/vertex_ai/common_utils.py | 35 ++++++++++ .../vertex_ai/test_vertex_ai_common_utils.py | 64 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 26f797cf5b2..137af3c4743 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -597,6 +597,8 @@ def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False): # refs recursively and correctly detects/skips circular references. unpack_defs(parameters, defs) + _normalize_tuple_items(parameters) + # 5. Nullable fields: # * https://github.com/pydantic/pydantic/issues/1270 # * https://stackoverflow.com/a/58841311 @@ -770,6 +772,39 @@ def filter_schema_fields(schema_dict: dict[str, Any], valid_fields: set[str], pr return result +def _normalize_tuple_items(schema: dict[str, Any], depth: int = 0) -> None: + """Rewrite tuple-form `items` (a list of sub-schemas) as `anyOf`. + + JSON Schema allows it, Vertex's Schema type does not, and every walker below + reads `items` as a single sub-schema, so a list there crashes them. + """ + if depth > DEFAULT_MAX_RECURSE_DEPTH: + raise ValueError(f"Max depth of {DEFAULT_MAX_RECURSE_DEPTH} exceeded while processing schema.") + + tuple_items: Final = schema.get("items", None) + if isinstance(tuple_items, list): + if tuple_items: + schema["items"] = {"anyOf": tuple_items} # mutable-ok: every walker here rewrites the schema tree in place + else: + schema.pop("items") + + properties: Final = schema.get("properties", None) + if properties is not None: + for value in properties.values(): + _normalize_tuple_items(value, depth=depth + 1) + + items: Final = schema.get("items", None) + if items is not None: + _normalize_tuple_items(items, depth=depth + 1) + + for key in ("anyOf", "oneOf", "allOf"): + values = schema.get(key, None) + if values is not None and isinstance(values, list): + for value in values: + if isinstance(value, dict): + _normalize_tuple_items(value, depth=depth + 1) + + def convert_anyof_null_to_nullable(schema, depth=0): if depth > DEFAULT_MAX_RECURSE_DEPTH: raise ValueError( 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 b83d4742b64..bb201ba1891 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 @@ -377,6 +377,70 @@ def test_build_vertex_schema_array_branch_missing_items_in_anyof(): }, f"array branch must have items synthesized; got {branch}" +def test_build_vertex_schema_tuple_form_items_become_anyof(): + """ + Regression: tuple-form `items` (`"items": [{...}, {...}]`, valid JSON Schema) + used to crash the transform with `'list' object has no attribute 'get'`, + surfacing as litellm.APIConnectionError on every vertex_ai request carrying + such a tool schema. + """ + from litellm.llms.vertex_ai.common_utils import _build_vertex_schema + + parameters = { + "type": "object", + "properties": { + "pair": { + "type": "array", + "items": [{"type": "integer"}, {"type": "string"}], + } + }, + } + + result = _build_vertex_schema(parameters) + + assert result["properties"]["pair"]["items"] == { + "anyOf": [{"type": "integer"}, {"type": "string"}] + } + + +def test_build_vertex_schema_tuple_form_items_nested_in_anyof_branch(): + """Tuple-form `items` on an anyOf branch is reachable only if the walk + descends into anyOf, which is where the original crash was raised.""" + from litellm.llms.vertex_ai.common_utils import _build_vertex_schema + + parameters = { + "type": "object", + "properties": { + "pair": { + "anyOf": [ + {"type": "array", "items": [{"type": "integer"}]}, + {"type": "null"}, + ] + } + }, + } + + result = _build_vertex_schema(parameters) + + array_branch = result["properties"]["pair"]["anyOf"][0] + assert array_branch["items"] == {"anyOf": [{"type": "integer"}]} + + +def test_build_vertex_schema_empty_tuple_form_items(): + """An empty `items: []` has no branches to fold into anyOf, so it takes the + same path as `items: {}` rather than raising on a zero-length anyOf.""" + from litellm.llms.vertex_ai.common_utils import _build_vertex_schema + + parameters = { + "type": "object", + "properties": {"pair": {"type": "array", "items": []}}, + } + + result = _build_vertex_schema(parameters) + + assert result["properties"]["pair"]["items"] == {"type": "object"} + + def test_vertex_ai_complex_response_schema(): import json from copy import deepcopy From c386994db10d5f526c58915fe9fea7e543e1ee0c Mon Sep 17 00:00:00 2001 From: Bharadwaj Pendyala Date: Fri, 14 Aug 2026 09:52:25 -0500 Subject: [PATCH 2/4] chore(tests): ignore _normalize_tuple_items in the recursion detector --- tests/code_coverage_tests/recursive_detector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 5bd6326d8f2..9133bd36996 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -11,6 +11,7 @@ IGNORE_FUNCTIONS = [ "clean_message", "unpack_defs", "convert_anyof_null_to_nullable", # has a set max depth + "_normalize_tuple_items", # has a set max depth "add_object_type", "strip_field", "_transform_prompt", From ba8226a79a442c1357b8318cccf2e985a065e39c Mon Sep 17 00:00:00 2001 From: Bharadwaj Pendyala Date: Fri, 14 Aug 2026 21:12:58 -0500 Subject: [PATCH 3/4] chore(vertex_ai): drop the suppression comment on the tuple-items rewrite mutable-ok does not suppress LIT011, and the sibling walker process_items carries the same LIT002 and LIT011 pair on one line with no pragma --- litellm/llms/vertex_ai/common_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 137af3c4743..1579ae0df21 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -784,7 +784,7 @@ def _normalize_tuple_items(schema: dict[str, Any], depth: int = 0) -> None: tuple_items: Final = schema.get("items", None) if isinstance(tuple_items, list): if tuple_items: - schema["items"] = {"anyOf": tuple_items} # mutable-ok: every walker here rewrites the schema tree in place + schema["items"] = {"anyOf": tuple_items} else: schema.pop("items") From 89d0301db2add5ef4895956aac1585990395c135 Mon Sep 17 00:00:00 2001 From: Bharadwaj Pendyala Date: Sat, 15 Aug 2026 08:15:42 -0500 Subject: [PATCH 4/4] chore(vertex_ai): restore the mutable-ok reason on the tuple-items rewrite --- litellm/llms/vertex_ai/common_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 1579ae0df21..096962f98e6 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -784,7 +784,7 @@ def _normalize_tuple_items(schema: dict[str, Any], depth: int = 0) -> None: tuple_items: Final = schema.get("items", None) if isinstance(tuple_items, list): if tuple_items: - schema["items"] = {"anyOf": tuple_items} + schema["items"] = {"anyOf": tuple_items} # mutable-ok: the walkers below rewrite this subtree in place else: schema.pop("items")