feat(memory): show a recent memory feed and key-aware opt-in toggle

This commit is contained in:
moe-berri 2026-09-12 21:00:50 -07:00
parent de6178a3c5
commit 3bba16c6a6
10 changed files with 620 additions and 224 deletions

View file

@ -2,8 +2,8 @@
Run this branch as an isolated forwarding gateway. Colleagues keep their existing
upstream LiteLLM key and model name and change only their gateway base URL. They
need no plugin or client-side memory tools. Choosing the pilot URL opts them into
the pilot; returning to the original URL stops using and collecting pilot memory.
need no plugin or client-side memory tools. Memory is off by default. Each person enables it explicitly for their key;
returning to the original URL stops using and collecting pilot memory.
Every model call uses that caller's upstream key. The upstream
gateway continues to enforce its model permissions, budgets, rate limits, and
@ -49,7 +49,7 @@ pilot must not be connected to an older gateway's production database.
| `PORT` | `4000` |
5. After deployment, open `/ui/memory`, sign in as `admin` using the pilot's master
key, and save a policy for **Whole gateway**, **Enabled automatically**,
key, expand **Advanced settings**, and save a policy for **Whole gateway**, **Users choose whether to opt in**,
**Private to each virtual key**. This policy persists across restarts. Memory
stays disabled until an administrator enables it.
@ -60,18 +60,23 @@ curl --fail-with-body "$PILOT_URL/v2/memory/policies" \
-H "Authorization: Bearer $PILOT_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-X PUT \
-d '{"target_type":"gateway","target_id":"*","activation":"automatic","scope":"key"}'
-d '{"target_type":"gateway","target_id":"*","activation":"opt_in","scope":"key"}'
```
Administrators can instead require opt-in, disable a particular registered key,
Administrators can instead enable memory automatically, disable a particular registered key,
or disable the whole gateway. Under an opt-in policy, callers set their preference
with `PUT /v2/memory/preference` and `{"enabled":true}` using their own key.
In a normal gateway, signed-in users can also select their key in Memory and turn
on the switch. Pilot administrators can do this for a registered key. The switch
shows the actual state; turning it off stops saving and recall but keeps saved
memories visible. Memories appear newest first, with optional details.
## Try it
Set an OpenAI-compatible client's base URL to `https://YOUR-SERVICE.onrender.com/v1`.
For Claude Code, set `ANTHROPIC_BASE_URL` to `https://YOUR-SERVICE.onrender.com`.
Retain the same gateway key and model setting.
Retain the same gateway key and model setting. First opt in using the preference
API above, or ask the pilot administrator to turn on memory for your key.
In one conversation, say “Remember that my demo project is Cobalt Heron and its
staging port is 8347.” In a **new conversation**, ask “What is my demo project and

View file

