fix(memory): typed Prisma error + explicit-null metadata on PUT

Two more greptile threads from the last review:

- Unique-violation detection was string-matching "Unique"/"UniqueViolation"
  in the exception message, fragile across Prisma/driver versions. Now
  check the typed error `code == "P2002"` first, with string fallback.

- PUT could not distinguish "metadata omitted" from "metadata: null" —
  both parsed as `None`, so callers had no way to clear stored metadata.
  Switch to Pydantic v2's `model_fields_set` to tell which fields the
  caller actually sent; explicit null now clears the column.

New tests:
- explicit null clears metadata
- omitted metadata preserves existing value

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-04-23 22:14:11 -07:00
parent 55f7655c2a
commit be7fe9b5f5
2 changed files with 65 additions and 3 deletions

View file

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

View file

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