This commit is contained in:
ryan-crabbe-berri 2026-08-28 04:10:32 +00:00 committed by GitHub
commit 55e8f23f61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 930 additions and 44 deletions

View file

@ -17,7 +17,7 @@ import json
import traceback
from collections.abc import Awaitable, Mapping, Sequence
from datetime import datetime, timezone
from typing import Any, Final, Literal, cast
from typing import Any, Final, Literal, Protocol, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@ -1181,7 +1181,29 @@ def _process_keys_for_user_info(
return returned_keys
def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | UpdateUserRequestNoUserIDorEmail) -> dict:
class UserUpdateFieldResolver(Protocol):
"""Decides which fields a user-update request writes; see `update_single_user`."""
def __call__(
self,
data_json: Mapping[str, object],
data: "UpdateUserRequest | UpdateUserRequestNoUserIDorEmail",
) -> Mapping[str, object]: ...
def _update_internal_user_params(
data_json: Mapping[str, object],
data: UpdateUserRequest | UpdateUserRequestNoUserIDorEmail,
) -> dict: # mutable-ok: `/user/bulk_update` pops identity fields off this result before writing it
"""Legacy `/user/update` field resolution: a null (or `[]`/`{}`) means "not sent", so it is dropped.
Retained verbatim for backwards compatibility. It dates to when `data_json` came from
`data.json()`, which serialized every unset field at its non-None default (`models=[]`,
`metadata={}`, `spend=0`), so without this filter every update wiped them. `exclude_unset=True`
has done that job since #10993, leaving the filter to do nothing but swallow deliberate clears,
which is why `max_budget` needed a `fields_set` carve-out to become clearable at all. New
surfaces should use the merge-patch resolver in `management_v1/users.py` instead.
"""
non_default_values: Final = {}
fields_set: Final = data.fields_set() if hasattr(data, "fields_set") else set()
@ -1266,7 +1288,13 @@ def _check_user_update_authz(
existing_user_row: BaseModel | None,
) -> None:
"""Authorization checks for /user/update — raises HTTPException on failure."""
if user_request.user_role is not None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
# Presence, not truthiness: a merge-patch caller clearing their own role to null would otherwise
# slip past a `is not None` check and demote themselves out of whatever an admin assigned.
sends_role: Final = (
"user_role" in (user_request.fields_set() if hasattr(user_request, "fields_set") else frozenset())
or user_request.user_role is not None
)
if sends_role and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
raise HTTPException(status_code=403, detail="Only proxy admins can modify user roles.")
if existing_user_row is not None:
@ -1347,15 +1375,22 @@ async def _invalidate_cached_user_entitlement(user_id: str | None, object_permis
verbose_proxy_logger.warning("Failed to invalidate cached entitlement key %r: %s", key, e)
async def _update_single_user_helper(
async def update_single_user(
user_request: UpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None = None,
resolve_fields: UserUpdateFieldResolver = _update_internal_user_params,
) -> dict[str, Any]:
"""
Helper function to update a single user.
Used by both user_update and bulk_user_update endpoints.
Everything past field resolution is policy the two surfaces must share: authorization, the
self-escalation guard, metadata merging, entitlement upsert, audit logging and cache
invalidation. Only the question of *which* fields a request writes differs, so that is the one
step injected: `/user/update` keeps its legacy drop-nulls rule, while `/management/v1` passes a
merge-patch resolver where an explicit null clears.
Returns the updated user data or raises an exception on failure.
"""
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
@ -1372,7 +1407,8 @@ async def _update_single_user_helper(
)
data_json: Final[dict] = user_request.model_dump(exclude_unset=True)
non_default_values = _update_internal_user_params(data_json=data_json, data=user_request)
resolved_fields: Final = resolve_fields(data_json, user_request)
non_default_values = dict(resolved_fields) # mutable-ok: the write path stamps into this before prisma takes it
_hash_password_in_dict(non_default_values)
existing_user_row: BaseModel | None = None
@ -1588,7 +1624,7 @@ async def user_update(
try:
verbose_proxy_logger.debug("/user/update: Received data = %s", data)
response: Final = await _update_single_user_helper(
response: Final = await update_single_user(
user_request=data,
user_api_key_dict=user_api_key_dict,
)
@ -1626,7 +1662,7 @@ async def bulk_update_processed_users(
try:
for user_request in users_to_update:
try:
response = await _update_single_user_helper(
response = await update_single_user(
user_request=user_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,

View file

@ -10,9 +10,13 @@ from litellm.proxy.management_endpoints.management_v1.budgets import (
from litellm.proxy.management_endpoints.management_v1.spend_logs import (
router as spend_logs_router,
)
from litellm.proxy.management_endpoints.management_v1.users import (
router as users_router,
)
router: Final = APIRouter()
router.include_router(budgets_router)
router.include_router(spend_logs_router)
router.include_router(users_router)
__all__ = ["router"]

View file

@ -1,6 +1,8 @@
"""Contract machinery shared by every `/management/v1` route."""
from typing import Final
from collections.abc import Mapping, MutableMapping
from types import MappingProxyType
from typing import Final, TypeAlias
from urllib.parse import urlencode
from fastapi import Request
@ -20,6 +22,20 @@ PROBLEM_CONTENT_TYPE: Final = "application/problem+json"
# type, and an https URI promises documentation at that address. Switch to an
# https base only when pages actually exist to serve.
PROBLEM_TYPE_BASE: Final = "urn:litellm:error:"
PROBLEM_DETAIL_SCHEMA_NAME: Final = "ProblemDetail"
PROBLEM_DETAIL_REF: Final = f"#/components/schemas/{PROBLEM_DETAIL_SCHEMA_NAME}"
_PROBLEM_TITLES: Final[Mapping[int, str]] = MappingProxyType(
{
400: "Invalid query parameter",
403: "Forbidden",
404: "Not found",
409: "Conflict",
422: "Invalid request body",
500: "Internal server error",
503: "Database not connected",
}
)
class ManagementProblem(Exception):
@ -38,6 +54,54 @@ def problem_response(problem: ProblemDetail) -> JSONResponse:
)
def add_problem_detail_component(
openapi_schema: MutableMapping[str, object], # mutable-ok: fastapi's generated schema is a plain nested dict
) -> None:
"""Register `ProblemDetail` as an OpenAPI component so `problem_responses()` can `$ref` it.
FastAPI only emits components for models reachable from a route's `response_model`, and a problem
document never is one: it is the failure shape, declared out of band.
"""
components: Final = openapi_schema.setdefault("components", {}) # mutable-ok: seeds a branch of fastapi's own dict
if not isinstance(components, MutableMapping):
return
schemas: Final = components.setdefault("schemas", {}) # mutable-ok: seeds a branch of fastapi's own dict
if isinstance(schemas, MutableMapping):
schemas.setdefault(PROBLEM_DETAIL_SCHEMA_NAME, ProblemDetail.model_json_schema())
# FastAPI declares `responses=` as a dict of dicts and rewrites copies of the entries as it renders
# the schema, so every layer below has to be a plain mutable dict.
ProblemResponses: TypeAlias = dict[int | str, dict[str, object]] # mutable-ok: fastapi's `responses=` contract
def problem_responses(*statuses: int) -> ProblemResponses:
"""OpenAPI `responses=` entries declaring each status as an RFC 9457 problem document.
Spelled as a raw `$ref` rather than `model=`, because FastAPI renders a `model=` entry under the
route's own media type and would document these as `application/json`.
"""
return {status: _problem_entry(status) for status in statuses} # mutable-ok: fastapi's `responses=` contract
def _problem_entry(status: int) -> dict[str, object]: # mutable-ok: fastapi's `responses=` contract
media_type: Final = {"schema": {"$ref": PROBLEM_DETAIL_REF}} # mutable-ok: fastapi's `responses=` contract
return { # mutable-ok: fastapi's `responses=` contract
"description": _PROBLEM_TITLES.get(status, "Error"),
"content": {PROBLEM_CONTENT_TYPE: media_type}, # mutable-ok: fastapi's `responses=` contract
}
def validation_problem(detail: str) -> ProblemDetail:
"""A rejected request body, as opposed to `unknown_query_param_problem` for the query string."""
return ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}invalid-request-body",
title=_PROBLEM_TITLES[422],
status=422,
detail=detail,
)
def _declared_query_params(request: Request) -> frozenset[str]:
route: Final = request.scope.get("route")
dependant: Final = getattr(route, "dependant", None)

View file

@ -0,0 +1,292 @@
"""`PATCH /management/v1/users/{user_id}`."""
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, Literal
from fastapi import APIRouter, Depends, HTTPException, Path
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import (
CommonProxyErrors,
LiteLLM_ObjectPermissionBase,
LitellmUserRoles,
UpdateUserRequest,
UpdateUserRequestNoUserIDorEmail,
UserAPIKeyAuth,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import validate_budget_duration
from litellm.proxy.management_endpoints.management_v1.common import (
MANAGEMENT_V1_PREFIX,
PROBLEM_TYPE_BASE,
ManagementProblem,
problem_responses,
)
from litellm.proxy.utils import PrismaClient
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.user_repository import UserRepository
from litellm.types.proxy.management_endpoints.management_v1 import (
ItemResponse,
ProblemDetail,
)
if TYPE_CHECKING:
from prisma import models as prisma_models
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
# Starlette leaves `HTTPException.status_code` untyped; validate rather than widen the call site.
_HTTP_STATUS: Final = TypeAdapter(int)
# Columns the schema declares NOT NULL with a default. A merge-patch null on one of these means
# "back to empty", which is the default, not SQL NULL; writing NULL would be rejected by the engine.
# Held as factories so each call gets its own empty container to hand to the query engine.
_CLEARS_TO_EMPTY: Final[Mapping[str, Callable[[], object]]] = MappingProxyType(
{"models": list, "metadata": dict, "model_max_budget": dict}
)
_NO_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})
class UserPatchRequest(BaseModel):
"""Body of `PATCH /management/v1/users/{user_id}`, read as an RFC 7396 JSON merge patch.
Every field is optional and nullable, and the two are not the same thing: an omitted field is
left alone, an explicit `null` clears the setting. `extra="forbid"` is what makes that promise
keepable, since a misspelled key would otherwise read as "omitted" and silently do nothing.
"""
model_config = ConfigDict(extra="forbid")
user_email: str | None = None
user_alias: str | None = None
user_role: (
Literal[
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
]
| None
) = None
models: Sequence[str] | None = None
max_budget: float | None = None
budget_duration: str | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
max_parallel_requests: int | None = None
metadata: Mapping[str, JsonValue] | None = None
model_max_budget: Mapping[str, JsonValue] | None = None
object_permission: LiteLLM_ObjectPermissionBase | None = None
class UserItem(BaseModel):
"""One internal user as the control plane returns it, read back off the row the write produced.
Re-reading rather than echoing the request is the point of the endpoint: a caller can tell a
clear that landed from one that was dropped by looking at the response.
"""
model_config = ConfigDict(from_attributes=True)
user_id: str
user_email: str | None = None
user_alias: str | None = None
user_role: str | None = None
models: Sequence[str] = Field(default_factory=list)
spend: float = 0.0
max_budget: float | None = None
budget_duration: str | None = None
budget_reset_at: datetime | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
max_parallel_requests: int | None = None
metadata: Mapping[str, JsonValue] = Field(default_factory=dict)
model_max_budget: Mapping[str, JsonValue] = Field(default_factory=dict)
object_permission_id: str | None = None
teams: Sequence[str] = Field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
def resolve_user_patch_fields(
data_json: Mapping[str, object],
data: UpdateUserRequest | UpdateUserRequestNoUserIDorEmail,
) -> Mapping[str, object]:
"""Merge-patch field resolution: whatever the caller sent is written, nulls included.
The inverse of `_update_internal_user_params`, which drops nulls and so cannot express a clear.
Only three adjustments are made to the raw body, and each is a property of the schema rather
than a policy choice: NOT NULL columns clear to their default instead of SQL NULL,
`budget_reset_at` follows `budget_duration` because it is derived from it, and an
`object_permission` clear is left for the caller of this resolver, which drops the entitlement
link rather than writing a column.
"""
resolved: Final[Mapping[str, object]] = MappingProxyType(
{
key: (_CLEARS_TO_EMPTY[key]() if value is None and key in _CLEARS_TO_EMPTY else value)
for key, value in data_json.items()
if not (key == "object_permission" and value is None)
}
)
budget_duration: Final = resolved.get("budget_duration")
derived: Final = (
_budget_reset_fields(budget_duration if isinstance(budget_duration, str) else None)
if "budget_duration" in resolved
else _NO_FIELDS
)
return MappingProxyType({**resolved, **derived, **_internal_user_role_defaults(data, resolved)})
def _budget_reset_fields(budget_duration: str | None) -> Mapping[str, object]:
"""`budget_reset_at` is derived from `budget_duration`, so the two only ever move together."""
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
if budget_duration is None:
return MappingProxyType({"budget_reset_at": None})
validate_budget_duration(budget_duration)
return MappingProxyType({"budget_reset_at": get_budget_reset_time(budget_duration=budget_duration)})
def _internal_user_role_defaults(
data: UpdateUserRequest | UpdateUserRequestNoUserIDorEmail,
resolved: Mapping[str, object],
) -> Mapping[str, object]:
"""Apply the proxy-wide internal-user budget caps, but only to fields the caller left alone.
Mirrors `/user/update`, so promoting someone to internal user still lands them under
`max_internal_user_budget`. Guarded on absence rather than falsiness, so an explicit clear is
never overwritten by a global default the caller was trying to get out from under.
"""
if data.user_role != LitellmUserRoles.INTERNAL_USER:
return _NO_FIELDS
budget: Final[Mapping[str, object]] = (
MappingProxyType({"max_budget": litellm.max_internal_user_budget})
if "max_budget" not in resolved and litellm.max_internal_user_budget is not None
else _NO_FIELDS
)
if "budget_duration" in resolved or litellm.internal_user_budget_duration is None:
return budget
return MappingProxyType(
{
**budget,
"budget_duration": litellm.internal_user_budget_duration,
**_budget_reset_fields(litellm.internal_user_budget_duration),
}
)
def _problem(status: int, slug: str, title: str, detail: str) -> ManagementProblem:
return ManagementProblem(
ProblemDetail(type=f"{PROBLEM_TYPE_BASE}{slug}", title=title, status=status, detail=detail)
)
_PROBLEM_BY_STATUS: Final[Mapping[int, tuple[str, str]]] = MappingProxyType(
{403: ("forbidden", "Forbidden"), 404: ("user-not-found", "Not found")}
)
def _problem_from_http_exception(exc: HTTPException) -> ManagementProblem:
"""Re-dress the shared write path's `HTTPException` as a problem document.
`update_single_user` is shared with `/user/update` and raises that endpoint's error
shape. Translating here keeps the control plane's contract without forking the authorization
checks, which is the one thing that must not drift between the two surfaces.
"""
status: Final[int] = _HTTP_STATUS.validate_python(exc.status_code)
detail: Final[object] = exc.detail
slug, title = _PROBLEM_BY_STATUS.get(status, ("user-update-failed", "User update failed"))
return _problem(
status=status,
slug=slug,
title=title,
detail=str(detail.get("error", detail)) if isinstance(detail, Mapping) else str(detail),
)
@router.patch(
"/users/{user_id}",
tags=("Internal User management",),
dependencies=(Depends(user_api_key_auth),),
response_model=ItemResponse[UserItem],
responses=problem_responses(403, 404, 422, 500, 503),
)
async def patch_user(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
body: UserPatchRequest,
user_id: Annotated[str, Path(description="The id of the user to update.")],
) -> ItemResponse[UserItem]:
"""
Partially update one internal user, as an RFC 7396 JSON merge patch.
An omitted field is left alone and an explicit `null` clears the setting, which is the whole
reason this route exists: `POST /user/update` drops nulls, so it answers `200` to a clear it
silently discarded, and only `max_budget` was ever made clearable. Unknown body keys are
refused with a `422` rather than ignored. `null` on `models`, `metadata` or `model_max_budget`
resets the column to empty, since the schema declares those NOT NULL.
Requires a proxy admin: the route is in no non-admin allowlist, so everyone else is refused at
the route gate, and the shared write path's self-service guards stand behind that as defense in
depth. Unlike `/user/update`, a user id that does not exist is a `404` rather than a silent
create, since the underlying write is an upsert.
Example curl, clearing a rate limit and setting another:
```
curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/users/user123' \
--header 'Authorization: Bearer sk-1234' \
--header 'Content-Type: application/json' \
--data '{"tpm_limit": null, "rpm_limit": 60}'
```
"""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
update_single_user,
)
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise _problem(
503,
"database-not-connected",
"Database not connected",
CommonProxyErrors.db_not_connected_error.value,
)
# `update_data` upserts, so a missing row would otherwise be created by an admin's PATCH.
if await _find_user(prisma_client, user_id) is None:
raise _problem(404, "user-not-found", "Not found", f"No user with id {user_id!r} exists.")
try:
await update_single_user(
user_request=UpdateUserRequest(user_id=user_id, **body.model_dump(exclude_unset=True)),
user_api_key_dict=user_api_key_dict,
resolve_fields=resolve_user_patch_fields,
)
except ManagementProblem:
raise
except HTTPException as e:
raise _problem_from_http_exception(e) from e
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.patch_user(): Exception occured - %s", e
)
raise _problem(500, "internal-server-error", "Internal server error", str(e)) from e
updated: Final = await _find_user(prisma_client, user_id)
if updated is None:
raise _problem(
500,
"internal-server-error",
"Internal server error",
"The updated user could not be read back.",
)
return ItemResponse[UserItem](data=UserItem.model_validate(updated))
async def _find_user(prisma_client: PrismaClient, user_id: str) -> "prisma_models.LiteLLM_UserTable | None":
table: Final[TableActions[prisma_models.LiteLLM_UserTable]] = UserRepository(prisma_client).table
return await table.find_first(where={"user_id": user_id}) # mutable-ok: prisma query filters are dict-shaped

View file

@ -492,7 +492,9 @@ from litellm.proxy.management_endpoints.management_v1.common import (
MANAGEMENT_V1_PREFIX,
PROBLEM_TYPE_BASE,
ManagementProblem,
add_problem_detail_component,
problem_response,
validation_problem,
)
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
router as model_access_group_management_router,
@ -1519,6 +1521,9 @@ def get_openapi_schema():
openapi_schema = inject_lazy_stubs(openapi_schema, loaded_lazy_modules(app))
openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema)
# `/management/v1` problem responses `$ref` this; nothing reachable from a response_model does.
add_problem_detail_component(openapi_schema)
# Fix Swagger UI execute path error when server_root_path is set
if server_root_path:
openapi_schema["servers"] = [{"url": "/" + server_root_path.strip("/")}]
@ -1702,17 +1707,22 @@ class _ValidationErrorDetail(TypedDict):
@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()
# A rejected body is a 422 and a rejected query string a 400; conflating them would report an
# unknown body key (which `extra="forbid"` mutation models refuse) as a query-parameter fault.
from_body: Final = any(error["loc"][:1] == ("body",) for error in validation_errors)
summary: Final = "; ".join(
f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors
)
_close_dangling_otel_server_span(request, 422 if from_body else 400, exc=exc)
return problem_response(
ProblemDetail(
validation_problem(summary or "The request body is invalid.")
if from_body
else 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.",
detail=summary or "The request query parameters are invalid.",
)
)
_close_dangling_otel_server_span(request, 422, exc=exc)

View file

@ -71,3 +71,9 @@ class ListResponse(BaseModel, Generic[TOut]):
data: list[TOut]
meta: ListMeta
links: ListLinks
class ItemResponse(BaseModel, Generic[TOut]):
"""Single-entity envelope. Shares `ListResponse`'s `data` key so a client unwraps both the same way."""
data: TOut

View file

@ -0,0 +1,253 @@
"""`PATCH /management/v1/users/{user_id}`.
The point of the endpoint is that an explicit `null` clears a setting, so most of what follows is
about telling "the caller sent null" apart from "the caller sent nothing" all the way down to the
values handed to the query engine.
"""
from datetime import datetime, timezone
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.testclient import TestClient
from pydantic import ValidationError
import litellm
from litellm.proxy._types import (
LitellmUserRoles,
UpdateUserRequest,
UserAPIKeyAuth,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_internal_user_params,
)
from litellm.proxy.management_endpoints.management_v1 import router
from litellm.proxy.management_endpoints.management_v1.common import (
MANAGEMENT_V1_PREFIX,
ManagementProblem,
problem_response,
validation_problem,
)
from litellm.proxy.management_endpoints.management_v1.users import (
UserPatchRequest,
resolve_user_patch_fields,
)
app = FastAPI()
@app.exception_handler(ManagementProblem)
async def _management_problem_handler(request: Request, exc: ManagementProblem):
return problem_response(exc.problem)
@app.exception_handler(RequestValidationError)
async def _validation_handler(request: Request, exc: RequestValidationError):
from_body = any(error["loc"][:1] == ("body",) for error in exc.errors())
if not from_body:
raise exc
return problem_response(validation_problem("; ".join(error["msg"] for error in exc.errors())))
app.include_router(router)
ADMIN = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN.value)
TARGET_ID = "user-1"
PATCH_PATH = f"{MANAGEMENT_V1_PREFIX}/users/{TARGET_ID}"
def _resolve(**body: Any) -> dict[str, Any]:
"""Run a patch body through the endpoint's field resolution, as the write path would."""
request = UpdateUserRequest(
user_id=TARGET_ID, **UserPatchRequest.model_validate(body).model_dump(exclude_unset=True)
)
return resolve_user_patch_fields(request.model_dump(exclude_unset=True), request)
def _user_row(**overrides: Any) -> MagicMock:
row = MagicMock()
row.model_dump.return_value = {
"user_id": TARGET_ID,
"user_email": "u1@example.com",
"user_alias": None,
"user_role": LitellmUserRoles.INTERNAL_USER.value,
"models": [],
"spend": 0.0,
"max_budget": None,
"budget_duration": None,
"budget_reset_at": None,
"tpm_limit": 500,
"rpm_limit": None,
"max_parallel_requests": None,
"metadata": {},
"model_max_budget": {},
"object_permission_id": None,
"teams": [],
"created_at": datetime(2026, 8, 1, tzinfo=timezone.utc),
"updated_at": datetime(2026, 8, 2, tzinfo=timezone.utc),
**overrides,
}
for key, value in row.model_dump.return_value.items():
setattr(row, key, value)
return row
@pytest.fixture
def caller():
"""Whoever the route should authenticate as. Rebind `.value` inside a test to change it."""
holder = MagicMock()
holder.value = ADMIN
app.dependency_overrides[user_api_key_auth] = lambda: holder.value
yield holder
app.dependency_overrides.clear()
@pytest.fixture
def prisma(mocker):
"""A prisma double whose `update_data` call is what the assertions inspect."""
client = MagicMock()
client.update_data = AsyncMock(return_value={"user_id": TARGET_ID, "data": _user_row()})
client.get_data = AsyncMock(return_value=[])
mocker.patch("litellm.proxy.proxy_server.prisma_client", client)
mocker.patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id")
mocker.patch("litellm.proxy.proxy_server._invalidate_spend_counter", AsyncMock())
table = MagicMock()
table.find_first = AsyncMock(return_value=_user_row())
mocker.patch(
"litellm.repositories.user_repository.UserRepository.table",
new_callable=mocker.PropertyMock,
return_value=table,
)
client.table = table
return client
def _written(prisma) -> dict[str, Any]:
assert prisma.update_data.await_count == 1, "expected exactly one user write"
return prisma.update_data.await_args.kwargs["data"]
# --- field resolution: null clears, absent is left alone -----------------------------------------
def test_explicit_null_clears_a_nullable_column():
"""The bug this endpoint exists for: `/user/update` drops this null and answers 200 unchanged."""
assert _resolve(tpm_limit=None)["tpm_limit"] is None
def test_omitted_field_is_not_written():
resolved = _resolve(rpm_limit=60)
assert resolved["rpm_limit"] == 60
assert "tpm_limit" not in resolved
assert "max_budget" not in resolved
@pytest.mark.parametrize(
("field", "empty"),
[("models", []), ("metadata", {}), ("model_max_budget", {})],
)
def test_null_on_a_not_null_column_clears_to_empty(field, empty):
"""These columns are NOT NULL in the schema, so the clear is the default, not SQL NULL."""
assert _resolve(**{field: None})[field] == empty
def test_null_budget_duration_also_clears_the_reset_time():
resolved = _resolve(budget_duration=None)
assert resolved["budget_duration"] is None
assert resolved["budget_reset_at"] is None
def test_setting_budget_duration_derives_a_reset_time():
resolved = _resolve(budget_duration="30d")
assert resolved["budget_duration"] == "30d"
assert isinstance(resolved["budget_reset_at"], datetime)
def test_object_permission_null_is_left_for_the_entitlement_unlink():
"""Passing it through would make the shared path's upsert branch swallow the clear."""
assert "object_permission" not in _resolve(object_permission=None)
def test_internal_user_default_budget_does_not_override_an_explicit_clear(monkeypatch):
monkeypatch.setattr(litellm, "max_internal_user_budget", 25.0)
assert _resolve(user_role=LitellmUserRoles.INTERNAL_USER, max_budget=None)["max_budget"] is None
def test_internal_user_default_budget_still_applies_when_unmentioned(monkeypatch):
monkeypatch.setattr(litellm, "max_internal_user_budget", 25.0)
assert _resolve(user_role=LitellmUserRoles.INTERNAL_USER)["max_budget"] == 25.0
def test_legacy_user_update_still_drops_nulls():
"""The old contract is unchanged: callers relying on null-as-no-op are not broken by this PR."""
request = UpdateUserRequest(user_id=TARGET_ID, tpm_limit=None, rpm_limit=60)
resolved = _update_internal_user_params(request.model_dump(exclude_unset=True), request)
assert "tpm_limit" not in resolved
assert resolved["rpm_limit"] == 60
# --- request validation --------------------------------------------------------------------------
def test_unknown_body_key_is_rejected():
"""Ignoring it would read as "omitted", i.e. a typo would silently do nothing."""
with pytest.raises(ValidationError):
UserPatchRequest.model_validate({"tpm_limitt": 5})
def test_unknown_body_key_answers_422_problem(caller, prisma):
response = TestClient(app).patch(PATCH_PATH, json={"tpm_limitt": 5})
assert response.status_code == 422
assert response.json()["type"].endswith("invalid-request-body")
assert prisma.update_data.await_count == 0
# --- route behaviour -----------------------------------------------------------------------------
def test_patch_writes_the_null_through_to_the_database(caller, prisma):
response = TestClient(app).patch(PATCH_PATH, json={"tpm_limit": None, "rpm_limit": 60})
assert response.status_code == 200
written = _written(prisma)
assert written["tpm_limit"] is None
assert written["rpm_limit"] == 60
def test_patch_returns_the_row_read_back_after_the_write(caller, prisma):
body = TestClient(app).patch(PATCH_PATH, json={"rpm_limit": 60}).json()
assert body["data"]["user_id"] == TARGET_ID
assert body["data"]["tpm_limit"] == 500
def test_missing_user_is_404_and_writes_nothing(caller, prisma):
prisma.table.find_first = AsyncMock(return_value=None)
response = TestClient(app).patch(PATCH_PATH, json={"rpm_limit": 60})
assert response.status_code == 404
assert response.json()["type"].endswith("user-not-found")
# `update_data` upserts, so a create here would be a silent user creation.
assert prisma.update_data.await_count == 0
def test_non_admin_cannot_clear_their_own_role(caller, prisma):
caller.value = UserAPIKeyAuth(user_id=TARGET_ID, user_role=LitellmUserRoles.INTERNAL_USER.value)
response = TestClient(app).patch(PATCH_PATH, json={"user_role": None})
assert response.status_code == 403
assert prisma.update_data.await_count == 0
def test_non_admin_cannot_clear_their_own_budget(caller, prisma):
caller.value = UserAPIKeyAuth(user_id=TARGET_ID, user_role=LitellmUserRoles.INTERNAL_USER.value)
response = TestClient(app).patch(PATCH_PATH, json={"max_budget": None})
assert response.status_code == 403
assert prisma.update_data.await_count == 0
def test_non_admin_cannot_patch_someone_else(caller, prisma):
caller.value = UserAPIKeyAuth(user_id="other", user_role=LitellmUserRoles.INTERNAL_USER.value)
response = TestClient(app).patch(PATCH_PATH, json={"rpm_limit": 60})
assert response.status_code == 403
assert prisma.update_data.await_count == 0

View file

@ -1208,14 +1208,14 @@ async def test_new_user_admin_can_set_permissions(mocker):
@pytest.mark.asyncio
async def test_update_single_user_non_admin_permissions_rejected(mocker):
"""`_update_single_user_helper` rejects a non-admin when `permissions`
"""`update_single_user` rejects a non-admin when `permissions`
is present in the request body. Covers both `/user/update` and
`/user/bulk_update`, which share this helper."""
from fastapi import HTTPException
from litellm.proxy._types import UpdateUserRequest
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -1231,7 +1231,7 @@ async def test_update_single_user_non_admin_permissions_rejected(mocker):
)
with pytest.raises(HTTPException) as exc_info:
await _update_single_user_helper(
await update_single_user(
user_request=data, user_api_key_dict=caller
)
assert exc_info.value.status_code == 403
@ -1240,13 +1240,13 @@ async def test_update_single_user_non_admin_permissions_rejected(mocker):
@pytest.mark.asyncio
async def test_update_single_user_non_admin_permissions_explicit_empty_rejected(mocker):
"""`_update_single_user_helper` rejects a non-admin when `permissions`
"""`update_single_user` rejects a non-admin when `permissions`
is present as `{}` in the request body."""
from fastapi import HTTPException
from litellm.proxy._types import UpdateUserRequest
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -1260,7 +1260,7 @@ async def test_update_single_user_non_admin_permissions_explicit_empty_rejected(
)
with pytest.raises(HTTPException) as exc_info:
await _update_single_user_helper(
await update_single_user(
user_request=data, user_api_key_dict=caller
)
assert exc_info.value.status_code == 403
@ -2557,7 +2557,7 @@ async def test_user_update_rejects_silent_create_for_non_proxy_admin(mocker):
from litellm.proxy._types import UpdateUserRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -2578,7 +2578,7 @@ async def test_user_update_rejects_silent_create_for_non_proxy_admin(mocker):
)
with pytest.raises(HTTPException) as exc:
await _update_single_user_helper(
await update_single_user(
user_request=user_request, user_api_key_dict=org_admin
)
assert exc.value.status_code == 404
@ -3436,7 +3436,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_max_budget(mocker):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -3461,7 +3461,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_max_budget(mocker):
)
with pytest.raises(HTTPException) as exc:
await _update_single_user_helper(
await update_single_user(
user_request=user_request, user_api_key_dict=caller
)
assert exc.value.status_code == 403
@ -3474,7 +3474,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_spend(mocker):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -3499,7 +3499,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_spend(mocker):
)
with pytest.raises(HTTPException) as exc:
await _update_single_user_helper(
await update_single_user(
user_request=user_request, user_api_key_dict=caller
)
assert exc.value.status_code == 403
@ -3510,7 +3510,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_spend(mocker):
async def test_ghsa_wvg4_proxy_admin_can_update_user_budget(mocker):
"""PROXY_ADMIN must still be able to modify another user's budget."""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -3539,7 +3539,7 @@ async def test_ghsa_wvg4_proxy_admin_can_update_user_budget(mocker):
user_role=LitellmUserRoles.PROXY_ADMIN,
)
result = await _update_single_user_helper(
result = await update_single_user(
user_request=user_request, user_api_key_dict=admin_caller
)
assert result is not None
@ -3550,7 +3550,7 @@ async def test_admin_user_update_spend_invalidates_counter(mocker):
"""A direct /user/update spend change must invalidate the cross-pod
spend counter so enforcement re-reads the new DB value."""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -3581,7 +3581,7 @@ async def test_admin_user_update_spend_invalidates_counter(mocker):
user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
)
await _update_single_user_helper(
await update_single_user(
user_request=user_request, user_api_key_dict=admin_caller
)
mock_invalidate.assert_awaited_once_with(counter_key="spend:user:target-user")
@ -3593,7 +3593,7 @@ async def test_user_update_rejects_non_finite_spend(mocker):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = mocker.MagicMock()
@ -3617,7 +3617,7 @@ async def test_user_update_rejects_non_finite_spend(mocker):
)
with pytest.raises(HTTPException) as exc:
await _update_single_user_helper(
await update_single_user(
user_request=user_request, user_api_key_dict=admin_caller
)
assert exc.value.status_code == 400
@ -3871,7 +3871,7 @@ async def test_user_update_persists_mcp_entitlement_and_links_it(mocker):
would not even be a column.
"""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = _object_permission_mocks(mocker)
@ -3879,7 +3879,7 @@ async def test_user_update_persists_mcp_entitlement_and_links_it(mocker):
cache.async_delete_cache = mocker.AsyncMock()
mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
await _update_single_user_helper(
await update_single_user(
user_request=UpdateUserRequest(
user_id="target-user",
object_permission={
@ -3910,7 +3910,7 @@ async def test_user_update_invalidates_the_cached_entitlement(mocker):
(which carries a "no entitlement" sentinel), and the cached user row.
"""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
_object_permission_mocks(mocker)
@ -3918,7 +3918,7 @@ async def test_user_update_invalidates_the_cached_entitlement(mocker):
cache.async_delete_cache = mocker.AsyncMock()
mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
await _update_single_user_helper(
await update_single_user(
user_request=UpdateUserRequest(
user_id="target-user",
object_permission={"mcp_tool_permissions": {"github": []}},
@ -3948,7 +3948,7 @@ async def test_admin_can_clear_a_users_mcp_entitlement(mocker):
the gateway would keep enforcing the cleared grants until the cache expired.
"""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = _object_permission_mocks(mocker, "perm-existing")
@ -3956,7 +3956,7 @@ async def test_admin_can_clear_a_users_mcp_entitlement(mocker):
cache.async_delete_cache = mocker.AsyncMock()
mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
await _update_single_user_helper(
await update_single_user(
user_request=UpdateUserRequest(user_id="target-user", object_permission={}),
user_api_key_dict=UserAPIKeyAuth(
user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
@ -3984,7 +3984,7 @@ async def test_user_update_invalidates_both_the_old_and_new_permission_rows(mock
grants, so anything still resolving that id keeps reading them.
"""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
_object_permission_mocks(mocker, "perm-existing")
@ -3992,7 +3992,7 @@ async def test_user_update_invalidates_both_the_old_and_new_permission_rows(mock
cache.async_delete_cache = mocker.AsyncMock()
mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
await _update_single_user_helper(
await update_single_user(
user_request=UpdateUserRequest(
user_id="target-user",
object_permission={"mcp_tool_permissions": {"github": ["list_issues"]}},
@ -4019,7 +4019,7 @@ async def test_non_admin_cannot_clear_their_own_mcp_entitlement(mocker):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = _object_permission_mocks(mocker, "perm-existing")
@ -4028,7 +4028,7 @@ async def test_non_admin_cannot_clear_their_own_mcp_entitlement(mocker):
mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
with pytest.raises(HTTPException) as exc:
await _update_single_user_helper(
await update_single_user(
user_request=UpdateUserRequest(user_id="target-user", object_permission={}),
user_api_key_dict=UserAPIKeyAuth(
user_id="target-user", user_role=LitellmUserRoles.INTERNAL_USER
@ -4046,7 +4046,7 @@ async def test_non_admin_cannot_rewrite_their_own_mcp_entitlement(mocker):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
update_single_user,
)
mock_prisma_client = _object_permission_mocks(mocker, "perm-existing")
@ -4055,7 +4055,7 @@ async def test_non_admin_cannot_rewrite_their_own_mcp_entitlement(mocker):
mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
with pytest.raises(HTTPException) as exc:
await _update_single_user_helper(
await update_single_user(
user_request=UpdateUserRequest(
user_id="target-user",
object_permission={"mcp_servers": [], "mcp_tool_permissions": {}},

View file

@ -8122,6 +8122,42 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/users/{user_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Patch User
* @description Partially update one internal user, as an RFC 7396 JSON merge patch.
*
* An omitted field is left alone and an explicit `null` clears the setting, which is the whole
* reason this route exists: `POST /user/update` drops nulls, so it answers `200` to a clear it
* silently discarded, and only `max_budget` was ever made clearable. Unknown body keys are
* refused with a `422` rather than ignored. `null` on `models`, `metadata` or `model_max_budget`
* resets the column to empty, since the schema declares those NOT NULL.
*
* Requires a proxy admin: the route is in no non-admin allowlist, so everyone else is refused at
* the route gate, and the shared write path's self-service guards stand behind that as defense in
* depth. Unlike `/user/update`, a user id that does not exist is a `404` rather than a silent
* create, since the underlying write is an upsert.
*
* Example curl, clearing a rate limit and setting another:
* ```
* curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/users/user123' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"tpm_limit": null, "rpm_limit": 60}'
* ```
*/
patch: operations["patch_user_management_v1_users__user_id__patch"];
trace?: never;
};
"/mcp": {
parameters: {
query?: never;
@ -27206,6 +27242,10 @@ export interface components {
/** Is Accepted */
is_accepted: boolean;
};
/** ItemResponse[UserItem] */
ItemResponse_UserItem_: {
data: components["schemas"]["UserItem"];
};
/** JWTKeyMappingResponse */
JWTKeyMappingResponse: {
/**
@ -27233,6 +27273,7 @@ export interface components {
/** Updated By */
updated_by?: string | null;
};
JsonValue: unknown;
/** KeyHealthResponse */
KeyHealthResponse: {
/**
@ -33027,6 +33068,25 @@ export interface components {
*/
version_status: string;
};
/**
* ProblemDetail
* @description RFC 9457 problem details, served as `application/problem+json`.
*/
ProblemDetail: {
/**
* Allowed
* @default null
*/
allowed: string[] | null;
/** Detail */
detail: string;
/** Status */
status: number;
/** Title */
title: string;
/** Type */
type: string;
};
/** Prompt */
Prompt: {
litellm_params: components["schemas"]["PromptLiteLLMParams"];
@ -37607,6 +37667,58 @@ export interface components {
/** User Role */
user_role?: string | null;
};
/**
* UserItem
* @description One internal user as the control plane returns it, read back off the row the write produced.
*
* Re-reading rather than echoing the request is the point of the endpoint: a caller can tell a
* clear that landed from one that was dropped by looking at the response.
*/
UserItem: {
/** Budget Duration */
budget_duration?: string | null;
/** Budget Reset At */
budget_reset_at?: string | null;
/** Created At */
created_at?: string | null;
/** Max Budget */
max_budget?: number | null;
/** Max Parallel Requests */
max_parallel_requests?: number | null;
/** Metadata */
metadata?: {
[key: string]: components["schemas"]["JsonValue"];
};
/** Model Max Budget */
model_max_budget?: {
[key: string]: components["schemas"]["JsonValue"];
};
/** Models */
models?: string[];
/** Object Permission Id */
object_permission_id?: string | null;
/** Rpm Limit */
rpm_limit?: number | null;
/**
* Spend
* @default 0
*/
spend: number;
/** Teams */
teams?: string[];
/** Tpm Limit */
tpm_limit?: number | null;
/** Updated At */
updated_at?: string | null;
/** User Alias */
user_alias?: string | null;
/** User Email */
user_email?: string | null;
/** User Id */
user_id: string;
/** User Role */
user_role?: string | null;
};
/**
* UserListResponse
* @description Response model for the user list endpoint
@ -37623,6 +37735,43 @@ export interface components {
/** Users */
users: components["schemas"]["LiteLLM_UserTableWithKeyCount"][];
};
/**
* UserPatchRequest
* @description Body of `PATCH /management/v1/users/{user_id}`, read as an RFC 7396 JSON merge patch.
*
* Every field is optional and nullable, and the two are not the same thing: an omitted field is
* left alone, an explicit `null` clears the setting. `extra="forbid"` is what makes that promise
* keepable, since a misspelled key would otherwise read as "omitted" and silently do nothing.
*/
UserPatchRequest: {
/** Budget Duration */
budget_duration?: string | null;
/** Max Budget */
max_budget?: number | null;
/** Max Parallel Requests */
max_parallel_requests?: number | null;
/** Metadata */
metadata?: {
[key: string]: components["schemas"]["JsonValue"];
} | null;
/** Model Max Budget */
model_max_budget?: {
[key: string]: components["schemas"]["JsonValue"];
} | null;
/** Models */
models?: string[] | null;
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
/** Rpm Limit */
rpm_limit?: number | null;
/** Tpm Limit */
tpm_limit?: number | null;
/** User Alias */
user_alias?: string | null;
/** User Email */
user_email?: string | null;
/** User Role */
user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null;
};
/**
* UserUpdateResult
* @description Result of a single user update operation
@ -48655,6 +48804,78 @@ export interface operations {
};
};
};
patch_user_management_v1_users__user_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id of the user to update. */
user_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UserPatchRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ItemResponse_UserItem_"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Invalid request body */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Database not connected */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
};
};
aggregate_mcp_route_mcp_get: {
parameters: {
query?: never;