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.
This commit is contained in:
mateo-berri 2026-11-06 14:08:34 -08:00
parent ea0153a416
commit 0bc2f59d62
3 changed files with 27 additions and 18 deletions

View file

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

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

View file

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