diff --git a/litellm/types/proxy/management_endpoints/key_requests.py b/litellm/types/proxy/management_endpoints/key_requests.py index baa02b68d98..58b5951dd3b 100644 --- a/litellm/types/proxy/management_endpoints/key_requests.py +++ b/litellm/types/proxy/management_endpoints/key_requests.py @@ -54,6 +54,16 @@ class LiteLLMKeyType(str, enum.Enum): DEFAULT = "default" # Uses default allowed routes +def _is_mapping(value: object) -> TypeIs[Mapping[object, object]]: # guard-ok: raw before-validator payload + return isinstance(value, Mapping) + + +def _reject_expires(values: object) -> object: + if _is_mapping(values) and "expires" in values: + raise ValueError('expires is not supported on key requests. Set duration instead, e.g. "3600s" or "30d"') + return values + + class GenerateKeyRequest(KeyRequestBase): soft_budget: float | None = None send_invite_email: bool | None = None @@ -69,6 +79,11 @@ class GenerateKeyRequest(KeyRequestBase): organization_id: str | None = None project_id: str | None = None + @model_validator(mode="before") + @classmethod + def reject_expires(cls, values: object) -> object: + return _reject_expires(values) + @field_validator("team_id", "organization_id", "project_id", mode="before") @classmethod def treat_cleared_id_as_unset(cls, v: object) -> object: @@ -77,10 +92,6 @@ class GenerateKeyRequest(KeyRequestBase): return v -def _is_mapping(value: object) -> TypeIs[Mapping[object, object]]: # guard-ok: raw before-validator payload - return isinstance(value, Mapping) - - class UpdateKeyRequest(KeyRequestBase): # Note: the defaults of all Params here MUST BE NONE # else they will get overwritten @@ -99,6 +110,11 @@ class UpdateKeyRequest(KeyRequestBase): description="Omit to retain the project, or send null to detach. Assigning a different project is not supported.", ) + @model_validator(mode="before") + @classmethod + def reject_expires(cls, values: object) -> object: + return _reject_expires(values) + @model_validator(mode="before") @classmethod def drop_blank_team_id(cls, values: object) -> object: 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 d6ee9b1d29e..28118cd96c1 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -15,7 +15,7 @@ from fastapi.testclient import TestClient from unittest.mock import AsyncMock, MagicMock, patch from fastapi import HTTPException -from pydantic import JsonValue +from pydantic import JsonValue, ValidationError import inspect @@ -108,6 +108,28 @@ from litellm.types.proxy.management_endpoints.key_management_endpoints import ( client = TestClient(app) +def test_key_request_models_reject_expires() -> None: + for request_model, request_values in ( + (GenerateKeyRequest, {"expires": "2026-01-01T00:00:00Z"}), + (UpdateKeyRequest, {"key": "sk-x", "expires": "2026-01-01T00:00:00Z"}), + (RegenerateKeyRequest, {"expires": "2026-01-01T00:00:00Z"}), + (GenerateKeyRequest, {"expires": None}), + ): + with pytest.raises(ValidationError) as exc_info: + request_model.model_validate(request_values) + assert "duration" in str(exc_info.value) + + +def test_generate_key_response_preserves_expires() -> None: + expires: Final = datetime(2026, 1, 1, tzinfo=timezone.utc) + + assert GenerateKeyResponse(key="sk-x", expires=expires).expires == expires + + +def test_generate_key_request_accepts_duration_without_expires() -> None: + assert GenerateKeyRequest.model_validate({"duration": "1h"}).duration == "1h" + + @pytest.mark.asyncio async def test_list_keys(): mock_prisma_client = AsyncMock() @@ -23305,5 +23327,3 @@ def test_key_info_database_not_connected_sanitized_500( finally: proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) - -