fix(management-v1): keep the user PATCH within the lint budgets and regenerate the specs

The patch request and item models now annotate their collections read-only, and the
merge-patch resolvers hand back MappingProxyType rather than plain dicts, so the only
mutable dict left is the one the shared write path stamps into before Prisma takes it.
The resolver seam is a Protocol instead of a Callable alias.

Also regenerates _lazy_openapi_snapshot.json, which had drifted on the base branch, and
schema.d.ts, which the new route belongs in.
This commit is contained in:
ryan-crabbe-berri 2026-08-27 21:10:24 -07:00
parent 0ace6e7ed5
commit 72679076fd
5 changed files with 350 additions and 57 deletions

View file

@ -15038,6 +15038,17 @@
}
],
"title": "Upstream Resource"
},
"upstream_token_header": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Upstream Token Header"
}
},
"title": "MCPCredentials",
@ -17518,6 +17529,17 @@
}
],
"title": "Upstream Resource"
},
"upstream_token_header": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Upstream Token Header"
}
},
"title": "MCPCredentials",
@ -20352,6 +20374,17 @@
}
],
"title": "Upstream Resource"
},
"upstream_token_header": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Upstream Token Header"
}
},
"title": "MCPCredentials",
@ -23699,6 +23732,17 @@
}
],
"title": "Upstream Resource"
},
"upstream_token_header": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Upstream Token Header"
}
},
"title": "MCPCredentials",

View file

