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;