fix(memory): show enrolled users and verify live team recall

This commit is contained in:
moe-berri 2026-09-14 17:30:11 -07:00
parent 49d0e9da59
commit 8b53d48e34
8 changed files with 107 additions and 23 deletions

View file

@ -19,7 +19,15 @@ from litellm.proxy.memory.store import MemoryStore
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.types.memory_v2 import MemoryCapture, MemoryEntry, MemoryQuery, MemorySearch, MemorySettings, MemoryStatus
from litellm.types.memory_v2 import (
MemoryCapture,
MemoryEntry,
MemoryQuery,
MemorySearch,
MemorySettings,
MemorySettingsView,
MemoryStatus,
)
_AUTH: Final = Depends(user_api_key_auth)
router: Final = APIRouter(prefix="/v2/memory", tags=["memory management"]) # mutable-ok: FastAPI requires native tags.
@ -30,14 +38,31 @@ def require_memory_admin(auth: UserAPIKeyAuth, *, write: bool = False) -> None:
raise HTTPException(status_code=403, detail="Only proxy administrators can configure gateway memory")
@router.get("/settings", response_model=MemorySettings)
async def get_settings(auth: UserAPIKeyAuth = _AUTH) -> MemorySettings:
async def settings_view(settings: MemorySettings) -> MemorySettingsView:
users: Final = (
await UserRepository(memory_primary_client(require_memory_prisma())).table.find_many(
where={"user_id": {"in": list(settings.user_ids)}}, # mutable-ok: Prisma requires native JSON.
take=len(settings.user_ids),
)
if settings.user_ids
else ()
)
return MemorySettingsView(
**settings.model_dump(),
user_names=MappingProxyType(
{user.user_id: user.user_alias or user.user_email or user.user_id for user in users}
),
)
@router.get("/settings", response_model=MemorySettingsView)
async def get_settings(auth: UserAPIKeyAuth = _AUTH) -> MemorySettingsView:
require_memory_admin(auth)
return await memory_settings(require_memory_prisma())
return await settings_view(await memory_settings(require_memory_prisma()))
@router.put("/settings", response_model=MemorySettings)
async def set_settings(settings: MemorySettings, auth: UserAPIKeyAuth = _AUTH) -> MemorySettings:
@router.put("/settings", response_model=MemorySettingsView)
async def set_settings(settings: MemorySettings, auth: UserAPIKeyAuth = _AUTH) -> MemorySettingsView:
require_memory_admin(auth, write=True)
prisma: Final = memory_primary_client(require_memory_prisma())
selected: Final = tuple(sorted(frozenset(settings.user_ids))) if not settings.everyone else ()
@ -53,7 +78,7 @@ async def set_settings(settings: MemorySettings, auth: UserAPIKeyAuth = _AUTH) -
saved: Final = settings.model_copy(update=MappingProxyType({"user_ids": selected}))
await ConfigRepository(prisma).set_param(MEMORY_CONFIG_PARAM, saved.model_dump(mode="json"))
await invalidate_memory_configuration()
return saved
return await settings_view(saved)
async def memory_store(auth: UserAPIKeyAuth) -> MemoryStore:

View file

@ -187,14 +187,13 @@ class MemoryStore:
{"user_id": user_id} if user_id else {}, # mutable-ok: Prisma requires native JSON.
_before(before),
)
result: Final[tuple[MemoryEntry, ...]]
if not search.query.strip():
result = await self._page(where, limit=search.limit, offset=search.offset)
else:
ranked, _ = await self._ranked(search.query, where, search.offset + search.limit, recent_first=recent_first)
result = tuple(entry for entry, _, _ in ranked[search.offset :])
page: Final = await self._page(where, limit=search.limit, offset=search.offset)
await self.authorize(require_active=require_active)
return page
ranked, _ = await self._ranked(search.query, where, search.offset + search.limit, recent_first=recent_first)
await self.authorize(require_active=require_active)
return result
return tuple(entry for entry, _, _ in ranked[search.offset :])
async def read(self, memory_id: str, *, require_active: bool = True) -> MemoryEntry:
access: Final = await self.authorize(require_active=require_active)

View file

@ -1,4 +1,5 @@
import re
from collections.abc import Mapping
from datetime import datetime
from typing import Annotated, Literal, TypeAlias
@ -27,6 +28,10 @@ class MemorySettings(BaseModel):
user_ids: tuple[Annotated[str, Field(min_length=1, max_length=256)], ...] = Field(default=(), max_length=10000)
class MemorySettingsView(MemorySettings):
user_names: Mapping[str, str]
class MemoryStatus(BaseModel):
model_config = ConfigDict(frozen=True)

View file