@ -167,8 +167,14 @@ async def delete_policy(policy_id: str, auth: UserAPIKeyAuth = _AUTH) -> Respons
@router.get("/preference", response_model=MemoryPreference)
async def get_preference(auth: UserAPIKeyAuth = _AUTH) -> MemoryPreference:
subject: Final = MemoryIdentity.from_auth(auth).preference_subject
async def get_preference(
auth: UserAPIKeyAuth = _AUTH,
key_id: Annotated[str | None, Query(pattern=r"^[a-f0-9]{64}$")] = None,
) -> MemoryPreference:
identity: Final = (
(await access_for_key(auth, key_id)).identity if key_id is not None else MemoryIdentity.from_auth(auth)
)
subject: Final = identity.preference_subject
row: Final = await MemoryPreferenceRepository(memory_primary_client(require_memory_prisma())).table.find_unique(
where={ # mutable-ok: Prisma serializes these as native JSON containers.
"subject": subject
@ -178,8 +184,14 @@ async def get_preference(auth: UserAPIKeyAuth = _AUTH) -> MemoryPreference:
@router.put("/preference", response_model=MemoryPreference)
async def set_preference(preference: MemoryPreference, auth: UserAPIKeyAuth = _AUTH) -> MemoryPreference:
identity: Final = MemoryIdentity.from_auth(auth)
async def set_preference(
preference: MemoryPreference,
auth: UserAPIKeyAuth = _AUTH,
key_id: Annotated[str | None, Query(pattern=r"^[a-f0-9]{64}$")] = None,
) -> MemoryPreference:
identity: Final = (
(await access_for_key(auth, key_id)).identity if key_id is not None else MemoryIdentity.from_auth(auth)
)
if identity.read_only:
raise HTTPException(status_code=403, detail="Read-only users cannot change memory preferences")
subject: Final = identity.preference_subject
@ -251,7 +263,7 @@ async def list_entries(
prisma: Final = memory_primary_client(require_memory_prisma())
access: Final = await access_for_key(auth, key_id)
return await MemoryStore(prisma, access).search(
MemorySearch(query=query, limit=limit, offset=offset), require_active=False
MemorySearch(query=query, limit=limit, offset=offset), require_active=False, recent_first=True
)

View file

@ -67,10 +67,18 @@ class MemoryStore:
raise HTTPException(status_code=403, detail="Memory is not available under the current policy")
return current.namespace
async def search(self, search: MemorySearch, *, require_active: bool = True) -> tuple[MemoryEntry, ...]:
async def search(
self, search: MemorySearch, *, require_active: bool = True, recent_first: bool = False
) -> tuple[MemoryEntry, ...]:
entries: Final = await self.entries(require_active=require_active)
ranked: Final = await asyncio.to_thread(fuzzy_memories, search.query, entries)
return tuple(entry for entry, _, _ in ranked[search.offset : search.offset + search.limit])
matched_ids: Final = frozenset(entry.memory_id for entry, _, _ in ranked)
ordered: Final = (
tuple(entry for entry in entries if entry.memory_id in matched_ids)
if recent_first
else tuple(entry for entry, _, _ in ranked)
)
return ordered[search.offset : search.offset + search.limit]
async def entries(self, *, require_active: bool = True) -> tuple[MemoryEntry, ...]:
namespace: Final = await self.authorize_namespace(require_active=require_active)

View file

@ -270,3 +270,87 @@ async def test_entry_endpoints_apply_namespace_and_delete_after_disable(database
with pytest.raises(HTTPException) as missing:
await management.delete_entry("entry", None, auth())
assert missing.value.status_code == 404
@pytest.mark.parametrize("key_owner", ["owner", None])
def test_admin_selected_key_preference_controls_actual_request_activation(
database: MagicMock, key_owner: str | None
) -> None:
database.db.litellm_verificationtoken.find_unique.return_value["user_id"] = key_owner
database.db.litellm_memorypolicy.find_many.return_value = [policy(activation="opt_in")]
table = database.db.litellm_memorypreference
app = FastAPI()
app.include_router(management.router)
app.dependency_overrides[user_api_key_auth] = lambda: auth("admin", LitellmUserRoles.PROXY_ADMIN)
params = {"key_id": "a" * 64}
subject = memory_digest("user", key_owner) if key_owner else memory_digest("key", "a" * 64)
with TestClient(app) as client:
assert client.get("/v2/memory/status", params=params).json()["active"] is False
assert client.put("/v2/memory/preference", params=params, json={"enabled": True}).status_code == 200
assert table.upsert.call_args.kwargs["where"] == {"subject": subject}
table.find_unique.return_value = SimpleNamespace(enabled=True)
assert client.get("/v2/memory/status", params=params).json()["active"] is True
assert client.get("/v2/memory/preference", params=params).json() == {"enabled": True}
assert client.put("/v2/memory/preference", params=params, json={"enabled": False}).status_code == 200
assert table.delete_many.call_args.kwargs["where"] == {"subject": subject}
table.find_unique.return_value = None
assert client.get("/v2/memory/status", params=params).json()["active"] is False
@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY])
def test_preference_selection_cannot_bypass_key_ownership_or_readonly(
database: MagicMock, role: LitellmUserRoles
) -> None:
app = FastAPI()
app.include_router(management.router)
app.dependency_overrides[user_api_key_auth] = lambda: auth(role=role).model_copy(
update={"token": "session", "team_id": UI_TEAM_ID}
)
with TestClient(app) as client:
own = client.put("/v2/memory/preference", params={"key_id": "a" * 64}, json={"enabled": True})
assert own.status_code == (403 if role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY else 200)
database.db.litellm_memorypreference.upsert.reset_mock()
database.db.litellm_verificationtoken.find_unique.return_value["user_id"] = "another-owner"
assert client.get("/v2/memory/preference", params={"key_id": "a" * 64}).status_code == 403
assert (
client.put("/v2/memory/preference", params={"key_id": "a" * 64}, json={"enabled": True}).status_code == 403
)
assert (
client.put("/v2/memory/preference", params={"key_id": "invalid"}, json={"enabled": True}).status_code == 422
)
database.db.litellm_memorypreference.upsert.assert_not_awaited()
@pytest.mark.asyncio
async def test_dashboard_search_keeps_recency_before_pagination_while_agent_search_ranks_relevance(
database: MagicMock,
) -> None:
from litellm.proxy.memory.store import MemoryStore
from litellm.types.memory_v2 import MemorySearch
database.db.litellm_memorypolicy.find_many.return_value = [policy()]
access = await management.access_for_key(auth(), None)
now = datetime(2026, 9, 12, tzinfo=timezone.utc)
database.db.litellm_memorytable.find_many.return_value = [
SimpleNamespace(
memory_id=memory_id,
key=f"memory-v2:{access.namespace}:{memory_id}",
namespace=access.namespace,
value=content,
metadata={"title": title, "evidence": "A user instruction"},
updated_at=now.replace(day=day),
created_at=now.replace(day=day),
created_by="owner",
)
for memory_id, title, content, day in [
("newer", "Demo configuration", "The demo uses port 8123", 12),
("unrelated", "Theme", "Use dark mode", 11),
("older", "port", "port", 10),
]
]
assert [entry.memory_id for entry in await management.list_entries("port", 1, 0, None, auth())] == ["newer"]
assert [entry.memory_id for entry in await management.list_entries("port", 1, 1, None, auth())] == ["older"]
assert [entry.memory_id for entry in await MemoryStore(database, access).search(MemorySearch(query="port"))] == [
"older",
"newer",
]

View file

@ -1,27 +1,109 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Brain, ChevronDown, Search } 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";
import { fetchClient } from "@/lib/http/api";
import type { components } from "@/lib/http/schema";
import { toast } from "@/lib/toast";
import { MemoryPreference } from "./MemorySettings";
import { MemoryKeyPicker } from "./MemoryTargetPicker";
type Entry = components["schemas"]["MemoryEntry"];
type Capture = components["schemas"]["MemoryCapture"];
type Status = components["schemas"]["MemoryStatus"];
export function AutomaticMemoryEntries({ userId, readOnly }: Readonly<{ userId: string; readOnly: boolean }>) {
function memoryDescription(status?: Status) {
if (!status) return "What your assistants remember across conversations.";
if (status.activation === "automatic") return "Memory is on for this key. Your administrator manages this setting.";
if (status.activation === "disabled" || !status.scope)
return "Memory is off. Your administrator can make it available for this key.";
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.";
}
function emptyDescription(query: string, active: boolean) {
if (query) return "Try a different search.";
if (active) return "Use your assistant as usual. What it remembers will appear here.";
return "Turn on memory, then use your assistant as usual. Your memories will appear here.";
}
function loadMoreLabel(fetching: boolean, failed: boolean) {
if (fetching) return "Loading…";
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) {
const [selection, setSelection] = useState<string>();
const keys = useKeys(1, 1, { userID: proxyAdmin ? undefined : userId, sortBy: "created_at", sortOrder: "desc" });
const keyId = selection ?? keys.data?.keys[0]?.token ?? "";
return (
<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
</Label>
<MemoryKeyPicker
inputId="memory-entry-key"
value={keyId}
disabled={keys.isPending}
userId={proxyAdmin ? undefined : userId}
onChange={setSelection}
/>
{keys.error && (
<p role="alert" className="text-sm text-destructive">
{keys.error.message}
</p>
)}
{keys.isSuccess && keys.data.total_count === 0 && (
<p className="text-sm text-muted-foreground">Create a virtual key to start using memory.</p>
)}
</div>
</MemoryDashboard>
);
}
function MemoryDashboard({
userId,
keyId,
readOnly,
children,
}: Readonly<{
userId: string;
keyId: string;
readOnly: boolean;
children: React.ReactNode;
}>) {
const cache = useQueryClient();
const [keyId, setKeyId] = useState("");
const [query, setQuery] = useState("");
const [offset, setOffset] = useState(0);
const [editing, setEditing] = useState<Entry | null>(null);
const [deleting, setDeleting] = useState<Entry | null>(null);
const status = useQuery({
@ -30,44 +112,47 @@ export function AutomaticMemoryEntries({ userId, readOnly }: Readonly<{ userId:
queryFn: async ({ signal }) =>
(await fetchClient.GET("/v2/memory/status", { params: { query: { key_id: keyId } }, signal })).data,
});
const entries = useQuery({
queryKey: ["memoryEntries", userId, keyId, query, offset],
const entriesOptions = {
queryKey: ["memoryEntries", userId, keyId, query],
enabled: !!keyId && !!status.data?.scope,
queryFn: async ({ signal }) =>
initialPageParam: 0,
queryFn: async ({ signal, pageParam }: { signal: AbortSignal; pageParam: number }) =>
(
await fetchClient.GET("/v2/memory/entries", {
params: { query: { key_id: keyId, query, offset, limit: 20 } },
params: { query: { key_id: keyId, query, offset: pageParam, limit: 20 } },
signal,
})
).data,
});
).data ?? [],
getNextPageParam: (lastPage: Entry[], _pages: Entry[][], offset: number) =>
lastPage.length === 20 ? offset + 20 : undefined,
};
const entries = useInfiniteQuery(entriesOptions);
const save = useMutation({
mutationFn: async ({ key, body }: { key: string; body: Capture }) =>
fetchClient.POST("/v2/memory/entries", { params: { query: { key_id: key } }, body }),
onSuccess: (_, variables) => {
mutationFn: async (body: Capture) =>
fetchClient.POST("/v2/memory/entries", { params: { query: { key_id: keyId } }, body }),
onSuccess: () => {
setEditing(null);
toast.success("Memory updated");
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, variables.key] });
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, keyId] });
},
onError: (error: Error) => toast.error(error.message),
});
const remove = useMutation({
mutationFn: async ({ key, memory_id }: { key: string; memory_id: string }) =>
fetchClient.DELETE("/v2/memory/entries/{memory_id}", { params: { path: { memory_id }, query: { key_id: key } } }),
onSuccess: (_, variables) => {
mutationFn: async (memory_id: string) =>
fetchClient.DELETE("/v2/memory/entries/{memory_id}", {
params: { path: { memory_id }, query: { key_id: keyId } },
}),
onSuccess: () => {
setDeleting(null);
toast.success("Memory deleted");
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, variables.key] });
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, keyId] });
},
onError: (error: Error) => toast.error(error.message),
});
const busy = save.isPending || remove.isPending;
const selectKey = (value: string) => {
setKeyId(value);
setOffset(0);
setEditing(null);
setDeleting(null);
};
const memories = Array.from(
new Map((entries.data?.pages ?? []).flat().map((entry) => [entry.memory_id, entry])).values(),
);
const saveCorrection = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editing) return;
@ -83,113 +168,198 @@ export function AutomaticMemoryEntries({ userId, readOnly }: Readonly<{ userId:
source: editing.source,
expected_revision: editing.updated_at,
};
save.mutate({ key: keyId, body });
save.mutate(body);
};
const description = memoryDescription(status.data);
return (
<section className="rounded-lg border p-5 space-y-4" aria-labelledby="automatic-entries-title">
<h2 id="automatic-entries-title" className="font-semibold">
Saved gateway memories
</h2>
<div className="max-w-lg space-y-2">
<Label htmlFor="memory-entry-key">Virtual key</Label>
<MemoryKeyPicker inputId="memory-entry-key" value={keyId} disabled={busy} onChange={selectKey} />
<section className="space-y-8" aria-labelledby="memory-title">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<h1 id="memory-title" className="text-[28px] font-semibold tracking-tight">
Memory
</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{status.data && !status.error && (
<MemoryPreference userId={userId} keyId={keyId} status={status.data} readOnly={readOnly} />
)}
{keyId && status.isPending && <Skeleton className="h-12 w-40" aria-label="Loading memory status" />}
</div>
{status.data && (
<p className="text-sm text-muted-foreground">
{status.data.active ? "Automatic memory is active" : "Automatic memory is off"}
{status.data.scope ? ` · ${status.data.scope} scope` : " · No applicable policy"}
</p>
)}
{(status.error || entries.error) && (
<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>
)}
</div>
{status.error && (
<p role="alert" className="text-sm text-destructive">
{status.error?.message || entries.error?.message}
Could not load memory status: {status.error.message}
</p>
)}
{status.data?.scope && (
<Input
aria-label="Search saved memories"
placeholder="Search memory text or keys"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setOffset(0);
}}
/>
)}
{entries.isFetching && <p role="status">Loading memories...</p>}
{entries.data?.length === 0 && <p className="text-sm text-muted-foreground">No memories match this search</p>}
<ul className="divide-y">
{(entries.data ?? []).map((entry) => (
<li key={entry.memory_id} className="space-y-2 py-4">
<h3 className="font-medium">{entry.title}</h3>
<p className="whitespace-pre-wrap text-sm">{entry.content}</p>
<p className="text-xs text-muted-foreground">Evidence: {entry.evidence}</p>
{!readOnly && (
<div className="flex gap-2">
<Button variant="outline" disabled={busy || !status.data?.active} onClick={() => setEditing(entry)}>
Edit memory
</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleting(entry)}>
Delete memory
</Button>
</div>
)}
</li>
))}
</ul>
{editing && (
<form className="space-y-3 rounded-md border p-4" onSubmit={saveCorrection}>
<Label htmlFor="memory-edit-content">Correct this memory</Label>
<Textarea
id="memory-edit-content"
value={editing.content}
required
maxLength={8000}
disabled={busy}
onChange={(event) => setEditing({ ...editing, content: event.target.value })}
/>
<Label htmlFor="memory-edit-evidence">Evidence</Label>
<Textarea
id="memory-edit-evidence"
value={editing.evidence}
required
maxLength={2000}
disabled={busy}
onChange={(event) => setEditing({ ...editing, evidence: event.target.value })}
/>
<div className="flex gap-2">
<Button type="submit" disabled={busy || !status.data?.active}>
Save correction
</Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => setEditing(null)}>
Cancel
</Button>
<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>
</form>
)}
{(offset > 0 || entries.data?.length === 20) && (
<div className="flex gap-2">
<Button
variant="outline"
disabled={offset === 0 || entries.isFetching}
onClick={() => setOffset(Math.max(0, offset - 20))}
>
Previous memories
</Button>
<Button
variant="outline"
disabled={entries.data?.length !== 20 || entries.isFetching}
onClick={() => setOffset(offset + 20)}
>
More memories
</Button>
{!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>
<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}`}
>
<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 && (
<p role="alert" className="text-sm text-destructive">
Could not load 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
open={!!editing}
onOpenChange={(open) => {
if (!open && !busy) setEditing(null);
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Edit memory</DialogTitle>
</DialogHeader>
{editing && (
<form className="space-y-4" onSubmit={saveCorrection}>
<div className="space-y-2">
<Label htmlFor="memory-edit-content">Correct this memory</Label>
<Textarea
id="memory-edit-content"
value={editing.content}
required
maxLength={8000}
disabled={busy}
rows={6}
onChange={(event) => setEditing({ ...editing, content: event.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="memory-edit-evidence">Evidence</Label>
<Textarea
id="memory-edit-evidence"
value={editing.evidence}
required
maxLength={2000}
disabled={busy}
onChange={(event) => setEditing({ ...editing, evidence: event.target.value })}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => setEditing(null)}>
Cancel
</Button>
<Button type="submit" disabled={busy || !status.data?.active}>
Save correction
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
<DeleteResourceModal
isOpen={deleting !== null}
isOpen={!!deleting}
onCancel={() => setDeleting(null)}
onOk={() => {
if (deleting) remove.mutate({ key: keyId, memory_id: deleting.memory_id });
if (deleting) remove.mutate(deleting.memory_id);
}}
title="Delete memory"
message={`Delete ${deleting?.title ?? "this memory"}?`}

View file

@ -36,48 +36,42 @@ const targetNames = {
} as const;
const selectClass = "h-9 w-full rounded-md border bg-background px-3 text-sm";
export function MemoryPreference({ userId, readOnly }: Readonly<{ userId: string; readOnly: boolean }>) {
export function MemoryPreference({
userId,
keyId,
status,
readOnly,
}: Readonly<{ userId: string; keyId: string; status: components["schemas"]["MemoryStatus"]; readOnly: boolean }>) {
const cache = useQueryClient();
const queryKey = ["memoryPreference", userId];
const preference = useQuery({
queryKey,
queryFn: async ({ signal }) => (await fetchClient.GET("/v2/memory/preference", { signal })).data,
});
const save = useMutation({
mutationFn: async (enabled: boolean) => fetchClient.PUT("/v2/memory/preference", { body: { enabled } }),
onSuccess: () =>
mutationFn: async (enabled: boolean) =>
fetchClient.PUT("/v2/memory/preference", { params: { query: { key_id: keyId } }, body: { enabled } }),
onSettled: () =>
Promise.all([
cache.invalidateQueries({ queryKey }),
cache.invalidateQueries({ queryKey: ["memoryStatus", userId] }),
cache.invalidateQueries({ queryKey: ["memoryEntries", userId] }),
]),
onError: (error: Error) => toast.error(error.message),
});
const unavailable = readOnly || preference.isPending || !!preference.error;
const canToggle = status.activation === "opt_in" && !!status.scope;
return (
<section className="rounded-lg border p-5 space-y-3" aria-labelledby="memory-preference-title">
<h2 id="memory-preference-title" className="font-semibold">
Your memory preference
</h2>
<p className="text-sm text-muted-foreground">
When your administrator offers opt-in memory, this setting applies to your virtual keys. Automatically enabled
policies apply regardless of this preference.
</p>
<div className="flex items-center gap-3">
<Switch
id="memory-opt-in"
checked={preference.data?.enabled ?? false}
disabled={unavailable || save.isPending}
onCheckedChange={(enabled) => save.mutate(enabled)}
/>
<Label htmlFor="memory-opt-in">Use memory when offered</Label>
</div>
{preference.error && (
<p role="alert" className="text-sm text-destructive">
{preference.error.message}
</p>
<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)}
/>
{save.isPending && (
<span role="status" className="sr-only">
Updating memory
</span>
)}
</section>
</div>
);
}
@ -156,11 +150,11 @@ export function MemoryPolicies({
<section className="rounded-lg border p-5 space-y-4" aria-labelledby="memory-policy-title">
<div>
<h2 id="memory-policy-title" className="font-semibold">
Automatic gateway memory
Memory policies
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Give existing clients memory search, reading, and saving through the gateway. No developer installation is
needed. Memory tools use the selected model and can add model calls, latency, and spend.
Choose who can use memory and how it is shared. Users opt in by default. Enabling memory can add model calls,
latency, and spend.
</p>
</div>
<form

View file

@ -65,9 +65,10 @@ export function MemoryKeyPicker({
onChange,
disabled,
inputId = "memory-target",
}: PickerProps & Readonly<{ inputId?: string }>) {
userId,
}: PickerProps & Readonly<{ inputId?: string; userId?: string }>) {
const [search, setSearch] = useState("");
const query = useInfiniteKeys(25, { search });
const query = useInfiniteKeys(25, { search, userID: userId });
return (
<PaginatedSearchSelect
inputId={inputId}

View file

@ -1,15 +1,20 @@
import { screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import Memory from "./page";
import { Toaster } from "@/components/ui/sonner";
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
vi.unmock("@/lib/toast");
const fetchMock = vi.fn<typeof fetch>();
const calls: { path: string; method: string; body: unknown }[] = [];
const calls: { path: string; method: string; body: unknown; keyId: string | null }[] = [];
let enabled = false;
let activation = "opt_in";
let paginated = false;
let failPreference = false;
const entry = {
memory_id: "entry-1",
key: "demo",
@ -34,23 +39,55 @@ beforeEach(async () => {
await testQueryClient.cancelQueries();
testQueryClient.clear();
calls.length = 0;
enabled = false;
activation = "opt_in";
paginated = false;
failPreference = false;
vi.clearAllMocks();
fetchMock.mockImplementation(async (input, init) => {
const request =
input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init);
const path = new URL(request.url).pathname;
const text = request.method === "GET" ? "" : await request.text();
calls.push({ path, method: request.method, body: text ? JSON.parse(text) : undefined });
const keyId = new URL(request.url).searchParams.get("key_id");
const call = { path, method: request.method, body: text ? JSON.parse(text) : undefined, keyId };
calls.push(call);
if (path === "/v2/memory/preference" && request.method === "PUT" && failPreference) {
return new Response(JSON.stringify({ detail: "Preference unavailable" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
const response = () => {
if (path === "/v2/memory/preference") return { enabled: request.method === "PUT" };
if (path === "/v2/memory/preference") {
if (request.method === "PUT") enabled = JSON.parse(text).enabled;
return { enabled };
}
if (path === "/v2/memory/policies") return [];
if (path === "/v1/memory") return { memories: [], total: 0 };
if (path === "/v2/memory/status") return { active: true, scope: "key" };
if (path === "/v2/memory/entries") return request.method === "POST" ? entry : [entry];
if (path === "/v2/memory/status")
return { active: activation === "automatic" || enabled, opted_in: enabled, activation, scope: "key" };
if (path === "/v2/memory/entries") {
if (request.method === "POST") return entry;
if (keyId === "b".repeat(64))
return [{ ...entry, memory_id: "other", title: "Other key memory", content: "Another project" }];
if (paginated) {
const offset = Number(new URL(request.url).searchParams.get("offset"));
return Array.from({ length: offset ? 1 : 20 }, (_, i) => ({
...entry,
memory_id: `entry-${offset + i}`,
title: `Memory ${offset + i}`,
}));
}
return [entry];
}
if (path.includes("/key/list"))
return {
keys: [{ token: "a".repeat(64), key_alias: "QA key" }],
total_count: 1,
keys: [
{ token: "a".repeat(64), key_alias: "QA key" },
{ token: "b".repeat(64), key_alias: "Other key" },
],
total_count: 2,
current_page: 1,
total_pages: 1,
};
@ -63,51 +100,54 @@ beforeEach(async () => {
vi.stubGlobal("fetch", fetchMock);
});
describe("Gateway memory settings", () => {
describe("Memory dashboard", () => {
it("preserves observation attribution when a person corrects saved content", async () => {
session("internal_user");
enabled = true;
const user = userEvent.setup();
renderWithProviders(<Memory />);
await user.click(await screen.findByLabelText("Virtual key"));
await user.click(await screen.findByRole("option", { name: "QA key" }));
await user.click(await screen.findByRole("button", { name: "Details for Demo port" }));
await user.click(await screen.findByRole("button", { name: "Edit memory" }));
await user.clear(screen.getByLabelText("Correct this memory"));
await user.type(screen.getByLabelText("Correct this memory"), "Use port 8124");
fireEvent.change(screen.getByLabelText("Correct this memory"), { target: { value: "Use port 8124" } });
await user.click(screen.getByRole("button", { name: "Save correction" }));
await waitFor(() =>
expect(calls).toContainEqual({
path: "/v2/memory/entries",
method: "POST",
body: {
key: entry.key,
title: entry.title,
content: "Use port 8124",
evidence: entry.evidence,
when_to_use: entry.when_to_use,
scope: entry.scope,
kind: entry.kind,
certainty: entry.certainty,
source: entry.source,
expected_revision: entry.updated_at,
},
}),
);
const expectedCall = {
path: "/v2/memory/entries",
keyId: "a".repeat(64),
method: "POST",
body: {
key: entry.key,
title: entry.title,
content: "Use port 8124",
evidence: entry.evidence,
when_to_use: entry.when_to_use,
scope: entry.scope,
kind: entry.kind,
certainty: entry.certainty,
source: entry.source,
expected_revision: entry.updated_at,
},
};
await waitFor(() => expect(calls).toContainEqual(expectedCall));
});
it("lets an administrator choose automatic activation and a sharing scope", async () => {
session("proxy_admin");
const user = userEvent.setup();
renderWithProviders(<Memory />);
await user.selectOptions(await screen.findByLabelText("Activation"), "automatic");
expect(screen.queryByLabelText("Activation")).not.toBeInTheDocument();
expect(calls.filter(({ path }) => path === "/v1/memory" || path === "/v2/memory/policies")).toEqual([]);
await user.click(screen.getByRole("button", { name: "Advanced settings" }));
expect(await screen.findByLabelText("Activation")).toHaveValue("opt_in");
await user.selectOptions(screen.getByLabelText("Activation"), "automatic");
await user.selectOptions(screen.getByLabelText("Who shares the memories"), "team");
await user.click(screen.getByRole("button", { name: "Save memory policy" }));
await waitFor(() =>
expect(calls).toContainEqual({
path: "/v2/memory/policies",
method: "PUT",
body: { target_type: "gateway", target_id: "*", activation: "automatic", scope: "team" },
}),
);
const expectedCall = {
path: "/v2/memory/policies",
keyId: null,
method: "PUT",
body: { target_type: "gateway", target_id: "*", activation: "automatic", scope: "team" },
};
await waitFor(() => expect(calls).toContainEqual(expectedCall));
await waitFor(() => expect(screen.getByRole("button", { name: "Save memory policy" })).toBeEnabled());
expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
});
@ -116,12 +156,24 @@ describe("Gateway memory settings", () => {
session("internal_user");
const user = userEvent.setup();
renderWithProviders(<Memory />);
const preference = await screen.findByRole("switch", { name: "Use memory when offered" });
const preference = await screen.findByRole("switch", { name: "Memory" });
await waitFor(() => expect(preference).toBeEnabled());
expect(preference).not.toBeChecked();
expect(await screen.findByText("Use port 8123")).toBeVisible();
expect(screen.queryByText("Fixture recommendation")).not.toBeInTheDocument();
await user.click(preference);
await waitFor(() =>
expect(calls).toContainEqual({ path: "/v2/memory/preference", method: "PUT", body: { enabled: true } }),
);
const expectedCall = {
path: "/v2/memory/preference",
method: "PUT",
body: { enabled: true },
keyId: "a".repeat(64),
};
await waitFor(() => expect(calls).toContainEqual(expectedCall));
await waitFor(() => expect(preference).toBeChecked());
await waitFor(() => expect(preference).not.toHaveAttribute("aria-disabled", "true"));
await user.click(preference);
await waitFor(() => expect(preference).not.toBeChecked());
expect(screen.getByText("Use port 8123")).toBeVisible();
expect(calls.filter(({ path }) => path === "/v1/memory" || path === "/v2/memory/policies")).toEqual([]);
expect(screen.queryByRole("button", { name: "Save memory policy" })).not.toBeInTheDocument();
});
@ -129,10 +181,50 @@ describe("Gateway memory settings", () => {
it("allows viewers to inspect memory while disabling preference writes", async () => {
session("internal_user_viewer");
renderWithProviders(<Memory />);
expect(await screen.findByRole("switch", { name: "Use memory when offered" })).toHaveAttribute(
"aria-disabled",
"true",
expect(await screen.findByRole("switch", { name: "Memory" })).toHaveAttribute("aria-disabled", "true");
expect(await screen.findByText("Use port 8123")).toBeVisible();
});
it("appends older memories and resets the feed when switching keys", 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));
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 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();
});
it("shows administrator-managed memory as on even when the preference is off", async () => {
session("internal_user");
activation = "automatic";
renderWithProviders(<Memory />);
const toggle = await screen.findByRole("switch", { name: "Memory" });
expect(toggle).toBeChecked();
expect(toggle).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText(/Your administrator manages this setting/)).toBeVisible();
});
it("keeps the actual state off when saving a preference fails", async () => {
session("internal_user");
failPreference = true;
const user = userEvent.setup();
renderWithProviders(
<>
<Memory />
<Toaster />
</>,
);
expect(screen.getByRole("heading", { name: "Saved gateway memories" })).toBeInTheDocument();
const toggle = await screen.findByRole("switch", { name: "Memory" });
await user.click(toggle);
expect(await screen.findByText("Preference unavailable")).toBeVisible();
await waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled", "true"));
expect(toggle).not.toBeChecked();
});
});

View file

@ -7,10 +7,15 @@ import useCan from "@/app/(dashboard)/hooks/useCan";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { MemoryPolicies, MemoryPreference } from "./_components/MemorySettings";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { MemoryPolicies } from "./_components/MemorySettings";
import { AutomaticMemoryEntries } from "./_components/AutomaticMemoryEntries";
export default function Memory() {
const [advanced, setAdvanced] = useState(false);
const { accessToken, userRole, userId, isViewOnly } = useAuthorized();
const canViewMemory = useCan("viewMemory");
const teams = useTeams();
@ -24,12 +29,24 @@ export default function Memory() {
}
return (
<div className="space-y-6 p-6">
<h1 className="text-2xl font-semibold">Memory</h1>
{userId && <MemoryPreference userId={userId} readOnly={isViewOnly} />}
{userId && <AutomaticMemoryEntries userId={userId} readOnly={isViewOnly} />}
{canManage && userId && <MemoryPolicies userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />}
{proxyAdmin && <MemoryView accessToken={accessToken} userID={userId} userRole={userRole} />}
<div className="space-y-8 px-8 py-8">
{userId && <AutomaticMemoryEntries 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>
)}
</div>
);
}

View file

@ -67491,7 +67491,9 @@ export interface operations {
};
get_preference_v2_memory_preference_get: {
parameters: {
query?: never;
query?: {
key_id?: string | null;
};
header?: never;
path?: never;
cookie?: never;
@ -67507,11 +67509,22 @@ export interface operations {
"application/json": components["schemas"]["MemoryPreference"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
set_preference_v2_memory_preference_put: {
parameters: {
query?: never;
query?: {
key_id?: string | null;
};
header?: never;
path?: never;
cookie?: never;