mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #41039 from BerriAI/litellm_bulk_user_delete
feat(proxy): add POST /user/bulk_delete and POST /team/bulk_member_delete
This commit is contained in:
commit
8b6c398b92
15 changed files with 2085 additions and 17 deletions
|
|
@ -662,6 +662,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/user/update",
|
||||
"/user/bulk_update",
|
||||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/info",
|
||||
"/user/list",
|
||||
"/user/daily/activity",
|
||||
|
|
@ -841,6 +842,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
self_managed_routes = [
|
||||
"/team/member_add",
|
||||
"/team/member_delete",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
"/team/member_update",
|
||||
"/team/{team_id}/member/{user_id}/reset_spend",
|
||||
"/team/permissions_list",
|
||||
|
|
@ -867,6 +869,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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -26,9 +26,11 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
|
|||
"/user/new",
|
||||
"/management/v1/users/bulk",
|
||||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/bulk_update",
|
||||
# team
|
||||
"/team/new",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
"/team/update",
|
||||
"/team/delete",
|
||||
"/team/block",
|
||||
|
|
@ -588,7 +590,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,8 +763,10 @@ class RouteChecks:
|
|||
"/user/new",
|
||||
"/management/v1/users/bulk",
|
||||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/bulk_update",
|
||||
"/team/new",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
"/team/update",
|
||||
"/team/delete",
|
||||
"/model/new",
|
||||
|
|
@ -826,7 +830,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
|
||||
|
|
@ -861,9 +865,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}",
|
||||
|
|
|
|||
|
|
@ -59,14 +59,23 @@ def escape_like(value: str) -> str:
|
|||
|
||||
|
||||
class ValidationErrorDetail(TypedDict):
|
||||
"""The two keys of a pydantic/FastAPI validation error a problem document needs."""
|
||||
"""The keys of a pydantic/FastAPI validation error a problem document needs."""
|
||||
|
||||
type: ReadOnly[str]
|
||||
loc: ReadOnly[tuple[int | str, ...]]
|
||||
msg: ReadOnly[str]
|
||||
|
||||
|
||||
def request_validation_problem(errors: Sequence[ValidationErrorDetail]) -> ProblemDetail:
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def request_validation_problem(raw_errors: Sequence[ValidationErrorDetail]) -> ProblemDetail:
|
||||
"""A body that fails validation (an unknown field included) is 422; a bad query parameter is 400."""
|
||||
errors: Final = tuple(error for error in raw_errors if not _is_length_error_of_rejected_items(error, raw_errors))
|
||||
detail: Final = "; ".join(f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in errors)
|
||||
if any(error["loc"] and error["loc"][0] == "body" for error in errors):
|
||||
return ProblemDetail(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ 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,
|
||||
)
|
||||
|
|
@ -17,6 +20,7 @@ from litellm.proxy.management_endpoints.management_v1.users import (
|
|||
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"]
|
||||
|
|
|
|||
94
litellm/proxy/management_endpoints/management_v1/teams.py
Normal file
94
litellm/proxy/management_endpoints/management_v1/teams.py
Normal 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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -1,19 +1,22 @@
|
|||
"""`POST /management/v1/users/bulk`."""
|
||||
"""`POST /management/v1/users/bulk` and `POST /management/v1/users/bulk_delete`."""
|
||||
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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
|
||||
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_creation import bulk_create_users
|
||||
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 untyped decorator
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
|
||||
BulkDeleteUserRequest,
|
||||
BulkDeleteUsersResponse,
|
||||
BulkNewUserRequest,
|
||||
BulkNewUserResponse,
|
||||
)
|
||||
|
|
@ -103,3 +106,82 @@ async def bulk_create_users_route(
|
|||
detail="Failed to create users.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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.",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
560
litellm/proxy/management_helpers/bulk_user_deletion.py
Normal file
560
litellm/proxy/management_helpers/bulk_user_deletion.py
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
"""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
|
||||
member_add on the team is never overwritten from a stale read. A user batch runs in one
|
||||
transaction, taking its team locks in sorted order, so either every team rewrite and every
|
||||
user row delete lands or none of them does.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Awaitable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
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.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
|
||||
)
|
||||
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, ProxyLogging
|
||||
from litellm.repositories.table_repositories import (
|
||||
OrganizationMembershipRepository,
|
||||
TeamMembershipRepository,
|
||||
)
|
||||
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,
|
||||
UserDeleteResult,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
BulkTeamMemberDeleteRequest,
|
||||
TeamMemberDeleteResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import Prisma
|
||||
from prisma import models as prisma_models
|
||||
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
|
||||
_AUDIT_LOG_CONCURRENCY: Final = 10
|
||||
_BATCH_TX_TIMEOUT: Final = timedelta(seconds=60)
|
||||
|
||||
|
||||
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]
|
||||
deleted_key_tokens: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _UserBatchDeletion:
|
||||
removals: Mapping[str, _TeamRemoval]
|
||||
deleted_key_tokens: tuple[str, ...]
|
||||
|
||||
|
||||
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]:
|
||||
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 _invitation_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_InvitationLink]":
|
||||
return tx.litellm_invitationlink # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _org_membership_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_OrganizationMembership]":
|
||||
return tx.litellm_organizationmembership # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _same_email(email: str | None, request: MemberDeleteRequest) -> bool:
|
||||
return request.user_email is not None and request.user_email == email
|
||||
|
||||
|
||||
def _addresses_member(member: Member, request: MemberDeleteRequest) -> bool:
|
||||
if request.user_id is None:
|
||||
return _same_email(member.user_email, request)
|
||||
return request.user_id == member.user_id or (member.user_id is None and _same_email(member.user_email, request))
|
||||
|
||||
|
||||
def _with_row_email(request: MemberDeleteRequest, email_of: Mapping[str, str]) -> MemberDeleteRequest:
|
||||
if request.user_id is None or request.user_email is not None:
|
||||
return request
|
||||
return MemberDeleteRequest(user_id=request.user_id, user_email=email_of.get(request.user_id))
|
||||
|
||||
|
||||
def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDeleteRequest) -> bool:
|
||||
if request.user_id is None:
|
||||
return _same_email(user.user_email, request)
|
||||
return request.user_id == user.user_id
|
||||
|
||||
|
||||
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):
|
||||
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(_AUDIT_LOG_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,
|
||||
tx: "Prisma",
|
||||
team_id: str,
|
||||
members: Sequence[MemberDeleteRequest],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> _TeamRemoval:
|
||||
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 _team_not_found(team_id)
|
||||
|
||||
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)
|
||||
requested_rows: Final = await _user_tx_db(tx).find_many(
|
||||
where=_any_filter(_in_filter("user_id", requested_ids), _in_filter("user_email", requested_emails))
|
||||
)
|
||||
email_of: Final = MappingProxyType(
|
||||
{u.user_id: u.user_email for u in requested_rows if u.user_email is not None and team_id in u.teams}
|
||||
)
|
||||
requests: Final = tuple(_with_row_email(r, email_of) for r in members)
|
||||
removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in requests))
|
||||
kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in requests))
|
||||
removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None)
|
||||
unfetched_ids: Final = removed_ids - frozenset(u.user_id for u in requested_rows)
|
||||
removed_rows: Final = (
|
||||
await _user_tx_db(tx).find_many(where=_in_filter("user_id", unfetched_ids)) if unfetched_ids else ()
|
||||
)
|
||||
stale_rows: Final = tuple(u for u in (*requested_rows, *removed_rows) if team_id in u.teams)
|
||||
cleanup_ids: Final = removed_ids | frozenset(u.user_id for u in stale_rows)
|
||||
matched: Final = frozenset(
|
||||
i
|
||||
for i, r in enumerate(requests)
|
||||
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=cleanup_ids,
|
||||
matched=matched,
|
||||
deleted_key_tokens=tuple(k.token for k in keys),
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def _duplicate_member_indexes(members: Sequence[MemberDeleteRequest]) -> frozenset[int]:
|
||||
return frozenset(
|
||||
i
|
||||
for i, m in enumerate(members)
|
||||
if any(
|
||||
(m.user_id is not None and m.user_id == earlier.user_id)
|
||||
or (m.user_email is not None and m.user_email == earlier.user_email)
|
||||
for earlier in members[:i]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
) -> tuple[TeamMemberDeleteResult, ...]:
|
||||
team: Final = await TeamRepository(prisma_client).find_by_id(team_id)
|
||||
if team is None:
|
||||
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 _forbidden(
|
||||
"Call not allowed. User not proxy admin OR team admin OR org admin for this team. "
|
||||
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, 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,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
_emit_team_members_metric(removal.team)
|
||||
|
||||
matched: Final = frozenset(kept_indexes[j] for j in removal.matched)
|
||||
|
||||
def error(index: int) -> str | None:
|
||||
if index in duplicates:
|
||||
return "Duplicate member in request"
|
||||
return None if index in matched else "User not found in team"
|
||||
|
||||
return tuple(
|
||||
TeamMemberDeleteResult(
|
||||
user_id=member.user_id,
|
||||
user_email=member.user_email,
|
||||
success=i in matched,
|
||||
error=error(i),
|
||||
)
|
||||
for i, member in enumerate(data.members)
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
tx: "Prisma",
|
||||
user_ids: frozenset[str],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: str | None,
|
||||
) -> tuple[str, ...]:
|
||||
keys: Final = await _token_tx_db(tx).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,
|
||||
tx=tx,
|
||||
)
|
||||
await _token_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
await _invitation_tx_db(tx).delete_many(
|
||||
where=_any_filter(
|
||||
_in_filter("user_id", user_ids),
|
||||
_in_filter("created_by", user_ids),
|
||||
_in_filter("updated_by", user_ids),
|
||||
)
|
||||
)
|
||||
await _org_membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
await _membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
await _user_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
return tuple(k.token for k in keys)
|
||||
|
||||
|
||||
async def _delete_users_tx(
|
||||
prisma_client: PrismaClient,
|
||||
users: Sequence["prisma_models.LiteLLM_UserTable"],
|
||||
teams_of: Mapping[str, frozenset[str]],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: str | None,
|
||||
) -> _UserBatchDeletion:
|
||||
"""Rewrites every team the users belong to and deletes their rows in one transaction, so a
|
||||
failure anywhere rolls back the whole batch. Teams a user still names but which no longer exist
|
||||
are skipped; the user row goes away regardless."""
|
||||
async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx:
|
||||
team_rows: Final = await _team_tx_db(tx).find_many(
|
||||
where=_in_filter("team_id", frozenset(t for teams in teams_of.values() for t in teams))
|
||||
)
|
||||
team_ids: Final = tuple(sorted(t.team_id for t in team_rows))
|
||||
removals: Final = MappingProxyType(
|
||||
{
|
||||
tid: await _remove_members_from_team(
|
||||
prisma_client,
|
||||
tx,
|
||||
tid,
|
||||
tuple(
|
||||
MemberDeleteRequest(user_id=u.user_id, user_email=u.user_email)
|
||||
for u in users
|
||||
if tid in teams_of[u.user_id]
|
||||
),
|
||||
user_api_key_dict,
|
||||
)
|
||||
for tid in team_ids
|
||||
}
|
||||
)
|
||||
deleted_key_tokens: Final = await _delete_user_rows(
|
||||
prisma_client, tx, frozenset(u.user_id for u in users), user_api_key_dict, litellm_changed_by
|
||||
)
|
||||
return _UserBatchDeletion(
|
||||
removals=removals,
|
||||
deleted_key_tokens=deleted_key_tokens + tuple(t for r in removals.values() for t in r.deleted_key_tokens),
|
||||
)
|
||||
|
||||
|
||||
async def _delete_users(
|
||||
prisma_client: PrismaClient,
|
||||
users: Sequence["prisma_models.LiteLLM_UserTable"],
|
||||
teams_of: Mapping[str, frozenset[str]],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
litellm_proxy_admin_name: str | None,
|
||||
litellm_changed_by: str | None,
|
||||
) -> _UserBatchDeletion | str:
|
||||
"""Returns the error message when the transaction rolled back, in which case no row was touched."""
|
||||
user_ids: Final = frozenset(u.user_id for u in 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("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,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await evict_and_broadcast(cache_keys=sorted(user_ids), user_api_key_cache=user_api_key_cache)
|
||||
for removal in deletion.removals.values():
|
||||
_emit_team_members_metric(removal.team)
|
||||
audit_outcomes: Final = 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
|
||||
)
|
||||
for u, outcome in zip(users, audit_outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
verbose_proxy_logger.warning("Failed to create audit log for user %s: %s", u.user_id, outcome)
|
||||
return deletion
|
||||
|
||||
|
||||
async def bulk_delete_users(
|
||||
data: BulkDeleteUserRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
litellm_proxy_admin_name: str | None,
|
||||
litellm_changed_by: str | None,
|
||||
) -> 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 _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))
|
||||
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
|
||||
}
|
||||
)
|
||||
deletion: Final = (
|
||||
await _delete_users(
|
||||
prisma_client,
|
||||
candidates,
|
||||
teams_of,
|
||||
user_api_key_dict,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
litellm_proxy_admin_name,
|
||||
litellm_changed_by,
|
||||
)
|
||||
if candidates
|
||||
else _UserBatchDeletion(removals=MappingProxyType({}), deleted_key_tokens=())
|
||||
)
|
||||
|
||||
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)
|
||||
if isinstance(deletion, str):
|
||||
return UserDeleteResult(
|
||||
user_id=user_id,
|
||||
user_email=rows_by_id[user_id].user_email,
|
||||
success=False,
|
||||
error=f"Failed to delete user: {deletion}",
|
||||
)
|
||||
return UserDeleteResult(
|
||||
user_id=user_id,
|
||||
user_email=rows_by_id[user_id].user_email,
|
||||
success=True,
|
||||
teams_removed=tuple(tid for tid, r in deletion.removals.items() if user_id in r.removed),
|
||||
)
|
||||
|
||||
return tuple(result(i, uid) for i, uid in enumerate(data.user_ids))
|
||||
|
|
@ -3793,6 +3793,7 @@ def jsonify_object(data: dict) -> dict:
|
|||
# Bounded to prevent memory leaks from accumulated rotations.
|
||||
_deprecated_key_cache: Final[LimitedSizeOrderedDict] = LimitedSizeOrderedDict(max_size=1000)
|
||||
_DEPRECATED_KEY_CACHE_TTL_SECONDS: Final = 60
|
||||
_PRISMA_DEFAULT_TX_TIMEOUT: Final = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def _lookup_deprecated_key(
|
||||
|
|
@ -4171,13 +4172,13 @@ class PrismaClient:
|
|||
return self.db.read_target
|
||||
return self.db
|
||||
|
||||
def tx(self) -> "TransactionManager":
|
||||
def tx(self, *, timeout: timedelta = _PRISMA_DEFAULT_TX_TIMEOUT) -> "TransactionManager":
|
||||
"""Open an interactive transaction on the writer.
|
||||
|
||||
Callers go through this instead of reaching into ``self.db`` so writer
|
||||
selection and read-replica routing stay encapsulated in the wrapper.
|
||||
"""
|
||||
return cast("TransactionManager", self.db.tx()) # cast-ok: wrappers delegate tx via __getattr__ (untyped)
|
||||
return cast("TransactionManager", self.db.tx(timeout=timeout)) # cast-ok: untyped __getattr__ delegate
|
||||
|
||||
def get_request_status(self, payload: dict | SpendLogsPayload) -> Literal["success", "failure"]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ from litellm.proxy._types import (
|
|||
UpdateUserRequest,
|
||||
UpdateUserRequestNoUserIDorEmail,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse
|
||||
|
||||
MAX_BULK_DELETE_USERS: Final = 500
|
||||
|
||||
MAX_BULK_NEW_USERS: Final = 500
|
||||
|
||||
|
|
@ -88,6 +91,28 @@ class BulkUpdateUserResponse(BaseModel):
|
|||
failed_updates: int
|
||||
|
||||
|
||||
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 requested user, in request order. `teams_removed` lists the teams the user left."""
|
||||
|
||||
user_id: str
|
||||
user_email: str | None = None
|
||||
success: bool
|
||||
teams_removed: tuple[str, ...] = ()
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BulkDeleteUsersResponse(ResourceResponse[tuple[UserDeleteResult, ...]]):
|
||||
"""`{data: [...]}` with one `UserDeleteResult` per requested user, in request order."""
|
||||
|
||||
|
||||
class BulkNewUserItem(NewUserRequest):
|
||||
"""One row of `POST /management/v1/users/bulk`: the `/user/new` body, with keys opt-in and invite emails
|
||||
unsupported. Unknown fields are rejected, as on every `/management/v1` request body."""
|
||||
|
|
|
|||
|
|
@ -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`."""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import Any, Literal
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from litellm.proxy._types import (
|
||||
KeyManagementRoutes,
|
||||
|
|
@ -8,10 +8,14 @@ from litellm.proxy._types import (
|
|||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
Member,
|
||||
MemberDeleteRequest,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse
|
||||
|
||||
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 +122,39 @@ class BulkTeamMemberAddResponse(BaseModel):
|
|||
updated_team: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TeamMemberRef(MemberDeleteRequest):
|
||||
"""One member to remove, named by exactly one of `user_id` or `user_email`."""
|
||||
|
||||
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 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 requested member, in request order."""
|
||||
|
||||
user_id: str | None = None
|
||||
user_email: str | None = None
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BulkTeamMemberDeleteResponse(ResourceResponse[tuple[TeamMemberDeleteResult, ...]]):
|
||||
"""`{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order."""
|
||||
|
||||
|
||||
class TeamMemberInfoResponse(LiteLLM_TeamMembership):
|
||||
"""Response for GET /team/{team_id}/members/me — caller's own membership row."""
|
||||
|
||||
|
|
|
|||
210
tests/proxy_behavior/management/test_team_bulk_member_delete.py
Normal file
210
tests/proxy_behavior/management/test_team_bulk_member_delete.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import pytest
|
||||
from prisma import Json
|
||||
|
||||
from .actors import Actor
|
||||
from .conftest import create_scratch_team, create_scratch_user
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
_MATRIX = [
|
||||
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
|
||||
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
|
||||
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200),
|
||||
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
|
||||
("alpha/owner", Actor.OWNER, "alpha", 403),
|
||||
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
|
||||
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
|
||||
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
|
||||
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
|
||||
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
|
||||
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
|
||||
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
|
||||
("beta/internal_user", Actor.INTERNAL_USER, "beta", 403),
|
||||
("beta/owner", Actor.OWNER, "beta", 403),
|
||||
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403),
|
||||
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403),
|
||||
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
|
||||
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
|
||||
]
|
||||
|
||||
|
||||
async def _seed_target(prisma, world, shape: str, team_id: str, victim_ids: list) -> None:
|
||||
if shape == "alpha":
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
team_id,
|
||||
organization_id=world.org_a_id,
|
||||
admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id],
|
||||
member_user_ids=victim_ids,
|
||||
)
|
||||
elif shape == "beta":
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
team_id,
|
||||
organization_id=world.org_b_id,
|
||||
member_user_ids=victim_ids,
|
||||
)
|
||||
else: # pragma: no cover - guard
|
||||
pytest.fail(f"unknown shape={shape}")
|
||||
|
||||
|
||||
def _member_ids(row) -> list:
|
||||
return [m["user_id"] for m in (row.members_with_roles or [])]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"actor,shape,expected_status",
|
||||
[(a, sh, s) for (_id, a, sh, s) in _MATRIX],
|
||||
ids=[s[0] for s in _MATRIX],
|
||||
)
|
||||
async def test_team_bulk_member_delete_authz_matrix(
|
||||
actor: Actor,
|
||||
shape: str,
|
||||
expected_status: int,
|
||||
proxy_client,
|
||||
prisma,
|
||||
scratch,
|
||||
world,
|
||||
):
|
||||
victims = [scratch.tag("v1"), scratch.tag("v2")]
|
||||
keep = scratch.tag("keep")
|
||||
await _seed_target(prisma, world, shape, scratch.prefix, victims + [keep])
|
||||
caller = world.keys[actor]
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
|
||||
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
||||
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()["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"
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_reports_each_row_in_order(proxy_client, prisma, scratch, world):
|
||||
victim = scratch.tag("victim")
|
||||
keep = scratch.tag("keep")
|
||||
stranger = scratch.tag("stranger")
|
||||
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim, keep])
|
||||
|
||||
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={"members": [{"user_id": stranger}, {"user_id": victim}]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert set(body) == {"data"}
|
||||
assert [(r["user_id"], r["success"]) for r in body["data"]] == [
|
||||
(stranger, False),
|
||||
(victim, True),
|
||||
]
|
||||
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]
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_by_id_removes_a_legacy_email_only_roster_entry(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
email = f"{scratch.prefix}@example.com"
|
||||
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim", user_email=email)
|
||||
keep = scratch.tag("keep")
|
||||
await prisma.db.litellm_teamtable.create(
|
||||
data={
|
||||
"team_id": scratch.prefix,
|
||||
"team_alias": scratch.prefix,
|
||||
"organization_id": world.org_a_id,
|
||||
"members_with_roles": Json([{"user_email": email, "role": "user"}, {"user_id": keep, "role": "user"}]),
|
||||
}
|
||||
)
|
||||
await prisma.db.litellm_usertable.update(where={"user_id": victim}, data={"teams": [scratch.prefix]})
|
||||
|
||||
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={"members": [{"user_id": victim}]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [(victim, True)]
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert row is not None and [(m["user_id"], m.get("user_email")) for m in row.members_with_roles] == [(keep, None)]
|
||||
user = await prisma.db.litellm_usertable.find_unique(where={"user_id": victim})
|
||||
assert user is not None and user.teams == []
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_row_naming_both_identifiers_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={"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"
|
||||
137
tests/proxy_behavior/management/test_users_bulk_delete.py
Normal file
137
tests/proxy_behavior/management/test_users_bulk_delete.py
Normal 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
|
||||
|
|
@ -0,0 +1,685 @@
|
|||
import copy
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
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, 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)
|
||||
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 find_many(self, where: Mapping[str, object]) -> list[LiteLLM_TeamTable]:
|
||||
return [t for t in self.rows.values() if _matches({"team_id": t.team_id}, where)]
|
||||
|
||||
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.litellm_invitationlink = db.litellm_invitationlink
|
||||
self.litellm_organizationmembership = db.litellm_organizationmembership
|
||||
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 team_id in self.locks, "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(),
|
||||
fail_commit: bool = False,
|
||||
) -> None:
|
||||
self.db = _Db(users, teams, memberships, tokens, invitations, org_memberships)
|
||||
self._on_lock = on_lock
|
||||
self._fail_locks = fail_locks
|
||||
self._fail_commit = fail_commit
|
||||
self.locks: list[str] = []
|
||||
self.roster_reads: list[str] = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def tx(self, *, timeout: object = None):
|
||||
snapshot = copy.deepcopy(self.db)
|
||||
tx = _Tx(self.db, self._on_lock, self._fail_locks)
|
||||
try:
|
||||
yield tx
|
||||
if self._fail_commit:
|
||||
raise RuntimeError("connection reset")
|
||||
except BaseException:
|
||||
self.db.__dict__.update(snapshot.__dict__)
|
||||
raise
|
||||
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]
|
||||
|
||||
|
||||
def _cache_with(*hashed_tokens: str) -> UserApiKeyCache:
|
||||
cache = UserApiKeyCache()
|
||||
for token in hashed_tokens:
|
||||
cache.set_cache(key=token, value=UserAPIKeyAuth(token=token))
|
||||
return cache
|
||||
|
||||
|
||||
async def _delete(
|
||||
prisma: _FakePrisma,
|
||||
user_ids: Sequence[str],
|
||||
caller: UserAPIKeyAuth = ADMIN,
|
||||
cache: UserApiKeyCache | None = None,
|
||||
):
|
||||
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
|
||||
user_api_key_cache=cache or UserApiKeyCache(),
|
||||
proxy_logging_obj=None,
|
||||
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: UserAPIKeyAuth = ADMIN,
|
||||
cache: UserApiKeyCache | None = None,
|
||||
):
|
||||
return await bulk_remove_team_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(),
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
|
||||
@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"}],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["u1", "u2"])
|
||||
|
||||
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",)),
|
||||
]
|
||||
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 prisma.locks == ["t1", "t2"] and prisma.roster_reads == ["t1", "t2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_leaves_teammates_who_share_the_deleted_users_email_alone():
|
||||
twin = _UserRow(user_id="twin", user_email="u1@example.com", teams=["t1"])
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id="u1", user_email="u1@example.com", role="user"),
|
||||
Member(user_id="twin", user_email="u1@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), twin],
|
||||
teams=[team],
|
||||
memberships=[("t1", "u1"), ("t1", "twin")],
|
||||
tokens=[
|
||||
{"token": "k1", "user_id": "u1", "team_id": "t1"},
|
||||
{"token": "k-twin", "user_id": "twin", "team_id": "t1"},
|
||||
],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
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"
|
||||
]
|
||||
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "twin"}]
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k-twin"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_removes_the_deleted_users_email_only_roster_entry():
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id=None, user_email="u1@example.com", role="user"),
|
||||
Member(user_id="keep", user_email="keep@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("keep", "t1")], teams=[team])
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
@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")],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
assert 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)
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
assert 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")])
|
||||
|
||||
results = await _delete(prisma, ["u1", "ghost", "u1"])
|
||||
|
||||
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"),
|
||||
]
|
||||
assert prisma.db.litellm_usertable.rows == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_fails():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "a-good", "z-bad"), _user("u2", "a-good")],
|
||||
teams=[_team("a-good", "u1", "u2"), _team("z-bad", "u1")],
|
||||
tokens=[{"token": "k1", "user_id": "u1", "team_id": "a-good"}],
|
||||
fail_locks=frozenset({"z-bad"}),
|
||||
)
|
||||
cache = _cache_with("k1")
|
||||
|
||||
results = await _delete(prisma, ["u1", "u2"], cache=cache)
|
||||
|
||||
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"),
|
||||
]
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2"}
|
||||
assert _roster(prisma, "a-good") == ["u1", "u2"] and _roster(prisma, "z-bad") == ["u1"]
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k1"]
|
||||
assert cache.get_cache(key="k1") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")])
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rolls_back_every_user_row_and_reports_it_per_row_when_the_delete_fails():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("u2")],
|
||||
teams=[_team("t1", "u1")],
|
||||
tokens=[{"token": "k1", "user_id": "u1"}],
|
||||
fail_commit=True,
|
||||
)
|
||||
cache = _cache_with("k1")
|
||||
|
||||
results = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache)
|
||||
|
||||
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"),
|
||||
]
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2"}
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k1"]
|
||||
assert prisma.db.litellm_deletedverificationtoken.rows == []
|
||||
assert cache.get_cache(key="k1") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_evicts_deleted_keys_and_users_from_the_auth_cache():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("keep", "t1")],
|
||||
teams=[_team("t1", "u1", "keep")],
|
||||
tokens=[
|
||||
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
|
||||
{"token": "personal-key", "user_id": "u1"},
|
||||
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
|
||||
],
|
||||
)
|
||||
cache = _cache_with("team-key", "personal-key", "keep-key")
|
||||
cache.set_cache(key="u1", value={"user_id": "u1"})
|
||||
|
||||
await _delete(prisma, ["u1"], cache=cache)
|
||||
|
||||
assert cache.get_cache(key="team-key") is None and cache.get_cache(key="personal-key") is None
|
||||
assert cache.get_cache(key="u1") is None
|
||||
assert cache.get_cache(key="keep-key") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db():
|
||||
prisma = _FakePrisma(users=[_user("u1")])
|
||||
|
||||
with pytest.raises(ManagementProblem) as exc:
|
||||
await _delete(prisma, ["u1"], caller=INTERNAL)
|
||||
|
||||
assert exc.value.problem.status == 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"},
|
||||
],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN)
|
||||
|
||||
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"),
|
||||
("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"},
|
||||
],
|
||||
)
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}])
|
||||
|
||||
assert [(r.user_id, r.user_email, r.success) for r in 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")])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}])
|
||||
|
||||
assert [(r.success, r.error) for r in results] == [
|
||||
(False, "User not found in team"),
|
||||
(False, "User not found in team"),
|
||||
]
|
||||
assert prisma.db.litellm_teamtable.update_calls == 0
|
||||
assert _roster(prisma, "t1") == ["u1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_members_alone():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("elsewhere")],
|
||||
teams=[_team("t1", "u1")],
|
||||
memberships=[("t1", "elsewhere")],
|
||||
tokens=[{"token": "orphan-key", "user_id": "elsewhere", "team_id": "t1"}],
|
||||
)
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}])
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")])
|
||||
|
||||
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 results] == [
|
||||
(True, None),
|
||||
(False, "Duplicate member in request"),
|
||||
(True, None),
|
||||
(True, None),
|
||||
]
|
||||
assert _roster(prisma, "t1") == ["keep"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_evicts_the_removed_team_keys_from_the_auth_cache():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("keep", "t1")],
|
||||
teams=[_team("t1", "u1", "keep")],
|
||||
tokens=[
|
||||
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
|
||||
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
|
||||
],
|
||||
)
|
||||
cache = _cache_with("team-key", "keep-key")
|
||||
|
||||
await _remove(prisma, "t1", [{"user_id": "u1"}], cache=cache)
|
||||
|
||||
assert cache.get_cache(key="team-key") is None
|
||||
assert cache.get_cache(key="keep-key") is not None
|
||||
|
||||
|
||||
@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")])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "stale"}])
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_by_id_removes_the_members_email_only_roster_entry():
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id=None, user_email="u1@example.com", role="user"),
|
||||
Member(user_id="twin", user_email="u1@example.com", role="user"),
|
||||
Member(user_id="keep", user_email="keep@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
twin = _UserRow(user_id="twin", user_email="u1@example.com", teams=["t1"])
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1"), twin, _user("keep", "t1")], teams=[team])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "u1"}])
|
||||
|
||||
assert [(r.success, r.error) for r in results] == [(True, None)]
|
||||
assert _roster(prisma, "t1") == ["twin", "keep"]
|
||||
users = prisma.db.litellm_usertable.rows
|
||||
assert users["u1"].teams == [] and users["twin"].teams == ["t1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_by_id_of_a_non_member_leaves_a_same_email_users_roster_entry():
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id=None, user_email="shared@example.com", role="user"),
|
||||
Member(user_id="keep", user_email="keep@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
outsider = _UserRow(user_id="outsider", user_email="shared@example.com", teams=[])
|
||||
member = _UserRow(user_id="member", user_email="shared@example.com", teams=["t1"])
|
||||
prisma = _FakePrisma(users=[outsider, member, _user("keep", "t1")], teams=[team])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "outsider"}])
|
||||
|
||||
assert [(r.success, r.error) for r in results] == [(False, "User not found in team")]
|
||||
assert _roster(prisma, "t1") == [None, "keep"]
|
||||
assert prisma.db.litellm_usertable.rows["member"].teams == ["t1"]
|
||||
|
||||
|
||||
@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(ManagementProblem) as missing:
|
||||
await _remove(prisma, "nope", [{"user_id": "u1"}])
|
||||
with pytest.raises(ManagementProblem) as forbidden:
|
||||
await _remove(prisma, "t1", [{"user_id": "u1"}], caller=INTERNAL)
|
||||
|
||||
assert missing.value.problem.status == 404
|
||||
assert forbidden.value.problem.status == 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])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead"))
|
||||
|
||||
assert 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(members=())
|
||||
with pytest.raises(ValidationError):
|
||||
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.model_validate({"members": [{"user_id": "u1", "user_email": "other@example.com"}]})
|
||||
with pytest.raises(ValidationError):
|
||||
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})
|
||||
215
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
215
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -8499,6 +8499,39 @@ 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -8547,6 +8580,38 @@ export interface paths {
|
|||
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;
|
||||
|
|
@ -24580,6 +24645,22 @@ export interface components {
|
|||
/** Budgets */
|
||||
budgets: string[];
|
||||
};
|
||||
/**
|
||||
* BulkDeleteUserRequest
|
||||
* @description Body of `POST /management/v1/users/bulk_delete`.
|
||||
*/
|
||||
BulkDeleteUserRequest: {
|
||||
/** User Ids */
|
||||
user_ids: string[];
|
||||
};
|
||||
/**
|
||||
* BulkDeleteUsersResponse
|
||||
* @description `{data: [...]}` with one `UserDeleteResult` per requested user, in request order.
|
||||
*/
|
||||
BulkDeleteUsersResponse: {
|
||||
/** Data */
|
||||
data: components["schemas"]["UserDeleteResult"][];
|
||||
};
|
||||
/**
|
||||
* BulkNewUserItem
|
||||
* @description One row of `POST /management/v1/users/bulk`: the `/user/new` body, with keys opt-in and invite emails
|
||||
|
|
@ -24767,6 +24848,22 @@ export interface components {
|
|||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/**
|
||||
* BulkTeamMemberDeleteRequest
|
||||
* @description Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`.
|
||||
*/
|
||||
BulkTeamMemberDeleteRequest: {
|
||||
/** Members */
|
||||
members: components["schemas"]["TeamMemberRef"][];
|
||||
};
|
||||
/**
|
||||
* BulkTeamMemberDeleteResponse
|
||||
* @description `{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order.
|
||||
*/
|
||||
BulkTeamMemberDeleteResponse: {
|
||||
/** Data */
|
||||
data: components["schemas"]["TeamMemberDeleteResult"][];
|
||||
};
|
||||
/**
|
||||
* BulkUpdateKeyRequest
|
||||
* @description Request for bulk key updates
|
||||
|
|
@ -35541,7 +35638,7 @@ export interface components {
|
|||
default_model?: string | null;
|
||||
/**
|
||||
* Deployment Affinity
|
||||
* @description When True and a session_id is resolvable on the request, pin the deployment chosen inside each routed model group and reuse it whenever the session returns to that group, without pinning which group the session routes to. Independent of session_affinity, which pins the model group instead (and always carries this deployment pin with it): with session_affinity off, every turn is still classified on its own merits while a session that escalates to a stronger tier and comes back still lands on the deployment it used before, which is what keeps a provider prompt cache warm. Pins are held per model group, so switching tiers does not disturb the pin left behind in the previous group. On by default because re-shuffling a conversation across deployments of the same model discards that cache for no benefit; set False to keep every turn load-balanced across the group, which is what a deployment set with tight per-deployment rate limits wants. Inert when no session_id is resolvable, since there is nothing to key a pin on, and suppressed when plugins are configured, for the same reason session_affinity is.
|
||||
* @description When True and a client session_id is resolvable, reuse the session's chosen model for each classified tier and its deployment within each model group. With session_affinity off, every turn is still classified: moving to another tier leaves the previous tier's model pin intact for a later return. Pins yield to current candidate, context, modality, and availability constraints. Adaptive selection chooses the initial model from its eligible pool, then reuses that choice per tier. This reduces avoidable provider prompt-cache misses; it does not guarantee cache hits. Set False to select models and load-balance deployments on every turn, unless session_affinity or user_turn classification requires a pin. Inert without a client session_id and suppressed when plugins are configured.
|
||||
* @default true
|
||||
*/
|
||||
deployment_affinity: boolean;
|
||||
|
|
@ -35685,7 +35782,7 @@ export interface components {
|
|||
session_affinity: boolean;
|
||||
/**
|
||||
* Session Affinity Ttl Seconds
|
||||
* @description TTL for the session affinity pin; refreshed on every cache hit. Bounds both the session_affinity model pin and the deployment_affinity deployment pin, so it measures idle time for the session's routing decisions rather than total session length
|
||||
* @description TTL for the session affinity pin; refreshed on every cache hit. Bounds both the session_affinity model pin and the deployment_affinity per-tier model and deployment pins, so it measures idle time for the session's routing decisions rather than total session length
|
||||
* @default 3600
|
||||
*/
|
||||
session_affinity_ttl_seconds: number;
|
||||
|
|
@ -37506,6 +37603,20 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* TeamMemberDeleteResult
|
||||
* @description Outcome for one requested member, in request order.
|
||||
*/
|
||||
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.
|
||||
|
|
@ -37536,6 +37647,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: {
|
||||
/**
|
||||
|
|
@ -39723,6 +39844,25 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* UserDeleteResult
|
||||
* @description Outcome for one requested user, in request order. `teams_removed` lists the teams the user left.
|
||||
*/
|
||||
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.
|
||||
|
|
@ -51534,6 +51674,41 @@ 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_create_users_route_management_v1_users_bulk_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -51567,6 +51742,42 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue