From 452061ec8b5474ddc307ee0bc2c5a7562dddb257 Mon Sep 17 00:00:00 2001 From: espinetandreu <116800856+espinetandreu@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:07:16 +0200 Subject: [PATCH] fix(gemini): convert prefixItems to items and ensure arrays have items field Gemini does not support JSON Schema prefixItems (tuple validation, draft 2020-12). Pydantic generates prefixItems for Python tuple types like tuple[str, str, str], causing Gemini to reject the tool schema with "items.items: missing field". Changes: - Convert prefixItems to a single items schema using the common type across all prefix entries (falls back to string for mixed types) - Ensure type=array schemas always have an items field Reproducer: any tool with a list[tuple[str, ...]] parameter --- litellm/llms/vertex_ai/common_utils.py | 24 +++ .../test_gemini_prefixitems_schema.py | 157 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/test_litellm/llms/vertex_ai/test_gemini_prefixitems_schema.py diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 5895a91f3aa..bd7dae0f80c 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -583,8 +583,32 @@ def process_items(schema, depth=0): f"Max depth of {DEFAULT_MAX_RECURSE_DEPTH} exceeded while processing schema. Please check the schema for excessive nesting." ) if isinstance(schema, dict): + # Convert prefixItems (JSON Schema tuple validation) to items. + # Gemini does not support prefixItems; collapse to a single items + # schema using the common type if all prefix items share one, else string. + if "prefixItems" in schema and "items" not in schema: + prefix = schema.pop("prefixItems") + if isinstance(prefix, list) and prefix: + types = { + item.get("type") for item in prefix if isinstance(item, dict) and "type" in item + } + if len(types) == 1: + schema["items"] = {"type": types.pop()} + else: + schema["items"] = {"type": "string"} + else: + schema["items"] = {"type": "string"} + elif "prefixItems" in schema: + # items already exists; just drop prefixItems + schema.pop("prefixItems") + if "items" in schema and schema["items"] == {}: schema["items"] = {"type": "object"} + + # Ensure type=array always has an items field (Gemini requires it) + if schema.get("type") == "array" and "items" not in schema: + schema["items"] = {"type": "string"} + for key, value in schema.items(): if isinstance(value, dict): process_items(value, depth + 1) diff --git a/tests/test_litellm/llms/vertex_ai/test_gemini_prefixitems_schema.py b/tests/test_litellm/llms/vertex_ai/test_gemini_prefixitems_schema.py new file mode 100644 index 00000000000..6567805020c --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/test_gemini_prefixitems_schema.py @@ -0,0 +1,157 @@ +def test_process_items_converts_prefixitems_to_items(): + """ + Test that prefixItems (JSON Schema tuple validation) is converted to a + single items schema for Gemini compatibility. + + Pydantic generates prefixItems for tuple types (e.g. tuple[str, str, str]). + Gemini does not support prefixItems and requires items on array types. + """ + from litellm.llms.vertex_ai.common_utils import process_items + + # Tuple of 3 strings: tuple[str, str, str] + schema = { + "type": "array", + "prefixItems": [ + {"type": "string"}, + {"type": "string"}, + {"type": "string"}, + ], + "minItems": 3, + "maxItems": 3, + } + process_items(schema) + assert "prefixItems" not in schema + assert schema["items"] == {"type": "string"} + + +def test_process_items_prefixitems_mixed_types(): + """ + Test that prefixItems with mixed types falls back to type string. + """ + from litellm.llms.vertex_ai.common_utils import process_items + + schema = { + "type": "array", + "prefixItems": [ + {"type": "string"}, + {"type": "integer"}, + ], + } + process_items(schema) + assert "prefixItems" not in schema + assert schema["items"] == {"type": "string"} + + +def test_process_items_prefixitems_preserves_existing_items(): + """ + Test that prefixItems is dropped when items already exists. + """ + from litellm.llms.vertex_ai.common_utils import process_items + + schema = { + "type": "array", + "prefixItems": [{"type": "string"}], + "items": {"type": "integer"}, + } + process_items(schema) + assert "prefixItems" not in schema + assert schema["items"] == {"type": "integer"} + + +def test_process_items_array_without_items_gets_default(): + """ + Test that type=array schemas without items get a default items field. + Gemini requires items on all array schemas. + """ + from litellm.llms.vertex_ai.common_utils import process_items + + schema = {"type": "array"} + process_items(schema) + assert schema["items"] == {"type": "string"} + + +def test_process_items_nested_prefixitems_in_anyof(): + """ + Test that prefixItems conversion works inside anyOf entries, matching + the real-world pattern from Pydantic's schema for list[tuple[str, str, str]] | None. + """ + from litellm.llms.vertex_ai.common_utils import process_items + + schema = { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + {"type": "string"}, + {"type": "string"}, + {"type": "string"}, + ], + "minItems": 3, + "maxItems": 3, + }, + }, + {"type": "null"}, + ], + } + process_items(schema) + inner = schema["anyOf"][0]["items"] + assert "prefixItems" not in inner + assert inner["items"] == {"type": "string"} + + +def test_build_vertex_schema_with_tuple_filters(): + """ + End-to-end test: a tool schema with list[tuple[str, str, str]] | None + parameter should produce a valid Gemini schema with items on all arrays. + + Reproduces: GenerateContentRequest.tools[0].function_declarations[N] + .parameters.properties[filters].any_of[0].items.items: missing field + """ + from copy import deepcopy + + from litellm.llms.vertex_ai.common_utils import _build_vertex_schema + + # Schema generated by Pydantic for: filters: list[tuple[str, str, str]] | None + schema = { + "type": "object", + "properties": { + "filters": { + "anyOf": [ + { + "items": { + "maxItems": 3, + "minItems": 3, + "prefixItems": [ + {"type": "string"}, + {"type": "string"}, + {"type": "string"}, + ], + "type": "array", + }, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Filters", + } + }, + } + + result = _build_vertex_schema(deepcopy(schema)) + filters = result["properties"]["filters"] + + # Should have anyOf with the array variant + assert "anyOf" in filters + array_variant = filters["anyOf"][0] + assert array_variant["type"] == "array" + assert "items" in array_variant + + # Inner array (the tuple) must also have items + inner = array_variant["items"] + assert inner["type"] == "array" + assert "items" in inner + assert inner["items"]["type"] == "string" + assert "prefixItems" not in inner