From c595f7a47f4da1dc0314a92e06e185eeb03fd86a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:30:45 -0800 Subject: [PATCH] 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. --- .../bedrock/vector_stores/transformation.py | 5 +++- litellm/types/vector_stores.py | 10 +++++-- ...est_bedrock_vector_store_transformation.py | 26 ++++++++++++++++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/litellm/llms/bedrock/vector_stores/transformation.py b/litellm/llms/bedrock/vector_stores/transformation.py index 59ce4572206..33578d95c24 100644 --- a/litellm/llms/bedrock/vector_stores/transformation.py +++ b/litellm/llms/bedrock/vector_stores/transformation.py @@ -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): diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index e5b0ed9eb9b..f5f425347eb 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, 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[ 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 7691a1848b8..9407533f72c 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,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