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:
parent
1cd6e78ccb
commit
fee3dc970c
@ -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",
|
||||
|
||||
@ -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)]
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user