From ff5b59b17336163ac251dc89700b1af406982947 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 01:17:19 +0000 Subject: [PATCH 01/25] feat(proxy): add POST /user/bulk_new for batched user and team membership creation Creates up to 500 internal users in one request with set-based validation, a single create_many for user rows, and one locked write per referenced team. Rows fail independently, keys are opt-in per row via auto_create_key, and send_invite_email is rejected for the batch. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 1 + litellm/proxy/auth/route_checks.py | 2 + .../internal_user_endpoints.py | 68 ++ .../key_management_endpoints.py | 69 +- .../management_helpers/bulk_user_creation.py | 820 ++++++++++++++++++ .../internal_user_endpoints.py | 40 +- .../test_bulk_user_creation.py | 347 ++++++++ 7 files changed, 1321 insertions(+), 26 deletions(-) create mode 100644 litellm/proxy/management_helpers/bulk_user_creation.py create mode 100644 tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b315a2beac9..ed182460644 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -656,6 +656,7 @@ class LiteLLMRoutes(enum.Enum): [ # user "/user/new", + "/user/bulk_new", "/user/update", "/user/bulk_update", "/user/delete", diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 953e3cf3e88..051ab13c058 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -24,6 +24,7 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset( [ # user "/user/new", + "/user/bulk_new", "/user/delete", "/user/bulk_update", # team @@ -755,6 +756,7 @@ class RouteChecks: _ADMIN_VIEWER_BLOCKED_WRITE_ROUTES = frozenset( [ "/user/new", + "/user/bulk_new", "/user/delete", "/user/bulk_update", "/team/new", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e3efda507f6..2624d7e373a 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -5,6 +5,7 @@ Internal User Management Endpoints These are members of a Team on LiteLLM /user/new +/user/bulk_new /user/update /user/bulk_update /user/delete @@ -77,6 +78,8 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkNewUserRequest, + BulkNewUserResponse, BulkUpdateUserRequest, BulkUpdateUserResponse, UserListResponse, @@ -637,6 +640,71 @@ async def new_user( raise handle_exception_on_proxy(e) +@router.post( + "/user/bulk_new", + tags=["Internal User management"], + dependencies=[Depends(user_api_key_auth)], + response_model=BulkNewUserResponse, +) +@management_endpoint_wrapper +async def bulk_new_user( + data: BulkNewUserRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection +) -> BulkNewUserResponse: + """ + Create up to 500 internal users in one request, optionally adding each one to teams. + + Every entry in `users` takes the same fields as `/user/new`, with two differences: `auto_create_key` + defaults to `false` (opt in per user to also get a virtual key back) and `send_invite_email` is not + supported. Rows are validated together (duplicate ids or emails, unknown teams, roles the caller may not + grant), inserted in one statement, and each referenced team is written once for all of its new members. + + Rows fail independently: a bad row is reported in `results` with `success: false` and an `error`, and the + other rows still get created. A user that was created but could not be added to one of its teams is + reported with `success: true`, `teams` listing where they did land, and `error` naming the failed team. + The whole request is rejected with 403 only if creating the valid rows would exceed the license seat limit. + + Usage Example + + ```shell + curl -X POST "http://localhost:4000/user/bulk_new" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk-1234" \\ + -d '{ + "users": [ + {"user_email": "a@example.com", "user_role": "internal_user", "teams": ["team-1"]}, + {"user_email": "b@example.com", "user_role": "internal_user", "auto_create_key": true} + ] + }' + ``` + + Returns `results` (one entry per input row, in order, with `user_id`, `user_email`, `success`, `teams`, + `key`, `error`), `total_requested`, `successful_creations` and `failed_creations`. + """ + from litellm.proxy.management_helpers.bulk_user_creation import bulk_create_users + from litellm.proxy.proxy_server import ( + _license_check, # pyright: ignore[reportPrivateUsage] # same proxy license singleton /user/new reads + litellm_proxy_admin_name, + prisma_client, + user_api_key_cache, + ) + + if prisma_client is None: + raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) + try: + return await bulk_create_users( + users=data.users, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + license_check=_license_check, + litellm_proxy_admin_name=litellm_proxy_admin_name, + user_api_key_cache=user_api_key_cache, + ) + except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract + verbose_proxy_logger.exception("/user/bulk_new: Exception occured - %s", e) + raise handle_exception_on_proxy(e) + + @router.get( "/user/available_roles", tags=["Internal User management"], diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 95ccb7bbe0b..11dd7ce1fa5 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -4082,6 +4082,40 @@ def _check_model_access_group(models: list[str] | None, llm_router: Router | Non return True +_NO_METADATA: Final[Mapping[str, object]] = MappingProxyType({}) + + +def metadata_json_with_limits( + metadata: Mapping[str, object] | None, + *, + model_rpm_limit: Mapping[str, object] | None, + model_tpm_limit: Mapping[str, object] | None, + mcp_rpm_limit: Mapping[str, int] | None, + tag_rpm_limit: Mapping[str, int] | None, + guardrails: Sequence[str] | None, + policies: Sequence[str] | None, + prompts: Sequence[str] | None, +) -> str: + """Serialize the stored metadata blob with the per-model, MCP, tag, guardrail, policy and prompt settings folded in.""" + limits: Final = tuple( + (name, value) + for name, value in ( + ("model_rpm_limit", model_rpm_limit), + ("model_tpm_limit", model_tpm_limit), + ("mcp_rpm_limit", mcp_rpm_limit), + ("tag_rpm_limit", tag_rpm_limit), + ("guardrails", guardrails), + ("policies", policies), + ("prompts", prompts), + ) + if value is not None + ) + if metadata is None and not limits: + return json.dumps(None) + merged: Final = {**(metadata or _NO_METADATA), **dict(limits)} # mutable-ok: encrypt_callback_vars takes a dict + return json.dumps(encrypt_callback_vars(merged)) + + async def generate_key_helper_fn( request_type: Literal["user", "key"], # identifies if this request is from /user/new or /key/generate duration: str | None = None, @@ -4184,31 +4218,16 @@ async def generate_key_helper_fn( permissions_json: Final = json.dumps(permissions) router_settings_json: Final = safe_dumps(router_settings) if router_settings is not None else safe_dumps({}) - # Add model_rpm_limit and model_tpm_limit to metadata - if model_rpm_limit is not None: - metadata = metadata or {} - metadata["model_rpm_limit"] = model_rpm_limit - if model_tpm_limit is not None: - metadata = metadata or {} - metadata["model_tpm_limit"] = model_tpm_limit - if mcp_rpm_limit is not None: - metadata = metadata or {} - metadata["mcp_rpm_limit"] = mcp_rpm_limit - if tag_rpm_limit is not None: - metadata = metadata or {} - metadata["tag_rpm_limit"] = tag_rpm_limit - if guardrails is not None: - metadata = metadata or {} - metadata["guardrails"] = guardrails - if policies is not None: - metadata = metadata or {} - metadata["policies"] = policies - if prompts is not None: - metadata = metadata or {} - metadata["prompts"] = prompts - - metadata = encrypt_callback_vars(metadata) - metadata_json: Final = json.dumps(metadata) + metadata_json: Final = metadata_json_with_limits( + metadata, + model_rpm_limit=model_rpm_limit, + model_tpm_limit=model_tpm_limit, + mcp_rpm_limit=mcp_rpm_limit, + tag_rpm_limit=tag_rpm_limit, + guardrails=guardrails, + policies=policies, + prompts=prompts, + ) validate_model_max_budget(model_max_budget) model_max_budget_json: Final = json.dumps(model_max_budget) budget_fallbacks_json: Final = json.dumps(budget_fallbacks or {}) diff --git a/litellm/proxy/management_helpers/bulk_user_creation.py b/litellm/proxy/management_helpers/bulk_user_creation.py new file mode 100644 index 00000000000..301629e6c2a --- /dev/null +++ b/litellm/proxy/management_helpers/bulk_user_creation.py @@ -0,0 +1,820 @@ +"""Batched internal user creation behind `/user/bulk_new`. + +The batch is validated with set queries, user rows land in one `create_many`, and every +referenced team is written once under its advisory lock instead of once per user. +""" + +import asyncio +import json +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, Literal, TypeAlias, TypeVar + +from fastapi import HTTPException, Request +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError +from typing_extensions import ReadOnly, TypedDict + +from litellm._logging import verbose_proxy_logger +from litellm._uuid import uuid +from litellm.integrations.prometheus import PrometheusLogger +from litellm.proxy._types import ( + LiteLLM_TeamTable, + LitellmUserRoles, + Member, + NewUserRequestTeam, + OrganizationMemberAddRequest, + OrgMember, + UserAPIKeyAuth, +) +from litellm.proxy.auth.auth_checks import invalidate_team_member_spend_state +from litellm.proxy.auth.litellm_license import LicenseCheck +from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time +from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks +from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same team-admin check /user/new uses + _is_user_team_admin, # pyright: ignore[reportPrivateUsage] # same team-admin check /user/new uses + validate_budget_duration, +) +from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _update_internal_new_user_params, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # /user/new defaults; result validated below + check_if_default_team_set, +) +from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_permissions_caller_permission, # pyright: ignore[reportPrivateUsage] # same permission check /user/new uses + generate_key_helper_fn, # pyright: ignore[reportUnknownVariableType] # legacy untyped helper; result validated by _KEY_RESPONSE + metadata_json_with_limits, +) +from litellm.proxy.management_endpoints.organization_endpoints import organization_member_add +from litellm.proxy.management_helpers.access_group_team_sync import TEAM_ADVISORY_LOCK_SQL +from litellm.proxy.management_helpers.object_permission_utils import ( + _set_object_permission, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # shared with /user/new; result validated below +) +from litellm.proxy.management_helpers.utils import ( + _resolve_member_budget_id, # pyright: ignore[reportPrivateUsage] # shared with /team/member_add +) +from litellm.proxy.utils import PrismaClient +from litellm.repositories.prisma_protocols import TableActions +from litellm.repositories.team_repository import TeamRepository +from litellm.repositories.user_repository import UserRepository +from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkNewUserItem, + BulkNewUserResponse, + UserCreateResult, +) + +if TYPE_CHECKING: + from prisma import Prisma + from prisma import models as prisma_models + + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + +BULK_NEW_USER_CONCURRENCY: Final = 10 + +TeamRole: TypeAlias = Literal["user", "admin"] +KeyGenerator: TypeAlias = Callable[..., Awaitable[object]] +_T: Final = TypeVar("_T") + + +@dataclass(frozen=True, slots=True) +class _RowFailure: + index: int + user_id: str | None + user_email: str | None + error: str + + +@dataclass(frozen=True, slots=True) +class _PendingUser: + index: int + request: BulkNewUserItem + user_id: str + teams: tuple[NewUserRequestTeam, ...] + + +class _UserRow(BaseModel): + """The `/user/new` body after defaults and object permission were applied.""" + + model_config = ConfigDict(extra="ignore") + + user_id: str + user_email: str | None = None + user_alias: str | None = None + user_role: str | None = None + team_id: str | None = None + max_budget: float | None = None + spend: float | None = 0.0 + models: tuple[str, ...] | None = None + metadata: Mapping[str, object] | None = None + max_parallel_requests: int | None = None + tpm_limit: int | None = None + rpm_limit: int | None = None + budget_duration: str | None = None + allowed_cache_controls: tuple[str, ...] | None = None + sso_user_id: str | None = None + object_permission_id: str | None = None + model_max_budget: Mapping[str, object] | None = None + model_rpm_limit: Mapping[str, object] | None = None + model_tpm_limit: Mapping[str, object] | None = None + mcp_rpm_limit: Mapping[str, int] | None = None + tag_rpm_limit: Mapping[str, int] | None = None + guardrails: tuple[str, ...] | None = None + policies: tuple[str, ...] | None = None + prompts: tuple[str, ...] | None = None + duration: str | None = None + key_alias: str | None = None + organizations: tuple[str, ...] | None = None + + +_USER_ROW: Final = TypeAdapter(_UserRow) + + +@dataclass(frozen=True, slots=True) +class _PreparedUser: + pending: _PendingUser + row: _UserRow + + +@dataclass(frozen=True, slots=True) +class _TeamAssignment: + user_id: str + user_email: str | None + role: TeamRole + max_budget_in_team: float | None + + +@dataclass(frozen=True, slots=True) +class _TeamWrite: + """Outcome of one locked roster write. `failed` maps user ids to the reason they were not added.""" + + team_id: str + after: tuple[Member, ...] + added: frozenset[str] + failed: Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class _CreatedUser: + prepared: _PreparedUser + teams: tuple[str, ...] + key: str | None + errors: tuple[str, ...] + + +_ERROR_DETAIL: Final = TypeAdapter(Mapping[str, object]) +_JSON_OBJECT: Final = TypeAdapter(dict[str, object]) + + +class _KeyResponse(BaseModel): + token: str + + +_KEY_RESPONSE: Final = TypeAdapter(_KeyResponse) + + +def _error_message(exc: BaseException) -> str: + if not isinstance(exc, HTTPException): + return str(exc) + try: + detail: Final = _ERROR_DETAIL.validate_python(exc.detail) + except ValidationError: + return str(exc.detail) + return str(detail.get("error", detail)) + + +def _requested_teams(item: BulkNewUserItem) -> tuple[NewUserRequestTeam, ...]: + if item.team_id is not None: + return (NewUserRequestTeam(team_id=item.team_id),) + teams: Final = item.teams if item.teams is not None else check_if_default_team_set() + if teams is None: + return () + return tuple(team if isinstance(team, NewUserRequestTeam) else NewUserRequestTeam(team_id=team) for team in teams) + + +def _row_error(item: BulkNewUserItem, user_api_key_dict: UserAPIKeyAuth) -> str | None: + if ( + item.user_role in (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN + ): + return ( + "Only proxy admins can create administrative users (proxy_admin, proxy_admin_viewer). " + f"Attempted to create user with role: {item.user_role}. Your role: {user_api_key_dict.user_role}" + ) + try: + validate_budget_duration(item.budget_duration) + _check_permissions_caller_permission(data=item, user_api_key_dict=user_api_key_dict) + except Exception as exc: # noqa: BLE001 # any validation failure is reported on this row only + return _error_message(exc) + return None + + +def _normalized_email(email: str | None) -> str | None: + return email.strip().lower() if email else None + + +def _partition_rows( + users: Sequence[BulkNewUserItem], user_api_key_dict: UserAPIKeyAuth +) -> tuple[tuple[_PendingUser, ...], tuple[_RowFailure, ...]]: + """Assign ids, run the per-row checks and fail later rows that repeat an earlier row's id or email.""" + user_ids: Final = tuple(item.user_id or str(uuid.uuid4()) for item in users) + first_index_by_id: Final = MappingProxyType( + {user_id: index for index, user_id in reversed(tuple(enumerate(user_ids)))} + ) + first_index_by_email: Final = MappingProxyType( + { + email: index + for index, email in reversed(tuple(enumerate(_normalized_email(item.user_email) for item in users))) + if email is not None + } + ) + + def classify(index: int, item: BulkNewUserItem) -> _PendingUser | _RowFailure: + user_id: Final = user_ids[index] + email: Final = _normalized_email(item.user_email) + if first_index_by_id[user_id] != index: + return _RowFailure(index, user_id, item.user_email, f"Duplicate user_id in request: {user_id}") + if email is not None and first_index_by_email[email] != index: + return _RowFailure(index, user_id, item.user_email, f"Duplicate user_email in request: {item.user_email}") + error: Final = _row_error(item, user_api_key_dict) + if error is not None: + return _RowFailure(index, user_id, item.user_email, error) + return _PendingUser(index, item, user_id, _requested_teams(item)) + + outcomes: Final = tuple(classify(index, item) for index, item in enumerate(users)) + return ( + tuple(outcome for outcome in outcomes if isinstance(outcome, _PendingUser)), + tuple(outcome for outcome in outcomes if isinstance(outcome, _RowFailure)), + ) + + +def _user_table(prisma_client: PrismaClient) -> "TableActions[prisma_models.LiteLLM_UserTable]": + return UserRepository(prisma_client).table + + +async def _existing_user_conflicts( + prisma_client: PrismaClient, pending: Sequence[_PendingUser] +) -> tuple[frozenset[str], frozenset[str]]: + """Return the requested user ids and (lowercased) emails that already exist, using one query each.""" + user_ids: Final = sorted(user.user_id for user in pending) + emails: Final = sorted(frozenset(user.request.user_email for user in pending if user.request.user_email)) + if not user_ids: + return frozenset(), frozenset() + table: Final = _user_table(prisma_client) + id_filter: Final = {"user_id": {"in": user_ids}} # mutable-ok: Prisma query filters are dict-shaped + email_filter: Final = {"user_email": {"in": emails, "mode": "insensitive"}} # mutable-ok: Prisma filter + id_rows: Final = await table.find_many(where=id_filter) + email_rows: Final = await table.find_many(where=email_filter) if emails else () + return ( + frozenset(row.user_id for row in id_rows), + frozenset(lowered for row in email_rows if (lowered := _normalized_email(row.user_email)) is not None), + ) + + +async def _load_teams(prisma_client: PrismaClient, team_ids: frozenset[str]) -> Mapping[str, LiteLLM_TeamTable]: + if not team_ids: + return MappingProxyType({}) + rows: Final = await TeamRepository(prisma_client).table.find_many( + where={"team_id": {"in": sorted(team_ids)}} # mutable-ok: Prisma query filters are dict-shaped + ) + return MappingProxyType({row.team_id: LiteLLM_TeamTable.model_validate(row.model_dump()) for row in rows}) + + +async def _team_permission_error(team: LiteLLM_TeamTable, user_api_key_dict: UserAPIKeyAuth) -> str | None: + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: + return None + if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team): + return None + if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team): + return None + return f"Call not allowed. User not proxy admin OR team admin. team_id={team.team_id}" + + +async def _unusable_teams( + prisma_client: PrismaClient, + pending: Sequence[_PendingUser], + user_api_key_dict: UserAPIKeyAuth, +) -> tuple[Mapping[str, LiteLLM_TeamTable], Mapping[str, str]]: + """Load every referenced team once and explain, per team id, why rows naming it cannot proceed.""" + team_ids: Final = frozenset(team.team_id for user in pending for team in user.teams) + teams: Final = await _load_teams(prisma_client, team_ids) + permission_errors: Final = await asyncio.gather( + *(_team_permission_error(team, user_api_key_dict) for team in teams.values()) + ) + missing: Final = tuple( + (team_id, f"Team id={team_id} does not exist") for team_id in team_ids if team_id not in teams + ) + denied: Final = tuple( + (team.team_id, error) + for team, error in zip(teams.values(), permission_errors, strict=True) + if error is not None + ) + return teams, MappingProxyType({team_id: error for team_id, error in (*missing, *denied)}) + + +def _db_failure( + user: _PendingUser, + existing_ids: frozenset[str], + existing_emails: frozenset[str], + team_errors: Mapping[str, str], +) -> _RowFailure | None: + email: Final = _normalized_email(user.request.user_email) + if user.user_id in existing_ids: + return _RowFailure(user.index, user.user_id, user.request.user_email, f"User id={user.user_id} already exists") + if email is not None and email in existing_emails: + return _RowFailure( + user.index, user.user_id, user.request.user_email, f"User email={user.request.user_email} already exists" + ) + errors: Final = tuple(team_errors[team.team_id] for team in user.teams if team.team_id in team_errors) + if errors: + return _RowFailure(user.index, user.user_id, user.request.user_email, "; ".join(errors)) + return None + + +async def _prepare_user(user: _PendingUser, prisma_client: PrismaClient) -> _PreparedUser | _RowFailure: + try: + dumped: Final = user.request.model_dump(exclude={"user_id"}) # mutable-ok: pydantic IncEx takes a set + data: Final = {**dumped, "user_id": user.user_id} # mutable-ok: /user/new defaults helper mutates in place + data_json: Final = _JSON_OBJECT.validate_python(_update_internal_new_user_params(data, user.request)) + with_permission: Final = _JSON_OBJECT.validate_python( + await _set_object_permission(data_json=data_json, prisma_client=prisma_client) # pyright: ignore[reportUnknownArgumentType] # validated by the adapter + ) + return _PreparedUser(user, _USER_ROW.validate_python(with_permission)) + except Exception as exc: # noqa: BLE001 # any preparation failure is reported on this row only + verbose_proxy_logger.warning("/user/bulk_new: could not prepare user %s - %s", user.user_id, exc) + return _RowFailure(user.index, user.user_id, user.request.user_email, _error_message(exc)) + + +class _UserCreateData(TypedDict): + """One `LiteLLM_UserTable` row as `create_many` takes it; JSON columns are pre-serialized.""" + + user_id: ReadOnly[str] + user_email: ReadOnly[str | None] + user_alias: ReadOnly[str | None] + user_role: ReadOnly[str | None] + team_id: ReadOnly[str | None] + max_budget: ReadOnly[float | None] + spend: ReadOnly[float] + models: ReadOnly[tuple[str, ...]] + metadata: ReadOnly[str] + max_parallel_requests: ReadOnly[int | None] + tpm_limit: ReadOnly[int | None] + rpm_limit: ReadOnly[int | None] + budget_duration: ReadOnly[str | None] + budget_reset_at: ReadOnly[datetime | None] + allowed_cache_controls: ReadOnly[tuple[str, ...]] + sso_user_id: ReadOnly[str | None] + object_permission_id: ReadOnly[str | None] + teams: ReadOnly[tuple[str, ...]] + model_max_budget: ReadOnly[str] + + +def _user_create_payload(prepared: _PreparedUser) -> _UserCreateData: + row: Final = prepared.row + metadata_json: Final = metadata_json_with_limits( + row.metadata, + model_rpm_limit=row.model_rpm_limit, + model_tpm_limit=row.model_tpm_limit, + mcp_rpm_limit=row.mcp_rpm_limit, + tag_rpm_limit=row.tag_rpm_limit, + guardrails=row.guardrails, + policies=row.policies, + prompts=row.prompts, + ) + payload: Final[_UserCreateData] = { + "user_id": row.user_id, + "user_email": row.user_email, + "user_alias": row.user_alias, + "user_role": row.user_role, + "team_id": row.team_id, + "max_budget": row.max_budget, + "spend": row.spend or 0.0, + "models": row.models or (), + "metadata": metadata_json, + "max_parallel_requests": row.max_parallel_requests, + "tpm_limit": row.tpm_limit, + "rpm_limit": row.rpm_limit, + "budget_duration": row.budget_duration, + "budget_reset_at": get_budget_reset_time(row.budget_duration) if row.budget_duration else None, + "allowed_cache_controls": row.allowed_cache_controls or (), + "sso_user_id": row.sso_user_id, + "object_permission_id": row.object_permission_id, + "teams": tuple(team.team_id for team in prepared.pending.teams), + "model_max_budget": json.dumps(row.model_max_budget) if row.model_max_budget else "{}", + } + return payload + + +async def _bounded(limit: int, awaitables: Sequence[Awaitable[_T]]) -> tuple[_T | BaseException, ...]: + semaphore: Final = asyncio.Semaphore(limit) + + async def run(awaitable: Awaitable[_T]) -> _T: + async with semaphore: + return await awaitable + + return tuple(await asyncio.gather(*(run(awaitable) for awaitable in awaitables), return_exceptions=True)) + + +async def _insert_users( + prisma_client: PrismaClient, prepared: Sequence[_PreparedUser] +) -> tuple[tuple[_PreparedUser, ...], tuple[_RowFailure, ...]]: + """Insert every row in one statement. If that fails, retry rows one at a time so the error lands on its row.""" + if not prepared: + return (), () + table: Final = _user_table(prisma_client) + payloads: Final = tuple(_user_create_payload(user) for user in prepared) + try: + await table.create_many(data=payloads) + return tuple(prepared), () + except Exception as exc: # noqa: BLE001 # fall back to per-row inserts so the failing row can be identified + verbose_proxy_logger.warning("/user/bulk_new: create_many failed, retrying rows individually - %s", exc) + outcomes: Final = await _bounded( + BULK_NEW_USER_CONCURRENCY, tuple(table.create(data=payload) for payload in payloads) + ) + return ( + tuple(user for user, outcome in zip(prepared, outcomes, strict=True) if not isinstance(outcome, BaseException)), + tuple( + _RowFailure(user.pending.index, user.pending.user_id, user.row.user_email, _error_message(outcome)) + for user, outcome in zip(prepared, outcomes, strict=True) + if isinstance(outcome, BaseException) + ), + ) + + +def _assignments_by_team(created: Sequence[_PreparedUser]) -> Mapping[str, tuple[_TeamAssignment, ...]]: + team_ids: Final = tuple(dict.fromkeys(team.team_id for user in created for team in user.pending.teams)) + return MappingProxyType( + { + team_id: tuple( + _TeamAssignment(user.pending.user_id, user.row.user_email, team.user_role, team.max_budget_in_team) + for user in created + for team in user.pending.teams + if team.team_id == team_id + ) + for team_id in team_ids + } + ) + + +class _MembershipData(TypedDict): + team_id: ReadOnly[str] + user_id: ReadOnly[str] + budget_id: ReadOnly[str | None] + + +class _RosterData(TypedDict): + members_with_roles: ReadOnly[str] + + +class _TeamsData(TypedDict): + teams: ReadOnly[tuple[str, ...]] + + +def _default_member_budget_id(team: LiteLLM_TeamTable) -> str | None: + metadata: Final = ( + _JSON_OBJECT.validate_python( + team.metadata # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_TeamTable.metadata is a bare dict; validated by the adapter + ) + if team.metadata # pyright: ignore[reportUnknownMemberType] # same bare dict + else None + ) + budget_id: Final = metadata.get("team_member_budget_id") if metadata is not None else None + return budget_id if isinstance(budget_id, str) else None + + +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 _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 + + +async def _write_team_roster( + prisma_client: PrismaClient, + team: LiteLLM_TeamTable, + members: Sequence[_TeamAssignment], + user_api_key_dict: UserAPIKeyAuth, + litellm_proxy_admin_name: str, +) -> _TeamWrite: + """Add every new member to one team under its advisory lock: one roster rewrite and one membership insert.""" + try: + async with prisma_client.tx() as tx: + await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team.team_id) + roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team.team_id) + if roster is None: + raise ValueError(f"Team id={team.team_id} does not exist") + already_present: Final = frozenset(member.user_id for member in roster if member.user_id) + new_members: Final = tuple(member for member in members if member.user_id not in already_present) + budget_ids: Final = tuple( + [ # mutable-ok: budgets are created one at a time on the transaction's single connection + await _resolve_member_budget_id( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + max_budget_in_team=member.max_budget_in_team, + allowed_models=team.default_team_member_models or None, + budget_duration=None, + default_team_budget_id=_default_member_budget_id(team), + tx=tx, # pyright: ignore[reportArgumentType] # MemberWriteTx lags the generated Prisma signatures, same as /team/member_add + ) + for member in new_members + ] + ) + await _membership_tx_db(tx).create_many( + data=tuple( + _MembershipData(team_id=team.team_id, user_id=member.user_id, budget_id=budget_id) + for member, budget_id in zip(new_members, budget_ids, strict=True) + ), + skip_duplicates=True, + ) + after: Final = ( + *roster, + *(Member(user_id=m.user_id, user_email=m.user_email, role=m.role) for m in new_members), + ) + await _team_tx_db(tx).update( + where={"team_id": team.team_id}, # mutable-ok: Prisma query filters are dict-shaped + data=_RosterData(members_with_roles=json.dumps(tuple(member.model_dump() for member in after))), + ) + return _TeamWrite( + team_id=team.team_id, + after=after, + added=frozenset(member.user_id for member in new_members), + failed=MappingProxyType({}), + ) + except Exception as exc: # noqa: BLE001 # the team write failure is reported on each affected row + verbose_proxy_logger.exception("/user/bulk_new: failed to add members to team %s - %s", team.team_id, exc) + message: Final = f"Failed to add user to team {team.team_id}: {_error_message(exc)}" + return _TeamWrite( + team_id=team.team_id, + after=(), + added=frozenset(), + failed=MappingProxyType({member.user_id: message for member in members}), + ) + + +async def _detach_failed_teams( + prisma_client: PrismaClient, created: Sequence[_PreparedUser], writes: Mapping[str, _TeamWrite] +) -> None: + """Users are inserted with `teams` already set; drop the teams whose roster write did not take them.""" + table: Final = _user_table(prisma_client) + updates: Final = tuple( + table.update( + where={"user_id": user.row.user_id}, # mutable-ok: Prisma query filters are dict-shaped + data=_TeamsData(teams=landed), + ) + for user in created + if (landed := _row_teams(user, writes)[0]) != tuple(team.team_id for team in user.pending.teams) + ) + for outcome in await _bounded(BULK_NEW_USER_CONCURRENCY, updates): + if isinstance(outcome, BaseException): + verbose_proxy_logger.warning("/user/bulk_new: could not detach failed teams from user - %s", outcome) + + +async def _publish_team_writes(writes: Sequence[_TeamWrite], user_api_key_cache: "UserApiKeyCache") -> None: + prometheus_logger: Final = PrometheusLogger.get_instance() + for write in writes: + if prometheus_logger is None or not write.added: + continue + try: + prometheus_logger.set_team_members_metric( + LiteLLM_TeamTable( + team_id=write.team_id, + members_with_roles=write.after, # pyright: ignore[reportArgumentType] # pydantic coerces the tuple into the declared list + ) + ) + except Exception as exc: # noqa: BLE001 # metrics are best-effort and must not fail the request + verbose_proxy_logger.debug("Prometheus: failed to emit team members metric: %s", exc) + evictions: Final = await _bounded( + BULK_NEW_USER_CONCURRENCY, + tuple( + invalidate_team_member_spend_state( + user_id=user_id, team_id=write.team_id, user_api_key_cache=user_api_key_cache + ) + for write in writes + for user_id in write.added + ), + ) + for eviction in evictions: + if isinstance(eviction, BaseException): + verbose_proxy_logger.warning("/user/bulk_new: cache eviction failed - %s", eviction) + + +_KEY_FIELDS: Final = MappingProxyType( + { + name: True + for name in ( + "user_id", + "team_id", + "duration", + "key_alias", + "models", + "metadata", + "max_parallel_requests", + "tpm_limit", + "rpm_limit", + "allowed_cache_controls", + "model_max_budget", + "model_rpm_limit", + "model_tpm_limit", + "mcp_rpm_limit", + "tag_rpm_limit", + "guardrails", + "policies", + "prompts", + "object_permission_id", + ) + } +) + + +async def _generate_key(prepared: _PreparedUser, generate_key: KeyGenerator) -> str: + response: Final = _KEY_RESPONSE.validate_python( + await generate_key( + request_type="key", table_name="key", **prepared.row.model_dump(include=_KEY_FIELDS, exclude_none=True) + ) + ) + return response.token + + +async def _add_to_organizations( + prepared: _PreparedUser, organizations: Sequence[str], user_api_key_dict: UserAPIKeyAuth +) -> None: + for organization_id in organizations: + await organization_member_add( + data=OrganizationMemberAddRequest( + organization_id=organization_id, + member=OrgMember(user_id=prepared.row.user_id, role=LitellmUserRoles.INTERNAL_USER), + ), + http_request=Request(scope={"type": "http", "path": "/user/bulk_new"}), # mutable-ok: ASGI scopes are dicts + user_api_key_dict=user_api_key_dict, + ) + + +async def _run_per_user( + created: Sequence[_PreparedUser], + select: Callable[[_PreparedUser], bool], + action: Callable[[_PreparedUser], Awaitable[_T]], +) -> Mapping[str, _T | BaseException]: + chosen: Final = tuple(user for user in created if select(user)) + outcomes: Final = await _bounded(BULK_NEW_USER_CONCURRENCY, tuple(action(user) for user in chosen)) + return MappingProxyType({user.row.user_id: outcome for user, outcome in zip(chosen, outcomes, strict=True)}) + + +async def _write_audit_logs( + prisma_client: PrismaClient, + created: Sequence[_PreparedUser], + user_api_key_dict: UserAPIKeyAuth, + litellm_proxy_admin_name: str, +) -> None: + if not created: + return + created_ids: Final = sorted(user.row.user_id for user in created) + created_filter: Final = {"user_id": {"in": created_ids}} # mutable-ok: Prisma query filters are dict-shaped + rows: Final = await _user_table(prisma_client).find_many(where=created_filter) + outcomes: Final = await _bounded( + BULK_NEW_USER_CONCURRENCY, + tuple( + UserManagementEventHooks.create_internal_user_audit_log( + user_id=row.user_id, + action="created", + litellm_changed_by=user_api_key_dict.user_id, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + before_value=None, + after_value=row.model_dump_json(exclude_none=True), + ) + for row in rows + ), + ) + for outcome in outcomes: + if isinstance(outcome, BaseException): + verbose_proxy_logger.warning("Unable to create audit log for user on `/user/bulk_new` - %s", outcome) + + +def _row_teams(prepared: _PreparedUser, writes: Mapping[str, _TeamWrite]) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Split a user's requested teams into the ones they landed in and the errors for the ones they did not.""" + requested: Final = tuple(team.team_id for team in prepared.pending.teams) + return ( + tuple(team_id for team_id in requested if prepared.row.user_id in writes[team_id].added), + tuple( + writes[team_id].failed[prepared.row.user_id] + for team_id in requested + if prepared.row.user_id in writes[team_id].failed + ), + ) + + +def _to_result(created: _CreatedUser) -> UserCreateResult: + return UserCreateResult( + user_id=created.prepared.row.user_id, + user_email=created.prepared.row.user_email, + success=True, + teams=created.teams, + key=created.key, + error="; ".join(created.errors) if created.errors else None, + ) + + +def _failure_result(failure: _RowFailure) -> UserCreateResult: + return UserCreateResult(user_id=failure.user_id, user_email=failure.user_email, success=False, error=failure.error) + + +async def bulk_create_users( + users: Sequence[BulkNewUserItem], + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + license_check: LicenseCheck, + litellm_proxy_admin_name: str, + user_api_key_cache: "UserApiKeyCache", + generate_key: KeyGenerator = generate_key_helper_fn, +) -> BulkNewUserResponse: + """Create every valid row in `users`; rows that fail validation or a write are reported, not raised. + + Raises `HTTPException(403)` only when the whole batch would push the deployment over its license seat limit. + """ + pending, request_failures = _partition_rows(users, user_api_key_dict) + existing_ids, existing_emails = await _existing_user_conflicts(prisma_client, pending) + teams, team_errors = await _unusable_teams(prisma_client, pending, user_api_key_dict) + db_failures: Final = tuple( + failure + for user in pending + if (failure := _db_failure(user, existing_ids, existing_emails, team_errors)) is not None + ) + failed_indexes: Final = frozenset(failure.index for failure in db_failures) + creatable: Final = tuple(user for user in pending if user.index not in failed_indexes) + + billable_users: Final = await UserRepository(prisma_client).count_billable_users() + if creatable and license_check.is_over_limit(total_users=billable_users + len(creatable)): + raise HTTPException( + status_code=403, + detail="License is over limit. Please contact support@berri.ai to upgrade your license.", + ) + + prepared_outcomes: Final = tuple([await _prepare_user(user, prisma_client) for user in creatable]) + prepare_failures: Final = tuple(o for o in prepared_outcomes if isinstance(o, _RowFailure)) + created, insert_failures = await _insert_users( + prisma_client, tuple(o for o in prepared_outcomes if isinstance(o, _PreparedUser)) + ) + + team_writes: Final = MappingProxyType( + { + team_id: await _write_team_roster( + prisma_client, teams[team_id], members, user_api_key_dict, litellm_proxy_admin_name + ) + for team_id, members in _assignments_by_team(created).items() + } + ) + await _detach_failed_teams(prisma_client, created, team_writes) + await _publish_team_writes(tuple(team_writes.values()), user_api_key_cache) + + keys: Final = await _run_per_user( + created, lambda user: user.pending.request.auto_create_key, lambda user: _generate_key(user, generate_key) + ) + org_outcomes: Final = await _run_per_user( + created, + lambda user: bool(user.row.organizations), + lambda user: _add_to_organizations(user, user.row.organizations or (), user_api_key_dict), + ) + await _write_audit_logs(prisma_client, created, user_api_key_dict, litellm_proxy_admin_name) + + def finish(prepared: _PreparedUser) -> _CreatedUser: + landed, team_failures = _row_teams(prepared, team_writes) + key_outcome: Final = keys.get(prepared.row.user_id) + org_outcome: Final = org_outcomes.get(prepared.row.user_id) + return _CreatedUser( + prepared=prepared, + teams=landed, + key=key_outcome if isinstance(key_outcome, str) else None, + errors=( + *team_failures, + *( + (f"Failed to create key: {_error_message(key_outcome)}",) + if isinstance(key_outcome, BaseException) + else () + ), + *( + (f"Failed to add user to organizations: {_error_message(org_outcome)}",) + if isinstance(org_outcome, BaseException) + else () + ), + ), + ) + + failures: Final = MappingProxyType( + { + failure.index: _failure_result(failure) + for failure in (*request_failures, *db_failures, *prepare_failures, *insert_failures) + } + ) + successes_by_index: Final = MappingProxyType({user.pending.index: _to_result(finish(user)) for user in created}) + results: Final = tuple( + failures[index] if index in failures else successes_by_index[index] for index in range(len(users)) + ) + successes: Final = sum(1 for result in results if result.success) + return BulkNewUserResponse( + results=results, + total_requested=len(users), + successful_creations=successes, + failed_creations=len(users) - successes, + ) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index 6973f1d1f12..a7e5c6d2916 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,15 +1,18 @@ from collections.abc import Mapping from typing import Any, Final, Literal -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import ( LiteLLM_UserTableWithKeyCount, + NewUserRequest, UpdateUserRequest, UpdateUserRequestNoUserIDorEmail, ) +MAX_BULK_NEW_USERS: Final = 500 + class InsensitiveContains(TypedDict): contains: ReadOnly[str] @@ -83,3 +86,38 @@ class BulkUpdateUserResponse(BaseModel): total_requested: int successful_updates: int failed_updates: int + + +class BulkNewUserItem(NewUserRequest): + """One row of `/user/bulk_new`: the `/user/new` body, with keys opt-in and invite emails unsupported.""" + + auto_create_key: bool = False + + @field_validator("send_invite_email") + @classmethod + def reject_invite_email(cls, value: bool | None) -> bool | None: + if value: + raise ValueError("send_invite_email is not supported on /user/bulk_new; invite users separately") + return value + + +class BulkNewUserRequest(BaseModel): + users: tuple[BulkNewUserItem, ...] = Field(min_length=1, max_length=MAX_BULK_NEW_USERS) + + +class UserCreateResult(BaseModel): + """Outcome for one row of `/user/bulk_new`. `teams` lists the teams the user was actually added to.""" + + user_id: str | None = None + user_email: str | None = None + success: bool + teams: tuple[str, ...] | None = None + key: str | None = None + error: str | None = None + + +class BulkNewUserResponse(BaseModel): + results: tuple[UserCreateResult, ...] + total_requested: int + successful_creations: int + failed_creations: int diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py new file mode 100644 index 00000000000..c9f0a0e4ffe --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py @@ -0,0 +1,347 @@ +import json +from contextlib import asynccontextmanager +from typing import Final + +import pytest +from fastapi import HTTPException +from pydantic import BaseModel, ConfigDict, ValidationError + +from litellm.caching.caching import DualCache +from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth +from litellm.proxy.management_helpers.bulk_user_creation import bulk_create_users +from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkNewUserItem, + BulkNewUserRequest, +) + +ADMIN: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) +INTERNAL: Final = UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER) + + +class _UserRow(BaseModel): + model_config = ConfigDict(extra="allow") + + user_id: str + user_email: str | None = None + user_role: str | None = None + teams: list[str] = [] + max_budget: float | None = None + + +class _UserTable: + """Enough of the Prisma user table for the bulk path: set lookups, one create_many and per-row fallbacks.""" + + def __init__(self, fail_ids: frozenset[str] = frozenset()) -> None: + self.rows: dict[str, _UserRow] = {} + self.fail_ids = fail_ids + self.create_many_calls = 0 + + async def count(self, where: object = None) -> int: + return 0 if where is not None else len(self.rows) + + async def find_many(self, where: dict[str, dict[str, object]]) -> list[_UserRow]: + if "user_id" in where: + wanted = where["user_id"]["in"] + return [row for row in self.rows.values() if row.user_id in wanted] + wanted_emails = {str(e).lower() for e in where["user_email"]["in"]} + return [row for row in self.rows.values() if (row.user_email or "").lower() in wanted_emails] + + async def create(self, data: dict[str, object]) -> _UserRow: + row = _UserRow.model_validate(data) + if row.user_id in self.fail_ids or row.user_id in self.rows: + raise RuntimeError(f"insert failed for {row.user_id}") + self.rows[row.user_id] = row + return row + + async def create_many(self, data: list[dict[str, object]]) -> int: + self.create_many_calls += 1 + rows = [_UserRow.model_validate(d) for d in data] + if any(row.user_id in self.fail_ids for row in rows): + raise RuntimeError("batch insert failed") + for row in rows: + self.rows[row.user_id] = row + return len(rows) + + async def update(self, where: dict[str, str], data: dict[str, object]) -> _UserRow: + row = self.rows[where["user_id"]] + updated = _UserRow.model_validate({**row.model_dump(), **data}) + self.rows[row.user_id] = updated + return updated + + +class _TeamTable: + def __init__(self, teams: list[LiteLLM_TeamTable]) -> None: + self.rows = {team.team_id: team for team in teams} + self.update_calls = 0 + + async def find_many(self, where: dict[str, dict[str, list[str]]]) -> list[LiteLLM_TeamTable]: + return [self.rows[team_id] for team_id in where["team_id"]["in"] if team_id in self.rows] + + async def update(self, where: dict[str, str], data: dict[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 _MembershipTable: + def __init__(self) -> None: + self.rows: list[dict[str, object]] = [] + + async def create_many(self, data: list[dict[str, object]], skip_duplicates: bool = False) -> int: + self.rows.extend(data) + return len(data) + + +class _Tx: + def __init__(self, db: "_Db") -> None: + self.litellm_teamtable = db.litellm_teamtable + self.litellm_teammembership = db.litellm_teammembership + self.locks: list[str] = [] + + async def query_raw(self, sql: str, *args: object) -> list[dict[str, object]]: + if "pg_advisory_xact_lock" in sql: + self.locks.append(str(args[0])) + return [] + team = self.litellm_teamtable.rows.get(str(args[0])) + if team is None: + return [] + return [{"members_with_roles": [m.model_dump() for m in team.members_with_roles]}] + + +class _Db: + def __init__(self, teams: list[LiteLLM_TeamTable], fail_ids: frozenset[str] = frozenset()) -> None: + self.litellm_usertable = _UserTable(fail_ids) + self.litellm_teamtable = _TeamTable(teams) + self.litellm_teammembership = _MembershipTable() + + +class _FakePrisma: + def __init__(self, teams: list[LiteLLM_TeamTable] | None = None, fail_ids: frozenset[str] = frozenset()) -> None: + self.db = _Db(teams or [], fail_ids) + self.tx_count = 0 + self.locks: list[str] = [] + + def jsonify_object(self, data: dict[str, object]) -> dict[str, object]: + return data + + @asynccontextmanager + async def tx(self): + self.tx_count += 1 + tx = _Tx(self.db) + yield tx + self.locks.extend(tx.locks) + + +class _License: + def __init__(self, max_users: int | None = None) -> None: + self.max_users = max_users + self.seen: list[int] = [] + + def is_over_limit(self, total_users: int) -> bool: + self.seen.append(total_users) + return self.max_users is not None and total_users > self.max_users + + +def _team(team_id: str, members: list[Member] | None = None) -> LiteLLM_TeamTable: + return LiteLLM_TeamTable(team_id=team_id, members_with_roles=members or []) + + +async def _no_keys(**kwargs: object) -> dict[str, object]: + raise AssertionError(f"key generation was not requested: {kwargs}") + + +async def _run(prisma, users, caller=ADMIN, license=None, generate_key=_no_keys): + return await bulk_create_users( + users=[BulkNewUserItem(**u) for u in users], + user_api_key_dict=caller, + prisma_client=prisma, + license_check=license or _License(), + litellm_proxy_admin_name="default_user_id", + user_api_key_cache=DualCache(), + generate_key=generate_key, + ) + + +@pytest.mark.asyncio +async def test_creates_users_and_team_membership_in_every_store(): + prisma = _FakePrisma(teams=[_team("t1", [Member(user_id="existing", role="admin")]), _team("t2")]) + response = await _run( + prisma, + [ + {"user_id": "u1", "user_email": "a@example.com", "teams": ["t1", "t2"], "max_budget": 50}, + {"user_id": "u2", "user_email": "b@example.com", "teams": ["t1"]}, + {"user_id": "u3", "user_email": "c@example.com"}, + ], + ) + + assert (response.total_requested, response.successful_creations, response.failed_creations) == (3, 3, 0) + assert [r.user_id for r in response.results] == ["u1", "u2", "u3"] + assert all(r.success and r.key is None and r.error is None for r in response.results) + assert [r.teams for r in response.results] == [("t1", "t2"), ("t1",), ()] + + users = prisma.db.litellm_usertable.rows + assert users["u1"].teams == ["t1", "t2"] and users["u1"].max_budget == 50 + assert users["u2"].teams == ["t1"] and users["u3"].teams == [] + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["existing", "u1", "u2"] + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t2"].members_with_roles] == ["u1"] + assert sorted((m["team_id"], m["user_id"]) for m in prisma.db.litellm_teammembership.rows) == [ + ("t1", "u1"), + ("t1", "u2"), + ("t2", "u1"), + ] + + +@pytest.mark.asyncio +async def test_one_insert_and_one_locked_write_per_team(): + prisma = _FakePrisma(teams=[_team("t1"), _team("t2")]) + await _run( + prisma, + [{"user_id": f"u{i}", "teams": ["t1"] if i % 2 else ["t1", "t2"]} for i in range(20)], + ) + + assert prisma.db.litellm_usertable.create_many_calls == 1 + assert prisma.tx_count == 2 + assert sorted(prisma.locks) == ["t1", "t2"] + assert prisma.db.litellm_teamtable.update_calls == 2 + assert len(prisma.db.litellm_teamtable.rows["t1"].members_with_roles) == 20 + assert len(prisma.db.litellm_teamtable.rows["t2"].members_with_roles) == 10 + + +@pytest.mark.asyncio +async def test_bad_rows_fail_alone_and_good_rows_still_land(): + prisma = _FakePrisma(teams=[_team("t1")]) + prisma.db.litellm_usertable.rows["taken"] = _UserRow(user_id="taken", user_email="Taken@Example.com") + response = await _run( + prisma, + [ + {"user_id": "u1", "user_email": "a@example.com", "teams": ["t1"]}, + {"user_id": "u2", "user_email": "A@EXAMPLE.COM"}, + {"user_id": "u1", "user_email": "z@example.com"}, + {"user_id": "u3", "user_email": "taken@example.com"}, + {"user_id": "taken"}, + {"user_id": "u4", "teams": ["missing"]}, + {"user_id": "u5", "teams": ["t1", "missing"]}, + {"user_id": "u6", "budget_duration": "not-a-duration"}, + {"user_id": "u7", "user_email": "ok@example.com", "teams": ["t1"]}, + ], + ) + + assert [r.success for r in response.results] == [True, False, False, False, False, False, False, False, True] + assert (response.successful_creations, response.failed_creations) == (2, 7) + errors = [r.error for r in response.results] + assert "Duplicate user_email" in errors[1] + assert "Duplicate user_id" in errors[2] + assert "already exists" in errors[3] and "already exists" in errors[4] + assert "missing" in errors[5] and "does not exist" in errors[5] + assert "missing" in errors[6] + assert errors[7] is not None + + assert set(prisma.db.litellm_usertable.rows) == {"taken", "u1", "u7"} + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1", "u7"] + + +@pytest.mark.asyncio +async def test_insert_failure_falls_back_to_per_row_and_reports_only_that_row(): + prisma = _FakePrisma(teams=[_team("t1")], fail_ids=frozenset({"u2"})) + response = await _run( + prisma, + [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2", "teams": ["t1"]}, {"user_id": "u3"}], + ) + + assert [r.success for r in response.results] == [True, False, True] + assert "insert failed for u2" in (response.results[1].error or "") + assert set(prisma.db.litellm_usertable.rows) == {"u1", "u3"} + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1"] + + +@pytest.mark.asyncio +async def test_team_write_failure_keeps_user_and_reports_it_on_the_row(): + prisma = _FakePrisma(teams=[_team("t1"), _team("t2")]) + + async def explode(where, data): + raise RuntimeError("roster write failed") + + prisma.db.litellm_teamtable.update = explode + response = await _run(prisma, [{"user_id": "u1", "teams": ["t1", "t2"]}]) + + result = response.results[0] + assert result.success is True + assert result.teams == () + assert "t1" in (result.error or "") and "roster write failed" in (result.error or "") + assert prisma.db.litellm_usertable.rows["u1"].teams == [] + assert (response.successful_creations, response.failed_creations) == (1, 0) + + +@pytest.mark.asyncio +async def test_keys_are_opt_in_per_row(): + prisma = _FakePrisma() + calls: list[dict[str, object]] = [] + + async def generate_key(**kwargs: object) -> dict[str, object]: + calls.append(kwargs) + return {"token": f"sk-{kwargs['user_id']}"} + + response = await _run( + prisma, + [ + {"user_id": "u1"}, + {"user_id": "u2", "auto_create_key": True, "models": ["gpt-4o"], "key_alias": "u2-key"}, + {"user_id": "u3", "auto_create_key": False}, + ], + generate_key=generate_key, + ) + + assert [r.key for r in response.results] == [None, "sk-u2", None] + assert len(calls) == 1 + assert calls[0]["user_id"] == "u2" and calls[0]["table_name"] == "key" + assert calls[0]["models"] == ("gpt-4o",) and calls[0]["key_alias"] == "u2-key" + assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2", "u3"} + + +@pytest.mark.asyncio +async def test_non_admin_cannot_create_admin_users_but_other_rows_proceed(): + prisma = _FakePrisma() + response = await _run( + prisma, + [{"user_id": "u1", "user_role": "proxy_admin"}, {"user_id": "u2", "user_role": "internal_user"}], + caller=INTERNAL, + ) + + assert [r.success for r in response.results] == [False, True] + assert "Only proxy admins" in (response.results[0].error or "") + assert set(prisma.db.litellm_usertable.rows) == {"u2"} + + +@pytest.mark.asyncio +async def test_license_is_checked_once_against_the_whole_batch(): + prisma = _FakePrisma() + prisma.db.litellm_usertable.rows["existing"] = _UserRow(user_id="existing") + license = _License(max_users=3) + + with pytest.raises(HTTPException) as exc: + await _run(prisma, [{"user_id": f"u{i}"} for i in range(3)], license=license) + + assert exc.value.status_code == 403 + assert license.seen == [4] + assert set(prisma.db.litellm_usertable.rows) == {"existing"} + + ok = await _run(prisma, [{"user_id": f"u{i}"} for i in range(2)], license=license) + assert ok.successful_creations == 2 + + resend = await _run(prisma, [{"user_id": f"u{i}"} for i in range(2)], license=license) + assert [r.success for r in resend.results] == [False, False] + assert all("already exists" in (r.error or "") for r in resend.results) + assert license.seen == [4, 3] + assert set(prisma.db.litellm_usertable.rows) == {"existing", "u0", "u1"} + + +def test_request_rejects_empty_oversized_and_invite_rows(): + with pytest.raises(ValidationError): + BulkNewUserRequest(users=[]) + with pytest.raises(ValidationError): + BulkNewUserRequest(users=[{"user_email": f"{i}@example.com"} for i in range(501)]) + with pytest.raises(ValidationError, match="send_invite_email"): + BulkNewUserItem(user_email="a@example.com", send_invite_email=True) + assert len(BulkNewUserRequest(users=[{"user_email": f"{i}@example.com"} for i in range(500)]).users) == 500 + assert BulkNewUserItem(user_email="a@example.com").auto_create_key is False From 24a1d772b6d2b0d8396145c12bd3ac638687a460 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 01:48:44 +0000 Subject: [PATCH 02/25] fix(proxy): keep key policy fields and reconcile committed rows in /user/bulk_new Rows opting into auto_create_key lost blocked, permissions, aliases, config, agent_id, budget_fallbacks and budget_limits before reaching the key helper. When create_many commits but the response is lost, re-read which ids landed and retry only the rest so committed rows report success and get their teams. Regenerate schema.d.ts and allowlist the endpoint in the Terraform audit. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_creation.py | 39 ++- .../endpointaudit/coverage_allowlist.txt | 1 + .../test_bulk_user_creation.py | 48 +++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 242 +++++++++++++++++- 4 files changed, 315 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_creation.py b/litellm/proxy/management_helpers/bulk_user_creation.py index 301629e6c2a..7558a862993 100644 --- a/litellm/proxy/management_helpers/bulk_user_creation.py +++ b/litellm/proxy/management_helpers/bulk_user_creation.py @@ -124,6 +124,13 @@ class _UserRow(BaseModel): prompts: tuple[str, ...] | None = None duration: str | None = None key_alias: str | None = None + aliases: Mapping[str, object] | None = None + config: Mapping[str, object] | None = None + permissions: Mapping[str, object] | None = None + blocked: bool | None = None + agent_id: str | None = None + budget_fallbacks: Mapping[str, tuple[str, ...]] | None = None + budget_limits: tuple[Mapping[str, object], ...] | None = None organizations: tuple[str, ...] | None = None @@ -428,16 +435,26 @@ async def _insert_users( return tuple(prepared), () except Exception as exc: # noqa: BLE001 # fall back to per-row inserts so the failing row can be identified verbose_proxy_logger.warning("/user/bulk_new: create_many failed, retrying rows individually - %s", exc) + landed_rows: Final = await table.find_many( + where={"user_id": {"in": [payload["user_id"] for payload in payloads]}} # mutable-ok: Prisma filter + ) + landed: Final = frozenset(row.user_id for row in landed_rows) + retried: Final = tuple(user for user in prepared if user.row.user_id not in landed) outcomes: Final = await _bounded( - BULK_NEW_USER_CONCURRENCY, tuple(table.create(data=payload) for payload in payloads) + BULK_NEW_USER_CONCURRENCY, tuple(table.create(data=_user_create_payload(user)) for user in retried) + ) + failed: Final = MappingProxyType( + { + user.row.user_id: _RowFailure( + user.pending.index, user.pending.user_id, user.row.user_email, _error_message(outcome) + ) + for user, outcome in zip(retried, outcomes, strict=True) + if isinstance(outcome, BaseException) + } ) return ( - tuple(user for user, outcome in zip(prepared, outcomes, strict=True) if not isinstance(outcome, BaseException)), - tuple( - _RowFailure(user.pending.index, user.pending.user_id, user.row.user_email, _error_message(outcome)) - for user, outcome in zip(prepared, outcomes, strict=True) - if isinstance(outcome, BaseException) - ), + tuple(user for user in prepared if user.row.user_id not in failed), + tuple(failed.values()), ) @@ -606,9 +623,17 @@ _KEY_FIELDS: Final = MappingProxyType( for name in ( "user_id", "team_id", + "agent_id", "duration", "key_alias", "models", + "aliases", + "config", + "permissions", + "blocked", + "spend", + "budget_fallbacks", + "budget_limits", "metadata", "max_parallel_requests", "tpm_limit", diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 6bc8947e89f..6f85df98850 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -84,6 +84,7 @@ POST /team/{team_id}/member/{user_id}/reset_spend POST /team/key/bulk_update POST /team/permissions_bulk_update POST /team/{team_id}/disable_logging +POST /user/bulk_new POST /user/bulk_update # Alternate method or path for functionality the provider already manages elsewhere diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py index c9f0a0e4ffe..7ff9b037cc1 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py @@ -31,9 +31,10 @@ class _UserRow(BaseModel): class _UserTable: """Enough of the Prisma user table for the bulk path: set lookups, one create_many and per-row fallbacks.""" - def __init__(self, fail_ids: frozenset[str] = frozenset()) -> None: + def __init__(self, fail_ids: frozenset[str] = frozenset(), commit_then_drop: bool = False) -> None: self.rows: dict[str, _UserRow] = {} self.fail_ids = fail_ids + self.commit_then_drop = commit_then_drop self.create_many_calls = 0 async def count(self, where: object = None) -> int: @@ -60,6 +61,8 @@ class _UserTable: raise RuntimeError("batch insert failed") for row in rows: self.rows[row.user_id] = row + if self.commit_then_drop: + raise ConnectionError("connection reset after commit") return len(rows) async def update(self, where: dict[str, str], data: dict[str, object]) -> _UserRow: @@ -110,15 +113,22 @@ class _Tx: class _Db: - def __init__(self, teams: list[LiteLLM_TeamTable], fail_ids: frozenset[str] = frozenset()) -> None: - self.litellm_usertable = _UserTable(fail_ids) + def __init__( + self, teams: list[LiteLLM_TeamTable], fail_ids: frozenset[str] = frozenset(), commit_then_drop: bool = False + ) -> None: + self.litellm_usertable = _UserTable(fail_ids, commit_then_drop) self.litellm_teamtable = _TeamTable(teams) self.litellm_teammembership = _MembershipTable() class _FakePrisma: - def __init__(self, teams: list[LiteLLM_TeamTable] | None = None, fail_ids: frozenset[str] = frozenset()) -> None: - self.db = _Db(teams or [], fail_ids) + def __init__( + self, + teams: list[LiteLLM_TeamTable] | None = None, + fail_ids: frozenset[str] = frozenset(), + commit_then_drop: bool = False, + ) -> None: + self.db = _Db(teams or [], fail_ids, commit_then_drop) self.tx_count = 0 self.locks: list[str] = [] @@ -255,6 +265,17 @@ async def test_insert_failure_falls_back_to_per_row_and_reports_only_that_row(): assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1"] +@pytest.mark.asyncio +async def test_insert_that_committed_but_lost_its_response_still_counts_as_created(): + prisma = _FakePrisma(teams=[_team("t1")], commit_then_drop=True) + response = await _run(prisma, [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2"}]) + + assert [r.success for r in response.results] == [True, True] + assert [r.error for r in response.results] == [None, None] + assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2"} + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1"] + + @pytest.mark.asyncio async def test_team_write_failure_keeps_user_and_reports_it_on_the_row(): prisma = _FakePrisma(teams=[_team("t1"), _team("t2")]) @@ -286,7 +307,17 @@ async def test_keys_are_opt_in_per_row(): prisma, [ {"user_id": "u1"}, - {"user_id": "u2", "auto_create_key": True, "models": ["gpt-4o"], "key_alias": "u2-key"}, + { + "user_id": "u2", + "auto_create_key": True, + "models": ["gpt-4o"], + "key_alias": "u2-key", + "blocked": True, + "permissions": {"get_spend_routes": True}, + "aliases": {"fast": "gpt-4o"}, + "config": {"tier": "gold"}, + "budget_fallbacks": {"gpt-4o": ["gpt-4o-mini"]}, + }, {"user_id": "u3", "auto_create_key": False}, ], generate_key=generate_key, @@ -296,6 +327,11 @@ async def test_keys_are_opt_in_per_row(): assert len(calls) == 1 assert calls[0]["user_id"] == "u2" and calls[0]["table_name"] == "key" assert calls[0]["models"] == ("gpt-4o",) and calls[0]["key_alias"] == "u2-key" + assert calls[0]["blocked"] is True + assert calls[0]["permissions"] == {"get_spend_routes": True} + assert calls[0]["aliases"] == {"fast": "gpt-4o"} + assert calls[0]["config"] == {"tier": "gold"} + assert calls[0]["budget_fallbacks"] == {"gpt-4o": ("gpt-4o-mini",)} assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2", "u3"} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7eadaa6c991..f63bed3c3ec 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16478,6 +16478,53 @@ export interface paths { patch?: never; trace?: never; }; + "/user/bulk_new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk New User + * @description Create up to 500 internal users in one request, optionally adding each one to teams. + * + * Every entry in `users` takes the same fields as `/user/new`, with two differences: `auto_create_key` + * defaults to `false` (opt in per user to also get a virtual key back) and `send_invite_email` is not + * supported. Rows are validated together (duplicate ids or emails, unknown teams, roles the caller may not + * grant), inserted in one statement, and each referenced team is written once for all of its new members. + * + * Rows fail independently: a bad row is reported in `results` with `success: false` and an `error`, and the + * other rows still get created. A user that was created but could not be added to one of its teams is + * reported with `success: true`, `teams` listing where they did land, and `error` naming the failed team. + * The whole request is rejected with 403 only if creating the valid rows would exceed the license seat limit. + * + * Usage Example + * + * ```shell + * curl -X POST "http://localhost:4000/user/bulk_new" \ + * -H "Content-Type: application/json" \ + * -H "Authorization: Bearer sk-1234" \ + * -d '{ + * "users": [ + * {"user_email": "a@example.com", "user_role": "internal_user", "teams": ["team-1"]}, + * {"user_email": "b@example.com", "user_role": "internal_user", "auto_create_key": true} + * ] + * }' + * ``` + * + * Returns `results` (one entry per input row, in order, with `user_id`, `user_email`, `success`, `teams`, + * `key`, `error`), `total_requested`, `successful_creations` and `failed_creations`. + */ + post: operations["bulk_new_user_user_bulk_new_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/user/bulk_update": { parameters: { query?: never; @@ -16781,7 +16828,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16887,7 +16933,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -24505,6 +24550,148 @@ export interface components { /** Budgets */ budgets: string[]; }; + /** + * BulkNewUserItem + * @description One row of `/user/bulk_new`: the `/user/new` body, with keys opt-in and invite emails unsupported. + */ + BulkNewUserItem: { + /** Agent Id */ + agent_id?: string | null; + /** + * Aliases + * @default {} + */ + aliases: { + [key: string]: unknown; + } | null; + /** + * Allowed Cache Controls + * @default [] + */ + allowed_cache_controls: unknown[] | null; + /** + * Auto Create Key + * @default false + */ + auto_create_key: boolean; + /** Blocked */ + blocked?: boolean | null; + /** Budget Duration */ + budget_duration?: string | null; + /** Budget Fallbacks */ + budget_fallbacks?: { + [key: string]: string[]; + } | null; + /** Budget Limits */ + budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null; + /** + * Config + * @default {} + */ + config: { + [key: string]: unknown; + } | null; + /** Duration */ + duration?: string | null; + /** Guardrails */ + guardrails?: string[] | null; + /** Key Alias */ + key_alias?: string | null; + /** Max Budget */ + max_budget?: number | null; + /** Max Parallel Requests */ + max_parallel_requests?: number | null; + /** Mcp Rpm Limit */ + mcp_rpm_limit?: { + [key: string]: number; + } | null; + /** + * Metadata + * @default {} + */ + metadata: { + [key: string]: unknown; + } | null; + /** + * Model Max Budget + * @default {} + */ + model_max_budget: { + [key: string]: unknown; + } | null; + /** Model Rpm Limit */ + model_rpm_limit?: { + [key: string]: unknown; + } | null; + /** Model Tpm Limit */ + model_tpm_limit?: { + [key: string]: unknown; + } | null; + /** + * Models + * @default [] + */ + models: unknown[] | null; + object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null; + /** Organizations */ + organizations?: string[] | null; + /** + * Permissions + * @default {} + */ + permissions: { + [key: string]: unknown; + } | null; + /** Policies */ + policies?: string[] | null; + /** Prompts */ + prompts?: string[] | null; + /** Rpm Limit */ + rpm_limit?: number | null; + /** Send Invite Email */ + send_invite_email?: boolean | null; + /** + * Spend + * @default 0 + */ + spend: number | null; + /** Sso User Id */ + sso_user_id?: string | null; + /** Tag Rpm Limit */ + tag_rpm_limit?: { + [key: string]: number; + } | null; + /** Team Id */ + team_id?: string | null; + /** Teams */ + teams?: string[] | components["schemas"]["NewUserRequestTeam"][] | null; + /** Tpm Limit */ + tpm_limit?: number | null; + /** User Alias */ + user_alias?: string | null; + /** User Email */ + user_email?: string | null; + /** User Id */ + user_id?: string | null; + /** User Role */ + user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null; + }; + /** BulkNewUserRequest */ + BulkNewUserRequest: { + /** Users */ + users: components["schemas"]["BulkNewUserItem"][]; + }; + /** BulkNewUserResponse */ + BulkNewUserResponse: { + /** Failed Creations */ + failed_creations: number; + /** Results */ + results: components["schemas"]["UserCreateResult"][]; + /** Successful Creations */ + successful_creations: number; + /** Total Requested */ + total_requested: number; + }; /** * BulkTeamMemberAddRequest * @description Request for bulk team member addition @@ -39345,6 +39532,24 @@ export interface components { */ severity: "info" | "warning" | "error"; }; + /** + * UserCreateResult + * @description Outcome for one row of `/user/bulk_new`. `teams` lists the teams the user was actually added to. + */ + UserCreateResult: { + /** Error */ + error?: string | null; + /** Key */ + key?: string | null; + /** Success */ + success: boolean; + /** Teams */ + teams?: string[] | null; + /** User Email */ + user_email?: string | null; + /** User Id */ + user_id?: string | null; + }; /** * UserHeaderMapping * @description Map an incoming HTTP header to a LiteLLM user role. @@ -60631,6 +60836,39 @@ export interface operations { }; }; }; + bulk_new_user_user_bulk_new_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkNewUserRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkNewUserResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; bulk_user_update_user_bulk_update_post: { parameters: { query?: never; From f5e7294b46efb2b33b7184043f22f0ebf5160e2f Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 02:12:54 +0000 Subject: [PATCH 03/25] fix(proxy): keep request identifiers out of /user/bulk_new log lines Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/management_helpers/bulk_user_creation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_creation.py b/litellm/proxy/management_helpers/bulk_user_creation.py index 7558a862993..a55445634dc 100644 --- a/litellm/proxy/management_helpers/bulk_user_creation.py +++ b/litellm/proxy/management_helpers/bulk_user_creation.py @@ -348,7 +348,7 @@ async def _prepare_user(user: _PendingUser, prisma_client: PrismaClient) -> _Pre ) return _PreparedUser(user, _USER_ROW.validate_python(with_permission)) except Exception as exc: # noqa: BLE001 # any preparation failure is reported on this row only - verbose_proxy_logger.warning("/user/bulk_new: could not prepare user %s - %s", user.user_id, exc) + verbose_proxy_logger.warning("/user/bulk_new: could not prepare row %d - %s", user.index, type(exc).__name__) return _RowFailure(user.index, user.user_id, user.request.user_email, _error_message(exc)) @@ -560,7 +560,7 @@ async def _write_team_roster( failed=MappingProxyType({}), ) except Exception as exc: # noqa: BLE001 # the team write failure is reported on each affected row - verbose_proxy_logger.exception("/user/bulk_new: failed to add members to team %s - %s", team.team_id, exc) + verbose_proxy_logger.exception("/user/bulk_new: failed to add %d members to a team", len(members)) message: Final = f"Failed to add user to team {team.team_id}: {_error_message(exc)}" return _TeamWrite( team_id=team.team_id, From 7ec9e2a7e882114831582e9a9a1fe1fd3089f4ae Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 02:35:36 +0000 Subject: [PATCH 04/25] fix(proxy): log /user/bulk_new failures with exc_info instead of request-derived values Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal_user_endpoints.py | 2 +- .../management_helpers/bulk_user_creation.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 2624d7e373a..5f570485ed3 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -701,7 +701,7 @@ async def bulk_new_user( user_api_key_cache=user_api_key_cache, ) except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract - verbose_proxy_logger.exception("/user/bulk_new: Exception occured - %s", e) + verbose_proxy_logger.exception("/user/bulk_new: Exception occured") raise handle_exception_on_proxy(e) diff --git a/litellm/proxy/management_helpers/bulk_user_creation.py b/litellm/proxy/management_helpers/bulk_user_creation.py index a55445634dc..3b587e932d4 100644 --- a/litellm/proxy/management_helpers/bulk_user_creation.py +++ b/litellm/proxy/management_helpers/bulk_user_creation.py @@ -433,8 +433,8 @@ async def _insert_users( try: await table.create_many(data=payloads) return tuple(prepared), () - except Exception as exc: # noqa: BLE001 # fall back to per-row inserts so the failing row can be identified - verbose_proxy_logger.warning("/user/bulk_new: create_many failed, retrying rows individually - %s", exc) + except Exception: # noqa: BLE001 # fall back to per-row inserts so the failing row can be identified + verbose_proxy_logger.warning("/user/bulk_new: create_many failed, retrying rows individually", exc_info=True) landed_rows: Final = await table.find_many( where={"user_id": {"in": [payload["user_id"] for payload in payloads]}} # mutable-ok: Prisma filter ) @@ -585,7 +585,9 @@ async def _detach_failed_teams( ) for outcome in await _bounded(BULK_NEW_USER_CONCURRENCY, updates): if isinstance(outcome, BaseException): - verbose_proxy_logger.warning("/user/bulk_new: could not detach failed teams from user - %s", outcome) + verbose_proxy_logger.warning( + "/user/bulk_new: could not detach failed teams from user - %s", type(outcome).__name__ + ) async def _publish_team_writes(writes: Sequence[_TeamWrite], user_api_key_cache: "UserApiKeyCache") -> None: @@ -600,8 +602,8 @@ async def _publish_team_writes(writes: Sequence[_TeamWrite], user_api_key_cache: members_with_roles=write.after, # pyright: ignore[reportArgumentType] # pydantic coerces the tuple into the declared list ) ) - except Exception as exc: # noqa: BLE001 # metrics are best-effort and must not fail the request - verbose_proxy_logger.debug("Prometheus: failed to emit team members metric: %s", exc) + except Exception: # noqa: BLE001 # metrics are best-effort and must not fail the request + verbose_proxy_logger.debug("Prometheus: failed to emit team members metric", exc_info=True) evictions: Final = await _bounded( BULK_NEW_USER_CONCURRENCY, tuple( @@ -614,7 +616,7 @@ async def _publish_team_writes(writes: Sequence[_TeamWrite], user_api_key_cache: ) for eviction in evictions: if isinstance(eviction, BaseException): - verbose_proxy_logger.warning("/user/bulk_new: cache eviction failed - %s", eviction) + verbose_proxy_logger.warning("/user/bulk_new: cache eviction failed - %s", type(eviction).__name__) _KEY_FIELDS: Final = MappingProxyType( @@ -714,7 +716,9 @@ async def _write_audit_logs( ) for outcome in outcomes: if isinstance(outcome, BaseException): - verbose_proxy_logger.warning("Unable to create audit log for user on `/user/bulk_new` - %s", outcome) + verbose_proxy_logger.warning( + "Unable to create audit log for user on `/user/bulk_new` - %s", type(outcome).__name__ + ) def _row_teams(prepared: _PreparedUser, writes: Mapping[str, _TeamWrite]) -> tuple[tuple[str, ...], tuple[str, ...]]: From 4285f1dfb09f9a5ccdda626fa0cd70074fca1354 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 02:39:47 +0000 Subject: [PATCH 05/25] fix(proxy): do not claim rows a concurrent request inserted when /user/bulk_new create_many fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_creation.py | 34 +++++++++++----- .../test_bulk_user_creation.py | 39 ++++++++++++++++--- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_creation.py b/litellm/proxy/management_helpers/bulk_user_creation.py index 3b587e932d4..8bd7e83d122 100644 --- a/litellm/proxy/management_helpers/bulk_user_creation.py +++ b/litellm/proxy/management_helpers/bulk_user_creation.py @@ -31,6 +31,7 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks import invalidate_team_member_spend_state from litellm.proxy.auth.litellm_license import LicenseCheck from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time +from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks from litellm.proxy.management_endpoints.common_utils import ( _is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same team-admin check /user/new uses @@ -433,23 +434,38 @@ async def _insert_users( try: await table.create_many(data=payloads) return tuple(prepared), () - except Exception: # noqa: BLE001 # fall back to per-row inserts so the failing row can be identified + except Exception as exc: # noqa: BLE001 # fall back to per-row inserts so the failing row can be identified verbose_proxy_logger.warning("/user/bulk_new: create_many failed, retrying rows individually", exc_info=True) - landed_rows: Final = await table.find_many( - where={"user_id": {"in": [payload["user_id"] for payload in payloads]}} # mutable-ok: Prisma filter - ) + outcome_unknown: Final = PrismaDBExceptionHandler.is_database_infrastructure_error(exc) + requested: Final = frozenset(payload["user_id"] for payload in payloads) + landed_rows: Final = await table.find_many(where={"user_id": {"in": list(requested)}}) # mutable-ok: Prisma filter landed: Final = frozenset(row.user_id for row in landed_rows) + # create_many is one INSERT: after a lost response the full set is ours, any partial set belongs to another request + if outcome_unknown and landed == requested: + return tuple(prepared), () + taken: Final = tuple(user for user in prepared if user.row.user_id in landed) retried: Final = tuple(user for user in prepared if user.row.user_id not in landed) outcomes: Final = await _bounded( BULK_NEW_USER_CONCURRENCY, tuple(table.create(data=_user_create_payload(user)) for user in retried) ) failed: Final = MappingProxyType( { - user.row.user_id: _RowFailure( - user.pending.index, user.pending.user_id, user.row.user_email, _error_message(outcome) - ) - for user, outcome in zip(retried, outcomes, strict=True) - if isinstance(outcome, BaseException) + **{ + user.row.user_id: _RowFailure( + user.pending.index, + user.pending.user_id, + user.row.user_email, + f"User id={user.row.user_id} already exists", + ) + for user in taken + }, + **{ + user.row.user_id: _RowFailure( + user.pending.index, user.pending.user_id, user.row.user_email, _error_message(outcome) + ) + for user, outcome in zip(retried, outcomes, strict=True) + if isinstance(outcome, BaseException) + }, } ) return ( diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py index 7ff9b037cc1..00ddd63c057 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py @@ -2,8 +2,10 @@ import json from contextlib import asynccontextmanager from typing import Final +import httpx import pytest from fastapi import HTTPException +from prisma.errors import UniqueViolationError from pydantic import BaseModel, ConfigDict, ValidationError from litellm.caching.caching import DualCache @@ -31,10 +33,16 @@ class _UserRow(BaseModel): class _UserTable: """Enough of the Prisma user table for the bulk path: set lookups, one create_many and per-row fallbacks.""" - def __init__(self, fail_ids: frozenset[str] = frozenset(), commit_then_drop: bool = False) -> None: + def __init__( + self, + fail_ids: frozenset[str] = frozenset(), + commit_then_drop: bool = False, + raced_ids: frozenset[str] = frozenset(), + ) -> None: self.rows: dict[str, _UserRow] = {} self.fail_ids = fail_ids self.commit_then_drop = commit_then_drop + self.raced_ids = raced_ids self.create_many_calls = 0 async def count(self, where: object = None) -> int: @@ -59,10 +67,15 @@ class _UserTable: rows = [_UserRow.model_validate(d) for d in data] if any(row.user_id in self.fail_ids for row in rows): raise RuntimeError("batch insert failed") + raced = [row.user_id for row in rows if row.user_id in self.raced_ids] + if raced: + for user_id in raced: + self.rows[user_id] = _UserRow(user_id=user_id, user_email=f"{user_id}@other-request.example") + raise UniqueViolationError({}, message="Unique constraint failed on the fields: (`user_id`)") for row in rows: self.rows[row.user_id] = row if self.commit_then_drop: - raise ConnectionError("connection reset after commit") + raise httpx.ReadError("connection reset after commit") return len(rows) async def update(self, where: dict[str, str], data: dict[str, object]) -> _UserRow: @@ -114,9 +127,13 @@ class _Tx: class _Db: def __init__( - self, teams: list[LiteLLM_TeamTable], fail_ids: frozenset[str] = frozenset(), commit_then_drop: bool = False + self, + teams: list[LiteLLM_TeamTable], + fail_ids: frozenset[str] = frozenset(), + commit_then_drop: bool = False, + raced_ids: frozenset[str] = frozenset(), ) -> None: - self.litellm_usertable = _UserTable(fail_ids, commit_then_drop) + self.litellm_usertable = _UserTable(fail_ids, commit_then_drop, raced_ids) self.litellm_teamtable = _TeamTable(teams) self.litellm_teammembership = _MembershipTable() @@ -127,8 +144,9 @@ class _FakePrisma: teams: list[LiteLLM_TeamTable] | None = None, fail_ids: frozenset[str] = frozenset(), commit_then_drop: bool = False, + raced_ids: frozenset[str] = frozenset(), ) -> None: - self.db = _Db(teams or [], fail_ids, commit_then_drop) + self.db = _Db(teams or [], fail_ids, commit_then_drop, raced_ids) self.tx_count = 0 self.locks: list[str] = [] @@ -276,6 +294,17 @@ async def test_insert_that_committed_but_lost_its_response_still_counts_as_creat assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1"] +@pytest.mark.asyncio +async def test_user_id_taken_by_a_concurrent_request_is_not_claimed_by_this_batch(): + prisma = _FakePrisma(teams=[_team("t1")], raced_ids=frozenset({"u1"})) + response = await _run(prisma, [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2", "teams": ["t1"]}]) + + assert [r.success for r in response.results] == [False, True] + assert "User id=u1 already exists" in (response.results[0].error or "") + assert prisma.db.litellm_usertable.rows["u1"].user_email == "u1@other-request.example" + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u2"] + + @pytest.mark.asyncio async def test_team_write_failure_keeps_user_and_reports_it_on_the_row(): prisma = _FakePrisma(teams=[_team("t1"), _team("t2")]) From 0c9e0c407e290f9b2d14b041ea8290e2f372e5a0 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 03:37:20 +0000 Subject: [PATCH 06/25] fix(proxy): keep a team on a /user/bulk_new row when the roster already lists that user id Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/management_helpers/bulk_user_creation.py | 2 +- .../management_helpers/test_bulk_user_creation.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_helpers/bulk_user_creation.py b/litellm/proxy/management_helpers/bulk_user_creation.py index 8bd7e83d122..2eed5121755 100644 --- a/litellm/proxy/management_helpers/bulk_user_creation.py +++ b/litellm/proxy/management_helpers/bulk_user_creation.py @@ -572,7 +572,7 @@ async def _write_team_roster( return _TeamWrite( team_id=team.team_id, after=after, - added=frozenset(member.user_id for member in new_members), + added=frozenset(member.user_id for member in members), failed=MappingProxyType({}), ) except Exception as exc: # noqa: BLE001 # the team write failure is reported on each affected row diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py index 00ddd63c057..16e56c1b723 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py @@ -220,6 +220,18 @@ async def test_creates_users_and_team_membership_in_every_store(): ] +@pytest.mark.asyncio +async def test_user_id_already_on_the_roster_keeps_the_team_and_is_not_added_twice(): + prisma = _FakePrisma(teams=[_team("t1", [Member(user_id="u1", role="user")])]) + response = await _run(prisma, [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2", "teams": ["t1"]}]) + + assert [r.success for r in response.results] == [True, True] + assert [r.teams for r in response.results] == [("t1",), ("t1",)] + assert [r.error for r in response.results] == [None, None] + assert prisma.db.litellm_usertable.rows["u1"].teams == ["t1"] + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1", "u2"] + + @pytest.mark.asyncio async def test_one_insert_and_one_locked_write_per_team(): prisma = _FakePrisma(teams=[_team("t1"), _team("t2")]) From cd9c39921bbe114c2652f993fd0e02a604c24a97 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 04:55:39 +0000 Subject: [PATCH 07/25] feat(proxy): add POST /user/bulk_delete and POST /team/bulk_member_delete Batch user deletion that also removes each user from every team they belong to, and batch removal of many members from one team. Each touched team is rewritten once under the team advisory lock from a roster re-read under that lock Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 2 + litellm/proxy/auth/route_checks.py | 4 + .../internal_user_endpoints.py | 59 +++ .../management_endpoints/team_endpoints.py | 52 +++ .../management_helpers/bulk_user_deletion.py | 442 ++++++++++++++++++ .../internal_user_endpoints.py | 25 +- .../management_endpoints/team_endpoints.py | 27 +- .../endpointaudit/coverage_allowlist.txt | 2 + .../test_bulk_user_deletion.py | 434 +++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 227 ++++++++- 10 files changed, 1270 insertions(+), 4 deletions(-) create mode 100644 litellm/proxy/management_helpers/bulk_user_deletion.py create mode 100644 tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b315a2beac9..39fefd02b86 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -659,6 +659,7 @@ class LiteLLMRoutes(enum.Enum): "/user/update", "/user/bulk_update", "/user/delete", + "/user/bulk_delete", "/user/info", "/user/list", "/user/daily/activity", @@ -838,6 +839,7 @@ class LiteLLMRoutes(enum.Enum): self_managed_routes = [ "/team/member_add", "/team/member_delete", + "/team/bulk_member_delete", "/team/member_update", "/team/{team_id}/member/{user_id}/reset_spend", "/team/permissions_list", diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 953e3cf3e88..34fede7a967 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -25,9 +25,11 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset( # user "/user/new", "/user/delete", + "/user/bulk_delete", "/user/bulk_update", # team "/team/new", + "/team/bulk_member_delete", "/team/update", "/team/delete", "/team/block", @@ -756,8 +758,10 @@ class RouteChecks: [ "/user/new", "/user/delete", + "/user/bulk_delete", "/user/bulk_update", "/team/new", + "/team/bulk_member_delete", "/team/update", "/team/delete", "/model/new", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e3efda507f6..a2e084f265a 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -8,6 +8,7 @@ These are members of a Team on LiteLLM /user/update /user/bulk_update /user/delete +/user/bulk_delete /user/info /user/list """ @@ -55,6 +56,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, prepare_metadata_fields, ) +from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, handle_update_object_permission_common, @@ -77,6 +79,8 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkDeleteUserRequest, + BulkDeleteUserResponse, BulkUpdateUserRequest, BulkUpdateUserResponse, UserListResponse, @@ -2496,6 +2500,61 @@ async def delete_user( return deleted_users +@router.post( + "/user/bulk_delete", + tags=["Internal User management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence + dependencies=(Depends(user_api_key_auth),), + response_model=BulkDeleteUserResponse, +) +@management_endpoint_wrapper +async def bulk_delete_user( + data: BulkDeleteUserRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection + litellm_changed_by: str | None = Header( + None, + description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", + ), +) -> BulkDeleteUserResponse: + """ + Delete up to 500 internal users in one request and remove each one from every team they belong to. + + Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose + organizations they all administer. Each team a deleted user was on is rewritten once under the team + lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards. + Then the users' keys, invitation links, organization memberships and user rows are deleted. + + Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with + `success: false` and an `error`, and the other users are still deleted. + + Usage Example + + ```shell + curl -X POST "http://localhost:4000/user/bulk_delete" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk-1234" \\ + -d '{"user_ids": ["user-1", "user-2"]}' + ``` + + Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`, + `teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. + """ + from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client + + if prisma_client is None: + raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) + try: + return await bulk_delete_users( + data=data, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + litellm_proxy_admin_name=litellm_proxy_admin_name, + litellm_changed_by=litellm_changed_by, + ) + except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract + verbose_proxy_logger.exception("/user/bulk_delete: Exception occured") + raise handle_exception_on_proxy(e) + + async def add_internal_user_to_organization( user_id: str, organization_id: str, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 0b7f69bbb7f..91e803d6f29 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -162,6 +162,8 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkTeamMemberAddRequest, BulkTeamMemberAddResponse, + BulkTeamMemberDeleteRequest, + BulkTeamMemberDeleteResponse, BulkUpdateTeamMemberPermissionsRequest, BulkUpdateTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse, @@ -3451,6 +3453,56 @@ async def team_member_delete( return existing_team_row +@router.post( + "/team/bulk_member_delete", + tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence + dependencies=(Depends(user_api_key_auth),), + response_model=BulkTeamMemberDeleteResponse, +) +@management_endpoint_wrapper +async def bulk_team_member_delete( + data: BulkTeamMemberDeleteRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection +) -> BulkTeamMemberDeleteResponse: + """ + Remove up to 500 members from one team in a single request. + + Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's + organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under + the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and + their team-scoped keys are all cleaned up together. Members that are not on the team are reported in + `results` with `success: false` and the rest are still removed. + + Example request: + ```bash + curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \\ + --header 'Authorization: Bearer sk-1234' \\ + --header 'Content-Type: application/json' \\ + --data '{ + "team_id": "team-1234", + "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}] + }' + ``` + + Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`, + `success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. + """ + from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) + try: + return await bulk_remove_team_members( + data=data, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract + verbose_proxy_logger.exception("/team/bulk_member_delete: Exception occured") + raise handle_exception_on_proxy(e) + + _MEMBER_BUDGET_PATCH_FIELDS: Final = { "max_budget_in_team": "max_budget", "tpm_limit": "tpm_limit", diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py new file mode 100644 index 00000000000..a2d3348a30e --- /dev/null +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -0,0 +1,442 @@ +"""Batched deletes behind `POST /user/bulk_delete` and `POST /team/bulk_member_delete`. + +Each team a batch touches is rewritten exactly once, under the same advisory lock +`/team/member_delete` takes and from a roster re-read under that lock, so a concurrent +member_add on the team is never overwritten from a stale read. +""" + +import asyncio +import json +from collections.abc import Awaitable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import TYPE_CHECKING, Final + +from fastapi import HTTPException +from typing_extensions import ReadOnly, TypedDict + +from litellm._logging import verbose_proxy_logger +from litellm.integrations.prometheus import PrometheusLogger +from litellm.proxy._types import ( + LiteLLM_TeamTable, + LitellmUserRoles, + Member, + MemberDeleteRequest, + UserAPIKeyAuth, +) +from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks +from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses + _is_user_team_admin, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses +) +from litellm.proxy.management_endpoints.key_management_endpoints import ( + _persist_deleted_verification_tokens, # pyright: ignore[reportPrivateUsage] # same audit path /key/delete uses +) +from litellm.proxy.management_helpers.access_group_team_sync import TEAM_ADVISORY_LOCK_SQL +from litellm.proxy.utils import PrismaClient +from litellm.repositories.table_repositories import ( + InvitationLinkRepository, + OrganizationMembershipRepository, + TeamMembershipRepository, +) +from litellm.repositories.team_repository import TeamRepository +from litellm.repositories.user_repository import UserRepository +from litellm.repositories.verification_token_repository import VerificationTokenRepository +from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkDeleteUserRequest, + BulkDeleteUserResponse, + UserDeleteResult, +) +from litellm.types.proxy.management_endpoints.team_endpoints import ( + BulkTeamMemberDeleteRequest, + BulkTeamMemberDeleteResponse, + TeamMemberDeleteResult, +) + +if TYPE_CHECKING: + from prisma import Prisma + from prisma import models as prisma_models + + from litellm.repositories.prisma_protocols import TableActions + +_TEAM_WRITE_CONCURRENCY: Final = 10 + + +class _ErrorDetail(TypedDict): + error: ReadOnly[str] + + +class _OrgAdminFilter(TypedDict): + user_id: ReadOnly[str] + user_role: ReadOnly[str] + + +class _RosterData(TypedDict): + members_with_roles: ReadOnly[str] + + +class _TeamsSet(TypedDict): + set: ReadOnly[tuple[str, ...]] + + +class _TeamsData(TypedDict): + teams: ReadOnly[_TeamsSet] + + +@dataclass(frozen=True, slots=True) +class _TeamRemoval: + """One team's rewrite. `removed` holds the user ids taken off the team (roster, `teams` array, or both); + `matched` holds the indexes into the requested members that named at least one of them.""" + + team: LiteLLM_TeamTable + removed: frozenset[str] + matched: frozenset[int] + + +def _http_error(status_code: int, message: str) -> HTTPException: + detail: Final[_ErrorDetail] = {"error": message} + return HTTPException(status_code=status_code, detail=detail) + + +def _in_filter(field: str, values: Iterable[str]) -> Mapping[str, object]: + return {field: {"in": sorted(values)}} # mutable-ok: Prisma query filters are dict-shaped + + +def _eq_filter(field: str, value: str) -> Mapping[str, object]: + return {field: value} # mutable-ok: Prisma query filters are dict-shaped + + +def _team_users_filter(team_id: str, user_ids: Iterable[str]) -> Mapping[str, object]: + return {"team_id": team_id, **_in_filter("user_id", user_ids)} # mutable-ok: Prisma query filters are dict-shaped + + +def _any_filter(*clauses: Mapping[str, object]) -> Mapping[str, object]: + return {"OR": clauses} # mutable-ok: Prisma query filters are dict-shaped + + +def _team_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamTable]": + return tx.litellm_teamtable # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do + + +def _user_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_UserTable]": + return tx.litellm_usertable # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do + + +def _membership_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamMembership]": + return tx.litellm_teammembership # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do + + +def _token_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_VerificationToken]": + return tx.litellm_verificationtoken # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do + + +def _addresses_member(member: Member, request: MemberDeleteRequest) -> bool: + return (request.user_id is not None and request.user_id == member.user_id) or ( + request.user_email is not None and request.user_email == member.user_email + ) + + +def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDeleteRequest) -> bool: + return (request.user_id is not None and request.user_id == user.user_id) or ( + request.user_email is not None and request.user_email == user.user_email + ) + + +def _error_message(exc: BaseException) -> str: + if isinstance(exc, HTTPException) and isinstance(exc.detail, dict): + return str(exc.detail.get("error", exc.detail)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # HTTPException.detail is untyped + if isinstance(exc, HTTPException): + return str(exc.detail) # pyright: ignore[reportUnknownArgumentType] # HTTPException.detail is untyped + return str(exc) or type(exc).__name__ + + +async def _bounded(awaitables: Iterable[Awaitable[object]]) -> tuple[object | BaseException, ...]: + semaphore: Final = asyncio.Semaphore(_TEAM_WRITE_CONCURRENCY) + + async def run(awaitable: Awaitable[object]) -> object: + async with semaphore: + return await awaitable + + return tuple(await asyncio.gather(*(run(a) for a in awaitables), return_exceptions=True)) + + +async def _remove_members_from_team( + prisma_client: PrismaClient, + team_id: str, + members: Sequence[MemberDeleteRequest], + user_api_key_dict: UserAPIKeyAuth, +) -> _TeamRemoval: + async with prisma_client.tx() as tx: + await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id) + roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id) + if roster is None: + raise _http_error(400, f"Team id={team_id} does not exist in db") + + removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members)) + kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members)) + removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None) + requested_ids: Final = frozenset(r.user_id for r in members if r.user_id is not None) + requested_emails: Final = frozenset(r.user_email for r in members if r.user_id is None and r.user_email) + user_rows: Final = await _user_tx_db(tx).find_many( + where=_any_filter( + _in_filter("user_id", removed_ids | requested_ids), + _in_filter("user_email", requested_emails), + ) + ) + stale_rows: Final = tuple(u for u in user_rows if team_id in u.teams) + cleanup_ids: Final = removed_ids | requested_ids | frozenset(u.user_id for u in stale_rows) + matched: Final = frozenset( + i + for i, r in enumerate(members) + if any(_addresses_member(m, r) for m in removed_members) or any(_addresses_user(u, r) for u in stale_rows) + ) + keys: Final = await _token_tx_db(tx).find_many(where=_team_users_filter(team_id, cleanup_ids)) + + if removed_members: + roster_data: Final[_RosterData] = { + "members_with_roles": json.dumps(tuple(m.model_dump() for m in kept_members)) + } + await _team_tx_db(tx).update(where=_eq_filter("team_id", team_id), data=roster_data) + for row in stale_rows: + teams_data: _TeamsData = {"teams": {"set": tuple(t for t in row.teams if t != team_id)}} + await _user_tx_db(tx).update(where=_eq_filter("user_id", row.user_id), data=teams_data) + await _membership_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids)) + if keys: + await _persist_deleted_verification_tokens( + keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + tx=tx, + ) + await _token_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids)) + + return _TeamRemoval( + team=LiteLLM_TeamTable( + team_id=team_id, + members_with_roles=kept_members, # pyright: ignore[reportArgumentType] # pydantic coerces the tuple into the list field + ), + removed=removed_ids | frozenset(u.user_id for u in stale_rows), + matched=matched, + ) + + +def _emit_team_members_metric(team: LiteLLM_TeamTable) -> None: + prometheus_logger: Final = PrometheusLogger.get_instance() + if prometheus_logger is None: + return + try: + prometheus_logger.set_team_members_metric(team) + except Exception as e: + verbose_proxy_logger.debug("Prometheus: failed to emit team members metric: %s", str(e)) + + +async def bulk_remove_team_members( + data: BulkTeamMemberDeleteRequest, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, +) -> BulkTeamMemberDeleteResponse: + team: Final = await TeamRepository(prisma_client).find_by_id(data.team_id) + if team is None: + raise _http_error(400, f"Team id={data.team_id} does not exist in db") + + if ( + user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team) + and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team) + ): + raise _http_error( + 403, + "Call not allowed. User not proxy admin OR team admin OR org admin for this team. " + f"route='/team/bulk_member_delete', team_id={data.team_id}", + ) + + removal: Final = await _remove_members_from_team(prisma_client, data.team_id, data.members, user_api_key_dict) + _emit_team_members_metric(removal.team) + + results: Final = tuple( + TeamMemberDeleteResult( + user_id=member.user_id, + user_email=member.user_email, + success=i in removal.matched, + error=None if i in removal.matched else "User not found in team", + ) + for i, member in enumerate(data.members) + ) + successful: Final = sum(1 for r in results if r.success) + return BulkTeamMemberDeleteResponse( + team_id=data.team_id, + results=results, + total_requested=len(results), + successful_deletions=successful, + failed_deletions=len(results) - successful, + ) + + +async def _caller_admin_org_ids(prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]: + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value or not user_api_key_dict.user_id: + return frozenset() + where: Final[_OrgAdminFilter] = { + "user_id": user_api_key_dict.user_id, + "user_role": LitellmUserRoles.ORG_ADMIN.value, + } + memberships: Final = await OrganizationMembershipRepository(prisma_client).table.find_many(where=where) + return frozenset(m.organization_id for m in memberships if m.organization_id) + + +def _scope_error(user_id: str, target_org_ids: frozenset[str], caller_admin_org_ids: frozenset[str]) -> str | None: + if target_org_ids and target_org_ids <= caller_admin_org_ids: + return None + return ( + f"User {user_id} is not within your admin scope. " + "Only PROXY_ADMIN may delete users outside your administered organizations." + ) + + +async def _delete_user_rows( + prisma_client: PrismaClient, + users: Sequence["prisma_models.LiteLLM_UserTable"], + user_api_key_dict: UserAPIKeyAuth, + litellm_proxy_admin_name: str | None, + litellm_changed_by: str | None, +) -> None: + user_ids: Final = frozenset(u.user_id for u in users) + await _bounded( + UserManagementEventHooks.create_internal_user_audit_log( + user_id=u.user_id, + action="deleted", + litellm_changed_by=litellm_changed_by, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + before_value=u.model_dump_json(exclude_none=True), + ) + for u in users + ) + keys: Final = await VerificationTokenRepository(prisma_client).table.find_many( + where=_in_filter("user_id", user_ids) + ) + if keys: + await _persist_deleted_verification_tokens( + keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) + await VerificationTokenRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) + await InvitationLinkRepository(prisma_client).table.delete_many( + where=_any_filter( + _in_filter("user_id", user_ids), + _in_filter("created_by", user_ids), + _in_filter("updated_by", user_ids), + ) + ) + await OrganizationMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) + await TeamMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) + await UserRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) + + +async def bulk_delete_users( + data: BulkDeleteUserRequest, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + litellm_proxy_admin_name: str | None, + litellm_changed_by: str | None, +) -> BulkDeleteUserResponse: + caller_is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + caller_admin_org_ids: Final = await _caller_admin_org_ids(prisma_client, user_api_key_dict) + if not caller_is_proxy_admin and not caller_admin_org_ids: + raise _http_error(403, "Only PROXY_ADMIN or ORG_ADMIN users may delete users.") + + unique_ids: Final = frozenset(data.user_ids) + rows: Final = await UserRepository(prisma_client).table.find_many(where=_in_filter("user_id", unique_ids)) + rows_by_id: Final = MappingProxyType({row.user_id: row for row in rows}) + target_memberships: Final = ( + () + if caller_is_proxy_admin + else await OrganizationMembershipRepository(prisma_client).table.find_many( + where=_in_filter("user_id", unique_ids) + ) + ) + + def precheck_error(user_id: str) -> str | None: + if user_id not in rows_by_id: + return f"User id={user_id} not found" + if caller_is_proxy_admin: + return None + org_ids: Final = frozenset( + m.organization_id for m in target_memberships if m.user_id == user_id and m.organization_id + ) + return _scope_error(user_id, org_ids, caller_admin_org_ids) + + precheck_errors: Final = MappingProxyType({uid: precheck_error(uid) for uid in unique_ids}) + candidates: Final = tuple(rows_by_id[uid] for uid in sorted(unique_ids) if precheck_errors[uid] is None) + candidate_ids: Final = frozenset(u.user_id for u in candidates) + + memberships: Final = await TeamMembershipRepository(prisma_client).table.find_many( + where=_in_filter("user_id", candidate_ids) + ) + teams_of: Final = MappingProxyType( + { + u.user_id: frozenset(u.teams) | frozenset(m.team_id for m in memberships if m.user_id == u.user_id) + for u in candidates + } + ) + team_ids: Final = tuple(sorted(frozenset(t for u in candidates for t in teams_of[u.user_id]))) + members_by_team: Final = MappingProxyType( + { + tid: tuple( + MemberDeleteRequest(user_id=u.user_id, user_email=u.user_email) + for u in candidates + if tid in teams_of[u.user_id] + ) + for tid in team_ids + } + ) + outcomes: Final = await _bounded( + _remove_members_from_team(prisma_client, tid, members_by_team[tid], user_api_key_dict) for tid in team_ids + ) + removals: Final = MappingProxyType( + {tid: o for tid, o in zip(team_ids, outcomes, strict=True) if isinstance(o, _TeamRemoval)} + ) + team_failures: Final = MappingProxyType( + {tid: _error_message(o) for tid, o in zip(team_ids, outcomes, strict=True) if isinstance(o, BaseException)} + ) + for tid, err in team_failures.items(): + verbose_proxy_logger.error("/user/bulk_delete: failed to remove users from team %s: %s", tid, err) + for removal in removals.values(): + _emit_team_members_metric(removal.team) + + def team_errors(user_id: str) -> tuple[str, ...]: + return tuple( + f"Failed to remove from team {tid}: {err}" for tid, err in team_failures.items() if tid in teams_of[user_id] + ) + + deletable: Final = tuple(u for u in candidates if not team_errors(u.user_id)) + if deletable: + await _delete_user_rows( + prisma_client, deletable, user_api_key_dict, litellm_proxy_admin_name, litellm_changed_by + ) + + def result(index: int, user_id: str) -> UserDeleteResult: + if user_id in data.user_ids[:index]: + return UserDeleteResult(user_id=user_id, success=False, error=f"Duplicate user_id in request: {user_id}") + error: Final = precheck_errors[user_id] + if error is not None: + return UserDeleteResult(user_id=user_id, success=False, error=error) + errors: Final = team_errors(user_id) + return UserDeleteResult( + user_id=user_id, + user_email=rows_by_id[user_id].user_email, + success=not errors, + teams_removed=tuple(tid for tid in team_ids if tid in removals and user_id in removals[tid].removed), + error="; ".join(errors) or None, + ) + + results: Final = tuple(result(i, uid) for i, uid in enumerate(data.user_ids)) + successful: Final = sum(1 for r in results if r.success) + return BulkDeleteUserResponse( + results=results, + total_requested=len(results), + successful_deletions=successful, + failed_deletions=len(results) - successful, + ) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index 6973f1d1f12..7d9f2bd2a85 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from typing import Any, Final, Literal -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import ( @@ -10,6 +10,8 @@ from litellm.proxy._types import ( UpdateUserRequestNoUserIDorEmail, ) +MAX_BULK_DELETE_USERS: Final = 500 + class InsensitiveContains(TypedDict): contains: ReadOnly[str] @@ -83,3 +85,24 @@ class BulkUpdateUserResponse(BaseModel): total_requested: int successful_updates: int failed_updates: int + + +class BulkDeleteUserRequest(BaseModel): + user_ids: tuple[str, ...] = Field(min_length=1, max_length=MAX_BULK_DELETE_USERS) + + +class UserDeleteResult(BaseModel): + """Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of.""" + + user_id: str + user_email: str | None = None + success: bool + teams_removed: tuple[str, ...] = () + error: str | None = None + + +class BulkDeleteUserResponse(BaseModel): + results: tuple[UserDeleteResult, ...] + total_requested: int + successful_deletions: int + failed_deletions: int diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index a282430bb11..6952de9c1cc 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field @@ -8,10 +8,13 @@ from litellm.proxy._types import ( LiteLLM_TeamMembership, LiteLLM_TeamTable, Member, + MemberDeleteRequest, ) TeamIdSearchMatch = Literal["exact", "prefix"] +MAX_BULK_TEAM_MEMBER_DELETES: Final = 500 + class GetTeamMemberPermissionsRequest(BaseModel): """Request to get the team member permissions for a team""" @@ -118,6 +121,28 @@ class BulkTeamMemberAddResponse(BaseModel): updated_team: dict[str, Any] | None = None +class BulkTeamMemberDeleteRequest(BaseModel): + team_id: str + members: tuple[MemberDeleteRequest, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES) + + +class TeamMemberDeleteResult(BaseModel): + """Outcome for one row of `/team/bulk_member_delete`.""" + + user_id: str | None = None + user_email: str | None = None + success: bool + error: str | None = None + + +class BulkTeamMemberDeleteResponse(BaseModel): + team_id: str + results: tuple[TeamMemberDeleteResult, ...] + total_requested: int + successful_deletions: int + failed_deletions: int + + class TeamMemberInfoResponse(LiteLLM_TeamMembership): """Response for GET /team/{team_id}/members/me — caller's own membership row.""" diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 6bc8947e89f..53a6bd4f8c6 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -80,10 +80,12 @@ POST /model/unblock POST /prompts/test POST /search_tools/test_connection POST /team/bulk_member_add +POST /team/bulk_member_delete POST /team/{team_id}/member/{user_id}/reset_spend POST /team/key/bulk_update POST /team/permissions_bulk_update POST /team/{team_id}/disable_logging +POST /user/bulk_delete POST /user/bulk_update # Alternate method or path for functionality the provider already manages elsewhere diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py new file mode 100644 index 00000000000..91ce7e56a40 --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -0,0 +1,434 @@ +import json +from collections.abc import Callable, Mapping, Sequence +from contextlib import asynccontextmanager +from typing import Final + +import pytest +from fastapi import HTTPException +from pydantic import BaseModel, ConfigDict, ValidationError + +from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, MemberDeleteRequest, UserAPIKeyAuth +from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users, bulk_remove_team_members +from litellm.types.proxy.management_endpoints.internal_user_endpoints import BulkDeleteUserRequest +from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest + +ADMIN: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin") +INTERNAL: Final = UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER) +ORG_ADMIN: Final = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) + + +class _UserRow(BaseModel): + model_config = ConfigDict(extra="allow") + + user_id: str + user_email: str | None = None + teams: list[str] = [] + + +class _Record(BaseModel): + """Attribute access like a Prisma row, over whatever columns the test seeded.""" + + model_config = ConfigDict(extra="allow") + + +def _in(where: Mapping[str, object], field: str) -> set[str] | None: + clause = where.get(field) + if isinstance(clause, dict) and "in" in clause: + return set(clause["in"]) + if isinstance(clause, str): + return {clause} + return None + + +def _matches(row: Mapping[str, object], where: Mapping[str, object]) -> bool: + if "OR" in where: + return any(_matches(row, clause) for clause in where["OR"]) + return all((wanted := _in(where, field)) is not None and row.get(field) in wanted for field in where) + + +class _Rows: + """A list-backed Prisma table supporting the `in`/equality/OR filters the helper issues.""" + + def __init__(self, rows: Sequence[Mapping[str, object]] = ()) -> None: + self.rows: list[dict[str, object]] = [dict(r) for r in rows] + + async def find_many(self, where: Mapping[str, object]) -> list[_Record]: + return [_Record.model_validate(r) for r in self.rows if _matches(r, where)] + + async def delete_many(self, where: Mapping[str, object]) -> int: + before = len(self.rows) + self.rows = [r for r in self.rows if not _matches(r, where)] + return before - len(self.rows) + + async def create_many(self, data: Sequence[Mapping[str, object]]) -> int: + self.rows.extend(dict(r) for r in data) + return len(data) + + +class _UserTable: + def __init__(self, users: Sequence[_UserRow]) -> None: + self.rows: dict[str, _UserRow] = {u.user_id: u for u in users} + + async def find_many(self, where: Mapping[str, object]) -> list[_UserRow]: + return [u for u in self.rows.values() if _matches(u.model_dump(), where)] + + async def update(self, where: Mapping[str, str], data: Mapping[str, Mapping[str, Sequence[str]]]) -> _UserRow: + row = self.rows[where["user_id"]] + updated = row.model_copy(update={"teams": list(data["teams"]["set"])}) + self.rows[row.user_id] = updated + return updated + + async def delete_many(self, where: Mapping[str, object]) -> int: + doomed = [uid for uid, u in self.rows.items() if _matches(u.model_dump(), where)] + for uid in doomed: + del self.rows[uid] + return len(doomed) + + +class _TeamTable: + def __init__(self, teams: Sequence[LiteLLM_TeamTable]) -> None: + self.rows: dict[str, LiteLLM_TeamTable] = {t.team_id: t for t in teams} + self.update_calls = 0 + + async def find_unique(self, where: Mapping[str, str]) -> LiteLLM_TeamTable | None: + return self.rows.get(where["team_id"]) + + async def update(self, where: Mapping[str, str], data: Mapping[str, str]) -> LiteLLM_TeamTable: + self.update_calls += 1 + team = self.rows[where["team_id"]] + team.members_with_roles = [Member(**m) for m in json.loads(data["members_with_roles"])] + return team + + +class _Db: + def __init__( + self, + users: Sequence[_UserRow], + teams: Sequence[LiteLLM_TeamTable], + memberships: Sequence[tuple[str, str]] = (), + tokens: Sequence[Mapping[str, object]] = (), + invitations: Sequence[Mapping[str, object]] = (), + org_memberships: Sequence[Mapping[str, object]] = (), + ) -> None: + self.litellm_usertable = _UserTable(users) + self.litellm_teamtable = _TeamTable(teams) + self.litellm_teammembership = _Rows([{"team_id": t, "user_id": u} for t, u in memberships]) + self.litellm_verificationtoken = _Rows(tokens) + self.litellm_deletedverificationtoken = _Rows() + self.litellm_invitationlink = _Rows(invitations) + self.litellm_organizationmembership = _Rows(org_memberships) + + +class _Tx: + def __init__(self, db: _Db, on_lock: Callable[[str], None], fail_locks: frozenset[str]) -> None: + self.litellm_teamtable = db.litellm_teamtable + self.litellm_usertable = db.litellm_usertable + self.litellm_teammembership = db.litellm_teammembership + self.litellm_verificationtoken = db.litellm_verificationtoken + self.litellm_deletedverificationtoken = db.litellm_deletedverificationtoken + self._on_lock = on_lock + self._fail_locks = fail_locks + self.locks: list[str] = [] + self.roster_reads: list[str] = [] + + async def query_raw(self, sql: str, *args: object) -> list[dict[str, object]]: + team_id = str(args[0]) + if "pg_advisory_xact_lock" in sql: + if team_id in self._fail_locks: + raise RuntimeError("lock timeout") + self.locks.append(team_id) + self._on_lock(team_id) + return [] + assert self.locks == [team_id], "roster must be read under this team's advisory lock" + self.roster_reads.append(team_id) + team = self.litellm_teamtable.rows.get(team_id) + if team is None: + return [] + return [{"members_with_roles": json.dumps([m.model_dump() for m in team.members_with_roles])}] + + +class _FakePrisma: + def __init__( + self, + users: Sequence[_UserRow] = (), + teams: Sequence[LiteLLM_TeamTable] = (), + memberships: Sequence[tuple[str, str]] = (), + tokens: Sequence[Mapping[str, object]] = (), + invitations: Sequence[Mapping[str, object]] = (), + org_memberships: Sequence[Mapping[str, object]] = (), + on_lock: Callable[[str], None] = lambda _: None, + fail_locks: frozenset[str] = frozenset(), + ) -> None: + self.db = _Db(users, teams, memberships, tokens, invitations, org_memberships) + self._on_lock = on_lock + self._fail_locks = fail_locks + self.locks: list[str] = [] + self.roster_reads: list[str] = [] + + @asynccontextmanager + async def tx(self): + tx = _Tx(self.db, self._on_lock, self._fail_locks) + yield tx + self.locks.extend(tx.locks) + self.roster_reads.extend(tx.roster_reads) + + +def _team(team_id: str, *members: str, org: str | None = None) -> LiteLLM_TeamTable: + return LiteLLM_TeamTable( + team_id=team_id, + organization_id=org, + members_with_roles=[Member(user_id=m, user_email=f"{m}@example.com", role="user") for m in members], + ) + + +def _user(user_id: str, *teams: str) -> _UserRow: + return _UserRow(user_id=user_id, user_email=f"{user_id}@example.com", teams=list(teams)) + + +def _roster(prisma: _FakePrisma, team_id: str) -> list[str | None]: + return [m.user_id for m in prisma.db.litellm_teamtable.rows[team_id].members_with_roles] + + +async def _delete(prisma: _FakePrisma, user_ids: Sequence[str], caller: UserAPIKeyAuth = ADMIN): + return await bulk_delete_users( + data=BulkDeleteUserRequest(user_ids=tuple(user_ids)), + user_api_key_dict=caller, + prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient + litellm_proxy_admin_name="default_user_id", + litellm_changed_by=None, + ) + + +async def _remove(prisma: _FakePrisma, team_id: str, members: Sequence[Mapping[str, str]], caller=ADMIN): + return await bulk_remove_team_members( + data=BulkTeamMemberDeleteRequest(team_id=team_id, members=tuple(MemberDeleteRequest(**m) for m in members)), + user_api_key_dict=caller, + prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient + ) + + +@pytest.mark.asyncio +async def test_bulk_delete_removes_users_from_every_team_and_store(): + prisma = _FakePrisma( + users=[_user("u1", "t1", "t2"), _user("u2", "t1"), _user("keep", "t1")], + teams=[_team("t1", "u1", "u2", "keep"), _team("t2", "u1", "other")], + memberships=[("t1", "u1"), ("t2", "u1"), ("t1", "u2"), ("t1", "keep")], + tokens=[{"token": "k1", "user_id": "u1", "team_id": "t1"}, {"token": "k2", "user_id": "keep"}], + invitations=[ + {"id": "i1", "user_id": "u2", "created_by": "admin", "updated_by": "admin"}, + {"id": "i2", "user_id": "keep", "created_by": "u1", "updated_by": "admin"}, + {"id": "i3", "user_id": "keep", "created_by": "admin", "updated_by": "admin"}, + ], + org_memberships=[{"user_id": "u1", "organization_id": "o1", "user_role": "internal_user"}], + ) + + response = await _delete(prisma, ["u1", "u2"]) + + assert (response.total_requested, response.successful_deletions, response.failed_deletions) == (2, 2, 0) + assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in response.results] == [ + ("u1", "u1@example.com", True, ("t1", "t2")), + ("u2", "u2@example.com", True, ("t1",)), + ] + assert _roster(prisma, "t1") == ["keep"] and _roster(prisma, "t2") == ["other"] + assert set(prisma.db.litellm_usertable.rows) == {"keep"} + assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "keep"}] + assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k2"] + assert [t["token"] for t in prisma.db.litellm_deletedverificationtoken.rows] == ["k1"] + assert [i["id"] for i in prisma.db.litellm_invitationlink.rows] == ["i3"] + assert prisma.db.litellm_organizationmembership.rows == [] + assert sorted(prisma.locks) == ["t1", "t2"] and sorted(prisma.roster_reads) == ["t1", "t2"] + + +@pytest.mark.asyncio +async def test_bulk_delete_finds_teams_through_membership_rows_when_user_teams_array_is_stale(): + prisma = _FakePrisma( + users=[_user("u1")], + teams=[_team("t1", "u1", "keep")], + memberships=[("t1", "u1")], + ) + + response = await _delete(prisma, ["u1"]) + + assert response.results[0].teams_removed == ("t1",) + assert _roster(prisma, "t1") == ["keep"] + assert prisma.db.litellm_teammembership.rows == [] + + +@pytest.mark.asyncio +async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives(): + team = _team("t1", "u1") + + def concurrent_member_add(team_id: str) -> None: + team.members_with_roles.append(Member(user_id="late", role="user")) + + prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[team], on_lock=concurrent_member_add) + + response = await _delete(prisma, ["u1"]) + + assert response.results[0].success is True + assert _roster(prisma, "t1") == ["late"] + + +@pytest.mark.asyncio +async def test_bulk_delete_reports_missing_and_duplicate_ids_per_item_and_still_deletes_the_rest(): + prisma = _FakePrisma(users=[_user("u1")]) + + response = await _delete(prisma, ["u1", "ghost", "u1"]) + + assert (response.successful_deletions, response.failed_deletions) == (1, 2) + assert [(r.user_id, r.success, r.error) for r in response.results] == [ + ("u1", True, None), + ("ghost", False, "User id=ghost not found"), + ("u1", False, "Duplicate user_id in request: u1"), + ] + assert prisma.db.litellm_usertable.rows == {} + + +@pytest.mark.asyncio +async def test_bulk_delete_keeps_user_when_a_team_rewrite_fails_and_deletes_the_others(): + prisma = _FakePrisma( + users=[_user("u1", "bad", "good"), _user("u2", "good")], + teams=[_team("bad", "u1"), _team("good", "u1", "u2")], + fail_locks=frozenset({"bad"}), + ) + + response = await _delete(prisma, ["u1", "u2"]) + + assert [(r.user_id, r.success, r.teams_removed) for r in response.results] == [ + ("u1", False, ("good",)), + ("u2", True, ("good",)), + ] + assert response.results[0].error == "Failed to remove from team bad: lock timeout" + assert set(prisma.db.litellm_usertable.rows) == {"u1"} + assert _roster(prisma, "bad") == ["u1"] and _roster(prisma, "good") == [] + + +@pytest.mark.asyncio +async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db(): + prisma = _FakePrisma(users=[_user("u1")]) + + with pytest.raises(HTTPException) as exc: + await _delete(prisma, ["u1"], caller=INTERNAL) + + assert exc.value.status_code == 403 + assert set(prisma.db.litellm_usertable.rows) == {"u1"} + + +@pytest.mark.asyncio +async def test_org_admin_deletes_only_users_fully_inside_their_orgs(): + prisma = _FakePrisma( + users=[_user("inside"), _user("straddles"), _user("orgless")], + org_memberships=[ + {"user_id": "org-admin", "organization_id": "o1", "user_role": LitellmUserRoles.ORG_ADMIN.value}, + {"user_id": "inside", "organization_id": "o1", "user_role": "internal_user"}, + {"user_id": "straddles", "organization_id": "o1", "user_role": "internal_user"}, + {"user_id": "straddles", "organization_id": "o2", "user_role": "internal_user"}, + ], + ) + + response = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN) + + assert [r.success for r in response.results] == [True, False, False] + assert all("not within your admin scope" in (r.error or "") for r in response.results[1:]) + assert set(prisma.db.litellm_usertable.rows) == {"straddles", "orgless"} + assert {(m["user_id"], m["organization_id"]) for m in prisma.db.litellm_organizationmembership.rows} == { + ("org-admin", "o1"), + ("straddles", "o1"), + ("straddles", "o2"), + } + + +@pytest.mark.asyncio +async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest(): + prisma = _FakePrisma( + users=[_user("u1", "t1", "t2"), _user("u2", "t1"), _user("keep", "t1")], + teams=[_team("t1", "u1", "u2", "keep")], + memberships=[("t1", "u1"), ("t1", "u2"), ("t1", "keep")], + tokens=[ + {"token": "team-key", "user_id": "u1", "team_id": "t1"}, + {"token": "other-team-key", "user_id": "u1", "team_id": "t2"}, + {"token": "keep-key", "user_id": "keep", "team_id": "t1"}, + ], + ) + + response = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}]) + + assert (response.team_id, response.successful_deletions, response.failed_deletions) == ("t1", 2, 0) + assert [(r.user_id, r.user_email, r.success) for r in response.results] == [ + ("u1", None, True), + (None, "u2@example.com", True), + ] + assert _roster(prisma, "t1") == ["keep"] + users = prisma.db.litellm_usertable.rows + assert users["u1"].teams == ["t2"] and users["u2"].teams == [] and users["keep"].teams == ["t1"] + assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "keep"}] + assert sorted(t["token"] for t in prisma.db.litellm_verificationtoken.rows) == ["keep-key", "other-team-key"] + assert [t["token"] for t in prisma.db.litellm_deletedverificationtoken.rows] == ["team-key"] + assert prisma.locks == ["t1"] and prisma.roster_reads == ["t1"] + + +@pytest.mark.asyncio +async def test_bulk_member_delete_reports_members_not_on_the_team_without_rewriting_the_roster(): + prisma = _FakePrisma(users=[_user("u1", "t1"), _user("elsewhere")], teams=[_team("t1", "u1")]) + + response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}]) + + assert [(r.success, r.error) for r in response.results] == [ + (False, "User not found in team"), + (False, "User not found in team"), + ] + assert (response.successful_deletions, response.failed_deletions) == (0, 2) + assert prisma.db.litellm_teamtable.update_calls == 0 + assert _roster(prisma, "t1") == ["u1"] + + +@pytest.mark.asyncio +async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_the_team(): + prisma = _FakePrisma(users=[_user("stale", "t1")], teams=[_team("t1", "other")], memberships=[("t1", "stale")]) + + response = await _remove(prisma, "t1", [{"user_id": "stale"}]) + + assert response.results[0].success is True + assert prisma.db.litellm_usertable.rows["stale"].teams == [] + assert prisma.db.litellm_teammembership.rows == [] + assert _roster(prisma, "t1") == ["other"] and prisma.db.litellm_teamtable.update_calls == 0 + + +@pytest.mark.asyncio +async def test_bulk_member_delete_rejects_unknown_team_and_unauthorized_callers(): + prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[_team("t1", "u1")]) + + with pytest.raises(HTTPException) as missing: + await _remove(prisma, "nope", [{"user_id": "u1"}]) + with pytest.raises(HTTPException) as forbidden: + await _remove(prisma, "t1", [{"user_id": "u1"}], caller=INTERNAL) + + assert missing.value.status_code == 400 + assert forbidden.value.status_code == 403 + assert _roster(prisma, "t1") == ["u1"] and prisma.locks == [] + + +@pytest.mark.asyncio +async def test_team_admin_may_bulk_remove_members(): + team = _team("t1", "lead", "u1") + team.members_with_roles[0].role = "admin" + prisma = _FakePrisma(users=[_user("lead", "t1"), _user("u1", "t1")], teams=[team]) + + response = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead")) + + assert response.results[0].success is True + assert _roster(prisma, "t1") == ["lead"] + + +def test_request_models_enforce_batch_bounds(): + with pytest.raises(ValidationError): + BulkDeleteUserRequest(user_ids=()) + with pytest.raises(ValidationError): + BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(501))) + with pytest.raises(ValidationError): + BulkTeamMemberDeleteRequest(team_id="t1", members=()) + with pytest.raises(ValidationError): + BulkTeamMemberDeleteRequest( + team_id="t1", members=tuple(MemberDeleteRequest(user_id=f"u{i}") for i in range(501)) + ) + assert len(BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(500))).user_ids) == 500 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7eadaa6c991..14aa5b081bd 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15056,6 +15056,46 @@ export interface paths { patch?: never; trace?: never; }; + "/team/bulk_member_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Team Member Delete + * @description Remove up to 500 members from one team in a single request. + * + * Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's + * organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under + * the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and + * their team-scoped keys are all cleaned up together. Members that are not on the team are reported in + * `results` with `success: false` and the rest are still removed. + * + * Example request: + * ```bash + * curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \ + * --header 'Authorization: Bearer sk-1234' \ + * --header 'Content-Type: application/json' \ + * --data '{ + * "team_id": "team-1234", + * "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}] + * }' + * ``` + * + * Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`, + * `success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. + */ + post: operations["bulk_team_member_delete_team_bulk_member_delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/team/daily/activity": { parameters: { query?: never; @@ -16478,6 +16518,46 @@ export interface paths { patch?: never; trace?: never; }; + "/user/bulk_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Delete User + * @description Delete up to 500 internal users in one request and remove each one from every team they belong to. + * + * Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose + * organizations they all administer. Each team a deleted user was on is rewritten once under the team + * lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards. + * Then the users' keys, invitation links, organization memberships and user rows are deleted. + * + * Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with + * `success: false` and an `error`, and the other users are still deleted. + * + * Usage Example + * + * ```shell + * curl -X POST "http://localhost:4000/user/bulk_delete" \ + * -H "Content-Type: application/json" \ + * -H "Authorization: Bearer sk-1234" \ + * -d '{"user_ids": ["user-1", "user-2"]}' + * ``` + * + * Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`, + * `teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. + */ + post: operations["bulk_delete_user_user_bulk_delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/user/bulk_update": { parameters: { query?: never; @@ -16781,7 +16861,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16887,7 +16966,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -24505,6 +24583,22 @@ export interface components { /** Budgets */ budgets: string[]; }; + /** BulkDeleteUserRequest */ + BulkDeleteUserRequest: { + /** User Ids */ + user_ids: string[]; + }; + /** BulkDeleteUserResponse */ + BulkDeleteUserResponse: { + /** Failed Deletions */ + failed_deletions: number; + /** Results */ + results: components["schemas"]["UserDeleteResult"][]; + /** Successful Deletions */ + successful_deletions: number; + /** Total Requested */ + total_requested: number; + }; /** * BulkTeamMemberAddRequest * @description Request for bulk team member addition @@ -24542,6 +24636,26 @@ export interface components { [key: string]: unknown; } | null; }; + /** BulkTeamMemberDeleteRequest */ + BulkTeamMemberDeleteRequest: { + /** Members */ + members: components["schemas"]["MemberDeleteRequest"][]; + /** Team Id */ + team_id: string; + }; + /** BulkTeamMemberDeleteResponse */ + BulkTeamMemberDeleteResponse: { + /** Failed Deletions */ + failed_deletions: number; + /** Results */ + results: components["schemas"]["TeamMemberDeleteResult"][]; + /** Successful Deletions */ + successful_deletions: number; + /** Team Id */ + team_id: string; + /** Total Requested */ + total_requested: number; + }; /** * BulkUpdateKeyRequest * @description Request for bulk key updates @@ -31903,6 +32017,13 @@ export interface components { */ user_id?: string | null; }; + /** MemberDeleteRequest */ + MemberDeleteRequest: { + /** User Email */ + user_email?: string | null; + /** User Id */ + user_id?: string | null; + }; /** MemoryCreateRequest */ MemoryCreateRequest: { /** @@ -37166,6 +37287,20 @@ export interface components { /** User Id */ user_id?: string | null; }; + /** + * TeamMemberDeleteResult + * @description Outcome for one row of `/team/bulk_member_delete`. + */ + TeamMemberDeleteResult: { + /** Error */ + error?: string | null; + /** Success */ + success: boolean; + /** User Email */ + user_email?: string | null; + /** User Id */ + user_id?: string | null; + }; /** * TeamMemberInfoResponse * @description Response for GET /team/{team_id}/members/me — caller's own membership row. @@ -39345,6 +39480,25 @@ export interface components { */ severity: "info" | "warning" | "error"; }; + /** + * UserDeleteResult + * @description Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of. + */ + UserDeleteResult: { + /** Error */ + error?: string | null; + /** Success */ + success: boolean; + /** + * Teams Removed + * @default [] + */ + teams_removed: string[]; + /** User Email */ + user_email?: string | null; + /** User Id */ + user_id: string; + }; /** * UserHeaderMapping * @description Map an incoming HTTP header to a LiteLLM user role. @@ -58887,6 +59041,39 @@ export interface operations { }; }; }; + bulk_team_member_delete_team_bulk_member_delete_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkTeamMemberDeleteRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkTeamMemberDeleteResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_team_daily_activity_team_daily_activity_get: { parameters: { query?: { @@ -60631,6 +60818,42 @@ export interface operations { }; }; }; + bulk_delete_user_user_bulk_delete_post: { + parameters: { + query?: never; + header?: { + /** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */ + "litellm-changed-by"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkDeleteUserRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkDeleteUserResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; bulk_user_update_user_bulk_update_post: { parameters: { query?: never; From beaa96fc8f849262dd16ada53c272bb514c636be Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 04:57:27 +0000 Subject: [PATCH 08/25] fix(proxy): log audit log failures in /user/bulk_delete instead of dropping them Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/management_helpers/bulk_user_deletion.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index a2d3348a30e..f2ac8259fa5 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -301,7 +301,7 @@ async def _delete_user_rows( litellm_changed_by: str | None, ) -> None: user_ids: Final = frozenset(u.user_id for u in users) - await _bounded( + audit_outcomes: Final = await _bounded( UserManagementEventHooks.create_internal_user_audit_log( user_id=u.user_id, action="deleted", @@ -312,6 +312,9 @@ async def _delete_user_rows( ) 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) keys: Final = await VerificationTokenRepository(prisma_client).table.find_many( where=_in_filter("user_id", user_ids) ) From 595aba3cb0d6a5b3815d4ab51811a33c50fc8aa5 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 05:12:34 +0000 Subject: [PATCH 09/25] fix(proxy): evict deleted keys from the auth cache and make bulk user deletion transactional /user/bulk_delete now deletes the users' keys, invitation links, org and team memberships and user rows in one transaction and reports a rolled-back batch per row instead of leaving partial deletes behind. Both bulk endpoints evict the deleted keys (and deleted user objects) from the auth cache, so a deleted key stops authenticating immediately rather than at TTL expiry. /team/bulk_member_delete rejects member rows that carry both user_id and user_email, reports repeated rows as duplicates, and only cleans up keys and memberships of members it actually matched. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal_user_endpoints.py | 33 ++-- .../management_endpoints/team_endpoints.py | 27 +--- .../management_helpers/bulk_user_deletion.py | 150 ++++++++++++++---- .../management_endpoints/team_endpoints.py | 9 +- .../test_bulk_user_deletion.py | 150 +++++++++++++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 47 ++---- 6 files changed, 303 insertions(+), 113 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index a2e084f265a..6b11847f09d 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2516,29 +2516,20 @@ async def bulk_delete_user( ), ) -> BulkDeleteUserResponse: """ - Delete up to 500 internal users in one request and remove each one from every team they belong to. + Delete up to 500 users and remove each one from every team they belong to. Same authorization as + `/user/delete`. Returns one result per user id, in order. - Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose - organizations they all administer. Each team a deleted user was on is rewritten once under the team - lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards. - Then the users' keys, invitation links, organization memberships and user rows are deleted. - - Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with - `success: false` and an `error`, and the other users are still deleted. - - Usage Example - - ```shell - curl -X POST "http://localhost:4000/user/bulk_delete" \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer sk-1234" \\ - -d '{"user_ids": ["user-1", "user-2"]}' + ```bash + curl -X POST 'http://localhost:4000/user/bulk_delete' -H 'Authorization: Bearer sk-1234' \\ + -H 'Content-Type: application/json' -d '{"user_ids": ["user-1", "user-2"]}' ``` - - Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`, - `teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. """ - from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client + from litellm.proxy.proxy_server import ( + litellm_proxy_admin_name, + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) @@ -2547,6 +2538,8 @@ async def bulk_delete_user( 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, ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 91e803d6f29..8626c073e55 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3465,30 +3465,17 @@ async def bulk_team_member_delete( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection ) -> BulkTeamMemberDeleteResponse: """ - Remove up to 500 members from one team in a single request. + Remove up to 500 members (each named by `user_id` or `user_email`) from one team. Same authorization + as `/team/member_delete`. Returns one result per member, in order. - Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's - organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under - the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and - their team-scoped keys are all cleaned up together. Members that are not on the team are reported in - `results` with `success: false` and the rest are still removed. - - Example request: ```bash - curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \\ - --header 'Authorization: Bearer sk-1234' \\ - --header 'Content-Type: application/json' \\ - --data '{ - "team_id": "team-1234", - "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}] - }' + curl -X POST 'http://0.0.0.0:4000/team/bulk_member_delete' -H 'Authorization: Bearer sk-1234' \\ + -H 'Content-Type: application/json' \\ + -d '{"team_id": "team-1234", "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]}' ``` - - Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`, - `success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. """ from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache if prisma_client is None: raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) @@ -3497,6 +3484,8 @@ async def bulk_team_member_delete( data=data, user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, ) except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract verbose_proxy_logger.exception("/team/bulk_member_delete: Exception occured") diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index f2ac8259fa5..4d3387aacb7 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -24,6 +24,9 @@ from litellm.proxy._types import ( 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.management_endpoints.common_utils import ( _is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses @@ -33,15 +36,13 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( _persist_deleted_verification_tokens, # pyright: ignore[reportPrivateUsage] # same audit path /key/delete uses ) from litellm.proxy.management_helpers.access_group_team_sync import TEAM_ADVISORY_LOCK_SQL -from litellm.proxy.utils import PrismaClient +from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.repositories.table_repositories import ( - InvitationLinkRepository, OrganizationMembershipRepository, TeamMembershipRepository, ) from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository -from litellm.repositories.verification_token_repository import VerificationTokenRepository from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkDeleteUserRequest, BulkDeleteUserResponse, @@ -91,6 +92,7 @@ class _TeamRemoval: team: LiteLLM_TeamTable removed: frozenset[str] matched: frozenset[int] + deleted_key_tokens: tuple[str, ...] def _http_error(status_code: int, message: str) -> HTTPException: @@ -130,6 +132,14 @@ def _token_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_Verificati 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 _addresses_member(member: Member, request: MemberDeleteRequest) -> bool: return (request.user_id is not None and request.user_id == member.user_id) or ( request.user_email is not None and request.user_email == member.user_email @@ -184,7 +194,7 @@ async def _remove_members_from_team( ) ) stale_rows: Final = tuple(u for u in user_rows if team_id in u.teams) - cleanup_ids: Final = removed_ids | requested_ids | frozenset(u.user_id for u in stale_rows) + cleanup_ids: Final = removed_ids | frozenset(u.user_id for u in stale_rows) matched: Final = frozenset( i for i, r in enumerate(members) @@ -216,8 +226,9 @@ async def _remove_members_from_team( team_id=team_id, members_with_roles=kept_members, # pyright: ignore[reportArgumentType] # pydantic coerces the tuple into the list field ), - removed=removed_ids | frozenset(u.user_id for u in stale_rows), + removed=cleanup_ids, matched=matched, + deleted_key_tokens=tuple(k.token for k in keys), ) @@ -231,10 +242,24 @@ def _emit_team_members_metric(team: LiteLLM_TeamTable) -> None: 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( data: BulkTeamMemberDeleteRequest, user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, + proxy_logging_obj: ProxyLogging | None, ) -> BulkTeamMemberDeleteResponse: team: Final = await TeamRepository(prisma_client).find_by_id(data.team_id) if team is None: @@ -251,15 +276,30 @@ async def bulk_remove_team_members( f"route='/team/bulk_member_delete', team_id={data.team_id}", ) - removal: Final = await _remove_members_from_team(prisma_client, data.team_id, data.members, user_api_key_dict) + 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) + removal: Final = await _remove_members_from_team(prisma_client, data.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" + results: Final = tuple( TeamMemberDeleteResult( user_id=member.user_id, user_email=member.user_email, - success=i in removal.matched, - error=None if i in removal.matched else "User not found in team", + success=i in matched, + error=error(i), ) for i, member in enumerate(data.members) ) @@ -293,14 +333,60 @@ def _scope_error(user_id: str, target_org_ids: frozenset[str], caller_admin_org_ ) +async def _delete_user_rows_tx( + prisma_client: PrismaClient, + user_ids: frozenset[str], + user_api_key_dict: UserAPIKeyAuth, + litellm_changed_by: str | None, +) -> tuple[str, ...]: + async with prisma_client.tx() as tx: + 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_user_rows( prisma_client: PrismaClient, users: Sequence["prisma_models.LiteLLM_UserTable"], 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, -) -> None: +) -> str | None: + """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: + deleted_key_tokens: Final = await _delete_user_rows_tx( + prisma_client, user_ids, user_api_key_dict, litellm_changed_by + ) + except Exception as e: # noqa: BLE001 # the rolled-back batch is reported per row, not as a request failure + verbose_proxy_logger.error("/user/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e) + return _error_message(e) + await delete_cache_key_objects( + hashed_tokens=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) audit_outcomes: Final = await _bounded( UserManagementEventHooks.create_internal_user_audit_log( user_id=u.user_id, @@ -315,33 +401,15 @@ async def _delete_user_rows( 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) - keys: Final = await VerificationTokenRepository(prisma_client).table.find_many( - where=_in_filter("user_id", user_ids) - ) - if keys: - await _persist_deleted_verification_tokens( - keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - await VerificationTokenRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) - await InvitationLinkRepository(prisma_client).table.delete_many( - where=_any_filter( - _in_filter("user_id", user_ids), - _in_filter("created_by", user_ids), - _in_filter("updated_by", user_ids), - ) - ) - await OrganizationMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) - await TeamMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) - await UserRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids)) + return None 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, ) -> BulkDeleteUserResponse: @@ -406,6 +474,11 @@ async def bulk_delete_users( ) for tid, err in team_failures.items(): verbose_proxy_logger.error("/user/bulk_delete: failed to remove users from team %s: %s", tid, err) + await delete_cache_key_objects( + hashed_tokens=tuple(t for removal in removals.values() for t in removal.deleted_key_tokens), + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) for removal in removals.values(): _emit_team_members_metric(removal.team) @@ -415,10 +488,19 @@ async def bulk_delete_users( ) deletable: Final = tuple(u for u in candidates if not team_errors(u.user_id)) - if deletable: + delete_error: Final = ( await _delete_user_rows( - prisma_client, deletable, user_api_key_dict, litellm_proxy_admin_name, litellm_changed_by + prisma_client, + deletable, + user_api_key_dict, + user_api_key_cache, + proxy_logging_obj, + litellm_proxy_admin_name, + litellm_changed_by, ) + if deletable + else None + ) def result(index: int, user_id: str) -> UserDeleteResult: if user_id in data.user_ids[:index]: @@ -426,7 +508,9 @@ async def bulk_delete_users( error: Final = precheck_errors[user_id] if error is not None: return UserDeleteResult(user_id=user_id, success=False, error=error) - errors: Final = team_errors(user_id) + errors: Final = team_errors(user_id) or ( + (f"Failed to delete user: {delete_error}",) if delete_error is not None else () + ) return UserDeleteResult( user_id=user_id, user_email=rows_by_id[user_id].user_email, diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 6952de9c1cc..1825813ae9c 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,6 +1,6 @@ from typing import Any, Final, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from litellm.proxy._types import ( KeyManagementRoutes, @@ -125,6 +125,13 @@ class BulkTeamMemberDeleteRequest(BaseModel): team_id: str members: tuple[MemberDeleteRequest, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES) + @field_validator("members") + @classmethod + def one_identifier_per_member(cls, members: tuple[MemberDeleteRequest, ...]) -> tuple[MemberDeleteRequest, ...]: + if any(m.user_id is not None and m.user_email is not None for m in members): + raise ValueError("Each member must be identified by exactly one of user_id or user_email") + return members + class TeamMemberDeleteResult(BaseModel): """Outcome for one row of `/team/bulk_member_delete`.""" diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index 91ce7e56a40..a8a09518326 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -1,3 +1,4 @@ +import copy import json from collections.abc import Callable, Mapping, Sequence from contextlib import asynccontextmanager @@ -8,6 +9,7 @@ from fastapi import HTTPException from pydantic import BaseModel, ConfigDict, ValidationError from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, MemberDeleteRequest, UserAPIKeyAuth +from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache 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 @@ -126,6 +128,8 @@ class _Tx: 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] = [] @@ -158,17 +162,26 @@ class _FakePrisma: org_memberships: Sequence[Mapping[str, object]] = (), on_lock: Callable[[str], None] = lambda _: None, fail_locks: frozenset[str] = frozenset(), + fail_user_delete: 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_user_delete = fail_user_delete self.locks: list[str] = [] self.roster_reads: list[str] = [] @asynccontextmanager async def tx(self): + snapshot = copy.deepcopy(self.db) tx = _Tx(self.db, self._on_lock, self._fail_locks) - yield tx + try: + yield tx + if self._fail_user_delete and tx.locks == []: + 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) @@ -189,21 +202,43 @@ def _roster(prisma: _FakePrisma, team_id: str) -> list[str | None]: return [m.user_id for m in prisma.db.litellm_teamtable.rows[team_id].members_with_roles] -async def _delete(prisma: _FakePrisma, user_ids: Sequence[str], caller: UserAPIKeyAuth = ADMIN): +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=ADMIN): +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( data=BulkTeamMemberDeleteRequest(team_id=team_id, members=tuple(MemberDeleteRequest(**m) for m in members)), user_api_key_dict=caller, prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient + user_api_key_cache=cache or UserApiKeyCache(), + proxy_logging_obj=None, ) @@ -303,6 +338,50 @@ async def test_bulk_delete_keeps_user_when_a_team_rewrite_fails_and_deletes_the_ assert _roster(prisma, "bad") == ["u1"] and _roster(prisma, "good") == [] +@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_user_delete=True, + ) + cache = _cache_with("k1") + + response = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache) + + assert [(r.user_id, r.success, r.error) for r in response.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")]) @@ -382,6 +461,58 @@ async def test_bulk_member_delete_reports_members_not_on_the_team_without_rewrit 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"}], + ) + + response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}]) + + assert response.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")]) + + response = await _remove( + prisma, "t1", [{"user_id": "u1"}, {"user_id": "u1"}, {"user_email": "u1@example.com"}, {"user_id": "u2"}] + ) + + assert [(r.success, r.error) for r in response.results] == [ + (True, None), + (False, "Duplicate member in request"), + (True, None), + (True, None), + ] + assert (response.successful_deletions, response.failed_deletions) == (3, 1) + assert _roster(prisma, "t1") == ["keep"] + + +@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")]) @@ -432,3 +563,16 @@ def test_request_models_enforce_batch_bounds(): team_id="t1", members=tuple(MemberDeleteRequest(user_id=f"u{i}") for i in range(501)) ) assert len(BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(500))).user_ids) == 500 + + +def test_bulk_member_delete_request_requires_exactly_one_identifier_per_member(): + with pytest.raises(ValidationError, match="exactly one of user_id or user_email"): + BulkTeamMemberDeleteRequest( + team_id="t1", members=(MemberDeleteRequest(user_id="u1", user_email="other@example.com"),) + ) + with pytest.raises(ValidationError): + BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{}]}) + assert ( + BulkTeamMemberDeleteRequest(team_id="t1", members=(MemberDeleteRequest(user_id="u1"),)).members[0].user_id + == "u1" + ) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 14aa5b081bd..23c989a3220 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15067,27 +15067,14 @@ export interface paths { put?: never; /** * Bulk Team Member Delete - * @description Remove up to 500 members from one team in a single request. + * @description Remove up to 500 members (each named by `user_id` or `user_email`) from one team. Same authorization + * as `/team/member_delete`. Returns one result per member, in order. * - * Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's - * organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under - * the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and - * their team-scoped keys are all cleaned up together. Members that are not on the team are reported in - * `results` with `success: false` and the rest are still removed. - * - * Example request: * ```bash - * curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \ - * --header 'Authorization: Bearer sk-1234' \ - * --header 'Content-Type: application/json' \ - * --data '{ - * "team_id": "team-1234", - * "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}] - * }' + * curl -X POST 'http://0.0.0.0:4000/team/bulk_member_delete' -H 'Authorization: Bearer sk-1234' \ + * -H 'Content-Type: application/json' \ + * -d '{"team_id": "team-1234", "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]}' * ``` - * - * Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`, - * `success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. */ post: operations["bulk_team_member_delete_team_bulk_member_delete_post"]; delete?: never; @@ -16529,27 +16516,13 @@ export interface paths { put?: never; /** * Bulk Delete User - * @description Delete up to 500 internal users in one request and remove each one from every team they belong to. + * @description Delete up to 500 users and remove each one from every team they belong to. Same authorization as + * `/user/delete`. Returns one result per user id, in order. * - * Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose - * organizations they all administer. Each team a deleted user was on is rewritten once under the team - * lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards. - * Then the users' keys, invitation links, organization memberships and user rows are deleted. - * - * Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with - * `success: false` and an `error`, and the other users are still deleted. - * - * Usage Example - * - * ```shell - * curl -X POST "http://localhost:4000/user/bulk_delete" \ - * -H "Content-Type: application/json" \ - * -H "Authorization: Bearer sk-1234" \ - * -d '{"user_ids": ["user-1", "user-2"]}' + * ```bash + * curl -X POST 'http://localhost:4000/user/bulk_delete' -H 'Authorization: Bearer sk-1234' \ + * -H 'Content-Type: application/json' -d '{"user_ids": ["user-1", "user-2"]}' * ``` - * - * Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`, - * `teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`. */ post: operations["bulk_delete_user_user_bulk_delete_post"]; delete?: never; From 82872c9627a0109f2a42550c96db5c50e5f8cd87 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 05:35:55 +0000 Subject: [PATCH 10/25] fix(proxy): run /user/bulk_delete team rewrites and user deletes in one transaction Lock affected teams in sorted order inside a single 60s transaction so a failure on any team rolls back every rewrite and every user row delete. PrismaClient.tx() gains an optional timeout for the larger batch. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_deletion.py | 260 ++++++++++-------- litellm/proxy/utils.py | 5 +- .../test_bulk_user_deletion.py | 53 ++-- 3 files changed, 178 insertions(+), 140 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index 4d3387aacb7..e7726652eca 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -2,13 +2,16 @@ 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. +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 @@ -60,7 +63,8 @@ if TYPE_CHECKING: from litellm.repositories.prisma_protocols import TableActions -_TEAM_WRITE_CONCURRENCY: Final = 10 +_AUDIT_LOG_CONCURRENCY: Final = 10 +_USER_BATCH_TX_TIMEOUT: Final = timedelta(seconds=60) class _ErrorDetail(TypedDict): @@ -95,6 +99,12 @@ class _TeamRemoval: deleted_key_tokens: tuple[str, ...] +@dataclass(frozen=True, slots=True) +class _UserBatchDeletion: + removals: Mapping[str, _TeamRemoval] + deleted_key_tokens: tuple[str, ...] + + def _http_error(status_code: int, message: str) -> HTTPException: detail: Final[_ErrorDetail] = {"error": message} return HTTPException(status_code=status_code, detail=detail) @@ -161,7 +171,7 @@ def _error_message(exc: BaseException) -> str: async def _bounded(awaitables: Iterable[Awaitable[object]]) -> tuple[object | BaseException, ...]: - semaphore: Final = asyncio.Semaphore(_TEAM_WRITE_CONCURRENCY) + semaphore: Final = asyncio.Semaphore(_AUDIT_LOG_CONCURRENCY) async def run(awaitable: Awaitable[object]) -> object: async with semaphore: @@ -172,54 +182,54 @@ async def _bounded(awaitables: Iterable[Awaitable[object]]) -> tuple[object | Ba async def _remove_members_from_team( prisma_client: PrismaClient, + tx: "Prisma", team_id: str, members: Sequence[MemberDeleteRequest], user_api_key_dict: UserAPIKeyAuth, ) -> _TeamRemoval: - async with prisma_client.tx() as tx: - await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id) - roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id) - if roster is None: - raise _http_error(400, f"Team id={team_id} does not exist in db") + await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id) + roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id) + if roster is None: + raise _http_error(400, f"Team id={team_id} does not exist in db") - removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members)) - kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members)) - removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None) - requested_ids: Final = frozenset(r.user_id for r in members if r.user_id is not None) - requested_emails: Final = frozenset(r.user_email for r in members if r.user_id is None and r.user_email) - user_rows: Final = await _user_tx_db(tx).find_many( - where=_any_filter( - _in_filter("user_id", removed_ids | requested_ids), - _in_filter("user_email", requested_emails), - ) + removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members)) + kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members)) + removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None) + requested_ids: Final = frozenset(r.user_id for r in members if r.user_id is not None) + requested_emails: Final = frozenset(r.user_email for r in members if r.user_id is None and r.user_email) + user_rows: Final = await _user_tx_db(tx).find_many( + where=_any_filter( + _in_filter("user_id", removed_ids | requested_ids), + _in_filter("user_email", requested_emails), ) - stale_rows: Final = tuple(u for u in user_rows if team_id in u.teams) - cleanup_ids: Final = removed_ids | frozenset(u.user_id for u in stale_rows) - matched: Final = frozenset( - i - for i, r in enumerate(members) - if any(_addresses_member(m, r) for m in removed_members) or any(_addresses_user(u, r) for u in stale_rows) - ) - keys: Final = await _token_tx_db(tx).find_many(where=_team_users_filter(team_id, cleanup_ids)) + ) + stale_rows: Final = tuple(u for u in user_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(members) + if any(_addresses_member(m, r) for m in removed_members) or any(_addresses_user(u, r) for u in stale_rows) + ) + keys: Final = await _token_tx_db(tx).find_many(where=_team_users_filter(team_id, cleanup_ids)) - if removed_members: - roster_data: Final[_RosterData] = { - "members_with_roles": json.dumps(tuple(m.model_dump() for m in kept_members)) - } - await _team_tx_db(tx).update(where=_eq_filter("team_id", team_id), data=roster_data) - for row in stale_rows: - teams_data: _TeamsData = {"teams": {"set": tuple(t for t in row.teams if t != team_id)}} - await _user_tx_db(tx).update(where=_eq_filter("user_id", row.user_id), data=teams_data) - await _membership_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids)) - if keys: - await _persist_deleted_verification_tokens( - keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=None, - tx=tx, - ) - await _token_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids)) + 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( @@ -279,7 +289,8 @@ async def bulk_remove_team_members( 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) - removal: Final = await _remove_members_from_team(prisma_client, data.team_id, members, user_api_key_dict) + async with prisma_client.tx() as tx: + removal: Final = await _remove_members_from_team(prisma_client, tx, data.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, @@ -333,60 +344,101 @@ def _scope_error(user_id: str, target_org_ids: frozenset[str], caller_admin_org_ ) -async def _delete_user_rows_tx( +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, ...]: - async with prisma_client.tx() as tx: - 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), - ) + 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 _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)) + 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_user_rows( +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=_USER_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, -) -> 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: - deleted_key_tokens: Final = await _delete_user_rows_tx( - prisma_client, user_ids, user_api_key_dict, litellm_changed_by - ) + deletion: Final = await _delete_users_tx(prisma_client, users, teams_of, user_api_key_dict, litellm_changed_by) except Exception as e: # noqa: BLE001 # the rolled-back batch is reported per row, not as a request failure verbose_proxy_logger.error("/user/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e) return _error_message(e) await delete_cache_key_objects( - hashed_tokens=deleted_key_tokens, + 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, @@ -401,7 +453,7 @@ async def _delete_user_rows( 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 None + return deletion async def bulk_delete_users( @@ -452,54 +504,19 @@ async def bulk_delete_users( for u in candidates } ) - team_ids: Final = tuple(sorted(frozenset(t for u in candidates for t in teams_of[u.user_id]))) - members_by_team: Final = MappingProxyType( - { - tid: tuple( - MemberDeleteRequest(user_id=u.user_id, user_email=u.user_email) - for u in candidates - if tid in teams_of[u.user_id] - ) - for tid in team_ids - } - ) - outcomes: Final = await _bounded( - _remove_members_from_team(prisma_client, tid, members_by_team[tid], user_api_key_dict) for tid in team_ids - ) - removals: Final = MappingProxyType( - {tid: o for tid, o in zip(team_ids, outcomes, strict=True) if isinstance(o, _TeamRemoval)} - ) - team_failures: Final = MappingProxyType( - {tid: _error_message(o) for tid, o in zip(team_ids, outcomes, strict=True) if isinstance(o, BaseException)} - ) - for tid, err in team_failures.items(): - verbose_proxy_logger.error("/user/bulk_delete: failed to remove users from team %s: %s", tid, err) - await delete_cache_key_objects( - hashed_tokens=tuple(t for removal in removals.values() for t in removal.deleted_key_tokens), - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - for removal in removals.values(): - _emit_team_members_metric(removal.team) - - def team_errors(user_id: str) -> tuple[str, ...]: - return tuple( - f"Failed to remove from team {tid}: {err}" for tid, err in team_failures.items() if tid in teams_of[user_id] - ) - - deletable: Final = tuple(u for u in candidates if not team_errors(u.user_id)) - delete_error: Final = ( - await _delete_user_rows( + deletion: Final = ( + await _delete_users( prisma_client, - deletable, + candidates, + teams_of, user_api_key_dict, user_api_key_cache, proxy_logging_obj, litellm_proxy_admin_name, litellm_changed_by, ) - if deletable - else None + if candidates + else _UserBatchDeletion(removals=MappingProxyType({}), deleted_key_tokens=()) ) def result(index: int, user_id: str) -> UserDeleteResult: @@ -508,15 +525,18 @@ async def bulk_delete_users( error: Final = precheck_errors[user_id] if error is not None: return UserDeleteResult(user_id=user_id, success=False, error=error) - errors: Final = team_errors(user_id) or ( - (f"Failed to delete user: {delete_error}",) if delete_error is not None else () - ) + 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=not errors, - teams_removed=tuple(tid for tid in team_ids if tid in removals and user_id in removals[tid].removed), - error="; ".join(errors) or None, + success=True, + teams_removed=tuple(tid for tid, r in deletion.removals.items() if user_id in r.removed), ) results: Final = tuple(result(i, uid) for i, uid in enumerate(data.user_ids)) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index c095586b6c9..a4875321a2a 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -3792,6 +3792,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( @@ -4170,13 +4171,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"]: """ diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index a8a09518326..dd266880ce0 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -95,6 +95,9 @@ class _TeamTable: 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"]] @@ -143,7 +146,7 @@ class _Tx: self.locks.append(team_id) self._on_lock(team_id) return [] - assert self.locks == [team_id], "roster must be read under this team's advisory lock" + 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: @@ -162,22 +165,22 @@ class _FakePrisma: org_memberships: Sequence[Mapping[str, object]] = (), on_lock: Callable[[str], None] = lambda _: None, fail_locks: frozenset[str] = frozenset(), - fail_user_delete: bool = False, + 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_user_delete = fail_user_delete + self._fail_commit = fail_commit self.locks: list[str] = [] self.roster_reads: list[str] = [] @asynccontextmanager - async def tx(self): + 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_user_delete and tx.locks == []: + if self._fail_commit: raise RuntimeError("connection reset") except BaseException: self.db.__dict__.update(snapshot.__dict__) @@ -271,7 +274,7 @@ async def test_bulk_delete_removes_users_from_every_team_and_store(): assert [t["token"] for t in prisma.db.litellm_deletedverificationtoken.rows] == ["k1"] assert [i["id"] for i in prisma.db.litellm_invitationlink.rows] == ["i3"] assert prisma.db.litellm_organizationmembership.rows == [] - assert sorted(prisma.locks) == ["t1", "t2"] and sorted(prisma.roster_reads) == ["t1", "t2"] + assert prisma.locks == ["t1", "t2"] and prisma.roster_reads == ["t1", "t2"] @pytest.mark.asyncio @@ -320,22 +323,36 @@ async def test_bulk_delete_reports_missing_and_duplicate_ids_per_item_and_still_ @pytest.mark.asyncio -async def test_bulk_delete_keeps_user_when_a_team_rewrite_fails_and_deletes_the_others(): +async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_fails(): prisma = _FakePrisma( - users=[_user("u1", "bad", "good"), _user("u2", "good")], - teams=[_team("bad", "u1"), _team("good", "u1", "u2")], - fail_locks=frozenset({"bad"}), + 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") - response = await _delete(prisma, ["u1", "u2"]) + response = await _delete(prisma, ["u1", "u2"], cache=cache) - assert [(r.user_id, r.success, r.teams_removed) for r in response.results] == [ - ("u1", False, ("good",)), - ("u2", True, ("good",)), + assert [(r.user_id, r.success, r.teams_removed, r.error) for r in response.results] == [ + ("u1", False, (), "Failed to delete user: lock timeout"), + ("u2", False, (), "Failed to delete user: lock timeout"), ] - assert response.results[0].error == "Failed to remove from team bad: lock timeout" - assert set(prisma.db.litellm_usertable.rows) == {"u1"} - assert _roster(prisma, "bad") == ["u1"] and _roster(prisma, "good") == [] + 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")]) + + response = await _delete(prisma, ["u1"]) + + assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))] + assert prisma.db.litellm_usertable.rows == {} and _roster(prisma, "t1") == ["keep"] + assert prisma.locks == ["t1"] @pytest.mark.asyncio @@ -344,7 +361,7 @@ async def test_bulk_delete_rolls_back_every_user_row_and_reports_it_per_row_when users=[_user("u1", "t1"), _user("u2")], teams=[_team("t1", "u1")], tokens=[{"token": "k1", "user_id": "u1"}], - fail_user_delete=True, + fail_commit=True, ) cache = _cache_with("k1") From 450f7deff843b92d774fa0fb4c679119295a2b31 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 05:46:02 +0000 Subject: [PATCH 11/25] docs(proxy): shorten bulk delete endpoint descriptions to one sentence Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal_user_endpoints.py | 10 +--------- .../management_endpoints/team_endpoints.py | 11 +---------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 17 ++--------------- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 6b11847f09d..c9fc6720490 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2515,15 +2515,7 @@ async def bulk_delete_user( description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", ), ) -> BulkDeleteUserResponse: - """ - Delete up to 500 users and remove each one from every team they belong to. Same authorization as - `/user/delete`. Returns one result per user id, in order. - - ```bash - curl -X POST 'http://localhost:4000/user/bulk_delete' -H 'Authorization: Bearer sk-1234' \\ - -H 'Content-Type: application/json' -d '{"user_ids": ["user-1", "user-2"]}' - ``` - """ + """Delete up to 500 users, removing each from every team; same authorization as `/user/delete`.""" from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, prisma_client, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 8626c073e55..b5e3978a2a8 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3464,16 +3464,7 @@ async def bulk_team_member_delete( data: BulkTeamMemberDeleteRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection ) -> BulkTeamMemberDeleteResponse: - """ - Remove up to 500 members (each named by `user_id` or `user_email`) from one team. Same authorization - as `/team/member_delete`. Returns one result per member, in order. - - ```bash - curl -X POST 'http://0.0.0.0:4000/team/bulk_member_delete' -H 'Authorization: Bearer sk-1234' \\ - -H 'Content-Type: application/json' \\ - -d '{"team_id": "team-1234", "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]}' - ``` - """ + """Remove up to 500 members from one team; same authorization as `/team/member_delete`.""" from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 23c989a3220..58f64b64949 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15067,14 +15067,7 @@ export interface paths { put?: never; /** * Bulk Team Member Delete - * @description Remove up to 500 members (each named by `user_id` or `user_email`) from one team. Same authorization - * as `/team/member_delete`. Returns one result per member, in order. - * - * ```bash - * curl -X POST 'http://0.0.0.0:4000/team/bulk_member_delete' -H 'Authorization: Bearer sk-1234' \ - * -H 'Content-Type: application/json' \ - * -d '{"team_id": "team-1234", "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]}' - * ``` + * @description Remove up to 500 members from one team; same authorization as `/team/member_delete`. */ post: operations["bulk_team_member_delete_team_bulk_member_delete_post"]; delete?: never; @@ -16516,13 +16509,7 @@ export interface paths { put?: never; /** * Bulk Delete User - * @description Delete up to 500 users and remove each one from every team they belong to. Same authorization as - * `/user/delete`. Returns one result per user id, in order. - * - * ```bash - * curl -X POST 'http://localhost:4000/user/bulk_delete' -H 'Authorization: Bearer sk-1234' \ - * -H 'Content-Type: application/json' -d '{"user_ids": ["user-1", "user-2"]}' - * ``` + * @description Delete up to 500 users, removing each from every team; same authorization as `/user/delete`. */ post: operations["bulk_delete_user_user_bulk_delete_post"]; delete?: never; From d442d90411fbc55ead49e1f42dc379920b3033f7 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 05:56:52 +0000 Subject: [PATCH 12/25] test(proxy): add /team/bulk_member_delete behavior-suite scenarios for route coverage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_team_bulk_member_delete.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/proxy_behavior/management/test_team_bulk_member_delete.py diff --git a/tests/proxy_behavior/management/test_team_bulk_member_delete.py b/tests/proxy_behavior/management/test_team_bulk_member_delete.py new file mode 100644 index 00000000000..09a3cd54783 --- /dev/null +++ b/tests/proxy_behavior/management/test_team_bulk_member_delete.py @@ -0,0 +1,129 @@ +import pytest + +from .actors import Actor +from .conftest import create_scratch_team + +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( + "/team/bulk_member_delete", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={"team_id": scratch.prefix, "members": [{"user_id": v} for v in victims]}, + ) + assert resp.status_code == expected_status, f"{actor.value} {shape}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert row is not None + assert keep in _member_ids(row), "unrelated member removed" + if expected_status == 200: + assert [(r["user_id"], r["success"]) for r in resp.json()["results"]] == [(v, True) for v in victims] + assert 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( + "/team/bulk_member_delete", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "members": [{"user_id": stranger}, {"user_id": victim}]}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert [(r["user_id"], r["success"]) for r in body["results"]] == [ + (stranger, False), + (victim, True), + ] + assert body["results"][0]["error"] == "User not found in team" + assert (body["successful_deletions"], body["failed_deletions"]) == (1, 1) + + 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_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( + "/team/bulk_member_delete", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={ + "team_id": scratch.prefix, + "members": [{"user_id": victim, "user_email": f"{victim}@example.com"}], + }, + ) + assert resp.status_code == 422, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert row is not None and victim in _member_ids(row) From dbb4de7bc2c1ea5288c8579073847b05282ce919 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 05:59:19 +0000 Subject: [PATCH 13/25] fix(proxy): match bulk-deleted users on team rosters by user_id only and give /team/bulk_member_delete the 60s batch timeout Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_deletion.py | 12 +++---- .../test_bulk_user_deletion.py | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index e7726652eca..d73901a3ea0 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -64,7 +64,7 @@ if TYPE_CHECKING: from litellm.repositories.prisma_protocols import TableActions _AUDIT_LOG_CONCURRENCY: Final = 10 -_USER_BATCH_TX_TIMEOUT: Final = timedelta(seconds=60) +_BATCH_TX_TIMEOUT: Final = timedelta(seconds=60) class _ErrorDetail(TypedDict): @@ -289,7 +289,7 @@ async def bulk_remove_team_members( 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() as tx: + async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx: removal: Final = await _remove_members_from_team(prisma_client, tx, data.team_id, members, user_api_key_dict) await delete_cache_key_objects( hashed_tokens=removal.deleted_key_tokens, @@ -384,7 +384,7 @@ async def _delete_users_tx( """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=_USER_BATCH_TX_TIMEOUT) as tx: + 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)) ) @@ -395,11 +395,7 @@ async def _delete_users_tx( 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] - ), + tuple(MemberDeleteRequest(user_id=u.user_id) for u in users if tid in teams_of[u.user_id]), user_api_key_dict, ) for tid in team_ids diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index dd266880ce0..aac30184e1d 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -277,6 +277,37 @@ async def test_bulk_delete_removes_users_from_every_team_and_store(): 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"}, + ], + ) + + response = await _delete(prisma, ["u1"]) + + assert [(r.success, r.teams_removed) for r in response.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_finds_teams_through_membership_rows_when_user_teams_array_is_stale(): prisma = _FakePrisma( From c25498b66dfe9071ffa3f29784560561fb4ffb8d Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 06:08:42 +0000 Subject: [PATCH 14/25] fix(proxy): remove a bulk-deleted user's email-only roster entries without touching same-email teammates Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_deletion.py | 22 +++++++++++++------ .../test_bulk_user_deletion.py | 18 +++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index d73901a3ea0..92e21a3803b 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -150,16 +150,20 @@ def _org_membership_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_O 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: - return (request.user_id is not None and request.user_id == member.user_id) or ( - request.user_email is not None and request.user_email == member.user_email - ) + 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 _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDeleteRequest) -> bool: - return (request.user_id is not None and request.user_id == user.user_id) or ( - request.user_email is not None and request.user_email == user.user_email - ) + 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: @@ -395,7 +399,11 @@ async def _delete_users_tx( prisma_client, tx, tid, - tuple(MemberDeleteRequest(user_id=u.user_id) for u in users if tid in teams_of[u.user_id]), + 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 diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index aac30184e1d..49aed00177a 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -308,6 +308,24 @@ async def test_bulk_delete_leaves_teammates_who_share_the_deleted_users_email_al 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]) + + response = await _delete(prisma, ["u1"]) + + assert [(r.success, r.teams_removed) for r in response.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( From c788769129e23261d7be091cac9aa036c2c04b09 Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:18:14 -0700 Subject: [PATCH 15/25] fix(terraform): recognize a credential name conflict and adopt the existing credential The proxy answered a duplicate credential_name with a 500 carrying Prisma's unique-constraint message, so terraform apply against a credential the state had lost died with an opaque database error. Classify that response as a conflict, adopt the existing credential with a PATCH that carries model_id, and set the resource ID only once the adopt succeeds so a failed PATCH does not taint a credential this run never owned Squashed from the commits on #39745 with authorship preserved Fixes https://github.com/BerriAI/terraform-provider-litellm/issues/8 --- terraform/provider/CHANGELOG.md | 1 + .../litellm/resource_credential_crud.go | 26 +++ .../litellm/resource_credential_crud_test.go | 157 ++++++++++++++++++ terraform/provider/litellm/utils.go | 32 ++++ 4 files changed, 216 insertions(+) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 8c0ef5a8b15..3daee3250b6 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -38,6 +38,7 @@ longer signal it. ### Fixed - **key**: An update that changes `team_id` and fails because the key was already cascade-deleted along with its previous team now recovers by recreating the key under the new team, instead of aborting the apply. The key's absence is confirmed against the proxy first, so an unrelated failure still errors out, and a `team_id` change between two teams that both still exist stays a plain in-place update +- **credential**: `litellm_credential` create now adopts an existing credential on a `credential_name` conflict instead of failing with a 500; `apply` is idempotent again once state loses track of a credential that still exists on the proxy - **team**: Read now decodes the `team_info` envelope `/team/info` actually returns, so team attributes refresh from the proxy instead of always falling back to the prior state - **key**: Read now unwraps the `info` envelope `/key/info` actually returns; previously reads mapped nothing back into state, so drift on a key was never detected - **key**: Read now picks up `model_rpm_limit`, `model_tpm_limit`, `guardrails`, `tags`, `enforced_params`, `allowed_passthrough_routes`, `rpm_limit_type`, `tpm_limit_type` and `prompts` from `info.metadata`, where the proxy actually stores them; previously they stayed empty in state, so a matching config showed a permanent phantom diff on them and out-of-band changes to them were never detected diff --git a/terraform/provider/litellm/resource_credential_crud.go b/terraform/provider/litellm/resource_credential_crud.go index dd9aef64f76..cb5031ee04e 100644 --- a/terraform/provider/litellm/resource_credential_crud.go +++ b/terraform/provider/litellm/resource_credential_crud.go @@ -88,6 +88,26 @@ func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) erro err = handleCredentialAPIResponse(resp, nil, client) if err != nil { + // If a credential with this name already exists, adopt it instead of + // failing: take ownership and update the existing credential's + // values (merged onto whatever it already had - not a full replace) + // rather than erroring on the unique-constraint conflict. See + // https://github.com/BerriAI/terraform-provider-litellm/issues/8. + if err.Error() == "credential_conflict" { + log.Printf("[WARN] Credential %q already exists; adopting it and updating to match configuration.", credentialName) + d.SetId(credentialName) + if updateErr := resourceLiteLLMCredentialUpdate(d, m); updateErr != nil { + // Adoption failed before this run took ownership of + // anything real. Clear the ID so create is reported as + // failed outright (matching pre-adoption behavior) instead + // of tainting state for a credential this run doesn't own - + // state that would otherwise get destroyed on the next + // apply. + d.SetId("") + return fmt.Errorf("failed to adopt existing credential %q: %w", credentialName, updateErr) + } + return nil + } return fmt.Errorf("failed to create credential: %w", err) } @@ -142,6 +162,7 @@ func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) erro client := m.(*Client) credentialName := d.Id() + modelID := d.Get("model_id").(string) credentialInfo := d.Get("credential_info").(map[string]interface{}) credentialValues := d.Get("credential_values").(map[string]interface{}) @@ -157,8 +178,13 @@ func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) erro credValuesMap[k] = v } + // model_id must travel with the update the same way it does on create, + // so the proxy's model-based credential resolution still applies. Without + // it, updating (or adopting) a model_id-scoped credential silently loses + // that association. credentialRequest := CredentialRequest{ CredentialName: credentialName, + ModelID: modelID, CredentialInfo: credInfoMap, CredentialValues: credValuesMap, } diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go index 3398e58dd13..6e0b818fe33 100644 --- a/terraform/provider/litellm/resource_credential_crud_test.go +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -3,6 +3,7 @@ package litellm import ( "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "sync/atomic" @@ -199,3 +200,159 @@ func TestRetryCredentialRead_ConnectionError(t *testing.T) { // Connection error should not be retried (not a "credential_not_found") fmt.Printf("connection error (expected): %v\n", err) } + +// conflictServer builds the shared conflict-then-recover mock used by the +// adoption tests below. patchStatus/patchBody control the PATCH response, so +// callers can exercise both the success and failure paths. +func conflictServer(t *testing.T, patchStatus int, patchBody string) (*httptest.Server, *int32, *int32, *[]byte) { + t.Helper() + var createCalls, patchCalls int32 + var capturedPatchBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/credentials": + atomic.AddInt32(&createCalls, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"Unique constraint failed on the fields: (` + "`credential_name`" + `)","type":"internal_server_error","code":"500"}}`)) + case r.Method == http.MethodPatch: + atomic.AddInt32(&patchCalls, 1) + if r.URL.Path != "/credentials/conflict-test" { + t.Errorf("PATCH went to %q, want /credentials/conflict-test", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + capturedPatchBody = body + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(patchStatus) + w.Write([]byte(patchBody)) + case r.Method == http.MethodGet: + if r.URL.Path != "/credentials/by_name/conflict-test" || r.URL.Query().Get("model_id") != "model-1" { + t.Errorf("GET went to %q (query %q), want /credentials/by_name/conflict-test?model_id=model-1", r.URL.Path, r.URL.RawQuery) + } + resp := CredentialResponse{CredentialName: "conflict-test", CredentialInfo: map[string]interface{}{}} + body, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + default: + http.NotFound(w, r) + } + })) + return srv, &createCalls, &patchCalls, &capturedPatchBody +} + +// A credential that already exists in LiteLLM (created out of band, or left +// behind by a prior apply that dropped state) must be adopted on create +// instead of failing on the credential_name unique-constraint conflict, and +// the adopt PATCH must carry model_id so model-based credential resolution +// still applies (previously dropped - see +// https://github.com/BerriAI/litellm/pull/39745). +func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { + srv, createCalls, patchCalls, patchBody := conflictServer(t, http.StatusOK, `{}`) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "conflict-test", + "model_id": "model-1", + "credential_info": map[string]interface{}{"custom_llm_provider": "bedrock"}, + "credential_values": map[string]interface{}{"aws_access_key_id": "val"}, + }) + + if err := resourceLiteLLMCredentialCreate(d, client); err != nil { + t.Fatalf("expected create to adopt the existing credential, got error: %v", err) + } + if d.Id() != "conflict-test" { + t.Fatalf("expected ID %q, got %q", "conflict-test", d.Id()) + } + if got := atomic.LoadInt32(createCalls); got != 1 { + t.Fatalf("expected exactly 1 POST /credentials call, got %d", got) + } + if got := atomic.LoadInt32(patchCalls); got != 1 { + t.Fatalf("expected the conflict to trigger exactly 1 PATCH (adopt-and-update), got %d", got) + } + + var sent map[string]interface{} + if err := json.Unmarshal(*patchBody, &sent); err != nil { + t.Fatalf("PATCH body was not valid JSON: %v (%s)", err, *patchBody) + } + if sent["credential_name"] != "conflict-test" { + t.Errorf("PATCH body credential_name = %v, want conflict-test", sent["credential_name"]) + } + if sent["model_id"] != "model-1" { + t.Errorf("PATCH body model_id = %v, want model-1 (adoption must not drop model-based credential resolution)", sent["model_id"]) + } + credInfo, _ := sent["credential_info"].(map[string]interface{}) + if credInfo["custom_llm_provider"] != "bedrock" { + t.Errorf("PATCH body credential_info = %v, want custom_llm_provider=bedrock", sent["credential_info"]) + } +} + +// If the adopt PATCH itself fails, create must not have set the resource ID +// for a credential this run doesn't own - otherwise Terraform taints the +// entry and the *next* apply destroys a credential nobody here created. +func TestResourceLiteLLMCredentialCreate_FailedAdoptDoesNotTaint(t *testing.T) { + srv, createCalls, patchCalls, _ := conflictServer(t, http.StatusInternalServerError, `{"error":{"message":"Internal Server Error"}}`) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "conflict-test", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + + err := resourceLiteLLMCredentialCreate(d, client) + if err == nil { + t.Fatal("expected an error when the adopt PATCH fails, got nil") + } + if got := atomic.LoadInt32(createCalls); got != 1 { + t.Fatalf("expected exactly 1 POST /credentials call, got %d", got) + } + if got := atomic.LoadInt32(patchCalls); got != 1 { + t.Fatalf("expected exactly 1 PATCH attempt, got %d", got) + } + if d.Id() != "" { + t.Fatalf("resource ID must stay empty after a failed adopt, got %q (a tainted entry would be destroyed on the next apply)", d.Id()) + } +} + +// A non-conflict failure (a plain 500, for example) must return the original +// error and never attempt to adopt anything. +func TestResourceLiteLLMCredentialCreate_NonConflictErrorDoesNotAdopt(t *testing.T) { + var createCalls, patchCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/credentials": + atomic.AddInt32(&createCalls, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"Internal Server Error","type":"internal_server_error"}}`)) + case r.Method == http.MethodPatch: + atomic.AddInt32(&patchCalls, 1) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "some-cred", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + + err := resourceLiteLLMCredentialCreate(d, client) + if err == nil { + t.Fatal("expected an error for a non-conflict failure, got nil") + } + if got := atomic.LoadInt32(&patchCalls); got != 0 { + t.Fatalf("expected no PATCH attempt for a non-conflict error, got %d", got) + } + if d.Id() != "" { + t.Fatalf("resource ID must stay empty on a non-conflict failure, got %q", d.Id()) + } +} diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go index 5e81766d3f3..a123f5d350a 100644 --- a/terraform/provider/litellm/utils.go +++ b/terraform/provider/litellm/utils.go @@ -202,6 +202,35 @@ func isCredentialNotFoundError(errResp ErrorResponse) bool { return false } +// isCredentialConflictError checks if the error response indicates a credential +// name collision. LiteLLM surfaces this as a 500 carrying the underlying Prisma +// unique-constraint message on credential_name. See +// https://github.com/BerriAI/terraform-provider-litellm/issues/8. +func isCredentialConflictError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "Unique constraint failed") && strings.Contains(msg, "credential_name") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Unique constraint failed") && strings.Contains(errStr, "credential_name") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "Unique constraint failed") && strings.Contains(errResp.Detail.Error, "credential_name") { + return true + } + } + + return false +} + // handleCredentialAPIResponse handles API responses specifically for credential operations func handleCredentialAPIResponse(resp *http.Response, result interface{}, client *Client) error { bodyBytes, err := io.ReadAll(resp.Body) @@ -219,6 +248,9 @@ func handleCredentialAPIResponse(resp *http.Response, result interface{}, client if isCredentialNotFoundError(errResp) { return fmt.Errorf("credential_not_found") } + if isCredentialConflictError(errResp) { + return fmt.Errorf("credential_conflict") + } } return fmt.Errorf("API request failed: Status: %s, Response: %s", resp.Status, client.redactSensitiveData(string(bodyBytes))) From 96332f75e857a2deda2d5db6db00deb79185db17 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 22:28:39 +0000 Subject: [PATCH 16/25] refactor(proxy): move bulk user creation to POST /management/v1/users/bulk Follows the Management API modernization design: plural resource under /management/v1, {data, meta} response envelope, unknown request fields rejected with 422, and RFC 9457 problem+json for request-level errors. Body validation failures under /management/v1 now answer 422 instead of the 400 query-parameter problem. /user/bulk_new is removed. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 2 +- litellm/proxy/auth/route_checks.py | 4 +- litellm/proxy/list_api/common.py | 27 +++ .../internal_user_endpoints.py | 68 ------- .../management_v1/__init__.py | 4 + .../management_v1/users.py | 105 ++++++++++ .../management_helpers/bulk_user_creation.py | 24 ++- litellm/proxy/proxy_server.py | 26 +-- .../internal_user_endpoints.py | 28 ++- .../endpointaudit/coverage_allowlist.txt | 1 - .../management_v1/test_users.py | 122 +++++++++++ .../test_bulk_user_creation.py | 61 +++--- .../proxy_server/test_exception_handlers.py | 32 ++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 192 +++++++++--------- 14 files changed, 462 insertions(+), 234 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/users.py create mode 100644 tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ed182460644..48a324bff71 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -656,7 +656,7 @@ class LiteLLMRoutes(enum.Enum): [ # user "/user/new", - "/user/bulk_new", + "/management/v1/users/bulk", "/user/update", "/user/bulk_update", "/user/delete", diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 051ab13c058..730ab30f65f 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -24,7 +24,7 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset( [ # user "/user/new", - "/user/bulk_new", + "/management/v1/users/bulk", "/user/delete", "/user/bulk_update", # team @@ -756,7 +756,7 @@ class RouteChecks: _ADMIN_VIEWER_BLOCKED_WRITE_ROUTES = frozenset( [ "/user/new", - "/user/bulk_new", + "/management/v1/users/bulk", "/user/delete", "/user/bulk_update", "/team/new", diff --git a/litellm/proxy/list_api/common.py b/litellm/proxy/list_api/common.py index 7ef2827f30e..a0b0d45848f 100644 --- a/litellm/proxy/list_api/common.py +++ b/litellm/proxy/list_api/common.py @@ -1,5 +1,6 @@ """Contract machinery shared by every LiteLLM-defined list route, on any surface.""" +from collections.abc import Sequence from typing import Final from urllib.parse import urlencode @@ -7,6 +8,7 @@ from fastapi import Request from fastapi.dependencies.utils import get_flat_params from fastapi.params import ParamTypes from fastapi.responses import JSONResponse +from typing_extensions import ReadOnly, TypedDict from litellm.types.proxy.management_endpoints.management_v1 import ( ListLinks, @@ -56,6 +58,31 @@ def escape_like(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") +class ValidationErrorDetail(TypedDict): + """The two keys of a pydantic/FastAPI validation error a problem document needs.""" + + loc: ReadOnly[tuple[int | str, ...]] + msg: ReadOnly[str] + + +def request_validation_problem(errors: Sequence[ValidationErrorDetail]) -> ProblemDetail: + """A body that fails validation (an unknown field included) is 422; a bad query parameter is 400.""" + 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( + type=f"{PROBLEM_TYPE_BASE}invalid-request-body", + title="Invalid request body", + status=422, + detail=detail or "The request body is invalid.", + ) + return ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", + title="Invalid query parameter", + status=400, + detail=detail or "The request query parameters are invalid.", + ) + + def unknown_query_param_problem(unknown: tuple[str, ...], allowed: tuple[str, ...]) -> ProblemDetail: return ProblemDetail( type=f"{PROBLEM_TYPE_BASE}unknown-query-parameter", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 5f570485ed3..e3efda507f6 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -5,7 +5,6 @@ Internal User Management Endpoints These are members of a Team on LiteLLM /user/new -/user/bulk_new /user/update /user/bulk_update /user/delete @@ -78,8 +77,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( - BulkNewUserRequest, - BulkNewUserResponse, BulkUpdateUserRequest, BulkUpdateUserResponse, UserListResponse, @@ -640,71 +637,6 @@ async def new_user( raise handle_exception_on_proxy(e) -@router.post( - "/user/bulk_new", - tags=["Internal User management"], - dependencies=[Depends(user_api_key_auth)], - response_model=BulkNewUserResponse, -) -@management_endpoint_wrapper -async def bulk_new_user( - data: BulkNewUserRequest, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection -) -> BulkNewUserResponse: - """ - Create up to 500 internal users in one request, optionally adding each one to teams. - - Every entry in `users` takes the same fields as `/user/new`, with two differences: `auto_create_key` - defaults to `false` (opt in per user to also get a virtual key back) and `send_invite_email` is not - supported. Rows are validated together (duplicate ids or emails, unknown teams, roles the caller may not - grant), inserted in one statement, and each referenced team is written once for all of its new members. - - Rows fail independently: a bad row is reported in `results` with `success: false` and an `error`, and the - other rows still get created. A user that was created but could not be added to one of its teams is - reported with `success: true`, `teams` listing where they did land, and `error` naming the failed team. - The whole request is rejected with 403 only if creating the valid rows would exceed the license seat limit. - - Usage Example - - ```shell - curl -X POST "http://localhost:4000/user/bulk_new" \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer sk-1234" \\ - -d '{ - "users": [ - {"user_email": "a@example.com", "user_role": "internal_user", "teams": ["team-1"]}, - {"user_email": "b@example.com", "user_role": "internal_user", "auto_create_key": true} - ] - }' - ``` - - Returns `results` (one entry per input row, in order, with `user_id`, `user_email`, `success`, `teams`, - `key`, `error`), `total_requested`, `successful_creations` and `failed_creations`. - """ - from litellm.proxy.management_helpers.bulk_user_creation import bulk_create_users - from litellm.proxy.proxy_server import ( - _license_check, # pyright: ignore[reportPrivateUsage] # same proxy license singleton /user/new reads - litellm_proxy_admin_name, - prisma_client, - user_api_key_cache, - ) - - if prisma_client is None: - raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) - try: - return await bulk_create_users( - users=data.users, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - license_check=_license_check, - litellm_proxy_admin_name=litellm_proxy_admin_name, - user_api_key_cache=user_api_key_cache, - ) - except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract - verbose_proxy_logger.exception("/user/bulk_new: Exception occured") - raise handle_exception_on_proxy(e) - - @router.get( "/user/available_roles", tags=["Internal User management"], diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index 342e6525cda..a2172162dae 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -10,9 +10,13 @@ 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.users import ( + router as users_router, +) router: Final = APIRouter() router.include_router(budgets_router) router.include_router(spend_logs_router) +router.include_router(users_router) __all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/management_v1/users.py b/litellm/proxy/management_endpoints/management_v1/users.py new file mode 100644 index 00000000000..33a55c08513 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/users.py @@ -0,0 +1,105 @@ +"""`POST /management/v1/users/bulk`.""" + +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 +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.utils import ( + management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy untyped decorator +) +from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkNewUserRequest, + BulkNewUserResponse, +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail + +router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +@router.post( + "/users/bulk", + tags=["Internal User management"], # mutable-ok: fastapi types tags as list[str | Enum] + dependencies=(Depends(user_api_key_auth),), + response_model=BulkNewUserResponse, +) +@management_endpoint_wrapper +async def bulk_create_users_route( + data: BulkNewUserRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> BulkNewUserResponse: + """ + Create up to 500 internal users in one request, optionally adding each one to teams. + + Every entry in `users` takes the same fields as `/user/new`, with two differences: `auto_create_key` + defaults to `false` (opt in per user to also get a virtual key back) and `send_invite_email` is not + supported. Unknown fields are rejected with 422. Rows are validated together (duplicate ids or emails, + unknown teams, roles the caller may not grant), inserted in one statement, and each referenced team is + written once for all of its new members. + + Rows fail independently: a bad row is reported in `data` with `success: false` and an `error`, and the + other rows still get created. A user that was created but could not be added to one of its teams is + reported with `success: true`, `teams` listing where they did land, and `error` naming the failed team. + The whole request is refused with a 403 problem document only if creating the valid rows would exceed + the license seat limit. + + Example curl: + ``` + curl -X POST "http://localhost:4000/management/v1/users/bulk" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk-1234" \\ + -d '{ + "users": [ + {"user_email": "a@example.com", "user_role": "internal_user", "teams": ["team-1"]}, + {"user_email": "b@example.com", "user_role": "internal_user", "auto_create_key": true} + ] + }' + ``` + + Returns `data` (one entry per input row, in order, with `user_id`, `user_email`, `success`, `teams`, + `key`, `error`) and `meta` with `total_requested`, `created` and `failed`. + """ + try: + from litellm.proxy.proxy_server import ( + _license_check, # pyright: ignore[reportPrivateUsage] # same proxy license singleton /user/new reads + litellm_proxy_admin_name, + prisma_client, + 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, + ) + ) + + return await bulk_create_users( + users=data.users, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + license_check=_license_check, + litellm_proxy_admin_name=litellm_proxy_admin_name, + user_api_key_cache=user_api_key_cache, + ) + + except ManagementProblem: + raise + except Exception: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape + verbose_proxy_logger.exception("/management/v1/users/bulk: Exception occurred") + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}internal-server-error", + title="Internal server error", + status=500, + detail="Failed to create users.", + ) + ) diff --git a/litellm/proxy/management_helpers/bulk_user_creation.py b/litellm/proxy/management_helpers/bulk_user_creation.py index 2eed5121755..56abe3b6a3f 100644 --- a/litellm/proxy/management_helpers/bulk_user_creation.py +++ b/litellm/proxy/management_helpers/bulk_user_creation.py @@ -1,4 +1,4 @@ -"""Batched internal user creation behind `/user/bulk_new`. +"""Batched internal user creation behind `POST /management/v1/users/bulk`. The batch is validated with set queries, user rows land in one `create_many`, and every referenced team is written once under its advisory lock instead of once per user. @@ -33,6 +33,7 @@ from litellm.proxy.auth.litellm_license import LicenseCheck from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler 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 team-admin check /user/new uses _is_user_team_admin, # pyright: ignore[reportPrivateUsage] # same team-admin check /user/new uses @@ -61,9 +62,11 @@ from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkNewUserItem, + BulkNewUserMeta, BulkNewUserResponse, UserCreateResult, ) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail if TYPE_CHECKING: from prisma import Prisma @@ -776,7 +779,8 @@ async def bulk_create_users( ) -> BulkNewUserResponse: """Create every valid row in `users`; rows that fail validation or a write are reported, not raised. - Raises `HTTPException(403)` only when the whole batch would push the deployment over its license seat limit. + Raises a 403 `ManagementProblem` only when the whole batch would push the deployment over its license seat + limit. """ pending, request_failures = _partition_rows(users, user_api_key_dict) existing_ids, existing_emails = await _existing_user_conflicts(prisma_client, pending) @@ -791,9 +795,13 @@ async def bulk_create_users( billable_users: Final = await UserRepository(prisma_client).count_billable_users() if creatable and license_check.is_over_limit(total_users=billable_users + len(creatable)): - raise HTTPException( - status_code=403, - detail="License is over limit. Please contact support@berri.ai to upgrade your license.", + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}license-limit-exceeded", + title="License limit exceeded", + status=403, + detail="License is over limit. Please contact support@berri.ai to upgrade your license.", + ) ) prepared_outcomes: Final = tuple([await _prepare_user(user, prisma_client) for user in creatable]) @@ -858,8 +866,6 @@ async def bulk_create_users( ) successes: Final = sum(1 for result in results if result.success) return BulkNewUserResponse( - results=results, - total_requested=len(users), - successful_creations=successes, - failed_creations=len(users) - successes, + data=results, + meta=BulkNewUserMeta(total_requested=len(users), created=successes, failed=len(users) - successes), ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index d9f8e04ebda..367f4b02a4c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -473,9 +473,10 @@ from litellm.proxy.hooks.prompt_injection_detection import ( from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger, run_spend_event from litellm.proxy.image_endpoints.endpoints import router as image_router from litellm.proxy.list_api.common import ( - PROBLEM_TYPE_BASE, ManagementProblem, + ValidationErrorDetail, problem_response, + request_validation_problem, ) from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request from litellm.proxy.logging_endpoints.callback_logs_endpoints import ( @@ -598,7 +599,6 @@ from litellm.proxy.spend_tracking.spend_event_producer import ( SpendEventProducer, build_spend_event_producer, ) -from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail try: from litellm.proxy.enterprise_billing.billing_metrics import ( @@ -1784,27 +1784,13 @@ class _ExceptionRow(TypedDict, total=False): exception_counts: Mapping[str, int] -class _ValidationErrorDetail(TypedDict): - loc: tuple[int | str, ...] - msg: str - - @app.exception_handler(RequestValidationError) async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError): if request.url.path.startswith(MANAGEMENT_V1_PREFIX): - _close_dangling_otel_server_span(request, 400, exc=exc) - validation_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors() - return problem_response( - ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", - title="Invalid query parameter", - status=400, - detail="; ".join( - f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors - ) - or "The request query parameters are invalid.", - ) - ) + validation_errors: Final[Sequence[ValidationErrorDetail]] = exc.errors() + problem: Final = request_validation_problem(validation_errors) + _close_dangling_otel_server_span(request, problem.status, exc=exc) + return problem_response(problem) _close_dangling_otel_server_span(request, 422, exc=exc) return JSONResponse( status_code=422, diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index a7e5c6d2916..f6a2c173ad5 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from typing import Any, Final, Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import ( @@ -89,7 +89,10 @@ class BulkUpdateUserResponse(BaseModel): class BulkNewUserItem(NewUserRequest): - """One row of `/user/bulk_new`: the `/user/new` body, with keys opt-in and invite emails unsupported.""" + """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.""" + + model_config = ConfigDict(extra="forbid", protected_namespaces=()) auto_create_key: bool = False @@ -97,16 +100,19 @@ class BulkNewUserItem(NewUserRequest): @classmethod def reject_invite_email(cls, value: bool | None) -> bool | None: if value: - raise ValueError("send_invite_email is not supported on /user/bulk_new; invite users separately") + raise ValueError("send_invite_email is not supported on /management/v1/users/bulk; invite users separately") return value class BulkNewUserRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + users: tuple[BulkNewUserItem, ...] = Field(min_length=1, max_length=MAX_BULK_NEW_USERS) class UserCreateResult(BaseModel): - """Outcome for one row of `/user/bulk_new`. `teams` lists the teams the user was actually added to.""" + """Outcome for one row of `POST /management/v1/users/bulk`. `teams` lists the teams the user was actually + added to.""" user_id: str | None = None user_email: str | None = None @@ -116,8 +122,14 @@ class UserCreateResult(BaseModel): error: str | None = None -class BulkNewUserResponse(BaseModel): - results: tuple[UserCreateResult, ...] +class BulkNewUserMeta(BaseModel): total_requested: int - successful_creations: int - failed_creations: int + created: int + failed: int + + +class BulkNewUserResponse(BaseModel): + """`data` holds one result per input row, in input order.""" + + data: tuple[UserCreateResult, ...] + meta: BulkNewUserMeta diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 6f85df98850..6bc8947e89f 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -84,7 +84,6 @@ POST /team/{team_id}/member/{user_id}/reset_spend POST /team/key/bulk_update POST /team/permissions_bulk_update POST /team/{team_id}/disable_logging -POST /user/bulk_new POST /user/bulk_update # Alternate method or path for functionality the provider already manages elsewhere diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py new file mode 100644 index 00000000000..b61e639e453 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py @@ -0,0 +1,122 @@ +"""The HTTP contract of `POST /management/v1/users/bulk`: envelope, problem documents and strict bodies. + +The batching behaviour itself is covered next to the helper, in +`tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py`, whose in-memory Prisma this reuses. +""" + +import pytest +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from litellm.proxy._types import LitellmUserRoles, Member +from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth +from litellm.proxy.list_api.common import ManagementProblem, problem_response, request_validation_problem +from litellm.proxy.management_endpoints.management_v1 import router +from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX +from tests.test_litellm.proxy.management_helpers.test_bulk_user_creation import _FakePrisma, _License, _team + +app = FastAPI() + + +@app.exception_handler(ManagementProblem) +async def management_problem_exception_handler(request: Request, exc: ManagementProblem): + return problem_response(exc.problem) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return problem_response(request_validation_problem(exc.errors())) + + +app.include_router(router) +client = TestClient(app) + +USERS_BULK_PATH = f"{MANAGEMENT_V1_PREFIX}/users/bulk" + + +@pytest.fixture +def as_proxy_admin(): + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + yield + app.dependency_overrides.clear() + + +@pytest.fixture +def prisma(monkeypatch): + fake = _FakePrisma(teams=[_team("t1", [Member(user_id="existing", role="admin")])]) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", fake) + monkeypatch.setattr("litellm.proxy.proxy_server._license_check", _License()) + return fake + + +def _post(body: object): + return client.post(USERS_BULK_PATH, json=body, headers={"Authorization": "Bearer k"}) + + +def test_returns_one_result_per_row_in_order_inside_the_data_meta_envelope(prisma, as_proxy_admin): + response = _post( + { + "users": [ + {"user_id": "u1", "user_email": "a@example.com", "teams": ["t1"]}, + {"user_id": "u2", "teams": ["missing-team"]}, + {"user_id": "u3"}, + ] + } + ) + + assert response.status_code == 200 + body = response.json() + assert set(body) == {"data", "meta"} + assert body["meta"] == {"total_requested": 3, "created": 2, "failed": 1} + assert [row["user_id"] for row in body["data"]] == ["u1", "u2", "u3"] + assert [row["success"] for row in body["data"]] == [True, False, True] + assert body["data"][0]["teams"] == ["t1"] + assert "missing-team" in body["data"][1]["error"] + assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["existing", "u1"] + + +def test_an_unknown_field_anywhere_in_the_body_is_a_422_problem(prisma, as_proxy_admin): + for body in ( + {"users": [{"user_email": "a@example.com", "user_emial": "typo"}]}, + {"users": [{"user_email": "a@example.com"}], "dry_run": True}, + ): + response = _post(body) + + assert response.status_code == 422, body + assert response.headers["content-type"] == "application/problem+json" + assert response.json()["type"] == "urn:litellm:error:invalid-request-body" + assert "Extra inputs are not permitted" in response.json()["detail"] + assert prisma.db.litellm_usertable.rows == {} + + +def test_empty_and_oversized_batches_are_422_problems(prisma, as_proxy_admin): + for users in ([], [{"user_email": f"{i}@example.com"} for i in range(501)]): + response = _post({"users": users}) + + assert response.status_code == 422, len(users) + assert response.json()["type"] == "urn:litellm:error:invalid-request-body" + assert prisma.db.litellm_usertable.rows == {} + + +def test_license_limit_is_a_403_problem_and_creates_nothing(prisma, as_proxy_admin, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server._license_check", _License(max_users=1)) + + response = _post({"users": [{"user_id": "u1"}, {"user_id": "u2"}]}) + + assert response.status_code == 403 + assert response.headers["content-type"] == "application/problem+json" + assert response.json()["type"] == "urn:litellm:error:license-limit-exceeded" + assert prisma.db.litellm_usertable.rows == {} + + +def test_no_database_is_a_503_problem(as_proxy_admin, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + + response = _post({"users": [{"user_id": "u1"}]}) + + assert response.status_code == 503 + assert response.headers["content-type"] == "application/problem+json" + assert response.json()["type"] == "urn:litellm:error:database-not-connected" diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py index 16e56c1b723..b5349fc2387 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_creation.py @@ -4,12 +4,12 @@ from typing import Final import httpx import pytest -from fastapi import HTTPException from prisma.errors import UniqueViolationError from pydantic import BaseModel, ConfigDict, ValidationError from litellm.caching.caching import DualCache from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth +from litellm.proxy.list_api.common import ManagementProblem from litellm.proxy.management_helpers.bulk_user_creation import bulk_create_users from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkNewUserItem, @@ -203,10 +203,10 @@ async def test_creates_users_and_team_membership_in_every_store(): ], ) - assert (response.total_requested, response.successful_creations, response.failed_creations) == (3, 3, 0) - assert [r.user_id for r in response.results] == ["u1", "u2", "u3"] - assert all(r.success and r.key is None and r.error is None for r in response.results) - assert [r.teams for r in response.results] == [("t1", "t2"), ("t1",), ()] + assert (response.meta.total_requested, response.meta.created, response.meta.failed) == (3, 3, 0) + assert [r.user_id for r in response.data] == ["u1", "u2", "u3"] + assert all(r.success and r.key is None and r.error is None for r in response.data) + assert [r.teams for r in response.data] == [("t1", "t2"), ("t1",), ()] users = prisma.db.litellm_usertable.rows assert users["u1"].teams == ["t1", "t2"] and users["u1"].max_budget == 50 @@ -225,9 +225,9 @@ async def test_user_id_already_on_the_roster_keeps_the_team_and_is_not_added_twi prisma = _FakePrisma(teams=[_team("t1", [Member(user_id="u1", role="user")])]) response = await _run(prisma, [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2", "teams": ["t1"]}]) - assert [r.success for r in response.results] == [True, True] - assert [r.teams for r in response.results] == [("t1",), ("t1",)] - assert [r.error for r in response.results] == [None, None] + assert [r.success for r in response.data] == [True, True] + assert [r.teams for r in response.data] == [("t1",), ("t1",)] + assert [r.error for r in response.data] == [None, None] assert prisma.db.litellm_usertable.rows["u1"].teams == ["t1"] assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1", "u2"] @@ -267,9 +267,9 @@ async def test_bad_rows_fail_alone_and_good_rows_still_land(): ], ) - assert [r.success for r in response.results] == [True, False, False, False, False, False, False, False, True] - assert (response.successful_creations, response.failed_creations) == (2, 7) - errors = [r.error for r in response.results] + assert [r.success for r in response.data] == [True, False, False, False, False, False, False, False, True] + assert (response.meta.created, response.meta.failed) == (2, 7) + errors = [r.error for r in response.data] assert "Duplicate user_email" in errors[1] assert "Duplicate user_id" in errors[2] assert "already exists" in errors[3] and "already exists" in errors[4] @@ -289,8 +289,8 @@ async def test_insert_failure_falls_back_to_per_row_and_reports_only_that_row(): [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2", "teams": ["t1"]}, {"user_id": "u3"}], ) - assert [r.success for r in response.results] == [True, False, True] - assert "insert failed for u2" in (response.results[1].error or "") + assert [r.success for r in response.data] == [True, False, True] + assert "insert failed for u2" in (response.data[1].error or "") assert set(prisma.db.litellm_usertable.rows) == {"u1", "u3"} assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1"] @@ -300,8 +300,8 @@ async def test_insert_that_committed_but_lost_its_response_still_counts_as_creat prisma = _FakePrisma(teams=[_team("t1")], commit_then_drop=True) response = await _run(prisma, [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2"}]) - assert [r.success for r in response.results] == [True, True] - assert [r.error for r in response.results] == [None, None] + assert [r.success for r in response.data] == [True, True] + assert [r.error for r in response.data] == [None, None] assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2"} assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u1"] @@ -311,8 +311,8 @@ async def test_user_id_taken_by_a_concurrent_request_is_not_claimed_by_this_batc prisma = _FakePrisma(teams=[_team("t1")], raced_ids=frozenset({"u1"})) response = await _run(prisma, [{"user_id": "u1", "teams": ["t1"]}, {"user_id": "u2", "teams": ["t1"]}]) - assert [r.success for r in response.results] == [False, True] - assert "User id=u1 already exists" in (response.results[0].error or "") + assert [r.success for r in response.data] == [False, True] + assert "User id=u1 already exists" in (response.data[0].error or "") assert prisma.db.litellm_usertable.rows["u1"].user_email == "u1@other-request.example" assert [m.user_id for m in prisma.db.litellm_teamtable.rows["t1"].members_with_roles] == ["u2"] @@ -327,12 +327,12 @@ async def test_team_write_failure_keeps_user_and_reports_it_on_the_row(): prisma.db.litellm_teamtable.update = explode response = await _run(prisma, [{"user_id": "u1", "teams": ["t1", "t2"]}]) - result = response.results[0] + result = response.data[0] assert result.success is True assert result.teams == () assert "t1" in (result.error or "") and "roster write failed" in (result.error or "") assert prisma.db.litellm_usertable.rows["u1"].teams == [] - assert (response.successful_creations, response.failed_creations) == (1, 0) + assert (response.meta.created, response.meta.failed) == (1, 0) @pytest.mark.asyncio @@ -364,7 +364,7 @@ async def test_keys_are_opt_in_per_row(): generate_key=generate_key, ) - assert [r.key for r in response.results] == [None, "sk-u2", None] + assert [r.key for r in response.data] == [None, "sk-u2", None] assert len(calls) == 1 assert calls[0]["user_id"] == "u2" and calls[0]["table_name"] == "key" assert calls[0]["models"] == ("gpt-4o",) and calls[0]["key_alias"] == "u2-key" @@ -385,8 +385,8 @@ async def test_non_admin_cannot_create_admin_users_but_other_rows_proceed(): caller=INTERNAL, ) - assert [r.success for r in response.results] == [False, True] - assert "Only proxy admins" in (response.results[0].error or "") + assert [r.success for r in response.data] == [False, True] + assert "Only proxy admins" in (response.data[0].error or "") assert set(prisma.db.litellm_usertable.rows) == {"u2"} @@ -396,19 +396,19 @@ async def test_license_is_checked_once_against_the_whole_batch(): prisma.db.litellm_usertable.rows["existing"] = _UserRow(user_id="existing") license = _License(max_users=3) - with pytest.raises(HTTPException) as exc: + with pytest.raises(ManagementProblem) as exc: await _run(prisma, [{"user_id": f"u{i}"} for i in range(3)], license=license) - assert exc.value.status_code == 403 + assert (exc.value.problem.status, exc.value.problem.type) == (403, "urn:litellm:error:license-limit-exceeded") assert license.seen == [4] assert set(prisma.db.litellm_usertable.rows) == {"existing"} ok = await _run(prisma, [{"user_id": f"u{i}"} for i in range(2)], license=license) - assert ok.successful_creations == 2 + assert ok.meta.created == 2 resend = await _run(prisma, [{"user_id": f"u{i}"} for i in range(2)], license=license) - assert [r.success for r in resend.results] == [False, False] - assert all("already exists" in (r.error or "") for r in resend.results) + assert [r.success for r in resend.data] == [False, False] + assert all("already exists" in (r.error or "") for r in resend.data) assert license.seen == [4, 3] assert set(prisma.db.litellm_usertable.rows) == {"existing", "u0", "u1"} @@ -422,3 +422,10 @@ def test_request_rejects_empty_oversized_and_invite_rows(): BulkNewUserItem(user_email="a@example.com", send_invite_email=True) assert len(BulkNewUserRequest(users=[{"user_email": f"{i}@example.com"} for i in range(500)]).users) == 500 assert BulkNewUserItem(user_email="a@example.com").auto_create_key is False + + +def test_request_rejects_unknown_fields_at_both_levels(): + with pytest.raises(ValidationError, match="extra_forbidden"): + BulkNewUserRequest(users=[{"user_email": "a@example.com", "user_emial": "typo"}]) + with pytest.raises(ValidationError, match="extra_forbidden"): + BulkNewUserRequest(users=[{"user_email": "a@example.com"}], dry_run=True) diff --git a/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py index 4aea2e16364..53ea761daa7 100644 --- a/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py +++ b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py @@ -227,7 +227,9 @@ async def test_otel_request_validation_exception_handler_empty_errors_invalid_pa async def test_otel_request_validation_exception_handler_returns_a_problem_on_the_control_plane(): """`/management/v1` answers validation errors as RFC 9457, so a caller there gets a 400 problem document rather than the proxy-wide 422 `{"detail": [...]}` shape.""" - errors = [{"loc": ["query", "page_size"], "msg": "Input should be less than or equal to 100", "type": "less_than_equal"}] + errors = [ + {"loc": ["query", "page_size"], "msg": "Input should be less than or equal to 100", "type": "less_than_equal"} + ] exc = RequestValidationError(errors) request = _make_request(path="/management/v1/spend_logs/end_users") @@ -242,6 +244,26 @@ async def test_otel_request_validation_exception_handler_returns_a_problem_on_th assert "detail" in body and not isinstance(body["detail"], list) +@pytest.mark.asyncio +async def test_otel_request_validation_exception_handler_answers_a_bad_control_plane_body_with_422(): + """A request body that fails validation, an unknown field included, is 422 on + `/management/v1`; only query parameter problems are 400.""" + errors = [ + {"loc": ["body", "users", 0, "user_emial"], "msg": "Extra inputs are not permitted", "type": "extra_forbidden"} + ] + exc = RequestValidationError(errors) + request = _make_request(path="/management/v1/users/bulk") + + response = await otel_request_validation_exception_handler(request=request, exc=exc) + body = json.loads(response.body) + + assert response.status_code == 422 + assert response.media_type == "application/problem+json" + assert body["type"] == "urn:litellm:error:invalid-request-body" + assert body["status"] == 422 + assert "users.0.user_emial: Extra inputs are not permitted" in body["detail"] + + @pytest.mark.asyncio async def test_otel_request_validation_exception_handler_leaves_other_routes_on_422(): """The problem+json branch is scoped by path prefix. A route that merely contains @@ -249,9 +271,7 @@ async def test_otel_request_validation_exception_handler_leaves_other_routes_on_ exc = RequestValidationError([]) for path in ("/management", "/v1/management/foo", "/customer/list"): - response = await otel_request_validation_exception_handler( - request=_make_request(path=path), exc=exc - ) + response = await otel_request_validation_exception_handler(request=_make_request(path=path), exc=exc) assert response.status_code == 422, path assert json.loads(response.body) == {"detail": []}, path @@ -294,6 +314,4 @@ async def test_otel_unhandled_exception_handler_reraises_proxy_exception_error() async def test_otel_unhandled_exception_handler_reraises_http_exception_invalid(): request = _make_request() with pytest.raises(HTTPException): - await otel_unhandled_exception_handler( - request=request, exc=HTTPException(status_code=418, detail="teapot") - ) + await otel_unhandled_exception_handler(request=request, exc=HTTPException(status_code=418, detail="teapot")) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f63bed3c3ec..366edbe13e0 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8470,6 +8470,54 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/users/bulk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Create Users Route + * @description Create up to 500 internal users in one request, optionally adding each one to teams. + * + * Every entry in `users` takes the same fields as `/user/new`, with two differences: `auto_create_key` + * defaults to `false` (opt in per user to also get a virtual key back) and `send_invite_email` is not + * supported. Unknown fields are rejected with 422. Rows are validated together (duplicate ids or emails, + * unknown teams, roles the caller may not grant), inserted in one statement, and each referenced team is + * written once for all of its new members. + * + * Rows fail independently: a bad row is reported in `data` with `success: false` and an `error`, and the + * other rows still get created. A user that was created but could not be added to one of its teams is + * reported with `success: true`, `teams` listing where they did land, and `error` naming the failed team. + * The whole request is refused with a 403 problem document only if creating the valid rows would exceed + * the license seat limit. + * + * Example curl: + * ``` + * curl -X POST "http://localhost:4000/management/v1/users/bulk" \ + * -H "Content-Type: application/json" \ + * -H "Authorization: Bearer sk-1234" \ + * -d '{ + * "users": [ + * {"user_email": "a@example.com", "user_role": "internal_user", "teams": ["team-1"]}, + * {"user_email": "b@example.com", "user_role": "internal_user", "auto_create_key": true} + * ] + * }' + * ``` + * + * Returns `data` (one entry per input row, in order, with `user_id`, `user_email`, `success`, `teams`, + * `key`, `error`) and `meta` with `total_requested`, `created` and `failed`. + */ + post: operations["bulk_create_users_route_management_v1_users_bulk_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/mcp": { parameters: { query?: never; @@ -16478,53 +16526,6 @@ export interface paths { patch?: never; trace?: never; }; - "/user/bulk_new": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Bulk New User - * @description Create up to 500 internal users in one request, optionally adding each one to teams. - * - * Every entry in `users` takes the same fields as `/user/new`, with two differences: `auto_create_key` - * defaults to `false` (opt in per user to also get a virtual key back) and `send_invite_email` is not - * supported. Rows are validated together (duplicate ids or emails, unknown teams, roles the caller may not - * grant), inserted in one statement, and each referenced team is written once for all of its new members. - * - * Rows fail independently: a bad row is reported in `results` with `success: false` and an `error`, and the - * other rows still get created. A user that was created but could not be added to one of its teams is - * reported with `success: true`, `teams` listing where they did land, and `error` naming the failed team. - * The whole request is rejected with 403 only if creating the valid rows would exceed the license seat limit. - * - * Usage Example - * - * ```shell - * curl -X POST "http://localhost:4000/user/bulk_new" \ - * -H "Content-Type: application/json" \ - * -H "Authorization: Bearer sk-1234" \ - * -d '{ - * "users": [ - * {"user_email": "a@example.com", "user_role": "internal_user", "teams": ["team-1"]}, - * {"user_email": "b@example.com", "user_role": "internal_user", "auto_create_key": true} - * ] - * }' - * ``` - * - * Returns `results` (one entry per input row, in order, with `user_id`, `user_email`, `success`, `teams`, - * `key`, `error`), `total_requested`, `successful_creations` and `failed_creations`. - */ - post: operations["bulk_new_user_user_bulk_new_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/user/bulk_update": { parameters: { query?: never; @@ -24552,7 +24553,8 @@ export interface components { }; /** * BulkNewUserItem - * @description One row of `/user/bulk_new`: the `/user/new` body, with keys opt-in and invite emails unsupported. + * @description 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. */ BulkNewUserItem: { /** Agent Id */ @@ -24676,21 +24678,28 @@ export interface components { /** User Role */ user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null; }; + /** BulkNewUserMeta */ + BulkNewUserMeta: { + /** Created */ + created: number; + /** Failed */ + failed: number; + /** Total Requested */ + total_requested: number; + }; /** BulkNewUserRequest */ BulkNewUserRequest: { /** Users */ users: components["schemas"]["BulkNewUserItem"][]; }; - /** BulkNewUserResponse */ + /** + * BulkNewUserResponse + * @description `data` holds one result per input row, in input order. + */ BulkNewUserResponse: { - /** Failed Creations */ - failed_creations: number; - /** Results */ - results: components["schemas"]["UserCreateResult"][]; - /** Successful Creations */ - successful_creations: number; - /** Total Requested */ - total_requested: number; + /** Data */ + data: components["schemas"]["UserCreateResult"][]; + meta: components["schemas"]["BulkNewUserMeta"]; }; /** * BulkTeamMemberAddRequest @@ -39534,7 +39543,8 @@ export interface components { }; /** * UserCreateResult - * @description Outcome for one row of `/user/bulk_new`. `teams` lists the teams the user was actually added to. + * @description Outcome for one row of `POST /management/v1/users/bulk`. `teams` lists the teams the user was actually + * added to. */ UserCreateResult: { /** Error */ @@ -51322,6 +51332,39 @@ export interface operations { }; }; }; + bulk_create_users_route_management_v1_users_bulk_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkNewUserRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkNewUserResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; aggregate_mcp_route_mcp_get: { parameters: { query?: never; @@ -60836,39 +60879,6 @@ export interface operations { }; }; }; - bulk_new_user_user_bulk_new_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BulkNewUserRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BulkNewUserResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; bulk_user_update_user_bulk_update_post: { parameters: { query?: never; From ca04b03c2c4af8844199adc46315f043d06b92a3 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 22:31:07 +0000 Subject: [PATCH 17/25] feat(proxy): move bulk user delete and team member delete under /management/v1 Replaces POST /user/bulk_delete and POST /team/bulk_member_delete with POST /management/v1/users/bulk_delete and POST /management/v1/teams/{team_id}/members/bulk_delete per the Management API modernization one-pager: {data} envelopes, application/problem+json errors with urn:litellm:error:* types, 422 on unknown body fields, 400 on unknown query params, 403 on authorization failures, 404 on unknown team. Route checks now match parametrized management/v1 paths so team-scoped callers reach the endpoint's own authorization and get a 403 problem instead of the generic 401. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 5 +- litellm/proxy/auth/route_checks.py | 20 +- .../internal_user_endpoints.py | 44 --- .../management_v1/__init__.py | 8 + .../management_v1/teams.py | 94 ++++++ .../management_v1/users.py | 101 ++++++ .../management_endpoints/team_endpoints.py | 32 -- .../management_helpers/bulk_user_deletion.py | 72 ++-- litellm/proxy/proxy_server.py | 25 +- .../internal_user_endpoints.py | 16 +- .../management_endpoints/management_v1.py | 6 + .../management_endpoints/team_endpoints.py | 37 ++- .../endpointaudit/coverage_allowlist.txt | 2 - .../test_team_bulk_member_delete.py | 76 ++++- .../management/test_users_bulk_delete.py | 137 ++++++++ .../test_bulk_user_deletion.py | 120 +++---- ui/litellm-dashboard/src/lib/http/schema.d.ts | 314 ++++++++++-------- 17 files changed, 735 insertions(+), 374 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/teams.py create mode 100644 litellm/proxy/management_endpoints/management_v1/users.py create mode 100644 tests/proxy_behavior/management/test_users_bulk_delete.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 95ca0ad352b..9d6fff0fa37 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -659,7 +659,7 @@ class LiteLLMRoutes(enum.Enum): "/user/update", "/user/bulk_update", "/user/delete", - "/user/bulk_delete", + "/management/v1/users/bulk_delete", "/user/info", "/user/list", "/user/daily/activity", @@ -839,7 +839,7 @@ class LiteLLMRoutes(enum.Enum): self_managed_routes = [ "/team/member_add", "/team/member_delete", - "/team/bulk_member_delete", + "/management/v1/teams/{team_id}/members/bulk_delete", "/team/member_update", "/team/{team_id}/member/{user_id}/reset_spend", "/team/permissions_list", @@ -866,6 +866,7 @@ class LiteLLMRoutes(enum.Enum): "/organization/daily/activity", "/user/available_roles", # read-only role metadata; any authenticated user may read "/user/list", # org admins checked in endpoint; non-admins get 403 + "/management/v1/users/bulk_delete", # proxy admins delete anyone, org admins only their orgs' users; others 403 "/model/{model_id}/update", "/prompt/list", "/prompt/info", diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 38cabcd0944..4c64d4d7e23 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -1,5 +1,5 @@ import re -from collections.abc import Sequence +from collections.abc import Collection from typing import Final from fastapi import HTTPException, Request, status @@ -25,11 +25,11 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset( # user "/user/new", "/user/delete", - "/user/bulk_delete", + "/management/v1/users/bulk_delete", "/user/bulk_update", # team "/team/new", - "/team/bulk_member_delete", + "/management/v1/teams/{team_id}/members/bulk_delete", "/team/update", "/team/delete", "/team/block", @@ -589,7 +589,7 @@ class RouteChecks: return False @staticmethod - def check_route_access(route: str, allowed_routes: Sequence[str]) -> bool: + def check_route_access(route: str, allowed_routes: Collection[str]) -> bool: """ Check if a route has access by checking both exact matches and patterns @@ -761,10 +761,10 @@ class RouteChecks: [ "/user/new", "/user/delete", - "/user/bulk_delete", + "/management/v1/users/bulk_delete", "/user/bulk_update", "/team/new", - "/team/bulk_member_delete", + "/management/v1/teams/{team_id}/members/bulk_delete", "/team/update", "/team/delete", "/model/new", @@ -828,7 +828,7 @@ class RouteChecks: status_code=status.HTTP_403_FORBIDDEN, detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route} and updating invalid param: {param}. only user_email and password can be updated", ) - elif route in _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES or ( + elif RouteChecks.check_route_access(route=route, allowed_routes=_PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES) or ( route.startswith("/key/") and route.endswith(_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES) ): # Block write operations for PROXY_ADMIN_VIEW_ONLY @@ -863,9 +863,9 @@ class RouteChecks: # Hard-block known write routes regardless of HTTP method (defensive # — these are POSTs in practice, but pinning them here protects # against future GET-shaped writes). - if route in RouteChecks._ADMIN_VIEWER_BLOCKED_WRITE_ROUTES or ( - route.startswith("/key/") and route.endswith("/regenerate") - ): + if RouteChecks.check_route_access( + route=route, allowed_routes=RouteChecks._ADMIN_VIEWER_BLOCKED_WRITE_ROUTES + ) or (route.startswith("/key/") and route.endswith("/regenerate")): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route}", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index c9fc6720490..e3efda507f6 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -8,7 +8,6 @@ These are members of a Team on LiteLLM /user/update /user/bulk_update /user/delete -/user/bulk_delete /user/info /user/list """ @@ -56,7 +55,6 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, prepare_metadata_fields, ) -from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, handle_update_object_permission_common, @@ -79,8 +77,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( - BulkDeleteUserRequest, - BulkDeleteUserResponse, BulkUpdateUserRequest, BulkUpdateUserResponse, UserListResponse, @@ -2500,46 +2496,6 @@ async def delete_user( return deleted_users -@router.post( - "/user/bulk_delete", - tags=["Internal User management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence - dependencies=(Depends(user_api_key_auth),), - response_model=BulkDeleteUserResponse, -) -@management_endpoint_wrapper -async def bulk_delete_user( - data: BulkDeleteUserRequest, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection - litellm_changed_by: str | None = Header( - None, - description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", - ), -) -> BulkDeleteUserResponse: - """Delete up to 500 users, removing each from every team; same authorization as `/user/delete`.""" - from litellm.proxy.proxy_server import ( - litellm_proxy_admin_name, - prisma_client, - proxy_logging_obj, - user_api_key_cache, - ) - - if prisma_client is None: - raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) - try: - return await bulk_delete_users( - data=data, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - litellm_proxy_admin_name=litellm_proxy_admin_name, - litellm_changed_by=litellm_changed_by, - ) - except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract - verbose_proxy_logger.exception("/user/bulk_delete: Exception occured") - raise handle_exception_on_proxy(e) - - async def add_internal_user_to_organization( user_id: str, organization_id: str, diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index 342e6525cda..b29b7fe5dd5 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -10,9 +10,17 @@ from litellm.proxy.management_endpoints.management_v1.budgets import ( from litellm.proxy.management_endpoints.management_v1.spend_logs import ( router as spend_logs_router, ) +from litellm.proxy.management_endpoints.management_v1.teams import ( + router as teams_router, +) +from litellm.proxy.management_endpoints.management_v1.users import ( + router as users_router, +) router: Final = APIRouter() router.include_router(budgets_router) router.include_router(spend_logs_router) +router.include_router(teams_router) +router.include_router(users_router) __all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/management_v1/teams.py b/litellm/proxy/management_endpoints/management_v1/teams.py new file mode 100644 index 00000000000..ba384bfb028 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/teams.py @@ -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.", + ) + ) diff --git a/litellm/proxy/management_endpoints/management_v1/users.py b/litellm/proxy/management_endpoints/management_v1/users.py new file mode 100644 index 00000000000..fa2a5cf3c3d --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/users.py @@ -0,0 +1,101 @@ +"""`POST /management/v1/users/bulk_delete`.""" + +from typing import Annotated, Final + +from fastapi import APIRouter, Depends, Header + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem, reject_unknown_query_params +from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX +from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users +from litellm.proxy.management_helpers.utils import ( + management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped +) +from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkDeleteUserRequest, + BulkDeleteUsersResponse, +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail + +router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +@router.post( + "/users/bulk_delete", + tags=["Internal User management"], # mutable-ok: FastAPI types `tags` as list[str], not Sequence + dependencies=(Depends(user_api_key_auth), Depends(reject_unknown_query_params)), + response_model=BulkDeleteUsersResponse, +) +@management_endpoint_wrapper +async def bulk_delete_users_action( + data: BulkDeleteUserRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + litellm_changed_by: Annotated[ + str | None, + Header(description="Who the caller is acting for; recorded on the audit log entries this call writes."), + ] = None, +) -> BulkDeleteUsersResponse: + """ + Delete up to 500 users in one call, taking each out of every team it belongs to. + Same authorization as `/user/delete`: proxy admins may delete anyone, org admins + only users inside organizations they administer. Unknown body fields are a 422. + + `data` holds one result per requested `user_id`, in request order. A row is + `success: false` with an `error` when the id is unknown, repeated in the request, + or outside the caller's scope. Rows that pass those checks are deleted together, + in one transaction, so either all of them go or none does. + + Example curl: + ``` + curl --location 'http://0.0.0.0:4000/management/v1/users/bulk_delete' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'Content-Type: application/json' \ + --data '{"user_ids": ["user-1", "user-2"]}' + ``` + """ + try: + from litellm.proxy.proxy_server import ( + litellm_proxy_admin_name, + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + if prisma_client is None: + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}database-not-connected", + title="Database not connected", + status=503, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + ) + + results: Final = await bulk_delete_users( + data=data, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + litellm_proxy_admin_name=litellm_proxy_admin_name, + litellm_changed_by=litellm_changed_by, + ) + return BulkDeleteUsersResponse(data=results) + + except ManagementProblem: + raise + except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.management_v1.users.bulk_delete_users_action(): Exception occured - %s", + e, + ) + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}internal-server-error", + title="Internal server error", + status=500, + detail="Failed to delete users.", + ) + ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index fbcde2bc032..6b16692f7ad 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -163,8 +163,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkTeamMemberAddRequest, BulkTeamMemberAddResponse, - BulkTeamMemberDeleteRequest, - BulkTeamMemberDeleteResponse, BulkUpdateTeamMemberPermissionsRequest, BulkUpdateTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse, @@ -3455,36 +3453,6 @@ async def team_member_delete( return existing_team_row -@router.post( - "/team/bulk_member_delete", - tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence - dependencies=(Depends(user_api_key_auth),), - response_model=BulkTeamMemberDeleteResponse, -) -@management_endpoint_wrapper -async def bulk_team_member_delete( - data: BulkTeamMemberDeleteRequest, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection -) -> BulkTeamMemberDeleteResponse: - """Remove up to 500 members from one team; same authorization as `/team/member_delete`.""" - from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members - from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache - - if prisma_client is None: - raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value) - try: - return await bulk_remove_team_members( - data=data, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract - verbose_proxy_logger.exception("/team/bulk_member_delete: Exception occured") - raise handle_exception_on_proxy(e) - - _MEMBER_BUDGET_PATCH_FIELDS: Final = { "max_budget_in_team": "max_budget", "tpm_limit": "tpm_limit", diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index 92e21a3803b..53527c42c25 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -1,4 +1,5 @@ -"""Batched deletes behind `POST /user/bulk_delete` and `POST /team/bulk_member_delete`. +"""Batched deletes behind `POST /management/v1/users/bulk_delete` and +`POST /management/v1/teams/{team_id}/members/bulk_delete`. Each team a batch touches is rewritten exactly once, under the same advisory lock `/team/member_delete` takes and from a roster re-read under that lock, so a concurrent @@ -31,6 +32,7 @@ from litellm.proxy.auth.auth_checks import delete_cache_key_objects from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks +from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem from litellm.proxy.management_endpoints.common_utils import ( _is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses _is_user_team_admin, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses @@ -48,12 +50,11 @@ from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkDeleteUserRequest, - BulkDeleteUserResponse, UserDeleteResult, ) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkTeamMemberDeleteRequest, - BulkTeamMemberDeleteResponse, TeamMemberDeleteResult, ) @@ -67,10 +68,6 @@ _AUDIT_LOG_CONCURRENCY: Final = 10 _BATCH_TX_TIMEOUT: Final = timedelta(seconds=60) -class _ErrorDetail(TypedDict): - error: ReadOnly[str] - - class _OrgAdminFilter(TypedDict): user_id: ReadOnly[str] user_role: ReadOnly[str] @@ -105,9 +102,21 @@ class _UserBatchDeletion: deleted_key_tokens: tuple[str, ...] -def _http_error(status_code: int, message: str) -> HTTPException: - detail: Final[_ErrorDetail] = {"error": message} - return HTTPException(status_code=status_code, detail=detail) +def _team_not_found(team_id: str) -> ManagementProblem: + return ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}team-not-found", + title="Team not found", + status=404, + detail=f"Team id={team_id} does not exist in db", + ) + ) + + +def _forbidden(detail: str) -> ManagementProblem: + return ManagementProblem( + ProblemDetail(type=f"{PROBLEM_TYPE_BASE}forbidden", title="Forbidden", status=403, detail=detail) + ) def _in_filter(field: str, values: Iterable[str]) -> Mapping[str, object]: @@ -167,6 +176,8 @@ def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDele def _error_message(exc: BaseException) -> str: + if isinstance(exc, ManagementProblem): + return exc.problem.detail if isinstance(exc, HTTPException) and isinstance(exc.detail, dict): return str(exc.detail.get("error", exc.detail)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # HTTPException.detail is untyped if isinstance(exc, HTTPException): @@ -194,7 +205,7 @@ async def _remove_members_from_team( await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id) roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id) if roster is None: - raise _http_error(400, f"Team id={team_id} does not exist in db") + raise _team_not_found(team_id) removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members)) kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members)) @@ -269,32 +280,32 @@ def _duplicate_member_indexes(members: Sequence[MemberDeleteRequest]) -> frozens async def bulk_remove_team_members( + team_id: str, data: BulkTeamMemberDeleteRequest, user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, user_api_key_cache: UserApiKeyCache, proxy_logging_obj: ProxyLogging | None, -) -> BulkTeamMemberDeleteResponse: - team: Final = await TeamRepository(prisma_client).find_by_id(data.team_id) +) -> tuple[TeamMemberDeleteResult, ...]: + team: Final = await TeamRepository(prisma_client).find_by_id(team_id) if team is None: - raise _http_error(400, f"Team id={data.team_id} does not exist in db") + raise _team_not_found(team_id) if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team) and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team) ): - raise _http_error( - 403, + raise _forbidden( "Call not allowed. User not proxy admin OR team admin OR org admin for this team. " - f"route='/team/bulk_member_delete', team_id={data.team_id}", + f"route='/management/v1/teams/{team_id}/members/bulk_delete'" ) duplicates: Final = _duplicate_member_indexes(data.members) kept_indexes: Final = tuple(i for i in range(len(data.members)) if i not in duplicates) members: Final = tuple(data.members[i] for i in kept_indexes) async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx: - removal: Final = await _remove_members_from_team(prisma_client, tx, data.team_id, members, user_api_key_dict) + removal: Final = await _remove_members_from_team(prisma_client, tx, team_id, members, user_api_key_dict) await delete_cache_key_objects( hashed_tokens=removal.deleted_key_tokens, user_api_key_cache=user_api_key_cache, @@ -309,7 +320,7 @@ async def bulk_remove_team_members( return "Duplicate member in request" return None if index in matched else "User not found in team" - results: Final = tuple( + return tuple( TeamMemberDeleteResult( user_id=member.user_id, user_email=member.user_email, @@ -318,14 +329,6 @@ async def bulk_remove_team_members( ) for i, member in enumerate(data.members) ) - successful: Final = sum(1 for r in results if r.success) - return BulkTeamMemberDeleteResponse( - team_id=data.team_id, - results=results, - total_requested=len(results), - successful_deletions=successful, - failed_deletions=len(results) - successful, - ) async def _caller_admin_org_ids(prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]: @@ -433,7 +436,7 @@ async def _delete_users( try: deletion: Final = await _delete_users_tx(prisma_client, users, teams_of, user_api_key_dict, litellm_changed_by) except Exception as e: # noqa: BLE001 # the rolled-back batch is reported per row, not as a request failure - verbose_proxy_logger.error("/user/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e) + verbose_proxy_logger.error("users/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e) return _error_message(e) await delete_cache_key_objects( hashed_tokens=deletion.deleted_key_tokens, @@ -468,11 +471,11 @@ async def bulk_delete_users( proxy_logging_obj: ProxyLogging | None, litellm_proxy_admin_name: str | None, litellm_changed_by: str | None, -) -> BulkDeleteUserResponse: +) -> tuple[UserDeleteResult, ...]: caller_is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value caller_admin_org_ids: Final = await _caller_admin_org_ids(prisma_client, user_api_key_dict) if not caller_is_proxy_admin and not caller_admin_org_ids: - raise _http_error(403, "Only PROXY_ADMIN or ORG_ADMIN users may delete users.") + raise _forbidden("Only PROXY_ADMIN or ORG_ADMIN users may delete users.") unique_ids: Final = frozenset(data.user_ids) rows: Final = await UserRepository(prisma_client).table.find_many(where=_in_filter("user_id", unique_ids)) @@ -543,11 +546,4 @@ async def bulk_delete_users( teams_removed=tuple(tid for tid, r in deletion.removals.items() if user_id in r.removed), ) - results: Final = tuple(result(i, uid) for i, uid in enumerate(data.user_ids)) - successful: Final = sum(1 for r in results if r.success) - return BulkDeleteUserResponse( - results=results, - total_requested=len(results), - successful_deletions=successful, - failed_deletions=len(results) - successful, - ) + return tuple(result(i, uid) for i, uid in enumerate(data.user_ids)) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1c931863a2f..88844c04e74 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1788,24 +1788,37 @@ class _ExceptionRow(TypedDict, total=False): class _ValidationErrorDetail(TypedDict): + type: str loc: tuple[int | str, ...] msg: str +def _is_length_error_of_rejected_items(error: _ValidationErrorDetail, errors: Sequence[_ValidationErrorDetail]) -> bool: + """pydantic counts only items that validated, so a bad item also trips the parent's min_length.""" + return error["type"] == "too_short" and any( + len(other["loc"]) > len(error["loc"]) and other["loc"][: len(error["loc"])] == error["loc"] for other in errors + ) + + @app.exception_handler(RequestValidationError) async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError): if request.url.path.startswith(MANAGEMENT_V1_PREFIX): - _close_dangling_otel_server_span(request, 400, exc=exc) - validation_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors() + raw_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors() + validation_errors: Final = tuple( + error for error in raw_errors if not _is_length_error_of_rejected_items(error, raw_errors) + ) + in_body: Final = any(error["loc"] and error["loc"][0] == "body" for error in validation_errors) + status: Final = 422 if in_body else 400 + _close_dangling_otel_server_span(request, status, exc=exc) return problem_response( ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", - title="Invalid query parameter", - status=400, + type=f"{PROBLEM_TYPE_BASE}{'invalid-request-body' if in_body else 'invalid-query-parameter'}", + title="Invalid request body" if in_body else "Invalid query parameter", + status=status, detail="; ".join( f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors ) - or "The request query parameters are invalid.", + or "The request is invalid.", ) ) _close_dangling_otel_server_span(request, 422, exc=exc) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index 7d9f2bd2a85..2b41893663d 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from typing import Any, Final, Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import ( @@ -9,6 +9,7 @@ from litellm.proxy._types import ( UpdateUserRequest, UpdateUserRequestNoUserIDorEmail, ) +from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse MAX_BULK_DELETE_USERS: Final = 500 @@ -88,11 +89,15 @@ class BulkUpdateUserResponse(BaseModel): class BulkDeleteUserRequest(BaseModel): + """Body of `POST /management/v1/users/bulk_delete`.""" + + model_config = ConfigDict(extra="forbid") + user_ids: tuple[str, ...] = Field(min_length=1, max_length=MAX_BULK_DELETE_USERS) class UserDeleteResult(BaseModel): - """Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of.""" + """Outcome for one requested user, in request order. `teams_removed` lists the teams the user left.""" user_id: str user_email: str | None = None @@ -101,8 +106,5 @@ class UserDeleteResult(BaseModel): error: str | None = None -class BulkDeleteUserResponse(BaseModel): - results: tuple[UserDeleteResult, ...] - total_requested: int - successful_deletions: int - failed_deletions: int +class BulkDeleteUsersResponse(ResourceResponse[tuple[UserDeleteResult, ...]]): + """`{data: [...]}` with one `UserDeleteResult` per requested user, in request order.""" diff --git a/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py index aa82138110d..c23d0ecfb54 100644 --- a/litellm/types/proxy/management_endpoints/management_v1.py +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -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`.""" diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 1825813ae9c..5f5be81ee4b 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,6 +1,6 @@ from typing import Any, Final, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from litellm.proxy._types import ( KeyManagementRoutes, @@ -10,6 +10,7 @@ from litellm.proxy._types import ( Member, MemberDeleteRequest, ) +from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse TeamIdSearchMatch = Literal["exact", "prefix"] @@ -121,20 +122,28 @@ class BulkTeamMemberAddResponse(BaseModel): updated_team: dict[str, Any] | None = None -class BulkTeamMemberDeleteRequest(BaseModel): - team_id: str - members: tuple[MemberDeleteRequest, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES) +class TeamMemberRef(MemberDeleteRequest): + """One member to remove, named by exactly one of `user_id` or `user_email`.""" - @field_validator("members") - @classmethod - def one_identifier_per_member(cls, members: tuple[MemberDeleteRequest, ...]) -> tuple[MemberDeleteRequest, ...]: - if any(m.user_id is not None and m.user_email is not None for m in members): + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def one_identifier(self) -> "TeamMemberRef": + if self.user_id is not None and self.user_email is not None: raise ValueError("Each member must be identified by exactly one of user_id or user_email") - return members + return self + + +class BulkTeamMemberDeleteRequest(BaseModel): + """Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`.""" + + model_config = ConfigDict(extra="forbid") + + members: tuple[TeamMemberRef, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES) class TeamMemberDeleteResult(BaseModel): - """Outcome for one row of `/team/bulk_member_delete`.""" + """Outcome for one requested member, in request order.""" user_id: str | None = None user_email: str | None = None @@ -142,12 +151,8 @@ class TeamMemberDeleteResult(BaseModel): error: str | None = None -class BulkTeamMemberDeleteResponse(BaseModel): - team_id: str - results: tuple[TeamMemberDeleteResult, ...] - total_requested: int - successful_deletions: int - failed_deletions: int +class BulkTeamMemberDeleteResponse(ResourceResponse[tuple[TeamMemberDeleteResult, ...]]): + """`{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order.""" class TeamMemberInfoResponse(LiteLLM_TeamMembership): diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 53a6bd4f8c6..6bc8947e89f 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -80,12 +80,10 @@ POST /model/unblock POST /prompts/test POST /search_tools/test_connection POST /team/bulk_member_add -POST /team/bulk_member_delete POST /team/{team_id}/member/{user_id}/reset_spend POST /team/key/bulk_update POST /team/permissions_bulk_update POST /team/{team_id}/disable_logging -POST /user/bulk_delete POST /user/bulk_update # Alternate method or path for functionality the provider already manages elsewhere diff --git a/tests/proxy_behavior/management/test_team_bulk_member_delete.py b/tests/proxy_behavior/management/test_team_bulk_member_delete.py index 09a3cd54783..5283497c6bf 100644 --- a/tests/proxy_behavior/management/test_team_bulk_member_delete.py +++ b/tests/proxy_behavior/management/test_team_bulk_member_delete.py @@ -71,17 +71,20 @@ async def test_team_bulk_member_delete_authz_matrix( caller = world.keys[actor] resp = await proxy_client.post( - "/team/bulk_member_delete", + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", headers={"Authorization": f"Bearer {caller.cleartext}"}, - json={"team_id": scratch.prefix, "members": [{"user_id": v} for v in victims]}, + json={"members": [{"user_id": v} for v in victims]}, ) assert resp.status_code == expected_status, f"{actor.value} {shape}: {resp.status_code} {resp.text}" + if expected_status == 403: + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:forbidden" row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) assert row is not None assert keep in _member_ids(row), "unrelated member removed" if expected_status == 200: - assert [(r["user_id"], r["success"]) for r in resp.json()["results"]] == [(v, True) for v in victims] + assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [(v, True) for v in victims] assert not set(victims) & set(_member_ids(row)) else: assert set(victims) <= set(_member_ids(row)), "denied but members removed" @@ -94,18 +97,18 @@ async def test_team_bulk_member_delete_reports_each_row_in_order(proxy_client, p await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim, keep]) resp = await proxy_client.post( - "/team/bulk_member_delete", + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, - json={"team_id": scratch.prefix, "members": [{"user_id": stranger}, {"user_id": victim}]}, + json={"members": [{"user_id": stranger}, {"user_id": victim}]}, ) assert resp.status_code == 200, resp.text body = resp.json() - assert [(r["user_id"], r["success"]) for r in body["results"]] == [ + assert set(body) == {"data"} + assert [(r["user_id"], r["success"]) for r in body["data"]] == [ (stranger, False), (victim, True), ] - assert body["results"][0]["error"] == "User not found in team" - assert (body["successful_deletions"], body["failed_deletions"]) == (1, 1) + assert body["data"][0]["error"] == "User not found in team" row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) assert row is not None and _member_ids(row) == [keep] @@ -116,14 +119,61 @@ async def test_team_bulk_member_delete_row_naming_both_identifiers_is_422(proxy_ await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim]) resp = await proxy_client.post( - "/team/bulk_member_delete", + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, - json={ - "team_id": scratch.prefix, - "members": [{"user_id": victim, "user_email": f"{victim}@example.com"}], - }, + json={"members": [{"user_id": victim, "user_email": f"{victim}@example.com"}]}, ) assert resp.status_code == 422, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:invalid-request-body" + assert ( + resp.json()["detail"] + == "members.0: Value error, Each member must be identified by exactly one of user_id or user_email" + ) row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) assert row is not None and victim in _member_ids(row) + + +async def test_team_bulk_member_delete_unknown_query_param_is_400(proxy_client, prisma, scratch, world): + victim = scratch.tag("victim") + await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim]) + + resp = await proxy_client.post( + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete?dry_run=1", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"members": [{"user_id": victim}]}, + ) + assert resp.status_code == 400, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert "dry_run" in resp.json()["detail"] + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert row is not None and victim in _member_ids(row) + + +async def test_team_bulk_member_delete_unknown_body_field_is_422(proxy_client, prisma, scratch, world): + victim = scratch.tag("victim") + await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim]) + + resp = await proxy_client.post( + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "members": [{"user_id": victim}]}, + ) + assert resp.status_code == 422, resp.text + assert "team_id" in resp.json()["detail"] + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert row is not None and victim in _member_ids(row) + + +async def test_team_bulk_member_delete_unknown_team_is_404_problem(proxy_client, scratch, world): + resp = await proxy_client.post( + f"/management/v1/teams/{scratch.tag('missing')}/members/bulk_delete", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"members": [{"user_id": scratch.tag("victim")}]}, + ) + assert resp.status_code == 404, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:team-not-found" diff --git a/tests/proxy_behavior/management/test_users_bulk_delete.py b/tests/proxy_behavior/management/test_users_bulk_delete.py new file mode 100644 index 00000000000..049234e737a --- /dev/null +++ b/tests/proxy_behavior/management/test_users_bulk_delete.py @@ -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 diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index 49aed00177a..077eb90ddce 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -5,14 +5,14 @@ from contextlib import asynccontextmanager from typing import Final import pytest -from fastapi import HTTPException from pydantic import BaseModel, ConfigDict, ValidationError -from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, MemberDeleteRequest, UserAPIKeyAuth +from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.proxy.list_api.common import ManagementProblem from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users, bulk_remove_team_members from litellm.types.proxy.management_endpoints.internal_user_endpoints import BulkDeleteUserRequest -from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest +from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest, TeamMemberRef ADMIN: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin") INTERNAL: Final = UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER) @@ -237,7 +237,8 @@ async def _remove( cache: UserApiKeyCache | None = None, ): return await bulk_remove_team_members( - data=BulkTeamMemberDeleteRequest(team_id=team_id, members=tuple(MemberDeleteRequest(**m) for m in members)), + team_id=team_id, + data=BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(**m) for m in members)), user_api_key_dict=caller, prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient user_api_key_cache=cache or UserApiKeyCache(), @@ -260,10 +261,10 @@ async def test_bulk_delete_removes_users_from_every_team_and_store(): org_memberships=[{"user_id": "u1", "organization_id": "o1", "user_role": "internal_user"}], ) - response = await _delete(prisma, ["u1", "u2"]) + results = await _delete(prisma, ["u1", "u2"]) - assert (response.total_requested, response.successful_deletions, response.failed_deletions) == (2, 2, 0) - assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in response.results] == [ + assert len(results) == 2 + assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in results] == [ ("u1", "u1@example.com", True, ("t1", "t2")), ("u2", "u2@example.com", True, ("t1",)), ] @@ -297,9 +298,9 @@ async def test_bulk_delete_leaves_teammates_who_share_the_deleted_users_email_al ], ) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))] + assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))] assert _roster(prisma, "t1") == ["twin"] assert set(prisma.db.litellm_usertable.rows) == {"twin"} and prisma.db.litellm_usertable.rows["twin"].teams == [ "t1" @@ -319,9 +320,9 @@ async def test_bulk_delete_removes_the_deleted_users_email_only_roster_entry(): ) prisma = _FakePrisma(users=[_user("u1", "t1"), _user("keep", "t1")], teams=[team]) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))] + assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))] assert _roster(prisma, "t1") == ["keep"] assert set(prisma.db.litellm_usertable.rows) == {"keep"} @@ -334,9 +335,9 @@ async def test_bulk_delete_finds_teams_through_membership_rows_when_user_teams_a memberships=[("t1", "u1")], ) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert response.results[0].teams_removed == ("t1",) + assert results[0].teams_removed == ("t1",) assert _roster(prisma, "t1") == ["keep"] assert prisma.db.litellm_teammembership.rows == [] @@ -350,9 +351,9 @@ async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives( prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[team], on_lock=concurrent_member_add) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert response.results[0].success is True + assert results[0].success is True assert _roster(prisma, "t1") == ["late"] @@ -360,10 +361,10 @@ async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives( async def test_bulk_delete_reports_missing_and_duplicate_ids_per_item_and_still_deletes_the_rest(): prisma = _FakePrisma(users=[_user("u1")]) - response = await _delete(prisma, ["u1", "ghost", "u1"]) + results = await _delete(prisma, ["u1", "ghost", "u1"]) - assert (response.successful_deletions, response.failed_deletions) == (1, 2) - assert [(r.user_id, r.success, r.error) for r in response.results] == [ + assert [r.success for r in results].count(True) == 1 + assert [(r.user_id, r.success, r.error) for r in results] == [ ("u1", True, None), ("ghost", False, "User id=ghost not found"), ("u1", False, "Duplicate user_id in request: u1"), @@ -381,9 +382,9 @@ async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_ ) cache = _cache_with("k1") - response = await _delete(prisma, ["u1", "u2"], cache=cache) + results = await _delete(prisma, ["u1", "u2"], cache=cache) - assert [(r.user_id, r.success, r.teams_removed, r.error) for r in response.results] == [ + assert [(r.user_id, r.success, r.teams_removed, r.error) for r in results] == [ ("u1", False, (), "Failed to delete user: lock timeout"), ("u2", False, (), "Failed to delete user: lock timeout"), ] @@ -397,9 +398,9 @@ async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_ async def test_bulk_delete_skips_teams_the_user_still_names_but_which_no_longer_exist(): prisma = _FakePrisma(users=[_user("u1", "gone", "t1")], teams=[_team("t1", "u1", "keep")]) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))] + assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))] assert prisma.db.litellm_usertable.rows == {} and _roster(prisma, "t1") == ["keep"] assert prisma.locks == ["t1"] @@ -414,9 +415,9 @@ async def test_bulk_delete_rolls_back_every_user_row_and_reports_it_per_row_when ) cache = _cache_with("k1") - response = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache) + results = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache) - assert [(r.user_id, r.success, r.error) for r in response.results] == [ + assert [(r.user_id, r.success, r.error) for r in results] == [ ("u1", False, "Failed to delete user: connection reset"), ("u2", False, "Failed to delete user: connection reset"), ("ghost", False, "User id=ghost not found"), @@ -452,10 +453,10 @@ async def test_bulk_delete_evicts_deleted_keys_and_users_from_the_auth_cache(): async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db(): prisma = _FakePrisma(users=[_user("u1")]) - with pytest.raises(HTTPException) as exc: + with pytest.raises(ManagementProblem) as exc: await _delete(prisma, ["u1"], caller=INTERNAL) - assert exc.value.status_code == 403 + assert exc.value.problem.status == 403 assert set(prisma.db.litellm_usertable.rows) == {"u1"} @@ -471,10 +472,10 @@ async def test_org_admin_deletes_only_users_fully_inside_their_orgs(): ], ) - response = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN) + results = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN) - assert [r.success for r in response.results] == [True, False, False] - assert all("not within your admin scope" in (r.error or "") for r in response.results[1:]) + assert [r.success for r in results] == [True, False, False] + assert all("not within your admin scope" in (r.error or "") for r in results[1:]) assert set(prisma.db.litellm_usertable.rows) == {"straddles", "orgless"} assert {(m["user_id"], m["organization_id"]) for m in prisma.db.litellm_organizationmembership.rows} == { ("org-admin", "o1"), @@ -496,10 +497,9 @@ async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest(): ], ) - response = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}]) + results = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}]) - assert (response.team_id, response.successful_deletions, response.failed_deletions) == ("t1", 2, 0) - assert [(r.user_id, r.user_email, r.success) for r in response.results] == [ + assert [(r.user_id, r.user_email, r.success) for r in results] == [ ("u1", None, True), (None, "u2@example.com", True), ] @@ -516,13 +516,12 @@ async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest(): async def test_bulk_member_delete_reports_members_not_on_the_team_without_rewriting_the_roster(): prisma = _FakePrisma(users=[_user("u1", "t1"), _user("elsewhere")], teams=[_team("t1", "u1")]) - response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}]) + results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}]) - assert [(r.success, r.error) for r in response.results] == [ + assert [(r.success, r.error) for r in results] == [ (False, "User not found in team"), (False, "User not found in team"), ] - assert (response.successful_deletions, response.failed_deletions) == (0, 2) assert prisma.db.litellm_teamtable.update_calls == 0 assert _roster(prisma, "t1") == ["u1"] @@ -536,9 +535,9 @@ async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_membe tokens=[{"token": "orphan-key", "user_id": "elsewhere", "team_id": "t1"}], ) - response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}]) + results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}]) - assert response.results[0].success is False + assert results[0].success is False assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "elsewhere"}] assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["orphan-key"] @@ -547,17 +546,16 @@ async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_membe async def test_bulk_member_delete_reports_repeated_members_as_duplicates_and_removes_them_once(): prisma = _FakePrisma(users=[_user("u1", "t1"), _user("u2", "t1")], teams=[_team("t1", "u1", "u2", "keep")]) - response = await _remove( + results = await _remove( prisma, "t1", [{"user_id": "u1"}, {"user_id": "u1"}, {"user_email": "u1@example.com"}, {"user_id": "u2"}] ) - assert [(r.success, r.error) for r in response.results] == [ + assert [(r.success, r.error) for r in results] == [ (True, None), (False, "Duplicate member in request"), (True, None), (True, None), ] - assert (response.successful_deletions, response.failed_deletions) == (3, 1) assert _roster(prisma, "t1") == ["keep"] @@ -583,9 +581,9 @@ async def test_bulk_member_delete_evicts_the_removed_team_keys_from_the_auth_cac async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_the_team(): prisma = _FakePrisma(users=[_user("stale", "t1")], teams=[_team("t1", "other")], memberships=[("t1", "stale")]) - response = await _remove(prisma, "t1", [{"user_id": "stale"}]) + results = await _remove(prisma, "t1", [{"user_id": "stale"}]) - assert response.results[0].success is True + assert results[0].success is True assert prisma.db.litellm_usertable.rows["stale"].teams == [] assert prisma.db.litellm_teammembership.rows == [] assert _roster(prisma, "t1") == ["other"] and prisma.db.litellm_teamtable.update_calls == 0 @@ -595,13 +593,13 @@ async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_th async def test_bulk_member_delete_rejects_unknown_team_and_unauthorized_callers(): prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[_team("t1", "u1")]) - with pytest.raises(HTTPException) as missing: + with pytest.raises(ManagementProblem) as missing: await _remove(prisma, "nope", [{"user_id": "u1"}]) - with pytest.raises(HTTPException) as forbidden: + with pytest.raises(ManagementProblem) as forbidden: await _remove(prisma, "t1", [{"user_id": "u1"}], caller=INTERNAL) - assert missing.value.status_code == 400 - assert forbidden.value.status_code == 403 + assert missing.value.problem.status == 404 + assert forbidden.value.problem.status == 403 assert _roster(prisma, "t1") == ["u1"] and prisma.locks == [] @@ -611,9 +609,9 @@ async def test_team_admin_may_bulk_remove_members(): team.members_with_roles[0].role = "admin" prisma = _FakePrisma(users=[_user("lead", "t1"), _user("u1", "t1")], teams=[team]) - response = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead")) + results = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead")) - assert response.results[0].success is True + assert results[0].success is True assert _roster(prisma, "t1") == ["lead"] @@ -623,22 +621,24 @@ def test_request_models_enforce_batch_bounds(): with pytest.raises(ValidationError): BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(501))) with pytest.raises(ValidationError): - BulkTeamMemberDeleteRequest(team_id="t1", members=()) + BulkTeamMemberDeleteRequest(members=()) with pytest.raises(ValidationError): - BulkTeamMemberDeleteRequest( - team_id="t1", members=tuple(MemberDeleteRequest(user_id=f"u{i}") for i in range(501)) - ) + BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(user_id=f"u{i}") for i in range(501))) assert len(BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(500))).user_ids) == 500 def test_bulk_member_delete_request_requires_exactly_one_identifier_per_member(): with pytest.raises(ValidationError, match="exactly one of user_id or user_email"): - BulkTeamMemberDeleteRequest( - team_id="t1", members=(MemberDeleteRequest(user_id="u1", user_email="other@example.com"),) - ) + BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "user_email": "other@example.com"}]}) with pytest.raises(ValidationError): - BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{}]}) - assert ( - BulkTeamMemberDeleteRequest(team_id="t1", members=(MemberDeleteRequest(user_id="u1"),)).members[0].user_id - == "u1" - ) + BulkTeamMemberDeleteRequest.model_validate({"members": [{}]}) + assert BulkTeamMemberDeleteRequest(members=(TeamMemberRef(user_id="u1"),)).members[0].user_id == "u1" + + +def test_request_models_reject_unknown_fields(): + with pytest.raises(ValidationError, match="team_id"): + BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{"user_id": "u1"}]}) + with pytest.raises(ValidationError, match="role"): + BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "role": "admin"}]}) + with pytest.raises(ValidationError, match="dry_run"): + BulkDeleteUserRequest.model_validate({"user_ids": ["u1"], "dry_run": True}) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0c8913d9c6c..b068565e697 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8470,6 +8470,71 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/teams/{team_id}/members/bulk_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Delete Team Members Action + * @description Remove up to 500 members from one team in one call. Same authorization as + * `/team/member_delete`: proxy admins, the team's admins, and admins of the team's + * organization. Each member is named by exactly one of `user_id` or `user_email`; + * unknown body fields are a 422 and an unknown team is a 404. + * + * `data` holds one result per requested member, in request order. A row is + * `success: false` with an `error` when it names nobody on the team or repeats an + * earlier row. The roster is rewritten once, under the team's advisory lock, so a + * concurrent member_add is never overwritten from a stale read. + * + * Example curl: + * ``` + * curl --location 'http://0.0.0.0:4000/management/v1/teams/team-1/members/bulk_delete' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"members": [{"user_id": "user-1"}, {"user_email": "user-2@example.com"}]}' + * ``` + */ + post: operations["bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/management/v1/users/bulk_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Delete Users Action + * @description Delete up to 500 users in one call, taking each out of every team it belongs to. + * Same authorization as `/user/delete`: proxy admins may delete anyone, org admins + * only users inside organizations they administer. Unknown body fields are a 422. + * + * `data` holds one result per requested `user_id`, in request order. A row is + * `success: false` with an `error` when the id is unknown, repeated in the request, + * or outside the caller's scope. Rows that pass those checks are deleted together, + * in one transaction, so either all of them go or none does. + * + * Example curl: + * ``` + * curl --location 'http://0.0.0.0:4000/management/v1/users/bulk_delete' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"user_ids": ["user-1", "user-2"]}' + * ``` + */ + post: operations["bulk_delete_users_action_management_v1_users_bulk_delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/mcp": { parameters: { query?: never; @@ -15056,26 +15121,6 @@ export interface paths { patch?: never; trace?: never; }; - "/team/bulk_member_delete": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Bulk Team Member Delete - * @description Remove up to 500 members from one team; same authorization as `/team/member_delete`. - */ - post: operations["bulk_team_member_delete_team_bulk_member_delete_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/team/daily/activity": { parameters: { query?: never; @@ -16498,26 +16543,6 @@ export interface paths { patch?: never; trace?: never; }; - "/user/bulk_delete": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Bulk Delete User - * @description Delete up to 500 users, removing each from every team; same authorization as `/user/delete`. - */ - post: operations["bulk_delete_user_user_bulk_delete_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/user/bulk_update": { parameters: { query?: never; @@ -24543,21 +24568,21 @@ export interface components { /** Budgets */ budgets: string[]; }; - /** BulkDeleteUserRequest */ + /** + * BulkDeleteUserRequest + * @description Body of `POST /management/v1/users/bulk_delete`. + */ BulkDeleteUserRequest: { /** User Ids */ user_ids: string[]; }; - /** BulkDeleteUserResponse */ - BulkDeleteUserResponse: { - /** Failed Deletions */ - failed_deletions: number; - /** Results */ - results: components["schemas"]["UserDeleteResult"][]; - /** Successful Deletions */ - successful_deletions: number; - /** Total Requested */ - total_requested: number; + /** + * BulkDeleteUsersResponse + * @description `{data: [...]}` with one `UserDeleteResult` per requested user, in request order. + */ + BulkDeleteUsersResponse: { + /** Data */ + data: components["schemas"]["UserDeleteResult"][]; }; /** * BulkTeamMemberAddRequest @@ -24596,25 +24621,21 @@ export interface components { [key: string]: unknown; } | null; }; - /** BulkTeamMemberDeleteRequest */ + /** + * BulkTeamMemberDeleteRequest + * @description Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`. + */ BulkTeamMemberDeleteRequest: { /** Members */ - members: components["schemas"]["MemberDeleteRequest"][]; - /** Team Id */ - team_id: string; + members: components["schemas"]["TeamMemberRef"][]; }; - /** BulkTeamMemberDeleteResponse */ + /** + * BulkTeamMemberDeleteResponse + * @description `{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order. + */ BulkTeamMemberDeleteResponse: { - /** Failed Deletions */ - failed_deletions: number; - /** Results */ - results: components["schemas"]["TeamMemberDeleteResult"][]; - /** Successful Deletions */ - successful_deletions: number; - /** Team Id */ - team_id: string; - /** Total Requested */ - total_requested: number; + /** Data */ + data: components["schemas"]["TeamMemberDeleteResult"][]; }; /** * BulkUpdateKeyRequest @@ -31982,13 +32003,6 @@ export interface components { */ user_id?: string | null; }; - /** MemberDeleteRequest */ - MemberDeleteRequest: { - /** User Email */ - user_email?: string | null; - /** User Id */ - user_id?: string | null; - }; /** MemoryCreateRequest */ MemoryCreateRequest: { /** @@ -37254,7 +37268,7 @@ export interface components { }; /** * TeamMemberDeleteResult - * @description Outcome for one row of `/team/bulk_member_delete`. + * @description Outcome for one requested member, in request order. */ TeamMemberDeleteResult: { /** Error */ @@ -37296,6 +37310,16 @@ export interface components { /** User Id */ user_id: string; }; + /** + * TeamMemberRef + * @description One member to remove, named by exactly one of `user_id` or `user_email`. + */ + TeamMemberRef: { + /** User Email */ + user_email?: string | null; + /** User Id */ + user_id?: string | null; + }; /** TeamMemberUpdateRequest */ TeamMemberUpdateRequest: { /** @@ -39447,7 +39471,7 @@ export interface components { }; /** * UserDeleteResult - * @description Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of. + * @description Outcome for one requested user, in request order. `teams_removed` lists the teams the user left. */ UserDeleteResult: { /** Error */ @@ -51236,6 +51260,77 @@ export interface operations { }; }; }; + bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post: { + parameters: { + query?: never; + header?: never; + path: { + team_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkTeamMemberDeleteRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkTeamMemberDeleteResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + bulk_delete_users_action_management_v1_users_bulk_delete_post: { + parameters: { + query?: never; + header?: { + /** @description Who the caller is acting for; recorded on the audit log entries this call writes. */ + "litellm-changed-by"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkDeleteUserRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkDeleteUsersResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; aggregate_mcp_route_mcp_get: { parameters: { query?: never; @@ -59006,39 +59101,6 @@ export interface operations { }; }; }; - bulk_team_member_delete_team_bulk_member_delete_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BulkTeamMemberDeleteRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BulkTeamMemberDeleteResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; get_team_daily_activity_team_daily_activity_get: { parameters: { query?: { @@ -60783,42 +60845,6 @@ export interface operations { }; }; }; - bulk_delete_user_user_bulk_delete_post: { - parameters: { - query?: never; - header?: { - /** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */ - "litellm-changed-by"?: string | null; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BulkDeleteUserRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BulkDeleteUserResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; bulk_user_update_user_bulk_update_post: { parameters: { query?: never; From 8e83e91275d481df8d9a5e869d1c7675b1abf123 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 22:40:57 +0000 Subject: [PATCH 18/25] fix(proxy): keep bulk user row errors free of the tuple length message Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/management_endpoints/internal_user_endpoints.py | 4 ++-- .../management_endpoints/management_v1/test_users.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index f6a2c173ad5..3b4a2a417f2 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,4 +1,4 @@ -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -107,7 +107,7 @@ class BulkNewUserItem(NewUserRequest): class BulkNewUserRequest(BaseModel): model_config = ConfigDict(extra="forbid") - users: tuple[BulkNewUserItem, ...] = Field(min_length=1, max_length=MAX_BULK_NEW_USERS) + users: Sequence[BulkNewUserItem] = Field(min_length=1, max_length=MAX_BULK_NEW_USERS) class UserCreateResult(BaseModel): diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py index b61e639e453..edd1d315093 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_users.py @@ -79,16 +79,16 @@ def test_returns_one_result_per_row_in_order_inside_the_data_meta_envelope(prism def test_an_unknown_field_anywhere_in_the_body_is_a_422_problem(prisma, as_proxy_admin): - for body in ( - {"users": [{"user_email": "a@example.com", "user_emial": "typo"}]}, - {"users": [{"user_email": "a@example.com"}], "dry_run": True}, + for body, field in ( + ({"users": [{"user_email": "a@example.com", "user_emial": "typo"}]}, "users.0.user_emial"), + ({"users": [{"user_email": "a@example.com"}], "dry_run": True}, "dry_run"), ): response = _post(body) assert response.status_code == 422, body assert response.headers["content-type"] == "application/problem+json" assert response.json()["type"] == "urn:litellm:error:invalid-request-body" - assert "Extra inputs are not permitted" in response.json()["detail"] + assert response.json()["detail"] == f"{field}: Extra inputs are not permitted" assert prisma.db.litellm_usertable.rows == {} From b0acda28256d2a0b244b2e5518aad4185b338b45 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 23:09:04 +0000 Subject: [PATCH 19/25] fix(proxy): mark validation error TypedDict fields ReadOnly Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 88844c04e74..04145eb68ef 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1788,9 +1788,9 @@ class _ExceptionRow(TypedDict, total=False): class _ValidationErrorDetail(TypedDict): - type: str - loc: tuple[int | str, ...] - msg: str + type: ReadOnly[str] + loc: ReadOnly[tuple[int | str, ...]] + msg: ReadOnly[str] def _is_length_error_of_rejected_items(error: _ValidationErrorDetail, errors: Sequence[_ValidationErrorDetail]) -> bool: From 5ace6fa731b0672db095ca64241bc2f4138305de Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 23:51:15 +0000 Subject: [PATCH 20/25] fix(proxy): match an id-only bulk member delete against a legacy email-only roster entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_deletion.py | 29 ++++++++++------ .../test_team_bulk_member_delete.py | 33 ++++++++++++++++++- .../test_bulk_user_deletion.py | 21 ++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index 53527c42c25..337b3cdeabc 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -169,6 +169,12 @@ def _addresses_member(member: Member, request: MemberDeleteRequest) -> bool: 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) @@ -207,22 +213,25 @@ async def _remove_members_from_team( if roster is None: raise _team_not_found(team_id) - removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members)) - kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members)) - removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None) requested_ids: Final = frozenset(r.user_id for r in members if r.user_id is not None) requested_emails: Final = frozenset(r.user_email for r in members if r.user_id is None and r.user_email) - user_rows: Final = await _user_tx_db(tx).find_many( - where=_any_filter( - _in_filter("user_id", removed_ids | requested_ids), - _in_filter("user_email", requested_emails), - ) + 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)) ) - stale_rows: Final = tuple(u for u in user_rows if team_id in u.teams) + email_of: Final = MappingProxyType({u.user_id: u.user_email for u in requested_rows if u.user_email is not None}) + 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(members) + 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)) diff --git a/tests/proxy_behavior/management/test_team_bulk_member_delete.py b/tests/proxy_behavior/management/test_team_bulk_member_delete.py index 5283497c6bf..0818fb25dfe 100644 --- a/tests/proxy_behavior/management/test_team_bulk_member_delete.py +++ b/tests/proxy_behavior/management/test_team_bulk_member_delete.py @@ -1,7 +1,8 @@ import pytest +from prisma import Json from .actors import Actor -from .conftest import create_scratch_team +from .conftest import create_scratch_team, create_scratch_user pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -114,6 +115,36 @@ async def test_team_bulk_member_delete_reports_each_row_in_order(proxy_client, p 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]) diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index 077eb90ddce..6736f1de08b 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -589,6 +589,27 @@ async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_th 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_rejects_unknown_team_and_unauthorized_callers(): prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[_team("t1", "u1")]) From 98ea14758fd670809d7bc0c61cd636e3db479c7d Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 15 Sep 2026 00:09:57 +0000 Subject: [PATCH 21/25] fix(proxy): only widen an id-only bulk member delete to its email when the user lists the team Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_deletion.py | 4 +++- .../test_bulk_user_deletion.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index 337b3cdeabc..1ae83b0004a 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -218,7 +218,9 @@ async def _remove_members_from_team( 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}) + 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)) diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index 6736f1de08b..fc972ccbb75 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -610,6 +610,26 @@ async def test_bulk_member_delete_by_id_removes_the_members_email_only_roster_en 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")]) From a502afe608fa264559a0afca19ec5b123fc52743 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:34:45 -0700 Subject: [PATCH 22/25] docs(github): ask for interactive coding-tool proof in the PR template --- .github/pull_request_template.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2dc85fce05b..7a9883df356 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -101,7 +101,8 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac For bug fixes: Before shows the reproduction, After shows the same steps passing For new features: Before shows the capability missing, After shows it working end-to-end If the change applies to all three LLM endpoints (/v1/responses, /v1/chat/completions, /v1/messages), make each endpoint its own case, not just one - For UI changes: before/after screenshots under the same headings --> + For UI changes: before/after screenshots under the same headings + If the main use case runs through a coding tool like Claude Code or Codex, drive that tool interactively the way the user does (never `claude -p`, `codex exec`, or curl on its own) and embed before/after screenshots of its pane under the same headings; curl replays and headless runs can follow as extra cases, never as the only proof --> ## Type From 9fce6e1989004da7ab22928e47afe78f458aa892 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:09:12 +0000 Subject: [PATCH 23/25] test(pricing): let synced GovCloud Bedrock rows cite the AWS price list Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/test_bedrock_usgov_pricing.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/test_bedrock_usgov_pricing.py b/tests/test_litellm/test_bedrock_usgov_pricing.py index 3dfd7350a06..4d5b27a8668 100644 --- a/tests/test_litellm/test_bedrock_usgov_pricing.py +++ b/tests/test_litellm/test_bedrock_usgov_pricing.py @@ -112,6 +112,11 @@ GOV_ROW_SOURCES = { } +BEDROCK_PRICE_LIST_URL = ( + "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" +) + + def _non_pricing_fields(info): return {k: v for k, v in info.items() if "cost" not in k and k not in ("litellm_provider", "source")} @@ -121,8 +126,10 @@ def test_usgov_rows_keep_commercial_limits_and_capabilities(model_data, gov_key) """A gov row differs from the commercial row it mirrors only in price and provider: context limits, mode, and capability flags stay identical, so a hand-copied row cannot silently drop tool calling or shrink the context window. + The only source a gov row may cite is the AWS price list, which prices the + us-gov regions itself; a commercial doc URL copied along with the row is not. """ gov = model_data[gov_key] assert _non_pricing_fields(gov) == _non_pricing_fields(model_data[GOV_ROW_SOURCES[gov_key]]) assert "search_context_cost_per_query" not in gov - assert "source" not in gov + assert gov.get("source", BEDROCK_PRICE_LIST_URL) == BEDROCK_PRICE_LIST_URL From 81806f33cf623e9bc96b553f87af641f8e378a3e Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 14 Sep 2026 15:18:21 -0700 Subject: [PATCH 24/25] fix(credentials): answer 409 on a name collision, let PATCH resolve values from model_id POST /credentials let a duplicate name hit the unique index and handed back Prisma's "Unique constraint failed" as a 500, so callers string-matched that message to tell a caller mistake from a server fault. The unique violation now maps to a 409 whose message names the PATCH route, two concurrent creates of one name agree on it, and the detection lives in a repository helper the five hand-rolled copies can move onto later PATCH /credentials/{name} took a CredentialItem body, so the model_id the Terraform adopt path sent was dropped. It now accepts UpdateCredentialItem and shares the deployment lookup with create. Both handlers take the router as a FastAPI dependency instead of reading the proxy global, which is what the tests override --- litellm/models/credentials.py | 9 ++ .../proxy/credential_endpoints/endpoints.py | 101 +++++++++---- litellm/repositories/base_repository.py | 10 ++ .../credential_endpoints/test_endpoints.py | 139 +++++++++++++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 17 ++- 5 files changed, 242 insertions(+), 34 deletions(-) diff --git a/litellm/models/credentials.py b/litellm/models/credentials.py index 56836234898..0878eea5769 100644 --- a/litellm/models/credentials.py +++ b/litellm/models/credentials.py @@ -5,6 +5,8 @@ These are the canonical credential types for the proxy. They live in the model layer; ``litellm.types.utils`` re-exports them for backwards compatibility. """ +from collections.abc import Mapping + from pydantic import BaseModel, model_validator @@ -27,3 +29,10 @@ class CreateCredentialItem(CredentialBase): if not values.get("credential_values") and not values.get("model_id"): raise ValueError("Either credential_values or model_id must be set") return values + + +class UpdateCredentialItem(BaseModel): + credential_name: str + credential_info: Mapping[str, object] + credential_values: Mapping[str, object] | None = None + model_id: str | None = None diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 66789748707..f99cce14722 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -2,25 +2,31 @@ CRUD endpoints for storing reusable credentials. """ +from collections.abc import Mapping from typing import ( + Annotated, Final, cast, # noqa: TID251 # jsonify_object in proxy/utils.py is annotated with a bare dict ) from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import _get_masked_values +from litellm.models.credentials import UpdateCredentialItem from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper from litellm.proxy.utils import handle_exception_on_proxy, jsonify_object +from litellm.repositories.base_repository import is_unique_violation from litellm.repositories.credentials_repository import CredentialsRepository from litellm.types.utils import CreateCredentialItem, CredentialItem router: Final = APIRouter() +_CREDENTIAL_DICT_ADAPTER: Final = TypeAdapter(dict[str, object]) class CredentialHelperUtils: @@ -40,6 +46,33 @@ class CredentialHelperUtils: ) +def _credential_exists_detail(credential_name: str) -> str: + return ( + f"Credential '{credential_name}' already exists. " + f"Update it with PATCH /credentials/{credential_name}, or delete it first." + ) + + +def get_llm_router() -> litellm.Router | None: + from litellm.proxy.proxy_server import llm_router + + return llm_router + + +def _resolve_deployment_credentials(llm_router: litellm.Router | None, model_id: str) -> Mapping[str, object]: + if llm_router is None: + raise HTTPException( + status_code=500, + detail="LLM router not found. Please ensure you have a valid router instance.", + ) + if llm_router.get_deployment(model_id) is None: + raise HTTPException(status_code=404, detail="Model not found") + credential_values: Final = llm_router.get_deployment_credentials(model_id) + if credential_values is None: + raise HTTPException(status_code=404, detail="Model not found") + return _CREDENTIAL_DICT_ADAPTER.validate_python(credential_values) + + @router.post( "/credentials", dependencies=[Depends(user_api_key_auth)], @@ -50,13 +83,14 @@ async def create_credential( fastapi_response: Response, credential: CreateCredentialItem, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + llm_router: Annotated[litellm.Router | None, Depends(get_llm_router)] = None, ): """ [BETA] endpoint. This might change unexpectedly. Stores credential in DB. Reloads credentials in memory. """ - from litellm.proxy.proxy_server import llm_router, prisma_client + from litellm.proxy.proxy_server import prisma_client try: if prisma_client is None: @@ -64,29 +98,19 @@ async def create_credential( status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - if credential.model_id: - if llm_router is None: - raise HTTPException( - status_code=500, - detail="LLM router not found. Please ensure you have a valid router instance.", - ) - # get model from router - model: Final = llm_router.get_deployment(credential.model_id) - if model is None: - raise HTTPException(status_code=404, detail="Model not found") - credential_values: Final = llm_router.get_deployment_credentials(credential.model_id) - if credential_values is None: - raise HTTPException(status_code=404, detail="Model not found") - credential.credential_values = credential_values - - if credential.credential_values is None: + credential_values: Final = ( + _resolve_deployment_credentials(llm_router, credential.model_id) + if credential.model_id + else credential.credential_values + ) + if credential_values is None: raise HTTPException( status_code=400, detail="Credential values are required. Unable to infer credential values from model ID.", ) processed_credential: Final = CredentialItem( credential_name=credential.credential_name, - credential_values=credential.credential_values, + credential_values=_CREDENTIAL_DICT_ADAPTER.validate_python(credential_values), credential_info=credential.credential_info, ) encrypted_credential: Final = CredentialHelperUtils.encrypt_credential_values(processed_credential) @@ -94,13 +118,18 @@ async def create_credential( credentials_dict_jsonified: Final = cast( # cast-ok: deep-copies a model_dump, so keys are str "dict[str, object]", jsonify_object(credentials_dict) ) - await CredentialsRepository(prisma_client).create( - data={ - **credentials_dict_jsonified, - "created_by": user_api_key_dict.user_id, - "updated_by": user_api_key_dict.user_id, - } - ) + try: + await CredentialsRepository(prisma_client).create( + data={ + **credentials_dict_jsonified, + "created_by": user_api_key_dict.user_id, + "updated_by": user_api_key_dict.user_id, + } + ) + except Exception as e: + if not is_unique_violation(e): + raise + raise HTTPException(status_code=409, detail=_credential_exists_detail(credential.credential_name)) ## ADD TO LITELLM ## CredentialAccessor.upsert_credentials([processed_credential]) @@ -300,9 +329,10 @@ def update_db_credential( async def update_credential( request: Request, fastapi_response: Response, - credential: CredentialItem, + credential: UpdateCredentialItem, credential_name: str = Path(..., description="The credential name, percent-decoded; may contain slashes"), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + llm_router: Annotated[litellm.Router | None, Depends(get_llm_router)] = None, ): """ [BETA] endpoint. This might change unexpectedly. @@ -319,7 +349,16 @@ async def update_credential( db_credential: Final = await credentials_repository.find_by_name(credential_name) if db_credential is None: raise HTTPException(status_code=404, detail="Credential not found in DB.") - merged_credential: Final = update_db_credential(db_credential, credential) + patch: Final = CredentialItem( + credential_name=credential.credential_name, + credential_info=_CREDENTIAL_DICT_ADAPTER.validate_python(credential.credential_info), + credential_values=_CREDENTIAL_DICT_ADAPTER.validate_python( + _resolve_deployment_credentials(llm_router, credential.model_id) + if credential.model_id + else credential.credential_values or {} + ), + ) + merged_credential: Final = update_db_credential(db_credential, patch) credential_object_jsonified: Final = cast( # cast-ok: deep-copies a model_dump, so keys are str "dict[str, object]", jsonify_object(merged_credential.model_dump()) ) @@ -341,11 +380,11 @@ async def update_credential( if existing_in_memory is not None: in_memory_values: Final = dict(existing_in_memory.credential_values or {}) - if credential.credential_values: - in_memory_values.update(credential.credential_values) + if patch.credential_values: + in_memory_values.update(patch.credential_values) in_memory_info: Final = dict(existing_in_memory.credential_info or {}) - if credential.credential_info: - in_memory_info.update(credential.credential_info) + if patch.credential_info: + in_memory_info.update(patch.credential_info) updated_in_memory: Final = CredentialItem( credential_name=new_name, credential_values=in_memory_values, diff --git a/litellm/repositories/base_repository.py b/litellm/repositories/base_repository.py index 26c1c386138..065842b39e2 100644 --- a/litellm/repositories/base_repository.py +++ b/litellm/repositories/base_repository.py @@ -117,3 +117,13 @@ class BaseRepository(ABC, Generic[T]): """Check if a record exists.""" record: Final = await self.table.find_unique(where={id_field: id_value}) return record is not None + + +def is_unique_violation(exc: BaseException) -> bool: + try: + from prisma.errors import UniqueViolationError + except ImportError: + return "P2002" in str(exc) or "unique constraint" in str(exc).lower() + if isinstance(exc, UniqueViolationError): + return True + return getattr(exc, "code", None) == "P2002" diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py index d67a9afdcc8..631767f52ae 100644 --- a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -1,5 +1,6 @@ """Tests for the credential management endpoints.""" +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -9,6 +10,7 @@ from fastapi.testclient import TestClient import litellm from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.credential_endpoints.endpoints import get_llm_router from litellm.proxy.proxy_server import app from litellm.types.utils import CredentialItem @@ -47,23 +49,27 @@ def _list_credentials(): @pytest.fixture def credential_store(): """Stands the credential store up for one test: whether the database is reachable, what - the proxy is already serving from memory, and what each repository call hands back.""" + the proxy is already serving from memory, which router deployments resolve against, and + what each repository call hands back.""" def install( *, connected: bool = True, in_memory: tuple[object, ...] = (), + llm_router: object | None = None, **repository_calls: AsyncMock, ) -> None: patch("litellm.proxy.proxy_server.prisma_client", MagicMock() if connected else None).start() patch("litellm.proxy.proxy_server.master_key", "sk-test-master").start() patch.object(litellm, "credential_list", list(in_memory)).start() + app.dependency_overrides[get_llm_router] = lambda: llm_router repository = patch("litellm.proxy.credential_endpoints.endpoints.CredentialsRepository").start() for call_name, result in repository_calls.items(): setattr(repository.return_value, call_name, result) yield install patch.stopall() + app.dependency_overrides.pop(get_llm_router, None) def test_update_credential_answers_404_when_the_credential_does_not_exist(credential_store): @@ -122,7 +128,9 @@ def test_delete_credential_answers_404_when_the_credential_does_not_exist(creden response = _delete_credential("definitely-not-there") - assert response.status_code == 404, f"delete of a missing credential answered {response.status_code}: {response.text}" + assert response.status_code == 404, ( + f"delete of a missing credential answered {response.status_code}: {response.text}" + ) assert "definitely-not-there" in response.text @@ -195,3 +203,130 @@ def test_get_credentials_answers_an_error_status_when_the_listing_fails(credenti assert response.status_code == 500, f"failed listing answered {response.status_code}: {response.text}" assert response.json().get("success") is not True + + +def _create_credential(body: dict): + return _call_as_admin("POST", "/credentials", body) + + +class _UniqueViolation(Exception): + code = "P2002" + + +def test_create_credential_answers_409_when_the_name_is_already_taken(credential_store): + """Regression: the unique index used to surface as a Prisma 500 that callers string-matched.""" + credential_store( + create=AsyncMock(side_effect=_UniqueViolation("Unique constraint failed on the fields: (`credential_name`)")), + ) + + response = _create_credential( + {"credential_name": "aws_bedrock", "credential_values": {"aws_access_key_id": "new"}, "credential_info": {}}, + ) + + assert response.status_code == 409, f"name collision answered {response.status_code}: {response.text}" + message = response.json()["error"]["message"] + assert message == ( + "Credential 'aws_bedrock' already exists. Update it with PATCH /credentials/aws_bedrock, or delete it first." + ), f"the operator reads this message verbatim: {message}" + assert "Unique constraint" not in response.text, f"the Prisma internals must not leak: {response.text}" + + +def test_create_credential_still_answers_500_when_the_write_fails_for_another_reason(credential_store): + credential_store(create=AsyncMock(side_effect=Exception("connection reset by peer"))) + + response = _create_credential( + {"credential_name": "aws_bedrock", "credential_values": {"aws_access_key_id": "new"}, "credential_info": {}}, + ) + + assert response.status_code == 500, f"database fault answered {response.status_code}: {response.text}" + + +def test_create_credential_still_answers_200_for_a_name_that_is_free(credential_store): + find_by_name = AsyncMock() + credential_store(find_by_name=find_by_name, create=AsyncMock(return_value=None)) + + response = _create_credential( + {"credential_name": "brand_new", "credential_values": {"aws_access_key_id": "new"}, "credential_info": {}}, + ) + + assert response.status_code == 200, response.text + assert response.json()["success"] is True + find_by_name.assert_not_awaited(), "the unique index is the guard; create must not add a lookup" + + +def test_update_credential_resolves_credential_values_from_model_id_like_create(credential_store): + """Regression: PATCH dropped ``model_id`` from the body, so an update that named a + deployment instead of raw values wrote whatever the caller sent, or nothing.""" + stored = CredentialItem( + credential_name="from-deployment", + credential_values={"api_key": "sk-old"}, + credential_info={}, + ) + update_by_name = AsyncMock(return_value=None) + router = MagicMock() + router.get_deployment.return_value = {"model_name": "gpt-5.2"} + router.get_deployment_credentials.return_value = {"api_key": "sk-from-deployment"} + credential_store(find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name, llm_router=router) + + response = _patch_credential( + "from-deployment", + {"credential_name": "from-deployment", "model_id": "deployment-1", "credential_info": {}}, + ) + + assert response.status_code == 200, response.text + router.get_deployment_credentials.assert_called_once_with("deployment-1") + written = json.loads(update_by_name.await_args.kwargs["data"]["credential_values"]) + assert set(written) == {"api_key"} + assert written["api_key"] != "sk-old", "the deployment's values must replace the stored ones" + assert written["api_key"] != "sk-from-deployment", "values are encrypted before they reach the table" + + +def test_update_credential_answers_404_when_model_id_names_no_deployment(credential_store): + stored = CredentialItem( + credential_name="from-deployment", credential_values={"api_key": "sk-old"}, credential_info={} + ) + update_by_name = AsyncMock(return_value=None) + router = MagicMock() + router.get_deployment.return_value = None + credential_store(find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name, llm_router=router) + + response = _patch_credential( + "from-deployment", + {"credential_name": "from-deployment", "model_id": "no-such-deployment", "credential_info": {}}, + ) + + assert response.status_code == 404, response.text + update_by_name.assert_not_awaited() + + +def test_update_credential_answers_500_when_model_id_is_given_but_no_router_is_loaded(credential_store): + stored = CredentialItem( + credential_name="from-deployment", credential_values={"api_key": "sk-old"}, credential_info={} + ) + update_by_name = AsyncMock(return_value=None) + credential_store(find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name, llm_router=None) + + response = _patch_credential( + "from-deployment", + {"credential_name": "from-deployment", "model_id": "deployment-1", "credential_info": {}}, + ) + + assert response.status_code == 500, response.text + update_by_name.assert_not_awaited() + + +def test_update_credential_still_accepts_a_body_without_credential_values(credential_store): + """Renaming or re-tagging a credential sends only ``credential_info``; that must not 422.""" + stored = CredentialItem(credential_name="existing", credential_values={"api_key": "sk-old"}, credential_info={}) + update_by_name = AsyncMock(return_value=None) + credential_store(find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name) + + response = _patch_credential( + "existing", + {"credential_name": "existing", "credential_info": {"custom_llm_provider": "openai"}}, + ) + + assert response.status_code == 200, response.text + written = update_by_name.await_args.kwargs["data"] + assert json.loads(written["credential_info"]) == {"custom_llm_provider": "openai"} + assert set(json.loads(written["credential_values"])) == {"api_key"}, "stored values survive an info-only patch" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0b0e3e18215..1a2d74063b7 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -37944,6 +37944,21 @@ export interface components { */ blocked_users: string[]; }; + /** UpdateCredentialItem */ + UpdateCredentialItem: { + /** Credential Info */ + credential_info: { + [key: string]: unknown; + }; + /** Credential Name */ + credential_name: string; + /** Credential Values */ + credential_values?: { + [key: string]: unknown; + } | null; + /** Model Id */ + model_id?: string | null; + }; /** * UpdateCustomerRequest * @description Update a Customer, use this to update customer budgets etc @@ -45535,7 +45550,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CredentialItem"]; + "application/json": components["schemas"]["UpdateCredentialItem"]; }; }; responses: { From a7f180fdd8fca129ee58ffa1a24f6e32df0df21c Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 14 Sep 2026 15:18:22 -0700 Subject: [PATCH 25/25] feat(terraform): make credential adoption opt-in, escape names in request URLs Terraform's convention is that create does not seize a resource the configuration never made, and credential_values holds secrets that are never read back into state, so a silent takeover overwrites values no plan showed. A name collision now fails with the terraform import command that adopts the existing credential explicitly, and adopt_existing = true opts into taking it over during create. The provider detects the conflict by the proxy's 409 and keeps the Prisma string match as a fallback for older proxies Credential names and model_id went into URLs raw, so a name with a slash or a question mark hit the wrong route. Every credential URL is now built from a package const through fmt.Sprintf with url.PathEscape or url.QueryEscape, which the endpoint audit can resolve. Toggling adopt_existing alone no longer sends a PATCH, so it does not rewrite the stored secret --- terraform/provider/CHANGELOG.md | 4 +- .../provider/docs/resources/credential.md | 1 + .../provider/litellm/resource_credential.go | 9 + .../litellm/resource_credential_crud.go | 145 ++++---- .../litellm/resource_credential_crud_test.go | 350 +++++++++++++++--- terraform/provider/litellm/utils.go | 41 +- 6 files changed, 390 insertions(+), 160 deletions(-) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 3daee3250b6..06a39d8da20 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -38,7 +38,9 @@ longer signal it. ### Fixed - **key**: An update that changes `team_id` and fails because the key was already cascade-deleted along with its previous team now recovers by recreating the key under the new team, instead of aborting the apply. The key's absence is confirmed against the proxy first, so an unrelated failure still errors out, and a `team_id` change between two teams that both still exist stays a plain in-place update -- **credential**: `litellm_credential` create now adopts an existing credential on a `credential_name` conflict instead of failing with a 500; `apply` is idempotent again once state loses track of a credential that still exists on the proxy +- **credential**: create now reports a `credential_name` collision as a clear error naming the `terraform import` command that adopts the existing credential, instead of surfacing the proxy's raw 500 with a Prisma `Unique constraint failed` message. New `adopt_existing` argument (default `false`) opts into taking the existing credential over during create, which makes `apply` idempotent again once state loses track of a credential that still exists on the proxy. Requires a proxy that answers 409 on the collision; older proxies are still detected by their 500 message +- **credential**: credential names and `model_id` are now percent-encoded in request URLs, so a name containing `/`, `?`, `#` or spaces reaches the proxy intact instead of being cut at the first reserved character and read, updated or deleted as a different credential +- **credential**: update now sends `model_id`, so a `model_id`-scoped credential keeps resolving its values from that deployment on update and on adoption instead of being overwritten with the literal `credential_values`; needs a proxy from 1.102.0, older proxies ignore the field - **team**: Read now decodes the `team_info` envelope `/team/info` actually returns, so team attributes refresh from the proxy instead of always falling back to the prior state - **key**: Read now unwraps the `info` envelope `/key/info` actually returns; previously reads mapped nothing back into state, so drift on a key was never detected - **key**: Read now picks up `model_rpm_limit`, `model_tpm_limit`, `guardrails`, `tags`, `enforced_params`, `allowed_passthrough_routes`, `rpm_limit_type`, `tpm_limit_type` and `prompts` from `info.metadata`, where the proxy actually stores them; previously they stayed empty in state, so a matching config showed a permanent phantom diff on them and out-of-band changes to them were never detected diff --git a/terraform/provider/docs/resources/credential.md b/terraform/provider/docs/resources/credential.md index 554ac07c395..d75ee33140d 100644 --- a/terraform/provider/docs/resources/credential.md +++ b/terraform/provider/docs/resources/credential.md @@ -130,6 +130,7 @@ The following arguments are supported: * `credential_values` - (Required, Sensitive) Map of sensitive credential values such as API keys, tokens, etc. * `model_id` - (Optional) Model ID associated with this credential. * `credential_info` - (Optional) Map of additional non-sensitive information about the credential. +* `adopt_existing` - (Optional, default `false`) Take over a credential of this name that already exists on the proxy instead of failing. Turning this on overwrites the existing credential's values with the ones in this configuration. ## Attributes Reference diff --git a/terraform/provider/litellm/resource_credential.go b/terraform/provider/litellm/resource_credential.go index f668a46a324..d1e41f6cf56 100644 --- a/terraform/provider/litellm/resource_credential.go +++ b/terraform/provider/litellm/resource_credential.go @@ -39,6 +39,15 @@ func resourceLiteLLMCredential() *schema.Resource { Elem: &schema.Schema{Type: schema.TypeString}, Description: "Sensitive credential values (API keys, tokens, etc.)", }, + "adopt_existing": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Take over a credential of this name that already exists on the proxy instead of failing. " + + "Off by default: create reports the conflict and points at `terraform import`, so an apply never " + + "silently overwrites a credential it does not manage. Turning this on overwrites the existing " + + "credential's values with the ones in this configuration.", + }, }, } } diff --git a/terraform/provider/litellm/resource_credential_crud.go b/terraform/provider/litellm/resource_credential_crud.go index cb5031ee04e..6b31a03d404 100644 --- a/terraform/provider/litellm/resource_credential_crud.go +++ b/terraform/provider/litellm/resource_credential_crud.go @@ -1,15 +1,23 @@ package litellm import ( + "errors" "fmt" "log" "net/http" + "net/url" "strings" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +const ( + endpointCredential = "/credentials/%s" + endpointCredentialByName = "/credentials/by_name/%s" + endpointCredentialByNameForModel = "/credentials/by_name/%s?model_id=%s" +) + // retryCredentialRead attempts to read a credential with exponential backoff. // If the read path clears the ID (e.g., transient 404 right after create), // we treat it as retryable instead of accepting an empty state. @@ -53,34 +61,28 @@ func retryCredentialRead(d *schema.ResourceData, m interface{}, maxRetries int) return err } -func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) error { - client := m.(*Client) - - credentialName := d.Get("credential_name").(string) - modelID := d.Get("model_id").(string) - credentialInfo := d.Get("credential_info").(map[string]interface{}) - credentialValues := d.Get("credential_values").(map[string]interface{}) - - // Convert credential_info to map[string]interface{} for JSON +func credentialRequestFromResource(d *schema.ResourceData, credentialName string) CredentialRequest { credInfoMap := make(map[string]interface{}) - for k, v := range credentialInfo { + for k, v := range d.Get("credential_info").(map[string]interface{}) { credInfoMap[k] = v } - - // Convert credential_values to map[string]interface{} for JSON credValuesMap := make(map[string]interface{}) - for k, v := range credentialValues { + for k, v := range d.Get("credential_values").(map[string]interface{}) { credValuesMap[k] = v } - - credentialRequest := CredentialRequest{ + return CredentialRequest{ CredentialName: credentialName, - ModelID: modelID, + ModelID: d.Get("model_id").(string), CredentialInfo: credInfoMap, CredentialValues: credValuesMap, } +} - resp, err := MakeRequest(client, "POST", "/credentials", credentialRequest) +func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Get("credential_name").(string) + + resp, err := MakeRequest(client, "POST", "/credentials", credentialRequestFromResource(d, credentialName)) if err != nil { return fmt.Errorf("failed to create credential: %w", err) } @@ -88,45 +90,51 @@ func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) erro err = handleCredentialAPIResponse(resp, nil, client) if err != nil { - // If a credential with this name already exists, adopt it instead of - // failing: take ownership and update the existing credential's - // values (merged onto whatever it already had - not a full replace) - // rather than erroring on the unique-constraint conflict. See - // https://github.com/BerriAI/terraform-provider-litellm/issues/8. - if err.Error() == "credential_conflict" { - log.Printf("[WARN] Credential %q already exists; adopting it and updating to match configuration.", credentialName) - d.SetId(credentialName) - if updateErr := resourceLiteLLMCredentialUpdate(d, m); updateErr != nil { - // Adoption failed before this run took ownership of - // anything real. Clear the ID so create is reported as - // failed outright (matching pre-adoption behavior) instead - // of tainting state for a credential this run doesn't own - - // state that would otherwise get destroyed on the next - // apply. - d.SetId("") - return fmt.Errorf("failed to adopt existing credential %q: %w", credentialName, updateErr) - } - return nil + if errors.Is(err, errCredentialConflict) { + return handleCredentialNameConflict(d, m, credentialName) } return fmt.Errorf("failed to create credential: %w", err) } - // Set the resource ID to the credential name d.SetId(credentialName) log.Printf("[INFO] Credential created with name %s. Starting retry mechanism to read the credential...", credentialName) return retryCredentialRead(d, m, 5) } +func handleCredentialNameConflict(d *schema.ResourceData, m interface{}, credentialName string) error { + if !d.Get("adopt_existing").(bool) { + return fmt.Errorf( + "credential %q already exists on the proxy but is not in Terraform state. "+ + "Import it to manage it here:\n\n"+ + " terraform import litellm_credential. %s\n\n"+ + "The next apply then updates it to match this configuration. To take it over during "+ + "create instead, set adopt_existing = true on this resource, which overwrites the "+ + "existing credential's values with the ones configured here", + credentialName, shellSingleQuote(credentialName), + ) + } + + log.Printf("[WARN] Credential %q already exists; adopt_existing is set, so taking it over and updating it to match configuration.", credentialName) + d.SetId(credentialName) + if err := patchCredential(m.(*Client), d, credentialName); err != nil { + d.SetId("") + return fmt.Errorf("failed to adopt existing credential %q: %w", credentialName, err) + } + return retryCredentialRead(d, m, 5) +} + +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + func resourceLiteLLMCredentialRead(d *schema.ResourceData, m interface{}) error { client := m.(*Client) credentialName := d.Id() - // Try to get credential by name first - modelID := d.Get("model_id").(string) - endpoint := fmt.Sprintf("/credentials/by_name/%s", credentialName) - if modelID != "" { - endpoint += fmt.Sprintf("?model_id=%s", modelID) + endpoint := fmt.Sprintf(endpointCredentialByName, url.PathEscape(credentialName)) + if modelID := d.Get("model_id").(string); modelID != "" { + endpoint = fmt.Sprintf(endpointCredentialByNameForModel, url.PathEscape(credentialName), url.QueryEscape(modelID)) } resp, err := MakeRequest(client, "GET", endpoint, nil) @@ -158,48 +166,28 @@ func resourceLiteLLMCredentialRead(d *schema.ResourceData, m interface{}) error return nil } -func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) error { - client := m.(*Client) - credentialName := d.Id() - - modelID := d.Get("model_id").(string) - credentialInfo := d.Get("credential_info").(map[string]interface{}) - credentialValues := d.Get("credential_values").(map[string]interface{}) - - // Convert credential_info to map[string]interface{} for JSON - credInfoMap := make(map[string]interface{}) - for k, v := range credentialInfo { - credInfoMap[k] = v - } - - // Convert credential_values to map[string]interface{} for JSON - credValuesMap := make(map[string]interface{}) - for k, v := range credentialValues { - credValuesMap[k] = v - } - - // model_id must travel with the update the same way it does on create, - // so the proxy's model-based credential resolution still applies. Without - // it, updating (or adopting) a model_id-scoped credential silently loses - // that association. - credentialRequest := CredentialRequest{ - CredentialName: credentialName, - ModelID: modelID, - CredentialInfo: credInfoMap, - CredentialValues: credValuesMap, - } - - endpoint := fmt.Sprintf("/credentials/%s", credentialName) - resp, err := MakeRequest(client, "PATCH", endpoint, credentialRequest) +func patchCredential(client *Client, d *schema.ResourceData, credentialName string) error { + resp, err := MakeRequest(client, "PATCH", fmt.Sprintf(endpointCredential, url.PathEscape(credentialName)), credentialRequestFromResource(d, credentialName)) if err != nil { return fmt.Errorf("failed to update credential: %w", err) } defer resp.Body.Close() - err = handleCredentialAPIResponse(resp, nil, client) - if err != nil { + if err := handleCredentialAPIResponse(resp, nil, client); err != nil { return fmt.Errorf("failed to update credential: %w", err) } + return nil +} + +func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) error { + if !d.HasChangesExcept("adopt_existing") { + return nil + } + + credentialName := d.Id() + if err := patchCredential(m.(*Client), d, credentialName); err != nil { + return err + } log.Printf("[INFO] Credential updated with name %s. Starting retry mechanism to read the credential...", credentialName) return retryCredentialRead(d, m, 5) @@ -209,8 +197,7 @@ func resourceLiteLLMCredentialDelete(d *schema.ResourceData, m interface{}) erro client := m.(*Client) credentialName := d.Id() - endpoint := fmt.Sprintf("/credentials/%s", credentialName) - resp, err := MakeRequest(client, "DELETE", endpoint, nil) + resp, err := MakeRequest(client, "DELETE", fmt.Sprintf(endpointCredential, url.PathEscape(credentialName)), nil) if err != nil { return fmt.Errorf("failed to delete credential: %w", err) } diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go index 6e0b818fe33..02ae7ef0671 100644 --- a/terraform/provider/litellm/resource_credential_crud_test.go +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -1,15 +1,18 @@ package litellm import ( + "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) // newTestResourceData creates a *schema.ResourceData with the credential schema, @@ -201,10 +204,30 @@ func TestRetryCredentialRead_ConnectionError(t *testing.T) { fmt.Printf("connection error (expected): %v\n", err) } -// conflictServer builds the shared conflict-then-recover mock used by the -// adoption tests below. patchStatus/patchBody control the PATCH response, so -// callers can exercise both the success and failure paths. -func conflictServer(t *testing.T, patchStatus int, patchBody string) (*httptest.Server, *int32, *int32, *[]byte) { +type conflictBody struct { + status int + body string +} + +var ( + modernConflictBody = conflictBody{ + status: http.StatusConflict, + body: `{"error":{"message":"Credential 'conflict-test' already exists. Update it with PATCH /credentials/conflict-test, or delete it first.","type":"internal_server_error","param":"None","code":"409"}}`, + } + legacyConflictBody = conflictBody{ + status: http.StatusInternalServerError, + body: `{"error":{"message":"Unique constraint failed on the fields: (` + "`credential_name`" + `)","type":"internal_server_error","code":"500"}}`, + } +) + +type conflictServerOptions struct { + conflict conflictBody + patchStatus int + patchBody string + getStatus int +} + +func conflictServer(t *testing.T, opts conflictServerOptions) (*httptest.Server, *int32, *int32, *[]byte) { t.Helper() var createCalls, patchCalls int32 var capturedPatchBody []byte @@ -213,8 +236,8 @@ func conflictServer(t *testing.T, patchStatus int, patchBody string) (*httptest. case r.Method == http.MethodPost && r.URL.Path == "/credentials": atomic.AddInt32(&createCalls, 1) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":{"message":"Unique constraint failed on the fields: (` + "`credential_name`" + `)","type":"internal_server_error","code":"500"}}`)) + w.WriteHeader(opts.conflict.status) + w.Write([]byte(opts.conflict.body)) case r.Method == http.MethodPatch: atomic.AddInt32(&patchCalls, 1) if r.URL.Path != "/credentials/conflict-test" { @@ -223,12 +246,17 @@ func conflictServer(t *testing.T, patchStatus int, patchBody string) (*httptest. body, _ := io.ReadAll(r.Body) capturedPatchBody = body w.Header().Set("Content-Type", "application/json") - w.WriteHeader(patchStatus) - w.Write([]byte(patchBody)) + w.WriteHeader(opts.patchStatus) + w.Write([]byte(opts.patchBody)) case r.Method == http.MethodGet: if r.URL.Path != "/credentials/by_name/conflict-test" || r.URL.Query().Get("model_id") != "model-1" { t.Errorf("GET went to %q (query %q), want /credentials/by_name/conflict-test?model_id=model-1", r.URL.Path, r.URL.RawQuery) } + if opts.getStatus != 0 && opts.getStatus != http.StatusOK { + w.WriteHeader(opts.getStatus) + w.Write([]byte(`{"error":{"message":"Internal Server Error"}}`)) + return + } resp := CredentialResponse{CredentialName: "conflict-test", CredentialInfo: map[string]interface{}{}} body, _ := json.Marshal(resp) w.Header().Set("Content-Type", "application/json") @@ -241,66 +269,114 @@ func conflictServer(t *testing.T, patchStatus int, patchBody string) (*httptest. return srv, &createCalls, &patchCalls, &capturedPatchBody } -// A credential that already exists in LiteLLM (created out of band, or left -// behind by a prior apply that dropped state) must be adopted on create -// instead of failing on the credential_name unique-constraint conflict, and -// the adopt PATCH must carry model_id so model-based credential resolution -// still applies (previously dropped - see -// https://github.com/BerriAI/litellm/pull/39745). -func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { - srv, createCalls, patchCalls, patchBody := conflictServer(t, http.StatusOK, `{}`) - defer srv.Close() - - client := NewClient(srv.URL, "test-key", true) - d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ +func adoptTestData(t *testing.T, adoptExisting bool) *schema.ResourceData { + t.Helper() + return schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ "credential_name": "conflict-test", "model_id": "model-1", "credential_info": map[string]interface{}{"custom_llm_provider": "bedrock"}, "credential_values": map[string]interface{}{"aws_access_key_id": "val"}, + "adopt_existing": adoptExisting, }) +} - if err := resourceLiteLLMCredentialCreate(d, client); err != nil { - t.Fatalf("expected create to adopt the existing credential, got error: %v", err) - } - if d.Id() != "conflict-test" { - t.Fatalf("expected ID %q, got %q", "conflict-test", d.Id()) - } - if got := atomic.LoadInt32(createCalls); got != 1 { - t.Fatalf("expected exactly 1 POST /credentials call, got %d", got) - } - if got := atomic.LoadInt32(patchCalls); got != 1 { - t.Fatalf("expected the conflict to trigger exactly 1 PATCH (adopt-and-update), got %d", got) - } +func TestResourceLiteLLMCredentialCreate_AdoptsOnConflictWhenOptedIn(t *testing.T) { + for _, tc := range []struct { + name string + conflict conflictBody + }{ + {"typed 409", modernConflictBody}, + {"legacy 500 with unique-constraint message", legacyConflictBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, createCalls, patchCalls, patchBody := conflictServer(t, conflictServerOptions{conflict: tc.conflict, patchStatus: http.StatusOK, patchBody: `{}`}) + defer srv.Close() - var sent map[string]interface{} - if err := json.Unmarshal(*patchBody, &sent); err != nil { - t.Fatalf("PATCH body was not valid JSON: %v (%s)", err, *patchBody) - } - if sent["credential_name"] != "conflict-test" { - t.Errorf("PATCH body credential_name = %v, want conflict-test", sent["credential_name"]) - } - if sent["model_id"] != "model-1" { - t.Errorf("PATCH body model_id = %v, want model-1 (adoption must not drop model-based credential resolution)", sent["model_id"]) - } - credInfo, _ := sent["credential_info"].(map[string]interface{}) - if credInfo["custom_llm_provider"] != "bedrock" { - t.Errorf("PATCH body credential_info = %v, want custom_llm_provider=bedrock", sent["credential_info"]) + client := NewClient(srv.URL, "test-key", true) + d := adoptTestData(t, true) + + if err := resourceLiteLLMCredentialCreate(d, client); err != nil { + t.Fatalf("expected create to adopt the existing credential, got error: %v", err) + } + if d.Id() != "conflict-test" { + t.Fatalf("expected ID %q, got %q", "conflict-test", d.Id()) + } + if got := atomic.LoadInt32(createCalls); got != 1 { + t.Fatalf("expected exactly 1 POST /credentials call, got %d", got) + } + if got := atomic.LoadInt32(patchCalls); got != 1 { + t.Fatalf("expected the conflict to trigger exactly 1 PATCH (adopt-and-update), got %d", got) + } + + var sent map[string]interface{} + if err := json.Unmarshal(*patchBody, &sent); err != nil { + t.Fatalf("PATCH body was not valid JSON: %v (%s)", err, *patchBody) + } + if sent["credential_name"] != "conflict-test" { + t.Errorf("PATCH body credential_name = %v, want conflict-test", sent["credential_name"]) + } + if sent["model_id"] != "model-1" { + t.Errorf("PATCH body model_id = %v, want model-1 (adoption must not drop model-based credential resolution)", sent["model_id"]) + } + credInfo, _ := sent["credential_info"].(map[string]interface{}) + if credInfo["custom_llm_provider"] != "bedrock" { + t.Errorf("PATCH body credential_info = %v, want custom_llm_provider=bedrock", sent["credential_info"]) + } + }) + } +} + +func TestResourceLiteLLMCredentialCreate_ConflictWithoutOptInFailsWithImportHint(t *testing.T) { + for _, tc := range []struct { + name string + conflict conflictBody + }{ + {"typed 409", modernConflictBody}, + {"legacy 500 with unique-constraint message", legacyConflictBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, createCalls, patchCalls, _ := conflictServer(t, conflictServerOptions{conflict: tc.conflict, patchStatus: http.StatusOK, patchBody: `{}`}) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := adoptTestData(t, false) + + err := resourceLiteLLMCredentialCreate(d, client) + if err == nil { + t.Fatal("expected create to fail on the conflict when adopt_existing is unset, got nil") + } + if got := atomic.LoadInt32(createCalls); got != 1 { + t.Fatalf("expected exactly 1 POST /credentials call, got %d", got) + } + if got := atomic.LoadInt32(patchCalls); got != 0 { + t.Fatalf("expected no PATCH without adopt_existing - create must not overwrite an unmanaged credential - got %d", got) + } + if d.Id() != "" { + t.Fatalf("resource ID must stay empty when create refuses the conflict, got %q", d.Id()) + } + for _, want := range []string{ + "already exists", + `terraform import litellm_credential. 'conflict-test'`, + "adopt_existing = true", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error must tell the operator how to proceed; missing %q in: %v", want, err) + } + } + }) } } -// If the adopt PATCH itself fails, create must not have set the resource ID -// for a credential this run doesn't own - otherwise Terraform taints the -// entry and the *next* apply destroys a credential nobody here created. func TestResourceLiteLLMCredentialCreate_FailedAdoptDoesNotTaint(t *testing.T) { - srv, createCalls, patchCalls, _ := conflictServer(t, http.StatusInternalServerError, `{"error":{"message":"Internal Server Error"}}`) + srv, createCalls, patchCalls, _ := conflictServer(t, conflictServerOptions{ + conflict: modernConflictBody, + patchStatus: http.StatusInternalServerError, + patchBody: `{"error":{"message":"Internal Server Error"}}`, + }) defer srv.Close() client := NewClient(srv.URL, "test-key", true) - d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ - "credential_name": "conflict-test", - "credential_info": map[string]interface{}{}, - "credential_values": map[string]interface{}{"key": "val"}, - }) + d := adoptTestData(t, true) err := resourceLiteLLMCredentialCreate(d, client) if err == nil { @@ -317,8 +393,6 @@ func TestResourceLiteLLMCredentialCreate_FailedAdoptDoesNotTaint(t *testing.T) { } } -// A non-conflict failure (a plain 500, for example) must return the original -// error and never attempt to adopt anything. func TestResourceLiteLLMCredentialCreate_NonConflictErrorDoesNotAdopt(t *testing.T) { var createCalls, patchCalls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -343,6 +417,7 @@ func TestResourceLiteLLMCredentialCreate_NonConflictErrorDoesNotAdopt(t *testing "credential_name": "some-cred", "credential_info": map[string]interface{}{}, "credential_values": map[string]interface{}{"key": "val"}, + "adopt_existing": true, }) err := resourceLiteLLMCredentialCreate(d, client) @@ -356,3 +431,166 @@ func TestResourceLiteLLMCredentialCreate_NonConflictErrorDoesNotAdopt(t *testing t.Fatalf("resource ID must stay empty on a non-conflict failure, got %q", d.Id()) } } + +func TestResourceLiteLLMCredentialCreate_AdoptKeepsIDWhenPostPatchReadFails(t *testing.T) { + srv, _, patchCalls, _ := conflictServer(t, conflictServerOptions{ + conflict: modernConflictBody, + patchStatus: http.StatusOK, + patchBody: `{}`, + getStatus: http.StatusInternalServerError, + }) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := adoptTestData(t, true) + + err := resourceLiteLLMCredentialCreate(d, client) + if err == nil { + t.Fatal("expected the failed post-adopt read to surface as an error, got nil") + } + if got := atomic.LoadInt32(patchCalls); got != 1 { + t.Fatalf("expected exactly 1 PATCH, got %d", got) + } + if d.Id() != "conflict-test" { + t.Fatalf("the PATCH already overwrote the remote credential, so the ID must stay set for Terraform to track it; got %q", d.Id()) + } +} + +func TestResourceLiteLLMCredentialImportHintQuotesTheNameForTheShell(t *testing.T) { + for _, tc := range []struct { + name string + want string + }{ + {"my cred", `'my cred'`}, + {"it's $HOME `id` \"x\"", `'it'\''s $HOME ` + "`id`" + ` "x"'`}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"error":{"message":"already exists","code":"409"}}`)) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": tc.name, + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + + err := resourceLiteLLMCredentialCreate(d, NewClient(srv.URL, "test-key", true)) + if err == nil { + t.Fatal("expected the conflict to fail create, got nil") + } + want := "terraform import litellm_credential. " + tc.want + if !strings.Contains(err.Error(), want) { + t.Fatalf("import hint must single-quote the name for the shell; missing %q in: %v", want, err) + } + }) + } +} + +func TestCredentialRequestsEscapeReservedCharactersInTheName(t *testing.T) { + const name = "team/a?b c" + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.EscapedPath()+"?"+r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"credential_name":"` + name + `","credential_info":{}}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": name, + "model_id": "m&1", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + d.SetId(name) + + if err := resourceLiteLLMCredentialRead(d, client); err != nil { + t.Fatalf("read failed: %v", err) + } + if err := patchCredential(client, d, name); err != nil { + t.Fatalf("patch failed: %v", err) + } + if err := resourceLiteLLMCredentialDelete(d, client); err != nil { + t.Fatalf("delete failed: %v", err) + } + + want := []string{ + "GET /credentials/by_name/team%2Fa%3Fb%20c?model_id=m%261", + "PATCH /credentials/team%2Fa%3Fb%20c?", + "DELETE /credentials/team%2Fa%3Fb%20c?", + } + if strings.Join(paths, "\n") != strings.Join(want, "\n") { + t.Fatalf("request paths:\n%s\nwant:\n%s", strings.Join(paths, "\n"), strings.Join(want, "\n")) + } +} + +func TestResourceLiteLLMCredentialUpdate_TogglingAdoptExistingSendsNoPatch(t *testing.T) { + var patchCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch { + atomic.AddInt32(&patchCalls, 1) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"credential_name":"cred-1","credential_info":{}}`)) + })) + defer srv.Close() + + res := resourceLiteLLMCredential() + priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{ + "credential_name": "cred-1", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"api_key": "sk-secret"}, + "adopt_existing": false, + }) + priorData.SetId("cred-1") + prior := priorData.State() + + toggled := terraform.NewResourceConfigRaw(map[string]interface{}{ + "credential_name": "cred-1", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"api_key": "sk-secret"}, + "adopt_existing": true, + }) + diff, err := res.Diff(context.Background(), prior, toggled, nil) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + d, err := schema.InternalMap(res.Schema).Data(prior, diff) + if err != nil { + t.Fatalf("data failed: %v", err) + } + if err := resourceLiteLLMCredentialUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + if got := atomic.LoadInt32(&patchCalls); got != 0 { + t.Fatalf("flipping adopt_existing alone must not rewrite the credential's secrets; got %d PATCH calls", got) + } + + rotated := terraform.NewResourceConfigRaw(map[string]interface{}{ + "credential_name": "cred-1", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"api_key": "sk-rotated"}, + "adopt_existing": true, + }) + diff, err = res.Diff(context.Background(), prior, rotated, nil) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + d, err = schema.InternalMap(res.Schema).Data(prior, diff) + if err != nil { + t.Fatalf("data failed: %v", err) + } + if err := resourceLiteLLMCredentialUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + if got := atomic.LoadInt32(&patchCalls); got != 1 { + t.Fatalf("a real value change must still PATCH; got %d PATCH calls", got) + } +} diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go index a123f5d350a..f8f66afba3c 100644 --- a/terraform/provider/litellm/utils.go +++ b/terraform/provider/litellm/utils.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -202,33 +203,21 @@ func isCredentialNotFoundError(errResp ErrorResponse) bool { return false } -// isCredentialConflictError checks if the error response indicates a credential -// name collision. LiteLLM surfaces this as a 500 carrying the underlying Prisma -// unique-constraint message on credential_name. See -// https://github.com/BerriAI/terraform-provider-litellm/issues/8. -func isCredentialConflictError(errResp ErrorResponse) bool { - if msg, ok := errResp.Error.Message.(string); ok { - if strings.Contains(msg, "Unique constraint failed") && strings.Contains(msg, "credential_name") { - return true - } - } +var errCredentialConflict = errors.New("credential_conflict") +func isLegacyCredentialConflictError(errResp ErrorResponse) bool { + isConflict := func(msg string) bool { + return strings.Contains(msg, "Unique constraint failed") && strings.Contains(msg, "credential_name") + } + if msg, ok := errResp.Error.Message.(string); ok && isConflict(msg) { + return true + } if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { - if errStr, ok := msgMap["error"].(string); ok { - if strings.Contains(errStr, "Unique constraint failed") && strings.Contains(errStr, "credential_name") { - return true - } - } - } - - // Check Detail.Error field for LiteLLM proxy error format - if errResp.Detail.Error != "" { - if strings.Contains(errResp.Detail.Error, "Unique constraint failed") && strings.Contains(errResp.Detail.Error, "credential_name") { + if errStr, ok := msgMap["error"].(string); ok && isConflict(errStr) { return true } } - - return false + return isConflict(errResp.Detail.Error) } // handleCredentialAPIResponse handles API responses specifically for credential operations @@ -242,14 +231,18 @@ func handleCredentialAPIResponse(resp *http.Response, result interface{}, client return fmt.Errorf("credential_not_found") } + if resp.StatusCode == http.StatusConflict { + return errCredentialConflict + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { var errResp ErrorResponse if err := json.Unmarshal(bodyBytes, &errResp); err == nil { if isCredentialNotFoundError(errResp) { return fmt.Errorf("credential_not_found") } - if isCredentialConflictError(errResp) { - return fmt.Errorf("credential_conflict") + if isLegacyCredentialConflictError(errResp) { + return errCredentialConflict } } return fmt.Errorf("API request failed: Status: %s, Response: %s",