feat(proxy): move bulk user delete and team member delete under /management/v1

Replaces POST /user/bulk_delete and POST /team/bulk_member_delete with
POST /management/v1/users/bulk_delete and
POST /management/v1/teams/{team_id}/members/bulk_delete per the Management
API modernization one-pager: {data} envelopes, application/problem+json
errors with urn:litellm:error:* types, 422 on unknown body fields, 400 on
unknown query params, 403 on authorization failures, 404 on unknown team.

Route checks now match parametrized management/v1 paths so team-scoped
callers reach the endpoint's own authorization and get a 403 problem
instead of the generic 401.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-14 22:31:07 +00:00
parent ef71222349
commit ca04b03c2c
17 changed files with 735 additions and 374 deletions

View file

@ -659,7 +659,7 @@ class LiteLLMRoutes(enum.Enum):
"/user/update",
"/user/bulk_update",
"/user/delete",
"/user/bulk_delete",
"/management/v1/users/bulk_delete",
"/user/info",
"/user/list",
"/user/daily/activity",
@ -839,7 +839,7 @@ class LiteLLMRoutes(enum.Enum):
self_managed_routes = [
"/team/member_add",
"/team/member_delete",
"/team/bulk_member_delete",
"/management/v1/teams/{team_id}/members/bulk_delete",
"/team/member_update",
"/team/{team_id}/member/{user_id}/reset_spend",
"/team/permissions_list",
@ -866,6 +866,7 @@ class LiteLLMRoutes(enum.Enum):
"/organization/daily/activity",
"/user/available_roles", # read-only role metadata; any authenticated user may read
"/user/list", # org admins checked in endpoint; non-admins get 403
"/management/v1/users/bulk_delete", # proxy admins delete anyone, org admins only their orgs' users; others 403
"/model/{model_id}/update",
"/prompt/list",
"/prompt/info",

View file

@ -1,5 +1,5 @@
import re
from collections.abc import Sequence
from collections.abc import Collection
from typing import Final
from fastapi import HTTPException, Request, status
@ -25,11 +25,11 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
# user
"/user/new",
"/user/delete",
"/user/bulk_delete",
"/management/v1/users/bulk_delete",
"/user/bulk_update",
# team
"/team/new",
"/team/bulk_member_delete",
"/management/v1/teams/{team_id}/members/bulk_delete",
"/team/update",
"/team/delete",
"/team/block",
@ -589,7 +589,7 @@ class RouteChecks:
return False
@staticmethod
def check_route_access(route: str, allowed_routes: Sequence[str]) -> bool:
def check_route_access(route: str, allowed_routes: Collection[str]) -> bool:
"""
Check if a route has access by checking both exact matches and patterns
@ -761,10 +761,10 @@ class RouteChecks:
[
"/user/new",
"/user/delete",
"/user/bulk_delete",
"/management/v1/users/bulk_delete",
"/user/bulk_update",
"/team/new",
"/team/bulk_member_delete",
"/management/v1/teams/{team_id}/members/bulk_delete",
"/team/update",
"/team/delete",
"/model/new",
@ -828,7 +828,7 @@ class RouteChecks:
status_code=status.HTTP_403_FORBIDDEN,
detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route} and updating invalid param: {param}. only user_email and password can be updated",
)
elif route in _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES or (
elif RouteChecks.check_route_access(route=route, allowed_routes=_PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES) or (
route.startswith("/key/") and route.endswith(_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES)
):
# Block write operations for PROXY_ADMIN_VIEW_ONLY
@ -863,9 +863,9 @@ class RouteChecks:
# Hard-block known write routes regardless of HTTP method (defensive
# — these are POSTs in practice, but pinning them here protects
# against future GET-shaped writes).
if route in RouteChecks._ADMIN_VIEWER_BLOCKED_WRITE_ROUTES or (
route.startswith("/key/") and route.endswith("/regenerate")
):
if RouteChecks.check_route_access(
route=route, allowed_routes=RouteChecks._ADMIN_VIEWER_BLOCKED_WRITE_ROUTES
) or (route.startswith("/key/") and route.endswith("/regenerate")):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route}",

View file

@ -8,7 +8,6 @@ These are members of a Team on LiteLLM
/user/update
/user/bulk_update
/user/delete
/user/bulk_delete
/user/info
/user/list
"""
@ -56,7 +55,6 @@ 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,
@ -79,8 +77,6 @@ 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,
@ -2500,46 +2496,6 @@ 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 users, removing each from every team; same authorization as `/user/delete`."""
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
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,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
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

@ -10,9 +10,17 @@ from litellm.proxy.management_endpoints.management_v1.budgets import (
from litellm.proxy.management_endpoints.management_v1.spend_logs import (
router as spend_logs_router,
)
from litellm.proxy.management_endpoints.management_v1.teams import (
router as teams_router,
)
from litellm.proxy.management_endpoints.management_v1.users import (
router as users_router,
)
router: Final = APIRouter()
router.include_router(budgets_router)
router.include_router(spend_logs_router)
router.include_router(teams_router)
router.include_router(users_router)
__all__ = ["router"]

View file

@ -0,0 +1,94 @@
"""`POST /management/v1/teams/{team_id}/members/bulk_delete`."""
from typing import Annotated, Final
from fastapi import APIRouter, Depends
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem, reject_unknown_query_params
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members
from litellm.proxy.management_helpers.utils import (
management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped
)
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
from litellm.types.proxy.management_endpoints.team_endpoints import (
BulkTeamMemberDeleteRequest,
BulkTeamMemberDeleteResponse,
)
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
@router.post(
"/teams/{team_id}/members/bulk_delete",
tags=["team management"], # mutable-ok: FastAPI types `tags` as list[str], not Sequence
dependencies=(Depends(user_api_key_auth), Depends(reject_unknown_query_params)),
response_model=BulkTeamMemberDeleteResponse,
)
@management_endpoint_wrapper
async def bulk_delete_team_members_action(
team_id: str,
data: BulkTeamMemberDeleteRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> BulkTeamMemberDeleteResponse:
"""
Remove up to 500 members from one team in one call. Same authorization as
`/team/member_delete`: proxy admins, the team's admins, and admins of the team's
organization. Each member is named by exactly one of `user_id` or `user_email`;
unknown body fields are a 422 and an unknown team is a 404.
`data` holds one result per requested member, in request order. A row is
`success: false` with an `error` when it names nobody on the team or repeats an
earlier row. The roster is rewritten once, under the team's advisory lock, so a
concurrent member_add is never overwritten from a stale read.
Example curl:
```
curl --location 'http://0.0.0.0:4000/management/v1/teams/team-1/members/bulk_delete' \
--header 'Authorization: Bearer sk-1234' \
--header 'Content-Type: application/json' \
--data '{"members": [{"user_id": "user-1"}, {"user_email": "user-2@example.com"}]}'
```
"""
try:
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
results: Final = await bulk_remove_team_members(
team_id=team_id,
data=data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
return BulkTeamMemberDeleteResponse(data=results)
except ManagementProblem:
raise
except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.management_v1.teams.bulk_delete_team_members_action(): "
"Exception occured - %s",
e,
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail="Failed to remove team members.",
)
)

View file

@ -0,0 +1,101 @@
"""`POST /management/v1/users/bulk_delete`."""
from typing import Annotated, Final
from fastapi import APIRouter, Depends, Header
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem, reject_unknown_query_params
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users
from litellm.proxy.management_helpers.utils import (
management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped
)
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
BulkDeleteUserRequest,
BulkDeleteUsersResponse,
)
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
@router.post(
"/users/bulk_delete",
tags=["Internal User management"], # mutable-ok: FastAPI types `tags` as list[str], not Sequence
dependencies=(Depends(user_api_key_auth), Depends(reject_unknown_query_params)),
response_model=BulkDeleteUsersResponse,
)
@management_endpoint_wrapper
async def bulk_delete_users_action(
data: BulkDeleteUserRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
litellm_changed_by: Annotated[
str | None,
Header(description="Who the caller is acting for; recorded on the audit log entries this call writes."),
] = None,
) -> BulkDeleteUsersResponse:
"""
Delete up to 500 users in one call, taking each out of every team it belongs to.
Same authorization as `/user/delete`: proxy admins may delete anyone, org admins
only users inside organizations they administer. Unknown body fields are a 422.
`data` holds one result per requested `user_id`, in request order. A row is
`success: false` with an `error` when the id is unknown, repeated in the request,
or outside the caller's scope. Rows that pass those checks are deleted together,
in one transaction, so either all of them go or none does.
Example curl:
```
curl --location 'http://0.0.0.0:4000/management/v1/users/bulk_delete' \
--header 'Authorization: Bearer sk-1234' \
--header 'Content-Type: application/json' \
--data '{"user_ids": ["user-1", "user-2"]}'
```
"""
try:
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
results: Final = await bulk_delete_users(
data=data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
litellm_proxy_admin_name=litellm_proxy_admin_name,
litellm_changed_by=litellm_changed_by,
)
return BulkDeleteUsersResponse(data=results)
except ManagementProblem:
raise
except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.management_v1.users.bulk_delete_users_action(): Exception occured - %s",
e,
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail="Failed to delete users.",
)
)

View file

@ -163,8 +163,6 @@ 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,
@ -3455,36 +3453,6 @@ 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; same authorization as `/team/member_delete`."""
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
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,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
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

@ -1,4 +1,5 @@
"""Batched deletes behind `POST /user/bulk_delete` and `POST /team/bulk_member_delete`.
"""Batched deletes behind `POST /management/v1/users/bulk_delete` and
`POST /management/v1/teams/{team_id}/members/bulk_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
@ -31,6 +32,7 @@ from litellm.proxy.auth.auth_checks import delete_cache_key_objects
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem
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
@ -48,12 +50,11 @@ from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
BulkDeleteUserRequest,
BulkDeleteUserResponse,
UserDeleteResult,
)
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
from litellm.types.proxy.management_endpoints.team_endpoints import (
BulkTeamMemberDeleteRequest,
BulkTeamMemberDeleteResponse,
TeamMemberDeleteResult,
)
@ -67,10 +68,6 @@ _AUDIT_LOG_CONCURRENCY: Final = 10
_BATCH_TX_TIMEOUT: Final = timedelta(seconds=60)
class _ErrorDetail(TypedDict):
error: ReadOnly[str]
class _OrgAdminFilter(TypedDict):
user_id: ReadOnly[str]
user_role: ReadOnly[str]
@ -105,9 +102,21 @@ class _UserBatchDeletion:
deleted_key_tokens: tuple[str, ...]
def _http_error(status_code: int, message: str) -> HTTPException:
detail: Final[_ErrorDetail] = {"error": message}
return HTTPException(status_code=status_code, detail=detail)
def _team_not_found(team_id: str) -> ManagementProblem:
return ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}team-not-found",
title="Team not found",
status=404,
detail=f"Team id={team_id} does not exist in db",
)
)
def _forbidden(detail: str) -> ManagementProblem:
return ManagementProblem(
ProblemDetail(type=f"{PROBLEM_TYPE_BASE}forbidden", title="Forbidden", status=403, detail=detail)
)
def _in_filter(field: str, values: Iterable[str]) -> Mapping[str, object]:
@ -167,6 +176,8 @@ def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDele
def _error_message(exc: BaseException) -> str:
if isinstance(exc, ManagementProblem):
return exc.problem.detail
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):
@ -194,7 +205,7 @@ async def _remove_members_from_team(
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")
raise _team_not_found(team_id)
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))
@ -269,32 +280,32 @@ def _duplicate_member_indexes(members: Sequence[MemberDeleteRequest]) -> frozens
async def bulk_remove_team_members(
team_id: str,
data: BulkTeamMemberDeleteRequest,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
) -> BulkTeamMemberDeleteResponse:
team: Final = await TeamRepository(prisma_client).find_by_id(data.team_id)
) -> tuple[TeamMemberDeleteResult, ...]:
team: Final = await TeamRepository(prisma_client).find_by_id(team_id)
if team is None:
raise _http_error(400, f"Team id={data.team_id} does not exist in db")
raise _team_not_found(team_id)
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,
raise _forbidden(
"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}",
f"route='/management/v1/teams/{team_id}/members/bulk_delete'"
)
duplicates: Final = _duplicate_member_indexes(data.members)
kept_indexes: Final = tuple(i for i in range(len(data.members)) if i not in duplicates)
members: Final = tuple(data.members[i] for i in kept_indexes)
async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx:
removal: Final = await _remove_members_from_team(prisma_client, tx, data.team_id, members, user_api_key_dict)
removal: Final = await _remove_members_from_team(prisma_client, tx, team_id, members, user_api_key_dict)
await delete_cache_key_objects(
hashed_tokens=removal.deleted_key_tokens,
user_api_key_cache=user_api_key_cache,
@ -309,7 +320,7 @@ async def bulk_remove_team_members(
return "Duplicate member in request"
return None if index in matched else "User not found in team"
results: Final = tuple(
return tuple(
TeamMemberDeleteResult(
user_id=member.user_id,
user_email=member.user_email,
@ -318,14 +329,6 @@ async def bulk_remove_team_members(
)
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]:
@ -433,7 +436,7 @@ async def _delete_users(
try:
deletion: Final = await _delete_users_tx(prisma_client, users, teams_of, user_api_key_dict, litellm_changed_by)
except Exception as e: # noqa: BLE001 # the rolled-back batch is reported per row, not as a request failure
verbose_proxy_logger.error("/user/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e)
verbose_proxy_logger.error("users/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e)
return _error_message(e)
await delete_cache_key_objects(
hashed_tokens=deletion.deleted_key_tokens,
@ -468,11 +471,11 @@ async def bulk_delete_users(
proxy_logging_obj: ProxyLogging | None,
litellm_proxy_admin_name: str | None,
litellm_changed_by: str | None,
) -> BulkDeleteUserResponse:
) -> tuple[UserDeleteResult, ...]:
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.")
raise _forbidden("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))
@ -543,11 +546,4 @@ async def bulk_delete_users(
teams_removed=tuple(tid for tid, r in deletion.removals.items() if user_id in r.removed),
)
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,
)
return tuple(result(i, uid) for i, uid in enumerate(data.user_ids))

View file

@ -1788,24 +1788,37 @@ class _ExceptionRow(TypedDict, total=False):
class _ValidationErrorDetail(TypedDict):
type: str
loc: tuple[int | str, ...]
msg: str
def _is_length_error_of_rejected_items(error: _ValidationErrorDetail, errors: Sequence[_ValidationErrorDetail]) -> bool:
"""pydantic counts only items that validated, so a bad item also trips the parent's min_length."""
return error["type"] == "too_short" and any(
len(other["loc"]) > len(error["loc"]) and other["loc"][: len(error["loc"])] == error["loc"] for other in errors
)
@app.exception_handler(RequestValidationError)
async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError):
if request.url.path.startswith(MANAGEMENT_V1_PREFIX):
_close_dangling_otel_server_span(request, 400, exc=exc)
validation_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors()
raw_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors()
validation_errors: Final = tuple(
error for error in raw_errors if not _is_length_error_of_rejected_items(error, raw_errors)
)
in_body: Final = any(error["loc"] and error["loc"][0] == "body" for error in validation_errors)
status: Final = 422 if in_body else 400
_close_dangling_otel_server_span(request, status, exc=exc)
return problem_response(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter",
title="Invalid query parameter",
status=400,
type=f"{PROBLEM_TYPE_BASE}{'invalid-request-body' if in_body else 'invalid-query-parameter'}",
title="Invalid request body" if in_body else "Invalid query parameter",
status=status,
detail="; ".join(
f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors
)
or "The request query parameters are invalid.",
or "The request is invalid.",
)
)
_close_dangling_otel_server_span(request, 422, exc=exc)

View file

@ -1,7 +1,7 @@
from collections.abc import Mapping
from typing import Any, Final, Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing_extensions import ReadOnly, TypedDict
from litellm.proxy._types import (
@ -9,6 +9,7 @@ from litellm.proxy._types import (
UpdateUserRequest,
UpdateUserRequestNoUserIDorEmail,
)
from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse
MAX_BULK_DELETE_USERS: Final = 500
@ -88,11 +89,15 @@ class BulkUpdateUserResponse(BaseModel):
class BulkDeleteUserRequest(BaseModel):
"""Body of `POST /management/v1/users/bulk_delete`."""
model_config = ConfigDict(extra="forbid")
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."""
"""Outcome for one requested user, in request order. `teams_removed` lists the teams the user left."""
user_id: str
user_email: str | None = None
@ -101,8 +106,5 @@ class UserDeleteResult(BaseModel):
error: str | None = None
class BulkDeleteUserResponse(BaseModel):
results: tuple[UserDeleteResult, ...]
total_requested: int
successful_deletions: int
failed_deletions: int
class BulkDeleteUsersResponse(ResourceResponse[tuple[UserDeleteResult, ...]]):
"""`{data: [...]}` with one `UserDeleteResult` per requested user, in request order."""

View file

@ -65,6 +65,12 @@ class ListLinks(BaseModel):
last: str
class ResourceResponse(BaseModel, Generic[TOut]):
"""Envelope for a single resource or an action's result: `{data: ...}`, no `meta` or `links`."""
data: TOut
class ListResponse(BaseModel, Generic[TOut]):
"""Rows stay flat: JSON:API's `{type, id, attributes}` wrapper is a deliberate deviation, so every
dashboard column accessor would otherwise have to go through `.attributes`."""

View file

@ -1,6 +1,6 @@
from typing import Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from litellm.proxy._types import (
KeyManagementRoutes,
@ -10,6 +10,7 @@ from litellm.proxy._types import (
Member,
MemberDeleteRequest,
)
from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse
TeamIdSearchMatch = Literal["exact", "prefix"]
@ -121,20 +122,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 TeamMemberRef(MemberDeleteRequest):
"""One member to remove, named by exactly one of `user_id` or `user_email`."""
@field_validator("members")
@classmethod
def one_identifier_per_member(cls, members: tuple[MemberDeleteRequest, ...]) -> tuple[MemberDeleteRequest, ...]:
if any(m.user_id is not None and m.user_email is not None for m in members):
model_config = ConfigDict(extra="forbid")
@model_validator(mode="after")
def one_identifier(self) -> "TeamMemberRef":
if self.user_id is not None and self.user_email is not None:
raise ValueError("Each member must be identified by exactly one of user_id or user_email")
return members
return self
class BulkTeamMemberDeleteRequest(BaseModel):
"""Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`."""
model_config = ConfigDict(extra="forbid")
members: tuple[TeamMemberRef, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES)
class TeamMemberDeleteResult(BaseModel):
"""Outcome for one row of `/team/bulk_member_delete`."""
"""Outcome for one requested member, in request order."""
user_id: str | None = None
user_email: str | None = None
@ -142,12 +151,8 @@ class TeamMemberDeleteResult(BaseModel):
error: str | None = None
class BulkTeamMemberDeleteResponse(BaseModel):
team_id: str
results: tuple[TeamMemberDeleteResult, ...]
total_requested: int
successful_deletions: int
failed_deletions: int
class BulkTeamMemberDeleteResponse(ResourceResponse[tuple[TeamMemberDeleteResult, ...]]):
"""`{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order."""
class TeamMemberInfoResponse(LiteLLM_TeamMembership):

View file

@ -80,12 +80,10 @@ 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

@ -71,17 +71,20 @@ async def test_team_bulk_member_delete_authz_matrix(
caller = world.keys[actor]
resp = await proxy_client.post(
"/team/bulk_member_delete",
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={"team_id": scratch.prefix, "members": [{"user_id": v} for v in victims]},
json={"members": [{"user_id": v} for v in victims]},
)
assert resp.status_code == expected_status, f"{actor.value} {shape}: {resp.status_code} {resp.text}"
if expected_status == 403:
assert resp.headers["content-type"] == "application/problem+json"
assert resp.json()["type"] == "urn:litellm:error:forbidden"
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert row is not None
assert keep in _member_ids(row), "unrelated member removed"
if expected_status == 200:
assert [(r["user_id"], r["success"]) for r in resp.json()["results"]] == [(v, True) for v in victims]
assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [(v, True) for v in victims]
assert not set(victims) & set(_member_ids(row))
else:
assert set(victims) <= set(_member_ids(row)), "denied but members removed"
@ -94,18 +97,18 @@ async def test_team_bulk_member_delete_reports_each_row_in_order(proxy_client, p
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim, keep])
resp = await proxy_client.post(
"/team/bulk_member_delete",
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "members": [{"user_id": stranger}, {"user_id": victim}]},
json={"members": [{"user_id": stranger}, {"user_id": victim}]},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert [(r["user_id"], r["success"]) for r in body["results"]] == [
assert set(body) == {"data"}
assert [(r["user_id"], r["success"]) for r in body["data"]] == [
(stranger, False),
(victim, True),
]
assert body["results"][0]["error"] == "User not found in team"
assert (body["successful_deletions"], body["failed_deletions"]) == (1, 1)
assert body["data"][0]["error"] == "User not found in team"
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert row is not None and _member_ids(row) == [keep]
@ -116,14 +119,61 @@ async def test_team_bulk_member_delete_row_naming_both_identifiers_is_422(proxy_
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim])
resp = await proxy_client.post(
"/team/bulk_member_delete",
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={
"team_id": scratch.prefix,
"members": [{"user_id": victim, "user_email": f"{victim}@example.com"}],
},
json={"members": [{"user_id": victim, "user_email": f"{victim}@example.com"}]},
)
assert resp.status_code == 422, resp.text
assert resp.headers["content-type"] == "application/problem+json"
assert resp.json()["type"] == "urn:litellm:error:invalid-request-body"
assert (
resp.json()["detail"]
== "members.0: Value error, Each member must be identified by exactly one of user_id or user_email"
)
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert row is not None and victim in _member_ids(row)
async def test_team_bulk_member_delete_unknown_query_param_is_400(proxy_client, prisma, scratch, world):
victim = scratch.tag("victim")
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim])
resp = await proxy_client.post(
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete?dry_run=1",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"members": [{"user_id": victim}]},
)
assert resp.status_code == 400, resp.text
assert resp.headers["content-type"] == "application/problem+json"
assert "dry_run" in resp.json()["detail"]
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert row is not None and victim in _member_ids(row)
async def test_team_bulk_member_delete_unknown_body_field_is_422(proxy_client, prisma, scratch, world):
victim = scratch.tag("victim")
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim])
resp = await proxy_client.post(
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "members": [{"user_id": victim}]},
)
assert resp.status_code == 422, resp.text
assert "team_id" in resp.json()["detail"]
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert row is not None and victim in _member_ids(row)
async def test_team_bulk_member_delete_unknown_team_is_404_problem(proxy_client, scratch, world):
resp = await proxy_client.post(
f"/management/v1/teams/{scratch.tag('missing')}/members/bulk_delete",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"members": [{"user_id": scratch.tag("victim")}]},
)
assert resp.status_code == 404, resp.text
assert resp.headers["content-type"] == "application/problem+json"
assert resp.json()["type"] == "urn:litellm:error:team-not-found"

View file

@ -0,0 +1,137 @@
import pytest
from .actors import Actor
from .conftest import create_scratch_team, create_scratch_user
pytestmark = pytest.mark.asyncio(loop_scope="session")
_URL = "/management/v1/users/bulk_delete"
# (id, actor, victims' org, expected status, whether the victims are gone afterwards)
_MATRIX = [
("org_a/proxy_admin", Actor.PROXY_ADMIN, "a", 200, True),
("org_a/org_admin", Actor.ORG_ADMIN, "a", 200, True),
("org_a/org_b_admin", Actor.ORG_B_ADMIN, "a", 200, False),
("org_a/team_admin", Actor.TEAM_ADMIN, "a", 403, False),
("org_a/internal_user", Actor.INTERNAL_USER, "a", 403, False),
("org_a/owner", Actor.OWNER, "a", 403, False),
("org_a/service_account", Actor.SERVICE_ACCOUNT, "a", 403, False),
("no_org/proxy_admin", Actor.PROXY_ADMIN, None, 200, True),
("no_org/org_admin", Actor.ORG_ADMIN, None, 200, False),
]
def _member_ids(row) -> list:
return [m["user_id"] for m in (row.members_with_roles or [])]
async def _seed_team_members(prisma, scratch, world, member_ids: list, org_id) -> None:
"""Leave behind what /team/member_add would: roster entry, `teams` array, and org membership."""
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=member_ids)
await prisma.db.litellm_usertable.update_many(
where={"user_id": {"in": member_ids}}, data={"teams": {"set": [scratch.prefix]}}
)
if org_id is None:
return
for uid in member_ids:
await prisma.db.litellm_organizationmembership.create(
data={"user_id": uid, "organization_id": org_id, "user_role": "internal_user"}
)
@pytest.mark.parametrize(
"actor,org,expected_status,expect_deleted",
[(a, o, s, d) for (_id, a, o, s, d) in _MATRIX],
ids=[s[0] for s in _MATRIX],
)
async def test_users_bulk_delete_authz_matrix(
actor: Actor,
org,
expected_status: int,
expect_deleted: bool,
proxy_client,
prisma,
scratch,
world,
):
victims = [await create_scratch_user(prisma, scratch.prefix, suffix=s) for s in ("v1", "v2")]
keep = await create_scratch_user(prisma, scratch.prefix, suffix="keep")
await _seed_team_members(prisma, scratch, world, victims + [keep], world.org_a_id if org == "a" else None)
resp = await proxy_client.post(
_URL,
headers={"Authorization": f"Bearer {world.keys[actor].cleartext}"},
json={"user_ids": victims},
)
assert resp.status_code == expected_status, f"{actor.value}: {resp.status_code} {resp.text}"
team = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert team is not None and keep in _member_ids(team), "unrelated member removed"
remaining = {u.user_id for u in await prisma.db.litellm_usertable.find_many(where={"user_id": {"in": victims}})}
if expected_status == 403:
assert resp.headers["content-type"] == "application/problem+json"
assert resp.json()["type"] == "urn:litellm:error:forbidden"
assert remaining == set(victims), "denied but users deleted"
assert set(victims) <= set(_member_ids(team)), "denied but members removed"
return
body = resp.json()
assert set(body) == {"data"}
rows = [(r["user_id"], r["success"], r["teams_removed"]) for r in body["data"]]
if expect_deleted:
assert rows == [(v, True, [scratch.prefix]) for v in victims]
assert remaining == set()
assert not set(victims) & set(_member_ids(team))
return
assert rows == [(v, False, []) for v in victims]
assert all("not within your admin scope" in r["error"] for r in body["data"])
assert remaining == set(victims), "out-of-scope rows reported failed but users deleted"
assert set(victims) <= set(_member_ids(team))
async def test_users_bulk_delete_reports_each_row_in_order(proxy_client, prisma, scratch, world):
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim")
ghost = scratch.tag("ghost")
resp = await proxy_client.post(
_URL,
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"user_ids": [ghost, victim, victim]},
)
assert resp.status_code == 200, resp.text
assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [
(ghost, False),
(victim, True),
(victim, False),
]
assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is None
async def test_users_bulk_delete_unknown_query_param_is_400_problem(proxy_client, prisma, scratch, world):
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim")
resp = await proxy_client.post(
f"{_URL}?dry_run=1",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"user_ids": [victim]},
)
assert resp.status_code == 400, resp.text
assert resp.headers["content-type"] == "application/problem+json"
assert resp.json()["type"] == "urn:litellm:error:unknown-query-parameter"
assert "dry_run" in resp.json()["detail"]
assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is not None
async def test_users_bulk_delete_unknown_body_field_is_422_problem(proxy_client, prisma, scratch, world):
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim")
resp = await proxy_client.post(
_URL,
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"user_ids": [victim], "dry_run": True},
)
assert resp.status_code == 422, resp.text
assert resp.headers["content-type"] == "application/problem+json"
assert resp.json()["type"] == "urn:litellm:error:invalid-request-body"
assert "dry_run" in resp.json()["detail"]
assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is not None

View file

@ -5,14 +5,14 @@ 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._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.list_api.common import ManagementProblem
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
from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest, TeamMemberRef
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)
@ -237,7 +237,8 @@ async def _remove(
cache: UserApiKeyCache | None = None,
):
return await bulk_remove_team_members(
data=BulkTeamMemberDeleteRequest(team_id=team_id, members=tuple(MemberDeleteRequest(**m) for m in members)),
team_id=team_id,
data=BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(**m) for m in members)),
user_api_key_dict=caller,
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
user_api_key_cache=cache or UserApiKeyCache(),
@ -260,10 +261,10 @@ async def test_bulk_delete_removes_users_from_every_team_and_store():
org_memberships=[{"user_id": "u1", "organization_id": "o1", "user_role": "internal_user"}],
)
response = await _delete(prisma, ["u1", "u2"])
results = 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] == [
assert len(results) == 2
assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in results] == [
("u1", "u1@example.com", True, ("t1", "t2")),
("u2", "u2@example.com", True, ("t1",)),
]
@ -297,9 +298,9 @@ async def test_bulk_delete_leaves_teammates_who_share_the_deleted_users_email_al
],
)
response = await _delete(prisma, ["u1"])
results = await _delete(prisma, ["u1"])
assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))]
assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))]
assert _roster(prisma, "t1") == ["twin"]
assert set(prisma.db.litellm_usertable.rows) == {"twin"} and prisma.db.litellm_usertable.rows["twin"].teams == [
"t1"
@ -319,9 +320,9 @@ async def test_bulk_delete_removes_the_deleted_users_email_only_roster_entry():
)
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("keep", "t1")], teams=[team])
response = await _delete(prisma, ["u1"])
results = await _delete(prisma, ["u1"])
assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))]
assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))]
assert _roster(prisma, "t1") == ["keep"]
assert set(prisma.db.litellm_usertable.rows) == {"keep"}
@ -334,9 +335,9 @@ async def test_bulk_delete_finds_teams_through_membership_rows_when_user_teams_a
memberships=[("t1", "u1")],
)
response = await _delete(prisma, ["u1"])
results = await _delete(prisma, ["u1"])
assert response.results[0].teams_removed == ("t1",)
assert results[0].teams_removed == ("t1",)
assert _roster(prisma, "t1") == ["keep"]
assert prisma.db.litellm_teammembership.rows == []
@ -350,9 +351,9 @@ async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives(
prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[team], on_lock=concurrent_member_add)
response = await _delete(prisma, ["u1"])
results = await _delete(prisma, ["u1"])
assert response.results[0].success is True
assert results[0].success is True
assert _roster(prisma, "t1") == ["late"]
@ -360,10 +361,10 @@ async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives(
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"])
results = 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] == [
assert [r.success for r in results].count(True) == 1
assert [(r.user_id, r.success, r.error) for r in results] == [
("u1", True, None),
("ghost", False, "User id=ghost not found"),
("u1", False, "Duplicate user_id in request: u1"),
@ -381,9 +382,9 @@ async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_
)
cache = _cache_with("k1")
response = await _delete(prisma, ["u1", "u2"], cache=cache)
results = await _delete(prisma, ["u1", "u2"], cache=cache)
assert [(r.user_id, r.success, r.teams_removed, r.error) for r in response.results] == [
assert [(r.user_id, r.success, r.teams_removed, r.error) for r in results] == [
("u1", False, (), "Failed to delete user: lock timeout"),
("u2", False, (), "Failed to delete user: lock timeout"),
]
@ -397,9 +398,9 @@ async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_
async def test_bulk_delete_skips_teams_the_user_still_names_but_which_no_longer_exist():
prisma = _FakePrisma(users=[_user("u1", "gone", "t1")], teams=[_team("t1", "u1", "keep")])
response = await _delete(prisma, ["u1"])
results = await _delete(prisma, ["u1"])
assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))]
assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))]
assert prisma.db.litellm_usertable.rows == {} and _roster(prisma, "t1") == ["keep"]
assert prisma.locks == ["t1"]
@ -414,9 +415,9 @@ async def test_bulk_delete_rolls_back_every_user_row_and_reports_it_per_row_when
)
cache = _cache_with("k1")
response = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache)
results = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache)
assert [(r.user_id, r.success, r.error) for r in response.results] == [
assert [(r.user_id, r.success, r.error) for r in results] == [
("u1", False, "Failed to delete user: connection reset"),
("u2", False, "Failed to delete user: connection reset"),
("ghost", False, "User id=ghost not found"),
@ -452,10 +453,10 @@ async def test_bulk_delete_evicts_deleted_keys_and_users_from_the_auth_cache():
async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db():
prisma = _FakePrisma(users=[_user("u1")])
with pytest.raises(HTTPException) as exc:
with pytest.raises(ManagementProblem) as exc:
await _delete(prisma, ["u1"], caller=INTERNAL)
assert exc.value.status_code == 403
assert exc.value.problem.status == 403
assert set(prisma.db.litellm_usertable.rows) == {"u1"}
@ -471,10 +472,10 @@ async def test_org_admin_deletes_only_users_fully_inside_their_orgs():
],
)
response = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN)
results = 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 [r.success for r in results] == [True, False, False]
assert all("not within your admin scope" in (r.error or "") for r in 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"),
@ -496,10 +497,9 @@ async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest():
],
)
response = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}])
results = 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] == [
assert [(r.user_id, r.user_email, r.success) for r in results] == [
("u1", None, True),
(None, "u2@example.com", True),
]
@ -516,13 +516,12 @@ async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest():
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"}])
results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}])
assert [(r.success, r.error) for r in response.results] == [
assert [(r.success, r.error) for r in 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"]
@ -536,9 +535,9 @@ async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_membe
tokens=[{"token": "orphan-key", "user_id": "elsewhere", "team_id": "t1"}],
)
response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}])
results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}])
assert response.results[0].success is False
assert results[0].success is False
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "elsewhere"}]
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["orphan-key"]
@ -547,17 +546,16 @@ async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_membe
async def test_bulk_member_delete_reports_repeated_members_as_duplicates_and_removes_them_once():
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("u2", "t1")], teams=[_team("t1", "u1", "u2", "keep")])
response = await _remove(
results = await _remove(
prisma, "t1", [{"user_id": "u1"}, {"user_id": "u1"}, {"user_email": "u1@example.com"}, {"user_id": "u2"}]
)
assert [(r.success, r.error) for r in response.results] == [
assert [(r.success, r.error) for r in results] == [
(True, None),
(False, "Duplicate member in request"),
(True, None),
(True, None),
]
assert (response.successful_deletions, response.failed_deletions) == (3, 1)
assert _roster(prisma, "t1") == ["keep"]
@ -583,9 +581,9 @@ async def test_bulk_member_delete_evicts_the_removed_team_keys_from_the_auth_cac
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"}])
results = await _remove(prisma, "t1", [{"user_id": "stale"}])
assert response.results[0].success is True
assert 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
@ -595,13 +593,13 @@ async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_th
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:
with pytest.raises(ManagementProblem) as missing:
await _remove(prisma, "nope", [{"user_id": "u1"}])
with pytest.raises(HTTPException) as forbidden:
with pytest.raises(ManagementProblem) as forbidden:
await _remove(prisma, "t1", [{"user_id": "u1"}], caller=INTERNAL)
assert missing.value.status_code == 400
assert forbidden.value.status_code == 403
assert missing.value.problem.status == 404
assert forbidden.value.problem.status == 403
assert _roster(prisma, "t1") == ["u1"] and prisma.locks == []
@ -611,9 +609,9 @@ async def test_team_admin_may_bulk_remove_members():
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"))
results = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead"))
assert response.results[0].success is True
assert results[0].success is True
assert _roster(prisma, "t1") == ["lead"]
@ -623,22 +621,24 @@ def test_request_models_enforce_batch_bounds():
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=())
BulkTeamMemberDeleteRequest(members=())
with pytest.raises(ValidationError):
BulkTeamMemberDeleteRequest(
team_id="t1", members=tuple(MemberDeleteRequest(user_id=f"u{i}") for i in range(501))
)
BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(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
def test_bulk_member_delete_request_requires_exactly_one_identifier_per_member():
with pytest.raises(ValidationError, match="exactly one of user_id or user_email"):
BulkTeamMemberDeleteRequest(
team_id="t1", members=(MemberDeleteRequest(user_id="u1", user_email="other@example.com"),)
)
BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "user_email": "other@example.com"}]})
with pytest.raises(ValidationError):
BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{}]})
assert (
BulkTeamMemberDeleteRequest(team_id="t1", members=(MemberDeleteRequest(user_id="u1"),)).members[0].user_id
== "u1"
)
BulkTeamMemberDeleteRequest.model_validate({"members": [{}]})
assert BulkTeamMemberDeleteRequest(members=(TeamMemberRef(user_id="u1"),)).members[0].user_id == "u1"
def test_request_models_reject_unknown_fields():
with pytest.raises(ValidationError, match="team_id"):
BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{"user_id": "u1"}]})
with pytest.raises(ValidationError, match="role"):
BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "role": "admin"}]})
with pytest.raises(ValidationError, match="dry_run"):
BulkDeleteUserRequest.model_validate({"user_ids": ["u1"], "dry_run": True})

View file

@ -8470,6 +8470,71 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/teams/{team_id}/members/bulk_delete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Bulk Delete Team Members Action
* @description Remove up to 500 members from one team in one call. Same authorization as
* `/team/member_delete`: proxy admins, the team's admins, and admins of the team's
* organization. Each member is named by exactly one of `user_id` or `user_email`;
* unknown body fields are a 422 and an unknown team is a 404.
*
* `data` holds one result per requested member, in request order. A row is
* `success: false` with an `error` when it names nobody on the team or repeats an
* earlier row. The roster is rewritten once, under the team's advisory lock, so a
* concurrent member_add is never overwritten from a stale read.
*
* Example curl:
* ```
* curl --location 'http://0.0.0.0:4000/management/v1/teams/team-1/members/bulk_delete' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"members": [{"user_id": "user-1"}, {"user_email": "user-2@example.com"}]}'
* ```
*/
post: operations["bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/management/v1/users/bulk_delete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Bulk Delete Users Action
* @description Delete up to 500 users in one call, taking each out of every team it belongs to.
* Same authorization as `/user/delete`: proxy admins may delete anyone, org admins
* only users inside organizations they administer. Unknown body fields are a 422.
*
* `data` holds one result per requested `user_id`, in request order. A row is
* `success: false` with an `error` when the id is unknown, repeated in the request,
* or outside the caller's scope. Rows that pass those checks are deleted together,
* in one transaction, so either all of them go or none does.
*
* Example curl:
* ```
* curl --location 'http://0.0.0.0:4000/management/v1/users/bulk_delete' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"user_ids": ["user-1", "user-2"]}'
* ```
*/
post: operations["bulk_delete_users_action_management_v1_users_bulk_delete_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mcp": {
parameters: {
query?: never;
@ -15056,26 +15121,6 @@ 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; same authorization as `/team/member_delete`.
*/
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;
@ -16498,26 +16543,6 @@ 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 users, removing each from every team; same authorization as `/user/delete`.
*/
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;
@ -24543,21 +24568,21 @@ export interface components {
/** Budgets */
budgets: string[];
};
/** BulkDeleteUserRequest */
/**
* BulkDeleteUserRequest
* @description Body of `POST /management/v1/users/bulk_delete`.
*/
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;
/**
* BulkDeleteUsersResponse
* @description `{data: [...]}` with one `UserDeleteResult` per requested user, in request order.
*/
BulkDeleteUsersResponse: {
/** Data */
data: components["schemas"]["UserDeleteResult"][];
};
/**
* BulkTeamMemberAddRequest
@ -24596,25 +24621,21 @@ export interface components {
[key: string]: unknown;
} | null;
};
/** BulkTeamMemberDeleteRequest */
/**
* BulkTeamMemberDeleteRequest
* @description Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`.
*/
BulkTeamMemberDeleteRequest: {
/** Members */
members: components["schemas"]["MemberDeleteRequest"][];
/** Team Id */
team_id: string;
members: components["schemas"]["TeamMemberRef"][];
};
/** BulkTeamMemberDeleteResponse */
/**
* BulkTeamMemberDeleteResponse
* @description `{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order.
*/
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;
/** Data */
data: components["schemas"]["TeamMemberDeleteResult"][];
};
/**
* BulkUpdateKeyRequest
@ -31982,13 +32003,6 @@ export interface components {
*/
user_id?: string | null;
};
/** MemberDeleteRequest */
MemberDeleteRequest: {
/** User Email */
user_email?: string | null;
/** User Id */
user_id?: string | null;
};
/** MemoryCreateRequest */
MemoryCreateRequest: {
/**
@ -37254,7 +37268,7 @@ export interface components {
};
/**
* TeamMemberDeleteResult
* @description Outcome for one row of `/team/bulk_member_delete`.
* @description Outcome for one requested member, in request order.
*/
TeamMemberDeleteResult: {
/** Error */
@ -37296,6 +37310,16 @@ export interface components {
/** User Id */
user_id: string;
};
/**
* TeamMemberRef
* @description One member to remove, named by exactly one of `user_id` or `user_email`.
*/
TeamMemberRef: {
/** User Email */
user_email?: string | null;
/** User Id */
user_id?: string | null;
};
/** TeamMemberUpdateRequest */
TeamMemberUpdateRequest: {
/**
@ -39447,7 +39471,7 @@ export interface components {
};
/**
* UserDeleteResult
* @description Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of.
* @description Outcome for one requested user, in request order. `teams_removed` lists the teams the user left.
*/
UserDeleteResult: {
/** Error */
@ -51236,6 +51260,77 @@ export interface operations {
};
};
};
bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post: {
parameters: {
query?: never;
header?: never;
path: {
team_id: string;
};
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"];
};
};
};
};
bulk_delete_users_action_management_v1_users_bulk_delete_post: {
parameters: {
query?: never;
header?: {
/** @description Who the caller is acting for; recorded on the audit log entries this call writes. */
"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"]["BulkDeleteUsersResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
aggregate_mcp_route_mcp_get: {
parameters: {
query?: never;
@ -59006,39 +59101,6 @@ 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?: {
@ -60783,42 +60845,6 @@ 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;