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 <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-02-28 02:57:24 +00:00
parent 8ce358e303
commit dbf8abed8c
9 changed files with 261 additions and 16 deletions

9
dev_config.yaml Normal file
View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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():
"""

View file

@ -108,9 +108,11 @@ export const useKeys = (
): UseQueryResult<KeysResponse> => {
const { accessToken } = useAuthorized();
const mergedOptions = { expand: "user", ...options };
return useQuery<KeysResponse>({
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<KeysResponse> => {
const { accessToken } = useAuthorized();
const mergedOptions = { expand: "user", ...options, status: "deleted" as const };
return useQuery<KeysResponse>({
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,

View file

@ -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 (
<Tooltip title={value || undefined}>
<span className="truncate block max-w-[180px]">
{value || "-"}
<Tooltip title={tooltipValue}>
<span className="text-xs truncate block max-w-[200px]">
{displayValue || "-"}
</span>
</Tooltip>
);

View file

@ -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(<VirtualKeysTable {...mockProps} />);
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(<VirtualKeysTable {...mockProps} />);
await waitFor(() => {
expect(screen.getByText("some-raw-uuid-5678")).toBeInTheDocument();
});
});
it("should render table without crashing when models is null", async () => {
const keyWithNullModels = {

View file

@ -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 (
<Tooltip title={displayValue}>
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
<Tooltip title={tooltipValue}>
<span className={`text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
{displayValue ?? "-"}
</span>
</Tooltip>

View file

@ -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 {