@ -15,9 +15,9 @@ These are members of a Team on LiteLLM
import asyncio
import json
import traceback
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import Awaitable, Mapping, Sequence
from datetime import datetime, timezone
from typing import Any, Final, Literal, TypeAlias, cast
from typing import Any, Final, Literal, Protocol, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@ -1181,13 +1181,20 @@ def _process_keys_for_user_info(
return returned_keys
UserUpdateFieldResolver: TypeAlias = Callable[
[dict, "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: dict, data: UpdateUserRequest | UpdateUserRequestNoUserIDorEmail) -> dict:
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
@ -1284,7 +1291,7 @@ def _check_user_update_authz(
# 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 set())
"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:
@ -1400,7 +1407,8 @@ async def update_single_user(
)
data_json: Final[dict] = user_request.model_dump(exclude_unset=True)
non_default_values = resolve_fields(data_json, 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

View file

@ -2,7 +2,7 @@
from collections.abc import Mapping, MutableMapping
from types import MappingProxyType
from typing import Final
from typing import Final, TypeAlias
from urllib.parse import urlencode
from fastapi import Request
@ -54,34 +54,41 @@ def problem_response(problem: ProblemDetail) -> JSONResponse:
)
def add_problem_detail_component(openapi_schema: MutableMapping[str, object]) -> MutableMapping[str, object]:
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", {})
components: Final = openapi_schema.setdefault("components", {}) # mutable-ok: seeds a branch of fastapi's own dict
if not isinstance(components, MutableMapping):
return openapi_schema
schemas: Final = components.setdefault("schemas", {})
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())
return openapi_schema
def problem_responses(*statuses: int) -> dict[int | str, dict[str, object]]:
# 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`. A plain `dict` because
that is the shape FastAPI's `responses=` parameter is annotated to take.
route's own media type and would document these as `application/json`.
"""
return {
status: {
"description": _PROBLEM_TITLES.get(status, "Error"),
"content": {PROBLEM_CONTENT_TYPE: {"schema": {"$ref": PROBLEM_DETAIL_REF}}},
}
for status in statuses
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
}

View file

@ -1,6 +1,6 @@
"""`PATCH /management/v1/users/{user_id}`."""
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, Literal
@ -49,6 +49,8 @@ _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.
@ -71,14 +73,14 @@ class UserPatchRequest(BaseModel):
]
| None
) = None
models: list[str] | 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: dict[str, JsonValue] | None = None
model_max_budget: dict[str, JsonValue] | None = None
metadata: Mapping[str, JsonValue] | None = None
model_max_budget: Mapping[str, JsonValue] | None = None
object_permission: LiteLLM_ObjectPermissionBase | None = None
@ -95,7 +97,7 @@ class UserItem(BaseModel):
user_email: str | None = None
user_alias: str | None = None
user_role: str | None = None
models: list[str] = Field(default_factory=list)
models: Sequence[str] = Field(default_factory=list)
spend: float = 0.0
max_budget: float | None = None
budget_duration: str | None = None
@ -103,18 +105,18 @@ class UserItem(BaseModel):
tpm_limit: int | None = None
rpm_limit: int | None = None
max_parallel_requests: int | None = None
metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_max_budget: dict[str, JsonValue] = Field(default_factory=dict)
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: list[str] = Field(default_factory=list)
teams: Sequence[str] = Field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
def resolve_user_patch_fields(
data_json: dict,
data_json: Mapping[str, object],
data: UpdateUserRequest | UpdateUserRequestNoUserIDorEmail,
) -> dict:
) -> 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.
@ -124,29 +126,36 @@ def resolve_user_patch_fields(
`object_permission` clear is left for the caller of this resolver, which drops the entitlement
link rather than writing a column.
"""
resolved: Final[dict] = {
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)
}
derived: Final = _budget_reset_fields(resolved["budget_duration"]) if "budget_duration" in resolved else {}
return {**resolved, **derived, **_internal_user_role_defaults(data, resolved)}
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) -> dict:
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 {"budget_reset_at": None}
return MappingProxyType({"budget_reset_at": None})
validate_budget_duration(budget_duration)
return {"budget_reset_at": get_budget_reset_time(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: dict,
) -> dict:
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
@ -154,19 +163,21 @@ def _internal_user_role_defaults(
never overwritten by a global default the caller was trying to get out from under.
"""
if data.user_role != LitellmUserRoles.INTERNAL_USER:
return {}
budget: Final = (
{"max_budget": litellm.max_internal_user_budget}
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 {}
else _NO_FIELDS
)
if "budget_duration" in resolved or litellm.internal_user_budget_duration is None:
return budget
return {
**budget,
"budget_duration": litellm.internal_user_budget_duration,
**_budget_reset_fields(litellm.internal_user_budget_duration),
}
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:
@ -200,7 +211,7 @@ def _problem_from_http_exception(exc: HTTPException) -> ManagementProblem:
@router.patch(
"/users/{user_id}",
tags=["Internal User management"],
tags=("Internal User management",),
dependencies=(Depends(user_api_key_auth),),
response_model=ItemResponse[UserItem],
responses=problem_responses(403, 404, 422, 500, 503),
@ -278,4 +289,4 @@ async def patch_user(
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})
return await table.find_first(where={"user_id": user_id}) # mutable-ok: prisma query filters are dict-shaped

View file

@ -8111,6 +8111,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;
@ -27151,6 +27187,10 @@ export interface components {
/** Is Accepted */
is_accepted: boolean;
};
/** ItemResponse[UserItem] */
ItemResponse_UserItem_: {
data: components["schemas"]["UserItem"];
};
/** JWTKeyMappingResponse */
JWTKeyMappingResponse: {
/**
@ -27178,6 +27218,7 @@ export interface components {
/** Updated By */
updated_by?: string | null;
};
JsonValue: unknown;
/** KeyHealthResponse */
KeyHealthResponse: {
/**
@ -30208,6 +30249,8 @@ export interface components {
token_exchange_profile?: string | null;
/** Upstream Resource */
upstream_resource?: string | null;
/** Upstream Token Header */
upstream_token_header?: string | null;
};
/**
* MCPEnvVar
@ -32960,6 +33003,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"];
@ -37540,6 +37602,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
@ -37556,6 +37670,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
@ -48588,6 +48739,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;