mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
parent
30f33a949b
commit
cd9c39921b
10 changed files with 1270 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
442
litellm/proxy/management_helpers/bulk_user_deletion.py
Normal file
442
litellm/proxy/management_helpers/bulk_user_deletion.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
227
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
227
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue