mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
fix(proxy): preserve explicit key expiration through key mutations
This commit is contained in:
parent
969161501a
commit
694b47dbdb
10 changed files with 326 additions and 26 deletions
|
|
@ -338,6 +338,7 @@ async def _sign_in(
|
|||
if os.getenv("DATABASE_URL") is not None:
|
||||
response = await generate_key_helper_fn(
|
||||
llm_router=None,
|
||||
expires=None,
|
||||
request_type="key",
|
||||
**{
|
||||
"user_role": LitellmUserRoles.PROXY_ADMIN,
|
||||
|
|
|
|||
|
|
@ -594,7 +594,7 @@ async def new_user(
|
|||
teams = check_if_default_team_set()
|
||||
organization_ids: Final = cast(list[str] | None, data_json.pop("organizations", None))
|
||||
|
||||
response: Final = await generate_key_helper_fn(request_type="user", **data_json, llm_router=None)
|
||||
response: Final = await generate_key_helper_fn(request_type="user", **data_json, expires=None, llm_router=None)
|
||||
created_user_id: Final = cast(str | None, response.get("user_id", None))
|
||||
if created_user_id is not None:
|
||||
forget_missing_user(created_user_id)
|
||||
|
|
|
|||
|
|
@ -477,6 +477,12 @@ def _generate_budget_windows(
|
|||
)
|
||||
|
||||
|
||||
def _key_expiration(data: GenerateKeyRequest | UpdateKeyRequest, now: datetime) -> datetime | None:
|
||||
if "expires" in data.model_fields_set:
|
||||
return data.expires
|
||||
return now + timedelta(seconds=duration_in_seconds(data.duration)) if data.duration is not None else None
|
||||
|
||||
|
||||
def _effective_key_for_generate(data: GenerateKeyRequest, now: datetime) -> LiteLLM_VerificationToken:
|
||||
requested: Final = _as_object_dict(data.model_dump(exclude_unset=True, exclude_none=True))
|
||||
metadata_fields: Final = MappingProxyType(
|
||||
|
|
@ -494,9 +500,6 @@ def _effective_key_for_generate(data: GenerateKeyRequest, now: datetime) -> Lite
|
|||
if default_allowed_routes is None
|
||||
else {**preset_columns, "allowed_routes": list(default_allowed_routes)}
|
||||
)
|
||||
expires: Final = (
|
||||
now + timedelta(seconds=duration_in_seconds(duration=data.duration)) if data.duration is not None else None
|
||||
)
|
||||
budget_reset_at: Final = (
|
||||
get_budget_reset_time(budget_duration=data.budget_duration) if data.budget_duration is not None else None
|
||||
)
|
||||
|
|
@ -510,7 +513,7 @@ def _effective_key_for_generate(data: GenerateKeyRequest, now: datetime) -> Lite
|
|||
{
|
||||
**columns,
|
||||
"metadata": encrypt_callback_vars(folded_metadata),
|
||||
"expires": expires,
|
||||
"expires": _key_expiration(data, now),
|
||||
"budget_reset_at": budget_reset_at,
|
||||
"key_rotation_at": key_rotation_at,
|
||||
"budget_limits": _generate_budget_windows(data.budget_limits),
|
||||
|
|
@ -1329,9 +1332,23 @@ async def validate_team_id_used_in_service_account_request(
|
|||
return True
|
||||
|
||||
|
||||
def _key_expiration_limit_error(
|
||||
expires: datetime | None, max_duration: str | None, now: datetime, source: str
|
||||
) -> str | None:
|
||||
if max_duration is None:
|
||||
return None
|
||||
if expires is None:
|
||||
return f"expires cannot be null when {source} sets a maximum key duration"
|
||||
if expires > now + timedelta(seconds=duration_in_seconds(max_duration)):
|
||||
return f"expires exceeds the maximum key duration set by {source}: {max_duration}"
|
||||
return None
|
||||
|
||||
|
||||
def _enforce_upperbound_key_params(
|
||||
data: GenerateKeyRequest | UpdateKeyRequest,
|
||||
fill_defaults: bool = True,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Enforce upperbound limits on key parameters.
|
||||
|
|
@ -1362,9 +1379,26 @@ def _enforce_upperbound_key_params(
|
|||
if litellm.upperbound_key_generate_params is None:
|
||||
return
|
||||
|
||||
expiration_error: Final = (
|
||||
_key_expiration_limit_error(
|
||||
data.expires,
|
||||
litellm.upperbound_key_generate_params.duration,
|
||||
now if now is not None else datetime.now(timezone.utc),
|
||||
"upperbound_key_generate_params",
|
||||
)
|
||||
if "expires" in data.model_fields_set
|
||||
else None
|
||||
)
|
||||
if expiration_error is not None:
|
||||
raise HTTPException(status_code=400, detail={"error": expiration_error})
|
||||
|
||||
for elem in data:
|
||||
key, value = elem
|
||||
upperbound_value = getattr(litellm.upperbound_key_generate_params, key, None)
|
||||
upperbound_value = (
|
||||
None
|
||||
if key == "duration" and "expires" in data.model_fields_set
|
||||
else getattr(litellm.upperbound_key_generate_params, key, None)
|
||||
)
|
||||
if upperbound_value is not None:
|
||||
if value is None:
|
||||
if fill_defaults:
|
||||
|
|
@ -1434,6 +1468,8 @@ async def _common_key_generation_helper(
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: str | None,
|
||||
team_table: LiteLLM_TeamTableCachedObj | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> GenerateKeyResponse:
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.proxy_server import (
|
||||
|
|
@ -1519,6 +1555,7 @@ async def _common_key_generation_helper(
|
|||
if (
|
||||
value is None
|
||||
and (key != "budget_duration" or key not in data.model_fields_set)
|
||||
and (key != "duration" or "expires" not in data.model_fields_set)
|
||||
and key
|
||||
in [
|
||||
"max_budget",
|
||||
|
|
@ -1540,7 +1577,8 @@ async def _common_key_generation_helper(
|
|||
setattr(data, key, litellm.default_key_generate_params.get(key, {}))
|
||||
|
||||
# check if user set upperbound key/generate params on config.yaml
|
||||
_enforce_upperbound_key_params(data, fill_defaults=True)
|
||||
generation_time: Final = now if now is not None else datetime.now(timezone.utc)
|
||||
_enforce_upperbound_key_params(data, fill_defaults=True, now=generation_time)
|
||||
|
||||
_enforce_budget_delegation_ceiling(
|
||||
requested_max_budget=_requested_max_budget,
|
||||
|
|
@ -1572,13 +1610,27 @@ async def _common_key_generation_helper(
|
|||
"litellm.proxy.proxy_server.generate_key_fn(): Enterprise key management params not applied - %s", e
|
||||
)
|
||||
|
||||
generation_expires: Final = _key_expiration(data, generation_time)
|
||||
if "expires" in data.model_fields_set and team_table is not None and data.user_id is not None:
|
||||
team_metadata: Final = TypeAdapter[dict[str, object]](dict[str, object]).validate_python(
|
||||
team_table.metadata or {} # pyright: ignore[reportUnknownMemberType] # legacy model declares metadata as bare dict
|
||||
)
|
||||
team_duration: Final = TypeAdapter[str | None](str | None).validate_python(
|
||||
team_metadata.get("team_member_key_duration")
|
||||
)
|
||||
team_expiration_error: Final = _key_expiration_limit_error(
|
||||
generation_expires, team_duration, generation_time, "team_member_key_duration"
|
||||
)
|
||||
if team_expiration_error is not None:
|
||||
raise HTTPException(status_code=400, detail={"error": team_expiration_error})
|
||||
|
||||
await _enforce_custom_key_policy(
|
||||
hook=_custom_key_policy_hook(proxy_server),
|
||||
build_policy_request=lambda: CustomKeyPolicyRequest(
|
||||
operation="generate",
|
||||
existing_key=None,
|
||||
effective_key=_effective_key_for_generate(
|
||||
data=_member_key_request(data, acting_as_team_admin), now=datetime.now(timezone.utc)
|
||||
data=_member_key_request(data, acting_as_team_admin), now=generation_time
|
||||
),
|
||||
request=data,
|
||||
),
|
||||
|
|
@ -1798,7 +1850,12 @@ async def _common_key_generation_helper(
|
|||
|
||||
try:
|
||||
response = await generate_key_helper_fn(
|
||||
request_type="key", **data_json, table_name="key", llm_router=llm_router
|
||||
request_type="key",
|
||||
**{field: value for field, value in data_json.items() if field not in {"duration", "expires"}},
|
||||
duration=None,
|
||||
expires=generation_expires,
|
||||
table_name="key",
|
||||
llm_router=llm_router,
|
||||
)
|
||||
except KeyProjectBindingError:
|
||||
if prisma_client is not None:
|
||||
|
|
@ -2304,6 +2361,7 @@ async def generate_key_fn(
|
|||
|
||||
Parameters:
|
||||
- duration: Optional[str] - Specify the length of time the token is valid for. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
- expires: Optional[datetime] - Absolute expiration with a timezone, overriding duration when supplied. Null requests no expiration, subject to lifetime limits. Past timestamps expire the key immediately.
|
||||
- key_alias: Optional[str] - User defined key alias
|
||||
- key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
|
||||
- team_id: Optional[str] - The team id of the key
|
||||
|
|
@ -2870,13 +2928,13 @@ async def prepare_key_update_data(
|
|||
|
||||
if "duration" in non_default_values:
|
||||
duration: Final = non_default_values.pop("duration")
|
||||
if duration is None or duration == "-1":
|
||||
# Set expires to None to indicate the key never expires
|
||||
non_default_values["expires"] = None
|
||||
elif duration and (isinstance(duration, str)) and len(duration) > 0:
|
||||
duration_s: Final = duration_in_seconds(duration=duration)
|
||||
expires: Final = datetime.now(timezone.utc) + timedelta(seconds=duration_s)
|
||||
non_default_values["expires"] = expires
|
||||
if "expires" not in non_default_values:
|
||||
if duration is None or duration == "-1":
|
||||
non_default_values["expires"] = None
|
||||
elif duration and (isinstance(duration, str)) and len(duration) > 0:
|
||||
duration_s: Final = duration_in_seconds(duration=duration)
|
||||
expires: Final = datetime.now(timezone.utc) + timedelta(seconds=duration_s)
|
||||
non_default_values["expires"] = expires
|
||||
|
||||
if "budget_duration" in non_default_values:
|
||||
budget_duration: Final = non_default_values.pop("budget_duration")
|
||||
|
|
@ -3874,6 +3932,7 @@ async def update_key_fn(
|
|||
- rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
|
||||
- allowed_cache_controls: Optional[list] - List of allowed cache control values
|
||||
- duration: Optional[str] - Key validity duration ("30d", "1h", etc.), null to never expire, or "-1" to never expire (deprecated, use null)
|
||||
- expires: Optional[datetime] - Absolute expiration with a timezone, overriding duration when supplied. Omit to retain expiration, or use null to clear it when lifetime limits allow. Past timestamps expire the key immediately.
|
||||
- permissions: Optional[dict] - Key-specific permissions
|
||||
- send_invite_email: Optional[bool] - Send invite email to user_id
|
||||
- guardrails: Optional[List[str]] - List of active guardrails for the key
|
||||
|
|
@ -5103,6 +5162,7 @@ async def generate_key_helper_fn(
|
|||
access_group_ids: list[str] | None = None,
|
||||
budget_limits: list | None = None, # multiple concurrent budget windows
|
||||
*,
|
||||
expires: datetime | None = None,
|
||||
llm_router: Router | None = None,
|
||||
):
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client
|
||||
|
|
@ -5123,12 +5183,11 @@ async def generate_key_helper_fn(
|
|||
else:
|
||||
token = f"sk-{secrets.token_urlsafe(LENGTH_OF_LITELLM_GENERATED_KEY)}"
|
||||
|
||||
if duration is None: # allow tokens that never expire
|
||||
expires = None
|
||||
else:
|
||||
# Add duration to current time for exact expiration (not standardized reset time)
|
||||
duration_seconds: Final = duration_in_seconds(duration)
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=duration_seconds)
|
||||
effective_expires: Final = (
|
||||
expires
|
||||
if expires is not None or duration is None
|
||||
else datetime.now(timezone.utc) + timedelta(seconds=duration_in_seconds(duration))
|
||||
)
|
||||
|
||||
if key_budget_duration is None: # one-time budget
|
||||
key_reset_at = None
|
||||
|
|
@ -5205,7 +5264,7 @@ async def generate_key_helper_fn(
|
|||
key_data: Final = {
|
||||
"token": token,
|
||||
"key_alias": key_alias,
|
||||
"expires": expires,
|
||||
"expires": effective_expires,
|
||||
"models": models,
|
||||
"aliases": aliases_json,
|
||||
"config": config_json,
|
||||
|
|
@ -6315,6 +6374,7 @@ async def regenerate_key_fn(
|
|||
- model_tpm_limit: Optional[dict] - Model-specific TPM limits {"gpt-4": 100000, "claude-v1": 200000}
|
||||
- allowed_cache_controls: Optional[list] - List of allowed cache control values
|
||||
- duration: Optional[str] - Key validity duration ("30d", "1h", etc.)
|
||||
- expires: Optional[datetime] - Absolute expiration with a timezone, overriding duration when supplied. Omit to retain expiration, or use null to clear it when lifetime limits allow. Past timestamps expire the key immediately.
|
||||
- permissions: Optional[dict] - Key-specific permissions
|
||||
- guardrails: Optional[List[str]] - List of active guardrails for the key
|
||||
- blocked: Optional[bool] - Whether the key is blocked
|
||||
|
|
|
|||
|
|
@ -18942,6 +18942,7 @@ async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str:
|
|||
|
||||
response: Final = await generate_key_helper_fn(
|
||||
llm_router=llm_router,
|
||||
expires=None,
|
||||
request_type="key",
|
||||
**{
|
||||
"user_role": user_obj.user_role,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any, Final, Literal, TypeAlias
|
||||
|
||||
|
|
@ -39,6 +40,14 @@ class BulkUpdateKeyRequestItem(LiteLLMBaseModel):
|
|||
tags: list[str] | None = None # Tags for organizing keys
|
||||
object_permission: LiteLLM_ObjectPermissionBase | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_expiration(cls, values: object) -> object:
|
||||
original: Final[object] = values
|
||||
if "expires" in (values if isinstance(values, Mapping) else {}):
|
||||
raise ValueError("Use /key/update to change expires; /key/bulk_update does not support expiration changes")
|
||||
return original
|
||||
|
||||
|
||||
class BulkUpdateKeyRequest(LiteLLMBaseModel):
|
||||
"""Request for bulk key updates"""
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import enum
|
|||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Literal # noqa: TID251 # allowed_routes and metadata stay Any for existing callers
|
||||
from typing import Annotated, Any, Literal # noqa: TID251 # allowed_routes and metadata stay Any for existing callers
|
||||
|
||||
from pydantic import Field, PositiveInt, field_validator, model_validator
|
||||
from pydantic import AwareDatetime, Field, PositiveInt, field_validator, model_validator
|
||||
from typing_extensions import TypeIs # noqa: TID251 # narrows the raw payload a before-validator receives
|
||||
|
||||
from litellm.types.proxy.management_endpoints.request_base import GenerateRequestBase
|
||||
|
|
@ -55,6 +55,10 @@ class LiteLLMKeyType(str, enum.Enum):
|
|||
|
||||
|
||||
class GenerateKeyRequest(KeyRequestBase):
|
||||
expires: Annotated[datetime, AwareDatetime] | None = Field(
|
||||
default=None,
|
||||
description="Absolute expiration with a timezone. When supplied, overrides duration. Null means no expiration, subject to lifetime limits. Past timestamps expire the key immediately.",
|
||||
)
|
||||
tpm_limit: int | None = Field(default=None, ge=0)
|
||||
rpm_limit: int | None = Field(default=None, ge=0)
|
||||
soft_budget: float | None = None
|
||||
|
|
@ -84,6 +88,10 @@ def _is_mapping(value: object) -> TypeIs[Mapping[object, object]]: # guard-ok:
|
|||
|
||||
|
||||
class UpdateKeyRequest(KeyRequestBase):
|
||||
expires: Annotated[datetime, AwareDatetime] | None = Field(
|
||||
default=None,
|
||||
description="Absolute expiration with a timezone. When supplied, overrides duration. Omit to retain expiration, or use null to clear it when lifetime limits allow. Past timestamps expire the key immediately.",
|
||||
)
|
||||
tpm_limit: int | None = Field(default=None, ge=0)
|
||||
rpm_limit: int | None = Field(default=None, ge=0)
|
||||
# Note: the defaults of all Params here MUST BE NONE
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import pytest
|
|||
import yaml
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import JsonValue
|
||||
from pydantic import JsonValue, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.constants import PROXY_DB_LOOKUP_STALL_WINDOW_SECONDS
|
||||
|
|
@ -101,7 +101,9 @@ from litellm.types.proxy.management_endpoints.key_management_endpoints import (
|
|||
BulkUpdateKeyRequest,
|
||||
BulkUpdateKeyResponse,
|
||||
CustomKeyPolicyRequest,
|
||||
KeyUpdateFields,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import LiteLLM_UpperboundKeyGenerateParams
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
|
@ -23372,3 +23374,185 @@ def test_key_info_database_not_connected_sanitized_500(
|
|||
assert "Database not connected" in caplog.text
|
||||
finally:
|
||||
proxy_server.app.dependency_overrides.pop(user_api_key_auth, None)
|
||||
|
||||
|
||||
_EXPIRATION_NOW: Final = datetime(2030, 1, 1, tzinfo=timezone.utc)
|
||||
_EXPIRATION_OFFSET: Final = "2030-01-01T05:30:00.123456+02:00"
|
||||
_EXPIRATION_INSTANT: Final = datetime.fromisoformat(_EXPIRATION_OFFSET).astimezone(timezone.utc)
|
||||
_EXPIRATION_BOUNDARY: Final = _EXPIRATION_NOW + timedelta(days=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_type", (GenerateKeyRequest, UpdateKeyRequest, RegenerateKeyRequest))
|
||||
@pytest.mark.parametrize("expires", ("not-a-date", "2030-01-01T05:30:00", datetime(2030, 1, 1)))
|
||||
def test_key_expiration_rejects_malformed_or_naive_input(
|
||||
request_type: type[GenerateKeyRequest] | type[UpdateKeyRequest], expires: str | datetime
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError, match="expires"):
|
||||
request_type.model_validate({"key": "sk-expiration", "expires": expires})
|
||||
|
||||
|
||||
def test_key_expiration_response_still_accepts_legacy_naive_datetime() -> None:
|
||||
naive: Final = datetime(2030, 1, 1)
|
||||
response: Final = GenerateKeyResponse(key="sk-expiration", expires=naive)
|
||||
assert response.expires == naive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "default_duration", "member", "expected", "rejected"),
|
||||
(
|
||||
({"expires": _EXPIRATION_OFFSET, "duration": "30d"}, "1d", None, _EXPIRATION_INSTANT, False),
|
||||
({"expires": None, "duration": "30d"}, "1d", None, None, False),
|
||||
({}, "1d", None, _EXPIRATION_BOUNDARY, False),
|
||||
({"duration": "1h"}, None, None, _EXPIRATION_NOW + timedelta(hours=1), False),
|
||||
({}, None, None, None, False),
|
||||
({"expires": _EXPIRATION_NOW - timedelta(seconds=1)}, None, None,
|
||||
_EXPIRATION_NOW - timedelta(seconds=1), False),
|
||||
({"expires": _EXPIRATION_BOUNDARY}, None, True, _EXPIRATION_BOUNDARY, False),
|
||||
({"duration": "2h"}, None, True, _EXPIRATION_BOUNDARY, False),
|
||||
({"expires": _EXPIRATION_BOUNDARY + timedelta(microseconds=1)}, None, True, None, True),
|
||||
({"expires": None}, None, True, None, True),
|
||||
({"expires": None}, None, False, None, False),
|
||||
({"expires": _EXPIRATION_NOW + timedelta(days=2)}, None, False,
|
||||
_EXPIRATION_NOW + timedelta(days=2), False),
|
||||
),
|
||||
ids=("absolute-overrides-duration-and-default", "null-overrides-duration-and-default",
|
||||
"default-duration", "relative-duration", "no-expiration", "already-expired",
|
||||
"member-at-limit", "legacy-member-duration", "member-over-limit", "member-cannot-clear",
|
||||
"service-account-can-clear", "service-account-exempt"),
|
||||
)
|
||||
async def test_key_expiration_generation_policy_storage_and_response_agree(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
payload: dict[str, object],
|
||||
default_duration: str | None,
|
||||
member: bool | None,
|
||||
expected: datetime | None,
|
||||
rejected: bool,
|
||||
) -> None:
|
||||
insert_data: Final = _wire_key_generation_prisma(monkeypatch)
|
||||
policy: Final = AsyncMock(return_value={"decision": True})
|
||||
monkeypatch.setattr(
|
||||
litellm, "default_key_generate_params",
|
||||
{"duration": default_duration} if default_duration is not None else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
litellm, "upperbound_key_generate_params", LiteLLM_UpperboundKeyGenerateParams(max_budget=10)
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_custom_key_policy", policy)
|
||||
team: Final = (
|
||||
LiteLLM_TeamTableCachedObj(team_id="expiry-team", metadata={"team_member_key_duration": "1d"})
|
||||
if member is not None else None
|
||||
)
|
||||
request: Final = GenerateKeyRequest.model_validate(
|
||||
{
|
||||
**payload,
|
||||
"user_id": "expiry-member" if member else None,
|
||||
"team_id": team.team_id if team is not None else None,
|
||||
}
|
||||
)
|
||||
caller: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="expiry-admin")
|
||||
if rejected:
|
||||
with pytest.raises(HTTPException, match="team_member_key_duration") as exc:
|
||||
await _common_key_generation_helper(
|
||||
data=request,
|
||||
user_api_key_dict=caller,
|
||||
litellm_changed_by=None,
|
||||
team_table=team,
|
||||
now=_EXPIRATION_NOW,
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
insert_data.assert_not_awaited()
|
||||
policy.assert_not_awaited()
|
||||
return
|
||||
response: Final = await _common_key_generation_helper(
|
||||
data=request,
|
||||
user_api_key_dict=caller,
|
||||
litellm_changed_by=None,
|
||||
team_table=team,
|
||||
now=_EXPIRATION_NOW,
|
||||
)
|
||||
saved: Final = next(
|
||||
call.kwargs["data"] for call in insert_data.await_args_list if call.kwargs["table_name"] == "key"
|
||||
)
|
||||
policy.assert_awaited_once()
|
||||
policy_request: Final = CustomKeyPolicyRequest.model_validate(policy.await_args.args[0])
|
||||
assert policy_request.effective_key.expires == saved["expires"] == response.expires == expected
|
||||
assert policy_request.effective_key.max_budget == saved["max_budget"] == response.max_budget == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_type", (UpdateKeyRequest, RegenerateKeyRequest))
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected", "writes_expiration"),
|
||||
(
|
||||
({"expires": _EXPIRATION_OFFSET, "duration": "not-a-duration"}, _EXPIRATION_INSTANT, True),
|
||||
({"expires": None, "duration": "1h"}, None, True),
|
||||
({}, _EXPIRATION_BOUNDARY, False),
|
||||
({"duration": None}, None, True),
|
||||
({"duration": ""}, _EXPIRATION_BOUNDARY, False),
|
||||
),
|
||||
ids=("absolute-precedence", "null-precedence", "omitted", "relative-clear", "blank-duration"),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_expiration_update_and_regenerate_preserve_intent(
|
||||
request_type: type[UpdateKeyRequest] | type[RegenerateKeyRequest],
|
||||
payload: dict[str, object],
|
||||
expected: datetime | None,
|
||||
writes_expiration: bool,
|
||||
) -> None:
|
||||
existing: Final = LiteLLM_VerificationToken(token="expiry-token", expires=_EXPIRATION_BOUNDARY)
|
||||
request: Final = request_type.model_validate({"key": "expiry-token", **payload})
|
||||
prepared: Final = await prepare_key_update_data(data=request, existing_key_row=existing)
|
||||
effective: Final = _effective_key_after_update(existing_key_row=existing, non_default_values=prepared)
|
||||
assert ("expires" in prepared) is writes_expiration
|
||||
assert "duration" not in prepared
|
||||
assert effective.expires == expected
|
||||
assert existing.expires == _EXPIRATION_BOUNDARY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_type", (GenerateKeyRequest, UpdateKeyRequest, RegenerateKeyRequest))
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "rejected"),
|
||||
(
|
||||
({"expires": _EXPIRATION_BOUNDARY.astimezone(timezone(timedelta(hours=5, minutes=30))),
|
||||
"duration": "30d"}, False),
|
||||
({"expires": _EXPIRATION_BOUNDARY + timedelta(microseconds=1)}, True),
|
||||
({"expires": None, "duration": "1h"}, True),
|
||||
({}, False),
|
||||
({"duration": None}, False),
|
||||
),
|
||||
ids=("same-instant-boundary-and-precedence", "over-limit", "explicit-clear", "omitted", "relative-clear"),
|
||||
)
|
||||
def test_key_expiration_upperbound_checks_effective_deadline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
request_type: type[GenerateKeyRequest] | type[UpdateKeyRequest],
|
||||
payload: dict[str, object],
|
||||
rejected: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
litellm, "upperbound_key_generate_params", LiteLLM_UpperboundKeyGenerateParams(duration="1d")
|
||||
)
|
||||
request: Final = request_type.model_validate({"key": "sk-expiration", **payload})
|
||||
fill_defaults: Final = request_type is GenerateKeyRequest
|
||||
if rejected:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_enforce_upperbound_key_params(request, fill_defaults=fill_defaults, now=_EXPIRATION_NOW)
|
||||
assert exc.value.status_code == 400
|
||||
return
|
||||
_enforce_upperbound_key_params(request, fill_defaults=fill_defaults, now=_EXPIRATION_NOW)
|
||||
if "expires" in payload:
|
||||
assert request.expires == _EXPIRATION_BOUNDARY
|
||||
elif fill_defaults:
|
||||
assert request.duration == "1d"
|
||||
elif "duration" in payload:
|
||||
assert "duration" in request.model_fields_set
|
||||
assert request.duration is None
|
||||
else:
|
||||
assert "duration" not in request.model_fields_set
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expires", (_EXPIRATION_OFFSET, None))
|
||||
def test_key_expiration_bulk_requests_do_not_silently_discard_input(expires: str | None) -> None:
|
||||
with pytest.raises(ValidationError, match="/key/update"):
|
||||
BulkUpdateKeyRequest.model_validate({"keys": [{"key": "expiry-token", "expires": expires}]})
|
||||
with pytest.raises(ValidationError, match="expires"):
|
||||
KeyUpdateFields.model_validate({"expires": expires})
|
||||
|
|
|
|||
|
|
@ -131,6 +131,23 @@ describe("duration", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("absolute expiration", () => {
|
||||
it.each(["", " "])("omits a cleared expiration from the request (%j)", (expires) => {
|
||||
const payload = payloadOf(build({ key_alias: "my-key", expires }));
|
||||
expect(JSON.parse(JSON.stringify(payload))).toStrictEqual(aliasOnly());
|
||||
});
|
||||
|
||||
it.each(["2030-01-01T10:30:00+05:30", null])("preserves an explicit expiration (%j)", (expires) => {
|
||||
const payload = payloadOf(build({ key_alias: "my-key", expires }));
|
||||
expect(JSON.parse(JSON.stringify(payload))).toStrictEqual(aliasOnly({ expires }));
|
||||
});
|
||||
|
||||
it("omits an untouched expiration without turning it into an explicit null", () => {
|
||||
const payload = payloadOf(build({ key_alias: "my-key", expires: undefined }));
|
||||
expect(JSON.parse(JSON.stringify(payload))).toStrictEqual(aliasOnly());
|
||||
});
|
||||
});
|
||||
|
||||
describe("key ownership", () => {
|
||||
it("overwrites user_id with the signed-in user when the key is owned by you", () => {
|
||||
expect(payloadOf(build({ key_alias: "my-key", user_id: "someone-else" }))).toStrictEqual(
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ export const buildKeyCreatePayload = (input: KeyCreateInput): KeyPayloadResult =
|
|||
endpoint: input.keyOwner === "service_account" ? "service_account" : "standard",
|
||||
payload: {
|
||||
...withoutKeys(values, dropped),
|
||||
...(typeof values.expires === "string" && values.expires.trim() === "" && { expires: undefined }),
|
||||
...(values.organization_id === null && { organization_id: undefined }),
|
||||
...(values.project_id === null && { project_id: undefined }),
|
||||
...(input.keyOwner === "you" && { user_id: input.userID }),
|
||||
|
|
|
|||
19
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
19
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -8451,6 +8451,7 @@ export interface paths {
|
|||
*
|
||||
* Parameters:
|
||||
* - duration: Optional[str] - Specify the length of time the token is valid for. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
* - expires: Optional[datetime] - Absolute expiration with a timezone, overriding duration when supplied. Null requests no expiration, subject to lifetime limits. Past timestamps expire the key immediately.
|
||||
* - key_alias: Optional[str] - User defined key alias
|
||||
* - key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
|
||||
* - team_id: Optional[str] - The team id of the key
|
||||
|
|
@ -8733,6 +8734,7 @@ export interface paths {
|
|||
* - model_tpm_limit: Optional[dict] - Model-specific TPM limits {"gpt-4": 100000, "claude-v1": 200000}
|
||||
* - allowed_cache_controls: Optional[list] - List of allowed cache control values
|
||||
* - duration: Optional[str] - Key validity duration ("30d", "1h", etc.)
|
||||
* - expires: Optional[datetime] - Absolute expiration with a timezone, overriding duration when supplied. Omit to retain expiration, or use null to clear it when lifetime limits allow. Past timestamps expire the key immediately.
|
||||
* - permissions: Optional[dict] - Key-specific permissions
|
||||
* - guardrails: Optional[List[str]] - List of active guardrails for the key
|
||||
* - blocked: Optional[bool] - Whether the key is blocked
|
||||
|
|
@ -8950,6 +8952,7 @@ export interface paths {
|
|||
* - rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
|
||||
* - allowed_cache_controls: Optional[list] - List of allowed cache control values
|
||||
* - duration: Optional[str] - Key validity duration ("30d", "1h", etc.), null to never expire, or "-1" to never expire (deprecated, use null)
|
||||
* - expires: Optional[datetime] - Absolute expiration with a timezone, overriding duration when supplied. Omit to retain expiration, or use null to clear it when lifetime limits allow. Past timestamps expire the key immediately.
|
||||
* - permissions: Optional[dict] - Key-specific permissions
|
||||
* - send_invite_email: Optional[bool] - Send invite email to user_id
|
||||
* - guardrails: Optional[List[str]] - List of active guardrails for the key
|
||||
|
|
@ -9032,6 +9035,7 @@ export interface paths {
|
|||
* - model_tpm_limit: Optional[dict] - Model-specific TPM limits {"gpt-4": 100000, "claude-v1": 200000}
|
||||
* - allowed_cache_controls: Optional[list] - List of allowed cache control values
|
||||
* - duration: Optional[str] - Key validity duration ("30d", "1h", etc.)
|
||||
* - expires: Optional[datetime] - Absolute expiration with a timezone, overriding duration when supplied. Omit to retain expiration, or use null to clear it when lifetime limits allow. Past timestamps expire the key immediately.
|
||||
* - permissions: Optional[dict] - Key-specific permissions
|
||||
* - guardrails: Optional[List[str]] - List of active guardrails for the key
|
||||
* - blocked: Optional[bool] - Whether the key is blocked
|
||||
|
|
@ -32817,6 +32821,11 @@ export interface components {
|
|||
end_user_budget_id?: string | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/**
|
||||
* Expires
|
||||
* @description Absolute expiration with a timezone. When supplied, overrides duration. Null means no expiration, subject to lifetime limits. Past timestamps expire the key immediately.
|
||||
*/
|
||||
expires?: string | null;
|
||||
/** Guardrails */
|
||||
guardrails?: string[] | null;
|
||||
/** Key */
|
||||
|
|
@ -42152,6 +42161,11 @@ export interface components {
|
|||
end_user_budget_id?: string | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/**
|
||||
* Expires
|
||||
* @description Absolute expiration with a timezone. When supplied, overrides duration. Null means no expiration, subject to lifetime limits. Past timestamps expire the key immediately.
|
||||
*/
|
||||
expires?: string | null;
|
||||
/** Grace Period */
|
||||
grace_period?: string | null;
|
||||
/** Guardrails */
|
||||
|
|
@ -48294,6 +48308,11 @@ export interface components {
|
|||
end_user_budget_id?: string | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/**
|
||||
* Expires
|
||||
* @description Absolute expiration with a timezone. When supplied, overrides duration. Omit to retain expiration, or use null to clear it when lifetime limits allow. Past timestamps expire the key immediately.
|
||||
*/
|
||||
expires?: string | null;
|
||||
/** Guardrails */
|
||||
guardrails?: string[] | null;
|
||||
/** Key */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue