Litellm memory improvements v2 (#26541)

* fix(memory): jsonify metadata before Prisma writes on /v1/memory

The POST/PUT memory endpoints handed bare dicts (and bare `None`) to
prisma-client-python for the `Json?` `metadata` column, which the client
rejects with `MissingRequiredValueError` / `DataError: metadata should
be of any of the following types: NullableJsonNullValueInput, Json`.
Both the create and upsert paths now route writes through the existing
`jsonify_object` helper used elsewhere in the proxy for `Json?` columns
(e.g. `LiteLLM_VerificationToken.budget_limits`), and omit metadata
when None so the column defaults to SQL NULL via the schema.

Explicit `metadata: null` on PUT is now a no-op for the column to match
how the rest of the proxy handles nullable JSON fields (no
`JsonNull`/`DbNull` sentinel exists in prisma-client-python — see
RobertCraigie/prisma-client-py#714). A payload with only `metadata: null`
returns 400 instead of a misleading 200.

Made-with: Cursor

* fix(memory): JSON-encode non-dict metadata before Prisma writes

`jsonify_object` only stringifies dict values, so list-shaped metadata
still hit Prisma as raw Python objects and triggered the same
DataError this PR is meant to fix. `metadata` is typed `Optional[Any]`
so list payloads are valid input. Replace `jsonify_object` with a
local `_serialize_metadata_for_prisma` helper that always `json.dumps`
non-string values, applied at all three write sites
(POST create, PUT update, PUT-create). Adds regression tests for
list metadata on each path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(memory): always json.dumps metadata, not just non-strings

The str-passthrough in `_serialize_metadata_for_prisma` left plain
Python strings (e.g. `metadata: "hello"`) unencoded — Postgres `jsonb`
rejects bare-word strings as invalid JSON, reproducing the same
DataError this PR is meant to fix. Always `json.dumps` regardless of
input type so all `Optional[Any]` shapes (dict, list, scalar, str)
become valid JSON. Adds a regression test for plain-string metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(memory): encode explicit metadata:null as JSON null to clear field

prisma-client-python has no JsonNull/DbNull sentinel for writing a
true SQL NULL on `Json?` columns (RobertCraigie/prisma-client-py#714),
so an earlier iteration of this PR treated `PUT {"metadata": null}`
as a no-op. That doesn't match the natural caller expectation that
explicit-null clears the field.

Encode it as the JSON literal `null` instead — stored as Postgres
`jsonb 'null'`, which prisma deserializes back to Python `None` on
read. Subsequent reads return `metadata: null`, so the field is
effectively cleared from the caller's perspective. Strict SQL NULL
remains unreachable via the typed client and would require raw SQL.

Also clean up stale `jsonify_object` references in test mock comments
(replaced by `_serialize_metadata_for_prisma`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(memory ui): use shared DeleteResourceModal for memory deletion

Swap the imperative `Modal.confirm` in MemoryView for the shared
`DeleteResourceModal`, so memory deletion matches the rest of the
dashboard: type-to-confirm guard on the key, in-flight loading state
on the OK button, cancel disabled while the request is pending, and
the modal stays open on error so the user can retry.

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia
2026-04-25 19:03:43 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 740bb44796
commit 4ed3e712e0
3 changed files with 73 additions and 43 deletions
+14 -13
View File
@@ -431,24 +431,25 @@ async def upsert_memory(
"""
prisma_client = _require_prisma()
# `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.
# `metadata` is a `Json?` column. prisma-client-python has no
# `JsonNull`/`DbNull` sentinel for writing a true SQL NULL
# (RobertCraigie/prisma-client-py#714), so an explicit `metadata: null`
# is encoded as the JSON literal `null` instead — stored as Postgres
# `jsonb 'null'`, which prisma deserializes back to Python `None` on
# read. From a caller's perspective `PUT {"metadata": null}` clears
# the field (subsequent reads return `metadata: null`), matching the
# natural expectation. Callers wanting a strict SQL NULL must use
# raw SQL — there is no typed-client path.
#
# When `metadata` is omitted from the request body entirely (not in
# `model_fields_set`), the column is preserved as-is.
fields_sent = body.model_fields_set
metadata_explicit_value = "metadata" in fields_sent and body.metadata is not None
metadata_in_payload = "metadata" in fields_sent
data: dict = {}
if body.value is not None:
data["value"] = body.value
if metadata_explicit_value:
if metadata_in_payload:
data["metadata"] = _serialize_metadata_for_prisma(body.metadata)
if not data:
raise HTTPException(
@@ -97,8 +97,8 @@ class _InMemoryMemoryTable:
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.
# `_serialize_metadata_for_prisma`), and Prisma deserializes them
# back to Python values on read.
metadata = data.get("metadata")
if isinstance(metadata, str):
try:
@@ -143,8 +143,9 @@ class _InMemoryMemoryTable:
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.
# the endpoint sends JSON strings via
# `_serialize_metadata_for_prisma`, and Prisma
# round-trips them back to Python values.
if k == "metadata" and isinstance(v, str):
try:
v = _json.loads(v)
@@ -647,15 +648,15 @@ class TestMemoryEndpoints:
assert resp.json()["value"] == "new"
assert len(table.rows) == 1
def test_put_memory_explicit_null_metadata_is_noop(self):
def test_put_memory_explicit_null_metadata_clears_field(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.
RobertCraigie/prisma-client-py#714). We instead encode an explicit
`metadata: null` as the JSON literal `null` (Postgres `jsonb 'null'`).
prisma deserializes that back to Python `None` on read, so from a
caller's perspective the field is cleared — matching the natural
expectation of `PUT {"metadata": null}`.
"""
table = self.prisma.db.litellm_memorytable
table.rows.append(
@@ -676,14 +677,15 @@ class TestMemoryEndpoints:
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["value"] == "new"
assert body["metadata"] == {"tag": "old"}
assert table.rows[0].metadata == {"tag": "old"}
assert body["metadata"] is None
assert table.rows[0].metadata is None
def test_put_memory_null_metadata_alone_returns_400(self):
def test_put_memory_null_metadata_alone_clears_field(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.
A payload that ONLY carries `metadata: null` should clear the
column — the field is effective, not skipped. (Earlier iterations
of this PR treated explicit-null as a no-op and surfaced 400; we
now write JSON `null` so the column reads back as None.)
"""
table = self.prisma.db.litellm_memorytable
table.rows.append(
@@ -699,7 +701,9 @@ class TestMemoryEndpoints:
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
assert resp.status_code == 200, resp.text
assert resp.json()["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."""
@@ -8,7 +8,6 @@ import {
Drawer,
Empty,
Input,
Modal,
Space,
Table,
Tooltip,
@@ -32,6 +31,7 @@ import {
updateMemory,
} from "../networking";
import { MemoryEditModal } from "./MemoryEditModal";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
const { Text, Paragraph, Title } = Typography;
@@ -65,6 +65,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
const [appliedSearch, setAppliedSearch] = useState("");
const [detailRow, setDetailRow] = useState<MemoryRow | null>(null);
const [editRow, setEditRow] = useState<MemoryRow | null>(null);
const [deleteRow, setDeleteRow] = useState<MemoryRow | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@@ -162,18 +163,18 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
});
const handleDelete = (row: MemoryRow) => {
Modal.confirm({
title: "Delete memory",
content: (
<span>
Delete memory key <Text code>{row.key}</Text>? This cannot be undone.
</span>
),
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: () => deleteMutation.mutateAsync(row.key).catch(() => {}),
});
setDeleteRow(row);
};
const confirmDelete = async () => {
if (!deleteRow) return;
try {
await deleteMutation.mutateAsync(deleteRow.key);
setDeleteRow(null);
} catch {
// Error toast already surfaced by deleteMutation.onError;
// leave the modal open so the user can retry or cancel.
}
};
const handleSave = async (
@@ -537,6 +538,30 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
}}
onSave={handleSave}
/>
{/* Delete confirmation modal */}
<DeleteResourceModal
isOpen={!!deleteRow}
title="Delete memory"
message="This action cannot be undone."
resourceInformationTitle="Memory"
resourceInformation={
deleteRow
? [
{ label: "Key", value: deleteRow.key, code: true },
{ label: "Memory ID", value: deleteRow.memory_id, code: true },
{ label: "User ID", value: deleteRow.user_id ?? "-", code: true },
{ label: "Team ID", value: deleteRow.team_id ?? "-", code: true },
]
: []
}
onCancel={() => {
if (!deleteMutation.isPending) setDeleteRow(null);
}}
onOk={confirmDelete}
confirmLoading={deleteMutation.isPending}
requiredConfirmation={deleteRow?.key}
/>
</div>
);
};