From 0bc2f59d6204c20bfdbd2851bd7d7bd3b4f1eeb4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:08:34 -0800 Subject: [PATCH] fix(bedrock): reject booleans in OpenAI in/nin filter lists and map older filter shapes first A list value now holds only strings and numbers, as in OpenAI's schema, so pydantic no longer turns true/false into 1/0 before the filter reaches AWS. The older operator and and/or shapes are checked before the type key, so an older filter that also carries a type key maps as it did before. --- .../bedrock/vector_stores/transformation.py | 8 ++--- litellm/types/vector_stores.py | 4 +-- ...est_bedrock_vector_store_transformation.py | 33 ++++++++++++------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/litellm/llms/bedrock/vector_stores/transformation.py b/litellm/llms/bedrock/vector_stores/transformation.py index 116d8ea6997..a828311c1a7 100644 --- a/litellm/llms/bedrock/vector_stores/transformation.py +++ b/litellm/llms/bedrock/vector_stores/transformation.py @@ -181,15 +181,15 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): def _aws_filters(self, filters: object, drop_params: bool) -> Mapping[str, object] | None: if not isinstance(filters, dict): return self._invalid_filters(f"filters must be an object, got {type(filters).__name__}", drop_params) + if "operator" in filters: + return self._map_operator_filter(filters) + if "and" in filters or "or" in filters: + return self._map_and_or_filters(filters) if "type" in filters: try: return _aws_retrieval_filter(_OPENAI_FILTER.validate_python(filters)) except ValidationError as error: return self._invalid_filters(_validation_reason(error), drop_params) - if "operator" in filters: - return self._map_operator_filter(filters) - if "and" in filters or "or" in filters: - return self._map_and_or_filters(filters) return filters @staticmethod diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index 7298611d0ef..50ffcfd8b3d 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 +from pydantic import ConfigDict, Field, StrictFloat, StrictInt, StrictStr from typing_extensions import ReadOnly, TypedDict from litellm.types.llms.base import LiteLLMBaseModel @@ -329,7 +329,7 @@ class OpenAIComparisonFilter(LiteLLMBaseModel): type: Literal["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin"] key: str - value: str | bool | int | float | tuple[str | int | float, ...] + value: str | bool | int | float | tuple[StrictStr | StrictInt | StrictFloat, ...] class OpenAICompoundFilter(LiteLLMBaseModel): 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 3ce910cdcc6..8e1e893e843 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 @@ -502,6 +502,11 @@ def _sent_filter(retrieve_route: respx.Route) -> object: {"notEquals": {"key": "department", "value": "hr"}}, id="operator-shape-still-mapped", ), + pytest.param( + {"key": "department", "value": "hr", "operator": "eq", "type": "string"}, + {"equals": {"key": "department", "value": "hr"}}, + id="operator-shape-with-type-key-still-mapped", + ), pytest.param( { "and": [ @@ -535,22 +540,26 @@ async def test_search_sends_openai_filters_as_aws_retrieval_filters( assert _sent_filter(retrieve_route) == aws_filter -INVALID_FILTERS: Final = [ - pytest.param({"type": "regex", "key": "department", "value": "h.*"}, id="unknown-type"), - pytest.param({"type": "eq", "key": "department"}, id="missing-value"), - pytest.param({"type": "and", "filters": []}, id="empty-compound"), - pytest.param( +INVALID_FILTERS: Final = ( + ("unknown-type", {"type": "regex", "key": "department", "value": "h.*"}, "Input tag 'regex'"), + ("missing-value", {"type": "eq", "key": "department"}, "filters.eq.value: Field required"), + ("empty-compound", {"type": "and", "filters": []}, "filters.and.filters: Tuple should have at least 1 item"), + ( + "untyped-member", {"type": "and", "filters": [{"type": "eq", "key": "department", "value": "hr"}, {"key": "year"}]}, - id="untyped-member", + "filters.and.filters.1: Unable to extract tag", ), - pytest.param([{"type": "eq", "key": "department", "value": "hr"}], id="not-an-object"), -] + ("bools-in-list", {"type": "in", "key": "remote", "value": [True, False]}, "filters.in.value"), + ("not-an-object", [{"type": "eq", "key": "department", "value": "hr"}], "filters must be an object, got list"), +) -@pytest.mark.parametrize("filters", INVALID_FILTERS) +@pytest.mark.parametrize( + ("filters", "reason"), [pytest.param(filters, reason, id=case) for case, filters, reason in INVALID_FILTERS] +) @pytest.mark.asyncio async def test_search_rejects_invalid_filters_with_400_without_calling_aws( - respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, filters: object + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, filters: object, reason: str ): import litellm @@ -572,11 +581,11 @@ async def test_search_rejects_invalid_filters_with_400_without_calling_aws( ) assert error.value.status_code == 400 - assert "filters" in error.value.message + assert reason in error.value.message assert retrieve_route.call_count == 0 -@pytest.mark.parametrize("filters", INVALID_FILTERS) +@pytest.mark.parametrize("filters", [pytest.param(filters, id=case) for case, filters, _ in INVALID_FILTERS]) @pytest.mark.parametrize("drop_params_source", ["request", "global"]) @pytest.mark.asyncio async def test_search_drops_invalid_filters_under_drop_params(