feat(memory): add user activation and contributor table

This commit is contained in:
moe-berri 2026-09-14 13:23:31 -07:00
parent 6f92e244fb
commit f4fc27f2e2
13 changed files with 497 additions and 190 deletions

View file

@ -1,3 +1,4 @@
from types import MappingProxyType
from typing import Annotated, Final
from fastapi import APIRouter, Depends, HTTPException, Query, Response
@ -225,7 +226,15 @@ async def get_status(
key_id: str | None = Query(None, pattern=r"^[a-f0-9]{64}$"),
auth: UserAPIKeyAuth = _AUTH,
) -> MemoryStatus:
return (await access_for_key(auth, key_id)).status
access: Final = await access_for_key(auth, key_id)
user: Final = (
await UserRepository(memory_primary_client(require_memory_prisma())).find_by_id(access.identity.user_id)
if access.identity.user_id
else None
)
return access.status.model_copy(
update=MappingProxyType({"user_name": user.user_alias or user.user_email or user.user_id if user else None})
)
async def access_for_key(auth: UserAPIKeyAuth, key_id: str | None) -> MemoryAccess:
@ -267,12 +276,25 @@ async def list_entries(
raise HTTPException(status_code=422, detail="Provide both memory cursor fields")
prisma: Final = memory_primary_client(require_memory_prisma())
access: Final = await access_for_key(auth, key_id)
return await MemoryStore(prisma, access).search(
entries: Final = await MemoryStore(prisma, access).search(
MemorySearch(query=query, limit=limit, offset=offset),
require_active=False,
recent_first=True,
before=(before_updated_at, before_memory_id) if before_updated_at and before_memory_id else None,
)
actors: Final = tuple(frozenset(entry.actor for entry in entries if entry.actor))
if not actors:
return entries
users: Final = await UserRepository(prisma).table.find_many(
where={"user_id": {"in": list(actors)}}, # mutable-ok: Prisma serializes the bounded page's contributor IDs.
take=len(actors),
)
names: Final = MappingProxyType(
{user.user_id: user.user_alias or user.user_email or user.user_id for user in users}
)
return tuple(
entry.model_copy(update=MappingProxyType({"actor_name": names.get(entry.actor or "")})) for entry in entries
)
@router.post("/entries", response_model=MemoryEntry)
@ -283,7 +305,9 @@ async def capture_entry(
) -> MemoryEntry:
prisma: Final = memory_primary_client(require_memory_prisma())
access: Final = await access_for_key(auth, key_id)
return await MemoryStore(prisma, access).capture(capture)
return await MemoryStore(prisma, access, actor=auth.user_id or MemoryIdentity.from_auth(auth).key_id).capture(
capture
)
@router.delete("/entries/{memory_id}", status_code=204)

View file

@ -82,10 +82,10 @@ class _LegacyMemoryVisibility(TypedDict):
def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object] | None:
"""
Prisma `where` fragment restricting rows to those the caller can see.
Returns None for admins (no restriction).
Administrators can access every V1 row, independently of user/team ownership.
"""
if user_api_key_has_admin_view(user_api_key_dict):
return None
return {"namespace": None} # mutable-ok: Prisma requires a native JSON filter for V1 visibility.
ors: Final = [
{field: value}
for field, value in (("user_id", user_api_key_dict.user_id), ("team_id", user_api_key_dict.team_id))

View file

@ -172,6 +172,7 @@ class MemoryAccess:
scope=self.policy.scope if self.policy and self.namespace else None,
opted_in=self.opted_in,
policy_id=self.policy.policy_id if self.policy else None,
user_id=self.identity.user_id,
)

View file

@ -50,9 +50,10 @@ def memory_entry(row: "LiteLLM_MemoryTable") -> MemoryEntry:
class MemoryStore:
def __init__(self, prisma_client: object, access: MemoryAccess) -> None:
def __init__(self, prisma_client: object, access: MemoryAccess, *, actor: str | None = None) -> None:
self.prisma_client = memory_primary_client(prisma_client)
self.access = access
self.actor = actor or access.identity.user_id or access.identity.key_id
self.table = MemoryRepository(self.prisma_client).table
async def authorize_namespace(self, *, write: bool = False, require_active: bool = True) -> str:
@ -159,7 +160,7 @@ class MemoryStore:
data: Final = { # mutable-ok: Prisma query and write JSON.
"value": content,
"metadata": json.dumps(metadata),
"updated_by": self.access.identity.user_id or self.access.identity.key_id,
"updated_by": self.actor,
}
existing: Final = await table.find_unique(
where={ # mutable-ok: Prisma query and write JSON.
@ -215,7 +216,7 @@ class MemoryStore:
"namespace": namespace,
"user_id": self.access.identity.user_id,
"team_id": self.access.identity.team_id,
"created_by": self.access.identity.user_id or self.access.identity.key_id,
"created_by": self.actor,
}
)
return memory_entry(created)

View file

@ -29,7 +29,7 @@ class MemoryPolicyInput(BaseModel):
target_type: MemoryTarget
target_id: str = Field(min_length=1, max_length=256)
activation: MemoryActivation
scope: MemoryScope = "key"
scope: MemoryScope = "user"
@model_validator(mode="after")
def validate_target(self) -> Self:
@ -62,6 +62,8 @@ class MemoryStatus(BaseModel):
scope: MemoryScope | None
opted_in: bool
policy_id: str | None
user_id: str | None = None
user_name: str | None = None
class MemoryCapture(BaseModel):
@ -90,6 +92,7 @@ class MemoryEntry(BaseModel):
updated_at: datetime
created_at: datetime | None = None
actor: str | None = None
actor_name: str | None = None
when_to_use: str = ""
scope: str = ""
kind: MemoryKind = "context"

View file

@ -1088,14 +1088,30 @@ class TestMemoryEndpoints:
assert resp.status_code == 404, resp.text
assert resp.json()["detail"] == "Memory with key 'notes' not found"
def test_visibility_filter_unscoped_for_admin_viewer(self):
def test_visibility_filter_excludes_v2_for_admin_viewer(self):
"""
proxy_admin_viewer reads with the same unscoped filter as proxy_admin;
every other role stays row-restricted.
Administrative reads include every V1 owner but exclude V2 namespaces.
"""
assert _visibility_filter(_admin_viewer_auth()) is None
assert _visibility_filter(_admin_viewer_auth()) == {"namespace": None}
assert _visibility_filter(_user_auth("user-a", "team-a")) is not None
def test_v1_admin_crud_does_not_read_or_change_v2_memories(self):
table = self.prisma.db.litellm_memorytable
automatic = _make_row(memory_id="automatic", key="memory-v2:private:note", value="Private context")
automatic.namespace = "private"
table.rows.extend([_make_row(key="original-v1", value="Existing integration"), automatic])
client = _make_client(_admin_auth())
with _patch_prisma(self.prisma):
listed = client.get("/v1/memory")
assert listed.status_code == 200
assert [row["key"] for row in listed.json()["memories"]] == ["original-v1"]
assert client.get("/v1/memory/memory-v2:private:note").status_code == 404
assert client.delete("/v1/memory/memory-v2:private:note").status_code == 404
assert client.put("/v1/memory/memory-v2:private:note", json={"value": "Overwrite"}).status_code == 409
updated = client.put("/v1/memory/original-v1", json={"value": "Still works", "metadata": None})
assert updated.status_code == 200 and updated.json()["value"] == "Still works"
assert automatic.value == "Private context"
def test_list_memory_admin_viewer_sees_all(self):
"""Read parity end-to-end: the viewer's own user_id/team_id must not filter the list."""
table = self.prisma.db.litellm_memorytable

View file

@ -12,7 +12,7 @@ from fastapi.testclient import TestClient
from litellm.proxy._types import UI_TEAM_ID, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.memory import management
from litellm.proxy.memory.policy import MemoryIdentity, memory_digest
from litellm.proxy.memory.policy import MemoryIdentity, memory_digest, resolve_memory_access
from litellm.types.memory_v2 import MemoryCapture, MemoryPolicy, MemoryPolicyInput, MemoryPreference
@ -96,6 +96,82 @@ def policy(**changes: object) -> MemoryPolicy:
)
@pytest.mark.asyncio
async def test_user_preference_and_memories_follow_owner_across_keys(database: MagicMock) -> None:
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="user", activation="opt_in")]
first = MemoryIdentity.from_auth(auth())
second = MemoryIdentity.from_auth(auth().model_copy(update={"token": "b" * 64}))
other = MemoryIdentity.from_auth(auth("other").model_copy(update={"token": "c" * 64}))
other_org = MemoryIdentity.from_auth(auth().model_copy(update={"org_id": "other-org"}))
assert not (await resolve_memory_access(database, first)).active
await management.set_preference(MemoryPreference(enabled=True), auth())
written = database.db.litellm_memorypreference.upsert.call_args.kwargs["data"]["create"]
async def preference(*, where: dict[str, str]) -> SimpleNamespace | None:
return SimpleNamespace(enabled=written["enabled"]) if where["subject"] == written["subject"] else None
database.db.litellm_memorypreference.find_unique.side_effect = preference
one = await resolve_memory_access(database, first)
two = await resolve_memory_access(database, second)
assert one.active and two.active and one.namespace == two.namespace
assert not (await resolve_memory_access(database, other)).active
assert one.namespace != (await resolve_memory_access(database, other_org)).namespace
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="user", activation="disabled")]
assert not (await resolve_memory_access(database, second)).active
@pytest.mark.asyncio
async def test_dashboard_resolves_saved_contributor_names_and_identifies_preference_owner(database: MagicMock) -> None:
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="team")]
database.db.litellm_usertable.find_unique.return_value = {"user_id": "owner", "user_alias": "Alex Rivera"}
status = await management.get_status(None, auth())
assert status.user_id == "owner" and status.user_name == "Alex Rivera"
now = datetime(2026, 9, 14, tzinfo=timezone.utc)
database.db.litellm_memorytable.find_many.return_value = [
SimpleNamespace(
memory_id="entry",
key="memory-v2:namespace:demo",
value="Use port 8123",
metadata={},
updated_at=now,
created_at=now,
created_by="contributor",
)
]
database.db.litellm_usertable.find_many.return_value = [
SimpleNamespace(user_id="contributor", user_alias="Jamie Davis", user_email=None)
]
entries = await management.list_entries("", 20, 0, None, auth())
assert entries[0].actor == "contributor" and entries[0].actor_name == "Jamie Davis"
assert database.db.litellm_usertable.find_many.call_args.kwargs["where"] == {"user_id": {"in": ["contributor"]}}
database.db.litellm_usertable.find_many.return_value = []
legacy = await management.list_entries("", 20, 0, None, auth())
assert legacy[0].actor == "contributor" and legacy[0].actor_name is None
@pytest.mark.asyncio
async def test_admin_manual_capture_uses_actual_author_instead_of_selected_key_owner(database: MagicMock) -> None:
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="user")]
now = datetime(2026, 9, 14, tzinfo=timezone.utc)
database.db.litellm_memorytable.create.return_value = SimpleNamespace(
memory_id="entry",
key="memory-v2:namespace:demo",
value="Use port 8123",
metadata={},
updated_at=now,
created_at=now,
created_by="admin",
)
saved = await management.capture_entry(
MemoryCapture(key="demo", title="Demo", content="Use port 8123", evidence="Admin correction"),
"a" * 64,
auth("admin", LitellmUserRoles.PROXY_ADMIN),
)
data = database.db.litellm_memorytable.create.call_args.kwargs["data"]
assert data["created_by"] == data["updated_by"] == saved.actor == "admin"
assert data["user_id"] == "owner"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"target,target_id",

