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;