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..bb9b9154ab9 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/keys.py @@ -0,0 +1,282 @@ +"""`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) + +# A JSON column that the schema declares NOT NULL with a `{}` default, so it is always present on +# the wire. `Mapping` keeps it read-only to callers. The factory is unavoidable: pydantic deep-copies +# field defaults, and a `MappingProxyType` cannot be deep-copied, so an immutable default raises at +# validation time. Declared once here rather than repeated on each of the seven fields that use it. +_JsonObject = Annotated[ + Mapping[str, JsonValue], + Field(default_factory=dict), # mutable-ok: pydantic hands each instance its own copy, so no state is shared +] + + +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: tuple[str, ...] = () + policies: tuple[str, ...] = () + access_group_ids: tuple[str, ...] = () + allowed_cache_controls: tuple[str, ...] = () + allowed_routes: tuple[str, ...] = () + aliases: _JsonObject + config: _JsonObject + permissions: _JsonObject + metadata: _JsonObject + model_spend: _JsonObject + model_max_budget: _JsonObject + budget_fallbacks: _JsonObject + router_settings: Mapping[str, JsonValue] | None = None + budget_limits: Mapping[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 7dad7abd210..73476222f30 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -489,9 +489,10 @@ from litellm.proxy.management_endpoints.management_v1 import ( ) 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, @@ -552,7 +553,6 @@ from litellm.proxy.plugin_routes import ( from litellm.proxy.plugin_routes import ( router as plugin_router, ) -from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail try: from litellm.proxy.enterprise_billing.billing_metrics import ( @@ -1693,27 +1693,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..366fdf9e544 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 @@ -17,16 +17,15 @@ from litellm.proxy.management_endpoints.management_v1.budgets import ( ) from litellm.proxy.management_endpoints.management_v1.common import ( MANAGEMENT_V1_PREFIX, - PROBLEM_TYPE_BASE, ManagementProblem, problem_response, + validation_problem, ) from litellm.proxy.management_endpoints.management_v1.list_framework import ( Compare, ScopeWhere, build_query_plan, ) -from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail app = FastAPI() @@ -38,14 +37,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..5825c6e2a21 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_keys.py @@ -0,0 +1,287 @@ +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_a_row_without_its_own_id_fails_rather_than_falling_back(key_write, as_proxy_admin): + """`key_id` has exactly one source, the row's hashed token. Without this, a fallback to any + other field on the row would quietly put the caller's plaintext secret in the response.""" + key_write.update_data = AsyncMock(return_value={"data": {k: v for k, v in _row().items() if k != "token"}}) + + response = _patch({"tpm_limit": 1}, key_id=PLAINTEXT_KEY) + + assert response.status_code == 500 + assert PLAINTEXT_KEY not in response.text + + +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/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 24ed24512dc..fba82d499f8 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8057,6 +8057,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; @@ -27164,6 +27199,10 @@ export interface components { /** Is Accepted */ is_accepted: boolean; }; + /** ItemResponse[KeyResource] */ + ItemResponse_KeyResource_: { + data: components["schemas"]["KeyResource"]; + }; /** JWTKeyMappingResponse */ JWTKeyMappingResponse: { /** @@ -27191,6 +27230,7 @@ export interface components { /** Updated By */ updated_by?: string | null; }; + JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -27240,6 +27280,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 */ @@ -27247,6 +27439,147 @@ 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 + * @default [] + */ + access_group_ids: string[]; + /** Agent Id */ + agent_id?: string | null; + /** Aliases */ + aliases?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** + * Allowed Cache Controls + * @default [] + */ + allowed_cache_controls: string[]; + /** + * Allowed Routes + * @default [] + */ + 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 + * @default [] + */ + models: string[]; + /** Object Permission Id */ + object_permission_id?: string | null; + /** Organization Id */ + organization_id?: string | null; + /** Permissions */ + permissions?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** + * Policies + * @default [] + */ + 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. @@ -48528,6 +48861,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: {