feat(proxy): add POST /user/bulk_delete and POST /team/bulk_member_delete

Batch user deletion that also removes each user from every team they belong to, and batch removal of many members from one team. Each touched team is rewritten once under the team advisory lock from a roster re-read under that lock

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-14 04:55:39 +00:00
parent 30f33a949b
commit cd9c39921b
10 changed files with 1270 additions and 4 deletions

View file

@ -659,6 +659,7 @@ class LiteLLMRoutes(enum.Enum):
"/user/update",
"/user/bulk_update",
"/user/delete",
"/user/bulk_delete",
"/user/info",
"/user/list",
"/user/daily/activity",
@ -838,6 +839,7 @@ class LiteLLMRoutes(enum.Enum):
self_managed_routes = [
"/team/member_add",
"/team/member_delete",
"/team/bulk_member_delete",
"/team/member_update",
"/team/{team_id}/member/{user_id}/reset_spend",
"/team/permissions_list",

View file

@ -25,9 +25,11 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
# user
"/user/new",
"/user/delete",
"/user/bulk_delete",
"/user/bulk_update",
# team
"/team/new",
"/team/bulk_member_delete",
"/team/update",
"/team/delete",
"/team/block",
@ -756,8 +758,10 @@ class RouteChecks:
[
"/user/new",
"/user/delete",
"/user/bulk_delete",
"/user/bulk_update",
"/team/new",
"/team/bulk_member_delete",
"/team/update",
"/team/delete",
"/model/new",

View file

@ -8,6 +8,7 @@ These are members of a Team on LiteLLM
/user/update
/user/bulk_update
/user/delete
/user/bulk_delete
/user/info
/user/list
"""
@ -55,6 +56,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
prepare_metadata_fields,
)
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users
from litellm.proxy.management_helpers.object_permission_utils import (
_set_object_permission,
handle_update_object_permission_common,
@ -77,6 +79,8 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
)
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
BulkDeleteUserRequest,
BulkDeleteUserResponse,
BulkUpdateUserRequest,
BulkUpdateUserResponse,
UserListResponse,
@ -2496,6 +2500,61 @@ async def delete_user(
return deleted_users
@router.post(
"/user/bulk_delete",
tags=["Internal User management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence
dependencies=(Depends(user_api_key_auth),),
response_model=BulkDeleteUserResponse,
)
@management_endpoint_wrapper
async def bulk_delete_user(
data: BulkDeleteUserRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
litellm_changed_by: str | None = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
) -> BulkDeleteUserResponse:
"""
Delete up to 500 internal users in one request and remove each one from every team they belong to.
Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose
organizations they all administer. Each team a deleted user was on is rewritten once under the team
lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards.
Then the users' keys, invitation links, organization memberships and user rows are deleted.
Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with
`success: false` and an `error`, and the other users are still deleted.
Usage Example
```shell
curl -X POST "http://localhost:4000/user/bulk_delete" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer sk-1234" \\
-d '{"user_ids": ["user-1", "user-2"]}'
```
Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`,
`teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
"""
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
if prisma_client is None:
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
try:
return await bulk_delete_users(
data=data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
litellm_proxy_admin_name=litellm_proxy_admin_name,
litellm_changed_by=litellm_changed_by,
)
except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract
verbose_proxy_logger.exception("/user/bulk_delete: Exception occured")
raise handle_exception_on_proxy(e)
async def add_internal_user_to_organization(
user_id: str,
organization_id: str,

View file

@ -162,6 +162,8 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
from litellm.types.proxy.management_endpoints.team_endpoints import (
BulkTeamMemberAddRequest,
BulkTeamMemberAddResponse,
BulkTeamMemberDeleteRequest,
BulkTeamMemberDeleteResponse,
BulkUpdateTeamMemberPermissionsRequest,
BulkUpdateTeamMemberPermissionsResponse,
GetTeamMemberPermissionsResponse,
@ -3451,6 +3453,56 @@ async def team_member_delete(
return existing_team_row
@router.post(
"/team/bulk_member_delete",
tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence
dependencies=(Depends(user_api_key_auth),),
response_model=BulkTeamMemberDeleteResponse,
)
@management_endpoint_wrapper
async def bulk_team_member_delete(
data: BulkTeamMemberDeleteRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
) -> BulkTeamMemberDeleteResponse:
"""
Remove up to 500 members from one team in a single request.
Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's
organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under
the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and
their team-scoped keys are all cleaned up together. Members that are not on the team are reported in
`results` with `success: false` and the rest are still removed.
Example request:
```bash
curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \\
--header 'Authorization: Bearer sk-1234' \\
--header 'Content-Type: application/json' \\
--data '{
"team_id": "team-1234",
"members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]
}'
```
Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`,
`success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
"""
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
try:
return await bulk_remove_team_members(
data=data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract
verbose_proxy_logger.exception("/team/bulk_member_delete: Exception occured")
raise handle_exception_on_proxy(e)
_MEMBER_BUDGET_PATCH_FIELDS: Final = {
"max_budget_in_team": "max_budget",
"tpm_limit": "tpm_limit",

View file

@ -0,0 +1,442 @@
"""Batched deletes behind `POST /user/bulk_delete` and `POST /team/bulk_member_delete`.
Each team a batch touches is rewritten exactly once, under the same advisory lock
`/team/member_delete` takes and from a roster re-read under that lock, so a concurrent
member_add on the team is never overwritten from a stale read.
"""
import asyncio
import json
from collections.abc import Awaitable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from fastapi import HTTPException
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import (
LiteLLM_TeamTable,
LitellmUserRoles,
Member,
MemberDeleteRequest,
UserAPIKeyAuth,
)
from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses
_is_user_team_admin, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses
)
from litellm.proxy.management_endpoints.key_management_endpoints import (
_persist_deleted_verification_tokens, # pyright: ignore[reportPrivateUsage] # same audit path /key/delete uses
)
from litellm.proxy.management_helpers.access_group_team_sync import TEAM_ADVISORY_LOCK_SQL
from litellm.proxy.utils import PrismaClient
from litellm.repositories.table_repositories import (
InvitationLinkRepository,
OrganizationMembershipRepository,
TeamMembershipRepository,
)
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.repositories.verification_token_repository import VerificationTokenRepository
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
BulkDeleteUserRequest,
BulkDeleteUserResponse,
UserDeleteResult,
)
from litellm.types.proxy.management_endpoints.team_endpoints import (
BulkTeamMemberDeleteRequest,
BulkTeamMemberDeleteResponse,
TeamMemberDeleteResult,
)
if TYPE_CHECKING:
from prisma import Prisma
from prisma import models as prisma_models
from litellm.repositories.prisma_protocols import TableActions
_TEAM_WRITE_CONCURRENCY: Final = 10
class _ErrorDetail(TypedDict):
error: ReadOnly[str]
class _OrgAdminFilter(TypedDict):
user_id: ReadOnly[str]
user_role: ReadOnly[str]
class _RosterData(TypedDict):
members_with_roles: ReadOnly[str]
class _TeamsSet(TypedDict):
set: ReadOnly[tuple[str, ...]]
class _TeamsData(TypedDict):
teams: ReadOnly[_TeamsSet]
@dataclass(frozen=True, slots=True)
class _TeamRemoval:
"""One team's rewrite. `removed` holds the user ids taken off the team (roster, `teams` array, or both);
`matched` holds the indexes into the requested members that named at least one of them."""
team: LiteLLM_TeamTable
removed: frozenset[str]
matched: frozenset[int]
def _http_error(status_code: int, message: str) -> HTTPException:
detail: Final[_ErrorDetail] = {"error": message}
return HTTPException(status_code=status_code, detail=detail)
def _in_filter(field: str, values: Iterable[str]) -> Mapping[str, object]:
return {field: {"in": sorted(values)}} # mutable-ok: Prisma query filters are dict-shaped
def _eq_filter(field: str, value: str) -> Mapping[str, object]:
return {field: value} # mutable-ok: Prisma query filters are dict-shaped
def _team_users_filter(team_id: str, user_ids: Iterable[str]) -> Mapping[str, object]:
return {"team_id": team_id, **_in_filter("user_id", user_ids)} # mutable-ok: Prisma query filters are dict-shaped
def _any_filter(*clauses: Mapping[str, object]) -> Mapping[str, object]:
return {"OR": clauses} # mutable-ok: Prisma query filters are dict-shaped
def _team_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamTable]":
return tx.litellm_teamtable # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
def _user_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_UserTable]":
return tx.litellm_usertable # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
def _membership_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamMembership]":
return tx.litellm_teammembership # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
def _token_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_VerificationToken]":
return tx.litellm_verificationtoken # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
def _addresses_member(member: Member, request: MemberDeleteRequest) -> bool:
return (request.user_id is not None and request.user_id == member.user_id) or (
request.user_email is not None and request.user_email == member.user_email
)
def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDeleteRequest) -> bool:
return (request.user_id is not None and request.user_id == user.user_id) or (
request.user_email is not None and request.user_email == user.user_email
)
def _error_message(exc: BaseException) -> str:
if isinstance(exc, HTTPException) and isinstance(exc.detail, dict):
return str(exc.detail.get("error", exc.detail)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # HTTPException.detail is untyped
if isinstance(exc, HTTPException):
return str(exc.detail) # pyright: ignore[reportUnknownArgumentType] # HTTPException.detail is untyped
return str(exc) or type(exc).__name__
async def _bounded(awaitables: Iterable[Awaitable[object]]) -> tuple[object | BaseException, ...]:
semaphore: Final = asyncio.Semaphore(_TEAM_WRITE_CONCURRENCY)
async def run(awaitable: Awaitable[object]) -> object:
async with semaphore:
return await awaitable
return tuple(await asyncio.gather(*(run(a) for a in awaitables), return_exceptions=True))
async def _remove_members_from_team(
prisma_client: PrismaClient,
team_id: str,
members: Sequence[MemberDeleteRequest],
user_api_key_dict: UserAPIKeyAuth,
) -> _TeamRemoval:
async with prisma_client.tx() as tx:
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id)
roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id)
if roster is None:
raise _http_error(400, f"Team id={team_id} does not exist in db")
removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members))
kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members))
removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None)
requested_ids: Final = frozenset(r.user_id for r in members if r.user_id is not None)
requested_emails: Final = frozenset(r.user_email for r in members if r.user_id is None and r.user_email)
user_rows: Final = await _user_tx_db(tx).find_many(
where=_any_filter(
_in_filter("user_id", removed_ids | requested_ids),
_in_filter("user_email", requested_emails),
)
)
stale_rows: Final = tuple(u for u in user_rows if team_id in u.teams)
cleanup_ids: Final = removed_ids | requested_ids | frozenset(u.user_id for u in stale_rows)
matched: Final = frozenset(
i
for i, r in enumerate(members)
if any(_addresses_member(m, r) for m in removed_members) or any(_addresses_user(u, r) for u in stale_rows)
)
keys: Final = await _token_tx_db(tx).find_many(where=_team_users_filter(team_id, cleanup_ids))
if removed_members:
roster_data: Final[_RosterData] = {
"members_with_roles": json.dumps(tuple(m.model_dump() for m in kept_members))
}
await _team_tx_db(tx).update(where=_eq_filter("team_id", team_id), data=roster_data)
for row in stale_rows:
teams_data: _TeamsData = {"teams": {"set": tuple(t for t in row.teams if t != team_id)}}
await _user_tx_db(tx).update(where=_eq_filter("user_id", row.user_id), data=teams_data)
await _membership_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids))
if keys:
await _persist_deleted_verification_tokens(
keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
tx=tx,
)
await _token_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids))
return _TeamRemoval(
team=LiteLLM_TeamTable(
team_id=team_id,
members_with_roles=kept_members, # pyright: ignore[reportArgumentType] # pydantic coerces the tuple into the list field
),
removed=removed_ids | frozenset(u.user_id for u in stale_rows),
matched=matched,
)
def _emit_team_members_metric(team: LiteLLM_TeamTable) -> None:
prometheus_logger: Final = PrometheusLogger.get_instance()
if prometheus_logger is None:
return
try:
prometheus_logger.set_team_members_metric(team)
except Exception as e:
verbose_proxy_logger.debug("Prometheus: failed to emit team members metric: %s", str(e))
async def bulk_remove_team_members(
data: BulkTeamMemberDeleteRequest,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
) -> BulkTeamMemberDeleteResponse:
team: Final = await TeamRepository(prisma_client).find_by_id(data.team_id)
if team is None:
raise _http_error(400, f"Team id={data.team_id} does not exist in db")
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=team)
and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team)
):
raise _http_error(
403,
"Call not allowed. User not proxy admin OR team admin OR org admin for this team. "
f"route='/team/bulk_member_delete', team_id={data.team_id}",
)
removal: Final = await _remove_members_from_team(prisma_client, data.team_id, data.members, user_api_key_dict)
_emit_team_members_metric(removal.team)
results: Final = tuple(
TeamMemberDeleteResult(
user_id=member.user_id,
user_email=member.user_email,
success=i in removal.matched,
error=None if i in removal.matched else "User not found in team",
)
for i, member in enumerate(data.members)
)
successful: Final = sum(1 for r in results if r.success)
return BulkTeamMemberDeleteResponse(
team_id=data.team_id,
results=results,
total_requested=len(results),
successful_deletions=successful,
failed_deletions=len(results) - successful,
)
async def _caller_admin_org_ids(prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]:
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value or not user_api_key_dict.user_id:
return frozenset()
where: Final[_OrgAdminFilter] = {
"user_id": user_api_key_dict.user_id,
"user_role": LitellmUserRoles.ORG_ADMIN.value,
}
memberships: Final = await OrganizationMembershipRepository(prisma_client).table.find_many(where=where)
return frozenset(m.organization_id for m in memberships if m.organization_id)
def _scope_error(user_id: str, target_org_ids: frozenset[str], caller_admin_org_ids: frozenset[str]) -> str | None:
if target_org_ids and target_org_ids <= caller_admin_org_ids:
return None
return (
f"User {user_id} is not within your admin scope. "
"Only PROXY_ADMIN may delete users outside your administered organizations."
)
async def _delete_user_rows(
prisma_client: PrismaClient,
users: Sequence["prisma_models.LiteLLM_UserTable"],
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: str | None,
litellm_changed_by: str | None,
) -> None:
user_ids: Final = frozenset(u.user_id for u in users)
await _bounded(
UserManagementEventHooks.create_internal_user_audit_log(
user_id=u.user_id,
action="deleted",
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
before_value=u.model_dump_json(exclude_none=True),
)
for u in users
)
keys: Final = await VerificationTokenRepository(prisma_client).table.find_many(
where=_in_filter("user_id", user_ids)
)
if keys:
await _persist_deleted_verification_tokens(
keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
await VerificationTokenRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
await InvitationLinkRepository(prisma_client).table.delete_many(
where=_any_filter(
_in_filter("user_id", user_ids),
_in_filter("created_by", user_ids),
_in_filter("updated_by", user_ids),
)
)
await OrganizationMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
await TeamMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
await UserRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
async def bulk_delete_users(
data: BulkDeleteUserRequest,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
litellm_proxy_admin_name: str | None,
litellm_changed_by: str | None,
) -> BulkDeleteUserResponse:
caller_is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
caller_admin_org_ids: Final = await _caller_admin_org_ids(prisma_client, user_api_key_dict)
if not caller_is_proxy_admin and not caller_admin_org_ids:
raise _http_error(403, "Only PROXY_ADMIN or ORG_ADMIN users may delete users.")
unique_ids: Final = frozenset(data.user_ids)
rows: Final = await UserRepository(prisma_client).table.find_many(where=_in_filter("user_id", unique_ids))
rows_by_id: Final = MappingProxyType({row.user_id: row for row in rows})
target_memberships: Final = (
()
if caller_is_proxy_admin
else await OrganizationMembershipRepository(prisma_client).table.find_many(
where=_in_filter("user_id", unique_ids)
)
)
def precheck_error(user_id: str) -> str | None:
if user_id not in rows_by_id:
return f"User id={user_id} not found"
if caller_is_proxy_admin:
return None
org_ids: Final = frozenset(
m.organization_id for m in target_memberships if m.user_id == user_id and m.organization_id
)
return _scope_error(user_id, org_ids, caller_admin_org_ids)
precheck_errors: Final = MappingProxyType({uid: precheck_error(uid) for uid in unique_ids})
candidates: Final = tuple(rows_by_id[uid] for uid in sorted(unique_ids) if precheck_errors[uid] is None)
candidate_ids: Final = frozenset(u.user_id for u in candidates)
memberships: Final = await TeamMembershipRepository(prisma_client).table.find_many(
where=_in_filter("user_id", candidate_ids)
)
teams_of: Final = MappingProxyType(
{
u.user_id: frozenset(u.teams) | frozenset(m.team_id for m in memberships if m.user_id == u.user_id)
for u in candidates
}
)
team_ids: Final = tuple(sorted(frozenset(t for u in candidates for t in teams_of[u.user_id])))
members_by_team: Final = MappingProxyType(
{
tid: tuple(
MemberDeleteRequest(user_id=u.user_id, user_email=u.user_email)
for u in candidates
if tid in teams_of[u.user_id]
)
for tid in team_ids
}
)
outcomes: Final = await _bounded(
_remove_members_from_team(prisma_client, tid, members_by_team[tid], user_api_key_dict) for tid in team_ids
)
removals: Final = MappingProxyType(
{tid: o for tid, o in zip(team_ids, outcomes, strict=True) if isinstance(o, _TeamRemoval)}
)
team_failures: Final = MappingProxyType(
{tid: _error_message(o) for tid, o in zip(team_ids, outcomes, strict=True) if isinstance(o, BaseException)}
)
for tid, err in team_failures.items():
verbose_proxy_logger.error("/user/bulk_delete: failed to remove users from team %s: %s", tid, err)
for removal in removals.values():
_emit_team_members_metric(removal.team)
def team_errors(user_id: str) -> tuple[str, ...]:
return tuple(
f"Failed to remove from team {tid}: {err}" for tid, err in team_failures.items() if tid in teams_of[user_id]
)
deletable: Final = tuple(u for u in candidates if not team_errors(u.user_id))
if deletable:
await _delete_user_rows(
prisma_client, deletable, user_api_key_dict, litellm_proxy_admin_name, litellm_changed_by
)
def result(index: int, user_id: str) -> UserDeleteResult:
if user_id in data.user_ids[:index]:
return UserDeleteResult(user_id=user_id, success=False, error=f"Duplicate user_id in request: {user_id}")
error: Final = precheck_errors[user_id]
if error is not None:
return UserDeleteResult(user_id=user_id, success=False, error=error)
errors: Final = team_errors(user_id)
return UserDeleteResult(
user_id=user_id,
user_email=rows_by_id[user_id].user_email,
success=not errors,
teams_removed=tuple(tid for tid in team_ids if tid in removals and user_id in removals[tid].removed),
error="; ".join(errors) or None,
)
results: Final = tuple(result(i, uid) for i, uid in enumerate(data.user_ids))
successful: Final = sum(1 for r in results if r.success)
return BulkDeleteUserResponse(
results=results,
total_requested=len(results),
successful_deletions=successful,
failed_deletions=len(results) - successful,
)

View file

@ -1,7 +1,7 @@
from collections.abc import Mapping
from typing import Any, Final, Literal
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, Field, field_validator
from typing_extensions import ReadOnly, TypedDict
from litellm.proxy._types import (
@ -10,6 +10,8 @@ from litellm.proxy._types import (
UpdateUserRequestNoUserIDorEmail,
)
MAX_BULK_DELETE_USERS: Final = 500
class InsensitiveContains(TypedDict):
contains: ReadOnly[str]
@ -83,3 +85,24 @@ class BulkUpdateUserResponse(BaseModel):
total_requested: int
successful_updates: int
failed_updates: int
class BulkDeleteUserRequest(BaseModel):
user_ids: tuple[str, ...] = Field(min_length=1, max_length=MAX_BULK_DELETE_USERS)
class UserDeleteResult(BaseModel):
"""Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of."""
user_id: str
user_email: str | None = None
success: bool
teams_removed: tuple[str, ...] = ()
error: str | None = None
class BulkDeleteUserResponse(BaseModel):
results: tuple[UserDeleteResult, ...]
total_requested: int
successful_deletions: int
failed_deletions: int

View file

@ -1,4 +1,4 @@
from typing import Any, Literal
from typing import Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field
@ -8,10 +8,13 @@ from litellm.proxy._types import (
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
Member,
MemberDeleteRequest,
)
TeamIdSearchMatch = Literal["exact", "prefix"]
MAX_BULK_TEAM_MEMBER_DELETES: Final = 500
class GetTeamMemberPermissionsRequest(BaseModel):
"""Request to get the team member permissions for a team"""
@ -118,6 +121,28 @@ class BulkTeamMemberAddResponse(BaseModel):
updated_team: dict[str, Any] | None = None
class BulkTeamMemberDeleteRequest(BaseModel):
team_id: str
members: tuple[MemberDeleteRequest, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES)
class TeamMemberDeleteResult(BaseModel):
"""Outcome for one row of `/team/bulk_member_delete`."""
user_id: str | None = None
user_email: str | None = None
success: bool
error: str | None = None
class BulkTeamMemberDeleteResponse(BaseModel):
team_id: str
results: tuple[TeamMemberDeleteResult, ...]
total_requested: int
successful_deletions: int
failed_deletions: int
class TeamMemberInfoResponse(LiteLLM_TeamMembership):
"""Response for GET /team/{team_id}/members/me — caller's own membership row."""

View file

@ -80,10 +80,12 @@ POST /model/unblock
POST /prompts/test
POST /search_tools/test_connection
POST /team/bulk_member_add
POST /team/bulk_member_delete
POST /team/{team_id}/member/{user_id}/reset_spend
POST /team/key/bulk_update
POST /team/permissions_bulk_update
POST /team/{team_id}/disable_logging
POST /user/bulk_delete
POST /user/bulk_update
# Alternate method or path for functionality the provider already manages elsewhere

View file

@ -0,0 +1,434 @@
import json
from collections.abc import Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from typing import Final
import pytest
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict, ValidationError
from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, MemberDeleteRequest, UserAPIKeyAuth
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users, bulk_remove_team_members
from litellm.types.proxy.management_endpoints.internal_user_endpoints import BulkDeleteUserRequest
from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest
ADMIN: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin")
INTERNAL: Final = UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER)
ORG_ADMIN: Final = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN)
class _UserRow(BaseModel):
model_config = ConfigDict(extra="allow")
user_id: str
user_email: str | None = None
teams: list[str] = []
class _Record(BaseModel):
"""Attribute access like a Prisma row, over whatever columns the test seeded."""
model_config = ConfigDict(extra="allow")
def _in(where: Mapping[str, object], field: str) -> set[str] | None:
clause = where.get(field)
if isinstance(clause, dict) and "in" in clause:
return set(clause["in"])
if isinstance(clause, str):
return {clause}
return None
def _matches(row: Mapping[str, object], where: Mapping[str, object]) -> bool:
if "OR" in where:
return any(_matches(row, clause) for clause in where["OR"])
return all((wanted := _in(where, field)) is not None and row.get(field) in wanted for field in where)
class _Rows:
"""A list-backed Prisma table supporting the `in`/equality/OR filters the helper issues."""
def __init__(self, rows: Sequence[Mapping[str, object]] = ()) -> None:
self.rows: list[dict[str, object]] = [dict(r) for r in rows]
async def find_many(self, where: Mapping[str, object]) -> list[_Record]:
return [_Record.model_validate(r) for r in self.rows if _matches(r, where)]
async def delete_many(self, where: Mapping[str, object]) -> int:
before = len(self.rows)
self.rows = [r for r in self.rows if not _matches(r, where)]
return before - len(self.rows)
async def create_many(self, data: Sequence[Mapping[str, object]]) -> int:
self.rows.extend(dict(r) for r in data)
return len(data)
class _UserTable:
def __init__(self, users: Sequence[_UserRow]) -> None:
self.rows: dict[str, _UserRow] = {u.user_id: u for u in users}
async def find_many(self, where: Mapping[str, object]) -> list[_UserRow]:
return [u for u in self.rows.values() if _matches(u.model_dump(), where)]
async def update(self, where: Mapping[str, str], data: Mapping[str, Mapping[str, Sequence[str]]]) -> _UserRow:
row = self.rows[where["user_id"]]
updated = row.model_copy(update={"teams": list(data["teams"]["set"])})
self.rows[row.user_id] = updated
return updated
async def delete_many(self, where: Mapping[str, object]) -> int:
doomed = [uid for uid, u in self.rows.items() if _matches(u.model_dump(), where)]
for uid in doomed:
del self.rows[uid]
return len(doomed)
class _TeamTable:
def __init__(self, teams: Sequence[LiteLLM_TeamTable]) -> None:
self.rows: dict[str, LiteLLM_TeamTable] = {t.team_id: t for t in teams}
self.update_calls = 0
async def find_unique(self, where: Mapping[str, str]) -> LiteLLM_TeamTable | None:
return self.rows.get(where["team_id"])
async def update(self, where: Mapping[str, str], data: Mapping[str, str]) -> LiteLLM_TeamTable:
self.update_calls += 1
team = self.rows[where["team_id"]]
team.members_with_roles = [Member(**m) for m in json.loads(data["members_with_roles"])]
return team
class _Db:
def __init__(
self,
users: Sequence[_UserRow],
teams: Sequence[LiteLLM_TeamTable],
memberships: Sequence[tuple[str, str]] = (),
tokens: Sequence[Mapping[str, object]] = (),
invitations: Sequence[Mapping[str, object]] = (),
org_memberships: Sequence[Mapping[str, object]] = (),
) -> None:
self.litellm_usertable = _UserTable(users)
self.litellm_teamtable = _TeamTable(teams)
self.litellm_teammembership = _Rows([{"team_id": t, "user_id": u} for t, u in memberships])
self.litellm_verificationtoken = _Rows(tokens)
self.litellm_deletedverificationtoken = _Rows()
self.litellm_invitationlink = _Rows(invitations)
self.litellm_organizationmembership = _Rows(org_memberships)
class _Tx:
def __init__(self, db: _Db, on_lock: Callable[[str], None], fail_locks: frozenset[str]) -> None:
self.litellm_teamtable = db.litellm_teamtable
self.litellm_usertable = db.litellm_usertable
self.litellm_teammembership = db.litellm_teammembership
self.litellm_verificationtoken = db.litellm_verificationtoken
self.litellm_deletedverificationtoken = db.litellm_deletedverificationtoken
self._on_lock = on_lock
self._fail_locks = fail_locks
self.locks: list[str] = []
self.roster_reads: list[str] = []
async def query_raw(self, sql: str, *args: object) -> list[dict[str, object]]:
team_id = str(args[0])
if "pg_advisory_xact_lock" in sql:
if team_id in self._fail_locks:
raise RuntimeError("lock timeout")
self.locks.append(team_id)
self._on_lock(team_id)
return []
assert self.locks == [team_id], "roster must be read under this team's advisory lock"
self.roster_reads.append(team_id)
team = self.litellm_teamtable.rows.get(team_id)
if team is None:
return []
return [{"members_with_roles": json.dumps([m.model_dump() for m in team.members_with_roles])}]
class _FakePrisma:
def __init__(
self,
users: Sequence[_UserRow] = (),
teams: Sequence[LiteLLM_TeamTable] = (),
memberships: Sequence[tuple[str, str]] = (),
tokens: Sequence[Mapping[str, object]] = (),
invitations: Sequence[Mapping[str, object]] = (),
org_memberships: Sequence[Mapping[str, object]] = (),
on_lock: Callable[[str], None] = lambda _: None,
fail_locks: frozenset[str] = frozenset(),
) -> None:
self.db = _Db(users, teams, memberships, tokens, invitations, org_memberships)
self._on_lock = on_lock
self._fail_locks = fail_locks
self.locks: list[str] = []
self.roster_reads: list[str] = []
@asynccontextmanager
async def tx(self):
tx = _Tx(self.db, self._on_lock, self._fail_locks)
yield tx
self.locks.extend(tx.locks)
self.roster_reads.extend(tx.roster_reads)
def _team(team_id: str, *members: str, org: str | None = None) -> LiteLLM_TeamTable:
return LiteLLM_TeamTable(
team_id=team_id,
organization_id=org,
members_with_roles=[Member(user_id=m, user_email=f"{m}@example.com", role="user") for m in members],
)
def _user(user_id: str, *teams: str) -> _UserRow:
return _UserRow(user_id=user_id, user_email=f"{user_id}@example.com", teams=list(teams))
def _roster(prisma: _FakePrisma, team_id: str) -> list[str | None]:
return [m.user_id for m in prisma.db.litellm_teamtable.rows[team_id].members_with_roles]
async def _delete(prisma: _FakePrisma, user_ids: Sequence[str], caller: UserAPIKeyAuth = ADMIN):
return await bulk_delete_users(
data=BulkDeleteUserRequest(user_ids=tuple(user_ids)),
user_api_key_dict=caller,
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
litellm_proxy_admin_name="default_user_id",
litellm_changed_by=None,
)
async def _remove(prisma: _FakePrisma, team_id: str, members: Sequence[Mapping[str, str]], caller=ADMIN):
return await bulk_remove_team_members(
data=BulkTeamMemberDeleteRequest(team_id=team_id, members=tuple(MemberDeleteRequest(**m) for m in members)),
user_api_key_dict=caller,
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
)
@pytest.mark.asyncio
async def test_bulk_delete_removes_users_from_every_team_and_store():
prisma = _FakePrisma(
users=[_user("u1", "t1", "t2"), _user("u2", "t1"), _user("keep", "t1")],
teams=[_team("t1", "u1", "u2", "keep"), _team("t2", "u1", "other")],
memberships=[("t1", "u1"), ("t2", "u1"), ("t1", "u2"), ("t1", "keep")],
tokens=[{"token": "k1", "user_id": "u1", "team_id": "t1"}, {"token": "k2", "user_id": "keep"}],
invitations=[
{"id": "i1", "user_id": "u2", "created_by": "admin", "updated_by": "admin"},
{"id": "i2", "user_id": "keep", "created_by": "u1", "updated_by": "admin"},
{"id": "i3", "user_id": "keep", "created_by": "admin", "updated_by": "admin"},
],
org_memberships=[{"user_id": "u1", "organization_id": "o1", "user_role": "internal_user"}],
)
response = await _delete(prisma, ["u1", "u2"])
assert (response.total_requested, response.successful_deletions, response.failed_deletions) == (2, 2, 0)
assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in response.results] == [
("u1", "u1@example.com", True, ("t1", "t2")),
("u2", "u2@example.com", True, ("t1",)),
]
assert _roster(prisma, "t1") == ["keep"] and _roster(prisma, "t2") == ["other"]
assert set(prisma.db.litellm_usertable.rows) == {"keep"}
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "keep"}]
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k2"]
assert [t["token"] for t in prisma.db.litellm_deletedverificationtoken.rows] == ["k1"]
assert [i["id"] for i in prisma.db.litellm_invitationlink.rows] == ["i3"]
assert prisma.db.litellm_organizationmembership.rows == []
assert sorted(prisma.locks) == ["t1", "t2"] and sorted(prisma.roster_reads) == ["t1", "t2"]
@pytest.mark.asyncio
async def test_bulk_delete_finds_teams_through_membership_rows_when_user_teams_array_is_stale():
prisma = _FakePrisma(
users=[_user("u1")],
teams=[_team("t1", "u1", "keep")],
memberships=[("t1", "u1")],
)
response = await _delete(prisma, ["u1"])
assert response.results[0].teams_removed == ("t1",)
assert _roster(prisma, "t1") == ["keep"]
assert prisma.db.litellm_teammembership.rows == []
@pytest.mark.asyncio
async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives():
team = _team("t1", "u1")
def concurrent_member_add(team_id: str) -> None:
team.members_with_roles.append(Member(user_id="late", role="user"))
prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[team], on_lock=concurrent_member_add)
response = await _delete(prisma, ["u1"])
assert response.results[0].success is True
assert _roster(prisma, "t1") == ["late"]
@pytest.mark.asyncio
async def test_bulk_delete_reports_missing_and_duplicate_ids_per_item_and_still_deletes_the_rest():
prisma = _FakePrisma(users=[_user("u1")])
response = await _delete(prisma, ["u1", "ghost", "u1"])
assert (response.successful_deletions, response.failed_deletions) == (1, 2)
assert [(r.user_id, r.success, r.error) for r in response.results] == [
("u1", True, None),
("ghost", False, "User id=ghost not found"),
("u1", False, "Duplicate user_id in request: u1"),
]
assert prisma.db.litellm_usertable.rows == {}
@pytest.mark.asyncio
async def test_bulk_delete_keeps_user_when_a_team_rewrite_fails_and_deletes_the_others():
prisma = _FakePrisma(
users=[_user("u1", "bad", "good"), _user("u2", "good")],
teams=[_team("bad", "u1"), _team("good", "u1", "u2")],
fail_locks=frozenset({"bad"}),
)
response = await _delete(prisma, ["u1", "u2"])
assert [(r.user_id, r.success, r.teams_removed) for r in response.results] == [
("u1", False, ("good",)),
("u2", True, ("good",)),
]
assert response.results[0].error == "Failed to remove from team bad: lock timeout"
assert set(prisma.db.litellm_usertable.rows) == {"u1"}
assert _roster(prisma, "bad") == ["u1"] and _roster(prisma, "good") == []
@pytest.mark.asyncio
async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db():
prisma = _FakePrisma(users=[_user("u1")])
with pytest.raises(HTTPException) as exc:
await _delete(prisma, ["u1"], caller=INTERNAL)
assert exc.value.status_code == 403
assert set(prisma.db.litellm_usertable.rows) == {"u1"}
@pytest.mark.asyncio
async def test_org_admin_deletes_only_users_fully_inside_their_orgs():
prisma = _FakePrisma(
users=[_user("inside"), _user("straddles"), _user("orgless")],
org_memberships=[
{"user_id": "org-admin", "organization_id": "o1", "user_role": LitellmUserRoles.ORG_ADMIN.value},
{"user_id": "inside", "organization_id": "o1", "user_role": "internal_user"},
{"user_id": "straddles", "organization_id": "o1", "user_role": "internal_user"},
{"user_id": "straddles", "organization_id": "o2", "user_role": "internal_user"},
],
)
response = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN)
assert [r.success for r in response.results] == [True, False, False]
assert all("not within your admin scope" in (r.error or "") for r in response.results[1:])
assert set(prisma.db.litellm_usertable.rows) == {"straddles", "orgless"}
assert {(m["user_id"], m["organization_id"]) for m in prisma.db.litellm_organizationmembership.rows} == {
("org-admin", "o1"),
("straddles", "o1"),
("straddles", "o2"),
}
@pytest.mark.asyncio
async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest():
prisma = _FakePrisma(
users=[_user("u1", "t1", "t2"), _user("u2", "t1"), _user("keep", "t1")],
teams=[_team("t1", "u1", "u2", "keep")],
memberships=[("t1", "u1"), ("t1", "u2"), ("t1", "keep")],
tokens=[
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
{"token": "other-team-key", "user_id": "u1", "team_id": "t2"},
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
],
)
response = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}])
assert (response.team_id, response.successful_deletions, response.failed_deletions) == ("t1", 2, 0)
assert [(r.user_id, r.user_email, r.success) for r in response.results] == [
("u1", None, True),
(None, "u2@example.com", True),
]
assert _roster(prisma, "t1") == ["keep"]
users = prisma.db.litellm_usertable.rows
assert users["u1"].teams == ["t2"] and users["u2"].teams == [] and users["keep"].teams == ["t1"]
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "keep"}]
assert sorted(t["token"] for t in prisma.db.litellm_verificationtoken.rows) == ["keep-key", "other-team-key"]
assert [t["token"] for t in prisma.db.litellm_deletedverificationtoken.rows] == ["team-key"]
assert prisma.locks == ["t1"] and prisma.roster_reads == ["t1"]
@pytest.mark.asyncio
async def test_bulk_member_delete_reports_members_not_on_the_team_without_rewriting_the_roster():
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("elsewhere")], teams=[_team("t1", "u1")])
response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}])
assert [(r.success, r.error) for r in response.results] == [
(False, "User not found in team"),
(False, "User not found in team"),
]
assert (response.successful_deletions, response.failed_deletions) == (0, 2)
assert prisma.db.litellm_teamtable.update_calls == 0
assert _roster(prisma, "t1") == ["u1"]
@pytest.mark.asyncio
async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_the_team():
prisma = _FakePrisma(users=[_user("stale", "t1")], teams=[_team("t1", "other")], memberships=[("t1", "stale")])
response = await _remove(prisma, "t1", [{"user_id": "stale"}])
assert response.results[0].success is True
assert prisma.db.litellm_usertable.rows["stale"].teams == []
assert prisma.db.litellm_teammembership.rows == []
assert _roster(prisma, "t1") == ["other"] and prisma.db.litellm_teamtable.update_calls == 0
@pytest.mark.asyncio
async def test_bulk_member_delete_rejects_unknown_team_and_unauthorized_callers():
prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[_team("t1", "u1")])
with pytest.raises(HTTPException) as missing:
await _remove(prisma, "nope", [{"user_id": "u1"}])
with pytest.raises(HTTPException) as forbidden:
await _remove(prisma, "t1", [{"user_id": "u1"}], caller=INTERNAL)
assert missing.value.status_code == 400
assert forbidden.value.status_code == 403
assert _roster(prisma, "t1") == ["u1"] and prisma.locks == []
@pytest.mark.asyncio
async def test_team_admin_may_bulk_remove_members():
team = _team("t1", "lead", "u1")
team.members_with_roles[0].role = "admin"
prisma = _FakePrisma(users=[_user("lead", "t1"), _user("u1", "t1")], teams=[team])
response = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead"))
assert response.results[0].success is True
assert _roster(prisma, "t1") == ["lead"]
def test_request_models_enforce_batch_bounds():
with pytest.raises(ValidationError):
BulkDeleteUserRequest(user_ids=())
with pytest.raises(ValidationError):
BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(501)))
with pytest.raises(ValidationError):
BulkTeamMemberDeleteRequest(team_id="t1", members=())
with pytest.raises(ValidationError):
BulkTeamMemberDeleteRequest(
team_id="t1", members=tuple(MemberDeleteRequest(user_id=f"u{i}") for i in range(501))
)
assert len(BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(500))).user_ids) == 500

