Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions litellm/proxy/memory/memory_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
the caller is a PROXY_ADMIN who explicitly supplies a different scope.
"""

import json
from typing import Any, List, Optional

from fastapi import APIRouter, Depends, HTTPException, Query
Expand All @@ -39,6 +40,21 @@
router = APIRouter()


def _serialize_metadata_for_prisma(metadata: Any) -> str:
"""
Encode a `metadata` payload for the `Json?` column.

`metadata` is typed `Optional[Any]`, so callers may send dicts, lists,
or JSON scalars (including plain Python strings like `"hello"`).
prisma-client-python rejects raw Python values on `Json?` columns
(`MissingRequiredValueError` / `DataError`), and Postgres `jsonb`
rejects bare-word strings as invalid JSON — so always `json.dumps`,
regardless of input type. Roundtrip on read deserializes back to the
original Python value.
"""
return json.dumps(metadata)
Comment thread
krrish-berri-2 marked this conversation as resolved.


def _is_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
return user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN

Expand Down Expand Up @@ -273,9 +289,9 @@ async def create_memory(
prisma_client = _require_prisma()
user_id, team_id = _resolve_scope(user_api_key_dict, body.user_id, body.team_id)

# Prisma's Python client rejects `metadata=None` on a `Json?` field —
# the field must be omitted entirely to store SQL NULL. Build the data
# dict conditionally so we only include metadata when the caller sent it.
# `metadata` is a `Json?` column — prisma-client-python rejects raw
# Python values, so JSON-encode any non-null payload and omit the field
# entirely when None so the column defaults to SQL NULL.
create_data: dict = {
"key": body.key,
"value": body.value,
Expand All @@ -285,7 +301,7 @@ async def create_memory(
"updated_by": user_api_key_dict.user_id,
}
if body.metadata is not None:
create_data["metadata"] = body.metadata
create_data["metadata"] = _serialize_metadata_for_prisma(body.metadata)

try:
row = await prisma_client.db.litellm_memorytable.create(data=create_data)
Expand Down Expand Up @@ -415,20 +431,25 @@ 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.
# `metadata` is a `Json?` column. prisma-client-python rejects raw
# Python values on `Json?` fields, and there is no `JsonNull`/`DbNull`
# sentinel yet (RobertCraigie/prisma-client-py#714) so we have no way
# to write a true SQL NULL via the typed client. We mirror the rest of
# the proxy's handling of nullable `Json?` columns: forward metadata
# only when the caller sent a non-null value (always JSON-encoded),
# and treat explicit `metadata: null` as a no-op for the column. This
# matches the prior crashing behavior the PR fixes — there is no
# regression of a previously-working "clear metadata" path, and the
# rest of the proxy gives the same treatment to nullable `Json?`
# fields elsewhere.
fields_sent = body.model_fields_set
metadata_explicit = "metadata" in fields_sent
metadata_explicit_value = "metadata" in fields_sent and body.metadata is not None

data: dict = {}
if body.value is not None:
data["value"] = body.value
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 metadata_explicit_value:
data["metadata"] = _serialize_metadata_for_prisma(body.metadata)
if not data:
raise HTTPException(
status_code=400,
Expand Down Expand Up @@ -467,7 +488,9 @@ async def _find_existing() -> Any:
user_id, team_id = _resolve_scope(
user_api_key_dict, body.user_id, body.team_id
)
# Omit `metadata` when None — Prisma rejects None on Json? fields.
# Omit `metadata` when None so the column defaults to SQL NULL;
# otherwise JSON-encode for Prisma — same pattern as
# `create_memory` above.
create_data: dict = {
"key": key,
"value": body.value,
Expand All @@ -477,7 +500,7 @@ async def _find_existing() -> Any:
"updated_by": user_api_key_dict.user_id,
}
if body.metadata is not None:
create_data["metadata"] = body.metadata
create_data["metadata"] = _serialize_metadata_for_prisma(body.metadata)
try:
row = await prisma_client.db.litellm_memorytable.create(
data=create_data
Expand Down
233 changes: 227 additions & 6 deletions tests/test_litellm/proxy/memory/test_memory_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,30 @@ def _filter(self, where: Optional[Dict[str, Any]]) -> List[MagicMock]:
return [r for r in self.rows if self._matches(r, where)]

async def create(self, data: Dict[str, Any]) -> MagicMock:
import json as _json

# Key is globally unique.
for r in self.rows:
if r.key == data["key"]:
raise Exception("UniqueViolation: duplicate key")
self._counter += 1
# Mirror real Prisma read-side behavior for `Json?` columns: writes
# come in as JSON strings (the endpoint pre-processes via
# `jsonify_object`), and Prisma deserializes them back to Python
# values on read.
metadata = data.get("metadata")
if isinstance(metadata, str):
try:
metadata = _json.loads(metadata)
except ValueError:
pass
row = _make_row(
memory_id=f"mem-{self._counter}",
key=data["key"],
value=data["value"],
user_id=data.get("user_id"),
team_id=data.get("team_id"),
metadata=data.get("metadata"),
metadata=metadata,
)
row.created_by = data.get("created_by")
row.updated_by = data.get("updated_by")
Expand All @@ -125,9 +137,19 @@ async def find_many(
return out

async def update(self, where: Dict[str, Any], data: Dict[str, Any]) -> MagicMock:
import json as _json

for r in self.rows:
if r.memory_id == where["memory_id"]:
for k, v in data.items():
# Mirror real Prisma's read behavior for `Json?` columns:
# the endpoint sends JSON strings via `jsonify_object`,
# and Prisma round-trips them back to Python values.
if k == "metadata" and isinstance(v, str):
try:
v = _json.loads(v)
except ValueError:
pass
setattr(r, k, v)
return r
raise Exception("Not found")
Expand Down Expand Up @@ -218,6 +240,172 @@ def test_create_memory_defaults_scope_to_caller(self):
assert body["user_id"] == "user-a"
assert body["team_id"] == "team-a"

def test_create_memory_with_metadata_jsonifies_for_prisma(self):
"""
Regression: prisma-client-python rejects bare dicts / None on `Json?`
columns with `DataError: metadata should be of any of the following
types: NullableJsonNullValueInput, Json`. The endpoint follows the
rest of the proxy's pattern (`jsonify_object`) and JSON-encodes dict
metadata to a string before handing it to Prisma.
"""
import json as _json

table = self.prisma.db.litellm_memorytable
original_create = table.create
captured: Dict[str, Any] = {}

async def spy_create(data: Dict[str, Any]):
captured["data"] = dict(data)
return await original_create(data)

table.create = spy_create # type: ignore[assignment]

client = _make_client(_user_auth("user-a", "team-a"))
with _patch_prisma(self.prisma):
resp = client.post(
"/v1/memory",
json={
"key": "agent_memory_id",
"value": "hello world",
"metadata": {"key": "value"},
},
)
assert resp.status_code == 200, resp.text
sent_metadata = captured["data"]["metadata"]
assert isinstance(sent_metadata, str)
assert _json.loads(sent_metadata) == {"key": "value"}
assert resp.json()["metadata"] == {"key": "value"}

def test_create_memory_with_list_metadata_jsonifies_for_prisma(self):
"""
`metadata` is typed `Optional[Any]` — callers may legitimately send
a JSON array (e.g. a list of tag objects). Lists must also be
JSON-stringified before reaching Prisma; otherwise prisma-client-
python raises the same `DataError` this PR is meant to fix.
"""
import json as _json

table = self.prisma.db.litellm_memorytable
original_create = table.create
captured: Dict[str, Any] = {}

async def spy_create(data: Dict[str, Any]):
captured["data"] = dict(data)
return await original_create(data)

table.create = spy_create # type: ignore[assignment]

client = _make_client(_user_auth("user-a", "team-a"))
with _patch_prisma(self.prisma):
resp = client.post(
"/v1/memory",
json={
"key": "agent_memory_id",
"value": "hello world",
"metadata": [{"tag": "work"}, {"tag": "shared"}],
},
)
assert resp.status_code == 200, resp.text
sent_metadata = captured["data"]["metadata"]
assert isinstance(sent_metadata, str)
assert _json.loads(sent_metadata) == [{"tag": "work"}, {"tag": "shared"}]
assert resp.json()["metadata"] == [{"tag": "work"}, {"tag": "shared"}]

def test_put_memory_with_list_metadata_jsonifies_for_prisma(self):
"""Same regression as the POST list-metadata test, but for PUT-create."""
import json as _json

table = self.prisma.db.litellm_memorytable
original_create = table.create
captured: Dict[str, Any] = {}

async def spy_create(data: Dict[str, Any]):
captured["data"] = dict(data)
return await original_create(data)

table.create = spy_create # type: ignore[assignment]

client = _make_client(_user_auth("user-a", "team-a"))
with _patch_prisma(self.prisma):
resp = client.put(
"/v1/memory/notes",
json={"value": "v", "metadata": [1, 2, 3]},
)
assert resp.status_code == 200, resp.text
sent_metadata = captured["data"]["metadata"]
assert isinstance(sent_metadata, str)
assert _json.loads(sent_metadata) == [1, 2, 3]
assert resp.json()["metadata"] == [1, 2, 3]

def test_put_memory_update_with_list_metadata_jsonifies_for_prisma(self):
"""Same regression as the POST list-metadata test, but for PUT-update."""
import json as _json

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": "old"},
)
)

original_update = table.update
captured: Dict[str, Any] = {}

async def spy_update(where, data):
captured["data"] = dict(data)
return await original_update(where, data)

table.update = spy_update # type: ignore[assignment]

client = _make_client(_user_auth("user-a", "team-a"))
with _patch_prisma(self.prisma):
resp = client.put(
"/v1/memory/notes",
json={"metadata": [{"a": 1}]},
)
assert resp.status_code == 200, resp.text
sent_metadata = captured["data"]["metadata"]
assert isinstance(sent_metadata, str)
assert _json.loads(sent_metadata) == [{"a": 1}]
assert resp.json()["metadata"] == [{"a": 1}]

def test_create_memory_with_string_metadata_jsonifies_for_prisma(self):
"""
`metadata: Optional[Any]` permits JSON scalars too (string, number,
bool). A bare Python string like `"hello"` is NOT valid JSON for
Postgres `jsonb` — it must be JSON-encoded as `"\"hello\""`.
Without that encoding Prisma still raises `DataError`.
"""
import json as _json

table = self.prisma.db.litellm_memorytable
original_create = table.create
captured: Dict[str, Any] = {}

async def spy_create(data: Dict[str, Any]):
captured["data"] = dict(data)
return await original_create(data)

table.create = spy_create # type: ignore[assignment]

client = _make_client(_user_auth("user-a", "team-a"))
with _patch_prisma(self.prisma):
resp = client.post(
"/v1/memory",
json={"key": "k", "value": "v", "metadata": "hello"},
)
assert resp.status_code == 200, resp.text
sent_metadata = captured["data"]["metadata"]
assert isinstance(sent_metadata, str)
# Encoded as JSON string literal — `_json.loads` round-trips back.
assert _json.loads(sent_metadata) == "hello"
assert resp.json()["metadata"] == "hello"

def test_create_memory_duplicate_key_returns_409(self):
client = _make_client(_user_auth("user-a", "team-a"))
with _patch_prisma(self.prisma):
Expand Down Expand Up @@ -459,8 +647,16 @@ def test_put_memory_updates_existing(self):
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)."""
def test_put_memory_explicit_null_metadata_is_noop(self):
"""
prisma-client-python can't write a true SQL NULL to a `Json?` column
(no `JsonNull`/`DbNull` sentinel — see
RobertCraigie/prisma-client-py#714). We mirror the rest of the proxy
and treat `metadata: null` as "leave the column alone" rather than
500ing or silently writing a JSON `null`. The caller can still
update other fields in the same request; existing metadata is
preserved.
"""
table = self.prisma.db.litellm_memorytable
table.rows.append(
_make_row(
Expand All @@ -474,11 +670,36 @@ def test_put_memory_explicit_null_metadata_clears_field(self):
)
client = _make_client(_user_auth("user-a", "team-a"))
with _patch_prisma(self.prisma):
resp = client.put("/v1/memory/notes", json={"metadata": None})
resp = client.put(
"/v1/memory/notes", json={"value": "new", "metadata": None}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["metadata"] is None
assert table.rows[0].metadata is None
assert body["value"] == "new"
assert body["metadata"] == {"tag": "old"}
assert table.rows[0].metadata == {"tag": "old"}

def test_put_memory_null_metadata_alone_returns_400(self):
"""
With explicit-null treated as a no-op, a payload that ONLY carries
`metadata: null` has no effective fields to write — surface 400 so
the caller doesn't get a misleading 200 with no state change.
"""
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 == 400

def test_put_memory_omitted_metadata_preserves_field(self):
"""PUT without a metadata field should NOT touch the stored metadata."""
Expand Down
Loading