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.
This commit is contained in:
Yuneng Jiang 2026-08-17 13:57:31 -07:00
parent 789217a8e1
commit 3d00d3ee7c
No known key found for this signature in database
11 changed files with 960 additions and 542 deletions

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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