From 3875a888d1150ad7109d5ac11e22e342e17265ab Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 18:12:19 +0000 Subject: [PATCH 01/17] feat(ui): show user, team and org names instead of raw ids across the dashboard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 20 +++++ .../access_group_endpoints.py | 25 +++++- .../resource_display_names.py | 10 +++ litellm/types/access_group.py | 2 + .../test_access_group_display_names.py | 89 +++++++++++++++++++ .../test_access_group_endpoints.py | 49 ++++++++++ .../test_resource_display_names.py | 27 ++++++ .../AccessGroupsDetailsPage.test.tsx | 14 +++ .../_components/AccessGroupsDetailsPage.tsx | 6 +- .../(dashboard)/hooks/users/useUsers.test.ts | 55 +++++++++++- .../app/(dashboard)/hooks/users/useUsers.ts | 21 +++++ ...MCPGatewaySessionsTab.integration.test.tsx | 2 + .../_components/MCPGatewaySessionsTab.tsx | 21 +++-- .../_components/mcp_servers.test.tsx | 2 + .../_components/MemoryDetailDrawer.test.tsx | 12 ++- .../memory/_components/MemoryDetailDrawer.tsx | 23 ++++- .../components/AllModelsTab.test.tsx | 6 ++ .../components/AllModelsTable.test.tsx | 6 ++ .../components/AllModelsTable.tsx | 6 ++ .../components/ModelsTableColumns.tsx | 26 ++++-- .../_components/ProjectDetailsPage.test.tsx | 6 ++ .../_components/ProjectDetailsPage.tsx | 10 ++- .../prompts/_components/PromptTable.test.tsx | 6 +- .../prompts/_components/PromptTable.tsx | 15 +++- .../_components/PromptTableColumns.tsx | 12 ++- .../prompts/_components/prompt_info.test.tsx | 6 ++ .../prompts/_components/prompt_info.tsx | 27 +++++- .../_components/IndexesTable.test.tsx | 4 + .../_components/IndexesTable.tsx | 13 ++- .../_components/IndexesTableColumns.tsx | 13 ++- .../vector-stores/_components/index.test.tsx | 10 +++ .../AIHub/MCPHubTableColumns.test.tsx | 2 + .../components/AIHub/MCPHubTableColumns.tsx | 20 +++-- .../src/components/AIHub/ModelHubTable.tsx | 29 +++++- .../DeletedTeamsTable.test.tsx | 2 +- .../DeletedTeamsTable/DeletedTeamsTable.tsx | 7 +- .../DeletedTeamsTableColumns.tsx | 12 ++- .../AuditLogDrawer/AuditLogDrawer.test.tsx | 2 + .../audit/AuditLogDrawer/AuditLogDrawer.tsx | 10 ++- .../logs/audit/AuditLogsPanel.test.tsx | 2 + .../components/logs/audit/AuditLogsPanel.tsx | 15 +++- .../logs/audit/AuditLogsTable.test.tsx | 11 +++ .../components/logs/audit/AuditLogsTable.tsx | 4 +- .../logs/audit/AuditLogsTableColumns.tsx | 13 ++- .../logs/request/RequestLogsPanel.test.tsx | 2 + .../request/RequestLogsTableColumns.test.tsx | 2 + .../logs/request/RequestLogsTableColumns.tsx | 10 ++- .../src/components/model_info_view.test.tsx | 6 ++ .../src/components/model_info_view.tsx | 20 ++++- .../organization/organization_view.test.tsx | 4 + .../organization/organization_view.tsx | 17 +++- .../shared/EntityReference.test.tsx | 60 +++++++++++++ .../src/components/shared/EntityReference.tsx | 41 +++++++++ .../src/components/team/TeamInfo.tsx | 17 +++- .../components/templates/key_info_view.tsx | 25 ++++-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 56 files changed, 797 insertions(+), 82 deletions(-) create mode 100644 tests/integration/management/test_access_group_display_names.py create mode 100644 ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/EntityReference.tsx diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 69db4525a17..90caf7de0ba 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -856,6 +856,16 @@ ], "title": "Created By" }, + "created_by_user": { + "anyOf": [ + { + "$ref": "#/components/schemas/AccessGroupResource" + }, + { + "type": "null" + } + ] + }, "description": { "anyOf": [ { @@ -882,6 +892,16 @@ } ], "title": "Updated By" + }, + "updated_by_user": { + "anyOf": [ + { + "$ref": "#/components/schemas/AccessGroupResource" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 3f05c3a407a..226bc09ca03 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -35,6 +35,7 @@ from litellm.proxy.management_helpers.resource_display_names import ( agent_display_names, key_display_names, mcp_server_display_names, + user_display_names, ) from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw from litellm.repositories.table_repositories import AccessGroupRepository, TeamRepository @@ -66,6 +67,12 @@ class _AccessGroupRecord(Protocol): @property def assigned_key_ids(self) -> Sequence[str] | None: ... + @property + def created_by(self) -> str | None: ... + + @property + def updated_by(self) -> str | None: ... + def dict(self) -> Mapping[str, object]: ... @@ -185,12 +192,19 @@ class _ResourceNames: agents: Mapping[str, str] teams: Mapping[str, str | None] keys: Mapping[str, str] + users: Mapping[str, str] def _label(ids: Sequence[str], names: Mapping[str, str | None]) -> tuple[AccessGroupResource, ...]: return tuple(AccessGroupResource(id=resource_id, name=names.get(resource_id)) for resource_id in ids) +def _actor_resource(user_id: str | None, names: Mapping[str, str]) -> AccessGroupResource | None: + if user_id is None: + return None + return AccessGroupResource(id=user_id, name=names.get(user_id)) + + def _record_to_response( record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str], names: _ResourceNames ) -> AccessGroupResponse: @@ -202,6 +216,8 @@ def _record_to_response( "access_agents": _label(record.access_agent_ids or (), names.agents), "assigned_teams": _label(assigned_team_ids, names.teams), "assigned_keys": _label(record.assigned_key_ids or (), names.keys), + "created_by_user": _actor_resource(record.created_by, names.users), + "updated_by_user": _actor_resource(record.updated_by, names.users), } ) return AccessGroupResponse.model_validate(payload) @@ -219,7 +235,7 @@ async def _responses_for( if not records: return () teams: Final = await _teams_touching(TeamRepository(prisma_client).table, records) - mcp_servers, agents, keys = await asyncio.gather( + mcp_servers, agents, keys, users = await asyncio.gather( mcp_server_display_names( prisma_client, _ids_across(records, lambda record: record.access_mcp_server_ids), @@ -229,12 +245,19 @@ async def _responses_for( prisma_client, _ids_across(records, lambda record: record.access_agent_ids), global_agent_registry ), key_display_names(prisma_client, _ids_across(records, lambda record: record.assigned_key_ids)), + user_display_names( + prisma_client, + _ids_across( + records, lambda record: tuple(user_id for user_id in (record.created_by, record.updated_by) if user_id) + ), + ), ) names: Final = _ResourceNames( mcp_servers=mcp_servers, agents=agents, teams=MappingProxyType({team.team_id: team.team_alias for team in teams}), keys=keys, + users=users, ) attached: Final = _attached_team_ids_by_group(records, teams) return tuple( diff --git a/litellm/proxy/management_helpers/resource_display_names.py b/litellm/proxy/management_helpers/resource_display_names.py index f3a97da1b12..333ddaa284e 100644 --- a/litellm/proxy/management_helpers/resource_display_names.py +++ b/litellm/proxy/management_helpers/resource_display_names.py @@ -7,6 +7,7 @@ from typing import Final from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry from litellm.proxy.utils import PrismaClient from litellm.repositories.table_repositories import AgentsRepository, MCPServerRepository +from litellm.repositories.user_repository import UserRepository from litellm.repositories.verification_token_repository import VerificationTokenRepository from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -59,3 +60,12 @@ async def key_display_names(prisma_client: PrismaClient, tokens: Sequence[str]) where: Final = {"token": {"in": tuple(frozenset(tokens))}} rows: Final = await VerificationTokenRepository(prisma_client).table.find_many(where=where) return MappingProxyType({row.token: row.key_alias for row in rows if row.key_alias}) + + +async def user_display_names(prisma_client: PrismaClient, user_ids: Sequence[str]) -> Mapping[str, str]: + """user_id -> user_alias, else user_email, for the users that have either.""" + if not user_ids: + return MappingProxyType({}) + where: Final = {"user_id": {"in": tuple(frozenset(user_ids))}} + rows: Final = await UserRepository(prisma_client).table.find_many(where=where) + return MappingProxyType({row.user_id: name for row in rows if (name := row.user_alias or row.user_email)}) diff --git a/litellm/types/access_group.py b/litellm/types/access_group.py index b4bf345ecbe..13d83e511be 100644 --- a/litellm/types/access_group.py +++ b/litellm/types/access_group.py @@ -45,5 +45,7 @@ class AccessGroupResponse(LiteLLMBaseModel): assigned_keys: tuple[AccessGroupResource, ...] created_at: datetime created_by: str | None = None + created_by_user: AccessGroupResource | None = None updated_at: datetime updated_by: str | None = None + updated_by_user: AccessGroupResource | None = None diff --git a/tests/integration/management/test_access_group_display_names.py b/tests/integration/management/test_access_group_display_names.py new file mode 100644 index 00000000000..62fde735c56 --- /dev/null +++ b/tests/integration/management/test_access_group_display_names.py @@ -0,0 +1,89 @@ +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Final + +import pytest + +from tests.integration._support.client import Gateway, object_value, string_value + +FALLBACK_EMAIL: Final = f"fallback-{uuid.uuid4().hex}@example.com" + + +@contextmanager +def _access_group(gateway: Gateway, body: dict[str, object], *, key: str) -> Iterator[str]: + created: Final = gateway.request("POST", "/v1/access_group", body, key=key) + assert created.status_code == 201, created.text + identity: Final = string_value(created.json()["access_group_id"]) + try: + yield identity + finally: + deleted: Final = gateway.request("DELETE", f"/v1/access_group/{identity}") + assert deleted.status_code == 204, deleted.text + + +def test_access_group_detail_names_the_user_who_created_and_updated_it(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + team_id: Final = scenario.team(team_alias="Review Team") + user_id: Final = scenario.user( + user_alias="Ada Reviewer", + user_email=f"ada-{uuid.uuid4().hex}@example.com", + user_role="proxy_admin", + ) + key: Final = scenario.key(user_id=user_id) + with _access_group( + gateway, + { + "access_group_name": f"integration-{uuid.uuid4().hex}", + "access_model_names": [], + "assigned_team_ids": [team_id], + }, + key=key, + ) as group_id: + updated: Final = gateway.request( + "PUT", + f"/v1/access_group/{group_id}", + {"description": "updated description"}, + key=key, + ) + assert updated.status_code == 200, updated.text + + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text + group: Final = object_value(detail.json()) + assert group["created_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, detail.text + assert group["updated_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, detail.text + assert group["assigned_teams"] == [{"id": team_id, "name": "Review Team"}], detail.text + + listing: Final = gateway.request("GET", "/v1/access_group") + assert listing.status_code == 200, listing.text + entries: Final = listing.json() + assert isinstance(entries, list), listing.text + listed: Final = object_value(next(entry for entry in entries if entry["access_group_id"] == group_id)) + assert listed["created_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, listing.text + + +@pytest.mark.parametrize( + ("user_fields", "expected_name"), + ( + pytest.param({"user_email": FALLBACK_EMAIL}, FALLBACK_EMAIL, id="email"), + pytest.param({}, None, id="neither"), + ), +) +def test_access_group_creator_without_alias_falls_back_to_email_then_none( + gateway: Gateway, user_fields: dict[str, str], expected_name: str | None +) -> None: + with gateway.scenario() as scenario: + user_id: Final = scenario.user(user_role="proxy_admin", **user_fields) + key: Final = scenario.key(user_id=user_id) + with _access_group( + gateway, + {"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []}, + key=key, + ) as group_id: + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text + assert object_value(detail.json())["created_by_user"] == { + "id": user_id, + "name": expected_name, + }, detail.text diff --git a/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py b/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py index 13e57408bd0..1622c21e9a7 100644 --- a/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py @@ -130,6 +130,9 @@ def client_and_mocks(monkeypatch): mock_agents_table.find_many = AsyncMock(return_value=[]) mock_agents_table.update = AsyncMock(return_value=None) + mock_user_table = MagicMock() + mock_user_table.find_many = AsyncMock(return_value=[]) + @asynccontextmanager async def mock_tx(): tx = types.SimpleNamespace( @@ -146,6 +149,7 @@ def client_and_mocks(monkeypatch): litellm_verificationtoken=mock_key_table, litellm_mcpservertable=mock_mcp_server_table, litellm_agentstable=mock_agents_table, + litellm_usertable=mock_user_table, tx=mock_tx, ) mock_prisma.db = mock_db @@ -423,6 +427,51 @@ def test_get_access_group_success(client_and_mocks, base_path, access_group_id): assert resp.json()["access_group_id"] == access_group_id +@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) +def test_get_access_group_resolves_actor_display_names(client_and_mocks, base_path): + """Get names the creator/updater from the user table: alias beats email, unresolved ids keep name=None.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + + record = _make_access_group_record( + access_group_id="ag-123", created_by="ada", updated_by="ghost-user" + ) + mock_table.find_unique = AsyncMock(return_value=record) + mock_prisma.db.litellm_usertable.find_many = AsyncMock( + return_value=[types.SimpleNamespace(user_id="ada", user_alias="Ada Reviewer", user_email="ada@x.com")] + ) + + resp = client.get(f"{base_path}/ag-123") + assert resp.status_code == 200 + body = resp.json() + assert body["created_by_user"] == {"id": "ada", "name": "Ada Reviewer"} + assert body["updated_by_user"] == {"id": "ghost-user", "name": None} + queried_ids = mock_prisma.db.litellm_usertable.find_many.call_args.kwargs["where"]["user_id"]["in"] + assert sorted(queried_ids) == ["ada", "ghost-user"] + + +@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) +def test_list_access_groups_resolves_actor_display_names_across_records(client_and_mocks, base_path): + """List carries created_by_user per group from one batched user query.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + + records = [ + _make_access_group_record(access_group_id="ag-1", created_by="ada", updated_by="ada"), + _make_access_group_record(access_group_id="ag-2", created_by="bob", updated_by="bob"), + ] + mock_table.find_many = AsyncMock(return_value=records) + mock_prisma.db.litellm_usertable.find_many = AsyncMock( + return_value=[types.SimpleNamespace(user_id="bob", user_alias=None, user_email="bob@x.com")] + ) + + resp = client.get(base_path) + assert resp.status_code == 200 + body = {entry["access_group_id"]: entry for entry in resp.json()} + assert body["ag-1"]["created_by_user"] == {"id": "ada", "name": None} + assert body["ag-2"]["created_by_user"] == {"id": "bob", "name": "bob@x.com"} + assert body["ag-2"]["updated_by_user"] == {"id": "bob", "name": "bob@x.com"} + mock_prisma.db.litellm_usertable.find_many.assert_awaited_once() + + @pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) def test_get_access_group_derives_assigned_teams_from_team_table(client_and_mocks, base_path): """Get drops ghost ids from the stored column and adds teams that carry the group but were never mirrored.""" diff --git a/tests/unit/proxy/management_helpers/test_resource_display_names.py b/tests/unit/proxy/management_helpers/test_resource_display_names.py index b530bc15c25..f900fd8b763 100644 --- a/tests/unit/proxy/management_helpers/test_resource_display_names.py +++ b/tests/unit/proxy/management_helpers/test_resource_display_names.py @@ -9,6 +9,7 @@ from litellm.proxy.management_helpers.resource_display_names import ( agent_display_names, key_display_names, mcp_server_display_names, + user_display_names, ) from litellm.types.agents import AgentResponse from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -128,3 +129,29 @@ async def test_key_empty_ids_skip_the_db(): table = _table() assert dict(await key_display_names(_prisma(litellm_verificationtoken=table), ())) == {} table.find_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_user_alias_beats_email_and_users_with_neither_are_skipped(): + table = _table( + [ + types.SimpleNamespace(user_id="u1", user_alias="Ada Reviewer", user_email="ada@x.com"), + types.SimpleNamespace(user_id="u2", user_alias=None, user_email="bob@x.com"), + types.SimpleNamespace(user_id="u3", user_alias=None, user_email=None), + ] + ) + names = await user_display_names(_prisma(litellm_usertable=table), ("u1", "u2", "u3", "missing", "u1")) + assert dict(names) == {"u1": "Ada Reviewer", "u2": "bob@x.com"} + assert sorted(table.find_many.call_args.kwargs["where"]["user_id"]["in"]) == [ + "missing", + "u1", + "u2", + "u3", + ] + + +@pytest.mark.asyncio +async def test_user_empty_ids_skip_the_db(): + table = _table() + assert dict(await user_display_names(_prisma(litellm_usertable=table), ())) == {} + table.find_many.assert_not_awaited() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx index aad63e979ac..5f20447078f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx @@ -143,6 +143,20 @@ describe("AccessGroupDetail", () => { expect(screen.getByText("A test access group")).toBeInTheDocument(); }); + it("shows the creator and updater names instead of their raw user ids", () => { + renderWith({ + created_by: "user-abc", + created_by_user: { id: "user-abc", name: "Ada Reviewer" }, + updated_by: "user-def", + updated_by_user: { id: "user-def", name: null }, + }); + + const creatorLink = screen.getByRole("link", { name: "Ada Reviewer" }); + expect(creatorLink).toHaveAttribute("href", expect.stringContaining("user-abc")); + expect(screen.queryByText("user-abc")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "user-def" })).toHaveAttribute("href", expect.stringContaining("user-def")); + }); + it("should display em dash when description is empty", () => { renderWith({ description: null }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 118a8655d5a..82d7e9b968b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -1,8 +1,8 @@ import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react"; import { useState } from "react"; -import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; import { BadgeLink } from "@/components/shared/BadgeLink"; +import { UserReference } from "@/components/shared/EntityReference"; import CopyButton from "@/components/shared/CopyButton"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -136,7 +136,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr {accessGroup.created_by && ( <> by - + )} @@ -146,7 +146,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr {accessGroup.updated_by && ( <> by - + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts index f49c728446b..a6917ac1595 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React, { ReactNode } from "react"; -import { useInfiniteUsers, useUserEmailLookup, useUserLookup } from "./useUsers"; +import { useInfiniteUsers, useUserDisplayNames, useUserEmailLookup, useUserLookup } from "./useUsers"; import { userListCall } from "@/components/networking"; import type { UserListResponse } from "@/components/networking"; @@ -404,3 +404,56 @@ describe("useUserEmailLookup", () => { expect(userListCall).not.toHaveBeenCalled(); }); }); + +describe("useUserDisplayNames", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue(DEFAULT_AUTH); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("prefers the alias, falls back to the email, and omits users with neither", async () => { + const response = buildUserListResponse(1, 1, 2); + vi.mocked(userListCall).mockResolvedValue({ + ...response, + users: [ + { ...response.users[0], user_alias: "Ada Reviewer" }, + { ...response.users[1], user_alias: null }, + ], + }); + + const { result } = renderHook(() => useUserDisplayNames(["user-1-0", "user-1-1"]), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ + "user-1-0": "Ada Reviewer", + "user-1-1": "user-1-1@example.com", + }); + }); + + it("omits users that have neither alias nor email so callers fall back to the id", async () => { + const response = buildUserListResponse(1, 1, 2); + vi.mocked(userListCall).mockResolvedValue({ + ...response, + users: [{ ...response.users[0], user_email: null, user_alias: null }, response.users[1]], + }); + + const { result } = renderHook(() => useUserDisplayNames(["user-1-0", "user-1-1"]), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ "user-1-1": "user-1-1@example.com" }); + }); + + it("does not query with no ids", async () => { + const { result } = renderHook(() => useUserDisplayNames([]), { wrapper }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.fetchStatus).toBe("idle"); + expect(userListCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts index 5bfbbc76e9a..28f8d245438 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -56,6 +56,27 @@ export const useUserEmailLookup = (userIds: readonly string[]) => { }); }; +const userDisplayNameKeys = createQueryKeys("userDisplayNames"); + +export const useUserDisplayNames = (userIds: readonly string[]) => { + const { accessToken, userRole } = useAuthorized(); + const distinctIds = Array.from(new Set(userIds.filter((id) => id !== ""))).sort(); + return useQuery>({ + queryKey: userDisplayNameKeys.list({ filters: { ids: JSON.stringify(distinctIds) } }), + queryFn: async () => { + const ids = distinctIds.slice(0, USER_LIST_MAX_PAGE_SIZE); + const response = await userListCall(accessToken!, ids, 1, ids.length); + return Object.fromEntries( + response.users.flatMap((user) => { + const name = user.user_alias || user.user_email; + return name ? [[user.user_id, name]] : []; + }), + ); + }, + enabled: Boolean(accessToken) && distinctIds.length > 0 && canListUsers(userRole), + }); +}; + export const useUserLookup = (userId: string | null) => { const { accessToken, userRole } = useAuthorized(); return useQuery({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx index f1ddf709038..387ea188e78 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx @@ -7,6 +7,8 @@ import { MCPGatewaySessionsTab, describeTerminateResult, formatIdleSeconds } fro import * as networking from "@/components/networking"; import type { MCPGatewaySessionsResponse, MCPGatewaySessionsTerminateResponse } from "@/components/mcp_tools/types"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + vi.mock("@/components/networking", () => ({ fetchMCPGatewaySessions: vi.fn(), terminateMCPGatewaySessions: vi.fn(), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.tsx index 04a095f8792..cf5630025a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.tsx @@ -16,6 +16,8 @@ import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { fetchMCPGatewaySessions, terminateMCPGatewaySessions } from "@/components/networking"; +import { EntityReference, UserReference } from "@/components/shared/EntityReference"; +import { teamDetailHref } from "@/utils/entityLinks"; import type { MCPGatewaySessionGroupCount, MCPGatewaySessionSelector, @@ -216,16 +218,21 @@ function SessionsBody({ {session.user_id === null ? ( {UNKNOWN_LABEL} ) : ( - <> - {session.user_id} - {session.user_email ? ( - {session.user_email} - ) : null} - + )} {session.key_alias ?? "-"} - {session.team_alias ?? session.team_id ?? "-"} + + {session.team_id ? ( + + ) : ( + session.team_alias ?? "-" + )} + {session.client_ip || "-"} {formatIdleSeconds(session.idle_seconds)} {session.in_flight_requests} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index acf1c24a197..9f575e72b28 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -9,6 +9,8 @@ import MCPServers, { compareServers, type SortKey } from "./mcp_servers"; import type { MCPServer } from "@/components/mcp_tools/types"; import * as networking from "@/components/networking"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + // Mock the networking module vi.mock("@/components/networking", () => ({ fetchMCPServers: vi.fn(), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx index df65d5b534f..a5c950f9dbe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx @@ -6,6 +6,12 @@ import { describe, expect, it, vi } from "vitest"; import { MemoryRow } from "@/components/networking"; import { MemoryDetailDrawer } from "./MemoryDetailDrawer"; +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), +})); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); const makeMemory = (overrides: Partial = {}): MemoryRow => ({ memory_id: "mem-1", @@ -66,8 +72,10 @@ describe("MemoryDetailDrawer", () => { it("attributes the created and updated timestamps to their actors", () => { render(); - expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument(); - expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "alice" })).toHaveAttribute("href", expect.stringContaining("alice")); + expect(screen.getByText(/^Created /)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument(); + expect(screen.getByText(/^Updated /)).toBeInTheDocument(); }); it("renders an em dash for a timestamp the backend did not send", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx index 5c2fac2d5a9..e43ac07151a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx @@ -4,6 +4,8 @@ import React from "react"; import { MemoryRow } from "@/components/networking"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { UserReference } from "@/components/shared/EntityReference"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; interface MemoryDetailDrawerProps { row: MemoryRow | null; @@ -25,6 +27,9 @@ function formatTimestamp(ts?: string): string { } export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) { + const { data: displayNames } = useUserDisplayNames( + React.useMemo(() => [row?.created_by ?? "", row?.updated_by ?? ""], [row]), + ); return ( Created {formatTimestamp(row.created_at)} - {row.created_by ? ` by ${row.created_by}` : ""} + {row.created_by ? ( + <> + {" by "} + + + ) : ( + "" + )} Updated {formatTimestamp(row.updated_at)} - {row.updated_by ? ` by ${row.updated_by}` : ""} + {row.updated_by ? ( + <> + {" by "} + + + ) : ( + "" + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 09e00db9fb0..e620bc5f6d2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -8,6 +8,8 @@ import { renderWithProviders } from "../../../../../tests/test-utils"; import AllModelsTab from "./AllModelsTab"; import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + const mockModelDeleteCall = vi.fn().mockResolvedValue({}); const mockModelPatchUpdateCall = vi.fn().mockResolvedValue({}); vi.mock("@/components/networking", () => ({ @@ -568,6 +570,10 @@ describe("AllModelsTab", () => { }); it("opens the team detail view from the team ID cell", async () => { + vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), + })); const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx index adbeb70a9c5..0755325e37a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx @@ -6,6 +6,8 @@ import { ModelData } from "@/components/model_dashboard/types"; import { AllModelsTable } from "./AllModelsTable"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + const makeModel = (overrides: Partial = {}): ModelData => ({ model_name: "gpt-4-public", @@ -457,6 +459,10 @@ describe("AllModelsTable", () => { }); it("runs the full reset from the filter drawer", async () => { + vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), + })); const user = userEvent.setup(); const onResetFilters = vi.fn(); render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx index e9eca183a7a..93a229091a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -12,6 +12,7 @@ import { DataTableToolbar, } from "@/components/shared/DataTable"; import { SearchSelect } from "@/components/shared/SearchSelect"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; @@ -132,6 +133,9 @@ export function AllModelsTable({ credentialLabels, }: AllModelsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); + const { data: displayNames } = useUserDisplayNames( + useMemo(() => data.map((model) => model.model_info.created_by ?? ""), [data]), + ); const columns = useMemo(() => { const columnDeps = { @@ -144,6 +148,7 @@ export function AllModelsTable({ onTogglePauseClick, pausingModelId, credentialLabels, + resolveUserName: (userId: string) => displayNames?.[userId], }; return getModelsTableColumns(columnDeps); }, [ @@ -156,6 +161,7 @@ export function AllModelsTable({ onTogglePauseClick, pausingModelId, credentialLabels, + displayNames, ]); const modelGroupOptions = useMemo( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx index c277f527f86..7e3eeaf24d1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -7,6 +7,7 @@ import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; import { ModelData } from "@/components/model_dashboard/types"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { CellTooltip, DateCell, formatCellDate, IdCell, StatusBadge } from "@/components/shared/table_cells"; +import { UserReference } from "@/components/shared/EntityReference"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; @@ -178,17 +179,30 @@ function CredentialsCell({ credentialName, label }: { credentialName: string | u ); } -function CreatedByCell({ model }: { model: ModelData }) { +function CreatedByCell({ + model, + resolveUserName, +}: { + model: ModelData; + resolveUserName: (userId: string) => string | undefined; +}) { const isConfigModel = !model.model_info?.db_model; const createdAt = formatShortDate(model.model_info.created_at); const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown"; const secondaryForDbModel = createdAt ?? "Unknown date"; + const createdBy = model.model_info.created_by; return (
- - {primary} - + {!isConfigModel && createdBy ? ( + + + + ) : ( + + {primary} + + )} {isConfigModel ? "-" : secondaryForDbModel}
); @@ -365,6 +379,7 @@ export interface ModelsTableColumnDeps { onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise; pausingModelId?: string | null; credentialLabels?: ReadonlyMap; + resolveUserName?: (userId: string) => string | undefined; } export const getModelsTableColumns = ({ @@ -377,6 +392,7 @@ export const getModelsTableColumns = ({ onTogglePauseClick, pausingModelId, credentialLabels, + resolveUserName = () => undefined, }: ModelsTableColumnDeps): ColumnDef[] => [ { id: MODEL_ID_COLUMN_ID, @@ -432,7 +448,7 @@ export const getModelsTableColumns = ({ enableSorting: true, size: 180, minSize: 110, - cell: ({ row }) => , + cell: ({ row }) => , }, { id: UPDATED_AT_COLUMN_ID, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx index 5d84488d739..124c342150f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx @@ -3,6 +3,12 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen } from "../../../../../tests/test-utils"; import { ProjectDetail } from "./ProjectDetailsPage"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), +})); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); const mockUseProjectDetails = vi.fn(); vi.mock("@/app/(dashboard)/hooks/projects/useProjectDetails", () => ({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index b9d8ef94cb4..1105473319e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -6,8 +6,9 @@ import { isUserTeamAdminForSingleTeam } from "@/utils/roles"; import { BarChart } from "@/components/shared/charts"; import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import { UserReference } from "@/components/shared/EntityReference"; import CopyButton from "@/components/shared/CopyButton"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; import { StatusBadge } from "@/components/shared/table_cells/status_badge"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -28,6 +29,9 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { const { data: project, isLoading } = useProjectDetails(projectId); const { data: teamInfo } = useTeam(project?.team_id ?? undefined); const { userId } = useAuthorized(); + const { data: displayNames } = useUserDisplayNames( + useMemo(() => [project?.created_by ?? "", project?.updated_by ?? ""], [project]), + ); const canEditProject = useCanManageProjects( isUserTeamAdminForSingleTeam(teamInfo?.members_with_roles ?? null, userId ?? ""), ); @@ -116,7 +120,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { {project.created_by && ( <> by - + )} @@ -126,7 +130,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { {project.updated_by && ( <> by - + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx index 009abf93d1f..78b80e3875d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx @@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PromptSpec } from "@/components/networking"; import PromptTable from "./PromptTable"; +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), +})); vi.mock("@/components/networking", () => ({ modelHubCall: vi.fn().mockResolvedValue({ data: [] }), @@ -60,7 +64,7 @@ describe("PromptTable", () => { render(); expect(screen.getByRole("link", { name: "user-1" })).toHaveAttribute("href", "/ui/users?user=user-1"); - expect(screen.getByText("default_user_id")).toBeInTheDocument(); + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx index e810c3622d1..87ef9475ac0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx @@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable"; import { modelHubCall, PromptSpec } from "@/components/networking"; import { getPromptTableColumns } from "./PromptTableColumns"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; import { ModelGroupInfo } from "./prompt_utils"; interface PromptTableProps { @@ -65,9 +66,19 @@ const PromptTable: React.FC = ({ fetchModelHubData(); }, [accessToken]); + const { data: displayNames } = useUserDisplayNames( + useMemo(() => promptsList.map((prompt) => prompt.created_by ?? ""), [promptsList]), + ); const columns = useMemo( - () => getPromptTableColumns({ modelHubData, isAdmin, onPromptClick, onDeleteClick }), - [modelHubData, isAdmin, onPromptClick, onDeleteClick], + () => + getPromptTableColumns({ + modelHubData, + isAdmin, + onPromptClick, + onDeleteClick, + resolveUserName: (userId) => displayNames?.[userId], + }), + [modelHubData, isAdmin, onPromptClick, onDeleteClick, displayNames], ); return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx index db0392bbd6e..66af3282f20 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; -import { userDetailHref } from "@/utils/entityLinks"; +import { UserReference } from "@/components/shared/EntityReference"; import { extractModel, getProviderFromModelHub, ModelGroupInfo } from "./prompt_utils"; @@ -111,6 +111,7 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr } interface PromptTableColumnsDeps { + resolveUserName?: (userId: string) => string | undefined; modelHubData: Map; isAdmin: boolean; onPromptClick?: (id: string, environment: string) => void; @@ -118,6 +119,7 @@ interface PromptTableColumnsDeps { } export const getPromptTableColumns = ({ + resolveUserName = () => undefined, modelHubData, isAdmin, onPromptClick, @@ -196,12 +198,8 @@ export const getPromptTableColumns = ({ return -; } return ( - - + + ); }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx index b14d5c3d91f..355e515c423 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx @@ -4,6 +4,12 @@ import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import PromptInfoView from "./prompt_info"; import * as networking from "@/components/networking"; +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), +})); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); vi.mock("@/components/networking", () => ({ getPromptInfo: vi.fn(), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx index 062e1d84a0a..4143f3ae87b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx @@ -17,6 +17,8 @@ import { toast } from "@/lib/toast"; import PromptCodeSnippets from "./prompt_editor_view/PromptCodeSnippets"; import { extractModel, extractTemplateVariables, getBasePromptId, getCurrentVersion } from "./prompt_utils"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { UserReference } from "@/components/shared/EntityReference"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; export interface PromptInfoProps { promptId: string; @@ -51,6 +53,12 @@ const PromptInfoView: React.FC = ({ const [versionHistory, setVersionHistory] = useState([]); const [selectedVersion, setSelectedVersion] = useState(null); const [loadingVersions, setLoadingVersions] = useState(false); + const { data: displayNames } = useUserDisplayNames( + React.useMemo( + () => [promptData?.created_by ?? "", ...versionHistory.map((v) => v.created_by ?? "")], + [promptData, versionHistory], + ), + ); // Fetches the requested environment (or the serve-time default when omitted) plus the environments list const fetchPromptInfo = async (environment?: string) => { @@ -329,7 +337,16 @@ const PromptInfoView: React.FC = ({

Created By

-

{promptData.created_by || "-"}

+

+ {promptData.created_by ? ( + + ) : ( + "-" + )} +

@@ -379,7 +396,13 @@ const PromptInfoView: React.FC = ({ )} - {v.created_by || "-"} + + {v.created_by ? ( + + ) : ( + "-" + )} + {formatDate(v.created_at)} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx index e318efb103b..62938ad35b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx @@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import type { VectorStoreIndex } from "./IndexesTab"; import IndexesTable from "./IndexesTable"; +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), +})); vi.mock("next/navigation", async () => ({ ...(await vi.importActual("next/navigation")), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx index a0b0c02f99d..76d3ce520e9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx @@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable"; import type { VectorStoreIndex } from "./IndexesTab"; import { getIndexesTableColumns } from "./IndexesTableColumns"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; interface IndexesTableProps { data: VectorStoreIndex[]; @@ -38,9 +39,17 @@ const IndexesTable: React.FC = ({ }) => { const [sorting, setSorting] = useState(DEFAULT_SORTING); + const { data: displayNames } = useUserDisplayNames( + useMemo(() => data.map((index) => index.created_by ?? ""), [data]), + ); const columns = useMemo( - () => getIndexesTableColumns({ resolveVectorStoreId, onViewVectorStore }), - [resolveVectorStoreId, onViewVectorStore], + () => + getIndexesTableColumns({ + resolveVectorStoreId, + onViewVectorStore, + resolveUserName: (userId) => displayNames?.[userId], + }), + [resolveVectorStoreId, onViewVectorStore, displayNames], ); return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx index c21665f87cd..55b1999a2ab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx @@ -4,17 +4,19 @@ import { ColumnDef } from "@tanstack/react-table"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell } from "@/components/shared/table_cells"; -import { userDetailHref } from "@/utils/entityLinks"; +import { UserReference } from "@/components/shared/EntityReference"; import type { VectorStoreIndex } from "./IndexesTab"; interface IndexesTableColumnsDeps { resolveVectorStoreId: (name: string) => string | undefined; + resolveUserName?: (userId: string) => string | undefined; onViewVectorStore: (vectorStoreId: string) => void; } export const getIndexesTableColumns = ({ resolveVectorStoreId, + resolveUserName = () => undefined, onViewVectorStore, }: IndexesTableColumnsDeps): ColumnDef[] => [ { @@ -84,12 +86,9 @@ export const getIndexesTableColumns = ({ const createdBy = row.original.created_by; if (createdBy) { return ( - + + + ); } return -; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx index 3224496b13a..ad45e08dd47 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx @@ -6,6 +6,16 @@ import { credentialListCall, indexesListCall, vectorStoreListCall } from "@/comp import VectorStoreManagement from "./index"; +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), +})); + +vi.mock("next/navigation", async () => ({ + ...(await vi.importActual("next/navigation")), + useRouter: () => ({ push: vi.fn() }), +})); + vi.mock("@/components/networking", () => ({ vectorStoreListCall: vi.fn(), vectorStoreDeleteCall: vi.fn(), diff --git a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.test.tsx b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.test.tsx index fee58e10fda..9ec93ac494e 100644 --- a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.test.tsx @@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import { DataTable } from "@/components/shared/DataTable"; import { getMCPHubTableColumns, MCPServerData } from "./MCPHubTableColumns"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + const SERVER_URL = "https://mcp.exa.ai/mcp"; const mockServer: MCPServerData = { diff --git a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx index db53e97569b..8402d8b7a35 100644 --- a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx @@ -6,6 +6,7 @@ import { Copy, Info, MoreHorizontal } from "lucide-react"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { MCP_REACHABLE_DESCRIPTION } from "@/components/mcp_tools/types"; import { IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +import { UserReference } from "@/components/shared/EntityReference"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { @@ -88,9 +89,13 @@ function MCPHubRowActions({ server, onServerClick }: MCPHubRowActionsProps) { interface MCPHubTableColumnsDeps { onServerClick: (server: MCPServerData) => void; + resolveUserName?: (userId: string) => string | undefined; } -export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps): ColumnDef[] => [ +export const getMCPHubTableColumns = ({ + onServerClick, + resolveUserName = () => undefined, +}: MCPHubTableColumnsDeps): ColumnDef[] => [ { id: "server_name", accessorKey: "server_name", @@ -194,11 +199,14 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps) size: 140, enableSorting: true, sortingFn: "alphanumeric", - cell: ({ row }) => ( - - {row.original.created_by || "-"} - - ), + cell: ({ row }) => + row.original.created_by ? ( + + + + ) : ( + - + ), }, { id: "is_public", diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index e5dcb8ebc73..8f609d1d2ab 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -3,6 +3,8 @@ import MakeAgentPublicForm from "@/components/AIHub/forms/MakeAgentPublicForm"; import MakeMCPPublicForm from "@/components/AIHub/forms/MakeMCPPublicForm"; import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm"; import { getMCPHubTableColumns, MCPServerData } from "@/components/AIHub/MCPHubTableColumns"; +import { UserReference } from "@/components/shared/EntityReference"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; import { getModelHubTableColumns, ModelHubData } from "@/components/AIHub/ModelHubTableColumns"; import { modelUsageExample } from "@/components/AIHub/modelUsageExample"; import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement"; @@ -402,7 +404,18 @@ const ModelHubTable: React.FC = ({ () => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]), [agentHubData, agentSearchTerm], ); - const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]); + const { data: displayNames } = useUserDisplayNames( + useMemo( + () => (mcpHubData ?? []).flatMap((server) => [server.created_by ?? "", server.updated_by ?? ""]), + [mcpHubData], + ), + ); + const resolveUserName = useCallback((userId: string) => displayNames?.[userId], [displayNames]); + + const mcpColumns = useMemo( + () => getMCPHubTableColumns({ onServerClick: showMcpModal, resolveUserName }), + [showMcpModal, resolveUserName], + ); // If this is a public page, use the dedicated PublicModelHub component if (publicPage && publicPageAllowed) { @@ -1070,11 +1083,21 @@ const ModelHubTable: React.FC = ({

Created By:

-

{selectedMcpServer.created_by}

+

+ +

Updated By:

-

{selectedMcpServer.updated_by}

+

+ +

Created At:

diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx index 837fb583f30..9de73c39e3e 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx @@ -98,6 +98,6 @@ it("leaves the default_user_id placeholder unlinked in the deleted by cell", () const team = makeDeletedTeam({ deleted_by: "default_user_id", organization_id: null }); renderWithProviders(); - expect(screen.getByText("default_user_id")).toBeInTheDocument(); + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx index 236b8b7cf18..cb5568b3cf0 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx @@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable"; import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; import { getDeletedTeamsTableColumns } from "./DeletedTeamsTableColumns"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; interface DeletedTeamsTableProps { teams: DeletedTeam[]; @@ -40,7 +41,11 @@ export function DeletedTeamsTable({ }: DeletedTeamsTableProps) { const [sorting, setSorting] = useState(DEFAULT_SORTING); - const columns = useMemo(() => getDeletedTeamsTableColumns(), []); + const { data: displayNames } = useUserDisplayNames( + useMemo(() => teams.map((team) => team.deleted_by ?? ""), [teams]), + ); + + const columns = useMemo(() => getDeletedTeamsTableColumns((userId) => displayNames?.[userId]), [displayNames]); return ( [] => [ +export const getDeletedTeamsTableColumns = ( + resolveUserName: (userId: string) => string | undefined = () => undefined, +): ColumnDef[] => [ { id: "team_alias", accessorKey: "team_alias", @@ -113,7 +116,10 @@ export const getDeletedTeamsTableColumns = (): ColumnDef[] => [ enableSorting: false, cell: ({ row }) => { const deletedBy = row.original.deleted_by; - return ; + if (!deletedBy) { + return ; + } + return ; }, }, ]; diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx index da158fb8450..61374178178 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.test.tsx @@ -5,6 +5,8 @@ import moment from "moment"; import { AuditLogDrawer } from "./AuditLogDrawer"; import { AuditLogEntry } from "../AuditLogsTableColumns"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + vi.mock("../../../common_components/DefaultProxyAdminTag", () => ({ default: ({ userId }: { userId: string }) => {userId}, })); diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx index fae3c35b80c..30a332a6e73 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogDrawer/AuditLogDrawer.tsx @@ -2,7 +2,7 @@ import { Check, Copy } from "lucide-react"; import { useState, useCallback } from "react"; import moment from "moment"; import { AuditLogEntry, AUDIT_TABLE_NAME_DISPLAY } from "../AuditLogsTableColumns"; -import DefaultProxyAdminTag from "../../../common_components/DefaultProxyAdminTag"; +import { UserReference } from "@/components/shared/EntityReference"; import CopyButton from "@/components/shared/CopyButton"; import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge"; import { Button } from "@/components/ui/button"; @@ -13,6 +13,7 @@ interface AuditLogDrawerProps { open: boolean; onClose: () => void; log: AuditLogEntry | null; + userDisplayName?: string | null; } const ACTION_TONE: Record = { @@ -170,7 +171,7 @@ function DiffSection({ log }: { log: AuditLogEntry }) { ); } -export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { +export function AuditLogDrawer({ open, onClose, log, userDisplayName }: AuditLogDrawerProps) { if (!log) return null; const tableDisplay = AUDIT_TABLE_NAME_DISPLAY[log.table_name] ?? log.table_name; @@ -200,7 +201,10 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { } /> - } /> + } + /> ({ import { uiAuditLogsCall } from "../../networking"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + type AuditLogsParams = NonNullable[0]["params"]>; const PAGE_SIZE = 50; diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsPanel.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsPanel.tsx index f174f45a791..3999a39749f 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsPanel.tsx @@ -1,10 +1,11 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table"; import { resolveLogoSrc } from "@/lib/assetPaths"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { uiAuditLogsCall } from "../../networking"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; import { AuditLogEntry } from "./AuditLogsTableColumns"; import { AuditLogsTable } from "./AuditLogsTable"; import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; @@ -98,6 +99,10 @@ export default function AuditLogsPanel({ setDrawerOpen(true); }, []); + const changedByIds = useMemo(() => (query.data?.audit_logs ?? []).map((log) => log.changed_by), [query.data]); + const { data: displayNames } = useUserDisplayNames(changedByIds); + const resolveUserName = useCallback((userId: string) => displayNames?.[userId], [displayNames]); + if (!premiumUser) { return (
@@ -129,6 +134,7 @@ export default function AuditLogsPanel({ return ( <> - setDrawerOpen(false)} log={selectedLog} /> + setDrawerOpen(false)} + log={selectedLog} + userDisplayName={selectedLog ? displayNames?.[selectedLog.changed_by] : undefined} + /> ); } diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.test.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.test.tsx index d8e549715af..112be51f27e 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.test.tsx @@ -4,6 +4,8 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { AuditLogsTable } from "./AuditLogsTable"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); import type { AuditLogEntry } from "./AuditLogsTableColumns"; const ROWS: AuditLogEntry[] = [ @@ -69,6 +71,15 @@ describe("AuditLogsTable", () => { expect(screen.getByText("sk-hash-abc")).toBeInTheDocument(); }); + it("shows the resolved user name in the Changed By column when provided", () => { + renderTable({ resolveUserName: (userId) => (userId === "user-42" ? "Ada Reviewer" : undefined) }); + + expect(screen.getByRole("link", { name: "Ada Reviewer" })).toHaveAttribute( + "href", + expect.stringContaining("user-42"), + ); + }); + it("opens the detail drawer from the Object ID identity cell with the full row", async () => { const user = userEvent.setup(); const props = renderTable(); diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx index 1f972df0f59..54df113bc5d 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx @@ -28,6 +28,7 @@ interface AuditLogsTableProps { onSearchChange?: (value: string) => void; onRefresh: () => void; onViewLog: (log: AuditLogEntry) => void; + resolveUserName?: (userId: string) => string | undefined; } const ALL_VALUE = "all"; @@ -110,9 +111,10 @@ export function AuditLogsTable({ onSearchChange, onRefresh, onViewLog, + resolveUserName, }: AuditLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); - const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]); + const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog, resolveUserName }), [onViewLog, resolveUserName]); const hasActiveSearch = Boolean(searchValue?.trim()); return ( diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx index 17e057a1343..24938509433 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx @@ -3,8 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { DateCell, IdCell, IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells"; - -import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag"; +import { UserReference } from "@/components/shared/EntityReference"; export type AuditLogEntry = { id: string; @@ -42,9 +41,13 @@ export const auditActionLabel = (action: string): string => { interface AuditLogsTableColumnsDeps { onViewLog: (log: AuditLogEntry) => void; + resolveUserName?: (userId: string) => string | undefined; } -export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef[] => [ +export const getAuditLogsTableColumns = ({ + onViewLog, + resolveUserName = () => undefined, +}: AuditLogsTableColumnsDeps): ColumnDef[] => [ { id: "updated_at", accessorKey: "updated_at", @@ -94,7 +97,9 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep header: "Changed By", size: 200, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { id: "changed_by_api_key", diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx index d950859f22e..a6af333e293 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx @@ -63,6 +63,8 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { uiSpendLogsCall } from "../../networking"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + const logEntry = (overrides: Partial): LogEntry => ({ request_id: "req-1", api_key: "key-1", diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx index 6d4a6e789b4..1ab1ed7de81 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx @@ -7,6 +7,8 @@ import { DataTable } from "@/components/shared/DataTable"; import type { LogEntry } from "../types"; import { getRequestLogsTableColumns } from "./RequestLogsTableColumns"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() })); vi.mock("@/utils/dataUtils", async (importOriginal) => ({ diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx index 5d5918e4657..e7b3c754d50 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx @@ -4,7 +4,9 @@ import type { ColumnDef } from "@tanstack/react-table"; import { Copy } from "lucide-react"; import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { EntityReference } from "@/components/shared/EntityReference"; import { CellTooltip, DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; +import { teamDetailHref } from "@/utils/entityLinks"; import { copyToClipboard, getSpendString } from "@/utils/dataUtils"; import { getProviderLogoAndName } from "../../provider_info_helpers"; @@ -275,7 +277,13 @@ export const getRequestLogsTableColumns = ({ header: "Team Name", size: 150, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => { + const teamId = row.original.team_id; + const alias = readMetaString(row.original.metadata, "user_api_key_team_alias"); + if (!teamId && !alias) return ; + if (!teamId) return ; + return ; + }, }, { id: "key_hash", diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index f555911eb5c..2bcd54ec81f 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import ModelInfoView from "./model_info_view"; import { toast } from "@/lib/toast"; import * as networking from "./networking"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); vi.mock( "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults", async () => await import("../../tests/mocks/complexityScorerDefaults"), @@ -1479,6 +1481,10 @@ describe("ModelInfoView", () => { // EditAutoRouterModal only speaks complexity and semantic. Offering it for an adaptive or // quality router lets a save write auto_router_config onto a row that stores its settings // elsewhere. These rows stay reachable from Health Status and direct ?model= links even + vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), + })); // though the Models table now excludes auto-routers, so the button itself has to be gated. describe("Edit Auto Router affordance", () => { const withRouter = (litellmParams: Record) => { diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 26c6308e3ac..f5bfc01513b 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -25,6 +25,8 @@ import { import { canEditAutoRouter, canModifyModel } from "@/utils/modelPermissions"; import { teamsUserCanAssign } from "@/utils/roles"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; +import { UserReference } from "@/components/shared/EntityReference"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal"; import ReuseCredentialsModal from "./model_add/reuse_credentials"; @@ -117,6 +119,9 @@ export default function ModelInfoView({ // Keep modelData variable name for backwards compatibility const modelData = transformedModelData; + const { data: displayNames } = useUserDisplayNames( + useMemo(() => [modelData?.model_info?.created_by ?? ""], [modelData]), + ); const aliasForTeam = (teamId: string | null | undefined): string | null => teams?.find((team) => team.team_id === teamId)?.team_alias || null; @@ -702,7 +707,15 @@ export default function ModelInfoView({ d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> - Created By {modelData.model_info.created_by || "Not Set"} + Created By{" "} + {modelData.model_info.created_by ? ( + + ) : ( + "Not Set" + )}
@@ -787,7 +800,10 @@ export default function ModelInfoView({ }, { label: "Created By", - value: modelData?.model_info?.created_by || "Not Set", + value: + (modelData?.model_info?.created_by && + (displayNames?.[modelData.model_info.created_by] ?? modelData.model_info.created_by)) || + "Not Set", }, ]} onCancel={() => setIsDeleteModalOpen(false)} diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index ca792084f2a..1c3f3f77e3a 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -375,6 +375,10 @@ describe("organization detail tab in the URL (?org_tab=)", () => { }); test("falls back to Overview for an unknown ?org_tab= and removes it from the URL", async () => { + vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({ + ...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")), + useUserDisplayNames: () => ({ data: undefined }), + })); const onUrlUpdate = vi.fn(); render(renderOrgView(), { wrapper: ({ children }) => ( diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index f325e92d2b5..73ef7872645 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -5,6 +5,8 @@ import { useUrlTab } from "@/hooks/useUrlTab"; import { useVisitedTabs } from "@/hooks/useVisitedTabs"; import { MoneyCell } from "@/components/shared/table_cells"; import CopyButton from "@/components/shared/CopyButton"; +import { UserReference } from "@/components/shared/EntityReference"; +import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -57,6 +59,7 @@ const OrganizationInfoView: React.FC = ({ const { onTabChange, hasVisited } = useVisitedTabs(tab); const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); + const { data: displayNames } = useUserDisplayNames(useMemo(() => [orgData?.created_by ?? ""], [orgData?.created_by])); const handleTabChange = (value: OrganizationTab) => { setTab(value); @@ -128,6 +131,11 @@ const OrganizationInfoView: React.FC = ({ } const orgMemberById = new Map((orgData.members || []).map((m) => [m.user_id, m])); + const createdByMember = orgData.created_by ? orgMemberById.get(orgData.created_by) : undefined; + const createdByName = + createdByMember?.user?.user_alias ?? + createdByMember?.user?.user_email ?? + (orgData.created_by ? displayNames?.[orgData.created_by] : undefined); const orgMemberFor = (record: Member) => (record.user_id != null ? orgMemberById.get(record.user_id) : undefined); const orgExtraColumns: MemberTableColumn[] = [ @@ -186,7 +194,14 @@ const OrganizationInfoView: React.FC = ({

Created: {new Date(orgData.created_at).toLocaleDateString()}

Updated: {new Date(orgData.updated_at).toLocaleDateString()}

-

Created By: {orgData.created_by}

+

+ Created By:{" "} + {orgData.created_by ? ( + + ) : ( + "-" + )} +

diff --git a/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx b/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx new file mode 100644 index 00000000000..60e38bf5504 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx @@ -0,0 +1,60 @@ +/* @vitest-environment jsdom */ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels"; +import { EntityReference, UserReference } from "./EntityReference"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + +describe("EntityReference", () => { + it("shows the name as the primary linked text when a name and href exist", () => { + render(); + expect(screen.getByRole("link", { name: "Review Team" })).toHaveAttribute("href", "/ui/teams?team=team-123"); + expect(screen.queryByText("team-123")).not.toBeInTheDocument(); + }); + + it("keeps the raw id reachable as a tooltip when a name is shown", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText("Review Team")); + expect(await screen.findByText("team-123")).toBeInTheDocument(); + }); + + it("falls back to the raw id in mono when there is no name", () => { + render(); + const link = screen.getByRole("link", { name: "team-123" }); + expect(link).toHaveAttribute("href", "/ui/teams?team=team-123"); + expect(link).toHaveClass("font-mono"); + }); + + it("renders the raw id without a link when neither name nor href exist", () => { + render(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByText("team-123")).toBeInTheDocument(); + }); +}); + +describe("UserReference", () => { + it("renders nothing for a nullish user id", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the display name linked to the user detail page", () => { + render(); + const link = screen.getByRole("link", { name: "Ada Reviewer" }); + expect(link).toHaveAttribute("href", expect.stringContaining("user-9")); + }); + + it("keeps the default proxy admin badge when the sentinel id has no name", () => { + render(); + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); + }); + + it("falls back to the raw user id when no display name resolved", () => { + render(); + expect(screen.getByRole("link", { name: "user-9" })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/EntityReference.tsx b/ui/litellm-dashboard/src/components/shared/EntityReference.tsx new file mode 100644 index 00000000000..18bda0dd145 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/EntityReference.tsx @@ -0,0 +1,41 @@ +"use client"; + +import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import { EntityLink } from "@/components/shared/EntityLink"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { userDetailHref } from "@/utils/entityLinks"; +import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels"; + +interface EntityReferenceProps { + id: string; + name?: string | null; + href?: string; +} + +export function EntityReference({ id, name, href }: EntityReferenceProps) { + if (!name) { + return ( + + {id} + + ); + } + return ( + + {name} + + ); +} + +interface UserReferenceProps { + userId: string | null | undefined; + displayName?: string | null; +} + +export function UserReference({ userId, displayName }: UserReferenceProps) { + if (userId == null || userId === "") return null; + if (userId === DEFAULT_PROXY_ADMIN_USER_ID && !displayName) { + return ; + } + return ; +} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 3f3c3a9ecc0..705f75acee0 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -25,8 +25,9 @@ import { isProxyAdminRole } from "@/utils/roles"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge"; import { BadgeLink } from "@/components/shared/BadgeLink"; +import { EntityReference } from "@/components/shared/EntityReference"; import { Badge } from "@/components/ui/badge"; -import { modelGroupHref, modelOrAccessGroupHref } from "@/utils/entityLinks"; +import { modelGroupHref, modelOrAccessGroupHref, orgDetailHref } from "@/utils/entityLinks"; import { Card } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input as UIInput } from "@/components/ui/input"; @@ -2347,8 +2348,18 @@ const TeamInfoView: React.FC = ({ )}
-

Organization ID

-
{info.organization_id}
+

Organization

+
+ {info.organization_id ? ( + + ) : ( + "-" + )} +

Status

diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 04a792bb5ca..11d420385d3 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -14,8 +14,8 @@ import { Card } from "@/components/ui/card"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { EntityLink } from "@/components/shared/EntityLink"; -import { modelOrAccessGroupHref, teamDetailHref } from "@/utils/entityLinks"; +import { EntityReference } from "@/components/shared/EntityReference"; +import { modelOrAccessGroupHref, orgDetailHref, teamDetailHref } from "@/utils/entityLinks"; import { BadgeLink } from "@/components/shared/BadgeLink"; import { KeyInfoHeader } from "./KeyInfoHeader"; import KeySavingsTab from "./KeySavingsTab"; @@ -533,6 +533,7 @@ export default function KeyInfoView({ const parentTeam = currentKeyData.team_id ? teamsData?.find((team) => team.team_id === currentKeyData.team_id) : null; const orgId = currentKeyData.organization_id || currentKeyData.org_id || parentTeam?.organization_id || ""; const parentOrg = orgId ? organizations?.find((org) => org.organization_id === orgId) : null; + const keyOrgId = currentKeyData.organization_id ?? currentKeyData.org_id; const hasOwnBudget = currentKeyData.max_budget !== null; const budgetDisplay = hasOwnBudget ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited"; @@ -916,9 +917,11 @@ export default function KeyInfoView({

Team ID

{currentKeyData.team_id ? ( - - {currentKeyData.team_id} - + ) : ( "Not Set" )} @@ -943,7 +946,17 @@ export default function KeyInfoView({

Organization

-

{(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}

+

+ {keyOrgId ? ( + + ) : ( + "Not Set" + )} +

diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 53ecf9b2d75..7bf881654e3 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -24901,6 +24901,7 @@ export interface components { created_at: string; /** Created By */ created_by?: string | null; + created_by_user?: components["schemas"]["AccessGroupResource"] | null; /** Description */ description?: string | null; /** @@ -24910,6 +24911,7 @@ export interface components { updated_at: string; /** Updated By */ updated_by?: string | null; + updated_by_user?: components["schemas"]["AccessGroupResource"] | null; }; /** AccessGroupUpdateRequest */ AccessGroupUpdateRequest: { From c7ee3eacfde4a50631ae1384923505daaff0d42a Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 18:39:46 +0000 Subject: [PATCH 02/17] fix(proxy): chunk the access group creator user_id lookup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/management_helpers/resource_display_names.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_helpers/resource_display_names.py b/litellm/proxy/management_helpers/resource_display_names.py index 333ddaa284e..347fd42d624 100644 --- a/litellm/proxy/management_helpers/resource_display_names.py +++ b/litellm/proxy/management_helpers/resource_display_names.py @@ -6,6 +6,7 @@ from typing import Final from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry from litellm.proxy.utils import PrismaClient +from litellm.repositories.chunked_in import find_many_in from litellm.repositories.table_repositories import AgentsRepository, MCPServerRepository from litellm.repositories.user_repository import UserRepository from litellm.repositories.verification_token_repository import VerificationTokenRepository @@ -66,6 +67,5 @@ async def user_display_names(prisma_client: PrismaClient, user_ids: Sequence[str """user_id -> user_alias, else user_email, for the users that have either.""" if not user_ids: return MappingProxyType({}) - where: Final = {"user_id": {"in": tuple(frozenset(user_ids))}} - rows: Final = await UserRepository(prisma_client).table.find_many(where=where) + rows: Final = await find_many_in(UserRepository(prisma_client).table, "user_id", frozenset(user_ids)) return MappingProxyType({row.user_id: name for row in rows if (name := row.user_alias or row.user_email)}) From c7e80f91d445d6287260196962ccd51e9c0862a5 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 18:53:06 +0000 Subject: [PATCH 03/17] fix(ui): look up every user display name exactly Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../(dashboard)/hooks/users/useUsers.test.ts | 34 +++++++++++++++++++ .../app/(dashboard)/hooks/users/useUsers.ts | 19 ++++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts index a6917ac1595..844696b2097 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -456,4 +456,38 @@ describe("useUserDisplayNames", () => { expect(result.current.fetchStatus).toBe("idle"); expect(userListCall).not.toHaveBeenCalled(); }); + + it("pages through ids in chunks of the max page size and merges the names", async () => { + const ids = Array.from({ length: 150 }, (_, i) => `u${String(i).padStart(3, "0")}`); + const response = buildUserListResponse(1, 1, 0); + vi.mocked(userListCall).mockImplementation(async (_token, userIDs) => ({ + ...response, + users: [{ ...response.users[0], user_id: userIDs![0], user_alias: `Alias ${userIDs![0]}` } as UserListResponse["users"][number]], + })); + + const { result } = renderHook(() => useUserDisplayNames(ids), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(userListCall).toHaveBeenCalledTimes(2); + expect(vi.mocked(userListCall).mock.calls[0][1]).toHaveLength(100); + expect(vi.mocked(userListCall).mock.calls[1][1]).toHaveLength(50); + expect(result.current.data).toEqual({ u000: "Alias u000", u100: "Alias u100" }); + }); + + it("requests the max page size and keeps only exact id matches", async () => { + const response = buildUserListResponse(1, 1, 0); + vi.mocked(userListCall).mockResolvedValue({ + ...response, + users: [ + { ...response.users[0], user_id: "ada-reviewer", user_alias: "Wrong" }, + { ...response.users[0], user_id: "ada", user_alias: "Ada" }, + ], + }); + + const { result } = renderHook(() => useUserDisplayNames(["ada"]), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(userListCall).toHaveBeenCalledWith("test-access-token", ["ada"], 1, 100); + expect(result.current.data).toEqual({ ada: "Ada" }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts index 28f8d245438..b35509ba4c0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -64,12 +64,21 @@ export const useUserDisplayNames = (userIds: readonly string[]) => { return useQuery>({ queryKey: userDisplayNameKeys.list({ filters: { ids: JSON.stringify(distinctIds) } }), queryFn: async () => { - const ids = distinctIds.slice(0, USER_LIST_MAX_PAGE_SIZE); - const response = await userListCall(accessToken!, ids, 1, ids.length); + const chunks: string[][] = []; + for (let i = 0; i < distinctIds.length; i += USER_LIST_MAX_PAGE_SIZE) { + chunks.push(distinctIds.slice(i, i + USER_LIST_MAX_PAGE_SIZE)); + } + const responses = await Promise.all( + chunks.map((ids) => userListCall(accessToken!, ids, 1, USER_LIST_MAX_PAGE_SIZE)), + ); return Object.fromEntries( - response.users.flatMap((user) => { - const name = user.user_alias || user.user_email; - return name ? [[user.user_id, name]] : []; + responses.flatMap((response, chunkIndex) => { + const requested = new Set(chunks[chunkIndex]); + return response.users.flatMap((user) => { + if (!requested.has(user.user_id)) return []; + const name = user.user_alias || user.user_email; + return name ? [[user.user_id, name]] : []; + }); }), ); }, From a309dea4f1c1bc4b1b87ac9c3341cd3b46696981 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 18:58:45 +0000 Subject: [PATCH 04/17] style(ui): format the user display name tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/app/(dashboard)/hooks/users/useUsers.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts index 844696b2097..fd5a6c18276 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -462,7 +462,13 @@ describe("useUserDisplayNames", () => { const response = buildUserListResponse(1, 1, 0); vi.mocked(userListCall).mockImplementation(async (_token, userIDs) => ({ ...response, - users: [{ ...response.users[0], user_id: userIDs![0], user_alias: `Alias ${userIDs![0]}` } as UserListResponse["users"][number]], + users: [ + { + ...response.users[0], + user_id: userIDs![0], + user_alias: `Alias ${userIDs![0]}`, + } as UserListResponse["users"][number], + ], })); const { result } = renderHook(() => useUserDisplayNames(ids), { wrapper }); From 3a6b92187f4be78b9ee6c0e62df7b43849babbdb Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 18:59:24 +0000 Subject: [PATCH 05/17] fix(ui): truncate long entity names instead of overflowing the card Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_components/AccessGroupsDetailsPage.tsx | 10 +++++----- .../projects/_components/ProjectDetailsPage.tsx | 10 +++++----- .../src/components/shared/EntityReference.test.tsx | 5 +++++ .../src/components/shared/EntityReference.tsx | 2 +- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 82d7e9b968b..2c1bbd447d7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -127,12 +127,12 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr Group Details -
+
Description
{accessGroup.description || "—"}
Created
-
- {new Date(accessGroup.created_at).toLocaleString()} +
+ {new Date(accessGroup.created_at).toLocaleString()} {accessGroup.created_by && ( <> by @@ -141,8 +141,8 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr )}
Last Updated
-
- {new Date(accessGroup.updated_at).toLocaleString()} +
+ {new Date(accessGroup.updated_at).toLocaleString()} {accessGroup.updated_by && ( <> by diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index 1105473319e..18490e753cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -111,12 +111,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { Project Details -
+
Description
{project.description || "—"}
Created
-
- {new Date(project.created_at).toLocaleString()} +
+ {new Date(project.created_at).toLocaleString()} {project.created_by && ( <> by @@ -125,8 +125,8 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { )}
Last Updated
-
- {new Date(project.updated_at).toLocaleString()} +
+ {new Date(project.updated_at).toLocaleString()} {project.updated_by && ( <> by diff --git a/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx b/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx index 60e38bf5504..1174676f121 100644 --- a/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx @@ -22,6 +22,11 @@ describe("EntityReference", () => { expect(await screen.findByText("team-123")).toBeInTheDocument(); }); + it("caps the named trigger at the container width so long names truncate", () => { + const { container } = render(); + expect(container.querySelector("span")).toHaveClass("max-w-full"); + }); + it("falls back to the raw id in mono when there is no name", () => { render(); const link = screen.getByRole("link", { name: "team-123" }); diff --git a/ui/litellm-dashboard/src/components/shared/EntityReference.tsx b/ui/litellm-dashboard/src/components/shared/EntityReference.tsx index 18bda0dd145..a25926da357 100644 --- a/ui/litellm-dashboard/src/components/shared/EntityReference.tsx +++ b/ui/litellm-dashboard/src/components/shared/EntityReference.tsx @@ -21,7 +21,7 @@ export function EntityReference({ id, name, href }: EntityReferenceProps) { ); } return ( - + {name} ); From b4a71577549fff60bf4b3dfc3b108a4c5e06299b Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 19:31:47 +0000 Subject: [PATCH 06/17] test(proxy): audit access group display names Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_access_group_display_names.py | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/integration/management/test_access_group_display_names.py b/tests/integration/management/test_access_group_display_names.py index 62fde735c56..d71b822ffb0 100644 --- a/tests/integration/management/test_access_group_display_names.py +++ b/tests/integration/management/test_access_group_display_names.py @@ -1,11 +1,13 @@ import uuid from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from typing import Final import pytest from tests.integration._support.client import Gateway, object_value, string_value +from tests.integration._support.database import write_rows FALLBACK_EMAIL: Final = f"fallback-{uuid.uuid4().hex}@example.com" @@ -87,3 +89,173 @@ def test_access_group_creator_without_alias_falls_back_to_email_then_none( "id": user_id, "name": expected_name, }, detail.text + + +def test_access_group_created_by_master_key_names_the_default_admin(gateway: Gateway) -> None: + with _access_group( + gateway, + {"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []}, + key=gateway.key, + ) as group_id: + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text + assert object_value(detail.json())["created_by_user"] == { + "id": "default_user_id", + "name": None, + }, detail.text + + +def test_access_group_creator_with_blank_alias_falls_back_to_email(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + email: Final = f"blank-alias-{uuid.uuid4().hex}@example.com" + user_id: Final = scenario.user(user_role="proxy_admin", user_alias="", user_email=email) + key: Final = scenario.key(user_id=user_id) + with _access_group( + gateway, + {"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []}, + key=key, + ) as group_id: + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text + assert object_value(detail.json())["created_by_user"] == {"id": user_id, "name": email}, detail.text + + +def test_access_group_update_by_another_user_keeps_creator_and_names_updater(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + creator_id: Final = scenario.user(user_alias="Creator One", user_role="proxy_admin") + creator_key: Final = scenario.key(user_id=creator_id) + updater_id: Final = scenario.user( + user_alias="Updater Two", user_email=f"updater-{uuid.uuid4().hex}@example.com", user_role="proxy_admin" + ) + updater_key: Final = scenario.key(user_id=updater_id) + with _access_group( + gateway, + {"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []}, + key=creator_key, + ) as group_id: + updated: Final = gateway.request( + "PUT", f"/v1/access_group/{group_id}", {"description": "by another"}, key=updater_key + ) + assert updated.status_code == 200, updated.text + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text + group: Final = object_value(detail.json()) + assert group["created_by_user"] == {"id": creator_id, "name": "Creator One"}, detail.text + assert group["updated_by_user"] == {"id": updater_id, "name": "Updater Two"}, detail.text + + +def test_access_group_creator_deleted_afterwards_resolves_name_to_none(gateway: Gateway) -> None: + user_id: Final = f"del-{uuid.uuid4().hex}" + created_user: Final = gateway.request( + "POST", "/user/new", {"user_id": user_id, "user_alias": "Soon Deleted", "user_role": "proxy_admin"} + ) + assert created_user.status_code == 200, created_user.text + minted: Final = gateway.request("POST", "/key/generate", {"user_id": user_id}) + assert minted.status_code == 200, minted.text + key: Final = string_value(minted.json()["key"]) + with _access_group( + gateway, + {"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []}, + key=key, + ) as group_id: + deleted: Final = gateway.request("POST", "/user/delete", {"user_ids": [user_id]}) + assert deleted.status_code == 200 and deleted.json() == 1, deleted.text + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text + assert object_value(detail.json())["created_by_user"] == { + "id": user_id, + "name": None, + }, detail.text + + +def test_access_group_name_reflects_the_current_alias_after_a_rename(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + user_id: Final = scenario.user(user_alias="Old Alias", user_role="proxy_admin") + key: Final = scenario.key(user_id=user_id) + with _access_group( + gateway, + {"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []}, + key=key, + ) as group_id: + renamed: Final = gateway.request("POST", "/user/update", {"user_id": user_id, "user_alias": "New Alias"}) + assert renamed.status_code == 200, renamed.text + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text + assert object_value(detail.json())["created_by_user"] == { + "id": user_id, + "name": "New Alias", + }, detail.text + + +def test_access_group_list_resolves_names_past_the_in_chunk_size(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + chunk_size: Final = 5000 + write_rows( + f'INSERT INTO "LiteLLM_UserTable" (user_id, user_alias) ' + f"SELECT 'chunk-{marker}-' || i, 'ChunkUser-' || i FROM generate_series(1, {chunk_size + 1}) AS i", + (), + ) + write_rows( + f'INSERT INTO "LiteLLM_AccessGroupTable" (access_group_id, access_group_name, created_by) ' + f"SELECT gen_random_uuid(), 'chunkgrp-{marker}-' || i, 'chunk-{marker}-' || i " + f"FROM generate_series(1, {chunk_size + 1}) AS i", + (), + ) + try: + listing: Final = gateway.request("GET", "/v1/access_group") + assert listing.status_code == 200, listing.text + entries: Final = listing.json() + assert isinstance(entries, list), listing.text + seeded: Final = [e for e in entries if str(e.get("access_group_name", "")).startswith(f"chunkgrp-{marker}-")] + assert len(seeded) == chunk_size + 1 + for entry in seeded: + assert entry["created_by_user"]["name"] == str(entry["created_by_user"]["id"]).replace( + f"chunk-{marker}-", "ChunkUser-" + ), entry + finally: + write_rows('DELETE FROM "LiteLLM_AccessGroupTable" WHERE access_group_name LIKE %s', (f"chunkgrp-{marker}-%",)) + write_rows('DELETE FROM "LiteLLM_UserTable" WHERE user_id LIKE %s', (f"chunk-{marker}-%",)) + + +def test_access_group_endpoints_reject_a_non_admin_caller(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + user_id: Final = scenario.user(user_role="internal_user") + key: Final = scenario.key(user_id=user_id) + listing: Final = gateway.request("GET", "/v1/access_group", key=key) + assert listing.status_code == 401, listing.text + + +def test_access_group_get_unknown_id_returns_404(gateway: Gateway) -> None: + response: Final = gateway.request("GET", f"/v1/access_group/{uuid.uuid4()}") + assert response.status_code == 404, response.text + + +def test_access_group_put_with_malformed_body_returns_422(gateway: Gateway) -> None: + response: Final = gateway.request("PUT", f"/v1/access_group/{uuid.uuid4()}", {"access_model_names": "not-a-list"}) + assert response.status_code == 422, response.text + + +def test_access_group_post_with_malformed_body_returns_422(gateway: Gateway) -> None: + response: Final = gateway.request("POST", "/v1/access_group", {"access_group_name": 123}) + assert response.status_code == 422, response.text + + +def test_access_group_concurrent_updates_while_listing_stay_consistent(gateway: Gateway) -> None: + with _access_group( + gateway, + {"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []}, + key=gateway.key, + ) as group_id: + + def call(index: int) -> int: + if index % 2 == 0: + response = gateway.request("PUT", f"/v1/access_group/{group_id}", {"description": f"desc-{index}"}) + else: + response = gateway.request("GET", "/v1/access_group") + return response.status_code + + with ThreadPoolExecutor(max_workers=8) as pool: + statuses: Final = list(pool.map(call, range(20))) + assert all(status == 200 for status in statuses), statuses + detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}") + assert detail.status_code == 200, detail.text From 01449fd93327dec2fe39098f2e2fef71a717a02f Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 20:31:53 +0000 Subject: [PATCH 07/17] fix(ui): resolve single user ids exactly and skip empty aliases in org creator Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../(dashboard)/hooks/users/useUsers.test.ts | 32 +++++++++++++++++-- .../app/(dashboard)/hooks/users/useUsers.ts | 5 ++- .../organization/organization_view.test.tsx | 28 ++++++++++++++++ .../organization/organization_view.tsx | 4 +-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts index fd5a6c18276..ac321d99361 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -458,7 +458,7 @@ describe("useUserDisplayNames", () => { }); it("pages through ids in chunks of the max page size and merges the names", async () => { - const ids = Array.from({ length: 150 }, (_, i) => `u${String(i).padStart(3, "0")}`); + const ids = Array.from({ length: 101 }, (_, i) => `u${String(i).padStart(3, "0")}`); const response = buildUserListResponse(1, 1, 0); vi.mocked(userListCall).mockImplementation(async (_token, userIDs) => ({ ...response, @@ -476,7 +476,7 @@ describe("useUserDisplayNames", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(userListCall).toHaveBeenCalledTimes(2); expect(vi.mocked(userListCall).mock.calls[0][1]).toHaveLength(100); - expect(vi.mocked(userListCall).mock.calls[1][1]).toHaveLength(50); + expect(vi.mocked(userListCall).mock.calls[1][1]).toEqual(["u100", "u100"]); expect(result.current.data).toEqual({ u000: "Alias u000", u100: "Alias u100" }); }); @@ -493,7 +493,33 @@ describe("useUserDisplayNames", () => { const { result } = renderHook(() => useUserDisplayNames(["ada"]), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(userListCall).toHaveBeenCalledWith("test-access-token", ["ada"], 1, 100); + expect(userListCall).toHaveBeenCalledWith("test-access-token", ["ada", "ada"], 1, 100); expect(result.current.data).toEqual({ ada: "Ada" }); }); + + it("resolves a single id even when substring matches would fill the page", async () => { + const allUsers = [ + ...Array.from({ length: 100 }, (_, i) => ({ + user_id: `ada-${i + 1}`, + user_alias: `Ada ${i + 1}`, + user_email: null, + })), + { user_id: "ada", user_alias: "Ada Exact", user_email: null }, + ]; + const response = buildUserListResponse(1, 1, 0); + vi.mocked(userListCall).mockImplementation(async (_token, userIDs, _page, pageSize) => ({ + ...response, + users: + userIDs!.length === 1 + ? (allUsers + .filter((user) => user.user_id.includes(userIDs![0])) + .slice(0, pageSize) as UserListResponse["users"]) + : (allUsers.filter((user) => userIDs!.includes(user.user_id)) as UserListResponse["users"]), + })); + + const { result } = renderHook(() => useUserDisplayNames(["ada"]), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ ada: "Ada Exact" }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts index b35509ba4c0..5cb38f8c43f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -69,7 +69,10 @@ export const useUserDisplayNames = (userIds: readonly string[]) => { chunks.push(distinctIds.slice(i, i + USER_LIST_MAX_PAGE_SIZE)); } const responses = await Promise.all( - chunks.map((ids) => userListCall(accessToken!, ids, 1, USER_LIST_MAX_PAGE_SIZE)), + chunks.map((ids) => + // a single user_ids value is a substring search on /user/list + userListCall(accessToken!, ids.length === 1 ? [ids[0], ids[0]] : ids, 1, USER_LIST_MAX_PAGE_SIZE), + ), ); return Object.fromEntries( responses.flatMap((response, chunkIndex) => { diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index 1c3f3f77e3a..6995301f12b 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -123,6 +123,34 @@ test("renders organization view after loading data", async () => { expect(orgName).toBeInTheDocument(); }); +test("shows the creator's email when their user alias is an empty string", async () => { + const orgWithBlankAliasCreator = { + ...mockOrg, + created_by: "creator-user-id", + members: [ + { + user_id: "creator-user-id", + user: { user_id: "creator-user-id", user_alias: "", user_email: "e@x.com" }, + }, + ], + }; + mockUseOrganization.mockReturnValue({ data: orgWithBlankAliasCreator, isLoading: false } as any); + + renderWithProviders( + {}} + accessToken="test-token" + is_org_admin={false} + is_proxy_admin={false} + userModels={[]} + />, + ); + + expect(await screen.findByText("e@x.com")).toBeInTheDocument(); + expect(screen.queryByText("creator-user-id")).not.toBeInTheDocument(); +}); + test("should display empty state when organization has no members", async () => { mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 73ef7872645..751902e303d 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -133,8 +133,8 @@ const OrganizationInfoView: React.FC = ({ const orgMemberById = new Map((orgData.members || []).map((m) => [m.user_id, m])); const createdByMember = orgData.created_by ? orgMemberById.get(orgData.created_by) : undefined; const createdByName = - createdByMember?.user?.user_alias ?? - createdByMember?.user?.user_email ?? + createdByMember?.user?.user_alias || + createdByMember?.user?.user_email || (orgData.created_by ? displayNames?.[orgData.created_by] : undefined); const orgMemberFor = (record: Member) => (record.user_id != null ? orgMemberById.get(record.user_id) : undefined); From 92a0b8d02ccd5798e5dbcdd2719358dc355972a1 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 21:02:27 +0000 Subject: [PATCH 08/17] fix(ui): keep long creator names inside the left half of detail cards Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../access-groups/_components/AccessGroupsDetailsPage.tsx | 2 +- .../app/(dashboard)/projects/_components/ProjectDetailsPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 2c1bbd447d7..79a1c611ea4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -127,7 +127,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr Group Details -
+
Description
{accessGroup.description || "—"}
Created
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index 18490e753cc..e1d07bae443 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -111,7 +111,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { Project Details -
+
Description
{project.description || "—"}
Created
From e68ec7939a28f10ff01ed2f597d6a7a453ac9a73 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 21:10:26 +0000 Subject: [PATCH 09/17] fix(ui): wrap creator names below the timestamp on narrow detail cards Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_components/AccessGroupsDetailsPage.tsx | 16 ++++++++-------- .../projects/_components/ProjectDetailsPage.tsx | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 79a1c611ea4..f720de77187 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -131,23 +131,23 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
Description
{accessGroup.description || "—"}
Created
-
+
{new Date(accessGroup.created_at).toLocaleString()} {accessGroup.created_by && ( - <> - by + + by - + )}
Last Updated
-
+
{new Date(accessGroup.updated_at).toLocaleString()} {accessGroup.updated_by && ( - <> - by + + by - + )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index e1d07bae443..e6c8452d950 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -115,23 +115,23 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
Description
{project.description || "—"}
Created
-
+
{new Date(project.created_at).toLocaleString()} {project.created_by && ( - <> - by + + by - + )}
Last Updated
-
+
{new Date(project.updated_at).toLocaleString()} {project.updated_by && ( - <> - by + + by - + )}
From e274267ede775eab4db26749c35b7b92a8bf7607 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 21:13:55 +0000 Subject: [PATCH 10/17] fix(ui): truncate long team alias on the project team card Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../projects/_components/ProjectDetailsPage.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index e6c8452d950..471d37d5ae7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -192,7 +192,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
-
+
@@ -213,7 +213,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { return (
-

{teamInfo.team_alias || teamInfo.team_id}

+

+ {teamInfo.team_alias || teamInfo.team_id} +

ID: {teamInfo.team_id} From d060d0267413958315fd3a6926368c54ffdfbf7b Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 21:19:53 +0000 Subject: [PATCH 11/17] fix(ui): cap entity reference width and show name plus id in the tooltip Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/shared/EntityReference.test.tsx | 12 ++++++++---- .../src/components/shared/EntityReference.tsx | 12 ++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx b/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx index 1174676f121..12ea7d41a37 100644 --- a/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/EntityReference.test.tsx @@ -15,16 +15,20 @@ describe("EntityReference", () => { expect(screen.queryByText("team-123")).not.toBeInTheDocument(); }); - it("keeps the raw id reachable as a tooltip when a name is shown", async () => { + it("shows the full name and the raw id together in the tooltip", async () => { const user = userEvent.setup(); - render(); - await user.hover(screen.getByText("Review Team")); + const longName = "Review Team " + "x".repeat(200); + render(); + const triggers = screen.getAllByText(longName); + await user.hover(triggers[0]); + const tips = await screen.findAllByText(longName); + expect(tips.length).toBeGreaterThan(1); expect(await screen.findByText("team-123")).toBeInTheDocument(); }); it("caps the named trigger at the container width so long names truncate", () => { const { container } = render(); - expect(container.querySelector("span")).toHaveClass("max-w-full"); + expect(container.querySelector("span")).toHaveClass("max-w-[min(100%,16rem)]"); }); it("falls back to the raw id in mono when there is no name", () => { diff --git a/ui/litellm-dashboard/src/components/shared/EntityReference.tsx b/ui/litellm-dashboard/src/components/shared/EntityReference.tsx index a25926da357..e3e7f8ba0cd 100644 --- a/ui/litellm-dashboard/src/components/shared/EntityReference.tsx +++ b/ui/litellm-dashboard/src/components/shared/EntityReference.tsx @@ -15,13 +15,21 @@ interface EntityReferenceProps { export function EntityReference({ id, name, href }: EntityReferenceProps) { if (!name) { return ( - + {id} ); } return ( - + + {name} + {id} + + } + className="min-w-0 max-w-[min(100%,16rem)]" + > {name} ); From 22bb7230af07af0a0e3758ab223f4f1ea11f8e60 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 21:21:21 +0000 Subject: [PATCH 12/17] fix(ui): truncate long team alias in the team page header and settings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/components/team/TeamInfo.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 705f75acee0..5fbc1c46c7b 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -2187,7 +2187,9 @@ const TeamInfoView: React.FC = ({

Team Name

-
{info.team_alias}
+
+ {info.team_alias} +

Team ID

@@ -2415,12 +2417,14 @@ const TeamInfoView: React.FC = ({ return (
-
+
-

{info.team_alias}

+

+ {info.team_alias} +

{info.team_id}

-

{orgData.organization_alias}

+

+ {orgData.organization_alias} +

{orgData.organization_id} @@ -255,8 +260,10 @@ const OrganizationInfoView: React.FC = ({

Teams

{orgData.teams?.map((team, index) => ( - - {teamAliasMap[team.team_id] || team.team_id} + + + {teamAliasMap[team.team_id] || team.team_id} + ))}
@@ -315,7 +322,9 @@ const OrganizationInfoView: React.FC = ({

Organization Name

-
{orgData.organization_alias}
+
+ {orgData.organization_alias} +

Organization ID

From c73a0c0227dcf0f9c8b2a7c289de102463ead14d Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 10 Oct 2026 21:48:18 +0000 Subject: [PATCH 17/17] fix(ui): let creator references ellipsize inside narrow model and MCP hub columns Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../models-and-endpoints/components/ModelsTableColumns.tsx | 2 +- .../src/components/AIHub/MCPHubTableColumns.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx index 7e3eeaf24d1..ce0618d5802 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -195,7 +195,7 @@ function CreatedByCell({ return (
{!isConfigModel && createdBy ? ( - + ) : ( diff --git a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx index 8402d8b7a35..566bd9a3dac 100644 --- a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx @@ -201,7 +201,7 @@ export const getMCPHubTableColumns = ({ sortingFn: "alphanumeric", cell: ({ row }) => row.original.created_by ? ( - + ) : (