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>
This commit is contained in:
ryan 2026-09-14 22:28:39 +00:00
parent 0c9e0c407e
commit 96332f75e8
14 changed files with 462 additions and 234 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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"],

View file

@ -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"]

View file

@ -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.",
)
)

View file

@ -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),
)

View file

@ -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,

View file

@ -1,7 +1,7 @@
from collections.abc import Mapping
from typing import Any, Final, Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing_extensions import ReadOnly, TypedDict
from litellm.proxy._types import (
@ -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

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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"))

View file

@ -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;