Skip to content

Add global filtering to Users tab #10195

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 22, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion litellm/proxy/_experimental/out/onboarding.html

This file was deleted.

2 changes: 2 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
@@ -687,6 +687,8 @@ class GenerateKeyResponse(KeyRequestBase):
token: Optional[str] = None
created_by: Optional[str] = None
updated_by: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

@model_validator(mode="before")
@classmethod
62 changes: 35 additions & 27 deletions litellm/proxy/management_endpoints/internal_user_endpoints.py
Original file line number Diff line number Diff line change
@@ -43,6 +43,9 @@
SpendAnalyticsPaginatedResponse,
SpendMetrics,
)
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
UserListResponse,
)

router = APIRouter()

@@ -899,15 +902,11 @@ async def get_user_key_counts(
return result


@router.get(
"/user/get_users",
tags=["Internal User management"],
dependencies=[Depends(user_api_key_auth)],
)
@router.get(
"/user/list",
tags=["Internal User management"],
dependencies=[Depends(user_api_key_auth)],
response_model=UserListResponse,
)
async def get_users(
role: Optional[str] = fastapi.Query(
@@ -916,15 +915,19 @@ async def get_users(
user_ids: Optional[str] = fastapi.Query(
default=None, description="Get list of users by user_ids"
),
user_email: Optional[str] = fastapi.Query(
default=None, description="Filter users by partial email match"
),
team: Optional[str] = fastapi.Query(
default=None, description="Filter users by team id"
),
page: int = fastapi.Query(default=1, ge=1, description="Page number"),
page_size: int = fastapi.Query(
default=25, ge=1, le=100, description="Number of items per page"
),
):
"""
Get a paginated list of users, optionally filtered by role.
Used by the UI to populate the user lists.
Get a paginated list of users with filtering options.
Parameters:
role: Optional[str]
@@ -935,17 +938,17 @@ async def get_users(
- internal_user_viewer
user_ids: Optional[str]
Get list of users by user_ids. Comma separated list of user_ids.
user_email: Optional[str]
Filter users by partial email match
team: Optional[str]
Filter users by team id. Will match if user has this team in their teams array.
page: int
The page number to return
page_size: int
The number of items per page
Currently - admin-only endpoint.
Example curl:
```
http://0.0.0.0:4000/user/list?user_ids=default_user_id,693c1a4a-1cc0-4c7c-afe8-b5d2c8d52e17
```
Returns:
UserListResponse with filtered and paginated users
"""
from litellm.proxy.proxy_server import prisma_client

@@ -958,35 +961,40 @@ async def get_users(
# Calculate skip and take for pagination
skip = (page - 1) * page_size

# Prepare the query conditions
# Build where conditions based on provided parameters
where_conditions: Dict[str, Any] = {}

if role:
where_conditions["user_role"] = {
"contains": role,
"mode": "insensitive", # Case-insensitive search
}
where_conditions["user_role"] = role # Exact match instead of contains

if user_ids and isinstance(user_ids, str):
user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
where_conditions["user_id"] = {
"in": user_id_list, # Now passing a list of strings as required by Prisma
"in": user_id_list,
}

users: Optional[
List[LiteLLM_UserTable]
] = await prisma_client.db.litellm_usertable.find_many(
if user_email is not None and isinstance(user_email, str):
where_conditions["user_email"] = {
"contains": user_email,
"mode": "insensitive", # Case-insensitive search
}

if team is not None and isinstance(team, str):
where_conditions["teams"] = {
"has": team # Array contains for string arrays in Prisma
}

## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}}
where_conditions = {k: v for k, v in where_conditions.items() if v is not None}
users = await prisma_client.db.litellm_usertable.find_many(
where=where_conditions,
skip=skip,
take=page_size,
order={"created_at": "desc"},
)

# Get total count of user rows
total_count = await prisma_client.db.litellm_usertable.count(
where=where_conditions # type: ignore
)
total_count = await prisma_client.db.litellm_usertable.count(where=where_conditions)

# Get key count for each user
if users is not None:
@@ -1009,7 +1017,7 @@ async def get_users(
LiteLLM_UserTableWithKeyCount(
**user.model_dump(), key_count=user_key_counts.get(user.user_id, 0)
)
) # Return full key object
)
else:
user_list = []

Original file line number Diff line number Diff line change
@@ -1347,10 +1347,13 @@ async def generate_key_helper_fn( # noqa: PLR0915
create_key_response = await prisma_client.insert_data(
data=key_data, table_name="key"
)

key_data["token_id"] = getattr(create_key_response, "token", None)
key_data["litellm_budget_table"] = getattr(
create_key_response, "litellm_budget_table", None
)
key_data["created_at"] = getattr(create_key_response, "created_at", None)
key_data["updated_at"] = getattr(create_key_response, "updated_at", None)
except Exception as e:
verbose_proxy_logger.error(
"litellm.proxy.proxy_server.generate_key_helper_fn(): Exception occured - {}".format(
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from typing import Any, Dict, List, Literal, Optional, Union

from fastapi import HTTPException
from pydantic import BaseModel, EmailStr

from litellm.proxy._types import LiteLLM_UserTableWithKeyCount


class UserListResponse(BaseModel):
"""
Response model for the user list endpoint
"""

users: List[LiteLLM_UserTableWithKeyCount]
total: int
page: int
page_size: int
total_pages: int
4 changes: 4 additions & 0 deletions test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
119 changes: 119 additions & 0 deletions tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
Search Users in Admin UI
E2E Test for user search functionality
Tests:
1. Navigate to Internal Users tab
2. Verify search input exists
3. Test search functionality
4. Verify results update
*/

import { test, expect } from "@playwright/test";

test("user search test", async ({ page }) => {
// Set a longer timeout for the entire test
test.setTimeout(60000);

// Enable console logging
page.on("console", (msg) => console.log("PAGE LOG:", msg.text()));

// Login first
await page.goto("http://localhost:4000/ui");
console.log("Navigated to login page");

// Wait for login form to be visible
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
console.log("Login form is visible");

await page.fill('input[name="username"]', "admin");
await page.fill('input[name="password"]', "gm");
console.log("Filled login credentials");

const loginButton = page.locator('input[type="submit"]');
await expect(loginButton).toBeEnabled();
await loginButton.click();
console.log("Clicked login button");

// Wait for navigation to complete and dashboard to load
await page.waitForLoadState("networkidle");
console.log("Page loaded after login");

// Take a screenshot for debugging
await page.screenshot({ path: "after-login.png" });
console.log("Took screenshot after login");

// Try to find the Internal User tab with more debugging
console.log("Looking for Internal User tab...");
const internalUserTab = page.locator("span.ant-menu-title-content", {
hasText: "Internal User",
});

// Wait for the tab to be visible
await internalUserTab.waitFor({ state: "visible", timeout: 10000 });
console.log("Internal User tab is visible");

// Take another screenshot before clicking
await page.screenshot({ path: "before-tab-click.png" });
console.log("Took screenshot before tab click");

await internalUserTab.click();
console.log("Clicked Internal User tab");

// Wait for the page to load and table to be visible
await page.waitForSelector("tbody tr", { timeout: 10000 });
await page.waitForTimeout(2000); // Additional wait for table to stabilize
console.log("Table is visible");

// Take a final screenshot
await page.screenshot({ path: "after-tab-click.png" });
console.log("Took screenshot after tab click");

// Verify search input exists
const searchInput = page.locator('input[placeholder="Search by email..."]');
await expect(searchInput).toBeVisible();
console.log("Search input is visible");

// Test search functionality
const initialUserCount = await page.locator("tbody tr").count();
console.log(`Initial user count: ${initialUserCount}`);

// Perform a search
const testEmail = "test@";
await searchInput.fill(testEmail);
console.log("Filled search input");

// Wait for the debounced search to complete
await page.waitForTimeout(500);
console.log("Waited for debounce");

// Wait for the results count to update
await page.waitForFunction((initialCount) => {
const currentCount = document.querySelectorAll("tbody tr").length;
return currentCount !== initialCount;
}, initialUserCount);
console.log("Results updated");

const filteredUserCount = await page.locator("tbody tr").count();
console.log(`Filtered user count: ${filteredUserCount}`);

expect(filteredUserCount).toBeDefined();

// Clear the search
await searchInput.clear();
console.log("Cleared search");

await page.waitForTimeout(500);
console.log("Waited for debounce after clear");

await page.waitForFunction((initialCount) => {
const currentCount = document.querySelectorAll("tbody tr").length;
return currentCount === initialCount;
}, initialUserCount);
console.log("Results reset");

const resetUserCount = await page.locator("tbody tr").count();
console.log(`Reset user count: ${resetUserCount}`);

expect(resetUserCount).toBe(initialUserCount);
});
73 changes: 51 additions & 22 deletions tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts
Original file line number Diff line number Diff line change
@@ -2,45 +2,74 @@
Test view internal user page
*/

import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";

test('view internal user page', async ({ page }) => {
test("view internal user page", async ({ page }) => {
// Go to the specified URL
await page.goto('http://localhost:4000/ui');
await page.goto("http://localhost:4000/ui");

// Enter "admin" in the username input field
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="username"]', "admin");

// Enter "gm" in the password input field
await page.fill('input[name="password"]', 'gm');
await page.fill('input[name="password"]', "gm");

// Optionally, you can add an assertion to verify the login button is enabled
// Click the login button
const loginButton = page.locator('input[type="submit"]');
await expect(loginButton).toBeEnabled();

// Optionally, you can click the login button to submit the form
await loginButton.click();

const tabElement = page.locator('span.ant-menu-title-content', { hasText: 'Internal User' });
// Wait for the Internal User tab and click it
const tabElement = page.locator("span.ant-menu-title-content", {
hasText: "Internal User",
});
await tabElement.click();

// try to click on button
// <button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-l focus:outline-none" disabled="">← Prev</button>
// wait 1-2 seconds
await page.waitForTimeout(10000);
// Wait for the table to load
await page.waitForSelector("tbody tr", { timeout: 10000 });
await page.waitForTimeout(2000); // Additional wait for table to stabilize

// Test all expected fields are present
// number of keys owned by user
const keysBadges = page.locator('p.tremor-Badge-text.text-sm.whitespace-nowrap', { hasText: 'Keys' });
const keysCountArray = await keysBadges.evaluateAll(elements => elements.map(el => parseInt(el.textContent.split(' ')[0], 10)));
// Test all expected fields are present
// number of keys owned by user
const keysBadges = page.locator(
"p.tremor-Badge-text.text-sm.whitespace-nowrap",
{ hasText: "Keys" }
);
const keysCountArray = await keysBadges.evaluateAll((elements) =>
elements.map((el) => {
const text = el.textContent;
return text ? parseInt(text.split(" ")[0], 10) : 0;
})
);

const hasNonZeroKeys = keysCountArray.some(count => count > 0);
const hasNonZeroKeys = keysCountArray.some((count) => count > 0);
expect(hasNonZeroKeys).toBe(true);

// test pagination
const prevButton = page.locator('button.px-3.py-1.text-sm.border.rounded-md.hover\\:bg-gray-50.disabled\\:opacity-50.disabled\\:cursor-not-allowed', { hasText: 'Previous' });
await expect(prevButton).toBeDisabled();
// Wait for pagination controls to be visible
await page.waitForSelector(".flex.justify-between.items-center", {
timeout: 5000,
});

// Check if we're on the first page by looking at the results count
const resultsText =
(await page.locator(".text-sm.text-gray-700").textContent()) || "";
const isFirstPage = resultsText.includes("1 -");

if (isFirstPage) {
// On first page, previous button should be disabled
const prevButton = page.locator("button", { hasText: "Previous" });
await expect(prevButton).toBeDisabled();
}

// Next button should be enabled if there are more pages
const nextButton = page.locator("button", { hasText: "Next" });
const totalResults =
(await page.locator(".text-sm.text-gray-700").textContent()) || "";
const hasMorePages =
totalResults.includes("of") && !totalResults.includes("1 - 25 of 25");

const nextButton = page.locator('button.px-3.py-1.text-sm.border.rounded-md.hover\\:bg-gray-50.disabled\\:opacity-50.disabled\\:cursor-not-allowed', { hasText: 'Next' });
await expect(nextButton).toBeEnabled();
if (hasMorePages) {
await expect(nextButton).toBeEnabled();
}
});
8 changes: 8 additions & 0 deletions tests/proxy_admin_ui_tests/test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"status": "failed",
"failedTests": [
"8306bf902634636ae770-183086b993a71bc98dd6",
"1bfc70f64c2dd4741dbb-58cd256736ebe53a2d97",
"ea1c46def20befad7a54-cb6c473c41474485b610"
]
}
12 changes: 12 additions & 0 deletions tests/test_keys.py
Original file line number Diff line number Diff line change
@@ -109,6 +109,18 @@ async def test_key_gen():
await asyncio.gather(*tasks)


@pytest.mark.asyncio
async def test_simple_key_gen():
async with aiohttp.ClientSession() as session:
key_data = await generate_key(session, i=0)
key = key_data["key"]
assert key_data["token"] is not None
assert key_data["token"] != key
assert key_data["token_id"] is not None
assert key_data["created_at"] is not None
assert key_data["updated_at"] is not None


@pytest.mark.asyncio
async def test_key_gen_bad_key():
"""
15 changes: 15 additions & 0 deletions ui/litellm-dashboard/src/components/networking.tsx
Original file line number Diff line number Diff line change
@@ -676,6 +676,9 @@ export const userListCall = async (
userIDs: string[] | null = null,
page: number | null = null,
page_size: number | null = null,
userEmail: string | null = null,
userRole: string | null = null,
team: string | null = null,
) => {
/**
* Get all available teams on proxy
@@ -698,6 +701,18 @@ export const userListCall = async (
if (page_size) {
queryParams.append('page_size', page_size.toString());
}

if (userEmail) {
queryParams.append('user_email', userEmail);
}

if (userRole) {
queryParams.append('role', userRole);
}

if (team) {
queryParams.append('team', team);
}

const queryString = queryParams.toString();
if (queryString) {
327 changes: 289 additions & 38 deletions ui/litellm-dashboard/src/components/view_users.tsx

Large diffs are not rendered by default.