fix(bedrock): report a bad vector store filter member without calling its group empty

An and/or group's non-empty check ran as min_length on the partly validated tuple after fail_fast stopped at a bad member, so every enclosing group also reported that it had no members. It now runs as an after-validator, which only sees a group whose members all validated, so one bad member gives one error at any depth.

A reason over 2000 characters now keeps its first and last 1000 characters, so a deeply nested path still ends on the bad field and its message.
This commit is contained in:
mateo-berri 2026-11-06 16:30:45 -08:00
parent 2b32463e76
commit c595f7a47f
3 changed files with 35 additions and 6 deletions

View file

@ -63,6 +63,7 @@ _AWS_FILTER_KEYS: Final = frozenset(
)
_OPENAI_FILTER: Final[TypeAdapter[OpenAIComparisonFilter | OpenAICompoundFilter]] = TypeAdapter(OpenAIVectorStoreFilter)
_MAX_REASON_CHARS: Final = 2000
_REASON_END_CHARS: Final = _MAX_REASON_CHARS // 2
def _aws_retrieval_filter(openai_filter: OpenAIComparisonFilter | OpenAICompoundFilter) -> Mapping[str, object]:
@ -81,7 +82,9 @@ def _validation_reason(error: ValidationError) -> str:
".".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]}..."
return (
reason if len(reason) <= _MAX_REASON_CHARS else f"{reason[:_REASON_END_CHARS]}...{reason[-_REASON_END_CHARS:]}"
)
class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):

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, FailFast, Field, StrictFloat, StrictInt, StrictStr
from pydantic import AfterValidator, ConfigDict, FailFast, Field, StrictFloat, StrictInt, StrictStr
from typing_extensions import ReadOnly, TypedDict
from litellm.types.llms.base import LiteLLMBaseModel
@ -332,11 +332,17 @@ class OpenAIComparisonFilter(LiteLLMBaseModel):
value: str | bool | int | float | Annotated[tuple[StrictStr | StrictInt | StrictFloat, ...], FailFast()]
def _require_members(members: tuple["OpenAIVectorStoreFilter", ...]) -> tuple["OpenAIVectorStoreFilter", ...]:
if not members:
raise ValueError("an and/or filter needs at least one member")
return members
class OpenAICompoundFilter(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
type: Literal["and", "or"]
filters: tuple["OpenAIVectorStoreFilter", ...] = Field(min_length=1, fail_fast=True)
filters: Annotated[tuple["OpenAIVectorStoreFilter", ...], FailFast(), AfterValidator(_require_members)]
OpenAIVectorStoreFilter: TypeAlias = Annotated[

View file

@ -1,5 +1,6 @@
import json
import traceback
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock
@ -649,7 +650,7 @@ async def _rejection(respx_mock: respx.MockRouter, filters: object) -> BaseExcep
return error.value
def _nested_compound(depth: int) -> dict[str, object]:
def _nested_compound(depth: int) -> Mapping[str, object]:
if depth == 0:
return {"type": "eq", "key": "year"}
return {"type": "and", "filters": [_nested_compound(depth - 1)]}
@ -694,8 +695,27 @@ async def test_search_rejection_and_its_traceback_do_not_grow_with_nesting_depth
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))
shallow: Final = await _rejection(respx_mock, _nested_compound(160))
deep: Final = await _rejection(respx_mock, _nested_compound(240))
assert len(str(deep)) == len(str(shallow))
assert "eq.value" in str(deep)
assert len("".join(traceback.format_exception(deep))) == len("".join(traceback.format_exception(shallow)))
@pytest.mark.asyncio
async def test_search_rejection_names_a_bad_member_the_same_wherever_it_sits(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
import litellm
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setattr(litellm, "drop_params", False)
good: Final = {"type": "eq", "key": "department", "value": "hr"}
bad: Final = {"key": "year"}
bad_first: Final = str(await _rejection(respx_mock, {"type": "and", "filters": [bad, good]}))
bad_last: Final = str(await _rejection(respx_mock, {"type": "and", "filters": [good, bad]}))
assert bad_first.replace("filters.and.filters.0", "filters.and.filters.1") == bad_last
assert "..." not in bad_last