From c5fafe56bb5a4e36bfdbad6de50f3672169b3b79 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 24 Apr 2026 09:08:53 -0700 Subject: [PATCH] fix(memory): compose visibility + key filters via explicit AND Greptile P1 (filter-fragility): `where.update(vis)` was semantically correct today, but dict-merging by key meant any future visibility filter that grew a new top-level "OR" would silently clobber the existing key filter. Compose explicitly instead: where = {"AND": [key_filter, vis]} Applied to both `list_memory` and `_find_memory_for_caller`. When either side is empty (admin has no visibility filter; list has no key filter), skip the wrapper and use the non-empty side directly to keep the generated SQL clean. Test fake's `_matches` now understands top-level `AND` too. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/memory/memory_endpoints.py | 31 ++++++++++++------- .../proxy/memory/test_memory_endpoints.py | 4 +++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/memory/memory_endpoints.py b/litellm/proxy/memory/memory_endpoints.py index 35e635bbc13..937647ce7a0 100644 --- a/litellm/proxy/memory/memory_endpoints.py +++ b/litellm/proxy/memory/memory_endpoints.py @@ -223,17 +223,25 @@ async def list_memory( """List memory entries visible to the caller.""" prisma_client = _require_prisma() - where: dict = {} - vis = _visibility_filter(user_api_key_dict) - if vis is not None: - where.update(vis) - # key_prefix takes precedence over exact `key` if both are passed. - # Both sit under the visibility filter (ANDed), so prefix scans can - # never leak across user/team scopes. + # Build the key filter first (prefix wins if both `key` and `key_prefix` + # are passed). Then AND it with the visibility filter via an explicit + # top-level "AND" — safer than `dict.update` since future visibility + # filters could grow an "OR" key that would clobber this one if merged + # by key. + key_filter: dict = {} if key_prefix is not None: - where["key"] = {"startsWith": key_prefix} + key_filter["key"] = {"startsWith": key_prefix} elif key is not None: - where["key"] = key + key_filter["key"] = key + + vis = _visibility_filter(user_api_key_dict) + where: dict + if vis is None: + where = key_filter + elif not key_filter: + where = vis + else: + where = {"AND": [key_filter, vis]} try: total = await prisma_client.db.litellm_memorytable.count(where=where) @@ -254,10 +262,9 @@ async def _find_memory_for_caller( prisma_client: Any, key: str, user_api_key_dict: UserAPIKeyAuth ) -> Any: """Look up a memory row by key, scoped to the caller's visibility.""" - where: dict = {"key": key} + key_filter = {"key": key} vis = _visibility_filter(user_api_key_dict) - if vis is not None: - where.update(vis) + where = key_filter if vis is None else {"AND": [key_filter, vis]} rows = await prisma_client.db.litellm_memorytable.find_many( where=where, take=1, order={"updated_at": "desc"} ) diff --git a/tests/test_litellm/proxy/memory/test_memory_endpoints.py b/tests/test_litellm/proxy/memory/test_memory_endpoints.py index fe83327af1a..fd82ba9b391 100644 --- a/tests/test_litellm/proxy/memory/test_memory_endpoints.py +++ b/tests/test_litellm/proxy/memory/test_memory_endpoints.py @@ -55,6 +55,10 @@ class _InMemoryMemoryTable: def _matches(self, row: MagicMock, where: Dict[str, Any]) -> bool: for k, v in where.items(): + if k == "AND": + if not all(self._matches(row, clause) for clause in v): + return False + continue if k == "OR": if not any(self._matches(row, clause) for clause in v): return False