View file

@ -1,15 +1,13 @@
"use client";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Brain, ChevronDown, Search } from "lucide-react";
import { Brain } from "lucide-react";
import { type FormEvent, useState } from "react";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
@ -19,6 +17,7 @@ import { toast } from "@/lib/toast";
import { MemoryPreference } from "./MemorySettings";
import { MemoryKeyPicker } from "./MemoryTargetPicker";
import { MemoryEntriesTable } from "./MemoryEntriesTable";
type Entry = components["schemas"]["MemoryEntry"];
type Capture = components["schemas"]["MemoryCapture"];
@ -28,10 +27,10 @@ type Status = components["schemas"]["MemoryStatus"];
function memoryDescription(status?: Status) {
if (!status) return "What your assistants remember across conversations.";
if (status.activation === "disabled" || !status.scope)
return "Memory is off. Your administrator can make it available for this key.";
return "Memory is off. Your administrator can make it available.";
if (status.activation === "automatic") {
if (status.active) return "Memory is on for this key. Your administrator manages this setting.";
return "Memory is off. Your administrator can make it available for this key.";
if (status.active) return "Memory is on. Your administrator manages this setting.";
return "Memory is off. Your administrator can make it available.";
}
if (status.active) return "Your assistants can save and recall memories. You can turn this off at any time.";
return "Your assistants won't save or recall memories. Turn it on whenever you're ready.";
@ -48,21 +47,6 @@ function loadMoreLabel(fetching: boolean, failed: boolean) {
return failed ? "Try again" : "Load more memories";
}
function memoryDetails(entry: Entry) {
const details = {
Evidence: entry.evidence,
"When to use": entry.when_to_use,
Source: entry.source,
Kind: entry.kind,
Certainty: entry.certainty,
Context: entry.scope,
"Saved by": entry.actor,
Created: entry.created_at ? new Date(entry.created_at).toLocaleString() : undefined,
Updated: new Date(entry.updated_at).toLocaleString(),
"Memory ID": entry.memory_id,
};
return Object.entries(details).filter(([, value]) => !!value);
}
type DashboardProps = Readonly<{ userId: string; readOnly: boolean; proxyAdmin: boolean }>;
export function AutomaticMemoryEntries({ userId, readOnly, proxyAdmin }: DashboardProps) {
@ -80,7 +64,7 @@ export function AutomaticMemoryEntries({ userId, readOnly, proxyAdmin }: Dashboa
<MemoryDashboard key={`${userId}:${keyId}`} userId={userId} keyId={keyId} readOnly={readOnly}>
<div className="w-full space-y-1.5 sm:w-80">
<Label htmlFor="memory-entry-key" className="text-xs text-muted-foreground">
Virtual key
Key context
</Label>
<MemoryKeyPicker
inputId="memory-entry-key"
@ -117,15 +101,18 @@ function MemoryDashboard({
const [query, setQuery] = useState("");
const [editing, setEditing] = useState<Entry | null>(null);
const [deleting, setDeleting] = useState<Entry | null>(null);
const status = useQuery({
const statusOptions = {
queryKey: ["memoryStatus", userId, keyId],
enabled: !!keyId,
queryFn: async ({ signal }) =>
refetchOnMount: true,
queryFn: async ({ signal }: { signal: AbortSignal }) =>
(await fetchClient.GET("/v2/memory/status", { params: { query: { key_id: keyId } }, signal })).data,
});
};
const status = useQuery(statusOptions);
const entriesOptions = {
queryKey: ["memoryEntries", userId, keyId, query],
enabled: !!keyId && !!status.data?.scope,
refetchOnMount: true,
initialPageParam: null as Cursor,
queryFn: async ({ signal, pageParam }: { signal: AbortSignal; pageParam: Cursor }) =>
(
@ -148,7 +135,7 @@ function MemoryDashboard({
onSuccess: () => {
setEditing(null);
toast.success("Memory updated");
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, keyId] });
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId] });
},
onError: (error: Error) => toast.error(error.message),
});
@ -160,7 +147,7 @@ function MemoryDashboard({
onSuccess: () => {
setDeleting(null);
toast.success("Memory deleted");
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, keyId] });
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId] });
},
onError: (error: Error) => toast.error(error.message),
});
@ -202,17 +189,12 @@ function MemoryDashboard({
</div>
<div className="flex flex-wrap items-end justify-between gap-4">
{children}
{status.data?.scope && (
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
aria-label="Search memories"
placeholder="Search memories"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{status.data?.user_id && (
<p className="text-sm text-muted-foreground">
Memory for{" "}
<span className="font-medium text-foreground">{status.data.user_name ?? status.data.user_id}</span>
{status.data.scope === "user" && ". Shared across this user's keys in this organization."}
</p>
)}
</div>
{status.error && (
@ -221,106 +203,55 @@ function MemoryDashboard({
</p>
)}
{status.data?.scope && (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-base font-semibold">Saved memories</h2>
<span className="text-xs text-muted-foreground">
{status.data.scope !== "key" &&
status.data.scope !== "user" &&
`Shared with your ${status.data.scope} · `}
Newest first
</span>
</div>
{!status.data.active && memories.length > 0 && (
<p className="text-sm text-muted-foreground">Saved memories stay here while memory is off.</p>
)}
{entries.isPending && (
<div role="status" aria-label="Loading memories" className="space-y-3">
{[0, 1, 2].map((row) => (
<Skeleton key={row} className="h-28 w-full rounded-lg" />
))}
</div>
)}
{entries.isSuccess && memories.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border bg-card px-6 py-12 text-center">
<Brain className="size-7 text-muted-foreground" />
<h3 className="font-medium">{query ? "No matching memories" : "No memories yet"}</h3>
<p className="text-sm text-muted-foreground">{emptyDescription(query, status.data.active)}</p>
</div>
)}
<ul className="space-y-3" aria-label="Saved memories">
{memories.map((entry) => (
<li key={entry.memory_id} className="space-y-3 rounded-lg border bg-card p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="font-medium">{entry.title}</h3>
<time
dateTime={entry.updated_at}
title={new Date(entry.updated_at).toLocaleString()}
className="text-xs text-muted-foreground"
>
{new Date(entry.updated_at).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</time>
<div className="space-y-3">
<h2 className="sr-only">Saved memories</h2>
<MemoryEntriesTable
entries={memories}
query={query}
onQueryChange={setQuery}
loading={entries.isPending}
readOnly={readOnly}
canEdit={status.data.active}
busy={busy}
onEdit={setEditing}
onDelete={setDeleting}
empty={
entries.error ? (
<p className="text-destructive">Could not load memories: {entries.error.message}</p>
) : (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Brain className="mb-1 size-6 text-muted-foreground" />
<h3 className="font-medium">{query ? "No matching memories" : "No memories yet"}</h3>
<p className="text-sm text-muted-foreground">{emptyDescription(query, status.data.active)}</p>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{entry.content}</p>
<Collapsible>
<CollapsibleTrigger
render={<Button variant="ghost" size="xs" className="gap-1 text-muted-foreground" />}
aria-label={`Details for ${entry.title}`}
)
}
footer={
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<span className="text-xs text-muted-foreground">
{memories.length} memories
{!status.data.active && memories.length > 0 && " · Saved memories stay here while memory is off"}
</span>
{(entries.hasNextPage || entries.isError) && (
<Button
variant="outline"
size="sm"
disabled={entries.isFetching}
onClick={() =>
entries.isError && !entries.isFetchNextPageError ? entries.refetch() : entries.fetchNextPage()
}
>
<ChevronDown className="size-3" /> Details
<span className="sr-only"> for {entry.title}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<dl className="mt-3 grid gap-x-6 gap-y-2 border-t pt-3 text-xs sm:grid-cols-[auto_1fr]">
{memoryDetails(entry).map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-muted-foreground">{label}</dt>
<dd className="whitespace-pre-wrap break-all">{value}</dd>
</div>
))}
</dl>
{!readOnly && (
<div className="mt-3 flex gap-2">
<Button
variant="outline"
size="sm"
disabled={busy || !status.data?.active}
onClick={() => setEditing(entry)}
>
Edit memory
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setDeleting(entry)}>
Delete memory
</Button>
</div>
)}
</CollapsibleContent>
</Collapsible>
</li>
))}
</ul>
{entries.error && (
{loadMoreLabel(entries.isFetching, entries.isError)}
</Button>
)}
</div>
}
/>
{entries.isFetchNextPageError && (
<p role="alert" className="text-sm text-destructive">
Could not load memories: {entries.error.message}
Could not load more memories: {entries.error.message}
</p>
)}
{(entries.hasNextPage || entries.isError) && (
<div className="flex justify-center">
<Button
variant="outline"
disabled={entries.isFetching}
onClick={() =>
entries.isError && !entries.isFetchNextPageError ? entries.refetch() : entries.fetchNextPage()
}
>
{loadMoreLabel(entries.isFetching, entries.isError)}
</Button>
</div>
)}
</div>
)}
<Dialog

View file

@ -0,0 +1,180 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { ChevronDown, ChevronRight, Search } from "lucide-react";
import { useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { components } from "@/lib/http/schema";
type Entry = components["schemas"]["MemoryEntry"];
function contributor(entry: Entry) {
if (entry.actor_name) return entry.actor_name;
if (!entry.actor) return "Unknown contributor";
return /^[a-f0-9]{64}$/.test(entry.actor) ? "Unlinked key" : entry.actor;
}
function memoryDetails(entry: Entry) {
const details = {
"Contributed by": contributor(entry),
Evidence: entry.evidence,
"When to use": entry.when_to_use,
Source: entry.source,
Kind: entry.kind,
Certainty: entry.certainty,
Context: entry.scope,
Created: entry.created_at ? new Date(entry.created_at).toLocaleString() : undefined,
Updated: new Date(entry.updated_at).toLocaleString(),
"Memory ID": entry.memory_id,
};
return Object.entries(details).filter(([, value]) => !!value);
}
export function MemoryEntriesTable({
entries,
query,
onQueryChange,
loading,
empty,
footer,
readOnly,
canEdit,
busy,
onEdit,
onDelete,
}: Readonly<{
entries: Entry[];
query: string;
onQueryChange: (query: string) => void;
loading: boolean;
empty: React.ReactNode;
footer: React.ReactNode;
readOnly: boolean;
canEdit: boolean;
busy: boolean;
onEdit: (entry: Entry) => void;
onDelete: (entry: Entry) => void;
}>) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = entries.find((entry) => entry.memory_id === selectedId);
const columns: ColumnDef<Entry>[] = [
{
id: "memory",
header: "Memory",
size: 600,
cell: ({ row: { original: entry } }) => (
<div className="min-w-48 space-y-1 py-1">
<Button
variant="link"
className="h-auto justify-start whitespace-normal p-0 text-left font-medium text-foreground"
aria-label={`Details for ${entry.title}`}
onClick={() => setSelectedId(entry.memory_id)}
>
{entry.title}
</Button>
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{entry.content}</p>
</div>
),
},
{
id: "contributor",
header: "Contributed by",
size: 180,
cell: ({ row }) => <span className="break-words text-sm">{contributor(row.original)}</span>,
},
{
id: "saved",
header: "Updated",
size: 130,
cell: ({ row }) => (
<time dateTime={row.original.updated_at} className="whitespace-nowrap text-xs text-muted-foreground">
{new Date(row.original.updated_at).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
<span className="block">
{new Date(row.original.updated_at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
</span>
</time>
),
},
{
id: "details",
header: () => <span className="sr-only">Details</span>,
size: 36,
cell: () => <ChevronRight className="size-4 text-muted-foreground" aria-hidden="true" />,
},
];
return (
<>
<DataTable
data={entries}
columns={columns}
getRowId={(entry) => entry.memory_id}
onRowClick={(entry) => setSelectedId(entry.memory_id)}
isLoading={loading}
loadingMessage="Loading memories"
noDataMessage={empty}
sortingMode="none"
paginationMode="none"
filterMode="none"
toolbar={() => (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative w-full sm:w-64">
<Search className="pointer-events-none absolute left-2.5 top-2 size-4 text-muted-foreground" />
<Input
aria-label="Search memories"
placeholder="Search memories"
className="h-8 pl-8"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
/>
</div>
<span className="text-xs text-muted-foreground">Newest first</span>
</div>
)}
paginationSlot={() => footer}
/>
<Sheet open={!!selected} onOpenChange={(open) => !open && setSelectedId(null)}>
<SheetContent className="overflow-y-auto sm:max-w-xl">
<SheetHeader className="pr-12">
<SheetTitle>{selected?.title}</SheetTitle>
</SheetHeader>
{selected && (
<div className="space-y-6 px-4 pb-6">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{selected.content}</p>
<p className="text-xs text-muted-foreground">Contributed by {contributor(selected)}</p>
<Collapsible>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" className="gap-1" />}>
<ChevronDown className="size-3" /> Details
</CollapsibleTrigger>
<CollapsibleContent>
<dl className="mt-3 grid gap-x-5 gap-y-3 border-t pt-4 text-xs sm:grid-cols-[auto_1fr]">
{memoryDetails(selected).map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words">{value}</dd>
</div>
))}
</dl>
</CollapsibleContent>
</Collapsible>
{!readOnly && (
<div className="flex gap-2 border-t pt-4">
<Button variant="outline" size="sm" disabled={busy || !canEdit} onClick={() => onEdit(selected)}>
Edit memory
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onDelete(selected)}>
Delete memory
</Button>
</div>
)}
</div>
)}
</SheetContent>
</Sheet>
</>
);
}

View file

@ -55,17 +55,22 @@ export function MemoryPreference({
});
const canToggle = status.activation === "opt_in" && !!status.scope;
return (
<div className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
<Label htmlFor="memory-enabled" className="font-medium">
Memory <span aria-hidden="true">{status.active ? "on" : "off"}</span>
</Label>
<Switch
id="memory-enabled"
aria-label="Memory"
checked={status.active}
disabled={readOnly || !canToggle || save.isPending}
onCheckedChange={(enabled) => save.mutate(enabled)}
/>
<div className="space-y-1.5">
<div className="flex items-center justify-end gap-3">
<Label htmlFor="memory-enabled" className="font-medium">
Memory <span aria-hidden="true">{status.active ? "on" : "off"}</span>
</Label>
<Switch
id="memory-enabled"
aria-label="Memory"
checked={status.active}
disabled={readOnly || !canToggle || save.isPending}
onCheckedChange={(enabled) => save.mutate(enabled)}
/>
</div>
<p className="text-right text-xs text-muted-foreground">
{status.user_id ? "Applies to this user's keys" : "Applies to this unlinked key"}
</p>
{save.isPending && (
<span role="status" className="sr-only">
Updating memory
@ -85,7 +90,7 @@ export function MemoryPolicies({
target_type: proxyAdmin ? "gateway" : "team",
target_id: proxyAdmin ? "*" : "",
activation: "opt_in",
scope: "key",
scope: proxyAdmin ? "user" : "key",
};
const [policy, setPolicy] = useState<PolicyInput>(initialPolicy);
const [offset, setOffset] = useState(0);
@ -94,7 +99,7 @@ export function MemoryPolicies({
...policy,
target_type: target,
target_id: target === "gateway" ? "*" : "",
scope: "key",
scope: proxyAdmin ? "user" : "key",
};
setPolicy(selection);
setOffset(0);

View file

@ -16,6 +16,7 @@ let activation = "opt_in";
let available = true;
let paginated = false;
let failPreference = false;
let sameUser = false;
const entry = {
memory_id: "entry-1",
key: "demo",
@ -30,6 +31,7 @@ const entry = {
created_at: "2026-09-12T00:00:00Z",
updated_at: "2026-09-12T00:00:00Z",
actor: "u1",
actor_name: "Alex Rivera",
};
const session = (user_role: string) => {
const payload = { key: "sk-test", user_id: "u1", user_role, exp: Math.floor(Date.now() / 1000) + 3600 };
@ -45,6 +47,7 @@ beforeEach(async () => {
available = true;
paginated = false;
failPreference = false;
sameUser = false;
vi.clearAllMocks();
fetchMock.mockImplementation(async (input, init) => {
const request =
@ -77,11 +80,13 @@ beforeEach(async () => {
active: available && (activation === "automatic" || enabled),
opted_in: enabled,
activation,
scope: available ? "key" : null,
scope: available ? "user" : null,
user_id: keyId === "b".repeat(64) && !sameUser ? "u2" : "u1",
user_name: keyId === "b".repeat(64) && !sameUser ? "Jamie Davis" : "Alex Rivera",
};
if (path === "/v2/memory/entries") {
if (request.method === "POST") return entry;
if (keyId === "b".repeat(64))
if (keyId === "b".repeat(64) && !sameUser)
return [{ ...entry, memory_id: "other", title: "Other key memory", content: "Another project" }];
if (paginated) {
const offset = new URL(request.url).searchParams.get("before_memory_id") === "entry-19" ? 20 : 0;
@ -190,6 +195,20 @@ describe("Memory dashboard", () => {
expect(screen.queryByRole("button", { name: "Save memory policy" })).not.toBeInTheDocument();
});
it("keeps the V1 management view available without loading it into automatic memory", async () => {
session("proxy_admin");
const user = userEvent.setup();
renderWithProviders(<Memory />);
expect(await screen.findByRole("switch", { name: "Memory" })).not.toBeChecked();
expect(calls.filter(({ path }) => path === "/v1/memory")).toEqual([]);
await user.click(screen.getByRole("tab", { name: "Memory API (V1)" }));
expect(await screen.findByRole("button", { name: "New memory" })).toBeVisible();
await waitFor(() => expect(calls.some(({ path }) => path === "/v1/memory")).toBe(true));
expect(screen.queryByRole("switch", { name: "Memory" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Automatic memory" }));
expect(await screen.findByRole("switch", { name: "Memory" })).not.toBeChecked();
});
it("allows viewers to inspect memory while disabling preference writes", async () => {
session("internal_user_viewer");
renderWithProviders(<Memory />);
@ -197,20 +216,49 @@ describe("Memory dashboard", () => {
expect(await screen.findByText("Use port 8123")).toBeVisible();
});
it("appends older memories and resets the feed when switching keys", async () => {
it("appends older memories and resets the table when switching contexts", async () => {
session("proxy_admin");
paginated = true;
const user = userEvent.setup();
renderWithProviders(<Memory />);
const feed = await screen.findByRole("list", { name: "Saved memories" });
await waitFor(() => expect(within(feed).getAllByRole("listitem")).toHaveLength(20));
const table = await screen.findByRole("table");
await waitFor(() =>
expect(within(table).getAllByRole("button", { name: /^Details for Memory \d+$/ })).toHaveLength(20),
);
await user.click(screen.getByRole("button", { name: "Load more memories" }));
await waitFor(() => expect(within(feed).getAllByRole("listitem")).toHaveLength(21));
expect(screen.getByRole("heading", { name: "Memory 0" })).toBeVisible();
await user.click(screen.getByLabelText("Virtual key"));
await waitFor(() =>
expect(within(table).getAllByRole("button", { name: /^Details for Memory \d+$/ })).toHaveLength(21),
);
expect(screen.getByRole("button", { name: "Details for Memory 0" })).toBeVisible();
await user.click(screen.getByLabelText("Key context"));
await user.click(await screen.findByRole("option", { name: "Other key" }));
expect(await screen.findByText("Another project")).toBeVisible();
expect(screen.queryByRole("heading", { name: "Memory 0" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Details for Memory 0" })).not.toBeInTheDocument();
});
it("shows contributors in the readable table and keeps user activation across that user's keys", async () => {
session("internal_user");
sameUser = true;
const user = userEvent.setup();
renderWithProviders(<Memory />);
const table = await screen.findByRole("table");
expect(within(table).getByRole("columnheader", { name: "Contributed by" })).toBeVisible();
const row = await within(table).findByRole("row", { name: /Demo port/ });
expect(within(row).getByText("Use port 8123")).toBeVisible();
expect(within(row).getByText("Alex Rivera")).toBeVisible();
expect(screen.queryByText("Fixture recommendation")).not.toBeInTheDocument();
expect(screen.getByText("Applies to this user's keys")).toBeVisible();
await user.click(screen.getByRole("switch", { name: "Memory" }));
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).toBeChecked());
await user.click(screen.getByLabelText("Key context"));
await user.click(await screen.findByRole("option", { name: "Other key" }));
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).toBeChecked());
expect(await screen.findByRole("button", { name: "Details for Demo port" })).toBeVisible();
await user.click(screen.getByRole("switch", { name: "Memory" }));
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).not.toBeChecked());
await user.click(screen.getByLabelText("Key context"));
await user.click(await screen.findByRole("option", { name: "QA key" }));
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).not.toBeChecked());
});
it("shows administrator-managed memory as on even when the preference is off", async () => {
@ -231,7 +279,7 @@ describe("Memory dashboard", () => {
const toggle = await screen.findByRole("switch", { name: "Memory" });
expect(toggle).not.toBeChecked();
expect(toggle).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("Memory is off. Your administrator can make it available for this key.")).toBeVisible();
expect(screen.getByText("Memory is off. Your administrator can make it available.")).toBeVisible();
});
it("keeps the actual state off when saving a preference fails", async () => {

View file

@ -13,9 +13,11 @@ import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { MemoryPolicies } from "./_components/MemorySettings";
import { AutomaticMemoryEntries } from "./_components/AutomaticMemoryEntries";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function Memory() {
const [advanced, setAdvanced] = useState(false);
const [view, setView] = useState("v2");
const { accessToken, userRole, userId, isViewOnly } = useAuthorized();
const canViewMemory = useCan("viewMemory");
const teams = useTeams();
@ -29,24 +31,38 @@ export default function Memory() {
}
return (
<div className="space-y-8 px-8 py-8">
{userId && <AutomaticMemoryEntries key={userId} userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />}
{canManage && userId && (
<Collapsible open={advanced} onOpenChange={setAdvanced} className="border-t pt-4">
<CollapsibleTrigger render={<Button variant="ghost" className="gap-2 text-muted-foreground" />}>
<ChevronDown className={`size-4 transition-transform ${advanced ? "rotate-180" : ""}`} />
Advanced settings
</CollapsibleTrigger>
<CollapsibleContent>
{advanced && (
<div className="space-y-6 pt-4">
<MemoryPolicies userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />
{proxyAdmin && <MemoryView accessToken={accessToken} userID={userId} userRole={userRole} />}
</div>
)}
</CollapsibleContent>
</Collapsible>
<Tabs value={view} onValueChange={(value) => setView(value === "v1" ? "v1" : "v2")} className="gap-6 px-8 py-8">
{proxyAdmin && (
<TabsList variant="line" aria-label="Memory version">
<TabsTrigger value="v2">Automatic memory</TabsTrigger>
<TabsTrigger value="v1">Memory API (V1)</TabsTrigger>
</TabsList>
)}
</div>
<TabsContent value="v2" className="space-y-8">
{userId && (
<AutomaticMemoryEntries key={userId} userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />
)}
{canManage && userId && (
<Collapsible open={advanced} onOpenChange={setAdvanced} className="border-t pt-4">
<CollapsibleTrigger render={<Button variant="ghost" className="gap-2 text-muted-foreground" />}>
<ChevronDown className={`size-4 transition-transform ${advanced ? "rotate-180" : ""}`} />
Advanced settings
</CollapsibleTrigger>
<CollapsibleContent>
{advanced && (
<div className="space-y-6 pt-4">
<MemoryPolicies userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
</TabsContent>
{proxyAdmin && (
<TabsContent value="v1">
{view === "v1" && <MemoryView accessToken={accessToken} userID={userId} userRole={userRole} />}
</TabsContent>
)}
</Tabs>
);
}

View file

@ -31987,6 +31987,8 @@ export interface components {
MemoryEntry: {
/** Actor */
actor?: string | null;
/** Actor Name */
actor_name?: string | null;
/**
* Certainty
* @default observed
@ -32050,7 +32052,7 @@ export interface components {
policy_id: string;
/**
* Scope
* @default key
* @default user
* @enum {string}
*/
scope: "key" | "user" | "team" | "project" | "organization";
@ -32078,7 +32080,7 @@ export interface components {
activation: "disabled" | "opt_in" | "automatic";
/**
* Scope
* @default key
* @default user
* @enum {string}
*/
scope: "key" | "user" | "team" | "project" | "organization";
@ -32110,6 +32112,10 @@ export interface components {
policy_id: string | null;
/** Scope */
scope: ("key" | "user" | "team" | "project" | "organization") | null;
/** User Id */
user_id?: string | null;
/** User Name */
user_name?: string | null;
};
/** MemoryUpdateRequest */
MemoryUpdateRequest: {