From e468b0278ff41f9f4a5a6464af93d77a32ef624c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 5 Mar 2026 20:54:30 -0800 Subject: [PATCH] [Fix] Key Expiry Default Duration - support null to never expire Support passing duration=null on /key/update to reset a key's expiry to never expires, alongside the existing "-1" magic string (kept for backward compat). Co-Authored-By: Claude Sonnet 4.6 --- .../key_management_endpoints.py | 8 ++--- .../test_key_management_endpoints.py | 28 +++++++++++++++ .../KeyLifecycleSettings.tsx | 35 +++++++++++++++---- .../components/templates/key_edit_view.tsx | 7 ++++ 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 4d44006369b..5d189e0db49 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -534,8 +534,8 @@ async def _common_key_generation_helper( # noqa: PLR0915 upperbound_duration = duration_in_seconds( duration=upperbound_value ) - # Handle special case where duration is "-1" (never expires) - if value == "-1": + # Handle special case where duration is None or "-1" (never expires) + if value is None or value == "-1": user_duration = float("inf") # Infinite duration else: user_duration = duration_in_seconds(duration=value) @@ -1462,7 +1462,7 @@ async def prepare_key_update_data( if "duration" in non_default_values: duration = non_default_values.pop("duration") - if duration == "-1": + 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: @@ -1786,7 +1786,7 @@ async def update_key_fn( - tpm_limit_type: Optional[str] - TPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic" - 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.) or "-1" to never expire + - duration: Optional[str] - Key validity duration ("30d", "1h", etc.), null to never expire, or "-1" to never expire (deprecated, use null) - 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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 11c80351839..bc5c6925107 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1087,6 +1087,34 @@ async def test_prepare_key_update_data_duration_never_expires(): assert result["expires"] is None +@pytest.mark.asyncio +async def test_prepare_key_update_data_duration_none_never_expires(): + """Test that duration=None sets expires to None (never expires).""" + from litellm.proxy._types import UpdateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + prepare_key_update_data, + ) + + existing_key = LiteLLM_VerificationToken( + token="test-token", + key_alias="test-key", + models=["gpt-3.5-turbo"], + user_id="test-user", + team_id=None, + auto_rotate=False, + rotation_interval=None, + metadata={}, + ) + + update_request = UpdateKeyRequest(key="test-token", duration=None) + + result = await prepare_key_update_data( + data=update_request, existing_key_row=existing_key + ) + + assert result["expires"] is None + + @pytest.mark.asyncio async def test_validate_team_id_used_in_service_account_request_requires_team_id(): """ diff --git a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx index 0f29a47d1dc..17d65e1f66a 100644 --- a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx +++ b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Select, Tooltip, Divider, Switch } from "antd"; +import { Select, Tooltip, Divider, Switch, Checkbox } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { TextInput } from "@tremor/react"; @@ -12,6 +12,8 @@ interface KeyLifecycleSettingsProps { rotationInterval: string; onRotationIntervalChange: (interval: string) => void; isCreateMode?: boolean; // If true, shows "leave empty to never expire" instead of "-1 to never expire" + neverExpire?: boolean; + onNeverExpireChange?: (checked: boolean) => void; } const KeyLifecycleSettings: React.FC = ({ @@ -21,6 +23,8 @@ const KeyLifecycleSettings: React.FC = ({ rotationInterval, onRotationIntervalChange, isCreateMode = false, + neverExpire = false, + onNeverExpireChange, }) => { // Predefined intervals const predefinedIntervals = ["7d", "30d", "90d", "180d", "365d"]; @@ -67,21 +71,38 @@ const KeyLifecycleSettings: React.FC = ({ diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 7e1b83fc977..b6c00577c9b 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -98,6 +98,7 @@ export function KeyEditView({ ); const [autoRotationEnabled, setAutoRotationEnabled] = useState(keyData.auto_rotate || false); const [rotationInterval, setRotationInterval] = useState(keyData.rotation_interval || ""); + const [neverExpire, setNeverExpire] = useState(!keyData.expires); const [isKeySaving, setIsKeySaving] = useState(false); const { data: projects } = useProjects(); const { data: uiSettingsData } = useUISettings(); @@ -265,6 +266,10 @@ export function KeyEditView({ } // If it's already an array (shouldn't happen, but handle it), keep as is + if (neverExpire) { + values.duration = null; + } + await onSubmit(values); } finally { setIsKeySaving(false); @@ -660,6 +665,8 @@ export function KeyEditView({ onAutoRotationChange={setAutoRotationEnabled} rotationInterval={rotationInterval} onRotationIntervalChange={setRotationInterval} + neverExpire={neverExpire} + onNeverExpireChange={setNeverExpire} />