From dbf8abed8c839e8351606675c8e43bc297bf31d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 02:57:24 +0000 Subject: [PATCH] feat: resolve Created By UUIDs to user emails in Virtual Keys table - Backend: extend expand=user in /key/list to also resolve created_by user IDs and return created_by_user objects with user_id and user_email - Frontend: update VirtualKeysTable and DeletedKeysTable to show user email instead of raw UUID in the Created By column - Frontend: pass expand=user by default in useKeys and useDeletedKeys hooks - Add backend and frontend tests for the new behavior Co-authored-by: Ishaan Jaff --- dev_config.yaml | 9 ++ litellm/proxy/_types.py | 1 + .../key_management_endpoints.py | 24 +++- .../test_key_management_endpoints.py | 111 ++++++++++++++++++ .../src/app/(dashboard)/hooks/keys/useKeys.ts | 12 +- .../DeletedKeysTable/DeletedKeysTable.tsx | 17 ++- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 83 +++++++++++++ .../VirtualKeysPage/VirtualKeysTable.tsx | 15 ++- .../components/key_team_helpers/key_list.tsx | 5 + 9 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 dev_config.yaml diff --git a/dev_config.yaml b/dev_config.yaml new file mode 100644 index 00000000000..938228546db --- /dev/null +++ b/dev_config.yaml @@ -0,0 +1,9 @@ +model_list: + - model_name: fake-openai-endpoint + litellm_params: + model: openai/fake + api_key: fake-key + api_base: https://exampleopenaiendpoint-production.up.railway.app + +general_settings: + master_key: sk-1234 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 3af878f49d3..eaa92d03512 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2334,6 +2334,7 @@ class UserAPIKeyAuth( user_max_budget: Optional[float] = None request_route: Optional[str] = None user: Optional[Any] = None # Expanded user object when expand=user is used + created_by_user: Optional[Any] = None # Expanded created_by user when expand=user end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 5b56133f1ce..d427849a0e4 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -4469,10 +4469,15 @@ async def _list_key_helper( # Fetch user information if expand includes "user" user_map = {} if expand and "user" in expand: - user_ids = [key.user_id for key in keys if key.user_id] - if user_ids: + all_user_ids: set = set() + for key in keys: + if key.user_id: + all_user_ids.add(key.user_id) + if key.created_by: + all_user_ids.add(key.created_by) + if all_user_ids: users = await prisma_client.db.litellm_usertable.find_many( - where={"user_id": {"in": list(set(user_ids))}} # Remove duplicates + where={"user_id": {"in": list(all_user_ids)}} ) user_map = {user.user_id: user for user in users} @@ -4496,6 +4501,19 @@ async def _list_key_helper( except Exception: key_dict["user"] = user_map[key.user_id].dict() + # Include created_by user information if expand includes "user" + if ( + expand + and "user" in expand + and key.created_by + and key.created_by in user_map + ): + created_by_user_obj = user_map[key.created_by] + key_dict["created_by_user"] = { + "user_id": created_by_user_obj.user_id, + "user_email": created_by_user_obj.user_email, + } + if return_full_object is True or (expand and "user" in expand): if use_deleted_table: # Use deleted key type to preserve deleted_at, deleted_by, etc. diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 7565e901ecd..57995cff7a4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -4084,6 +4084,117 @@ async def test_list_keys_with_expand_user(): } +@pytest.mark.asyncio +async def test_list_keys_with_expand_user_includes_created_by_user(): + """ + Test that expand=user also resolves created_by user IDs into created_by_user objects. + """ + mock_prisma_client = AsyncMock() + + # Key where created_by differs from user_id + key1_dict = { + "token": "token1", + "user_id": "owner1", + "created_by": "creator1", + "key_alias": "key1", + "models": ["gpt-4"], + } + mock_key1 = MagicMock() + mock_key1.token = "token1" + mock_key1.user_id = "owner1" + mock_key1.created_by = "creator1" + mock_key1.model_dump = MagicMock(return_value=dict(key1_dict)) + mock_key1.dict = MagicMock(return_value=dict(key1_dict)) + + # Key where created_by is same as user_id + key2_dict = { + "token": "token2", + "user_id": "owner2", + "created_by": "owner2", + "key_alias": "key2", + "models": ["gpt-3.5-turbo"], + } + mock_key2 = MagicMock() + mock_key2.token = "token2" + mock_key2.user_id = "owner2" + mock_key2.created_by = "owner2" + mock_key2.model_dump = MagicMock(return_value=dict(key2_dict)) + mock_key2.dict = MagicMock(return_value=dict(key2_dict)) + + mock_find_many_keys = AsyncMock(return_value=[mock_key1, mock_key2]) + mock_count_keys = AsyncMock(return_value=2) + + # Create mock users (including the creator) + mock_owner1 = MagicMock() + mock_owner1.user_id = "owner1" + mock_owner1.user_email = "owner1@example.com" + mock_owner1.model_dump = MagicMock( + return_value={"user_id": "owner1", "user_email": "owner1@example.com"} + ) + + mock_creator1 = MagicMock() + mock_creator1.user_id = "creator1" + mock_creator1.user_email = "creator1@example.com" + mock_creator1.model_dump = MagicMock( + return_value={"user_id": "creator1", "user_email": "creator1@example.com"} + ) + + mock_owner2 = MagicMock() + mock_owner2.user_id = "owner2" + mock_owner2.user_email = "owner2@example.com" + mock_owner2.model_dump = MagicMock( + return_value={"user_id": "owner2", "user_email": "owner2@example.com"} + ) + + mock_find_many_users = AsyncMock( + return_value=[mock_owner1, mock_creator1, mock_owner2] + ) + + mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many_keys + mock_prisma_client.db.litellm_verificationtoken.count = mock_count_keys + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many_users + + async def mock_attach_object_permission(d, _): + return d + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.attach_object_permission_to_dict", + side_effect=mock_attach_object_permission, + ): + args = { + "prisma_client": mock_prisma_client, + "page": 1, + "size": 50, + "user_id": None, + "team_id": None, + "organization_id": None, + "key_alias": None, + "key_hash": None, + "exclude_team_id": None, + "return_full_object": False, + "admin_team_ids": None, + "include_created_by_keys": False, + "expand": ["user"], + } + + result = await _list_key_helper(**args) + + # Verify user_ids include both user_id and created_by IDs + call_args = mock_find_many_users.call_args + user_ids_in_query = set(call_args.kwargs["where"]["user_id"]["in"]) + assert user_ids_in_query == {"owner1", "owner2", "creator1"} + + # Verify created_by_user is attached + assert result["keys"][0].created_by_user == { + "user_id": "creator1", + "user_email": "creator1@example.com", + } + assert result["keys"][1].created_by_user == { + "user_id": "owner2", + "user_email": "owner2@example.com", + } + + @pytest.mark.asyncio async def test_list_keys_with_status_deleted(): """ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index cf477a2e556..9905d0fb456 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -108,9 +108,11 @@ export const useKeys = ( ): UseQueryResult => { const { accessToken } = useAuthorized(); + const mergedOptions = { expand: "user", ...options }; + return useQuery({ - queryKey: keyKeys.list({ page, limit: pageSize, ...options }), - queryFn: async () => await keyListCall(accessToken!, page, pageSize, options), + queryKey: keyKeys.list({ page, limit: pageSize, ...mergedOptions }), + queryFn: async () => await keyListCall(accessToken!, page, pageSize, mergedOptions), enabled: Boolean(accessToken), staleTime: 30000, // 30 seconds placeholderData: keepPreviousData, @@ -125,9 +127,11 @@ export const useDeletedKeys = ( ): UseQueryResult => { const { accessToken } = useAuthorized(); + const mergedOptions = { expand: "user", ...options, status: "deleted" as const }; + return useQuery({ - queryKey: deletedKeyKeys.list({ page, limit: pageSize, ...options }), - queryFn: async () => await keyListCall(accessToken!, page, pageSize, { ...options, status: "deleted" }), + queryKey: deletedKeyKeys.list({ page, limit: pageSize, ...mergedOptions }), + queryFn: async () => await keyListCall(accessToken!, page, pageSize, mergedOptions), enabled: Boolean(accessToken), staleTime: 30000, // 30 seconds placeholderData: keepPreviousData, diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx index e0d2c05bd86..0121d164f0a 100644 --- a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx @@ -189,14 +189,21 @@ export function DeletedKeysTable({ id: "created_by", accessorKey: "created_by", header: "Created By", - size: 120, - maxSize: 180, + size: 150, + maxSize: 200, cell: (info) => { const value = (info.row.original as any).created_by as string | null | undefined; + const createdByUser = (info.row.original as any).created_by_user as { user_id: string; user_email: string } | undefined; + const displayValue = value === "default_user_id" + ? "Default Proxy Admin" + : createdByUser?.user_email ?? value; + const tooltipValue = createdByUser?.user_email && value + ? `${createdByUser.user_email} (${value})` + : (displayValue ?? undefined); return ( - - - {value || "-"} + + + {displayValue || "-"} ); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 4fd513b0d2d..64374dcd611 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -539,6 +539,89 @@ it("should display 'Default Proxy Admin' for created_by when value is 'default_u }); }); +it("should display user email in Created By column when created_by_user is available", async () => { + const keyWithCreatedByUser = { + ...mockKey, + created_by: "some-uuid-1234", + created_by_user: { + user_id: "some-uuid-1234", + user_email: "creator@example.com", + }, + }; + + mockUseFilterLogic.mockReturnValue({ + filters: { + "Team ID": "", + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }, + filteredKeys: [keyWithCreatedByUser], + allTeams: [mockTeam], + allOrganizations: [mockOrganization], + handleFilterChange: vi.fn(), + handleFilterReset: vi.fn(), + }); + + const mockProps = { + teams: [mockTeam], + organizations: [mockOrganization], + onSortChange: vi.fn(), + currentSort: { + sortBy: "created_at", + sortOrder: "desc" as const, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("creator@example.com")).toBeInTheDocument(); + }); +}); + +it("should fall back to raw UUID in Created By column when created_by_user is not available", async () => { + const keyWithoutCreatedByUser = { + ...mockKey, + created_by: "some-raw-uuid-5678", + created_by_user: undefined, + }; + + mockUseFilterLogic.mockReturnValue({ + filters: { + "Team ID": "", + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }, + filteredKeys: [keyWithoutCreatedByUser], + allTeams: [mockTeam], + allOrganizations: [mockOrganization], + handleFilterChange: vi.fn(), + handleFilterReset: vi.fn(), + }); + + const mockProps = { + teams: [mockTeam], + organizations: [mockOrganization], + onSortChange: vi.fn(), + currentSort: { + sortBy: "created_at", + sortOrder: "desc" as const, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("some-raw-uuid-5678")).toBeInTheDocument(); + }); +}); + it("should render table without crashing when models is null", async () => { const keyWithNullModels = { diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index badaca93939..042c25d8a56 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -269,15 +269,22 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo id: "created_by", accessorKey: "created_by", header: "Created By", - size: 70, + size: 150, enableSorting: false, cell: (info) => { const value = info.getValue() as string | null; - const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value; + const row = info.row.original; + const createdByUser = row.created_by_user; + const displayValue = value === "default_user_id" + ? "Default Proxy Admin" + : createdByUser?.user_email ?? value; + const tooltipValue = createdByUser?.user_email && value + ? `${createdByUser.user_email} (${value})` + : (displayValue ?? undefined); const width = info.cell.column.getSize(); return ( - - + + {displayValue ?? "-"} diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 5512809ba3f..2ab53d47614 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -98,6 +98,11 @@ export interface KeyResponse { user_id: string; user_email: string; }; + created_by?: string; + created_by_user?: { + user_id: string; + user_email: string; + }; } interface KeyListResponse {