feat(team): bulk update team members

This commit is contained in:
mubashir1osmani 2026-07-11 13:12:04 -07:00
parent 173193d717
commit d252e91823
9 changed files with 580 additions and 5685 deletions

View file

@ -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"""

View file

@ -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",

View file

@ -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,

View file

@ -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",

View file

@ -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),
)

View file

@ -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`;

View file

@ -889,6 +889,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
setSelectedEditMember={setSelectedEditMember}
setIsEditMemberModalVisible={setIsEditMemberModalVisible}
setIsAddMemberModalVisible={setIsAddMemberModalVisible}
onMembersUpdated={async () => {
if (!accessToken) return;
const updatedTeamData = await teamInfoCall(accessToken, teamId);
setTeamData(updatedTeamData);
onUpdate(updatedTeamData);
}}
/>
),
},

View file

@ -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<void>;
}
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 (
<MemberTable
members={teamData.team_info.members_with_roles}
canEdit={canEditTeam}
onEdit={(record) => {
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 && (
<Button className="mb-3" onClick={() => setIsBulkUpdateVisible(true)}>
Bulk Update Members
</Button>
)}
<MemberTable
members={teamData.team_info.members_with_roles}
canEdit={canEditTeam}
onEdit={(record) => {
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)
}
/>
<Modal
title="Bulk Update Team Members"
open={isBulkUpdateVisible}
onCancel={() => setIsBulkUpdateVisible(false)}
onOk={() => bulkUpdateForm.submit()}
okText="Update Members"
confirmLoading={isBulkUpdating}
>
<Form form={bulkUpdateForm} layout="vertical" onFinish={handleBulkUpdate}>
<Form.Item name="all_members_in_team" valuePropName="checked">
<Checkbox>Update all {teamData.team_info.members_with_roles.length} members</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
!getFieldValue("all_members_in_team") && (
<Form.Item
name="user_ids"
label="Members"
rules={[{ required: true, message: "Select at least one member" }]}
>
<Select
mode="multiple"
placeholder="Select members"
options={teamData.team_info.members_with_roles
.filter((member) => member.user_id)
.map((member) => ({
label: member.user_email || member.user_id,
value: member.user_id as string,
}))}
/>
</Form.Item>
)
}
</Form.Item>
<Typography.Text type="secondary">Choose the fields to apply to every selected member.</Typography.Text>
<Form.Item name="apply_role" valuePropName="checked" className="mt-3 mb-1">
<Checkbox>Team role</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue("apply_role") && (
<Form.Item name="role" rules={[{ required: true, message: "Select a role" }]}>
<Select
placeholder="Select role"
options={[
{ label: "Admin", value: "admin" },
{ label: "User", value: "user" },
]}
/>
</Form.Item>
)
}
</Form.Item>
<Form.Item name="apply_max_budget" valuePropName="checked" className="mb-1">
<Checkbox>Team member budget (USD)</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue("apply_max_budget") && (
<Form.Item name="max_budget_in_team">
<NumericalInput
min={0}
step={0.01}
placeholder="Leave blank to clear the budget"
style={{ width: "100%" }}
/>
</Form.Item>
)
}
</Form.Item>
<Form.Item name="apply_budget_duration" valuePropName="checked" className="mb-1">
<Checkbox>Budget reset period</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue("apply_budget_duration") && (
<Form.Item name="budget_duration">
<BudgetDurationDropdown />
</Form.Item>
)
}
</Form.Item>
<Form.Item name="apply_tpm_limit" valuePropName="checked" className="mb-1">
<Checkbox>TPM limit</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue("apply_tpm_limit") && (
<Form.Item name="tpm_limit">
<NumericalInput min={0} placeholder="Leave blank to clear the TPM limit" style={{ width: "100%" }} />
</Form.Item>
)
}
</Form.Item>
<Form.Item name="apply_rpm_limit" valuePropName="checked" className="mb-1">
<Checkbox>RPM limit</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue("apply_rpm_limit") && (
<Form.Item name="rpm_limit">
<NumericalInput min={0} placeholder="Leave blank to clear the RPM limit" style={{ width: "100%" }} />
</Form.Item>
)
}
</Form.Item>
<Form.Item name="apply_allowed_models" valuePropName="checked" className="mb-1">
<Checkbox>Allowed models</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue("apply_allowed_models") && (
<Form.Item name="allowed_models">
<Select
mode="multiple"
placeholder="Leave empty to inherit team models"
options={(teamData.team_info.models || []).map((model) => ({ label: model, value: model }))}
/>
</Form.Item>
)
}
</Form.Item>
</Form>
</Modal>
</>
);
}

File diff suppressed because it is too large Load diff