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.
This commit is contained in:
mateo-berri 2026-11-06 16:01:07 -08:00
parent 39f4360e12
commit 2b32463e76
3 changed files with 68 additions and 5 deletions

View file

@ -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:

View file

@ -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[

View file

@ -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)))