From f312c324124b5ed5084b5159673c66e76d424bb2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 17 Aug 2026 10:32:45 -0700 Subject: [PATCH 1/3] feat(key): add RESTful PATCH /key/{key} with JSON merge patch semantics POST /key/update replaces stored metadata wholesale, so a caller that resends one entry silently drops every entry it did not resend, including nested ones. It also ignores unknown fields, so a misspelled field returns 200 having changed nothing. Add PATCH /key/{key}, which deep-merges metadata per RFC 7386 and rejects unknown fields with a 422. The route reshapes its body and delegates to update_key_fn, so POST behavior is unchanged by construction. Resource-scoped path matches the existing /key/{key}/regenerate and /key/{key}/reset_spend routes. --- litellm/proxy/_types.py | 21 ++ .../key_management_endpoints.py | 77 +++++- .../test_key_management_endpoints.py | 194 +++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 224 ++++++++++++++++++ 4 files changed, 515 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a566d491597..f5a312cef23 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1241,6 +1241,27 @@ 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 ca2607653a1..004cefb2f72 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -20,11 +20,12 @@ import secrets import traceback from collections.abc import Awaitable, Callable, Mapping, Sequence from datetime import datetime, timedelta, timezone -from typing import Any, Final, Literal, Optional, Protocol, TypeVar, cast +from typing import Annotated, 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 @@ -68,6 +69,7 @@ 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 @@ -2903,6 +2905,79 @@ 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/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index bdf09a95e4b..7fd752b3756 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,3 +16444,197 @@ 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 095afc6110f..8fca7eaad4d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7371,6 +7371,39 @@ 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; @@ -30307,6 +30340,159 @@ 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; @@ -45575,6 +45761,44 @@ 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; From 3d00d3ee7c543e76c58de35ab350181062deb48f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 17 Aug 2026 13:57:31 -0700 Subject: [PATCH 2/3] feat(management): add PATCH /management/v1/keys/{key_id} Replaces the earlier PATCH /key/{key} draft on this branch with the same merge patch semantics served on the versioned control plane, so keys follow the shape budgets already ships: one canonical representation under a `data` envelope, RFC 9457 problem details, and strict rejection of unknown body fields and unknown query parameters. The key representation is defined here because this is the first keys route on the surface, so the forthcoming list and read operations inherit it rather than each picking their own projection. The plaintext key is not one of its fields, so a caller that addresses a key by its secret does not get that secret back. POST /key/update is untouched. Both files it lives in are byte identical to staging, so its behaviour cannot drift from this change. Also fixes the /management/v1 validation handler, which labelled every failure `invalid-query-parameter` with a 400 because the surface carried no request bodies until now. Body errors are 422 `invalid-request-body`; query and path errors keep their 400. The budgets test app now installs that shared handler instead of a local approximation, which is what let the mislabelling through. --- litellm/proxy/_types.py | 21 - .../key_management_endpoints.py | 77 +-- .../management_v1/__init__.py | 4 + .../management_v1/common.py | 31 +- .../management_v1/keys.py | 273 +++++++++ litellm/proxy/proxy_server.py | 24 +- .../management_endpoints/management_v1.py | 11 + .../management_v1/test_budgets.py | 11 +- .../management_v1/test_keys.py | 276 +++++++++ .../test_key_management_endpoints.py | 194 ------ ui/litellm-dashboard/src/lib/http/schema.d.ts | 580 +++++++++++------- 11 files changed, 960 insertions(+), 542 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/keys.py create mode 100644 tests/test_litellm/proxy/management_endpoints/management_v1/test_keys.py 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: { From cc742ebe44409c09dac4959aba1a4dd0bbff954f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 17 Aug 2026 14:06:58 -0700 Subject: [PATCH 3/3] fix(management/v1): report body validation errors as 422, and tighten the keys route Three defects found by running the new route against a live proxy rather than only through its tests. The `/management/v1` validation handler labelled every failure `invalid-query-parameter` with a 400. That was accurate while the surface was read-only, and wrong as soon as it carried request bodies: a rejected field came back as a query parameter problem. Body errors are now 422 `invalid-request-body`, and query and path errors keep their 400. The keys route did not reject unknown query parameters, so the strictness the list routes have was silently absent on the first write route. It is a route dependency, not something a handler gets for free. Both test apps now install the shared handler instead of a local approximation of it, which is what let the mislabelling pass. Adds a regression test per defect, plus one pinning that `key_id` has a single source: without it a fallback to another field on the row could put the caller's plaintext key in the response. Also swaps the representation's mutable field defaults for immutable ones, and drops two imports the handler rewrite orphaned, both of which the lint budgets were failing on. --- .../management_v1/keys.py | 37 ++++++++++++------- litellm/proxy/proxy_server.py | 2 - .../management_v1/test_budgets.py | 2 - .../management_v1/test_keys.py | 11 ++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 35 +++++++++++++----- 5 files changed, 59 insertions(+), 28 deletions(-) diff --git a/litellm/proxy/management_endpoints/management_v1/keys.py b/litellm/proxy/management_endpoints/management_v1/keys.py index 92a9dcdd642..bb9b9154ab9 100644 --- a/litellm/proxy/management_endpoints/management_v1/keys.py +++ b/litellm/proxy/management_endpoints/management_v1/keys.py @@ -35,6 +35,15 @@ from litellm.types.proxy.management_endpoints.management_v1 import ( 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. @@ -62,20 +71,20 @@ class KeyResource(BaseModel): 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 + 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 diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index dbc6d9f0098..82f5748c37a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -461,7 +461,6 @@ 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, @@ -526,7 +525,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 ( 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 06efd2ec0ab..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,7 +17,6 @@ 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, @@ -27,7 +26,6 @@ from litellm.proxy.management_endpoints.management_v1.list_framework import ( ScopeWhere, build_query_plan, ) -from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail app = FastAPI() 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 index 146118632a7..5825c6e2a21 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_keys.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_keys.py @@ -185,6 +185,17 @@ def test_answers_in_the_item_envelope_without_the_plaintext_secret(key_write, as 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.""" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index dc2993f47be..f77d55d298f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26195,18 +26195,27 @@ export interface components { * display form safe to show in a UI. */ KeyResource: { - /** Access Group Ids */ - access_group_ids?: string[]; + /** + * Access Group Ids + * @default [] + */ + 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[]; + /** + * Allowed Cache Controls + * @default [] + */ + allowed_cache_controls: string[]; + /** + * Allowed Routes + * @default [] + */ + allowed_routes: string[]; /** Auto Rotate */ auto_rotate?: boolean | null; /** Blocked */ @@ -26265,8 +26274,11 @@ export interface components { model_spend?: { [key: string]: components["schemas"]["JsonValue"]; }; - /** Models */ - models?: string[]; + /** + * Models + * @default [] + */ + models: string[]; /** Object Permission Id */ object_permission_id?: string | null; /** Organization Id */ @@ -26275,8 +26287,11 @@ export interface components { permissions?: { [key: string]: components["schemas"]["JsonValue"]; }; - /** Policies */ - policies?: string[]; + /** + * Policies + * @default [] + */ + policies: string[]; /** Project Id */ project_id?: string | null; /** Rotation Count */