diff --git a/litellm/proxy/memory/memory_endpoints.py b/litellm/proxy/memory/memory_endpoints.py index 7e6cfc3a645..35e635bbc13 100644 --- a/litellm/proxy/memory/memory_endpoints.py +++ b/litellm/proxy/memory/memory_endpoints.py @@ -87,9 +87,20 @@ def _require_prisma(): def _is_unique_violation(exc: Exception) -> bool: - """Best-effort detection of a Prisma unique-constraint violation.""" + """ + Detect a Prisma unique-constraint violation. + + Prefer the typed error code `P2002` from `PrismaClientKnownRequestError`; + fall back to string matching so we stay robust across Prisma versions + where the typed class may be unavailable or differently named. + """ + code = getattr(exc, "code", None) + if code == "P2002": + return True msg = str(exc) - return "Unique" in msg or "unique" in msg or "UniqueViolation" in msg + return ( + "P2002" in msg or "Unique" in msg or "unique" in msg or "UniqueViolation" in msg + ) def _resolve_scope( @@ -292,10 +303,19 @@ async def upsert_memory( """ prisma_client = _require_prisma() + # Distinguish "metadata omitted from request" from "metadata: null". + # Omitted → don't touch the existing field. Explicit null → clear to SQL NULL. + # `model_fields_set` (Pydantic v2) only contains field names the caller + # actually sent in the payload. + fields_sent = body.model_fields_set + metadata_explicit = "metadata" in fields_sent + data: dict = {} if body.value is not None: data["value"] = body.value - if body.metadata is not None: + if metadata_explicit: + # body.metadata may be None here — Prisma update accepts None on a + # nullable Json? field and sets the column to SQL NULL. data["metadata"] = body.metadata if not data: raise HTTPException( diff --git a/tests/test_litellm/proxy/memory/test_memory_endpoints.py b/tests/test_litellm/proxy/memory/test_memory_endpoints.py index 90863e5e492..fe83327af1a 100644 --- a/tests/test_litellm/proxy/memory/test_memory_endpoints.py +++ b/tests/test_litellm/proxy/memory/test_memory_endpoints.py @@ -427,6 +427,48 @@ class TestMemoryEndpoints: assert resp.json()["value"] == "new" assert len(table.rows) == 1 + def test_put_memory_explicit_null_metadata_clears_field(self): + """PUT with `metadata: null` should clear the metadata column (not silently drop the field).""" + table = self.prisma.db.litellm_memorytable + table.rows.append( + _make_row( + memory_id="m1", + key="notes", + value="v", + user_id="user-a", + team_id="team-a", + metadata={"tag": "old"}, + ) + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.put("/v1/memory/notes", json={"metadata": None}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["metadata"] is None + assert table.rows[0].metadata is None + + def test_put_memory_omitted_metadata_preserves_field(self): + """PUT without a metadata field should NOT touch the stored metadata.""" + table = self.prisma.db.litellm_memorytable + table.rows.append( + _make_row( + memory_id="m1", + key="notes", + value="old", + user_id="user-a", + team_id="team-a", + metadata={"tag": "keep"}, + ) + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.put("/v1/memory/notes", json={"value": "new"}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["value"] == "new" + assert body["metadata"] == {"tag": "keep"} + def test_put_memory_empty_body_returns_400(self): client = _make_client(_user_auth("user-a", "team-a")) with _patch_prisma(self.prisma):