diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 9783f44b41f..b798c7510cc 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -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, diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index d6bf9220551..42093b99004 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index f7b72b263d4..f4b89672bca 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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 diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3b36ea5afce..c71287e4d11 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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, diff --git a/litellm/types/proxy/management_endpoints/key_management_endpoints.py b/litellm/types/proxy/management_endpoints/key_management_endpoints.py index f4348391710..b61f17b012a 100644 --- a/litellm/types/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/key_management_endpoints.py @@ -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""" diff --git a/litellm/types/proxy/management_endpoints/key_requests.py b/litellm/types/proxy/management_endpoints/key_requests.py index 172357d8ac4..398f2c7f982 100644 --- a/litellm/types/proxy/management_endpoints/key_requests.py +++ b/litellm/types/proxy/management_endpoints/key_requests.py @@ -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 diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index 9634f1efce8..96e89ab13b4 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -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}) diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts b/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts index 7c6d5def8da..f084829a582 100644 --- a/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts +++ b/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts @@ -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( diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts b/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts index 311e9357825..1f5f54cfa82 100644 --- a/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts +++ b/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts @@ -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 }), diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 13a8a8b993d..1f50d83bb38 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */