mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
feat(memory): show a recent memory feed and key-aware opt-in toggle
This commit is contained in:
parent
de6178a3c5
commit
3bba16c6a6
10 changed files with 620 additions and 224 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}?`}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
17
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
17
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue