mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge e85d55cf26 into 74050e03c5
This commit is contained in:
commit
5c93d7d623
8 changed files with 994 additions and 31 deletions
|
|
@ -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",
|
||||
|
|
|
|||
282
litellm/proxy/management_endpoints/management_v1/keys.py
Normal file
282
litellm/proxy/management_endpoints/management_v1/keys.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"""`PATCH /management/v1/keys/{key_id}`."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, model_validator
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import (
|
||||
CommonProxyErrors,
|
||||
ProxyException,
|
||||
UpdateKeyRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_get_and_validate_existing_key, # pyright: ignore[reportPrivateUsage] # shared with POST /key/update on purpose, so the two routes cannot drift apart
|
||||
update_key_fn,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
reject_unknown_query_params,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
ItemResponse,
|
||||
ProblemDetail,
|
||||
)
|
||||
|
||||
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
|
||||
|
||||
# A JSON column that the schema declares NOT NULL with a `{}` default, so it is always present on
|
||||
# the wire. `Mapping` keeps it read-only to callers. The factory is unavoidable: pydantic deep-copies
|
||||
# field defaults, and a `MappingProxyType` cannot be deep-copied, so an immutable default raises at
|
||||
# validation time. Declared once here rather than repeated on each of the seven fields that use it.
|
||||
_JsonObject = Annotated[
|
||||
Mapping[str, JsonValue],
|
||||
Field(default_factory=dict), # mutable-ok: pydantic hands each instance its own copy, so no state is shared
|
||||
]
|
||||
|
||||
|
||||
class KeyResource(BaseModel):
|
||||
"""A key as every `/management/v1/keys` operation returns it.
|
||||
|
||||
One representation, shared by list, read, create and update, so a form seeded from any of them
|
||||
holds exactly the fields the server stores. A per-operation projection is what lets a form
|
||||
compute its dirty-field delta against a value the server never sent.
|
||||
|
||||
The plaintext secret is structurally absent rather than filtered: it is not a declared field and
|
||||
extras are ignored, so it cannot appear here however the row was assembled. `key_id` is the
|
||||
hashed token, which is what identifies a key everywhere else, and `key_name` is the masked
|
||||
display form safe to show in a UI.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
key_id: str
|
||||
key_name: str | None = None
|
||||
key_alias: str | None = None
|
||||
key_type: str | None = None
|
||||
user_id: str | None = None
|
||||
team_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
project_id: str | None = None
|
||||
organization_id: str | None = None
|
||||
budget_id: str | None = None
|
||||
object_permission_id: str | None = None
|
||||
models: tuple[str, ...] = ()
|
||||
policies: tuple[str, ...] = ()
|
||||
access_group_ids: tuple[str, ...] = ()
|
||||
allowed_cache_controls: tuple[str, ...] = ()
|
||||
allowed_routes: tuple[str, ...] = ()
|
||||
aliases: _JsonObject
|
||||
config: _JsonObject
|
||||
permissions: _JsonObject
|
||||
metadata: _JsonObject
|
||||
model_spend: _JsonObject
|
||||
model_max_budget: _JsonObject
|
||||
budget_fallbacks: _JsonObject
|
||||
router_settings: Mapping[str, JsonValue] | None = None
|
||||
budget_limits: Mapping[str, JsonValue] | None = None
|
||||
spend: float = 0.0
|
||||
max_budget: float | None = None
|
||||
max_parallel_requests: int | None = None
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
budget_duration: str | None = None
|
||||
budget_reset_at: datetime | None = None
|
||||
blocked: bool | None = None
|
||||
expires: datetime | None = None
|
||||
auto_rotate: bool | None = None
|
||||
rotation_interval: str | None = None
|
||||
rotation_count: int | None = None
|
||||
last_rotation_at: datetime | None = None
|
||||
key_rotation_at: datetime | None = None
|
||||
last_active: datetime | None = None
|
||||
settings_updated_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
created_by: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
updated_by: str | None = None
|
||||
|
||||
|
||||
class KeyPatchRequest(UpdateKeyRequest):
|
||||
"""Body of `PATCH /management/v1/keys/{key_id}`.
|
||||
|
||||
Unknown fields are rejected rather than ignored: on a merge patch the set of fields present
|
||||
*is* the request, so a misspelled field has to fail loudly instead of silently no-op'ing.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key_id: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_key_identifier(self) -> "KeyPatchRequest":
|
||||
"""The path supplies the identifier, and `key_id` is this surface's only spelling of it.
|
||||
|
||||
`key` is the legacy route's spelling, inherited from `UpdateKeyRequest`. Accepting both
|
||||
would put two names for one field on a surface whose whole point is that there is one.
|
||||
"""
|
||||
if self.key is not None:
|
||||
raise ValueError("`key` is not a field on this resource; the identifier is `key_id`, taken from the path")
|
||||
return self
|
||||
|
||||
|
||||
_KEY_RESOURCE: Final = TypeAdapter(KeyResource)
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
# `object`, not `JsonValue`: a database row carries datetimes, which JsonValue does not admit.
|
||||
_ROW: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
_PROXY_ERROR_PROBLEMS: Final[Mapping[int, tuple[str, str]]] = MappingProxyType(
|
||||
{ # mutable-ok: an immutable mapping has no literal form; MappingProxyType freezes this one and it never escapes
|
||||
400: ("bad-request", "Bad request"),
|
||||
401: ("unauthorized", "Unauthorized"),
|
||||
403: ("forbidden", "Forbidden"),
|
||||
404: ("key-not-found", "Key not found"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _problem(slug: str, title: str, status_code: int, detail: str) -> ProblemDetail:
|
||||
return ProblemDetail(type=f"{PROBLEM_TYPE_BASE}{slug}", title=title, status=status_code, detail=detail)
|
||||
|
||||
|
||||
def _problem_from_proxy_exception(exc: ProxyException) -> ProblemDetail:
|
||||
"""Translate the legacy write path's OpenAI-shaped error into a problem document.
|
||||
|
||||
The write core is shared with `POST /key/update`, which must keep raising `ProxyException`, so
|
||||
the translation happens here rather than by changing what that core raises.
|
||||
"""
|
||||
code: Final = str(exc.code)
|
||||
status_code: Final = int(code) if code.isdigit() else 400
|
||||
slug, title = _PROXY_ERROR_PROBLEMS.get(status_code, ("key-update-failed", "Key update failed"))
|
||||
return _problem(slug=slug, title=title, status_code=status_code, detail=exc.message)
|
||||
|
||||
|
||||
def to_key_resource(row: Mapping[str, object]) -> KeyResource:
|
||||
"""`key_id` comes from the row's own hashed token, never from the path.
|
||||
|
||||
A caller may address a key by its plaintext secret, and echoing the path value back would put
|
||||
that secret in the response body.
|
||||
"""
|
||||
return _KEY_RESOURCE.validate_python({**row, "key_id": row.get("token")})
|
||||
|
||||
|
||||
async def _merge_key_metadata(
|
||||
key_id: str,
|
||||
prisma_client: PrismaClient | None,
|
||||
metadata_patch: JsonValue,
|
||||
) -> JsonValue:
|
||||
"""Deep-merge a metadata patch onto the key's stored metadata, per RFC 7396."""
|
||||
existing_key_row: Final = await _get_and_validate_existing_key(token=key_id, prisma_client=prisma_client)
|
||||
existing_metadata: Final = _JSON_OBJECT.validate_python(
|
||||
existing_key_row.metadata or {} # pyright: ignore[reportUnknownMemberType] # unannotated on the row model; the validate_python call around it is what types it
|
||||
)
|
||||
return apply_json_merge_patch(existing_metadata, metadata_patch)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/keys/{key_id}",
|
||||
tags=["key management"],
|
||||
dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)],
|
||||
response_model=ItemResponse[KeyResource],
|
||||
)
|
||||
async def patch_key(
|
||||
key_id: str,
|
||||
data: KeyPatchRequest,
|
||||
request: Request,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
litellm_changed_by: Annotated[
|
||||
str | None,
|
||||
Header(
|
||||
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
|
||||
),
|
||||
] = None,
|
||||
) -> ItemResponse[KeyResource]:
|
||||
"""
|
||||
Partially update a key, using RFC 7396 JSON Merge Patch semantics.
|
||||
|
||||
`key_id` is taken from the path; a `key_id` in the body is accepted only when it matches.
|
||||
Omitting a field preserves it, `null` clears it, and any other value overwrites it. `metadata`
|
||||
merges rather than replacing: an omitted entry is preserved, `entry: null` deletes it, and a
|
||||
nested object recurses. Arrays replace wholesale, which RFC 7396 is explicit about. An unknown
|
||||
field is a 422 rather than a silent no-op.
|
||||
|
||||
Answers with the full key under `data`, the same representation every other keys operation
|
||||
serves. The key's plaintext secret is never in that representation.
|
||||
|
||||
```
|
||||
curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/keys/<key_id>' \
|
||||
--header 'Authorization: Bearer sk-1234' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"metadata": {"cost_center": "1234", "deprecated_entry": null}
|
||||
}'
|
||||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise ManagementProblem(
|
||||
_problem(
|
||||
slug="database-not-connected",
|
||||
title="Database not connected",
|
||||
status_code=503,
|
||||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
)
|
||||
|
||||
if data.key_id is not None and data.key_id != key_id:
|
||||
raise ManagementProblem(
|
||||
_problem(
|
||||
slug="identifier-mismatch",
|
||||
title="Identifier mismatch",
|
||||
status_code=400,
|
||||
detail="`key_id` in the body does not match the `key_id` in the path.",
|
||||
)
|
||||
)
|
||||
|
||||
patch_fields: Final = _JSON_OBJECT.validate_python(
|
||||
data.model_dump(exclude_unset=True, exclude={"key_id", "key"}, mode="json")
|
||||
)
|
||||
merged_fields: Final = (
|
||||
{**patch_fields, "metadata": await _merge_key_metadata(key_id, prisma_client, patch_fields["metadata"])}
|
||||
if "metadata" in patch_fields
|
||||
else patch_fields
|
||||
)
|
||||
|
||||
updated: Final = _ROW.validate_python(
|
||||
await update_key_fn(
|
||||
request=request,
|
||||
data=UpdateKeyRequest.model_validate({"key": key_id, **merged_fields}),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
)
|
||||
return ItemResponse(data=to_key_resource(updated))
|
||||
|
||||
except ManagementProblem:
|
||||
raise
|
||||
except ProxyException as e:
|
||||
raise ManagementProblem(_problem_from_proxy_exception(e))
|
||||
except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
|
||||
verbose_proxy_logger.exception(
|
||||
"litellm.proxy.management_endpoints.management_v1.keys.patch_key(): Exception occured - %s", e
|
||||
)
|
||||
raise ManagementProblem(
|
||||
_problem(
|
||||
slug="internal-server-error",
|
||||
title="Internal server error",
|
||||
status_code=500,
|
||||
detail="Failed to update key.",
|
||||
)
|
||||
)
|
||||
|
|
@ -489,9 +489,10 @@ from litellm.proxy.management_endpoints.management_v1 import (
|
|||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
ValidationErrorDetail,
|
||||
problem_response,
|
||||
validation_problem,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
router as model_access_group_management_router,
|
||||
|
|
@ -552,7 +553,6 @@ from litellm.proxy.plugin_routes import (
|
|||
from litellm.proxy.plugin_routes import (
|
||||
router as plugin_router,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
|
||||
try:
|
||||
from litellm.proxy.enterprise_billing.billing_metrics import (
|
||||
|
|
@ -1693,27 +1693,13 @@ class _ExceptionRow(TypedDict, total=False):
|
|||
exception_counts: Mapping[str, int]
|
||||
|
||||
|
||||
class _ValidationErrorDetail(TypedDict):
|
||||
loc: tuple[int | str, ...]
|
||||
msg: str
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
if request.url.path.startswith(MANAGEMENT_V1_PREFIX):
|
||||
_close_dangling_otel_server_span(request, 400, exc=exc)
|
||||
validation_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors()
|
||||
return problem_response(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter",
|
||||
title="Invalid query parameter",
|
||||
status=400,
|
||||
detail="; ".join(
|
||||
f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors
|
||||
)
|
||||
or "The request query parameters are invalid.",
|
||||
)
|
||||
)
|
||||
validation_errors: Final[Sequence[ValidationErrorDetail]] = exc.errors()
|
||||
problem: Final = validation_problem(validation_errors)
|
||||
_close_dangling_otel_server_span(request, problem.status, exc=exc)
|
||||
return problem_response(problem)
|
||||
_close_dangling_otel_server_span(request, 422, exc=exc)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,16 +17,15 @@ from litellm.proxy.management_endpoints.management_v1.budgets import (
|
|||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
validation_problem,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.list_framework import (
|
||||
Compare,
|
||||
ScopeWhere,
|
||||
build_query_plan,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
|
@ -38,14 +37,8 @@ async def management_problem_exception_handler(request: Request, exc: Management
|
|||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
return problem_response(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter",
|
||||
title="Invalid query parameter",
|
||||
status=400,
|
||||
detail="The request query parameters are invalid.",
|
||||
)
|
||||
)
|
||||
"""The same translation `proxy_server` installs, rather than a local approximation of it."""
|
||||
return problem_response(validation_problem(exc.errors()))
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_VerificationToken,
|
||||
LitellmUserRoles,
|
||||
UpdateKeyRequest,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.management_v1 import router
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
validation_problem,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.exception_handler(ManagementProblem)
|
||||
async def management_problem_exception_handler(request: Request, exc: ManagementProblem):
|
||||
return problem_response(exc.problem)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""The same translation `proxy_server` installs. Registering only the `ManagementProblem`
|
||||
handler here would let FastAPI's default 422 stand in for the real one, and the tests would
|
||||
pass against a status code production never returns."""
|
||||
return problem_response(validation_problem(exc.errors()))
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
KEYS_PATH = f"{MANAGEMENT_V1_PREFIX}/keys"
|
||||
HASHED_TOKEN = "a1b2c3d4" * 8
|
||||
PLAINTEXT_KEY = "sk-plaintext-secret-value"
|
||||
|
||||
|
||||
def _row(**overrides: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"token": HASHED_TOKEN,
|
||||
"key_name": "sk-...alue",
|
||||
"key_alias": "reporting",
|
||||
"user_id": "test-user",
|
||||
"spend": 0.0,
|
||||
"models": [],
|
||||
"metadata": {},
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_write(monkeypatch):
|
||||
"""Mocks the write path and hands back the prisma mock, so a test can assert on the
|
||||
exact dict handed to `update_data` as well as on the HTTP response."""
|
||||
prisma_client = AsyncMock()
|
||||
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user")
|
||||
)
|
||||
prisma_client.update_data = AsyncMock(return_value={"data": _row()})
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
monkeypatch.setattr("litellm.store_audit_logs", False)
|
||||
return prisma_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def as_proxy_admin():
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _patch(body: dict[str, Any], key_id: str = HASHED_TOKEN):
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new=AsyncMock(),
|
||||
):
|
||||
return client.patch(f"{KEYS_PATH}/{key_id}", json=body, headers={"Authorization": "Bearer k"})
|
||||
|
||||
|
||||
async def _drive_post(monkeypatch, existing_metadata: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drive the legacy POST write core against the same mocked row, and return what it wrote."""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import update_key_fn
|
||||
|
||||
prisma_client = AsyncMock()
|
||||
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user", metadata=existing_metadata)
|
||||
)
|
||||
prisma_client.update_data = AsyncMock(return_value={"data": _row()})
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
monkeypatch.setattr("litellm.store_audit_logs", False)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new=AsyncMock(),
|
||||
):
|
||||
await update_key_fn(
|
||||
request=MagicMock(),
|
||||
data=UpdateKeyRequest(key=HASHED_TOKEN, **body),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user"
|
||||
),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
return prisma_client.update_data.call_args.kwargs["data"]
|
||||
|
||||
|
||||
# (label, stored_metadata, patch_body, what POST writes, what PATCH writes)
|
||||
_METADATA_MAPPING = [
|
||||
(
|
||||
"sibling entries survive a merge patch but not a POST",
|
||||
{"cost_center": "cc-1", "owner": "data-eng"},
|
||||
{"cost_center": "cc-2"},
|
||||
{"cost_center": "cc-2"},
|
||||
{"cost_center": "cc-2", "owner": "data-eng"},
|
||||
),
|
||||
(
|
||||
"a nested object recurses instead of being replaced",
|
||||
{"nested": {"a": 1, "b": 2}, "owner": "data-eng"},
|
||||
{"nested": {"b": 99}},
|
||||
{"nested": {"b": 99}},
|
||||
{"nested": {"a": 1, "b": 99}, "owner": "data-eng"},
|
||||
),
|
||||
(
|
||||
"null deletes only its own entry",
|
||||
{"cost_center": "cc-1", "owner": "data-eng"},
|
||||
{"cost_center": None},
|
||||
{"cost_center": None},
|
||||
{"owner": "data-eng"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"label,stored,body,expected_post,expected_patch",
|
||||
_METADATA_MAPPING,
|
||||
ids=[row[0] for row in _METADATA_MAPPING],
|
||||
)
|
||||
async def test_metadata_merges_where_the_legacy_post_replaces(
|
||||
monkeypatch, key_write, as_proxy_admin, label, stored, body, expected_post, expected_patch
|
||||
):
|
||||
"""The one sanctioned divergence from `POST /key/update`, which writes the submitted
|
||||
metadata verbatim and so drops every entry the caller did not resend."""
|
||||
written_post = await _drive_post(monkeypatch, stored, {"metadata": body})
|
||||
assert written_post["metadata"] == expected_post
|
||||
|
||||
key_write.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user", metadata=stored)
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", key_write)
|
||||
|
||||
assert _patch({"metadata": body}).status_code == 200
|
||||
assert key_write.update_data.call_args.kwargs["data"]["metadata"] == expected_patch
|
||||
|
||||
|
||||
def test_answers_in_the_item_envelope_without_the_plaintext_secret(key_write, as_proxy_admin):
|
||||
"""`{"data": {...}}`, and `key_id` is the row's hashed token even when the caller addressed
|
||||
the key by its plaintext secret, which must not come back in the body."""
|
||||
response = _patch({"tpm_limit": 77}, key_id=PLAINTEXT_KEY)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert set(body) == {"data"}
|
||||
assert body["data"]["key_id"] == HASHED_TOKEN
|
||||
assert PLAINTEXT_KEY not in response.text
|
||||
assert "key" not in body["data"]
|
||||
|
||||
|
||||
def test_a_row_without_its_own_id_fails_rather_than_falling_back(key_write, as_proxy_admin):
|
||||
"""`key_id` has exactly one source, the row's hashed token. Without this, a fallback to any
|
||||
other field on the row would quietly put the caller's plaintext secret in the response."""
|
||||
key_write.update_data = AsyncMock(return_value={"data": {k: v for k, v in _row().items() if k != "token"}})
|
||||
|
||||
response = _patch({"tpm_limit": 1}, key_id=PLAINTEXT_KEY)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert PLAINTEXT_KEY not in response.text
|
||||
|
||||
|
||||
def test_null_clears_and_omission_preserves(key_write, as_proxy_admin):
|
||||
"""Both directions in one test: a route that cleared everything would pass a clear-only
|
||||
assertion, and a route that cleared nothing would pass a preserve-only one."""
|
||||
assert _patch({"tpm_limit": None}).status_code == 200
|
||||
cleared = key_write.update_data.call_args.kwargs["data"]
|
||||
assert "tpm_limit" in cleared and cleared["tpm_limit"] is None
|
||||
|
||||
assert _patch({"rpm_limit": 9}).status_code == 200
|
||||
preserved = key_write.update_data.call_args.kwargs["data"]
|
||||
assert "tpm_limit" not in preserved
|
||||
assert preserved["rpm_limit"] == 9
|
||||
|
||||
|
||||
def test_does_not_slide_the_budget_window(key_write, as_proxy_admin):
|
||||
"""A merge patch is idempotent, so a patch that never mentions `budget_duration` must leave
|
||||
`budget_reset_at` alone rather than postponing the key's reset on every save."""
|
||||
key_write.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_VerificationToken(token=HASHED_TOKEN, user_id="test-user", budget_duration="30d")
|
||||
)
|
||||
|
||||
assert _patch({"rpm_limit": 5}).status_code == 200
|
||||
|
||||
written = key_write.update_data.call_args.kwargs["data"]
|
||||
assert written["rpm_limit"] == 5
|
||||
assert "budget_reset_at" not in written
|
||||
assert "budget_duration" not in written
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body,reason",
|
||||
[
|
||||
({"tpm_limitt": 5}, "a misspelled field"),
|
||||
({"key": HASHED_TOKEN}, "the legacy `key` spelling of the identifier"),
|
||||
],
|
||||
ids=["misspelled field", "legacy key spelling"],
|
||||
)
|
||||
def test_rejects_bodies_that_would_otherwise_no_op(key_write, as_proxy_admin, body, reason):
|
||||
"""On a merge patch the set of fields present IS the request, so anything unrecognized has to
|
||||
fail loudly rather than silently changing nothing the way the legacy POST does.
|
||||
|
||||
422 and `invalid-request-body`, not the 400 `invalid-query-parameter` a body error got before
|
||||
this surface had bodies to validate."""
|
||||
response = _patch(body)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["type"] == "urn:litellm:error:invalid-request-body"
|
||||
key_write.update_data.assert_not_called()
|
||||
|
||||
|
||||
def test_rejects_an_unknown_query_parameter(key_write, as_proxy_admin):
|
||||
"""The strictness the list surface already has, which a write route does not get for free:
|
||||
the guard is a route dependency, and omitting it silently accepts the parameter."""
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new=AsyncMock(),
|
||||
):
|
||||
response = client.patch(
|
||||
f"{KEYS_PATH}/{HASHED_TOKEN}?bogus=1", json={"tpm_limit": 1}, headers={"Authorization": "Bearer k"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["type"] == "urn:litellm:error:unknown-query-parameter"
|
||||
key_write.update_data.assert_not_called()
|
||||
|
||||
|
||||
def test_identifier_mismatch_is_a_problem_document(key_write, as_proxy_admin):
|
||||
"""The path is authoritative, and the refusal must not echo either identifier back."""
|
||||
response = _patch({"key_id": "a-different-key", "tpm_limit": 1})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
problem = response.json()
|
||||
assert problem["type"] == "urn:litellm:error:identifier-mismatch"
|
||||
assert "a-different-key" not in response.text
|
||||
assert HASHED_TOKEN not in response.text
|
||||
key_write.update_data.assert_not_called()
|
||||
|
||||
|
||||
def test_a_missing_key_is_a_problem_document(key_write, as_proxy_admin):
|
||||
"""The legacy write core raises the OpenAI error shape; this surface answers RFC 9457."""
|
||||
key_write.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
response = _patch({"tpm_limit": 1})
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["type"] == "urn:litellm:error:key-not-found"
|
||||
key_write.update_data.assert_not_called()
|
||||
371
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
371
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -8057,6 +8057,41 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/management/v1/keys/{key_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Patch Key
|
||||
* @description Partially update a key, using RFC 7396 JSON Merge Patch semantics.
|
||||
*
|
||||
* `key_id` is taken from the path; a `key_id` in the body is accepted only when it matches.
|
||||
* Omitting a field preserves it, `null` clears it, and any other value overwrites it. `metadata`
|
||||
* merges rather than replacing: an omitted entry is preserved, `entry: null` deletes it, and a
|
||||
* nested object recurses. Arrays replace wholesale, which RFC 7396 is explicit about. An unknown
|
||||
* field is a 422 rather than a silent no-op.
|
||||
*
|
||||
* Answers with the full key under `data`, the same representation every other keys operation
|
||||
* serves. The key's plaintext secret is never in that representation.
|
||||
*
|
||||
* ```
|
||||
* curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/keys/<key_id>' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data-raw '{
|
||||
* "metadata": {"cost_center": "1234", "deprecated_entry": null}
|
||||
* }'
|
||||
* ```
|
||||
*/
|
||||
patch: operations["patch_key_management_v1_keys__key_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/management/v1/spend_logs/end_users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -27164,6 +27199,10 @@ export interface components {
|
|||
/** Is Accepted */
|
||||
is_accepted: boolean;
|
||||
};
|
||||
/** ItemResponse[KeyResource] */
|
||||
ItemResponse_KeyResource_: {
|
||||
data: components["schemas"]["KeyResource"];
|
||||
};
|
||||
/** JWTKeyMappingResponse */
|
||||
JWTKeyMappingResponse: {
|
||||
/**
|
||||
|
|
@ -27191,6 +27230,7 @@ export interface components {
|
|||
/** Updated By */
|
||||
updated_by?: string | null;
|
||||
};
|
||||
JsonValue: unknown;
|
||||
/** KeyHealthResponse */
|
||||
KeyHealthResponse: {
|
||||
/**
|
||||
|
|
@ -27240,6 +27280,158 @@ export interface components {
|
|||
metadata?: components["schemas"]["KeyMetadata"];
|
||||
metrics: components["schemas"]["SpendMetrics"];
|
||||
};
|
||||
/**
|
||||
* KeyPatchRequest
|
||||
* @description Body of `PATCH /management/v1/keys/{key_id}`.
|
||||
*
|
||||
* Unknown fields are rejected rather than ignored: on a merge patch the set of fields present
|
||||
* *is* the request, so a misspelled field has to fail loudly instead of silently no-op'ing.
|
||||
*/
|
||||
KeyPatchRequest: {
|
||||
/** Access Group Ids */
|
||||
access_group_ids?: string[] | null;
|
||||
/** Agent Id */
|
||||
agent_id?: string | null;
|
||||
/**
|
||||
* Aliases
|
||||
* @default {}
|
||||
*/
|
||||
aliases: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Allowed Cache Controls
|
||||
* @default []
|
||||
*/
|
||||
allowed_cache_controls: unknown[] | null;
|
||||
/** Allowed Passthrough Routes */
|
||||
allowed_passthrough_routes?: unknown[] | null;
|
||||
/**
|
||||
* Allowed Routes
|
||||
* @default []
|
||||
*/
|
||||
allowed_routes: unknown[] | null;
|
||||
/** Allowed Vector Store Indexes */
|
||||
allowed_vector_store_indexes?: components["schemas"]["AllowedVectorStoreIndexItem"][] | null;
|
||||
/** Auto Rotate */
|
||||
auto_rotate?: boolean | null;
|
||||
/** Blocked */
|
||||
blocked?: boolean | null;
|
||||
/** Budget Duration */
|
||||
budget_duration?: string | null;
|
||||
/** Budget Fallbacks */
|
||||
budget_fallbacks?: {
|
||||
[key: string]: string[];
|
||||
} | null;
|
||||
/** Budget Id */
|
||||
budget_id?: string | null;
|
||||
/** Budget Limits */
|
||||
budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null;
|
||||
/**
|
||||
* Config
|
||||
* @default {}
|
||||
*/
|
||||
config: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Default Estimated Output Tokens */
|
||||
default_estimated_output_tokens?: number | null;
|
||||
/** Default Estimated Output Tokens Per Model */
|
||||
default_estimated_output_tokens_per_model?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
duration?: string | null;
|
||||
/** Enable Prompt Caching */
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/** Guardrails */
|
||||
guardrails?: string[] | null;
|
||||
/** Key */
|
||||
key?: string | null;
|
||||
/** Key Alias */
|
||||
key_alias?: string | null;
|
||||
/** Key Id */
|
||||
key_id?: string | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
max_parallel_requests?: number | null;
|
||||
/** Mcp Rpm Limit */
|
||||
mcp_rpm_limit?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Metadata */
|
||||
metadata?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Model Max Budget
|
||||
* @default {}
|
||||
*/
|
||||
model_max_budget: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Model Rpm Limit */
|
||||
model_rpm_limit?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Model Tpm Limit */
|
||||
model_tpm_limit?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Models
|
||||
* @default []
|
||||
*/
|
||||
models: unknown[] | null;
|
||||
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
|
||||
/** Organization Id */
|
||||
organization_id?: string | null;
|
||||
/**
|
||||
* Permissions
|
||||
* @default {}
|
||||
*/
|
||||
permissions: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Policies */
|
||||
policies?: string[] | null;
|
||||
/** Prompts */
|
||||
prompts?: string[] | null;
|
||||
/** Rotation Interval */
|
||||
rotation_interval?: string | null;
|
||||
router_settings?: components["schemas"]["UpdateRouterConfig"] | null;
|
||||
/** Rpm Limit */
|
||||
rpm_limit?: number | null;
|
||||
/** Rpm Limit Type */
|
||||
rpm_limit_type?: ("guaranteed_throughput" | "best_effort_throughput" | "dynamic") | null;
|
||||
/** Spend */
|
||||
spend?: number | null;
|
||||
/** Tag Rpm Limit */
|
||||
tag_rpm_limit?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Tags */
|
||||
tags?: string[] | null;
|
||||
/** Team Id */
|
||||
team_id?: string | null;
|
||||
/** Temp Budget Expiry */
|
||||
temp_budget_expiry?: string | null;
|
||||
/** Temp Budget Increase */
|
||||
temp_budget_increase?: number | null;
|
||||
/** Throttle On Budget Exceeded */
|
||||
throttle_on_budget_exceeded?: boolean | null;
|
||||
/** Tpm Limit */
|
||||
tpm_limit?: number | null;
|
||||
/** Tpm Limit Type */
|
||||
tpm_limit_type?: ("guaranteed_throughput" | "best_effort_throughput" | "dynamic") | null;
|
||||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/** KeyRequest */
|
||||
KeyRequest: {
|
||||
/** Key Aliases */
|
||||
|
|
@ -27247,6 +27439,147 @@ export interface components {
|
|||
/** Keys */
|
||||
keys?: string[] | null;
|
||||
};
|
||||
/**
|
||||
* KeyResource
|
||||
* @description A key as every `/management/v1/keys` operation returns it.
|
||||
*
|
||||
* One representation, shared by list, read, create and update, so a form seeded from any of them
|
||||
* holds exactly the fields the server stores. A per-operation projection is what lets a form
|
||||
* compute its dirty-field delta against a value the server never sent.
|
||||
*
|
||||
* The plaintext secret is structurally absent rather than filtered: it is not a declared field and
|
||||
* extras are ignored, so it cannot appear here however the row was assembled. `key_id` is the
|
||||
* hashed token, which is what identifies a key everywhere else, and `key_name` is the masked
|
||||
* display form safe to show in a UI.
|
||||
*/
|
||||
KeyResource: {
|
||||
/**
|
||||
* Access Group Ids
|
||||
* @default []
|
||||
*/
|
||||
access_group_ids: string[];
|
||||
/** Agent Id */
|
||||
agent_id?: string | null;
|
||||
/** Aliases */
|
||||
aliases?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
};
|
||||
/**
|
||||
* Allowed Cache Controls
|
||||
* @default []
|
||||
*/
|
||||
allowed_cache_controls: string[];
|
||||
/**
|
||||
* Allowed Routes
|
||||
* @default []
|
||||
*/
|
||||
allowed_routes: string[];
|
||||
/** Auto Rotate */
|
||||
auto_rotate?: boolean | null;
|
||||
/** Blocked */
|
||||
blocked?: boolean | null;
|
||||
/** Budget Duration */
|
||||
budget_duration?: string | null;
|
||||
/** Budget Fallbacks */
|
||||
budget_fallbacks?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
};
|
||||
/** Budget Id */
|
||||
budget_id?: string | null;
|
||||
/** Budget Limits */
|
||||
budget_limits?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
} | null;
|
||||
/** Budget Reset At */
|
||||
budget_reset_at?: string | null;
|
||||
/** Config */
|
||||
config?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
};
|
||||
/** Created At */
|
||||
created_at?: string | null;
|
||||
/** Created By */
|
||||
created_by?: string | null;
|
||||
/** Expires */
|
||||
expires?: string | null;
|
||||
/** Key Alias */
|
||||
key_alias?: string | null;
|
||||
/** Key Id */
|
||||
key_id: string;
|
||||
/** Key Name */
|
||||
key_name?: string | null;
|
||||
/** Key Rotation At */
|
||||
key_rotation_at?: string | null;
|
||||
/** Key Type */
|
||||
key_type?: string | null;
|
||||
/** Last Active */
|
||||
last_active?: string | null;
|
||||
/** Last Rotation At */
|
||||
last_rotation_at?: string | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
max_parallel_requests?: number | null;
|
||||
/** Metadata */
|
||||
metadata?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
};
|
||||
/** Model Max Budget */
|
||||
model_max_budget?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
};
|
||||
/** Model Spend */
|
||||
model_spend?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
};
|
||||
/**
|
||||
* Models
|
||||
* @default []
|
||||
*/
|
||||
models: string[];
|
||||
/** Object Permission Id */
|
||||
object_permission_id?: string | null;
|
||||
/** Organization Id */
|
||||
organization_id?: string | null;
|
||||
/** Permissions */
|
||||
permissions?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
};
|
||||
/**
|
||||
* Policies
|
||||
* @default []
|
||||
*/
|
||||
policies: string[];
|
||||
/** Project Id */
|
||||
project_id?: string | null;
|
||||
/** Rotation Count */
|
||||
rotation_count?: number | null;
|
||||
/** Rotation Interval */
|
||||
rotation_interval?: string | null;
|
||||
/** Router Settings */
|
||||
router_settings?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
} | null;
|
||||
/** Rpm Limit */
|
||||
rpm_limit?: number | null;
|
||||
/** Settings Updated At */
|
||||
settings_updated_at?: string | null;
|
||||
/**
|
||||
* Spend
|
||||
* @default 0
|
||||
*/
|
||||
spend: number;
|
||||
/** Team Id */
|
||||
team_id?: string | null;
|
||||
/** Tpm Limit */
|
||||
tpm_limit?: number | null;
|
||||
/** Updated At */
|
||||
updated_at?: string | null;
|
||||
/** Updated By */
|
||||
updated_by?: string | null;
|
||||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* KeyUpdateFields
|
||||
* @description Allowlist of bulk-broadcastable fields for /team/key/bulk_update; `extra="forbid"` blocks RBAC/ownership/scope mutations even by team admins.
|
||||
|
|
@ -48528,6 +48861,44 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
patch_key_management_v1_keys__key_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */
|
||||
"litellm-changed-by"?: string | null;
|
||||
};
|
||||
path: {
|
||||
key_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["KeyPatchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ItemResponse_KeyResource_"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_spend_log_end_users_management_v1_spend_logs_end_users_get: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue