This commit is contained in:
yuneng-jiang 2026-08-27 19:59:02 -05:00 committed by GitHub
commit 5c93d7d623
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 994 additions and 31 deletions

View file

@ -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"]

View file

@ -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",

View file

@ -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/<key_id>' \
--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.",
)
)

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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/<key_id>' --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: {