diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 4312148d532..d32cf74d6ad 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -39,6 +39,7 @@ import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage"; import { ProjectsPage } from "@/components/Projects/ProjectsPage"; import VectorStoreManagement from "@/components/vector_store_management"; import ToolPoliciesView from "@/components/ToolPoliciesView"; +import { MemoryView } from "@/components/MemoryView"; import SpendLogsTable from "@/components/view_logs"; import ViewUserDashboard from "@/components/view_users"; import { ThemeProvider } from "@/contexts/ThemeContext"; @@ -630,6 +631,12 @@ function CreateKeyPageContent() { ) : page == "tool-policies" ? ( + ) : page == "memory" ? ( + ) : page == "guardrails-monitor" ? ( ) : page == "new_usage" ? ( diff --git a/ui/litellm-dashboard/src/components/MemoryView/MemoryEditModal.tsx b/ui/litellm-dashboard/src/components/MemoryView/MemoryEditModal.tsx new file mode 100644 index 00000000000..84a8504a319 --- /dev/null +++ b/ui/litellm-dashboard/src/components/MemoryView/MemoryEditModal.tsx @@ -0,0 +1,121 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Form, Input, Modal, Typography } from "antd"; +import type { MemoryRow } from "../networking"; + +const { Text } = Typography; + +interface MemoryEditModalProps { + open: boolean; + mode: "create" | "edit"; + initialRow?: MemoryRow; + onClose: () => void; + onSave: ( + key: string, + value: string, + metadataText: string, + isCreate: boolean, + ) => Promise; +} + +export const MemoryEditModal: React.FC = ({ + open, + mode, + initialRow, + onClose, + onSave, +}) => { + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + if (mode === "edit" && initialRow) { + form.setFieldsValue({ + key: initialRow.key, + value: initialRow.value, + metadata: + initialRow.metadata != null + ? JSON.stringify(initialRow.metadata, null, 2) + : "", + }); + } else { + form.resetFields(); + } + }, [open, mode, initialRow, form]); + + const handleOk = async () => { + const values = await form.validateFields(); + setSubmitting(true); + const ok = await onSave( + values.key.trim(), + values.value ?? "", + values.metadata ?? "", + mode === "create", + ); + setSubmitting(false); + if (ok) { + form.resetFields(); + onClose(); + } + }; + + return ( + { + form.resetFields(); + onClose(); + }} + onOk={handleOk} + okText={mode === "create" ? "Create" : "Save"} + confirmLoading={submitting} + width={640} + destroyOnClose + > +
+ + + + + + + + Metadata (optional JSON) + + } + name="metadata" + tooltip="Optional structured metadata — must be valid JSON if provided." + > + + +
+
+ ); +}; + +export default MemoryEditModal; diff --git a/ui/litellm-dashboard/src/components/MemoryView/MemoryView.tsx b/ui/litellm-dashboard/src/components/MemoryView/MemoryView.tsx new file mode 100644 index 00000000000..21f09e1d4ef --- /dev/null +++ b/ui/litellm-dashboard/src/components/MemoryView/MemoryView.tsx @@ -0,0 +1,419 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Button, + Card, + Drawer, + Empty, + Input, + Modal, + Space, + Table, + Tag, + Typography, + message, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + DeleteOutlined, + EditOutlined, + EyeOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { + MemoryRow, + createMemory, + deleteMemory, + fetchMemoryList, + updateMemory, +} from "../networking"; +import { MemoryEditModal } from "./MemoryEditModal"; + +const { Text, Paragraph, Title } = Typography; + +interface MemoryViewProps { + accessToken: string | null; + userID: string | null; + userRole: string | null; +} + +function previewValue(value: string, max = 120): string { + if (!value) return ""; + const trimmed = value.trim(); + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max)}…`; +} + +function formatTimestamp(ts?: string): string { + if (!ts) return "—"; + try { + const d = new Date(ts); + return d.toLocaleString(); + } catch { + return ts; + } +} + +export const MemoryView: React.FC = ({ accessToken }) => { + const [searchInput, setSearchInput] = useState(""); + const [appliedSearch, setAppliedSearch] = useState(""); + const [detailRow, setDetailRow] = useState(null); + const [editRow, setEditRow] = useState(null); + const [isCreateOpen, setIsCreateOpen] = useState(false); + + const { + data, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: ["memoryList", appliedSearch], + queryFn: () => { + if (!accessToken) throw new Error("Access token required"); + // Current API supports exact key match; treat empty search as list-all. + return fetchMemoryList(accessToken, { + keyPrefix: appliedSearch || undefined, + pageSize: 200, + }); + }, + enabled: !!accessToken, + }); + + const rows = useMemo(() => data?.memories ?? [], [data]); + + const handleDelete = async (row: MemoryRow) => { + Modal.confirm({ + title: "Delete memory", + content: ( + + Delete memory key {row.key}? This cannot be undone. + + ), + okText: "Delete", + okType: "danger", + cancelText: "Cancel", + onOk: async () => { + if (!accessToken) return; + try { + await deleteMemory(accessToken, row.key); + message.success(`Deleted ${row.key}`); + refetch(); + } catch (err: any) { + message.error(`Delete failed: ${err?.message ?? err}`); + } + }, + }); + }; + + const handleSave = async ( + key: string, + value: string, + metadataText: string, + isCreate: boolean, + ): Promise => { + if (!accessToken) return false; + let metadataParsed: unknown | undefined; + if (metadataText.trim()) { + try { + metadataParsed = JSON.parse(metadataText); + } catch { + message.error("Metadata must be valid JSON (or leave empty)."); + return false; + } + } + try { + if (isCreate) { + await createMemory(accessToken, { + key, + value, + metadata: metadataParsed, + }); + message.success(`Created ${key}`); + } else { + await updateMemory(accessToken, key, { + value, + metadata: metadataParsed, + }); + message.success(`Updated ${key}`); + } + refetch(); + return true; + } catch (err: any) { + message.error(`Save failed: ${err?.message ?? err}`); + return false; + } + }; + + const columns: ColumnsType = [ + { + title: "Key", + dataIndex: "key", + key: "key", + render: (k: string) => {k}, + sorter: (a, b) => a.key.localeCompare(b.key), + width: 240, + }, + { + title: "Preview", + dataIndex: "value", + key: "value", + render: (v: string) => ( + + {previewValue(v)} + + ), + }, + { + title: "Scope", + key: "scope", + width: 220, + render: (_: unknown, r: MemoryRow) => ( + + {r.user_id && user: {r.user_id}} + {r.team_id && team: {r.team_id}} + {!r.user_id && !r.team_id && global} + + ), + }, + { + title: "Updated", + dataIndex: "updated_at", + key: "updated_at", + width: 180, + render: (ts?: string) => ( + {formatTimestamp(ts)} + ), + sorter: (a, b) => + new Date(a.updated_at ?? 0).getTime() - + new Date(b.updated_at ?? 0).getTime(), + defaultSortOrder: "descend", + }, + { + title: "", + key: "actions", + width: 140, + render: (_: unknown, r: MemoryRow) => ( + + + + + + + + + ), + }} + /> + {data?.total !== undefined && ( + + Showing {rows.length} of {data.total} + + )} + + + + {/* Detail drawer */} + setDetailRow(null)} + title={ + detailRow ? ( + + {detailRow.key} + + ) : ( + "Memory" + ) + } + width={720} + destroyOnClose + > + {detailRow && ( + +
+ Scope +
+ + {detailRow.user_id && ( + user: {detailRow.user_id} + )} + {detailRow.team_id && ( + team: {detailRow.team_id} + )} + {!detailRow.user_id && !detailRow.team_id && global} + +
+
+
+ Value + + {detailRow.value} + +
+ {detailRow.metadata !== undefined && + detailRow.metadata !== null && ( +
+ Metadata + + {JSON.stringify(detailRow.metadata, null, 2)} + +
+ )} + ·} + wrap + size="small" + style={{ color: "rgba(0,0,0,0.45)" }} + > + + Created {formatTimestamp(detailRow.created_at)} + {detailRow.created_by ? ` by ${detailRow.created_by}` : ""} + + + Updated {formatTimestamp(detailRow.updated_at)} + {detailRow.updated_by ? ` by ${detailRow.updated_by}` : ""} + + +
+ )} +
+ + {/* Create / edit modal */} + { + setIsCreateOpen(false); + setEditRow(null); + }} + onSave={handleSave} + /> + + ); +}; + +export default MemoryView; diff --git a/ui/litellm-dashboard/src/components/MemoryView/index.tsx b/ui/litellm-dashboard/src/components/MemoryView/index.tsx new file mode 100644 index 00000000000..8b1c720148a --- /dev/null +++ b/ui/litellm-dashboard/src/components/MemoryView/index.tsx @@ -0,0 +1 @@ +export { MemoryView, default } from "./MemoryView"; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index ef0806c9594..272d348e3fd 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -180,6 +180,12 @@ const menuGroups: MenuGroup[] = [ label: "Tool Policies", icon: , }, + { + key: "memory", + page: "memory", + label: "Memory", + icon: , + }, ], }, ], diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index fb0e70842db..1f08e0190f1 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -9986,3 +9986,120 @@ export const listMCPUserCredentials = async ( if (!response.ok) return []; return response.json(); }; + +// ============================================================ +// Memory management (/v1/memory) +// ============================================================ + +export interface MemoryRow { + memory_id: string; + key: string; + value: string; + metadata?: unknown; + user_id?: string | null; + team_id?: string | null; + created_at?: string; + created_by?: string | null; + updated_at?: string; + updated_by?: string | null; +} + +export interface MemoryListResponse { + memories: MemoryRow[]; + total: number; +} + +export const fetchMemoryList = async ( + accessToken: string, + options: { keyPrefix?: string; page?: number; pageSize?: number } = {}, +): Promise => { + const base = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`; + const params = new URLSearchParams(); + if (options.keyPrefix) params.append("key", options.keyPrefix); + if (options.page != null) params.append("page", String(options.page)); + if (options.pageSize != null) + params.append("page_size", String(options.pageSize)); + const url = params.toString() ? `${base}?${params.toString()}` : base; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.text(); + throw new Error(errorData); + } + return response.json(); +}; + +export const createMemory = async ( + accessToken: string, + payload: { key: string; value: string; metadata?: unknown }, +): Promise => { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`; + const body: Record = { + key: payload.key, + value: payload.value, + }; + if (payload.metadata !== undefined) body.metadata = payload.metadata; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const errorData = await response.text(); + throw new Error(errorData); + } + return response.json(); +}; + +export const updateMemory = async ( + accessToken: string, + key: string, + payload: { value?: string; metadata?: unknown }, +): Promise => { + const encoded = encodeURIComponent(key); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/memory/${encoded}` + : `/v1/memory/${encoded}`; + const response = await fetch(url, { + method: "PUT", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const errorData = await response.text(); + throw new Error(errorData); + } + return response.json(); +}; + +export const deleteMemory = async ( + accessToken: string, + key: string, +): Promise => { + const encoded = encodeURIComponent(key); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/memory/${encoded}` + : `/v1/memory/${encoded}`; + const response = await fetch(url, { + method: "DELETE", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.text(); + throw new Error(errorData); + } +};