From d252e918239a626aa99f224759111ce4847b93aa Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Sat, 11 Jul 2026 13:12:04 -0700 Subject: [PATCH] feat(team): bulk update team members --- litellm/proxy/_types.py | 43 + litellm/proxy/auth/route_checks.py | 2 + .../management_endpoints/team_endpoints.py | 79 + .../proxy/auth/test_route_checks.py | 1 + .../proxy/test_team_member_update.py | 55 +- .../src/components/networking.tsx | 26 + .../src/components/team/TeamInfo.tsx | 6 + .../src/components/team/TeamMemberTab.tsx | 251 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 5802 +---------------- 9 files changed, 580 insertions(+), 5685 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 23fe7730c17..9b9c84861bc 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -600,6 +600,7 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_list", "/team/permissions_update", "/team/permissions_bulk_update", + "/team/member/bulk_update", "/team/daily/activity", # model "/model/new", @@ -3704,6 +3705,48 @@ class TeamMemberUpdateResponse(MemberUpdateResponse): allowed_models: Optional[List[str]] = None +class TeamMemberBulkUpdateFields(LiteLLMPydanticObjectBase): + max_budget_in_team: Optional[float] = None + role: Optional[Literal["admin", "user"]] = None + tpm_limit: Optional[int] = None + rpm_limit: Optional[int] = None + budget_duration: Optional[str] = None + allowed_models: Optional[List[str]] = None + + @model_validator(mode="after") + def require_at_least_one_field(self): + if not self.model_fields_set: + raise ValueError("update_fields must specify at least one field to update") + return self + + +class BulkTeamMemberUpdateRequest(LiteLLMPydanticObjectBase): + team_id: str + user_ids: Optional[List[str]] = None + all_members_in_team: bool = False + update_fields: TeamMemberBulkUpdateFields + + @model_validator(mode="after") + def validate_selection(self): + has_user_ids = self.user_ids is not None and len(self.user_ids) > 0 + if has_user_ids and self.all_members_in_team: + raise ValueError("Provide either user_ids or all_members_in_team=True, not both") + if not has_user_ids and not self.all_members_in_team: + raise ValueError("Must provide either user_ids (non-empty) or all_members_in_team=True") + return self + + +class FailedTeamMemberUpdate(MemberUpdateResponse): + failed_reason: str + + +class BulkTeamMemberUpdateResponse(LiteLLMPydanticObjectBase): + team_id: str + total_requested: int + successful_updates: List[TeamMemberUpdateResponse] + failed_updates: List[FailedTeamMemberUpdate] + + class TeamModelAddRequest(BaseModel): """Request to add models to a team""" diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index dd0a34a7898..70291548bb4 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -33,6 +33,7 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES = frozenset( "/team/unblock", "/team/permissions_update", "/team/permissions_bulk_update", + "/team/member/bulk_update", # model "/model/new", "/model/update", @@ -734,6 +735,7 @@ class RouteChecks: "/team/new", "/team/update", "/team/delete", + "/team/member/bulk_update", "/model/new", "/model/update", "/model/delete", diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f468f5ec30b..fdb93647c90 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -28,8 +28,11 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import ( UI_TEAM_ID, BlockTeamRequest, + BulkTeamMemberUpdateRequest, + BulkTeamMemberUpdateResponse, CommonProxyErrors, DeleteTeamRequest, + FailedTeamMemberUpdate, LiteLLM_AuditLogs, LiteLLM_DeletedTeamTable, LiteLLM_ManagementEndpoint_MetadataFields, @@ -3002,6 +3005,82 @@ async def team_member_update( ) +@router.post( + "/team/member/bulk_update", + tags=["team management"], + dependencies=[Depends(user_api_key_auth)], + response_model=BulkTeamMemberUpdateResponse, +) +@management_endpoint_wrapper +async def bulk_update_team_members( + data: BulkTeamMemberUpdateRequest, + http_request: Request, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +): + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail={"error": "No db connected"}) + + existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": data.team_id}) + if existing_team_row is None: + raise HTTPException( + status_code=400, + detail={"error": "Team id={} does not exist in db".format(data.team_id)}, + ) + existing_team = LiteLLM_TeamTable(**existing_team_row.model_dump()) + if ( + user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=existing_team) + and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=existing_team) + ): + raise HTTPException( + status_code=403, + detail={ + "error": "Call not allowed. User not proxy admin OR team admin. route={}, team_id={}".format( + "/team/member/bulk_update", data.team_id + ) + }, + ) + + if data.all_members_in_team: + user_ids = [member.user_id for member in existing_team.members_with_roles if member.user_id is not None] + else: + user_ids = list(dict.fromkeys(data.user_ids or [])) + + max_batch_size = 500 + if len(user_ids) > max_batch_size: + raise HTTPException( + status_code=400, + detail={ + "error": "Maximum {} team members can be updated at once. Found {} user_ids.".format( + max_batch_size, len(user_ids) + ) + }, + ) + + update_fields = data.update_fields.model_dump(exclude_unset=True) + successful_updates: List[TeamMemberUpdateResponse] = [] + failed_updates: List[FailedTeamMemberUpdate] = [] + for user_id in user_ids: + try: + response = await team_member_update( + data=TeamMemberUpdateRequest(team_id=data.team_id, user_id=user_id, **update_fields), + http_request=http_request, + user_api_key_dict=user_api_key_dict, + ) + successful_updates.append(response) + except HTTPException as exc: + failed_updates.append(FailedTeamMemberUpdate(user_id=user_id, failed_reason=str(exc.detail))) + + return BulkTeamMemberUpdateResponse( + team_id=data.team_id, + total_requested=len(user_ids), + successful_updates=successful_updates, + failed_updates=failed_updates, + ) + + def _create_results_from_response( members: List[Member], response: TeamAddMemberResponse, diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index a6d4dc63697..bd7fba17a3e 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -131,6 +131,7 @@ def test_proxy_admin_viewer_config_update_route_rejected(): "/team/unblock", "/team/permissions_update", "/team/permissions_bulk_update", + "/team/member/bulk_update", # JWT key mapping write routes "/jwt/key/mapping/new", "/jwt/key/mapping/update", diff --git a/tests/test_litellm/proxy/test_team_member_update.py b/tests/test_litellm/proxy/test_team_member_update.py index 352c68d491c..5150c546078 100644 --- a/tests/test_litellm/proxy/test_team_member_update.py +++ b/tests/test_litellm/proxy/test_team_member_update.py @@ -8,13 +8,18 @@ from starlette.requests import Request import litellm.proxy.proxy_server as proxy_server import litellm.proxy.management_endpoints.team_endpoints as team_endpoints from litellm.proxy._types import ( + BulkTeamMemberUpdateRequest, LiteLLM_TeamTable, LitellmUserRoles, Member, + TeamMemberBulkUpdateFields, TeamMemberUpdateRequest, UserAPIKeyAuth, ) -from litellm.proxy.management_endpoints.team_endpoints import team_member_update +from litellm.proxy.management_endpoints.team_endpoints import ( + bulk_update_team_members, + team_member_update, +) @pytest.mark.asyncio @@ -178,3 +183,51 @@ async def test_team_member_update_rejects_invalid_budget_duration( assert exc_info.value.status_code == 400 assert "budget_duration" in str(exc_info.value.detail) upsert_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_bulk_team_member_update_applies_patch_and_returns_member_failures(monkeypatch): + team_row = LiteLLM_TeamTable( + team_id="team-1234", + members_with_roles=[Member(user_id="user-1", role="user")], + ) + prisma_client = MagicMock() + prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row) + monkeypatch.setattr(proxy_server, "prisma_client", prisma_client) + update_mock = AsyncMock( + side_effect=[ + team_endpoints.TeamMemberUpdateResponse(team_id="team-1234", user_id="user-1", tpm_limit=42), + HTTPException(status_code=404, detail={"error": "User is not a team member"}), + ] + ) + monkeypatch.setattr(team_endpoints, "team_member_update", update_mock) + + response = await bulk_update_team_members( + data=BulkTeamMemberUpdateRequest( + team_id="team-1234", + user_ids=["user-1", "user-2", "user-1"], + update_fields=TeamMemberBulkUpdateFields(tpm_limit=42), + ), + http_request=Request({"type": "http", "method": "POST", "path": "/team/member/bulk_update"}), + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin"), + ) + + assert response.total_requested == 2 + assert [member.user_id for member in response.successful_updates] == ["user-1"] + assert response.failed_updates[0].user_id == "user-2" + assert "not a team member" in response.failed_updates[0].failed_reason + assert update_mock.await_args_list[0].kwargs["data"].model_dump(exclude_unset=True) == { + "team_id": "team-1234", + "user_id": "user-1", + "tpm_limit": 42, + } + + +def test_bulk_team_member_update_requires_exactly_one_member_selector(): + with pytest.raises(ValueError, match="either user_ids or all_members_in_team"): + BulkTeamMemberUpdateRequest( + team_id="team-1234", + user_ids=["user-1"], + all_members_in_team=True, + update_fields=TeamMemberBulkUpdateFields(tpm_limit=42), + ) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index da6bb079876..89c8446baa2 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2732,6 +2732,32 @@ export interface Member { allowed_models?: string[] | null; } +export interface TeamMemberBulkUpdateFields { + role?: "admin" | "user" | null; + max_budget_in_team?: number | null; + tpm_limit?: number | null; + rpm_limit?: number | null; + budget_duration?: string | null; + allowed_models?: string[] | null; +} + +export const teamMemberBulkUpdateCall = async ( + accessToken: string, + teamId: string, + userIds: string[], + allMembersInTeam: boolean, + updateFields: TeamMemberBulkUpdateFields, +) => { + return apiClient.post(`/team/member/bulk_update`, { + accessToken, + body: { + team_id: teamId, + ...(allMembersInTeam ? { all_members_in_team: true } : { user_ids: userIds }), + update_fields: updateFields, + }, + }); +}; + export const teamMemberAddCall = async (accessToken: string, teamId: string, formValues: Member) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/team/member_add` : `/team/member_add`; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 3b80c598d34..ee76d993d14 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -889,6 +889,12 @@ const TeamInfoView: React.FC = ({ setSelectedEditMember={setSelectedEditMember} setIsEditMemberModalVisible={setIsEditMemberModalVisible} setIsAddMemberModalVisible={setIsAddMemberModalVisible} + onMembersUpdated={async () => { + if (!accessToken) return; + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + onUpdate(updatedTeamData); + }} /> ), }, diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index b884490efc0..dd9eab27129 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -1,12 +1,16 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { Member } from "@/components/networking"; +import { Member, TeamMemberBulkUpdateFields, teamMemberBulkUpdateCall } from "@/components/networking"; +import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import NumericalInput from "@/components/shared/numerical_input"; import { DateCell, MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { Space, Tooltip, Typography } from "antd"; +import { Button, Checkbox, Form, Modal, Select, Space, Tooltip, Typography } from "antd"; import type { ColumnsType } from "antd/es/table"; +import { useState } from "react"; import MemberTable from "@/components/common_components/MemberTable"; import { TeamData } from "./TeamInfo"; @@ -17,6 +21,7 @@ interface TeamMemberTabProps { setSelectedEditMember: (member: Member) => void; setIsEditMemberModalVisible: (visible: boolean) => void; setIsAddMemberModalVisible: (visible: boolean) => void; + onMembersUpdated?: () => Promise; } export default function TeamMemberTab({ @@ -26,7 +31,11 @@ export default function TeamMemberTab({ setSelectedEditMember, setIsEditMemberModalVisible, setIsAddMemberModalVisible, + onMembersUpdated, }: TeamMemberTabProps) { + const [isBulkUpdateVisible, setIsBulkUpdateVisible] = useState(false); + const [isBulkUpdating, setIsBulkUpdating] = useState(false); + const [bulkUpdateForm] = Form.useForm(); const formatNumber = (value: number | null): string => { if (value === null || value === undefined) return "0"; @@ -79,7 +88,7 @@ export default function TeamMemberTab({ }; const { data: uiSettingsData } = useUISettings(); - const { userId, userRole } = useAuthorized(); + const { accessToken, userId, userRole } = useAuthorized(); const disableTeamAdminDeleteTeamUser = Boolean(uiSettingsData?.values?.disable_team_admin_delete_team_user); const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || ""); const isProxyAdmin = isProxyAdminRole(userRole || ""); @@ -183,31 +192,217 @@ export default function TeamMemberTab({ }, ]; - return ( - { - const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id); - const enhancedMember = { - ...record, - max_budget_in_team: membership?.litellm_budget_table?.max_budget || null, - tpm_limit: membership?.litellm_budget_table?.tpm_limit || null, - rpm_limit: membership?.litellm_budget_table?.rpm_limit || null, - budget_duration: membership?.litellm_budget_table?.budget_duration || null, - allowed_models: membership?.litellm_budget_table?.allowed_models || [], - }; - setSelectedEditMember(enhancedMember); - setIsEditMemberModalVisible(true); - }} - onDelete={handleMemberDelete} - onAddMember={() => setIsAddMemberModalVisible(true)} - roleColumnTitle="Team Role" - roleTooltip="This role applies only to this team and is independent from the user's proxy-level role." - extraColumns={extraColumns} - showDeleteForMember={() => - isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) + const handleBulkUpdate = async (values: { + all_members_in_team?: boolean; + user_ids?: string[]; + apply_role?: boolean; + role?: "admin" | "user"; + apply_max_budget?: boolean; + max_budget_in_team?: number | null; + apply_budget_duration?: boolean; + budget_duration?: string | null; + apply_tpm_limit?: boolean; + tpm_limit?: number | null; + apply_rpm_limit?: boolean; + rpm_limit?: number | null; + apply_allowed_models?: boolean; + allowed_models?: string[]; + }) => { + if (!accessToken) return; + const updateFields: TeamMemberBulkUpdateFields = { + ...(values.apply_role ? { role: values.role } : {}), + ...(values.apply_max_budget ? { max_budget_in_team: values.max_budget_in_team ?? null } : {}), + ...(values.apply_budget_duration ? { budget_duration: values.budget_duration ?? null } : {}), + ...(values.apply_tpm_limit ? { tpm_limit: values.tpm_limit ?? null } : {}), + ...(values.apply_rpm_limit ? { rpm_limit: values.rpm_limit ?? null } : {}), + ...(values.apply_allowed_models ? { allowed_models: values.allowed_models ?? [] } : {}), + }; + if (Object.keys(updateFields).length === 0) { + NotificationsManager.fromBackend("Select at least one field to update"); + return; + } + + setIsBulkUpdating(true); + try { + const response = await teamMemberBulkUpdateCall( + accessToken, + teamData.team_id, + values.user_ids ?? [], + values.all_members_in_team === true, + updateFields, + ); + await onMembersUpdated?.(); + setIsBulkUpdateVisible(false); + bulkUpdateForm.resetFields(); + NotificationsManager.success( + `${response.successful_updates.length} team member${response.successful_updates.length === 1 ? "" : "s"} updated`, + ); + if (response.failed_updates.length > 0) { + NotificationsManager.fromBackend(`${response.failed_updates.length} team member updates failed`); } - /> + } catch (error) { + NotificationsManager.fromBackend(error instanceof Error ? error.message : "Failed to bulk update team members"); + } finally { + setIsBulkUpdating(false); + } + }; + + return ( + <> + {canEditTeam && ( + + )} + { + const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id); + const enhancedMember = { + ...record, + max_budget_in_team: membership?.litellm_budget_table?.max_budget || null, + tpm_limit: membership?.litellm_budget_table?.tpm_limit || null, + rpm_limit: membership?.litellm_budget_table?.rpm_limit || null, + budget_duration: membership?.litellm_budget_table?.budget_duration || null, + allowed_models: membership?.litellm_budget_table?.allowed_models || [], + }; + setSelectedEditMember(enhancedMember); + setIsEditMemberModalVisible(true); + }} + onDelete={handleMemberDelete} + onAddMember={() => setIsAddMemberModalVisible(true)} + roleColumnTitle="Team Role" + roleTooltip="This role applies only to this team and is independent from the user's proxy-level role." + extraColumns={extraColumns} + showDeleteForMember={() => + isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) + } + /> + setIsBulkUpdateVisible(false)} + onOk={() => bulkUpdateForm.submit()} + okText="Update Members" + confirmLoading={isBulkUpdating} + > +
+ + Update all {teamData.team_info.members_with_roles.length} members + + + {({ getFieldValue }) => + !getFieldValue("all_members_in_team") && ( + + + + ) + } + + + Team member budget (USD) + + + {({ getFieldValue }) => + getFieldValue("apply_max_budget") && ( + + + + ) + } + + + Budget reset period + + + {({ getFieldValue }) => + getFieldValue("apply_budget_duration") && ( + + + + ) + } + + + TPM limit + + + {({ getFieldValue }) => + getFieldValue("apply_tpm_limit") && ( + + + + ) + } + + + RPM limit + + + {({ getFieldValue }) => + getFieldValue("apply_rpm_limit") && ( + + + + ) + } + + + Allowed models + + + {({ getFieldValue }) => + getFieldValue("apply_allowed_models") && ( + +