From 72679076fda7a92ea838248f846ff3d62ec54c5c Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 27 Aug 2026 21:10:24 -0700 Subject: [PATCH] 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. --- litellm/proxy/_lazy_openapi_snapshot.json | 44 ++++ .../internal_user_endpoints.py | 26 +- .../management_v1/common.py | 37 +-- .../management_v1/users.py | 77 +++--- ui/litellm-dashboard/src/lib/http/schema.d.ts | 223 ++++++++++++++++++ 5 files changed, 350 insertions(+), 57 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index e30750ef565..1963c7799a2 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index be8b55ee6cb..9e5fc6e7aff 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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 diff --git a/litellm/proxy/management_endpoints/management_v1/common.py b/litellm/proxy/management_endpoints/management_v1/common.py index cc8f351c129..723a5cfd83a 100644 --- a/litellm/proxy/management_endpoints/management_v1/common.py +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -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 } diff --git a/litellm/proxy/management_endpoints/management_v1/users.py b/litellm/proxy/management_endpoints/management_v1/users.py index 906a8b583ce..824742356c5 100644 --- a/litellm/proxy/management_endpoints/management_v1/users.py +++ b/litellm/proxy/management_endpoints/management_v1/users.py @@ -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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index c124cc2e9c8..be60135255e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;