mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
8ce358e303
commit
dbf8abed8c
9 changed files with 261 additions and 16 deletions
9
dev_config.yaml
Normal file
9
dev_config.yaml
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue