mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
fix(memory): show enrolled users and verify live team recall
This commit is contained in:
parent
49d0e9da59
commit
8b53d48e34
8 changed files with 107 additions and 23 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
26
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
26
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue