mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
Merge remote-tracking branch 'origin/main' into litellm_refuse_example_master_key
# Conflicts: # litellm/proxy/_types.py
This commit is contained in:
commit
417d33911c
33 changed files with 4770 additions and 154 deletions
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -658,9 +658,11 @@ class LiteLLMRoutes(enum.Enum):
|
|||
[
|
||||
# user
|
||||
"/user/new",
|
||||
"/management/v1/users/bulk",
|
||||
"/user/update",
|
||||
"/user/bulk_update",
|
||||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/info",
|
||||
"/user/list",
|
||||
"/user/daily/activity",
|
||||
|
|
@ -840,6 +842,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
self_managed_routes = [
|
||||
"/team/member_add",
|
||||
"/team/member_delete",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
"/team/member_update",
|
||||
"/team/{team_id}/member/{user_id}/reset_spend",
|
||||
"/team/permissions_list",
|
||||
|
|
@ -867,6 +870,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/user/available_roles", # read-only role metadata; any authenticated user may read
|
||||
"/user/list", # org admins checked in endpoint; non-admins get 403
|
||||
"/user/password/change", # endpoint only ever writes the caller's own row
|
||||
"/management/v1/users/bulk_delete", # proxy admins delete anyone, org admins only their orgs' users; others 403
|
||||
"/model/{model_id}/update",
|
||||
"/prompt/list",
|
||||
"/prompt/info",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import re
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Collection
|
||||
from typing import Final
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
|
@ -24,10 +24,13 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
|
|||
[
|
||||
# user
|
||||
"/user/new",
|
||||
"/management/v1/users/bulk",
|
||||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/bulk_update",
|
||||
# team
|
||||
"/team/new",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
"/team/update",
|
||||
"/team/delete",
|
||||
"/team/block",
|
||||
|
|
@ -601,7 +604,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
|
||||
|
||||
|
|
@ -772,9 +775,12 @@ class RouteChecks:
|
|||
_ADMIN_VIEWER_BLOCKED_WRITE_ROUTES = frozenset(
|
||||
[
|
||||
"/user/new",
|
||||
"/management/v1/users/bulk",
|
||||
"/user/delete",
|
||||
"/management/v1/users/bulk_delete",
|
||||
"/user/bulk_update",
|
||||
"/team/new",
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete",
|
||||
"/team/update",
|
||||
"/team/delete",
|
||||
"/model/new",
|
||||
|
|
@ -839,7 +845,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 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
|
||||
|
|
@ -878,9 +884,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}",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,40 @@ def escape_like(value: str) -> str:
|
|||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
class ValidationErrorDetail(TypedDict):
|
||||
"""The keys of a pydantic/FastAPI validation error a problem document needs."""
|
||||
|
||||
type: ReadOnly[str]
|
||||
loc: ReadOnly[tuple[int | str, ...]]
|
||||
msg: ReadOnly[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
|
||||
)
|
||||
|
||||
|
||||
def request_validation_problem(raw_errors: Sequence[ValidationErrorDetail]) -> ProblemDetail:
|
||||
"""A body that fails validation (an unknown field included) is 422; a bad query parameter is 400."""
|
||||
errors: Final = tuple(error for error in raw_errors if not _is_length_error_of_rejected_items(error, raw_errors))
|
||||
detail: Final = "; ".join(f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in errors)
|
||||
if any(error["loc"] and error["loc"][0] == "body" for error in errors):
|
||||
return ProblemDetail(
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -4294,6 +4294,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,
|
||||
|
|
@ -4405,31 +4439,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 {})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
94
litellm/proxy/management_endpoints/management_v1/teams.py
Normal file
94
litellm/proxy/management_endpoints/management_v1/teams.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""`POST /management/v1/teams/{team_id}/members/bulk_delete`."""
|
||||
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem, reject_unknown_query_params
|
||||
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
|
||||
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
BulkTeamMemberDeleteRequest,
|
||||
BulkTeamMemberDeleteResponse,
|
||||
)
|
||||
|
||||
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/teams/{team_id}/members/bulk_delete",
|
||||
tags=["team management"], # mutable-ok: FastAPI types `tags` as list[str], not Sequence
|
||||
dependencies=(Depends(user_api_key_auth), Depends(reject_unknown_query_params)),
|
||||
response_model=BulkTeamMemberDeleteResponse,
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def bulk_delete_team_members_action(
|
||||
team_id: str,
|
||||
data: BulkTeamMemberDeleteRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> BulkTeamMemberDeleteResponse:
|
||||
"""
|
||||
Remove up to 500 members from one team in one call. Same authorization as
|
||||
`/team/member_delete`: proxy admins, the team's admins, and admins of the team's
|
||||
organization. Each member is named by exactly one of `user_id` or `user_email`;
|
||||
unknown body fields are a 422 and an unknown team is a 404.
|
||||
|
||||
`data` holds one result per requested member, in request order. A row is
|
||||
`success: false` with an `error` when it names nobody on the team or repeats an
|
||||
earlier row. The roster is rewritten once, under the team's advisory lock, so a
|
||||
concurrent member_add is never overwritten from a stale read.
|
||||
|
||||
Example curl:
|
||||
```
|
||||
curl --location 'http://0.0.0.0:4000/management/v1/teams/team-1/members/bulk_delete' \
|
||||
--header 'Authorization: Bearer sk-1234' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"members": [{"user_id": "user-1"}, {"user_email": "user-2@example.com"}]}'
|
||||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
|
||||
title="Database not connected",
|
||||
status=503,
|
||||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
)
|
||||
|
||||
results: Final = await bulk_remove_team_members(
|
||||
team_id=team_id,
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
return BulkTeamMemberDeleteResponse(data=results)
|
||||
|
||||
except ManagementProblem:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
|
||||
verbose_proxy_logger.exception(
|
||||
"litellm.proxy.management_endpoints.management_v1.teams.bulk_delete_team_members_action(): "
|
||||
"Exception occured - %s",
|
||||
e,
|
||||
)
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
|
||||
title="Internal server error",
|
||||
status=500,
|
||||
detail="Failed to remove team members.",
|
||||
)
|
||||
)
|
||||
187
litellm/proxy/management_endpoints/management_v1/users.py
Normal file
187
litellm/proxy/management_endpoints/management_v1/users.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""`POST /management/v1/users/bulk` and `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_creation import bulk_create_users
|
||||
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy untyped decorator
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
|
||||
BulkDeleteUserRequest,
|
||||
BulkDeleteUsersResponse,
|
||||
BulkNewUserRequest,
|
||||
BulkNewUserResponse,
|
||||
)
|
||||
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.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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.",
|
||||
)
|
||||
)
|
||||
871
litellm/proxy/management_helpers/bulk_user_creation.py
Normal file
871
litellm/proxy/management_helpers/bulk_user_creation.py
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
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.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
|
||||
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,
|
||||
BulkNewUserMeta,
|
||||
BulkNewUserResponse,
|
||||
UserCreateResult,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
_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 row %d - %s", user.index, type(exc).__name__)
|
||||
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", exc_info=True)
|
||||
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,
|
||||
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 (
|
||||
tuple(user for user in prepared if user.row.user_id not in failed),
|
||||
tuple(failed.values()),
|
||||
)
|
||||
|
||||
|
||||
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 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 %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,
|
||||
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", type(outcome).__name__
|
||||
)
|
||||
|
||||
|
||||
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: # 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(
|
||||
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", type(eviction).__name__)
|
||||
|
||||
|
||||
_KEY_FIELDS: Final = MappingProxyType(
|
||||
{
|
||||
name: True
|
||||
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",
|
||||
"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", type(outcome).__name__
|
||||
)
|
||||
|
||||
|
||||
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 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)
|
||||
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 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])
|
||||
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(
|
||||
data=results,
|
||||
meta=BulkNewUserMeta(total_requested=len(users), created=successes, failed=len(users) - successes),
|
||||
)
|
||||
560
litellm/proxy/management_helpers/bulk_user_deletion.py
Normal file
560
litellm/proxy/management_helpers/bulk_user_deletion.py
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
"""Batched deletes behind `POST /management/v1/users/bulk_delete` and
|
||||
`POST /management/v1/teams/{team_id}/members/bulk_delete`.
|
||||
|
||||
Each team a batch touches is rewritten exactly once, under the same advisory lock
|
||||
`/team/member_delete` takes and from a roster re-read under that lock, so a concurrent
|
||||
member_add on the team is never overwritten from a stale read. A user batch runs in one
|
||||
transaction, taking its team locks in sorted order, so either every team rewrite and every
|
||||
user row delete lands or none of them does.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Awaitable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from fastapi import HTTPException
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
MemberDeleteRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import delete_cache_key_objects
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks
|
||||
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses
|
||||
_is_user_team_admin, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses
|
||||
)
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_persist_deleted_verification_tokens, # pyright: ignore[reportPrivateUsage] # same audit path /key/delete uses
|
||||
)
|
||||
from litellm.proxy.management_helpers.access_group_team_sync import TEAM_ADVISORY_LOCK_SQL
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.repositories.table_repositories import (
|
||||
OrganizationMembershipRepository,
|
||||
TeamMembershipRepository,
|
||||
)
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
|
||||
BulkDeleteUserRequest,
|
||||
UserDeleteResult,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
BulkTeamMemberDeleteRequest,
|
||||
TeamMemberDeleteResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import Prisma
|
||||
from prisma import models as prisma_models
|
||||
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
|
||||
_AUDIT_LOG_CONCURRENCY: Final = 10
|
||||
_BATCH_TX_TIMEOUT: Final = timedelta(seconds=60)
|
||||
|
||||
|
||||
class _OrgAdminFilter(TypedDict):
|
||||
user_id: ReadOnly[str]
|
||||
user_role: ReadOnly[str]
|
||||
|
||||
|
||||
class _RosterData(TypedDict):
|
||||
members_with_roles: ReadOnly[str]
|
||||
|
||||
|
||||
class _TeamsSet(TypedDict):
|
||||
set: ReadOnly[tuple[str, ...]]
|
||||
|
||||
|
||||
class _TeamsData(TypedDict):
|
||||
teams: ReadOnly[_TeamsSet]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TeamRemoval:
|
||||
"""One team's rewrite. `removed` holds the user ids taken off the team (roster, `teams` array, or both);
|
||||
`matched` holds the indexes into the requested members that named at least one of them."""
|
||||
|
||||
team: LiteLLM_TeamTable
|
||||
removed: frozenset[str]
|
||||
matched: frozenset[int]
|
||||
deleted_key_tokens: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _UserBatchDeletion:
|
||||
removals: Mapping[str, _TeamRemoval]
|
||||
deleted_key_tokens: tuple[str, ...]
|
||||
|
||||
|
||||
def _team_not_found(team_id: str) -> ManagementProblem:
|
||||
return ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}team-not-found",
|
||||
title="Team not found",
|
||||
status=404,
|
||||
detail=f"Team id={team_id} does not exist in db",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _forbidden(detail: str) -> ManagementProblem:
|
||||
return ManagementProblem(
|
||||
ProblemDetail(type=f"{PROBLEM_TYPE_BASE}forbidden", title="Forbidden", status=403, detail=detail)
|
||||
)
|
||||
|
||||
|
||||
def _in_filter(field: str, values: Iterable[str]) -> Mapping[str, object]:
|
||||
return {field: {"in": sorted(values)}} # mutable-ok: Prisma query filters are dict-shaped
|
||||
|
||||
|
||||
def _eq_filter(field: str, value: str) -> Mapping[str, object]:
|
||||
return {field: value} # mutable-ok: Prisma query filters are dict-shaped
|
||||
|
||||
|
||||
def _team_users_filter(team_id: str, user_ids: Iterable[str]) -> Mapping[str, object]:
|
||||
return {"team_id": team_id, **_in_filter("user_id", user_ids)} # mutable-ok: Prisma query filters are dict-shaped
|
||||
|
||||
|
||||
def _any_filter(*clauses: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return {"OR": clauses} # mutable-ok: Prisma query filters are dict-shaped
|
||||
|
||||
|
||||
def _team_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamTable]":
|
||||
return tx.litellm_teamtable # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _user_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_UserTable]":
|
||||
return tx.litellm_usertable # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _membership_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamMembership]":
|
||||
return tx.litellm_teammembership # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _token_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_VerificationToken]":
|
||||
return tx.litellm_verificationtoken # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _invitation_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_InvitationLink]":
|
||||
return tx.litellm_invitationlink # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _org_membership_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_OrganizationMembership]":
|
||||
return tx.litellm_organizationmembership # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
|
||||
|
||||
|
||||
def _same_email(email: str | None, request: MemberDeleteRequest) -> bool:
|
||||
return request.user_email is not None and request.user_email == email
|
||||
|
||||
|
||||
def _addresses_member(member: Member, request: MemberDeleteRequest) -> bool:
|
||||
if request.user_id is None:
|
||||
return _same_email(member.user_email, request)
|
||||
return request.user_id == member.user_id or (member.user_id is None and _same_email(member.user_email, request))
|
||||
|
||||
|
||||
def _with_row_email(request: MemberDeleteRequest, email_of: Mapping[str, str]) -> MemberDeleteRequest:
|
||||
if request.user_id is None or request.user_email is not None:
|
||||
return request
|
||||
return MemberDeleteRequest(user_id=request.user_id, user_email=email_of.get(request.user_id))
|
||||
|
||||
|
||||
def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDeleteRequest) -> bool:
|
||||
if request.user_id is None:
|
||||
return _same_email(user.user_email, request)
|
||||
return request.user_id == user.user_id
|
||||
|
||||
|
||||
def _error_message(exc: BaseException) -> str:
|
||||
if isinstance(exc, ManagementProblem):
|
||||
return exc.problem.detail
|
||||
if isinstance(exc, HTTPException) and isinstance(exc.detail, dict):
|
||||
return str(exc.detail.get("error", exc.detail)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # HTTPException.detail is untyped
|
||||
if isinstance(exc, HTTPException):
|
||||
return str(exc.detail) # pyright: ignore[reportUnknownArgumentType] # HTTPException.detail is untyped
|
||||
return str(exc) or type(exc).__name__
|
||||
|
||||
|
||||
async def _bounded(awaitables: Iterable[Awaitable[object]]) -> tuple[object | BaseException, ...]:
|
||||
semaphore: Final = asyncio.Semaphore(_AUDIT_LOG_CONCURRENCY)
|
||||
|
||||
async def run(awaitable: Awaitable[object]) -> object:
|
||||
async with semaphore:
|
||||
return await awaitable
|
||||
|
||||
return tuple(await asyncio.gather(*(run(a) for a in awaitables), return_exceptions=True))
|
||||
|
||||
|
||||
async def _remove_members_from_team(
|
||||
prisma_client: PrismaClient,
|
||||
tx: "Prisma",
|
||||
team_id: str,
|
||||
members: Sequence[MemberDeleteRequest],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> _TeamRemoval:
|
||||
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id)
|
||||
roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id)
|
||||
if roster is None:
|
||||
raise _team_not_found(team_id)
|
||||
|
||||
requested_ids: Final = frozenset(r.user_id for r in members if r.user_id is not None)
|
||||
requested_emails: Final = frozenset(r.user_email for r in members if r.user_id is None and r.user_email)
|
||||
requested_rows: Final = await _user_tx_db(tx).find_many(
|
||||
where=_any_filter(_in_filter("user_id", requested_ids), _in_filter("user_email", requested_emails))
|
||||
)
|
||||
email_of: Final = MappingProxyType(
|
||||
{u.user_id: u.user_email for u in requested_rows if u.user_email is not None and team_id in u.teams}
|
||||
)
|
||||
requests: Final = tuple(_with_row_email(r, email_of) for r in members)
|
||||
removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in requests))
|
||||
kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in requests))
|
||||
removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None)
|
||||
unfetched_ids: Final = removed_ids - frozenset(u.user_id for u in requested_rows)
|
||||
removed_rows: Final = (
|
||||
await _user_tx_db(tx).find_many(where=_in_filter("user_id", unfetched_ids)) if unfetched_ids else ()
|
||||
)
|
||||
stale_rows: Final = tuple(u for u in (*requested_rows, *removed_rows) if team_id in u.teams)
|
||||
cleanup_ids: Final = removed_ids | frozenset(u.user_id for u in stale_rows)
|
||||
matched: Final = frozenset(
|
||||
i
|
||||
for i, r in enumerate(requests)
|
||||
if any(_addresses_member(m, r) for m in removed_members) or any(_addresses_user(u, r) for u in stale_rows)
|
||||
)
|
||||
keys: Final = await _token_tx_db(tx).find_many(where=_team_users_filter(team_id, cleanup_ids))
|
||||
|
||||
if removed_members:
|
||||
roster_data: Final[_RosterData] = {
|
||||
"members_with_roles": json.dumps(tuple(m.model_dump() for m in kept_members))
|
||||
}
|
||||
await _team_tx_db(tx).update(where=_eq_filter("team_id", team_id), data=roster_data)
|
||||
for row in stale_rows:
|
||||
teams_data: _TeamsData = {"teams": {"set": tuple(t for t in row.teams if t != team_id)}}
|
||||
await _user_tx_db(tx).update(where=_eq_filter("user_id", row.user_id), data=teams_data)
|
||||
await _membership_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids))
|
||||
if keys:
|
||||
await _persist_deleted_verification_tokens(
|
||||
keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=None,
|
||||
tx=tx,
|
||||
)
|
||||
await _token_tx_db(tx).delete_many(where=_team_users_filter(team_id, cleanup_ids))
|
||||
|
||||
return _TeamRemoval(
|
||||
team=LiteLLM_TeamTable(
|
||||
team_id=team_id,
|
||||
members_with_roles=kept_members, # pyright: ignore[reportArgumentType] # pydantic coerces the tuple into the list field
|
||||
),
|
||||
removed=cleanup_ids,
|
||||
matched=matched,
|
||||
deleted_key_tokens=tuple(k.token for k in keys),
|
||||
)
|
||||
|
||||
|
||||
def _emit_team_members_metric(team: LiteLLM_TeamTable) -> None:
|
||||
prometheus_logger: Final = PrometheusLogger.get_instance()
|
||||
if prometheus_logger is None:
|
||||
return
|
||||
try:
|
||||
prometheus_logger.set_team_members_metric(team)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug("Prometheus: failed to emit team members metric: %s", str(e))
|
||||
|
||||
|
||||
def _duplicate_member_indexes(members: Sequence[MemberDeleteRequest]) -> frozenset[int]:
|
||||
return frozenset(
|
||||
i
|
||||
for i, m in enumerate(members)
|
||||
if any(
|
||||
(m.user_id is not None and m.user_id == earlier.user_id)
|
||||
or (m.user_email is not None and m.user_email == earlier.user_email)
|
||||
for earlier in members[:i]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def bulk_remove_team_members(
|
||||
team_id: str,
|
||||
data: BulkTeamMemberDeleteRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
) -> tuple[TeamMemberDeleteResult, ...]:
|
||||
team: Final = await TeamRepository(prisma_client).find_by_id(team_id)
|
||||
if team is None:
|
||||
raise _team_not_found(team_id)
|
||||
|
||||
if (
|
||||
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team)
|
||||
and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team)
|
||||
):
|
||||
raise _forbidden(
|
||||
"Call not allowed. User not proxy admin OR team admin OR org admin for this team. "
|
||||
f"route='/management/v1/teams/{team_id}/members/bulk_delete'"
|
||||
)
|
||||
|
||||
duplicates: Final = _duplicate_member_indexes(data.members)
|
||||
kept_indexes: Final = tuple(i for i in range(len(data.members)) if i not in duplicates)
|
||||
members: Final = tuple(data.members[i] for i in kept_indexes)
|
||||
async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx:
|
||||
removal: Final = await _remove_members_from_team(prisma_client, tx, team_id, members, user_api_key_dict)
|
||||
await delete_cache_key_objects(
|
||||
hashed_tokens=removal.deleted_key_tokens,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
_emit_team_members_metric(removal.team)
|
||||
|
||||
matched: Final = frozenset(kept_indexes[j] for j in removal.matched)
|
||||
|
||||
def error(index: int) -> str | None:
|
||||
if index in duplicates:
|
||||
return "Duplicate member in request"
|
||||
return None if index in matched else "User not found in team"
|
||||
|
||||
return tuple(
|
||||
TeamMemberDeleteResult(
|
||||
user_id=member.user_id,
|
||||
user_email=member.user_email,
|
||||
success=i in matched,
|
||||
error=error(i),
|
||||
)
|
||||
for i, member in enumerate(data.members)
|
||||
)
|
||||
|
||||
|
||||
async def _caller_admin_org_ids(prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]:
|
||||
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value or not user_api_key_dict.user_id:
|
||||
return frozenset()
|
||||
where: Final[_OrgAdminFilter] = {
|
||||
"user_id": user_api_key_dict.user_id,
|
||||
"user_role": LitellmUserRoles.ORG_ADMIN.value,
|
||||
}
|
||||
memberships: Final = await OrganizationMembershipRepository(prisma_client).table.find_many(where=where)
|
||||
return frozenset(m.organization_id for m in memberships if m.organization_id)
|
||||
|
||||
|
||||
def _scope_error(user_id: str, target_org_ids: frozenset[str], caller_admin_org_ids: frozenset[str]) -> str | None:
|
||||
if target_org_ids and target_org_ids <= caller_admin_org_ids:
|
||||
return None
|
||||
return (
|
||||
f"User {user_id} is not within your admin scope. "
|
||||
"Only PROXY_ADMIN may delete users outside your administered organizations."
|
||||
)
|
||||
|
||||
|
||||
async def _delete_user_rows(
|
||||
prisma_client: PrismaClient,
|
||||
tx: "Prisma",
|
||||
user_ids: frozenset[str],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: str | None,
|
||||
) -> tuple[str, ...]:
|
||||
keys: Final = await _token_tx_db(tx).find_many(where=_in_filter("user_id", user_ids))
|
||||
if keys:
|
||||
await _persist_deleted_verification_tokens(
|
||||
keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
tx=tx,
|
||||
)
|
||||
await _token_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
await _invitation_tx_db(tx).delete_many(
|
||||
where=_any_filter(
|
||||
_in_filter("user_id", user_ids),
|
||||
_in_filter("created_by", user_ids),
|
||||
_in_filter("updated_by", user_ids),
|
||||
)
|
||||
)
|
||||
await _org_membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
await _membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
await _user_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
|
||||
return tuple(k.token for k in keys)
|
||||
|
||||
|
||||
async def _delete_users_tx(
|
||||
prisma_client: PrismaClient,
|
||||
users: Sequence["prisma_models.LiteLLM_UserTable"],
|
||||
teams_of: Mapping[str, frozenset[str]],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: str | None,
|
||||
) -> _UserBatchDeletion:
|
||||
"""Rewrites every team the users belong to and deletes their rows in one transaction, so a
|
||||
failure anywhere rolls back the whole batch. Teams a user still names but which no longer exist
|
||||
are skipped; the user row goes away regardless."""
|
||||
async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx:
|
||||
team_rows: Final = await _team_tx_db(tx).find_many(
|
||||
where=_in_filter("team_id", frozenset(t for teams in teams_of.values() for t in teams))
|
||||
)
|
||||
team_ids: Final = tuple(sorted(t.team_id for t in team_rows))
|
||||
removals: Final = MappingProxyType(
|
||||
{
|
||||
tid: await _remove_members_from_team(
|
||||
prisma_client,
|
||||
tx,
|
||||
tid,
|
||||
tuple(
|
||||
MemberDeleteRequest(user_id=u.user_id, user_email=u.user_email)
|
||||
for u in users
|
||||
if tid in teams_of[u.user_id]
|
||||
),
|
||||
user_api_key_dict,
|
||||
)
|
||||
for tid in team_ids
|
||||
}
|
||||
)
|
||||
deleted_key_tokens: Final = await _delete_user_rows(
|
||||
prisma_client, tx, frozenset(u.user_id for u in users), user_api_key_dict, litellm_changed_by
|
||||
)
|
||||
return _UserBatchDeletion(
|
||||
removals=removals,
|
||||
deleted_key_tokens=deleted_key_tokens + tuple(t for r in removals.values() for t in r.deleted_key_tokens),
|
||||
)
|
||||
|
||||
|
||||
async def _delete_users(
|
||||
prisma_client: PrismaClient,
|
||||
users: Sequence["prisma_models.LiteLLM_UserTable"],
|
||||
teams_of: Mapping[str, frozenset[str]],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
litellm_proxy_admin_name: str | None,
|
||||
litellm_changed_by: str | None,
|
||||
) -> _UserBatchDeletion | str:
|
||||
"""Returns the error message when the transaction rolled back, in which case no row was touched."""
|
||||
user_ids: Final = frozenset(u.user_id for u in users)
|
||||
try:
|
||||
deletion: Final = await _delete_users_tx(prisma_client, users, teams_of, user_api_key_dict, litellm_changed_by)
|
||||
except Exception as e: # noqa: BLE001 # the rolled-back batch is reported per row, not as a request failure
|
||||
verbose_proxy_logger.error("users/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e)
|
||||
return _error_message(e)
|
||||
await delete_cache_key_objects(
|
||||
hashed_tokens=deletion.deleted_key_tokens,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await evict_and_broadcast(cache_keys=sorted(user_ids), user_api_key_cache=user_api_key_cache)
|
||||
for removal in deletion.removals.values():
|
||||
_emit_team_members_metric(removal.team)
|
||||
audit_outcomes: Final = await _bounded(
|
||||
UserManagementEventHooks.create_internal_user_audit_log(
|
||||
user_id=u.user_id,
|
||||
action="deleted",
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
before_value=u.model_dump_json(exclude_none=True),
|
||||
)
|
||||
for u in users
|
||||
)
|
||||
for u, outcome in zip(users, audit_outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
verbose_proxy_logger.warning("Failed to create audit log for user %s: %s", u.user_id, outcome)
|
||||
return deletion
|
||||
|
||||
|
||||
async def bulk_delete_users(
|
||||
data: BulkDeleteUserRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
litellm_proxy_admin_name: str | None,
|
||||
litellm_changed_by: str | None,
|
||||
) -> tuple[UserDeleteResult, ...]:
|
||||
caller_is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
|
||||
caller_admin_org_ids: Final = await _caller_admin_org_ids(prisma_client, user_api_key_dict)
|
||||
if not caller_is_proxy_admin and not caller_admin_org_ids:
|
||||
raise _forbidden("Only PROXY_ADMIN or ORG_ADMIN users may delete users.")
|
||||
|
||||
unique_ids: Final = frozenset(data.user_ids)
|
||||
rows: Final = await UserRepository(prisma_client).table.find_many(where=_in_filter("user_id", unique_ids))
|
||||
rows_by_id: Final = MappingProxyType({row.user_id: row for row in rows})
|
||||
target_memberships: Final = (
|
||||
()
|
||||
if caller_is_proxy_admin
|
||||
else await OrganizationMembershipRepository(prisma_client).table.find_many(
|
||||
where=_in_filter("user_id", unique_ids)
|
||||
)
|
||||
)
|
||||
|
||||
def precheck_error(user_id: str) -> str | None:
|
||||
if user_id not in rows_by_id:
|
||||
return f"User id={user_id} not found"
|
||||
if caller_is_proxy_admin:
|
||||
return None
|
||||
org_ids: Final = frozenset(
|
||||
m.organization_id for m in target_memberships if m.user_id == user_id and m.organization_id
|
||||
)
|
||||
return _scope_error(user_id, org_ids, caller_admin_org_ids)
|
||||
|
||||
precheck_errors: Final = MappingProxyType({uid: precheck_error(uid) for uid in unique_ids})
|
||||
candidates: Final = tuple(rows_by_id[uid] for uid in sorted(unique_ids) if precheck_errors[uid] is None)
|
||||
candidate_ids: Final = frozenset(u.user_id for u in candidates)
|
||||
|
||||
memberships: Final = await TeamMembershipRepository(prisma_client).table.find_many(
|
||||
where=_in_filter("user_id", candidate_ids)
|
||||
)
|
||||
teams_of: Final = MappingProxyType(
|
||||
{
|
||||
u.user_id: frozenset(u.teams) | frozenset(m.team_id for m in memberships if m.user_id == u.user_id)
|
||||
for u in candidates
|
||||
}
|
||||
)
|
||||
deletion: Final = (
|
||||
await _delete_users(
|
||||
prisma_client,
|
||||
candidates,
|
||||
teams_of,
|
||||
user_api_key_dict,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
litellm_proxy_admin_name,
|
||||
litellm_changed_by,
|
||||
)
|
||||
if candidates
|
||||
else _UserBatchDeletion(removals=MappingProxyType({}), deleted_key_tokens=())
|
||||
)
|
||||
|
||||
def result(index: int, user_id: str) -> UserDeleteResult:
|
||||
if user_id in data.user_ids[:index]:
|
||||
return UserDeleteResult(user_id=user_id, success=False, error=f"Duplicate user_id in request: {user_id}")
|
||||
error: Final = precheck_errors[user_id]
|
||||
if error is not None:
|
||||
return UserDeleteResult(user_id=user_id, success=False, error=error)
|
||||
if isinstance(deletion, str):
|
||||
return UserDeleteResult(
|
||||
user_id=user_id,
|
||||
user_email=rows_by_id[user_id].user_email,
|
||||
success=False,
|
||||
error=f"Failed to delete user: {deletion}",
|
||||
)
|
||||
return UserDeleteResult(
|
||||
user_id=user_id,
|
||||
user_email=rows_by_id[user_id].user_email,
|
||||
success=True,
|
||||
teams_removed=tuple(tid for tid, r in deletion.removals.items() if user_id in r.removed),
|
||||
)
|
||||
|
||||
return tuple(result(i, uid) for i, uid in enumerate(data.user_ids))
|
||||
|
|
@ -477,9 +477,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 (
|
||||
|
|
@ -605,7 +606,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 (
|
||||
|
|
@ -1799,27 +1799,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,
|
||||
|
|
|
|||
|
|
@ -3793,6 +3793,7 @@ def jsonify_object(data: dict) -> dict:
|
|||
# Bounded to prevent memory leaks from accumulated rotations.
|
||||
_deprecated_key_cache: Final[LimitedSizeOrderedDict] = LimitedSizeOrderedDict(max_size=1000)
|
||||
_DEPRECATED_KEY_CACHE_TTL_SECONDS: Final = 60
|
||||
_PRISMA_DEFAULT_TX_TIMEOUT: Final = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def _lookup_deprecated_key(
|
||||
|
|
@ -4171,13 +4172,13 @@ class PrismaClient:
|
|||
return self.db.read_target
|
||||
return self.db
|
||||
|
||||
def tx(self) -> "TransactionManager":
|
||||
def tx(self, *, timeout: timedelta = _PRISMA_DEFAULT_TX_TIMEOUT) -> "TransactionManager":
|
||||
"""Open an interactive transaction on the writer.
|
||||
|
||||
Callers go through this instead of reaching into ``self.db`` so writer
|
||||
selection and read-replica routing stay encapsulated in the wrapper.
|
||||
"""
|
||||
return cast("TransactionManager", self.db.tx()) # cast-ok: wrappers delegate tx via __getattr__ (untyped)
|
||||
return cast("TransactionManager", self.db.tx(timeout=timeout)) # cast-ok: untyped __getattr__ delegate
|
||||
|
||||
def get_request_status(self, payload: dict | SpendLogsPayload) -> Literal["success", "failure"]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_UserTableWithKeyCount,
|
||||
NewUserRequest,
|
||||
UpdateUserRequest,
|
||||
UpdateUserRequestNoUserIDorEmail,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse
|
||||
|
||||
MAX_BULK_DELETE_USERS: Final = 500
|
||||
|
||||
MAX_BULK_NEW_USERS: Final = 500
|
||||
|
||||
|
||||
class InsensitiveContains(TypedDict):
|
||||
|
|
@ -83,3 +89,72 @@ class BulkUpdateUserResponse(BaseModel):
|
|||
total_requested: int
|
||||
successful_updates: int
|
||||
failed_updates: int
|
||||
|
||||
|
||||
class BulkDeleteUserRequest(BaseModel):
|
||||
"""Body of `POST /management/v1/users/bulk_delete`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_ids: tuple[str, ...] = Field(min_length=1, max_length=MAX_BULK_DELETE_USERS)
|
||||
|
||||
|
||||
class UserDeleteResult(BaseModel):
|
||||
"""Outcome for one requested user, in request order. `teams_removed` lists the teams the user left."""
|
||||
|
||||
user_id: str
|
||||
user_email: str | None = None
|
||||
success: bool
|
||||
teams_removed: tuple[str, ...] = ()
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BulkDeleteUsersResponse(ResourceResponse[tuple[UserDeleteResult, ...]]):
|
||||
"""`{data: [...]}` with one `UserDeleteResult` per requested user, in request order."""
|
||||
|
||||
|
||||
class BulkNewUserItem(NewUserRequest):
|
||||
"""One row of `POST /management/v1/users/bulk`: the `/user/new` body, with keys opt-in and invite emails
|
||||
unsupported. Unknown fields are rejected, as on every `/management/v1` request body."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
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 /management/v1/users/bulk; invite users separately")
|
||||
return value
|
||||
|
||||
|
||||
class BulkNewUserRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
users: Sequence[BulkNewUserItem] = Field(min_length=1, max_length=MAX_BULK_NEW_USERS)
|
||||
|
||||
|
||||
class UserCreateResult(BaseModel):
|
||||
"""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
|
||||
success: bool
|
||||
teams: tuple[str, ...] | None = None
|
||||
key: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BulkNewUserMeta(BaseModel):
|
||||
total_requested: int
|
||||
created: int
|
||||
failed: int
|
||||
|
||||
|
||||
class BulkNewUserResponse(BaseModel):
|
||||
"""`data` holds one result per input row, in input order."""
|
||||
|
||||
data: tuple[UserCreateResult, ...]
|
||||
meta: BulkNewUserMeta
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ class ListLinks(BaseModel):
|
|||
last: str
|
||||
|
||||
|
||||
class ResourceResponse(BaseModel, Generic[TOut]):
|
||||
"""Envelope for a single resource or an action's result: `{data: ...}`, no `meta` or `links`."""
|
||||
|
||||
data: TOut
|
||||
|
||||
|
||||
class ListResponse(BaseModel, Generic[TOut]):
|
||||
"""Rows stay flat: JSON:API's `{type, id, attributes}` wrapper is a deliberate deviation, so every
|
||||
dashboard column accessor would otherwise have to go through `.attributes`."""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import Any, Literal
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from litellm.proxy._types import (
|
||||
KeyManagementRoutes,
|
||||
|
|
@ -8,10 +8,14 @@ from litellm.proxy._types import (
|
|||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
Member,
|
||||
MemberDeleteRequest,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse
|
||||
|
||||
TeamIdSearchMatch = Literal["exact", "prefix"]
|
||||
|
||||
MAX_BULK_TEAM_MEMBER_DELETES: Final = 500
|
||||
|
||||
|
||||
class GetTeamMemberPermissionsRequest(BaseModel):
|
||||
"""Request to get the team member permissions for a team"""
|
||||
|
|
@ -118,6 +122,39 @@ class BulkTeamMemberAddResponse(BaseModel):
|
|||
updated_team: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TeamMemberRef(MemberDeleteRequest):
|
||||
"""One member to remove, named by exactly one of `user_id` or `user_email`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_identifier(self) -> "TeamMemberRef":
|
||||
if self.user_id is not None and self.user_email is not None:
|
||||
raise ValueError("Each member must be identified by exactly one of user_id or user_email")
|
||||
return self
|
||||
|
||||
|
||||
class BulkTeamMemberDeleteRequest(BaseModel):
|
||||
"""Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
members: tuple[TeamMemberRef, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES)
|
||||
|
||||
|
||||
class TeamMemberDeleteResult(BaseModel):
|
||||
"""Outcome for one requested member, in request order."""
|
||||
|
||||
user_id: str | None = None
|
||||
user_email: str | None = None
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BulkTeamMemberDeleteResponse(ResourceResponse[tuple[TeamMemberDeleteResult, ...]]):
|
||||
"""`{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order."""
|
||||
|
||||
|
||||
class TeamMemberInfoResponse(LiteLLM_TeamMembership):
|
||||
"""Response for GET /team/{team_id}/members/me — caller's own membership row."""
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +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**: 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,25 +90,51 @@ func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) erro
|
|||
|
||||
err = handleCredentialAPIResponse(resp, nil, client)
|
||||
if err != 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.<this resource's name in your config> %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)
|
||||
|
|
@ -138,42 +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()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
credentialRequest := CredentialRequest{
|
||||
CredentialName: credentialName,
|
||||
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)
|
||||
|
|
@ -183,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +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,
|
||||
|
|
@ -199,3 +203,394 @@ 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)
|
||||
}
|
||||
|
||||
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
|
||||
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(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" {
|
||||
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(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")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(body)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
return srv, &createCalls, &patchCalls, &capturedPatchBody
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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.<this resource's name in your config> '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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceLiteLLMCredentialCreate_FailedAdoptDoesNotTaint(t *testing.T) {
|
||||
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 := adoptTestData(t, true)
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
"adopt_existing": true,
|
||||
})
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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.<this resource's name in your config> " + 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -202,6 +203,23 @@ func isCredentialNotFoundError(errResp ErrorResponse) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
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 && isConflict(errStr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isConflict(errResp.Detail.Error)
|
||||
}
|
||||
|
||||
// handleCredentialAPIResponse handles API responses specifically for credential operations
|
||||
func handleCredentialAPIResponse(resp *http.Response, result interface{}, client *Client) error {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
|
|
@ -213,12 +231,19 @@ 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 isLegacyCredentialConflictError(errResp) {
|
||||
return errCredentialConflict
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("API request failed: Status: %s, Response: %s",
|
||||
resp.Status, client.redactSensitiveData(string(bodyBytes)))
|
||||
|
|
|
|||
210
tests/proxy_behavior/management/test_team_bulk_member_delete.py
Normal file
210
tests/proxy_behavior/management/test_team_bulk_member_delete.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import pytest
|
||||
from prisma import Json
|
||||
|
||||
from .actors import Actor
|
||||
from .conftest import create_scratch_team, create_scratch_user
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
_MATRIX = [
|
||||
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
|
||||
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
|
||||
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200),
|
||||
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
|
||||
("alpha/owner", Actor.OWNER, "alpha", 403),
|
||||
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
|
||||
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
|
||||
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
|
||||
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
|
||||
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
|
||||
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
|
||||
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
|
||||
("beta/internal_user", Actor.INTERNAL_USER, "beta", 403),
|
||||
("beta/owner", Actor.OWNER, "beta", 403),
|
||||
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403),
|
||||
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403),
|
||||
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
|
||||
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
|
||||
]
|
||||
|
||||
|
||||
async def _seed_target(prisma, world, shape: str, team_id: str, victim_ids: list) -> None:
|
||||
if shape == "alpha":
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
team_id,
|
||||
organization_id=world.org_a_id,
|
||||
admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id],
|
||||
member_user_ids=victim_ids,
|
||||
)
|
||||
elif shape == "beta":
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
team_id,
|
||||
organization_id=world.org_b_id,
|
||||
member_user_ids=victim_ids,
|
||||
)
|
||||
else: # pragma: no cover - guard
|
||||
pytest.fail(f"unknown shape={shape}")
|
||||
|
||||
|
||||
def _member_ids(row) -> list:
|
||||
return [m["user_id"] for m in (row.members_with_roles or [])]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"actor,shape,expected_status",
|
||||
[(a, sh, s) for (_id, a, sh, s) in _MATRIX],
|
||||
ids=[s[0] for s in _MATRIX],
|
||||
)
|
||||
async def test_team_bulk_member_delete_authz_matrix(
|
||||
actor: Actor,
|
||||
shape: str,
|
||||
expected_status: int,
|
||||
proxy_client,
|
||||
prisma,
|
||||
scratch,
|
||||
world,
|
||||
):
|
||||
victims = [scratch.tag("v1"), scratch.tag("v2")]
|
||||
keep = scratch.tag("keep")
|
||||
await _seed_target(prisma, world, shape, scratch.prefix, victims + [keep])
|
||||
caller = world.keys[actor]
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
|
||||
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
||||
json={"members": [{"user_id": v} for v in victims]},
|
||||
)
|
||||
assert resp.status_code == expected_status, f"{actor.value} {shape}: {resp.status_code} {resp.text}"
|
||||
if expected_status == 403:
|
||||
assert resp.headers["content-type"] == "application/problem+json"
|
||||
assert resp.json()["type"] == "urn:litellm:error:forbidden"
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert row is not None
|
||||
assert keep in _member_ids(row), "unrelated member removed"
|
||||
if expected_status == 200:
|
||||
assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [(v, True) for v in victims]
|
||||
assert not set(victims) & set(_member_ids(row))
|
||||
else:
|
||||
assert set(victims) <= set(_member_ids(row)), "denied but members removed"
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_reports_each_row_in_order(proxy_client, prisma, scratch, world):
|
||||
victim = scratch.tag("victim")
|
||||
keep = scratch.tag("keep")
|
||||
stranger = scratch.tag("stranger")
|
||||
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim, keep])
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"members": [{"user_id": stranger}, {"user_id": victim}]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert set(body) == {"data"}
|
||||
assert [(r["user_id"], r["success"]) for r in body["data"]] == [
|
||||
(stranger, False),
|
||||
(victim, True),
|
||||
]
|
||||
assert body["data"][0]["error"] == "User not found in team"
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert row is not None and _member_ids(row) == [keep]
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_by_id_removes_a_legacy_email_only_roster_entry(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
email = f"{scratch.prefix}@example.com"
|
||||
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim", user_email=email)
|
||||
keep = scratch.tag("keep")
|
||||
await prisma.db.litellm_teamtable.create(
|
||||
data={
|
||||
"team_id": scratch.prefix,
|
||||
"team_alias": scratch.prefix,
|
||||
"organization_id": world.org_a_id,
|
||||
"members_with_roles": Json([{"user_email": email, "role": "user"}, {"user_id": keep, "role": "user"}]),
|
||||
}
|
||||
)
|
||||
await prisma.db.litellm_usertable.update(where={"user_id": victim}, data={"teams": [scratch.prefix]})
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"members": [{"user_id": victim}]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [(victim, True)]
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert row is not None and [(m["user_id"], m.get("user_email")) for m in row.members_with_roles] == [(keep, None)]
|
||||
user = await prisma.db.litellm_usertable.find_unique(where={"user_id": victim})
|
||||
assert user is not None and user.teams == []
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_row_naming_both_identifiers_is_422(proxy_client, prisma, scratch, world):
|
||||
victim = scratch.tag("victim")
|
||||
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim])
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"members": [{"user_id": victim, "user_email": f"{victim}@example.com"}]},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert resp.headers["content-type"] == "application/problem+json"
|
||||
assert resp.json()["type"] == "urn:litellm:error:invalid-request-body"
|
||||
assert (
|
||||
resp.json()["detail"]
|
||||
== "members.0: Value error, Each member must be identified by exactly one of user_id or user_email"
|
||||
)
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert row is not None and victim in _member_ids(row)
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_unknown_query_param_is_400(proxy_client, prisma, scratch, world):
|
||||
victim = scratch.tag("victim")
|
||||
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim])
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete?dry_run=1",
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"members": [{"user_id": victim}]},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert resp.headers["content-type"] == "application/problem+json"
|
||||
assert "dry_run" in resp.json()["detail"]
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert row is not None and victim in _member_ids(row)
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_unknown_body_field_is_422(proxy_client, prisma, scratch, world):
|
||||
victim = scratch.tag("victim")
|
||||
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim])
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.prefix}/members/bulk_delete",
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"team_id": scratch.prefix, "members": [{"user_id": victim}]},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert "team_id" in resp.json()["detail"]
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert row is not None and victim in _member_ids(row)
|
||||
|
||||
|
||||
async def test_team_bulk_member_delete_unknown_team_is_404_problem(proxy_client, scratch, world):
|
||||
resp = await proxy_client.post(
|
||||
f"/management/v1/teams/{scratch.tag('missing')}/members/bulk_delete",
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"members": [{"user_id": scratch.tag("victim")}]},
|
||||
)
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert resp.headers["content-type"] == "application/problem+json"
|
||||
assert resp.json()["type"] == "urn:litellm:error:team-not-found"
|
||||
137
tests/proxy_behavior/management/test_users_bulk_delete.py
Normal file
137
tests/proxy_behavior/management/test_users_bulk_delete.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import pytest
|
||||
|
||||
from .actors import Actor
|
||||
from .conftest import create_scratch_team, create_scratch_user
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
_URL = "/management/v1/users/bulk_delete"
|
||||
|
||||
# (id, actor, victims' org, expected status, whether the victims are gone afterwards)
|
||||
_MATRIX = [
|
||||
("org_a/proxy_admin", Actor.PROXY_ADMIN, "a", 200, True),
|
||||
("org_a/org_admin", Actor.ORG_ADMIN, "a", 200, True),
|
||||
("org_a/org_b_admin", Actor.ORG_B_ADMIN, "a", 200, False),
|
||||
("org_a/team_admin", Actor.TEAM_ADMIN, "a", 403, False),
|
||||
("org_a/internal_user", Actor.INTERNAL_USER, "a", 403, False),
|
||||
("org_a/owner", Actor.OWNER, "a", 403, False),
|
||||
("org_a/service_account", Actor.SERVICE_ACCOUNT, "a", 403, False),
|
||||
("no_org/proxy_admin", Actor.PROXY_ADMIN, None, 200, True),
|
||||
("no_org/org_admin", Actor.ORG_ADMIN, None, 200, False),
|
||||
]
|
||||
|
||||
|
||||
def _member_ids(row) -> list:
|
||||
return [m["user_id"] for m in (row.members_with_roles or [])]
|
||||
|
||||
|
||||
async def _seed_team_members(prisma, scratch, world, member_ids: list, org_id) -> None:
|
||||
"""Leave behind what /team/member_add would: roster entry, `teams` array, and org membership."""
|
||||
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=member_ids)
|
||||
await prisma.db.litellm_usertable.update_many(
|
||||
where={"user_id": {"in": member_ids}}, data={"teams": {"set": [scratch.prefix]}}
|
||||
)
|
||||
if org_id is None:
|
||||
return
|
||||
for uid in member_ids:
|
||||
await prisma.db.litellm_organizationmembership.create(
|
||||
data={"user_id": uid, "organization_id": org_id, "user_role": "internal_user"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"actor,org,expected_status,expect_deleted",
|
||||
[(a, o, s, d) for (_id, a, o, s, d) in _MATRIX],
|
||||
ids=[s[0] for s in _MATRIX],
|
||||
)
|
||||
async def test_users_bulk_delete_authz_matrix(
|
||||
actor: Actor,
|
||||
org,
|
||||
expected_status: int,
|
||||
expect_deleted: bool,
|
||||
proxy_client,
|
||||
prisma,
|
||||
scratch,
|
||||
world,
|
||||
):
|
||||
victims = [await create_scratch_user(prisma, scratch.prefix, suffix=s) for s in ("v1", "v2")]
|
||||
keep = await create_scratch_user(prisma, scratch.prefix, suffix="keep")
|
||||
await _seed_team_members(prisma, scratch, world, victims + [keep], world.org_a_id if org == "a" else None)
|
||||
|
||||
resp = await proxy_client.post(
|
||||
_URL,
|
||||
headers={"Authorization": f"Bearer {world.keys[actor].cleartext}"},
|
||||
json={"user_ids": victims},
|
||||
)
|
||||
assert resp.status_code == expected_status, f"{actor.value}: {resp.status_code} {resp.text}"
|
||||
|
||||
team = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
|
||||
assert team is not None and keep in _member_ids(team), "unrelated member removed"
|
||||
remaining = {u.user_id for u in await prisma.db.litellm_usertable.find_many(where={"user_id": {"in": victims}})}
|
||||
if expected_status == 403:
|
||||
assert resp.headers["content-type"] == "application/problem+json"
|
||||
assert resp.json()["type"] == "urn:litellm:error:forbidden"
|
||||
assert remaining == set(victims), "denied but users deleted"
|
||||
assert set(victims) <= set(_member_ids(team)), "denied but members removed"
|
||||
return
|
||||
|
||||
body = resp.json()
|
||||
assert set(body) == {"data"}
|
||||
rows = [(r["user_id"], r["success"], r["teams_removed"]) for r in body["data"]]
|
||||
if expect_deleted:
|
||||
assert rows == [(v, True, [scratch.prefix]) for v in victims]
|
||||
assert remaining == set()
|
||||
assert not set(victims) & set(_member_ids(team))
|
||||
return
|
||||
assert rows == [(v, False, []) for v in victims]
|
||||
assert all("not within your admin scope" in r["error"] for r in body["data"])
|
||||
assert remaining == set(victims), "out-of-scope rows reported failed but users deleted"
|
||||
assert set(victims) <= set(_member_ids(team))
|
||||
|
||||
|
||||
async def test_users_bulk_delete_reports_each_row_in_order(proxy_client, prisma, scratch, world):
|
||||
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim")
|
||||
ghost = scratch.tag("ghost")
|
||||
|
||||
resp = await proxy_client.post(
|
||||
_URL,
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"user_ids": [ghost, victim, victim]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [
|
||||
(ghost, False),
|
||||
(victim, True),
|
||||
(victim, False),
|
||||
]
|
||||
assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is None
|
||||
|
||||
|
||||
async def test_users_bulk_delete_unknown_query_param_is_400_problem(proxy_client, prisma, scratch, world):
|
||||
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim")
|
||||
|
||||
resp = await proxy_client.post(
|
||||
f"{_URL}?dry_run=1",
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"user_ids": [victim]},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert resp.headers["content-type"] == "application/problem+json"
|
||||
assert resp.json()["type"] == "urn:litellm:error:unknown-query-parameter"
|
||||
assert "dry_run" in resp.json()["detail"]
|
||||
assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is not None
|
||||
|
||||
|
||||
async def test_users_bulk_delete_unknown_body_field_is_422_problem(proxy_client, prisma, scratch, world):
|
||||
victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim")
|
||||
|
||||
resp = await proxy_client.post(
|
||||
_URL,
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"user_ids": [victim], "dry_run": True},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert resp.headers["content-type"] == "application/problem+json"
|
||||
assert resp.json()["type"] == "urn:litellm:error:invalid-request-body"
|
||||
assert "dry_run" in resp.json()["detail"]
|
||||
assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is not None
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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, 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 response.json()["detail"] == f"{field}: Extra inputs are not permitted"
|
||||
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"
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
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,
|
||||
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(),
|
||||
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:
|
||||
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")
|
||||
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 httpx.ReadError("connection reset after commit")
|
||||
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(),
|
||||
commit_then_drop: bool = False,
|
||||
raced_ids: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
self.litellm_usertable = _UserTable(fail_ids, commit_then_drop, raced_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(),
|
||||
commit_then_drop: bool = False,
|
||||
raced_ids: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
self.db = _Db(teams or [], fail_ids, commit_then_drop, raced_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.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
|
||||
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_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.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"]
|
||||
|
||||
|
||||
@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.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]
|
||||
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.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"]
|
||||
|
||||
|
||||
@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.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"]
|
||||
|
||||
|
||||
@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.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"]
|
||||
|
||||
|
||||
@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.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.meta.created, response.meta.failed) == (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",
|
||||
"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,
|
||||
)
|
||||
|
||||
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"
|
||||
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"}
|
||||
|
||||
|
||||
@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.data] == [False, True]
|
||||
assert "Only proxy admins" in (response.data[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(ManagementProblem) as exc:
|
||||
await _run(prisma, [{"user_id": f"u{i}"} for i in range(3)], license=license)
|
||||
|
||||
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.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.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"}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -0,0 +1,685 @@
|
|||
import copy
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.list_api.common import ManagementProblem
|
||||
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users, bulk_remove_team_members
|
||||
from litellm.types.proxy.management_endpoints.internal_user_endpoints import BulkDeleteUserRequest
|
||||
from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest, TeamMemberRef
|
||||
|
||||
ADMIN: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin")
|
||||
INTERNAL: Final = UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
ORG_ADMIN: Final = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN)
|
||||
|
||||
|
||||
class _UserRow(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
user_id: str
|
||||
user_email: str | None = None
|
||||
teams: list[str] = []
|
||||
|
||||
|
||||
class _Record(BaseModel):
|
||||
"""Attribute access like a Prisma row, over whatever columns the test seeded."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
def _in(where: Mapping[str, object], field: str) -> set[str] | None:
|
||||
clause = where.get(field)
|
||||
if isinstance(clause, dict) and "in" in clause:
|
||||
return set(clause["in"])
|
||||
if isinstance(clause, str):
|
||||
return {clause}
|
||||
return None
|
||||
|
||||
|
||||
def _matches(row: Mapping[str, object], where: Mapping[str, object]) -> bool:
|
||||
if "OR" in where:
|
||||
return any(_matches(row, clause) for clause in where["OR"])
|
||||
return all((wanted := _in(where, field)) is not None and row.get(field) in wanted for field in where)
|
||||
|
||||
|
||||
class _Rows:
|
||||
"""A list-backed Prisma table supporting the `in`/equality/OR filters the helper issues."""
|
||||
|
||||
def __init__(self, rows: Sequence[Mapping[str, object]] = ()) -> None:
|
||||
self.rows: list[dict[str, object]] = [dict(r) for r in rows]
|
||||
|
||||
async def find_many(self, where: Mapping[str, object]) -> list[_Record]:
|
||||
return [_Record.model_validate(r) for r in self.rows if _matches(r, where)]
|
||||
|
||||
async def delete_many(self, where: Mapping[str, object]) -> int:
|
||||
before = len(self.rows)
|
||||
self.rows = [r for r in self.rows if not _matches(r, where)]
|
||||
return before - len(self.rows)
|
||||
|
||||
async def create_many(self, data: Sequence[Mapping[str, object]]) -> int:
|
||||
self.rows.extend(dict(r) for r in data)
|
||||
return len(data)
|
||||
|
||||
|
||||
class _UserTable:
|
||||
def __init__(self, users: Sequence[_UserRow]) -> None:
|
||||
self.rows: dict[str, _UserRow] = {u.user_id: u for u in users}
|
||||
|
||||
async def find_many(self, where: Mapping[str, object]) -> list[_UserRow]:
|
||||
return [u for u in self.rows.values() if _matches(u.model_dump(), where)]
|
||||
|
||||
async def update(self, where: Mapping[str, str], data: Mapping[str, Mapping[str, Sequence[str]]]) -> _UserRow:
|
||||
row = self.rows[where["user_id"]]
|
||||
updated = row.model_copy(update={"teams": list(data["teams"]["set"])})
|
||||
self.rows[row.user_id] = updated
|
||||
return updated
|
||||
|
||||
async def delete_many(self, where: Mapping[str, object]) -> int:
|
||||
doomed = [uid for uid, u in self.rows.items() if _matches(u.model_dump(), where)]
|
||||
for uid in doomed:
|
||||
del self.rows[uid]
|
||||
return len(doomed)
|
||||
|
||||
|
||||
class _TeamTable:
|
||||
def __init__(self, teams: Sequence[LiteLLM_TeamTable]) -> None:
|
||||
self.rows: dict[str, LiteLLM_TeamTable] = {t.team_id: t for t in teams}
|
||||
self.update_calls = 0
|
||||
|
||||
async def find_unique(self, where: Mapping[str, str]) -> LiteLLM_TeamTable | None:
|
||||
return self.rows.get(where["team_id"])
|
||||
|
||||
async def find_many(self, where: Mapping[str, object]) -> list[LiteLLM_TeamTable]:
|
||||
return [t for t in self.rows.values() if _matches({"team_id": t.team_id}, where)]
|
||||
|
||||
async def update(self, where: Mapping[str, str], data: Mapping[str, str]) -> LiteLLM_TeamTable:
|
||||
self.update_calls += 1
|
||||
team = self.rows[where["team_id"]]
|
||||
team.members_with_roles = [Member(**m) for m in json.loads(data["members_with_roles"])]
|
||||
return team
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(
|
||||
self,
|
||||
users: Sequence[_UserRow],
|
||||
teams: Sequence[LiteLLM_TeamTable],
|
||||
memberships: Sequence[tuple[str, str]] = (),
|
||||
tokens: Sequence[Mapping[str, object]] = (),
|
||||
invitations: Sequence[Mapping[str, object]] = (),
|
||||
org_memberships: Sequence[Mapping[str, object]] = (),
|
||||
) -> None:
|
||||
self.litellm_usertable = _UserTable(users)
|
||||
self.litellm_teamtable = _TeamTable(teams)
|
||||
self.litellm_teammembership = _Rows([{"team_id": t, "user_id": u} for t, u in memberships])
|
||||
self.litellm_verificationtoken = _Rows(tokens)
|
||||
self.litellm_deletedverificationtoken = _Rows()
|
||||
self.litellm_invitationlink = _Rows(invitations)
|
||||
self.litellm_organizationmembership = _Rows(org_memberships)
|
||||
|
||||
|
||||
class _Tx:
|
||||
def __init__(self, db: _Db, on_lock: Callable[[str], None], fail_locks: frozenset[str]) -> None:
|
||||
self.litellm_teamtable = db.litellm_teamtable
|
||||
self.litellm_usertable = db.litellm_usertable
|
||||
self.litellm_teammembership = db.litellm_teammembership
|
||||
self.litellm_verificationtoken = db.litellm_verificationtoken
|
||||
self.litellm_deletedverificationtoken = db.litellm_deletedverificationtoken
|
||||
self.litellm_invitationlink = db.litellm_invitationlink
|
||||
self.litellm_organizationmembership = db.litellm_organizationmembership
|
||||
self._on_lock = on_lock
|
||||
self._fail_locks = fail_locks
|
||||
self.locks: list[str] = []
|
||||
self.roster_reads: list[str] = []
|
||||
|
||||
async def query_raw(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
team_id = str(args[0])
|
||||
if "pg_advisory_xact_lock" in sql:
|
||||
if team_id in self._fail_locks:
|
||||
raise RuntimeError("lock timeout")
|
||||
self.locks.append(team_id)
|
||||
self._on_lock(team_id)
|
||||
return []
|
||||
assert team_id in self.locks, "roster must be read under this team's advisory lock"
|
||||
self.roster_reads.append(team_id)
|
||||
team = self.litellm_teamtable.rows.get(team_id)
|
||||
if team is None:
|
||||
return []
|
||||
return [{"members_with_roles": json.dumps([m.model_dump() for m in team.members_with_roles])}]
|
||||
|
||||
|
||||
class _FakePrisma:
|
||||
def __init__(
|
||||
self,
|
||||
users: Sequence[_UserRow] = (),
|
||||
teams: Sequence[LiteLLM_TeamTable] = (),
|
||||
memberships: Sequence[tuple[str, str]] = (),
|
||||
tokens: Sequence[Mapping[str, object]] = (),
|
||||
invitations: Sequence[Mapping[str, object]] = (),
|
||||
org_memberships: Sequence[Mapping[str, object]] = (),
|
||||
on_lock: Callable[[str], None] = lambda _: None,
|
||||
fail_locks: frozenset[str] = frozenset(),
|
||||
fail_commit: bool = False,
|
||||
) -> None:
|
||||
self.db = _Db(users, teams, memberships, tokens, invitations, org_memberships)
|
||||
self._on_lock = on_lock
|
||||
self._fail_locks = fail_locks
|
||||
self._fail_commit = fail_commit
|
||||
self.locks: list[str] = []
|
||||
self.roster_reads: list[str] = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def tx(self, *, timeout: object = None):
|
||||
snapshot = copy.deepcopy(self.db)
|
||||
tx = _Tx(self.db, self._on_lock, self._fail_locks)
|
||||
try:
|
||||
yield tx
|
||||
if self._fail_commit:
|
||||
raise RuntimeError("connection reset")
|
||||
except BaseException:
|
||||
self.db.__dict__.update(snapshot.__dict__)
|
||||
raise
|
||||
self.locks.extend(tx.locks)
|
||||
self.roster_reads.extend(tx.roster_reads)
|
||||
|
||||
|
||||
def _team(team_id: str, *members: str, org: str | None = None) -> LiteLLM_TeamTable:
|
||||
return LiteLLM_TeamTable(
|
||||
team_id=team_id,
|
||||
organization_id=org,
|
||||
members_with_roles=[Member(user_id=m, user_email=f"{m}@example.com", role="user") for m in members],
|
||||
)
|
||||
|
||||
|
||||
def _user(user_id: str, *teams: str) -> _UserRow:
|
||||
return _UserRow(user_id=user_id, user_email=f"{user_id}@example.com", teams=list(teams))
|
||||
|
||||
|
||||
def _roster(prisma: _FakePrisma, team_id: str) -> list[str | None]:
|
||||
return [m.user_id for m in prisma.db.litellm_teamtable.rows[team_id].members_with_roles]
|
||||
|
||||
|
||||
def _cache_with(*hashed_tokens: str) -> UserApiKeyCache:
|
||||
cache = UserApiKeyCache()
|
||||
for token in hashed_tokens:
|
||||
cache.set_cache(key=token, value=UserAPIKeyAuth(token=token))
|
||||
return cache
|
||||
|
||||
|
||||
async def _delete(
|
||||
prisma: _FakePrisma,
|
||||
user_ids: Sequence[str],
|
||||
caller: UserAPIKeyAuth = ADMIN,
|
||||
cache: UserApiKeyCache | None = None,
|
||||
):
|
||||
return await bulk_delete_users(
|
||||
data=BulkDeleteUserRequest(user_ids=tuple(user_ids)),
|
||||
user_api_key_dict=caller,
|
||||
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
|
||||
user_api_key_cache=cache or UserApiKeyCache(),
|
||||
proxy_logging_obj=None,
|
||||
litellm_proxy_admin_name="default_user_id",
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
|
||||
|
||||
async def _remove(
|
||||
prisma: _FakePrisma,
|
||||
team_id: str,
|
||||
members: Sequence[Mapping[str, str]],
|
||||
caller: UserAPIKeyAuth = ADMIN,
|
||||
cache: UserApiKeyCache | None = None,
|
||||
):
|
||||
return await bulk_remove_team_members(
|
||||
team_id=team_id,
|
||||
data=BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(**m) for m in members)),
|
||||
user_api_key_dict=caller,
|
||||
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
|
||||
user_api_key_cache=cache or UserApiKeyCache(),
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_removes_users_from_every_team_and_store():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1", "t2"), _user("u2", "t1"), _user("keep", "t1")],
|
||||
teams=[_team("t1", "u1", "u2", "keep"), _team("t2", "u1", "other")],
|
||||
memberships=[("t1", "u1"), ("t2", "u1"), ("t1", "u2"), ("t1", "keep")],
|
||||
tokens=[{"token": "k1", "user_id": "u1", "team_id": "t1"}, {"token": "k2", "user_id": "keep"}],
|
||||
invitations=[
|
||||
{"id": "i1", "user_id": "u2", "created_by": "admin", "updated_by": "admin"},
|
||||
{"id": "i2", "user_id": "keep", "created_by": "u1", "updated_by": "admin"},
|
||||
{"id": "i3", "user_id": "keep", "created_by": "admin", "updated_by": "admin"},
|
||||
],
|
||||
org_memberships=[{"user_id": "u1", "organization_id": "o1", "user_role": "internal_user"}],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["u1", "u2"])
|
||||
|
||||
assert len(results) == 2
|
||||
assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in results] == [
|
||||
("u1", "u1@example.com", True, ("t1", "t2")),
|
||||
("u2", "u2@example.com", True, ("t1",)),
|
||||
]
|
||||
assert _roster(prisma, "t1") == ["keep"] and _roster(prisma, "t2") == ["other"]
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"keep"}
|
||||
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "keep"}]
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k2"]
|
||||
assert [t["token"] for t in prisma.db.litellm_deletedverificationtoken.rows] == ["k1"]
|
||||
assert [i["id"] for i in prisma.db.litellm_invitationlink.rows] == ["i3"]
|
||||
assert prisma.db.litellm_organizationmembership.rows == []
|
||||
assert prisma.locks == ["t1", "t2"] and prisma.roster_reads == ["t1", "t2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_leaves_teammates_who_share_the_deleted_users_email_alone():
|
||||
twin = _UserRow(user_id="twin", user_email="u1@example.com", teams=["t1"])
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id="u1", user_email="u1@example.com", role="user"),
|
||||
Member(user_id="twin", user_email="u1@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), twin],
|
||||
teams=[team],
|
||||
memberships=[("t1", "u1"), ("t1", "twin")],
|
||||
tokens=[
|
||||
{"token": "k1", "user_id": "u1", "team_id": "t1"},
|
||||
{"token": "k-twin", "user_id": "twin", "team_id": "t1"},
|
||||
],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))]
|
||||
assert _roster(prisma, "t1") == ["twin"]
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"twin"} and prisma.db.litellm_usertable.rows["twin"].teams == [
|
||||
"t1"
|
||||
]
|
||||
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "twin"}]
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k-twin"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_removes_the_deleted_users_email_only_roster_entry():
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id=None, user_email="u1@example.com", role="user"),
|
||||
Member(user_id="keep", user_email="keep@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("keep", "t1")], teams=[team])
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))]
|
||||
assert _roster(prisma, "t1") == ["keep"]
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"keep"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_finds_teams_through_membership_rows_when_user_teams_array_is_stale():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1")],
|
||||
teams=[_team("t1", "u1", "keep")],
|
||||
memberships=[("t1", "u1")],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
assert results[0].teams_removed == ("t1",)
|
||||
assert _roster(prisma, "t1") == ["keep"]
|
||||
assert prisma.db.litellm_teammembership.rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives():
|
||||
team = _team("t1", "u1")
|
||||
|
||||
def concurrent_member_add(team_id: str) -> None:
|
||||
team.members_with_roles.append(Member(user_id="late", role="user"))
|
||||
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[team], on_lock=concurrent_member_add)
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
assert results[0].success is True
|
||||
assert _roster(prisma, "t1") == ["late"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_reports_missing_and_duplicate_ids_per_item_and_still_deletes_the_rest():
|
||||
prisma = _FakePrisma(users=[_user("u1")])
|
||||
|
||||
results = await _delete(prisma, ["u1", "ghost", "u1"])
|
||||
|
||||
assert [r.success for r in results].count(True) == 1
|
||||
assert [(r.user_id, r.success, r.error) for r in results] == [
|
||||
("u1", True, None),
|
||||
("ghost", False, "User id=ghost not found"),
|
||||
("u1", False, "Duplicate user_id in request: u1"),
|
||||
]
|
||||
assert prisma.db.litellm_usertable.rows == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_fails():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "a-good", "z-bad"), _user("u2", "a-good")],
|
||||
teams=[_team("a-good", "u1", "u2"), _team("z-bad", "u1")],
|
||||
tokens=[{"token": "k1", "user_id": "u1", "team_id": "a-good"}],
|
||||
fail_locks=frozenset({"z-bad"}),
|
||||
)
|
||||
cache = _cache_with("k1")
|
||||
|
||||
results = await _delete(prisma, ["u1", "u2"], cache=cache)
|
||||
|
||||
assert [(r.user_id, r.success, r.teams_removed, r.error) for r in results] == [
|
||||
("u1", False, (), "Failed to delete user: lock timeout"),
|
||||
("u2", False, (), "Failed to delete user: lock timeout"),
|
||||
]
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2"}
|
||||
assert _roster(prisma, "a-good") == ["u1", "u2"] and _roster(prisma, "z-bad") == ["u1"]
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k1"]
|
||||
assert cache.get_cache(key="k1") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_skips_teams_the_user_still_names_but_which_no_longer_exist():
|
||||
prisma = _FakePrisma(users=[_user("u1", "gone", "t1")], teams=[_team("t1", "u1", "keep")])
|
||||
|
||||
results = await _delete(prisma, ["u1"])
|
||||
|
||||
assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))]
|
||||
assert prisma.db.litellm_usertable.rows == {} and _roster(prisma, "t1") == ["keep"]
|
||||
assert prisma.locks == ["t1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rolls_back_every_user_row_and_reports_it_per_row_when_the_delete_fails():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("u2")],
|
||||
teams=[_team("t1", "u1")],
|
||||
tokens=[{"token": "k1", "user_id": "u1"}],
|
||||
fail_commit=True,
|
||||
)
|
||||
cache = _cache_with("k1")
|
||||
|
||||
results = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache)
|
||||
|
||||
assert [(r.user_id, r.success, r.error) for r in results] == [
|
||||
("u1", False, "Failed to delete user: connection reset"),
|
||||
("u2", False, "Failed to delete user: connection reset"),
|
||||
("ghost", False, "User id=ghost not found"),
|
||||
]
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2"}
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k1"]
|
||||
assert prisma.db.litellm_deletedverificationtoken.rows == []
|
||||
assert cache.get_cache(key="k1") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_evicts_deleted_keys_and_users_from_the_auth_cache():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("keep", "t1")],
|
||||
teams=[_team("t1", "u1", "keep")],
|
||||
tokens=[
|
||||
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
|
||||
{"token": "personal-key", "user_id": "u1"},
|
||||
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
|
||||
],
|
||||
)
|
||||
cache = _cache_with("team-key", "personal-key", "keep-key")
|
||||
cache.set_cache(key="u1", value={"user_id": "u1"})
|
||||
|
||||
await _delete(prisma, ["u1"], cache=cache)
|
||||
|
||||
assert cache.get_cache(key="team-key") is None and cache.get_cache(key="personal-key") is None
|
||||
assert cache.get_cache(key="u1") is None
|
||||
assert cache.get_cache(key="keep-key") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db():
|
||||
prisma = _FakePrisma(users=[_user("u1")])
|
||||
|
||||
with pytest.raises(ManagementProblem) as exc:
|
||||
await _delete(prisma, ["u1"], caller=INTERNAL)
|
||||
|
||||
assert exc.value.problem.status == 403
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"u1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_admin_deletes_only_users_fully_inside_their_orgs():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("inside"), _user("straddles"), _user("orgless")],
|
||||
org_memberships=[
|
||||
{"user_id": "org-admin", "organization_id": "o1", "user_role": LitellmUserRoles.ORG_ADMIN.value},
|
||||
{"user_id": "inside", "organization_id": "o1", "user_role": "internal_user"},
|
||||
{"user_id": "straddles", "organization_id": "o1", "user_role": "internal_user"},
|
||||
{"user_id": "straddles", "organization_id": "o2", "user_role": "internal_user"},
|
||||
],
|
||||
)
|
||||
|
||||
results = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN)
|
||||
|
||||
assert [r.success for r in results] == [True, False, False]
|
||||
assert all("not within your admin scope" in (r.error or "") for r in results[1:])
|
||||
assert set(prisma.db.litellm_usertable.rows) == {"straddles", "orgless"}
|
||||
assert {(m["user_id"], m["organization_id"]) for m in prisma.db.litellm_organizationmembership.rows} == {
|
||||
("org-admin", "o1"),
|
||||
("straddles", "o1"),
|
||||
("straddles", "o2"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1", "t2"), _user("u2", "t1"), _user("keep", "t1")],
|
||||
teams=[_team("t1", "u1", "u2", "keep")],
|
||||
memberships=[("t1", "u1"), ("t1", "u2"), ("t1", "keep")],
|
||||
tokens=[
|
||||
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
|
||||
{"token": "other-team-key", "user_id": "u1", "team_id": "t2"},
|
||||
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
|
||||
],
|
||||
)
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}])
|
||||
|
||||
assert [(r.user_id, r.user_email, r.success) for r in results] == [
|
||||
("u1", None, True),
|
||||
(None, "u2@example.com", True),
|
||||
]
|
||||
assert _roster(prisma, "t1") == ["keep"]
|
||||
users = prisma.db.litellm_usertable.rows
|
||||
assert users["u1"].teams == ["t2"] and users["u2"].teams == [] and users["keep"].teams == ["t1"]
|
||||
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "keep"}]
|
||||
assert sorted(t["token"] for t in prisma.db.litellm_verificationtoken.rows) == ["keep-key", "other-team-key"]
|
||||
assert [t["token"] for t in prisma.db.litellm_deletedverificationtoken.rows] == ["team-key"]
|
||||
assert prisma.locks == ["t1"] and prisma.roster_reads == ["t1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_reports_members_not_on_the_team_without_rewriting_the_roster():
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("elsewhere")], teams=[_team("t1", "u1")])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}])
|
||||
|
||||
assert [(r.success, r.error) for r in results] == [
|
||||
(False, "User not found in team"),
|
||||
(False, "User not found in team"),
|
||||
]
|
||||
assert prisma.db.litellm_teamtable.update_calls == 0
|
||||
assert _roster(prisma, "t1") == ["u1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_members_alone():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("elsewhere")],
|
||||
teams=[_team("t1", "u1")],
|
||||
memberships=[("t1", "elsewhere")],
|
||||
tokens=[{"token": "orphan-key", "user_id": "elsewhere", "team_id": "t1"}],
|
||||
)
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}])
|
||||
|
||||
assert results[0].success is False
|
||||
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "elsewhere"}]
|
||||
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["orphan-key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_reports_repeated_members_as_duplicates_and_removes_them_once():
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("u2", "t1")], teams=[_team("t1", "u1", "u2", "keep")])
|
||||
|
||||
results = await _remove(
|
||||
prisma, "t1", [{"user_id": "u1"}, {"user_id": "u1"}, {"user_email": "u1@example.com"}, {"user_id": "u2"}]
|
||||
)
|
||||
|
||||
assert [(r.success, r.error) for r in results] == [
|
||||
(True, None),
|
||||
(False, "Duplicate member in request"),
|
||||
(True, None),
|
||||
(True, None),
|
||||
]
|
||||
assert _roster(prisma, "t1") == ["keep"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_evicts_the_removed_team_keys_from_the_auth_cache():
|
||||
prisma = _FakePrisma(
|
||||
users=[_user("u1", "t1"), _user("keep", "t1")],
|
||||
teams=[_team("t1", "u1", "keep")],
|
||||
tokens=[
|
||||
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
|
||||
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
|
||||
],
|
||||
)
|
||||
cache = _cache_with("team-key", "keep-key")
|
||||
|
||||
await _remove(prisma, "t1", [{"user_id": "u1"}], cache=cache)
|
||||
|
||||
assert cache.get_cache(key="team-key") is None
|
||||
assert cache.get_cache(key="keep-key") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_the_team():
|
||||
prisma = _FakePrisma(users=[_user("stale", "t1")], teams=[_team("t1", "other")], memberships=[("t1", "stale")])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "stale"}])
|
||||
|
||||
assert results[0].success is True
|
||||
assert prisma.db.litellm_usertable.rows["stale"].teams == []
|
||||
assert prisma.db.litellm_teammembership.rows == []
|
||||
assert _roster(prisma, "t1") == ["other"] and prisma.db.litellm_teamtable.update_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_by_id_removes_the_members_email_only_roster_entry():
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id=None, user_email="u1@example.com", role="user"),
|
||||
Member(user_id="twin", user_email="u1@example.com", role="user"),
|
||||
Member(user_id="keep", user_email="keep@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
twin = _UserRow(user_id="twin", user_email="u1@example.com", teams=["t1"])
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1"), twin, _user("keep", "t1")], teams=[team])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "u1"}])
|
||||
|
||||
assert [(r.success, r.error) for r in results] == [(True, None)]
|
||||
assert _roster(prisma, "t1") == ["twin", "keep"]
|
||||
users = prisma.db.litellm_usertable.rows
|
||||
assert users["u1"].teams == [] and users["twin"].teams == ["t1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_by_id_of_a_non_member_leaves_a_same_email_users_roster_entry():
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
members_with_roles=[
|
||||
Member(user_id=None, user_email="shared@example.com", role="user"),
|
||||
Member(user_id="keep", user_email="keep@example.com", role="user"),
|
||||
],
|
||||
)
|
||||
outsider = _UserRow(user_id="outsider", user_email="shared@example.com", teams=[])
|
||||
member = _UserRow(user_id="member", user_email="shared@example.com", teams=["t1"])
|
||||
prisma = _FakePrisma(users=[outsider, member, _user("keep", "t1")], teams=[team])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "outsider"}])
|
||||
|
||||
assert [(r.success, r.error) for r in results] == [(False, "User not found in team")]
|
||||
assert _roster(prisma, "t1") == [None, "keep"]
|
||||
assert prisma.db.litellm_usertable.rows["member"].teams == ["t1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_member_delete_rejects_unknown_team_and_unauthorized_callers():
|
||||
prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[_team("t1", "u1")])
|
||||
|
||||
with pytest.raises(ManagementProblem) as missing:
|
||||
await _remove(prisma, "nope", [{"user_id": "u1"}])
|
||||
with pytest.raises(ManagementProblem) as forbidden:
|
||||
await _remove(prisma, "t1", [{"user_id": "u1"}], caller=INTERNAL)
|
||||
|
||||
assert missing.value.problem.status == 404
|
||||
assert forbidden.value.problem.status == 403
|
||||
assert _roster(prisma, "t1") == ["u1"] and prisma.locks == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_admin_may_bulk_remove_members():
|
||||
team = _team("t1", "lead", "u1")
|
||||
team.members_with_roles[0].role = "admin"
|
||||
prisma = _FakePrisma(users=[_user("lead", "t1"), _user("u1", "t1")], teams=[team])
|
||||
|
||||
results = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead"))
|
||||
|
||||
assert results[0].success is True
|
||||
assert _roster(prisma, "t1") == ["lead"]
|
||||
|
||||
|
||||
def test_request_models_enforce_batch_bounds():
|
||||
with pytest.raises(ValidationError):
|
||||
BulkDeleteUserRequest(user_ids=())
|
||||
with pytest.raises(ValidationError):
|
||||
BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(501)))
|
||||
with pytest.raises(ValidationError):
|
||||
BulkTeamMemberDeleteRequest(members=())
|
||||
with pytest.raises(ValidationError):
|
||||
BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(user_id=f"u{i}") for i in range(501)))
|
||||
assert len(BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(500))).user_ids) == 500
|
||||
|
||||
|
||||
def test_bulk_member_delete_request_requires_exactly_one_identifier_per_member():
|
||||
with pytest.raises(ValidationError, match="exactly one of user_id or user_email"):
|
||||
BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "user_email": "other@example.com"}]})
|
||||
with pytest.raises(ValidationError):
|
||||
BulkTeamMemberDeleteRequest.model_validate({"members": [{}]})
|
||||
assert BulkTeamMemberDeleteRequest(members=(TeamMemberRef(user_id="u1"),)).members[0].user_id == "u1"
|
||||
|
||||
|
||||
def test_request_models_reject_unknown_fields():
|
||||
with pytest.raises(ValidationError, match="team_id"):
|
||||
BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{"user_id": "u1"}]})
|
||||
with pytest.raises(ValidationError, match="role"):
|
||||
BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "role": "admin"}]})
|
||||
with pytest.raises(ValidationError, match="dry_run"):
|
||||
BulkDeleteUserRequest.model_validate({"user_ids": ["u1"], "dry_run": True})
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
478
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
478
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -8499,6 +8499,119 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/management/v1/teams/{team_id}/members/bulk_delete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Bulk Delete Team Members Action
|
||||
* @description Remove up to 500 members from one team in one call. Same authorization as
|
||||
* `/team/member_delete`: proxy admins, the team's admins, and admins of the team's
|
||||
* organization. Each member is named by exactly one of `user_id` or `user_email`;
|
||||
* unknown body fields are a 422 and an unknown team is a 404.
|
||||
*
|
||||
* `data` holds one result per requested member, in request order. A row is
|
||||
* `success: false` with an `error` when it names nobody on the team or repeats an
|
||||
* earlier row. The roster is rewritten once, under the team's advisory lock, so a
|
||||
* concurrent member_add is never overwritten from a stale read.
|
||||
*
|
||||
* Example curl:
|
||||
* ```
|
||||
* curl --location 'http://0.0.0.0:4000/management/v1/teams/team-1/members/bulk_delete' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"members": [{"user_id": "user-1"}, {"user_email": "user-2@example.com"}]}'
|
||||
* ```
|
||||
*/
|
||||
post: operations["bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/management/v1/users/bulk": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
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;
|
||||
};
|
||||
"/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;
|
||||
|
|
@ -24563,6 +24676,172 @@ export interface components {
|
|||
/** Budgets */
|
||||
budgets: string[];
|
||||
};
|
||||
/**
|
||||
* BulkDeleteUserRequest
|
||||
* @description Body of `POST /management/v1/users/bulk_delete`.
|
||||
*/
|
||||
BulkDeleteUserRequest: {
|
||||
/** User Ids */
|
||||
user_ids: string[];
|
||||
};
|
||||
/**
|
||||
* BulkDeleteUsersResponse
|
||||
* @description `{data: [...]}` with one `UserDeleteResult` per requested user, in request order.
|
||||
*/
|
||||
BulkDeleteUsersResponse: {
|
||||
/** Data */
|
||||
data: components["schemas"]["UserDeleteResult"][];
|
||||
};
|
||||
/**
|
||||
* BulkNewUserItem
|
||||
* @description One row of `POST /management/v1/users/bulk`: the `/user/new` body, with keys opt-in and invite emails
|
||||
* unsupported. Unknown fields are rejected, as on every `/management/v1` request body.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
/** BulkNewUserMeta */
|
||||
BulkNewUserMeta: {
|
||||
/** Created */
|
||||
created: number;
|
||||
/** Failed */
|
||||
failed: number;
|
||||
/** Total Requested */
|
||||
total_requested: number;
|
||||
};
|
||||
/** BulkNewUserRequest */
|
||||
BulkNewUserRequest: {
|
||||
/** Users */
|
||||
users: components["schemas"]["BulkNewUserItem"][];
|
||||
};
|
||||
/**
|
||||
* BulkNewUserResponse
|
||||
* @description `data` holds one result per input row, in input order.
|
||||
*/
|
||||
BulkNewUserResponse: {
|
||||
/** Data */
|
||||
data: components["schemas"]["UserCreateResult"][];
|
||||
meta: components["schemas"]["BulkNewUserMeta"];
|
||||
};
|
||||
/**
|
||||
* BulkTeamMemberAddRequest
|
||||
* @description Request for bulk team member addition
|
||||
|
|
@ -24600,6 +24879,22 @@ export interface components {
|
|||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/**
|
||||
* BulkTeamMemberDeleteRequest
|
||||
* @description Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`.
|
||||
*/
|
||||
BulkTeamMemberDeleteRequest: {
|
||||
/** Members */
|
||||
members: components["schemas"]["TeamMemberRef"][];
|
||||
};
|
||||
/**
|
||||
* BulkTeamMemberDeleteResponse
|
||||
* @description `{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order.
|
||||
*/
|
||||
BulkTeamMemberDeleteResponse: {
|
||||
/** Data */
|
||||
data: components["schemas"]["TeamMemberDeleteResult"][];
|
||||
};
|
||||
/**
|
||||
* BulkUpdateKeyRequest
|
||||
* @description Request for bulk key updates
|
||||
|
|
@ -37365,6 +37660,20 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* TeamMemberDeleteResult
|
||||
* @description Outcome for one requested member, in request order.
|
||||
*/
|
||||
TeamMemberDeleteResult: {
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
/** Success */
|
||||
success: boolean;
|
||||
/** User Email */
|
||||
user_email?: string | null;
|
||||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* TeamMemberInfoResponse
|
||||
* @description Response for GET /team/{team_id}/members/me — caller's own membership row.
|
||||
|
|
@ -37395,6 +37704,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: {
|
||||
/**
|
||||
|
|
@ -38140,6 +38459,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
|
||||
|
|
@ -39548,6 +39882,44 @@ export interface components {
|
|||
*/
|
||||
severity: "info" | "warning" | "error";
|
||||
};
|
||||
/**
|
||||
* UserCreateResult
|
||||
* @description Outcome for one row of `POST /management/v1/users/bulk`. `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;
|
||||
};
|
||||
/**
|
||||
* UserDeleteResult
|
||||
* @description Outcome for one requested user, in request order. `teams_removed` lists the teams the user left.
|
||||
*/
|
||||
UserDeleteResult: {
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
/** Success */
|
||||
success: boolean;
|
||||
/**
|
||||
* Teams Removed
|
||||
* @default []
|
||||
*/
|
||||
teams_removed: string[];
|
||||
/** User Email */
|
||||
user_email?: string | null;
|
||||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/**
|
||||
* UserHeaderMapping
|
||||
* @description Map an incoming HTTP header to a LiteLLM user role.
|
||||
|
|
@ -45774,7 +46146,7 @@ export interface operations {
|
|||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CredentialItem"];
|
||||
"application/json": components["schemas"]["UpdateCredentialItem"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
|
@ -51359,6 +51731,110 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
team_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkTeamMemberDeleteRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkTeamMemberDeleteResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
bulk_create_users_route_management_v1_users_bulk_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
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_delete_users_action_management_v1_users_bulk_delete_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description Who the caller is acting for; recorded on the audit log entries this call writes. */
|
||||
"litellm-changed-by"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkDeleteUserRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkDeleteUsersResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
aggregate_mcp_route_mcp_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue