diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index be7b7769cc1..22cc961a7d2 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1243,27 +1243,6 @@ class UpdateKeyRequest(KeyRequestBase): return self -class PatchKeyRequest(UpdateKeyRequest): - """ - Body of PATCH /key/{key}. - - Differs from UpdateKeyRequest in two ways. `key` is optional, because PATCH takes it - from the path; a `key` in the body is still accepted when it matches. Unknown fields - are rejected rather than ignored, because on a merge patch the set of fields present - *is* the request, so a misspelled field has to fail loudly instead of silently - no-op'ing. - """ - - model_config = ConfigDict(extra="forbid") - - key: str | None = None - - @model_validator(mode="after") - def validate_key_identifier(self) -> "PatchKeyRequest": - """The path supplies the identifier, so the body needs neither key nor key_alias.""" - return self - - class RegenerateKeyRequest(GenerateKeyRequest): # This needs to be different from UpdateKeyRequest, because "key" is optional for this key: str | None = None diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 004cefb2f72..ca2607653a1 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -20,12 +20,11 @@ import secrets import traceback from collections.abc import Awaitable, Callable, Mapping, Sequence from datetime import datetime, timedelta, timezone -from typing import Annotated, Any, Final, Literal, Optional, Protocol, TypeVar, cast +from typing import Any, Final, Literal, Optional, Protocol, TypeVar, cast import fastapi import yaml from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status -from pydantic import JsonValue import litellm from litellm._logging import verbose_proxy_logger @@ -69,7 +68,6 @@ from litellm.proxy.common_utils.config_sync_pubsub import ( coordination_redis_cache, publish_config_change, ) -from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch from litellm.proxy.common_utils.rbac_utils import check_org_admin_can_generate_keys from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache @@ -2905,79 +2903,6 @@ async def update_key_fn( ) -async def _merge_key_metadata( - key: str, - prisma_client: PrismaClient | None, - metadata_patch: JsonValue, -) -> JsonValue: - """Deep-merge a metadata patch onto the key's stored metadata, per RFC 7386.""" - existing_key_row: Final = await _get_and_validate_existing_key(token=key, prisma_client=prisma_client) - existing_metadata: Final = existing_key_row.metadata if isinstance(existing_key_row.metadata, dict) else {} - return apply_json_merge_patch(existing_metadata, metadata_patch) - - -@router.patch( - "/key/{key:path}", - tags=["key management"], - dependencies=[Depends(user_api_key_auth)], - include_in_schema=False, -) -async def patch_key( - key: str, - data: PatchKeyRequest, - request: Request, - user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], - litellm_changed_by: Annotated[ - str | None, - Header( - description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", - ), - ] = None, -): - """ - Partially update a key using RFC 7386 JSON Merge Patch semantics. - - `key` is taken from the path; a `key` in the body is accepted only when it matches. - `metadata` is merged with the key's stored metadata rather than replacing it: an - omitted entry is preserved, `entry: null` deletes it, and any other value overwrites - (recursing into nested objects). Every other field behaves exactly like - `POST /key/update` (omitted preserves, `null` clears, a value overwrites). An unknown - field is rejected with a 422 rather than silently ignored. Returns the updated key. - - ``` - curl --location --request PATCH 'http://0.0.0.0:4000/key/sk-1234' \ - --header 'Authorization: Bearer sk-1234' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "metadata": {"cost_center": "1234", "deprecated_entry": null} - }' - ``` - """ - from litellm.proxy.proxy_server import prisma_client - - if data.key is not None and data.key != key: - raise ProxyException( - message="key in body does not match key in path", - type=ProxyErrorTypes.bad_request_error, - param="key", - code=status.HTTP_400_BAD_REQUEST, - ) - - patch_fields: Final = data.model_dump(exclude_unset=True, exclude={"key"}) - merged_fields: Final = ( - {**patch_fields, "metadata": await _merge_key_metadata(key, prisma_client, patch_fields["metadata"])} - if "metadata" in patch_fields - else patch_fields - ) - - return await update_key_fn( - request=request, - data=UpdateKeyRequest.model_validate({"key": key, **merged_fields}), - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - - @router.post( "/key/bulk_update", tags=["key management"], diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index 342e6525cda..9f7c7bddb46 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -7,12 +7,16 @@ from fastapi import APIRouter from litellm.proxy.management_endpoints.management_v1.budgets import ( router as budgets_router, ) +from litellm.proxy.management_endpoints.management_v1.keys import ( + router as keys_router, +) from litellm.proxy.management_endpoints.management_v1.spend_logs import ( router as spend_logs_router, ) router: Final = APIRouter() router.include_router(budgets_router) +router.include_router(keys_router) router.include_router(spend_logs_router) __all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/management_v1/common.py b/litellm/proxy/management_endpoints/management_v1/common.py index ec79820465a..b5e1dfe258f 100644 --- a/litellm/proxy/management_endpoints/management_v1/common.py +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -1,6 +1,7 @@ """Contract machinery shared by every `/management/v1` route.""" -from typing import Final +from collections.abc import Sequence +from typing import Final, TypedDict from urllib.parse import urlencode from fastapi import Request @@ -57,6 +58,34 @@ def escape_like(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") +class ValidationErrorDetail(TypedDict): + loc: tuple[int | str, ...] + msg: str + + +def validation_problem(errors: Sequence[ValidationErrorDetail]) -> ProblemDetail: + """A body error is a 422; a query or path error is a 400. + + The two have different causes and different fixes. An unknown body field is a malformed + request the caller corrects against the schema, which is what 422 means. An unknown query + parameter is this surface refusing to silently ignore a filter, which is a 400 because the + request line itself is what was wrong. + """ + from_body: Final = any(error["loc"][:1] == ("body",) for error in errors) + detail: Final = "; ".join( + f"{location}: {error['msg']}" + if (location := ".".join(str(part) for part in error["loc"][1:])) + else error["msg"] + for error in errors + ) or ("The request body is invalid." if from_body else "The request query parameters are invalid.") + return ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}{'invalid-request-body' if from_body else 'invalid-query-parameter'}", + title="Invalid request body" if from_body else "Invalid query parameter", + status=422 if from_body else 400, + detail=detail, + ) + + def unknown_query_param_problem(unknown: tuple[str, ...], allowed: tuple[str, ...]) -> ProblemDetail: return ProblemDetail( type=f"{PROBLEM_TYPE_BASE}unknown-query-parameter", diff --git a/litellm/proxy/management_endpoints/management_v1/keys.py b/litellm/proxy/management_endpoints/management_v1/keys.py new file mode 100644 index 00000000000..92a9dcdd642 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/keys.py @@ -0,0 +1,273 @@ +"""`PATCH /management/v1/keys/{key_id}`.""" + +from collections.abc import Mapping +from datetime import datetime +from types import MappingProxyType +from typing import Annotated, Final + +from fastapi import APIRouter, Depends, Header, Request +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, model_validator + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import ( + CommonProxyErrors, + ProxyException, + UpdateKeyRequest, + UserAPIKeyAuth, +) +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch +from litellm.proxy.management_endpoints.key_management_endpoints import ( + _get_and_validate_existing_key, # pyright: ignore[reportPrivateUsage] # shared with POST /key/update on purpose, so the two routes cannot drift apart + update_key_fn, +) +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, + reject_unknown_query_params, +) +from litellm.proxy.utils import PrismaClient +from litellm.types.proxy.management_endpoints.management_v1 import ( + ItemResponse, + ProblemDetail, +) + +router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +class KeyResource(BaseModel): + """A key as every `/management/v1/keys` operation returns it. + + One representation, shared by list, read, create and update, so a form seeded from any of them + holds exactly the fields the server stores. A per-operation projection is what lets a form + compute its dirty-field delta against a value the server never sent. + + The plaintext secret is structurally absent rather than filtered: it is not a declared field and + extras are ignored, so it cannot appear here however the row was assembled. `key_id` is the + hashed token, which is what identifies a key everywhere else, and `key_name` is the masked + display form safe to show in a UI. + """ + + model_config = ConfigDict(extra="ignore") + + key_id: str + key_name: str | None = None + key_alias: str | None = None + key_type: str | None = None + user_id: str | None = None + team_id: str | None = None + agent_id: str | None = None + project_id: str | None = None + organization_id: str | None = None + budget_id: str | None = None + object_permission_id: str | None = None + models: list[str] = Field(default_factory=list) + policies: list[str] = Field(default_factory=list) + access_group_ids: list[str] = Field(default_factory=list) + allowed_cache_controls: list[str] = Field(default_factory=list) + allowed_routes: list[str] = Field(default_factory=list) + aliases: dict[str, JsonValue] = Field(default_factory=dict) + config: dict[str, JsonValue] = Field(default_factory=dict) + permissions: dict[str, JsonValue] = Field(default_factory=dict) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + model_spend: dict[str, JsonValue] = Field(default_factory=dict) + model_max_budget: dict[str, JsonValue] = Field(default_factory=dict) + budget_fallbacks: dict[str, JsonValue] = Field(default_factory=dict) + router_settings: dict[str, JsonValue] | None = None + budget_limits: dict[str, JsonValue] | None = None + spend: float = 0.0 + max_budget: float | None = None + max_parallel_requests: int | None = None + tpm_limit: int | None = None + rpm_limit: int | None = None + budget_duration: str | None = None + budget_reset_at: datetime | None = None + blocked: bool | None = None + expires: datetime | None = None + auto_rotate: bool | None = None + rotation_interval: str | None = None + rotation_count: int | None = None + last_rotation_at: datetime | None = None + key_rotation_at: datetime | None = None + last_active: datetime | None = None + settings_updated_at: datetime | None = None + created_at: datetime | None = None + created_by: str | None = None + updated_at: datetime | None = None + updated_by: str | None = None + + +class KeyPatchRequest(UpdateKeyRequest): + """Body of `PATCH /management/v1/keys/{key_id}`. + + Unknown fields are rejected rather than ignored: on a merge patch the set of fields present + *is* the request, so a misspelled field has to fail loudly instead of silently no-op'ing. + """ + + model_config = ConfigDict(extra="forbid") + + key_id: str | None = None + + @model_validator(mode="after") + def validate_key_identifier(self) -> "KeyPatchRequest": + """The path supplies the identifier, and `key_id` is this surface's only spelling of it. + + `key` is the legacy route's spelling, inherited from `UpdateKeyRequest`. Accepting both + would put two names for one field on a surface whose whole point is that there is one. + """ + if self.key is not None: + raise ValueError("`key` is not a field on this resource; the identifier is `key_id`, taken from the path") + return self + + +_KEY_RESOURCE: Final = TypeAdapter(KeyResource) +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +# `object`, not `JsonValue`: a database row carries datetimes, which JsonValue does not admit. +_ROW: Final = TypeAdapter(dict[str, object]) + +_PROXY_ERROR_PROBLEMS: Final[Mapping[int, tuple[str, str]]] = MappingProxyType( + { # mutable-ok: an immutable mapping has no literal form; MappingProxyType freezes this one and it never escapes + 400: ("bad-request", "Bad request"), + 401: ("unauthorized", "Unauthorized"), + 403: ("forbidden", "Forbidden"), + 404: ("key-not-found", "Key not found"), + } +) + + +def _problem(slug: str, title: str, status_code: int, detail: str) -> ProblemDetail: + return ProblemDetail(type=f"{PROBLEM_TYPE_BASE}{slug}", title=title, status=status_code, detail=detail) + + +def _problem_from_proxy_exception(exc: ProxyException) -> ProblemDetail: + """Translate the legacy write path's OpenAI-shaped error into a problem document. + + The write core is shared with `POST /key/update`, which must keep raising `ProxyException`, so + the translation happens here rather than by changing what that core raises. + """ + code: Final = str(exc.code) + status_code: Final = int(code) if code.isdigit() else 400 + slug, title = _PROXY_ERROR_PROBLEMS.get(status_code, ("key-update-failed", "Key update failed")) + return _problem(slug=slug, title=title, status_code=status_code, detail=exc.message) + + +def to_key_resource(row: Mapping[str, object]) -> KeyResource: + """`key_id` comes from the row's own hashed token, never from the path. + + A caller may address a key by its plaintext secret, and echoing the path value back would put + that secret in the response body. + """ + return _KEY_RESOURCE.validate_python({**row, "key_id": row.get("token")}) + + +async def _merge_key_metadata( + key_id: str, + prisma_client: PrismaClient | None, + metadata_patch: JsonValue, +) -> JsonValue: + """Deep-merge a metadata patch onto the key's stored metadata, per RFC 7396.""" + existing_key_row: Final = await _get_and_validate_existing_key(token=key_id, prisma_client=prisma_client) + existing_metadata: Final = _JSON_OBJECT.validate_python( + existing_key_row.metadata or {} # pyright: ignore[reportUnknownMemberType] # unannotated on the row model; the validate_python call around it is what types it + ) + return apply_json_merge_patch(existing_metadata, metadata_patch) + + +@router.patch( + "/keys/{key_id}", + tags=["key management"], + dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)], + response_model=ItemResponse[KeyResource], +) +async def patch_key( + key_id: str, + data: KeyPatchRequest, + request: Request, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + litellm_changed_by: Annotated[ + str | None, + Header( + description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", + ), + ] = None, +) -> ItemResponse[KeyResource]: + """ + Partially update a key, using RFC 7396 JSON Merge Patch semantics. + + `key_id` is taken from the path; a `key_id` in the body is accepted only when it matches. + Omitting a field preserves it, `null` clears it, and any other value overwrites it. `metadata` + merges rather than replacing: an omitted entry is preserved, `entry: null` deletes it, and a + nested object recurses. Arrays replace wholesale, which RFC 7396 is explicit about. An unknown + field is a 422 rather than a silent no-op. + + Answers with the full key under `data`, the same representation every other keys operation + serves. The key's plaintext secret is never in that representation. + + ``` + curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/keys/' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "metadata": {"cost_center": "1234", "deprecated_entry": null} + }' + ``` + """ + try: + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise ManagementProblem( + _problem( + slug="database-not-connected", + title="Database not connected", + status_code=503, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + ) + + if data.key_id is not None and data.key_id != key_id: + raise ManagementProblem( + _problem( + slug="identifier-mismatch", + title="Identifier mismatch", + status_code=400, + detail="`key_id` in the body does not match the `key_id` in the path.", + ) + ) + + patch_fields: Final = _JSON_OBJECT.validate_python( + data.model_dump(exclude_unset=True, exclude={"key_id", "key"}, mode="json") + ) + merged_fields: Final = ( + {**patch_fields, "metadata": await _merge_key_metadata(key_id, prisma_client, patch_fields["metadata"])} + if "metadata" in patch_fields + else patch_fields + ) + + updated: Final = _ROW.validate_python( + await update_key_fn( + request=request, + data=UpdateKeyRequest.model_validate({"key": key_id, **merged_fields}), + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) + ) + return ItemResponse(data=to_key_resource(updated)) + + except ManagementProblem: + raise + except ProxyException as e: + raise ManagementProblem(_problem_from_proxy_exception(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.keys.patch_key(): Exception occured - %s", e + ) + raise ManagementProblem( + _problem( + slug="internal-server-error", + title="Internal server error", + status_code=500, + detail="Failed to update key.", + ) + ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9692d4449d4..dbc6d9f0098 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -463,7 +463,9 @@ from litellm.proxy.management_endpoints.management_v1.common import ( MANAGEMENT_V1_PREFIX, PROBLEM_TYPE_BASE, ManagementProblem, + ValidationErrorDetail, problem_response, + validation_problem, ) from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( router as model_access_group_management_router, @@ -1644,27 +1646,13 @@ class _ExceptionRow(TypedDict, total=False): exception_counts: Mapping[str, int] -class _ValidationErrorDetail(TypedDict): - loc: tuple[int | str, ...] - msg: str - - @app.exception_handler(RequestValidationError) async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError): if request.url.path.startswith(MANAGEMENT_V1_PREFIX): - _close_dangling_otel_server_span(request, 400, exc=exc) - validation_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors() - return problem_response( - ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", - title="Invalid query parameter", - status=400, - detail="; ".join( - f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors - ) - or "The request query parameters are invalid.", - ) - ) + validation_errors: Final[Sequence[ValidationErrorDetail]] = exc.errors() + problem: Final = validation_problem(validation_errors) + _close_dangling_otel_server_span(request, problem.status, exc=exc) + return problem_response(problem) _close_dangling_otel_server_span(request, 422, exc=exc) return JSONResponse( status_code=422, diff --git a/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py index b2244f6eb9b..5c9667c113b 100644 --- a/litellm/types/proxy/management_endpoints/management_v1.py +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -71,3 +71,14 @@ class ListResponse(BaseModel, Generic[TOut]): data: list[TOut] meta: ListMeta links: ListLinks + + +class ItemResponse(BaseModel, Generic[TOut]): + """One resource, under the same `data` member `ListResponse` uses, so a client unwraps every + control-plane route the same way. + + `meta` and `links` are absent until there is something to put in them. Adding either later is + additive precisely because they are siblings of `data` rather than keys alongside the resource's + own fields, where a new key could collide with a real one.""" + + data: TOut diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py index 40473f1a25a..06efd2ec0ab 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py @@ -20,6 +20,7 @@ from litellm.proxy.management_endpoints.management_v1.common import ( PROBLEM_TYPE_BASE, ManagementProblem, problem_response, + validation_problem, ) from litellm.proxy.management_endpoints.management_v1.list_framework import ( Compare, @@ -38,14 +39,8 @@ async def management_problem_exception_handler(request: Request, exc: Management @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): - return problem_response( - ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", - title="Invalid query parameter", - status=400, - detail="The request query parameters are invalid.", - ) - ) + """The same translation `proxy_server` installs, rather than a local approximation of it.""" + return problem_response(validation_problem(exc.errors())) app.include_router(router) diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_keys.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_keys.py new file mode 100644 index 00000000000..146118632a7 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_keys.py @@ -0,0 +1,276 @@ +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from litellm.proxy._types import ( + LiteLLM_VerificationToken, + LitellmUserRoles, + UpdateKeyRequest, +) +from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth +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, +) + +app = FastAPI() + + +@app.exception_handler(ManagementProblem) +async def management_problem_exception_handler(request: Request, exc: ManagementProblem): + return problem_response(exc.problem) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """The same translation `proxy_server` installs. Registering only the `ManagementProblem` + handler here would let FastAPI's default 422 stand in for the real one, and the tests would + pass against a status code production never returns.""" + return problem_response(validation_problem(exc.errors())) + + +app.include_router(router) +client = TestClient(app) + +KEYS_PATH = f"{MANAGEMENT_V1_PREFIX}/keys" +HASHED_TOKEN = "a1b2c3d4" * 8 +PLAINTEXT_KEY = "sk-plaintext-secret-value" + + +def _row(**overrides: Any) -> dict[str, Any]: + return { + "token": HASHED_TOKEN, + "key_name": "sk-...alue", + "key_alias": "reporting", + "user_id": "test-user", + "spend": 0.0, + "models": [], + "metadata": {}, + **overrides, + } + + +@pytest.fixture +def key_write(monkeypatch): + """Mocks the write path and hands back the prisma mock, so a test can assert on the + exact dict handed to `update_data` as well as on the HTTP response.""" + prisma_client = AsyncMock() + prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user") + ) + prisma_client.update_data = AsyncMock(return_value={"data": _row()}) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + monkeypatch.setattr("litellm.store_audit_logs", False) + return prisma_client + + +@pytest.fixture +def as_proxy_admin(): + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + yield + app.dependency_overrides.clear() + + +def _patch(body: dict[str, Any], key_id: str = HASHED_TOKEN): + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object", + new=AsyncMock(), + ): + return client.patch(f"{KEYS_PATH}/{key_id}", json=body, headers={"Authorization": "Bearer k"}) + + +async def _drive_post(monkeypatch, existing_metadata: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + """Drive the legacy POST write core against the same mocked row, and return what it wrote.""" + from litellm.proxy.management_endpoints.key_management_endpoints import update_key_fn + + prisma_client = AsyncMock() + prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user", metadata=existing_metadata) + ) + prisma_client.update_data = AsyncMock(return_value={"data": _row()}) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + monkeypatch.setattr("litellm.store_audit_logs", False) + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object", + new=AsyncMock(), + ): + await update_key_fn( + request=MagicMock(), + data=UpdateKeyRequest(key=HASHED_TOKEN, **body), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user" + ), + litellm_changed_by=None, + ) + return prisma_client.update_data.call_args.kwargs["data"] + + +# (label, stored_metadata, patch_body, what POST writes, what PATCH writes) +_METADATA_MAPPING = [ + ( + "sibling entries survive a merge patch but not a POST", + {"cost_center": "cc-1", "owner": "data-eng"}, + {"cost_center": "cc-2"}, + {"cost_center": "cc-2"}, + {"cost_center": "cc-2", "owner": "data-eng"}, + ), + ( + "a nested object recurses instead of being replaced", + {"nested": {"a": 1, "b": 2}, "owner": "data-eng"}, + {"nested": {"b": 99}}, + {"nested": {"b": 99}}, + {"nested": {"a": 1, "b": 99}, "owner": "data-eng"}, + ), + ( + "null deletes only its own entry", + {"cost_center": "cc-1", "owner": "data-eng"}, + {"cost_center": None}, + {"cost_center": None}, + {"owner": "data-eng"}, + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "label,stored,body,expected_post,expected_patch", + _METADATA_MAPPING, + ids=[row[0] for row in _METADATA_MAPPING], +) +async def test_metadata_merges_where_the_legacy_post_replaces( + monkeypatch, key_write, as_proxy_admin, label, stored, body, expected_post, expected_patch +): + """The one sanctioned divergence from `POST /key/update`, which writes the submitted + metadata verbatim and so drops every entry the caller did not resend.""" + written_post = await _drive_post(monkeypatch, stored, {"metadata": body}) + assert written_post["metadata"] == expected_post + + key_write.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user", metadata=stored) + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", key_write) + + assert _patch({"metadata": body}).status_code == 200 + assert key_write.update_data.call_args.kwargs["data"]["metadata"] == expected_patch + + +def test_answers_in_the_item_envelope_without_the_plaintext_secret(key_write, as_proxy_admin): + """`{"data": {...}}`, and `key_id` is the row's hashed token even when the caller addressed + the key by its plaintext secret, which must not come back in the body.""" + response = _patch({"tpm_limit": 77}, key_id=PLAINTEXT_KEY) + + assert response.status_code == 200 + body = response.json() + assert set(body) == {"data"} + assert body["data"]["key_id"] == HASHED_TOKEN + assert PLAINTEXT_KEY not in response.text + assert "key" not in body["data"] + + +def test_null_clears_and_omission_preserves(key_write, as_proxy_admin): + """Both directions in one test: a route that cleared everything would pass a clear-only + assertion, and a route that cleared nothing would pass a preserve-only one.""" + assert _patch({"tpm_limit": None}).status_code == 200 + cleared = key_write.update_data.call_args.kwargs["data"] + assert "tpm_limit" in cleared and cleared["tpm_limit"] is None + + assert _patch({"rpm_limit": 9}).status_code == 200 + preserved = key_write.update_data.call_args.kwargs["data"] + assert "tpm_limit" not in preserved + assert preserved["rpm_limit"] == 9 + + +def test_does_not_slide_the_budget_window(key_write, as_proxy_admin): + """A merge patch is idempotent, so a patch that never mentions `budget_duration` must leave + `budget_reset_at` alone rather than postponing the key's reset on every save.""" + key_write.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user", budget_duration="30d") + ) + + assert _patch({"rpm_limit": 5}).status_code == 200 + + written = key_write.update_data.call_args.kwargs["data"] + assert written["rpm_limit"] == 5 + assert "budget_reset_at" not in written + assert "budget_duration" not in written + + +@pytest.mark.parametrize( + "body,reason", + [ + ({"tpm_limitt": 5}, "a misspelled field"), + ({"key": HASHED_TOKEN}, "the legacy `key` spelling of the identifier"), + ], + ids=["misspelled field", "legacy key spelling"], +) +def test_rejects_bodies_that_would_otherwise_no_op(key_write, as_proxy_admin, body, reason): + """On a merge patch the set of fields present IS the request, so anything unrecognized has to + fail loudly rather than silently changing nothing the way the legacy POST does. + + 422 and `invalid-request-body`, not the 400 `invalid-query-parameter` a body error got before + this surface had bodies to validate.""" + response = _patch(body) + + assert response.status_code == 422 + assert response.headers["content-type"].startswith("application/problem+json") + assert response.json()["type"] == "urn:litellm:error:invalid-request-body" + key_write.update_data.assert_not_called() + + +def test_rejects_an_unknown_query_parameter(key_write, as_proxy_admin): + """The strictness the list surface already has, which a write route does not get for free: + the guard is a route dependency, and omitting it silently accepts the parameter.""" + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object", + new=AsyncMock(), + ): + response = client.patch( + f"{KEYS_PATH}/{HASHED_TOKEN}?bogus=1", json={"tpm_limit": 1}, headers={"Authorization": "Bearer k"} + ) + + assert response.status_code == 400 + assert response.json()["type"] == "urn:litellm:error:unknown-query-parameter" + key_write.update_data.assert_not_called() + + +def test_identifier_mismatch_is_a_problem_document(key_write, as_proxy_admin): + """The path is authoritative, and the refusal must not echo either identifier back.""" + response = _patch({"key_id": "a-different-key", "tpm_limit": 1}) + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + problem = response.json() + assert problem["type"] == "urn:litellm:error:identifier-mismatch" + assert "a-different-key" not in response.text + assert HASHED_TOKEN not in response.text + key_write.update_data.assert_not_called() + + +def test_a_missing_key_is_a_problem_document(key_write, as_proxy_admin): + """The legacy write core raises the OpenAI error shape; this surface answers RFC 9457.""" + key_write.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None) + + response = _patch({"tpm_limit": 1}) + + assert response.status_code == 404 + assert response.headers["content-type"].startswith("application/problem+json") + assert response.json()["type"] == "urn:litellm:error:key-not-found" + key_write.update_data.assert_not_called() diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 7fd752b3756..bdf09a95e4b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -16444,197 +16444,3 @@ async def test_regenerate_key_repoints_live_membership_not_the_key_row_it_read( assert await _authorized_models_for_key( access_groups, new_token_hash, ["ag-revoked-since", "ag-attached-since"] ) == ["attached-model"] - - -# --------------------------------------------------------------------------- -# PATCH /key/{key} - RFC 7386 JSON merge patch. -# Drives POST update_key_fn and PATCH patch_key against the same mocked key row -# and asserts on the exact dict handed to prisma_client.update_data. -# --------------------------------------------------------------------------- - -_PATCH_KEY_TOKEN = "a1b2c3d4" * 8 - - -async def _drive_key_write( - kind, - monkeypatch, - *, - existing_metadata=None, - existing_kwargs=None, - payload=None, -): - """Drive POST ``update_key_fn`` or PATCH ``patch_key`` against a mocked key. - - Returns the dict handed to ``prisma_client.update_data``. - """ - from litellm.proxy._types import PatchKeyRequest - from litellm.proxy.management_endpoints.key_management_endpoints import ( - patch_key, - update_key_fn, - ) - - key_in_db = LiteLLM_VerificationToken( - token=_PATCH_KEY_TOKEN, - user_id="test-user", - **({"metadata": existing_metadata} if existing_metadata is not None else {}), - **(existing_kwargs or {}), - ) - - mock_prisma_client = AsyncMock() - mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( - return_value=key_in_db - ) - mock_prisma_client.update_data = AsyncMock(return_value={"data": {}}) - _setup_update_key_mocks(monkeypatch, mock_prisma_client) - - auth = UserAPIKeyAuth( - user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user" - ) - - with patch( - "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object", - new=AsyncMock(), - ): - if kind == "post": - await update_key_fn( - request=MagicMock(), - data=UpdateKeyRequest(key=_PATCH_KEY_TOKEN, **(payload or {})), - user_api_key_dict=auth, - litellm_changed_by=None, - ) - else: - await patch_key( - key=_PATCH_KEY_TOKEN, - data=PatchKeyRequest.model_validate(dict(payload or {})), - request=MagicMock(), - user_api_key_dict=auth, - litellm_changed_by=None, - ) - - return mock_prisma_client.update_data.call_args.kwargs["data"] - - -# (label, existing_metadata, merge_patch_body, expected_POST_metadata, expected_PATCH_metadata) -_KEY_METADATA_MAPPING = [ - ( - "sibling entries survive a merge patch but not a POST", - {"cost_center": "cc-1", "owner": "data-eng"}, - {"cost_center": "cc-2"}, - {"cost_center": "cc-2"}, - {"cost_center": "cc-2", "owner": "data-eng"}, - ), - ( - "a nested object recurses instead of being replaced", - {"nested": {"a": 1, "b": 2}, "owner": "data-eng"}, - {"nested": {"b": 99}}, - {"nested": {"b": 99}}, - {"nested": {"a": 1, "b": 99}, "owner": "data-eng"}, - ), - ( - "null deletes only its own entry", - {"cost_center": "cc-1", "owner": "data-eng"}, - {"cost_center": None}, - {"cost_center": None}, - {"owner": "data-eng"}, - ), -] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "label,existing,body,expected_post,expected_patch", - _KEY_METADATA_MAPPING, - ids=[row[0] for row in _KEY_METADATA_MAPPING], -) -async def test_patch_key_merges_metadata_where_post_replaces_it( - monkeypatch, label, existing, body, expected_post, expected_patch -): - """POST /key/update writes the submitted metadata verbatim, dropping every entry - the caller did not resend. PATCH merges it onto what is already stored.""" - written_post = await _drive_key_write( - "post", monkeypatch, existing_metadata=existing, payload={"metadata": body} - ) - assert written_post["metadata"] == expected_post - - written_patch = await _drive_key_write( - "patch", monkeypatch, existing_metadata=existing, payload={"metadata": body} - ) - assert written_patch["metadata"] == expected_patch - - -@pytest.mark.asyncio -async def test_patch_key_null_clears_and_omission_preserves(monkeypatch): - """Both directions in one test: a route that cleared everything would pass a - clear-only assertion, and a route that cleared nothing would pass a preserve-only one.""" - cleared = await _drive_key_write( - "patch", monkeypatch, existing_kwargs={"tpm_limit": 500}, payload={"tpm_limit": None} - ) - assert "tpm_limit" in cleared - assert cleared["tpm_limit"] is None - - preserved = await _drive_key_write( - "patch", monkeypatch, existing_kwargs={"tpm_limit": 500}, payload={"rpm_limit": 9} - ) - assert "tpm_limit" not in preserved - assert preserved["rpm_limit"] == 9 - - -@pytest.mark.asyncio -async def test_patch_key_does_not_slide_the_budget_window(monkeypatch): - """A merge patch is idempotent, so a patch that never mentions budget_duration - must leave budget_reset_at alone rather than postponing the key's reset.""" - written = await _drive_key_write( - "patch", - monkeypatch, - existing_kwargs={"budget_duration": "30d"}, - payload={"rpm_limit": 5}, - ) - assert written["rpm_limit"] == 5 - assert "budget_reset_at" not in written - assert "budget_duration" not in written - - -@pytest.mark.asyncio -async def test_patch_key_rejects_a_body_key_that_disagrees_with_the_path(monkeypatch): - """The path is authoritative, and the rejection must not echo either secret back.""" - from litellm.proxy._types import PatchKeyRequest - from litellm.proxy.management_endpoints.key_management_endpoints import patch_key - - mock_prisma_client = AsyncMock() - _setup_update_key_mocks(monkeypatch, mock_prisma_client) - - with pytest.raises(ProxyException) as exc_info: - await patch_key( - key=_PATCH_KEY_TOKEN, - data=PatchKeyRequest(key="sk-a-different-key", tpm_limit=1), - request=MagicMock(), - user_api_key_dict=UserAPIKeyAuth( - user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user" - ), - litellm_changed_by=None, - ) - - assert str(exc_info.value.code) == "400" - assert "sk-a-different-key" not in str(exc_info.value.message) - assert _PATCH_KEY_TOKEN not in str(exc_info.value.message) - mock_prisma_client.update_data.assert_not_called() - - -def test_patch_key_request_rejects_unknown_fields(): - """On a merge patch the set of fields present IS the request, so a misspelled - field has to fail loudly instead of silently no-op'ing the way POST does.""" - from pydantic import ValidationError - - from litellm.proxy._types import PatchKeyRequest - - with pytest.raises(ValidationError): - PatchKeyRequest.model_validate({"tpm_limitt": 5}) - - assert UpdateKeyRequest(key="sk-x", **{"tpm_limitt": 5}).tpm_limit is None - - -def test_patch_key_request_makes_key_optional(): - """PATCH takes the identifier from the path, so the body needs neither key nor key_alias.""" - from litellm.proxy._types import PatchKeyRequest - - assert PatchKeyRequest.model_validate({"tpm_limit": 5}).key is None diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0629554eecf..dc2993f47be 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7371,39 +7371,6 @@ export interface paths { patch?: never; trace?: never; }; - "/key/{key}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** - * Patch Key - * @description Partially update a key using RFC 7386 JSON Merge Patch semantics. - * - * `key` is taken from the path; a `key` in the body is accepted only when it matches. - * `metadata` is merged with the key's stored metadata rather than replacing it: an - * omitted entry is preserved, `entry: null` deletes it, and any other value overwrites - * (recursing into nested objects). Every other field behaves exactly like - * `POST /key/update` (omitted preserves, `null` clears, a value overwrites). An unknown - * field is rejected with a 422 rather than silently ignored. Returns the updated key. - * - * ``` - * curl --location --request PATCH 'http://0.0.0.0:4000/key/sk-1234' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data-raw '{ - * "metadata": {"cost_center": "1234", "deprecated_entry": null} - * }' - * ``` - */ - patch: operations["patch_key_key__key__patch"]; - trace?: never; - }; "/key/{key}/regenerate": { parameters: { query?: never; @@ -7621,6 +7588,41 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/keys/{key_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Patch Key + * @description Partially update a key, using RFC 7396 JSON Merge Patch semantics. + * + * `key_id` is taken from the path; a `key_id` in the body is accepted only when it matches. + * Omitting a field preserves it, `null` clears it, and any other value overwrites it. `metadata` + * merges rather than replacing: an omitted entry is preserved, `entry: null` deletes it, and a + * nested object recurses. Arrays replace wholesale, which RFC 7396 is explicit about. An unknown + * field is a 422 rather than a silent no-op. + * + * Answers with the full key under `data`, the same representation every other keys operation + * serves. The key's plaintext secret is never in that representation. + * + * ``` + * curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/keys/' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data-raw '{ + * "metadata": {"cost_center": "1234", "deprecated_entry": null} + * }' + * ``` + */ + patch: operations["patch_key_management_v1_keys__key_id__patch"]; + trace?: never; + }; "/management/v1/spend_logs/end_users": { parameters: { query?: never; @@ -25939,6 +25941,10 @@ export interface components { /** Is Accepted */ is_accepted: boolean; }; + /** ItemResponse[KeyResource] */ + ItemResponse_KeyResource_: { + data: components["schemas"]["KeyResource"]; + }; /** JWTKeyMappingResponse */ JWTKeyMappingResponse: { /** @@ -25966,6 +25972,7 @@ export interface components { /** Updated By */ updated_by?: string | null; }; + JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -26015,6 +26022,158 @@ export interface components { metadata?: components["schemas"]["KeyMetadata"]; metrics: components["schemas"]["SpendMetrics"]; }; + /** + * KeyPatchRequest + * @description Body of `PATCH /management/v1/keys/{key_id}`. + * + * Unknown fields are rejected rather than ignored: on a merge patch the set of fields present + * *is* the request, so a misspelled field has to fail loudly instead of silently no-op'ing. + */ + KeyPatchRequest: { + /** Access Group Ids */ + access_group_ids?: string[] | null; + /** Agent Id */ + agent_id?: string | null; + /** + * Aliases + * @default {} + */ + aliases: { + [key: string]: unknown; + } | null; + /** + * Allowed Cache Controls + * @default [] + */ + allowed_cache_controls: unknown[] | null; + /** Allowed Passthrough Routes */ + allowed_passthrough_routes?: unknown[] | null; + /** + * Allowed Routes + * @default [] + */ + allowed_routes: unknown[] | null; + /** Allowed Vector Store Indexes */ + allowed_vector_store_indexes?: components["schemas"]["AllowedVectorStoreIndexItem"][] | null; + /** Auto Rotate */ + auto_rotate?: boolean | null; + /** Blocked */ + blocked?: boolean | null; + /** Budget Duration */ + budget_duration?: string | null; + /** Budget Fallbacks */ + budget_fallbacks?: { + [key: string]: string[]; + } | null; + /** Budget Id */ + budget_id?: string | null; + /** Budget Limits */ + budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null; + /** + * Config + * @default {} + */ + config: { + [key: string]: unknown; + } | null; + /** Default Estimated Output Tokens */ + default_estimated_output_tokens?: number | null; + /** Default Estimated Output Tokens Per Model */ + default_estimated_output_tokens_per_model?: { + [key: string]: number; + } | null; + /** Disable Global Guardrails */ + disable_global_guardrails?: boolean | null; + /** Duration */ + duration?: string | null; + /** Enable Prompt Caching */ + enable_prompt_caching?: boolean | null; + /** Enforced Params */ + enforced_params?: string[] | null; + /** Guardrails */ + guardrails?: string[] | null; + /** Key */ + key?: string | null; + /** Key Alias */ + key_alias?: string | null; + /** Key Id */ + key_id?: string | null; + /** Max Budget */ + max_budget?: number | null; + /** Max Parallel Requests */ + max_parallel_requests?: number | null; + /** Mcp Rpm Limit */ + mcp_rpm_limit?: { + [key: string]: number; + } | null; + /** Metadata */ + metadata?: { + [key: string]: unknown; + } | null; + /** + * Model Max Budget + * @default {} + */ + model_max_budget: { + [key: string]: unknown; + } | null; + /** Model Rpm Limit */ + model_rpm_limit?: { + [key: string]: unknown; + } | null; + /** Model Tpm Limit */ + model_tpm_limit?: { + [key: string]: unknown; + } | null; + /** + * Models + * @default [] + */ + models: unknown[] | null; + object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null; + /** Organization Id */ + organization_id?: string | null; + /** + * Permissions + * @default {} + */ + permissions: { + [key: string]: unknown; + } | null; + /** Policies */ + policies?: string[] | null; + /** Prompts */ + prompts?: string[] | null; + /** Rotation Interval */ + rotation_interval?: string | null; + router_settings?: components["schemas"]["UpdateRouterConfig"] | null; + /** Rpm Limit */ + rpm_limit?: number | null; + /** Rpm Limit Type */ + rpm_limit_type?: ("guaranteed_throughput" | "best_effort_throughput" | "dynamic") | null; + /** Spend */ + spend?: number | null; + /** Tag Rpm Limit */ + tag_rpm_limit?: { + [key: string]: number; + } | null; + /** Tags */ + tags?: string[] | null; + /** Team Id */ + team_id?: string | null; + /** Temp Budget Expiry */ + temp_budget_expiry?: string | null; + /** Temp Budget Increase */ + temp_budget_increase?: number | null; + /** Throttle On Budget Exceeded */ + throttle_on_budget_exceeded?: boolean | null; + /** Tpm Limit */ + tpm_limit?: number | null; + /** Tpm Limit Type */ + tpm_limit_type?: ("guaranteed_throughput" | "best_effort_throughput" | "dynamic") | null; + /** User Id */ + user_id?: string | null; + }; /** KeyRequest */ KeyRequest: { /** Key Aliases */ @@ -26022,6 +26181,132 @@ export interface components { /** Keys */ keys?: string[] | null; }; + /** + * KeyResource + * @description A key as every `/management/v1/keys` operation returns it. + * + * One representation, shared by list, read, create and update, so a form seeded from any of them + * holds exactly the fields the server stores. A per-operation projection is what lets a form + * compute its dirty-field delta against a value the server never sent. + * + * The plaintext secret is structurally absent rather than filtered: it is not a declared field and + * extras are ignored, so it cannot appear here however the row was assembled. `key_id` is the + * hashed token, which is what identifies a key everywhere else, and `key_name` is the masked + * display form safe to show in a UI. + */ + KeyResource: { + /** Access Group Ids */ + access_group_ids?: string[]; + /** Agent Id */ + agent_id?: string | null; + /** Aliases */ + aliases?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Allowed Cache Controls */ + allowed_cache_controls?: string[]; + /** Allowed Routes */ + allowed_routes?: string[]; + /** Auto Rotate */ + auto_rotate?: boolean | null; + /** Blocked */ + blocked?: boolean | null; + /** Budget Duration */ + budget_duration?: string | null; + /** Budget Fallbacks */ + budget_fallbacks?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Budget Id */ + budget_id?: string | null; + /** Budget Limits */ + budget_limits?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + /** Budget Reset At */ + budget_reset_at?: string | null; + /** Config */ + config?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Created At */ + created_at?: string | null; + /** Created By */ + created_by?: string | null; + /** Expires */ + expires?: string | null; + /** Key Alias */ + key_alias?: string | null; + /** Key Id */ + key_id: string; + /** Key Name */ + key_name?: string | null; + /** Key Rotation At */ + key_rotation_at?: string | null; + /** Key Type */ + key_type?: string | null; + /** Last Active */ + last_active?: string | null; + /** Last Rotation At */ + last_rotation_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"]; + }; + /** Model Spend */ + model_spend?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Models */ + models?: string[]; + /** Object Permission Id */ + object_permission_id?: string | null; + /** Organization Id */ + organization_id?: string | null; + /** Permissions */ + permissions?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Policies */ + policies?: string[]; + /** Project Id */ + project_id?: string | null; + /** Rotation Count */ + rotation_count?: number | null; + /** Rotation Interval */ + rotation_interval?: string | null; + /** Router Settings */ + router_settings?: { + [key: string]: components["schemas"]["JsonValue"]; + } | null; + /** Rpm Limit */ + rpm_limit?: number | null; + /** Settings Updated At */ + settings_updated_at?: string | null; + /** + * Spend + * @default 0 + */ + spend: number; + /** Team Id */ + team_id?: string | null; + /** Tpm Limit */ + tpm_limit?: number | null; + /** Updated At */ + updated_at?: string | null; + /** Updated By */ + updated_by?: string | null; + /** User Id */ + user_id?: string | null; + }; /** * KeyUpdateFields * @description Allowlist of bulk-broadcastable fields for /team/key/bulk_update; `extra="forbid"` blocks RBAC/ownership/scope mutations even by team admins. @@ -30360,159 +30645,6 @@ export interface components { guardrail_name?: string | null; litellm_params?: components["schemas"]["BaseLitellmParams-Input"] | null; }; - /** - * PatchKeyRequest - * @description Body of PATCH /key/{key}. - * - * Differs from UpdateKeyRequest in two ways. `key` is optional, because PATCH takes it - * from the path; a `key` in the body is still accepted when it matches. Unknown fields - * are rejected rather than ignored, because on a merge patch the set of fields present - * *is* the request, so a misspelled field has to fail loudly instead of silently - * no-op'ing. - */ - PatchKeyRequest: { - /** Access Group Ids */ - access_group_ids?: string[] | null; - /** Agent Id */ - agent_id?: string | null; - /** - * Aliases - * @default {} - */ - aliases: { - [key: string]: unknown; - } | null; - /** - * Allowed Cache Controls - * @default [] - */ - allowed_cache_controls: unknown[] | null; - /** Allowed Passthrough Routes */ - allowed_passthrough_routes?: unknown[] | null; - /** - * Allowed Routes - * @default [] - */ - allowed_routes: unknown[] | null; - /** Allowed Vector Store Indexes */ - allowed_vector_store_indexes?: components["schemas"]["AllowedVectorStoreIndexItem"][] | null; - /** Auto Rotate */ - auto_rotate?: boolean | null; - /** Blocked */ - blocked?: boolean | null; - /** Budget Duration */ - budget_duration?: string | null; - /** Budget Fallbacks */ - budget_fallbacks?: { - [key: string]: string[]; - } | null; - /** Budget Id */ - budget_id?: string | null; - /** Budget Limits */ - budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null; - /** - * Config - * @default {} - */ - config: { - [key: string]: unknown; - } | null; - /** Default Estimated Output Tokens */ - default_estimated_output_tokens?: number | null; - /** Default Estimated Output Tokens Per Model */ - default_estimated_output_tokens_per_model?: { - [key: string]: number; - } | null; - /** Disable Global Guardrails */ - disable_global_guardrails?: boolean | null; - /** Duration */ - duration?: string | null; - /** Enable Prompt Caching */ - enable_prompt_caching?: boolean | null; - /** Enforced Params */ - enforced_params?: string[] | null; - /** Guardrails */ - guardrails?: string[] | null; - /** Key */ - key?: string | null; - /** Key Alias */ - key_alias?: string | null; - /** Max Budget */ - max_budget?: number | null; - /** Max Parallel Requests */ - max_parallel_requests?: number | null; - /** Mcp Rpm Limit */ - mcp_rpm_limit?: { - [key: string]: number; - } | null; - /** Metadata */ - metadata?: { - [key: string]: unknown; - } | null; - /** - * Model Max Budget - * @default {} - */ - model_max_budget: { - [key: string]: unknown; - } | null; - /** Model Rpm Limit */ - model_rpm_limit?: { - [key: string]: unknown; - } | null; - /** Model Tpm Limit */ - model_tpm_limit?: { - [key: string]: unknown; - } | null; - /** - * Models - * @default [] - */ - models: unknown[] | null; - object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null; - /** Organization Id */ - organization_id?: string | null; - /** - * Permissions - * @default {} - */ - permissions: { - [key: string]: unknown; - } | null; - /** Policies */ - policies?: string[] | null; - /** Prompts */ - prompts?: string[] | null; - /** Rotation Interval */ - rotation_interval?: string | null; - router_settings?: components["schemas"]["UpdateRouterConfig"] | null; - /** Rpm Limit */ - rpm_limit?: number | null; - /** Rpm Limit Type */ - rpm_limit_type?: ("guaranteed_throughput" | "best_effort_throughput" | "dynamic") | null; - /** Spend */ - spend?: number | null; - /** Tag Rpm Limit */ - tag_rpm_limit?: { - [key: string]: number; - } | null; - /** Tags */ - tags?: string[] | null; - /** Team Id */ - team_id?: string | null; - /** Temp Budget Expiry */ - temp_budget_expiry?: string | null; - /** Temp Budget Increase */ - temp_budget_increase?: number | null; - /** Throttle On Budget Exceeded */ - throttle_on_budget_exceeded?: boolean | null; - /** Tpm Limit */ - tpm_limit?: number | null; - /** Tpm Limit Type */ - tpm_limit_type?: ("guaranteed_throughput" | "best_effort_throughput" | "dynamic") | null; - /** User Id */ - user_id?: string | null; - }; /** PatchPromptRequest */ PatchPromptRequest: { litellm_params?: components["schemas"]["PromptLiteLLMParams"] | null; @@ -45781,44 +45913,6 @@ export interface operations { }; }; }; - patch_key_key__key__patch: { - parameters: { - query?: never; - header?: { - /** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */ - "litellm-changed-by"?: string | null; - }; - path: { - key: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["PatchKeyRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; regenerate_key_fn_key__key__regenerate_post: { parameters: { query?: never; @@ -46143,6 +46237,44 @@ export interface operations { }; }; }; + patch_key_management_v1_keys__key_id__patch: { + parameters: { + query?: never; + header?: { + /** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */ + "litellm-changed-by"?: string | null; + }; + path: { + key_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["KeyPatchRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ItemResponse_KeyResource_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_spend_log_end_users_management_v1_spend_logs_end_users_get: { parameters: { query: {