View file

@ -15056,6 +15056,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/team/bulk_member_delete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Bulk Team Member Delete
* @description Remove up to 500 members from one team in a single request.
*
* Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's
* organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under
* the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and
* their team-scoped keys are all cleaned up together. Members that are not on the team are reported in
* `results` with `success: false` and the rest are still removed.
*
* Example request:
* ```bash
* curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \
* --header 'Authorization: Bearer sk-1234' \
* --header 'Content-Type: application/json' \
* --data '{
* "team_id": "team-1234",
* "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]
* }'
* ```
*
* Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`,
* `success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
*/
post: operations["bulk_team_member_delete_team_bulk_member_delete_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/team/daily/activity": {
parameters: {
query?: never;
@ -16478,6 +16518,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/user/bulk_delete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Bulk Delete User
* @description Delete up to 500 internal users in one request and remove each one from every team they belong to.
*
* Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose
* organizations they all administer. Each team a deleted user was on is rewritten once under the team
* lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards.
* Then the users' keys, invitation links, organization memberships and user rows are deleted.
*
* Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with
* `success: false` and an `error`, and the other users are still deleted.
*
* Usage Example
*
* ```shell
* curl -X POST "http://localhost:4000/user/bulk_delete" \
* -H "Content-Type: application/json" \
* -H "Authorization: Bearer sk-1234" \
* -d '{"user_ids": ["user-1", "user-2"]}'
* ```
*
* Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`,
* `teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
*/
post: operations["bulk_delete_user_user_bulk_delete_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/user/bulk_update": {
parameters: {
query?: never;
@ -16781,7 +16861,6 @@ export interface paths {
* - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking.
* - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" }
* - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x.
* - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests.
* - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys)
* - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
* - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
@ -16887,7 +16966,6 @@ export interface paths {
* - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking.
* - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" }
* - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x.
* - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests.
* - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys)
* - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
* - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
@ -24505,6 +24583,22 @@ export interface components {
/** Budgets */
budgets: string[];
};
/** BulkDeleteUserRequest */
BulkDeleteUserRequest: {
/** User Ids */
user_ids: string[];
};
/** BulkDeleteUserResponse */
BulkDeleteUserResponse: {
/** Failed Deletions */
failed_deletions: number;
/** Results */
results: components["schemas"]["UserDeleteResult"][];
/** Successful Deletions */
successful_deletions: number;
/** Total Requested */
total_requested: number;
};
/**
* BulkTeamMemberAddRequest
* @description Request for bulk team member addition
@ -24542,6 +24636,26 @@ export interface components {
[key: string]: unknown;
} | null;
};
/** BulkTeamMemberDeleteRequest */
BulkTeamMemberDeleteRequest: {
/** Members */
members: components["schemas"]["MemberDeleteRequest"][];
/** Team Id */
team_id: string;
};
/** BulkTeamMemberDeleteResponse */
BulkTeamMemberDeleteResponse: {
/** Failed Deletions */
failed_deletions: number;
/** Results */
results: components["schemas"]["TeamMemberDeleteResult"][];
/** Successful Deletions */
successful_deletions: number;
/** Team Id */
team_id: string;
/** Total Requested */
total_requested: number;
};
/**
* BulkUpdateKeyRequest
* @description Request for bulk key updates
@ -31903,6 +32017,13 @@ export interface components {
*/
user_id?: string | null;
};
/** MemberDeleteRequest */
MemberDeleteRequest: {
/** User Email */
user_email?: string | null;
/** User Id */
user_id?: string | null;
};
/** MemoryCreateRequest */
MemoryCreateRequest: {
/**
@ -37166,6 +37287,20 @@ export interface components {
/** User Id */
user_id?: string | null;
};
/**
* TeamMemberDeleteResult
* @description Outcome for one row of `/team/bulk_member_delete`.
*/
TeamMemberDeleteResult: {
/** Error */
error?: string | null;
/** Success */
success: boolean;
/** User Email */
user_email?: string | null;
/** User Id */
user_id?: string | null;
};
/**
* TeamMemberInfoResponse
* @description Response for GET /team/{team_id}/members/me caller's own membership row.
@ -39345,6 +39480,25 @@ export interface components {
*/
severity: "info" | "warning" | "error";
};
/**
* UserDeleteResult
* @description Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of.
*/
UserDeleteResult: {
/** Error */
error?: string | null;
/** Success */
success: boolean;
/**
* Teams Removed
* @default []
*/
teams_removed: string[];
/** User Email */
user_email?: string | null;
/** User Id */
user_id: string;
};
/**
* UserHeaderMapping
* @description Map an incoming HTTP header to a LiteLLM user role.
@ -58887,6 +59041,39 @@ export interface operations {
};
};
};
bulk_team_member_delete_team_bulk_member_delete_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BulkTeamMemberDeleteRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BulkTeamMemberDeleteResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_team_daily_activity_team_daily_activity_get: {
parameters: {
query?: {
@ -60631,6 +60818,42 @@ export interface operations {
};
};
};
bulk_delete_user_user_bulk_delete_post: {
parameters: {
query?: never;
header?: {
/** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */
"litellm-changed-by"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BulkDeleteUserRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BulkDeleteUserResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
bulk_user_update_user_bulk_update_post: {
parameters: {
query?: never;