From 2b32463e761fea165a80ecff4b9f7acb549716f2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:01:07 -0800 Subject: [PATCH] fix(bedrock): stop validating a vector store filter at its first bad item A malformed OpenAI filter listed every pydantic error in its 400, so one 30 KB filter built a 1.4 MB message that stalled the worker while it was redacted. Validation now stops at the first bad list item or group member, the reason is cut at 2000 characters, and the 400 no longer chains the pydantic error into the logged traceback. --- .../bedrock/vector_stores/transformation.py | 6 +- litellm/types/vector_stores.py | 6 +- ...est_bedrock_vector_store_transformation.py | 61 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/litellm/llms/bedrock/vector_stores/transformation.py b/litellm/llms/bedrock/vector_stores/transformation.py index 940b69d003f..59ce4572206 100644 --- a/litellm/llms/bedrock/vector_stores/transformation.py +++ b/litellm/llms/bedrock/vector_stores/transformation.py @@ -62,6 +62,7 @@ _AWS_FILTER_KEYS: Final = frozenset( ) ) _OPENAI_FILTER: Final[TypeAdapter[OpenAIComparisonFilter | OpenAICompoundFilter]] = TypeAdapter(OpenAIVectorStoreFilter) +_MAX_REASON_CHARS: Final = 2000 def _aws_retrieval_filter(openai_filter: OpenAIComparisonFilter | OpenAICompoundFilter) -> Mapping[str, object]: @@ -76,10 +77,11 @@ def _aws_retrieval_filter(openai_filter: OpenAIComparisonFilter | OpenAICompound def _validation_reason(error: ValidationError) -> str: - return "; ".join( + reason: Final = "; ".join( ".".join(("filters", *(str(part) for part in detail["loc"]))) + f": {detail['msg']}" for detail in error.errors() ) + return reason if len(reason) <= _MAX_REASON_CHARS else f"{reason[:_MAX_REASON_CHARS]}..." class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): @@ -209,7 +211,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): "Fix the filter, or set drop_params to drop it.", model=None, llm_provider="bedrock", - ) + ) from None verbose_logger.debug("Dropping invalid Bedrock vector store filters under drop_params: %s", reason) def validate_environment(self, headers: dict, litellm_params: GenericLiteLLMParams | None) -> dict: diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index 50ffcfd8b3d..e5b0ed9eb9b 100644 --- a/litellm/types/vector_stores.py +++ b/litellm/types/vector_stores.py @@ -4,7 +4,7 @@ from datetime import datetime from enum import Enum from typing import Annotated, Any, Literal, TypeAlias -from pydantic import ConfigDict, Field, StrictFloat, StrictInt, StrictStr +from pydantic import ConfigDict, FailFast, Field, StrictFloat, StrictInt, StrictStr from typing_extensions import ReadOnly, TypedDict from litellm.types.llms.base import LiteLLMBaseModel @@ -329,14 +329,14 @@ class OpenAIComparisonFilter(LiteLLMBaseModel): type: Literal["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin"] key: str - value: str | bool | int | float | tuple[StrictStr | StrictInt | StrictFloat, ...] + value: str | bool | int | float | Annotated[tuple[StrictStr | StrictInt | StrictFloat, ...], FailFast()] class OpenAICompoundFilter(LiteLLMBaseModel): model_config = ConfigDict(frozen=True) type: Literal["and", "or"] - filters: tuple["OpenAIVectorStoreFilter", ...] = Field(min_length=1) + filters: tuple["OpenAIVectorStoreFilter", ...] = Field(min_length=1, fail_fast=True) OpenAIVectorStoreFilter: TypeAlias = Annotated[ diff --git a/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py b/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py index 68ac1d8b846..7691a1848b8 100644 --- a/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py +++ b/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py @@ -1,4 +1,5 @@ import json +import traceback from typing import Final from unittest.mock import MagicMock @@ -638,3 +639,63 @@ async def test_search_drops_invalid_filters_under_drop_params( assert retrieve_route.call_count == 1 assert _sent_filter(retrieve_route) is None + + +async def _rejection(respx_mock: respx.MockRouter, filters: object) -> BaseException: + import litellm + + with pytest.raises(litellm.BadRequestError) as error: + await _search_with_filters(respx_mock, filters) + return error.value + + +def _nested_compound(depth: int) -> dict[str, object]: + if depth == 0: + return {"type": "eq", "key": "year"} + return {"type": "and", "filters": [_nested_compound(depth - 1)]} + + +@pytest.mark.parametrize( + ("one_bad_item", "many_bad_items"), + [ + pytest.param( + {"type": "in", "key": "remote", "value": [True]}, + {"type": "in", "key": "remote", "value": [True] * 1000}, + id="bools-in-list", + ), + pytest.param( + {"type": "and", "filters": [{"key": "year"}]}, + {"type": "and", "filters": [{"key": "year"}] * 1000}, + id="untyped-members", + ), + ], +) +@pytest.mark.asyncio +async def test_search_rejection_names_the_first_bad_item_however_many_follow( + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + one_bad_item: dict[str, object], + many_bad_items: dict[str, object], +): + import litellm + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "drop_params", False) + + assert str(await _rejection(respx_mock, many_bad_items)) == str(await _rejection(respx_mock, one_bad_item)) + + +@pytest.mark.asyncio +async def test_search_rejection_and_its_traceback_do_not_grow_with_nesting_depth( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + import litellm + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "drop_params", False) + + shallow: Final = await _rejection(respx_mock, _nested_compound(100)) + deep: Final = await _rejection(respx_mock, _nested_compound(200)) + + assert len(str(deep)) == len(str(shallow)) + assert len("".join(traceback.format_exception(deep))) == len("".join(traceback.format_exception(shallow)))