mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
789217a8e1
commit
3d00d3ee7c
11 changed files with 960 additions and 542 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
273
litellm/proxy/management_endpoints/management_v1/keys.py
Normal file
273
litellm/proxy/management_endpoints/management_v1/keys.py
Normal 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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
580
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
580
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue