diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 9783f44b41f..b629559d0a8 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -336,20 +336,21 @@ async def _sign_in( ) if os.getenv("DATABASE_URL") is not None: + session_key_data: Final[Mapping[str, object]] = { + "user_role": LitellmUserRoles.PROXY_ADMIN, + "duration": LITELLM_UI_SESSION_DURATION, + "key_max_budget": litellm.max_ui_session_budget, + "models": [], + "aliases": {}, + "config": {}, + "spend": 0, + "user_id": key_user_id, + "team_id": "litellm-dashboard", + } response = await generate_key_helper_fn( llm_router=None, request_type="key", - **{ - "user_role": LitellmUserRoles.PROXY_ADMIN, - "duration": LITELLM_UI_SESSION_DURATION, - "key_max_budget": litellm.max_ui_session_budget, - "models": [], - "aliases": {}, - "config": {}, - "spend": 0, - "user_id": key_user_id, - "team_id": "litellm-dashboard", - }, + **session_key_data, ) else: raise ProxyException( diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index c8895c01f7b..dc7dc2949e6 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -504,7 +504,11 @@ def _effective_key_for_generate(data: GenerateKeyRequest, now: datetime) -> Lite 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 + data.expires + if data.expires is not None + else 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 @@ -1407,6 +1411,21 @@ def _enforce_upperbound_key_params( ) +def _reject_conflicting_expires_and_duration(data: GenerateKeyRequest | UpdateKeyRequest) -> None: + """`expires` is an absolute timestamp, `duration` is relative -- they are two + ways of saying when a key stops working. If a caller supplies both, honoring + either one would silently discard the other (GH#45236), so reject the request. + """ + if "expires" in data.model_fields_set and data.expires is not None and "duration" in data.model_fields_set: + raise HTTPException( + status_code=400, + detail={ + "error": ( + "`expires` and `duration` cannot be set together. " + "Use `expires` for an absolute timestamp or `duration` for a relative lifetime, not both." + ) + }, + ) def _team_admin_requests_member_key( data: GenerateKeyRequest, team_table: LiteLLM_TeamTableCachedObj | None, @@ -1479,6 +1498,7 @@ async def _common_key_generation_helper( ) validate_budget_duration(data.budget_duration) + _reject_conflicting_expires_and_duration(data) raise_on_invalid_key_logging_config(data.metadata) if data.throttle_on_budget_exceeded is True and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: @@ -2363,6 +2383,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 expiry timestamp for the key (ISO 8601). Use this or `duration`, not both. - 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 @@ -3013,6 +3034,7 @@ async def prepare_key_update_data( prisma_client: PrismaClient | None = None, llm_router: Router | None = None, ): + _reject_conflicting_expires_and_duration(data) if data.router_settings is not None or ( "router_settings" not in data.model_fields_set and "team_id" in data.model_fields_set @@ -4067,6 +4089,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 expiry timestamp for the key (ISO 8601). Use this or `duration`, not both. - 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 @@ -5243,6 +5266,9 @@ def metadata_json_with_limits( async def generate_key_helper_fn( request_type: Literal["user", "key"], # identifies if this request is from /user/new or /key/generate duration: str | None = None, + # Typed as `object` because this helper is called with `**data_json` spreads whose + # value type is `object`; the caller-supplied absolute expiry (a `datetime`). + expires: object = None, models: list = [], aliases: dict = {}, config: dict = {}, @@ -5320,12 +5346,17 @@ 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: + if expires is not None and not isinstance(expires, datetime): + raise ValueError(f"`expires` must be a datetime, got {type(expires).__name__}") + + key_expires: Final = ( + expires + if expires is not None # 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) + else datetime.now(timezone.utc) + timedelta(seconds=duration_in_seconds(duration)) + if duration is not None + else None # allow tokens that never expire + ) if key_budget_duration is None: # one-time budget key_reset_at = None @@ -5402,7 +5433,7 @@ async def generate_key_helper_fn( key_data: Final = { "token": token, "key_alias": key_alias, - "expires": expires, + "expires": key_expires, "models": models, "aliases": aliases_json, "config": config_json, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index debefec4a30..0dc2f79c952 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -19755,20 +19755,21 @@ async def _rollback_onboarding_invite_claim( async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str: global master_key, general_settings + onboarding_key_data: Final[Mapping[str, object]] = { + "user_role": user_obj.user_role, + "duration": LITELLM_UI_SESSION_DURATION, + "key_max_budget": litellm.max_ui_session_budget, + "models": [], + "aliases": {}, + "config": {}, + "spend": 0, + "user_id": user_obj.user_id, + "team_id": UI_TEAM_ID, + } response: Final = await generate_key_helper_fn( llm_router=llm_router, request_type="key", - **{ - "user_role": user_obj.user_role, - "duration": LITELLM_UI_SESSION_DURATION, - "key_max_budget": litellm.max_ui_session_budget, - "models": [], - "aliases": {}, - "config": {}, - "spend": 0, - "user_id": user_obj.user_id, - "team_id": UI_TEAM_ID, - }, + **onboarding_key_data, ) key: Final = response["token"] diff --git a/litellm/types/proxy/management_endpoints/key_requests.py b/litellm/types/proxy/management_endpoints/key_requests.py index f42a412cf2d..a5942d54176 100644 --- a/litellm/types/proxy/management_endpoints/key_requests.py +++ b/litellm/types/proxy/management_endpoints/key_requests.py @@ -25,6 +25,7 @@ def _fallback_targets(rules: ContentPolicyFallbackRules) -> Iterator[object]: class KeyRequestBase(GenerateRequestBase): key: str | None = None + expires: datetime | None = None tpd_limit: int | None = None default_estimated_output_tokens: PositiveInt | None = None default_estimated_output_tokens_per_model: Mapping[str, PositiveInt] | None = 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 b453f985512..b03c66e0cfc 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -511,6 +511,100 @@ async def test_key_expiration_exact_duration_hours(monkeypatch): ), f"Expected expiration to be approximately 12 hours from creation, got {hours_diff} hours" +@pytest.mark.asyncio +async def test_generate_key_honors_explicit_expires_without_duration(monkeypatch): + """GH#45236: an absolute `expires` timestamp supplied to /key/generate must be + persisted and returned as-is — not silently replaced by a duration-derived + expiry. No `duration` is given, so the supplied timestamp is the only + source of truth for the key's expiry, and the insert payload (what an + immediate GET /key/info reads back) must carry it.""" + explicit_expires = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc) + + mock_prisma_client = AsyncMock() + mock_prisma_client.insert_data = AsyncMock( + return_value=MagicMock(token="hashed_token_123", litellm_budget_table=None, object_permission=None) + ) + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_verificationtoken = MagicMock() + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + response = await generate_key_fn( + data=GenerateKeyRequest(expires=explicit_expires), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-9876", user_id="admin" + ), + ) + + assert response.expires == explicit_expires + key_insert = mock_prisma_client.insert_data.await_args_list[-1].kwargs + assert key_insert["table_name"] == "key" + assert key_insert["data"]["expires"] == explicit_expires + + +@pytest.mark.asyncio +async def test_generate_key_rejects_explicit_expires_with_duration(): + """GH#45236: `expires` (absolute) and `duration` (relative) are two ways to say + when a key dies. Accepting both would require silently picking one, so the + ambiguous request must be rejected with a clear 400.""" + with pytest.raises(HTTPException) as exc: + await _common_key_generation_helper( + data=GenerateKeyRequest( + expires=datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc), + duration="1d", + ), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-9876", user_id="admin" + ), + litellm_changed_by=None, + team_table=None, + ) + assert exc.value.status_code == 400 + assert "expires" in str(exc.value.detail) and "duration" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_generate_key_expires_only_survives_config_upperbound_duration(monkeypatch): + """With `upperbound_key_generate_params.duration` configured, generate fills a + duration onto requests that don't set one. An explicit `expires` timestamp + from the caller must still win over that config-derived duration — otherwise + the timestamp is silently ignored (the exact bug in GH#45236).""" + explicit_expires = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc) + from litellm.types.proxy.management_endpoints.ui_sso import ( + LiteLLM_UpperboundKeyGenerateParams, + ) + + monkeypatch.setattr( + litellm, + "upperbound_key_generate_params", + LiteLLM_UpperboundKeyGenerateParams(duration="1d"), + ) + + mock_prisma_client = AsyncMock() + mock_prisma_client.insert_data = AsyncMock( + return_value=MagicMock(token="hashed_token_123", litellm_budget_table=None, object_permission=None) + ) + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_verificationtoken = MagicMock() + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + response = await generate_key_fn( + data=GenerateKeyRequest(expires=explicit_expires), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-9876", user_id="admin" + ), + ) + + assert response.expires == explicit_expires + key_insert = mock_prisma_client.insert_data.await_args_list[-1].kwargs + assert key_insert["data"]["expires"] == explicit_expires + + @pytest.mark.asyncio async def test_generate_key_persists_tpd_limit(monkeypatch): mock_prisma_client = AsyncMock() @@ -2462,6 +2556,64 @@ async def test_prepare_key_update_data_duration_none_never_expires(): assert result["expires"] is None +@pytest.mark.asyncio +async def test_prepare_key_update_data_honors_explicit_expires(): + """GH#45236: an absolute `expires` timestamp on /key/update must be persisted, + replacing the stored expiry — previously it was dropped and the stored + expiry (here, from the original duration) stayed unchanged.""" + explicit_expires = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc) + existing_key = LiteLLM_VerificationToken( + token="test-token", + expires="2030-01-01T00:00:00+00:00", + metadata={}, + ) + + result = await prepare_key_update_data( + data=UpdateKeyRequest(key="test-token", expires=explicit_expires), + existing_key_row=existing_key, + ) + + assert result["expires"] == explicit_expires + assert "duration" not in result + + +@pytest.mark.asyncio +async def test_prepare_key_update_data_rejects_explicit_expires_with_duration(): + """GH#45236: `expires` + `duration` on /key/update is ambiguous — reject with a + clear 400 instead of silently letting one of them win.""" + existing_key = LiteLLM_VerificationToken( + token="test-token", + expires="2030-01-01T00:00:00+00:00", + metadata={}, + ) + + with pytest.raises(HTTPException) as exc: + await prepare_key_update_data( + data=UpdateKeyRequest( + key="test-token", + expires=datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc), + duration="1d", + ), + existing_key_row=existing_key, + ) + assert exc.value.status_code == 400 + assert "expires" in str(exc.value.detail) and "duration" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_effective_key_for_generate_uses_explicit_expires(): + """The custom-key-policy view of a generate request must see the caller's + absolute `expires`, not a duration-derived timestamp.""" + now = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc) + explicit_expires = datetime(2031, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + effective = _effective_key_for_generate( + data=GenerateKeyRequest(expires=explicit_expires), now=now + ) + + assert effective.expires == explicit_expires + + @pytest.mark.asyncio @pytest.mark.parametrize("cleared_value", [[], None]) async def test_prepare_key_update_data_budget_limits_clears_field(cleared_value): diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 11ce7f97ad8..2ddbb731818 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8605,6 +8605,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 expiry timestamp for the key (ISO 8601). Use this or `duration`, not both. * - 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 @@ -9104,6 +9105,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 expiry timestamp for the key (ISO 8601). Use this or `duration`, not both. * - 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 @@ -33638,6 +33640,11 @@ export interface components { end_user_budget_id?: string | null; /** Enforced Params */ enforced_params?: string[] | null; + /** + * Expires + * @description Absolute expiry timestamp for the key. Cannot be combined with `duration`. + */ + expires?: string | null; /** Guardrails */ guardrails?: string[] | null; /** Key */ @@ -43130,6 +43137,11 @@ export interface components { end_user_budget_id?: string | null; /** Enforced Params */ enforced_params?: string[] | null; + /** + * Expires + * @description Absolute expiry timestamp for the key. Cannot be combined with `duration`. + */ + expires?: string | null; /** Grace Period */ grace_period?: string | null; /** Guardrails */ @@ -49454,6 +49466,11 @@ export interface components { end_user_budget_id?: string | null; /** Enforced Params */ enforced_params?: string[] | null; + /** + * Expires + * @description Absolute expiry timestamp for the key. Cannot be combined with `duration`. + */ + expires?: string | null; /** Guardrails */ guardrails?: string[] | null; /** Key */