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