@ -276,9 +276,10 @@ class TestMemoryV2:
@pytest.mark.covers("mgmt.memory_v2.entries.team_permissions")
def test_delegated_team_reads_allow_recall_but_not_edit_and_can_be_revoked(
self, client: ManagementClient, memory: MemoryClient, subjects: MemorySubjects
self, client: ManagementClient, memory: MemoryClient, subjects: MemorySubjects, memory_models: MemoryModels
) -> None:
saved = unwrap(memory.capture(subjects.owner, _fact(unique_marker())))
marker = f"team-{unique_marker()}"
saved = unwrap(memory.capture(subjects.owner, _fact(marker)))
assert memory.entries(subjects.outsider) == []
for permissions, visible in ((["/spend/logs"], False), (["/v2/memory/entries"], True), ([], False)):
unwrap(
@ -296,6 +297,22 @@ class TestMemoryV2:
assert unwrap(memory.read(subjects.outsider, saved.memory_id)).content == saved.content
_assert_denied(memory.update(subjects.outsider, saved.memory_id, _fact(unique_marker())))
_assert_denied(memory.delete_entry(subjects.outsider, saved.memory_id))
recalled = unwrap(
client.proxy.chat(
subjects.outsider,
ChatBody(
model=memory_models.chat,
max_tokens=1200,
messages=[
ChatMessage(
role="user",
content="Search the team's memories for the demo project codename and return it exactly.",
)
],
),
)
)
assert marker in recalled.model_dump_json()
else:
_assert_denied(memory.read(subjects.outsider, saved.memory_id))

View file

@ -108,13 +108,16 @@ def row(**changes: object) -> LiteLLM_MemoryTable:
@pytest.mark.asyncio
async def test_default_off_and_proxy_admin_can_enable_selected_users(database: MagicMock) -> None:
admin = auth("admin", LitellmUserRoles.PROXY_ADMIN)
assert (await management.get_settings(admin)) == MemorySettings()
assert not (await management.get_settings(admin)).enabled
assert not (await management.get_status(auth())).active
database.db.litellm_usertable.find_many.return_value = [SimpleNamespace(user_id="owner")]
database.db.litellm_usertable.find_many.return_value = [
SimpleNamespace(user_id="owner", user_alias="Alex", user_email="alex@example.test")
]
saved = await management.set_settings(
MemorySettings(enabled=True, everyone=False, user_ids=("owner", "owner")), admin
)
assert saved.user_ids == ("owner",)
assert saved.user_names == {"owner": "Alex"}
written = database.db.litellm_config.upsert.call_args.kwargs["data"]["update"]["param_value"]
database.db.litellm_config.find_unique.return_value = SimpleNamespace(param_value=written)
assert (await management.get_status(auth())).active

View file

@ -28,7 +28,8 @@ export function MemoryAdministration({
});
const current = draft ?? settings.data;
const save = useMutation({
mutationFn: (body: Settings) => fetchClient.PUT("/v2/memory/settings", { body }),
mutationFn: ({ enabled, everyone, user_ids }: Settings) =>
fetchClient.PUT("/v2/memory/settings", { body: { enabled, everyone, user_ids } }),
onSuccess: async ({ data }) => {
cache.setQueryData(["memorySettings", userId], data);
setDraft(null);
@ -98,12 +99,12 @@ export function MemoryAdministration({
<ul className="divide-y">
{(current.user_ids ?? []).map((id) => (
<li key={id} className="flex items-center justify-between gap-3 py-2">
<span className="break-all text-sm">{names[id] ?? id}</span>
<span className="break-all text-sm">{names[id] ?? settings.data?.user_names?.[id] ?? id}</span>
<Button
variant="ghost"
size="sm"
disabled={busy}
aria-label={`Remove ${names[id] ?? id}`}
aria-label={`Remove ${names[id] ?? settings.data?.user_names?.[id] ?? id}`}
onClick={() =>
setDraft({ ...current, user_ids: current.user_ids?.filter((value) => value !== id) })
}

View file

@ -64,7 +64,7 @@ beforeEach(async () => {
const response = () => {
if (path === "/v2/memory/settings") {
if (request.method === "PUT") settings = JSON.parse(text);
return settings;
return { ...settings, user_names: { u1: "Alex Rivera" } };
}
if (path === "/v2/memory/status")
return {
@ -146,6 +146,18 @@ describe("Memory dashboard", () => {
await waitFor(() => expect(settings).toEqual({ enabled: true, everyone: false, user_ids: ["u1"] }));
});
it("shows saved user names after loading and sends only editable settings", async () => {
session("proxy_admin");
settings = { enabled: true, everyone: false, user_ids: ["u1"] };
const user = userEvent.setup();
renderWithProviders(<Memory />);
await user.click(screen.getByRole("tab", { name: "Administration" }));
expect(await screen.findByRole("button", { name: "Remove Alex Rivera" })).toBeVisible();
await user.click(screen.getByRole("switch", { name: "Gateway memory" }));
await user.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(settings).toEqual({ enabled: false, everyone: false, user_ids: ["u1"] }));
});
it("keeps an unsuccessful activation unsaved and shows the error", async () => {
session("proxy_admin");
failure = "/v2/memory/settings";

View file

@ -32271,6 +32271,28 @@ export interface components {
*/
user_ids: string[];
};
/** MemorySettingsView */
MemorySettingsView: {
/**
* Enabled
* @default false
*/
enabled: boolean;
/**
* Everyone
* @default true
*/
everyone: boolean;
/**
* User Ids
* @default []
*/
user_ids: string[];
/** User Names */
user_names: {
[key: string]: string;
};
};
/** MemoryStatus */
MemoryStatus: {
/** Active */
@ -67774,7 +67796,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MemorySettings"];
"application/json": components["schemas"]["MemorySettingsView"];
};
};
};
@ -67798,7 +67820,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MemorySettings"];
"application/json": components["schemas"]["MemorySettingsView"];
};
};
/** @description Validation Error */