UI (Teams Page) - Support filtering by team id + team name (#10324)

* feat(team_endpoints.py): support new `/v2/team/list` endpoint - paginated + partial filtering support

Allows UI to support partial filtering on team tab

* feat(teams/): working team filtering based on partial alias

allows easier search on team alias on UI

* fix(teams.tsx): clarify team id needs to be specified (partial match not supported here)

working team id lookup on litellm UI

* fix(proxy/_types.py): allow `/v2/team/list` to be called for internal user

allows filtering

* fix(team_endpoints.py): fix returning non-admin only teams they're a member of

* fix(team_endpoints.py): fix check
This commit is contained in:
Krish Dholakia 2025-04-25 18:19:29 -07:00 committed by GitHub
parent 1cd6e78ccb
commit fee3dc970c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 444 additions and 2 deletions

View File

@ -331,6 +331,7 @@ class LiteLLMRoutes(enum.Enum):
"/key/health",
"/team/info",
"/team/list",
"/v2/team/list",
"/organization/list",
"/team/available",
"/user/info",
@ -370,6 +371,7 @@ class LiteLLMRoutes(enum.Enum):
"/team/update",
"/team/delete",
"/team/list",
"/v2/team/list",
"/team/info",
"/team/block",
"/team/unblock",

View File

@ -14,7 +14,7 @@ import json
import traceback
import uuid
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Tuple, Union, cast
from typing import Any, Dict, List, Optional, Tuple, Union, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@ -85,6 +85,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
)
from litellm.types.proxy.management_endpoints.team_endpoints import (
GetTeamMemberPermissionsResponse,
TeamListResponse,
UpdateTeamMemberPermissionsRequest,
)
@ -1553,6 +1554,150 @@ async def list_available_teams(
return available_teams_correct_type
@router.get(
"/v2/team/list",
tags=["team management"],
response_model=TeamListResponse,
dependencies=[Depends(user_api_key_auth)],
)
async def list_team_v2(
http_request: Request,
user_id: Optional[str] = fastapi.Query(
default=None, description="Only return teams which this 'user_id' belongs to"
),
organization_id: Optional[str] = fastapi.Query(
default=None,
description="Only return teams which this 'organization_id' belongs to",
),
team_id: Optional[str] = fastapi.Query(
default=None, description="Only return teams which this 'team_id' belongs to"
),
team_alias: Optional[str] = fastapi.Query(
default=None,
description="Only return teams which this 'team_alias' belongs to. Supports partial matching.",
),
page: int = fastapi.Query(
default=1, description="Page number for pagination", ge=1
),
page_size: int = fastapi.Query(
default=10, description="Number of teams per page", ge=1, le=100
),
sort_by: Optional[str] = fastapi.Query(
default=None,
description="Column to sort by (e.g. 'team_id', 'team_alias', 'created_at')",
),
sort_order: str = fastapi.Query(
default="asc", description="Sort order ('asc' or 'desc')"
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get a paginated list of teams with filtering and sorting options.
Parameters:
user_id: Optional[str]
Only return teams which this user belongs to
organization_id: Optional[str]
Only return teams which belong to this organization
team_id: Optional[str]
Filter teams by exact team_id match
team_alias: Optional[str]
Filter teams by partial team_alias match
page: int
The page number to return
page_size: int
The number of items per page
sort_by: Optional[str]
Column to sort by (e.g. 'team_id', 'team_alias', 'created_at')
sort_order: str
Sort order ('asc' or 'desc')
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": f"No db connected. prisma client={prisma_client}"},
)
if user_id is None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
user_id = user_api_key_dict.user_id
# Calculate skip and take for pagination
skip = (page - 1) * page_size
# Build where conditions based on provided parameters
where_conditions: Dict[str, Any] = {}
if team_id:
where_conditions["team_id"] = team_id
if team_alias:
where_conditions["team_alias"] = {
"contains": team_alias,
"mode": "insensitive", # Case-insensitive search
}
if organization_id:
where_conditions["organization_id"] = organization_id
if user_id:
try:
user_object = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_id}
)
except Exception:
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)
if user_object is None:
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)
user_object_correct_type = LiteLLM_UserTable(**user_object.model_dump())
# Find teams where this user is a member by checking members_with_roles array
if team_id is None:
where_conditions["team_id"] = {"in": user_object_correct_type.teams}
elif team_id in user_object_correct_type.teams:
where_conditions["team_id"] = team_id
else:
raise HTTPException(
status_code=404,
detail={"error": f"User is not a member of team_id={team_id}"},
)
# Build order_by conditions
valid_sort_columns = ["team_id", "team_alias", "created_at"]
order_by = None
if sort_by and sort_by in valid_sort_columns:
if sort_order.lower() not in ["asc", "desc"]:
sort_order = "asc"
order_by = {sort_by: sort_order.lower()}
# Get teams with pagination
teams = await prisma_client.db.litellm_teamtable.find_many(
where=where_conditions,
skip=skip,
take=page_size,
order=order_by if order_by else {"created_at": "desc"}, # Default sort
)
# Get total count for pagination
total_count = await prisma_client.db.litellm_teamtable.count(where=where_conditions)
# Calculate total pages
total_pages = -(-total_count // page_size) # Ceiling division
return {
"teams": [team.model_dump() for team in teams] if teams else [],
"total": total_count,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get(
"/team/list", tags=["team management"], dependencies=[Depends(user_api_key_auth)]
)

View File

@ -2,6 +2,8 @@ from typing import List, Optional
from pydantic import BaseModel
from litellm.proxy._types import LiteLLM_TeamTable
class GetTeamMemberPermissionsRequest(BaseModel):
"""Request to get the team member permissions for a team"""
@ -33,3 +35,13 @@ class UpdateTeamMemberPermissionsRequest(BaseModel):
team_id: str
team_member_permissions: List[str]
class TeamListResponse(BaseModel):
"""Response to get the list of teams"""
teams: List[LiteLLM_TeamTable]
total: int
page: int
page_size: int
total_pages: int

View File

@ -848,10 +848,85 @@ export const teamInfoCall = async (
}
};
type TeamListResponse = {
teams: Team[];
total: number;
page: number;
page_size: number;
total_pages: number;
};
export const v2TeamListCall = async (
accessToken: String,
organizationID: string | null,
userID: String | null = null,
teamID: string | null = null,
team_alias: string | null = null,
page: number = 1,
page_size: number = 10,
sort_by: string | null = null,
sort_order: 'asc' | 'desc' | null = null,
): Promise<TeamListResponse> => {
/**
* Get list of teams with filtering and sorting options
*/
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/v2/team/list` : `/v2/team/list`;
console.log("in teamInfoCall");
const queryParams = new URLSearchParams();
if (userID) {
queryParams.append('user_id', userID.toString());
}
if (organizationID) {
queryParams.append('organization_id', organizationID.toString());
}
if (teamID) {
queryParams.append('team_id', teamID.toString());
}
if (team_alias) {
queryParams.append('team_alias', team_alias.toString());
}
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;
}
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
console.log("/v2/team/list API Response:", data);
return data;
// Handle success - you might want to update some state or UI based on the created key
} catch (error) {
console.error("Failed to create key:", error);
throw error;
}
};
export const teamListCall = async (
accessToken: String,
organizationID: string | null,
userID: String | null = null,
teamID: string | null = null,
team_alias: string | null = null,
) => {
/**
* Get all available teams on proxy
@ -868,6 +943,14 @@ export const teamListCall = async (
if (organizationID) {
queryParams.append('organization_id', organizationID.toString());
}
if (teamID) {
queryParams.append('team_id', teamID.toString());
}
if (team_alias) {
queryParams.append('team_alias', team_alias.toString());
}
const queryString = queryParams.toString();
if (queryString) {

View File

@ -71,6 +71,14 @@ interface TeamProps {
organizations: Organization[] | null;
}
interface FilterState {
team_id: string;
team_alias: string;
organization_id: string;
sort_by: string;
sort_order: 'asc' | 'desc';
}
interface EditTeamModalProps {
visible: boolean;
onCancel: () => void;
@ -84,7 +92,7 @@ import {
teamMemberUpdateCall,
Member,
modelAvailableCall,
teamListCall
v2TeamListCall
} from "./networking";
import { updateExistingKeys } from "@/utils/dataUtils";
@ -119,6 +127,14 @@ const Teams: React.FC<TeamProps> = ({
const [lastRefreshed, setLastRefreshed] = useState("");
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState<Organization | null>(null);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<FilterState>({
team_id: "",
team_alias: "",
organization_id: "",
sort_by: "created_at",
sort_order: "desc"
});
useEffect(() => {
console.log(`inside useeffect - ${lastRefreshed}`)
@ -318,6 +334,71 @@ const Teams: React.FC<TeamProps> = ({
setLastRefreshed(currentDate.toLocaleString());
};
const handleFilterChange = (key: keyof FilterState, value: string) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
// Call teamListCall with the new filters
if (accessToken) {
v2TeamListCall(
accessToken,
newFilters.organization_id || null,
null,
newFilters.team_id || null,
newFilters.team_alias || null
).then((response) => {
if (response && response.teams) {
setTeams(response.teams);
}
}).catch((error) => {
console.error("Error fetching teams:", error);
});
}
};
const handleSortChange = (sortBy: string, sortOrder: 'asc' | 'desc') => {
const newFilters = {
...filters,
sort_by: sortBy,
sort_order: sortOrder
};
setFilters(newFilters);
// Call teamListCall with the new sort parameters
if (accessToken) {
v2TeamListCall(
accessToken,
filters.organization_id || null,
null,
filters.team_id || null,
filters.team_alias || null
).then((response) => {
if (response && response.teams) {
setTeams(response.teams);
}
}).catch((error) => {
console.error("Error fetching teams:", error);
});
}
};
const handleFilterReset = () => {
setFilters({
team_id: "",
team_alias: "",
organization_id: "",
sort_by: "created_at",
sort_order: "desc"
});
// Reset teams list
if (accessToken) {
v2TeamListCall(accessToken, null, userID || null, null, null).then((response) => {
if (response && response.teams) {
setTeams(response.teams);
}
}).catch((error) => {
console.error("Error fetching teams:", error);
});
}
};
return (
<div className="w-full mx-4 h-[75vh]">
@ -377,6 +458,125 @@ const Teams: React.FC<TeamProps> = ({
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
<Col numColSpan={1}>
<Card className="w-full mx-auto flex-auto overflow-hidden overflow-y-auto max-h-[50vh]">
<div className="border-b px-6 py-4">
<div className="flex flex-col space-y-4">
{/* Search and Filter Controls */}
<div className="flex flex-wrap items-center gap-3">
{/* Team Alias Search */}
<div className="relative w-64">
<input
type="text"
placeholder="Search by Team Name..."
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={filters.team_alias}
onChange={(e) => handleFilterChange('team_alias', e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/* Filter Button */}
<button
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? 'bg-gray-100' : ''}`}
onClick={() => setShowFilters(!showFilters)}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filters
{(filters.team_id || filters.team_alias || filters.organization_id) && (
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
)}
</button>
{/* Reset Filters Button */}
<button
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
onClick={handleFilterReset}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Reset Filters
</button>
</div>
{/* Additional Filters */}
{showFilters && (
<div className="flex flex-wrap items-center gap-3 mt-3">
{/* Team ID Search */}
<div className="relative w-64">
<input
type="text"
placeholder="Enter Team ID"
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={filters.team_id}
onChange={(e) => handleFilterChange('team_id', e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{/* Organization Dropdown */}
<div className="w-64">
<Select
value={filters.organization_id || ""}
onValueChange={(value) => handleFilterChange('organization_id', value)}
placeholder="Select Organization"
>
{organizations?.map((org) => (
<SelectItem key={org.organization_id} value={org.organization_id || ""}>
{org.organization_alias || org.organization_id}
</SelectItem>
))}
</Select>
</div>
</div>
)}
</div>
</div>
<Table>
<TableHead>
<TableRow>