From 62c174d455a87d48baad66131a27a3ef474f9501 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 14:49:59 -0800 Subject: [PATCH] [Fix] Key Expiry: treat null as never-expires, guard -1 team max, add Never Expires checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In `_validate_regenerate_key_duration_against_team`: use `model_fields_set` to distinguish "duration not sent" (leave unchanged) from "duration: null" (never expires / infinite). Guard against team max being "-1" (no limit) before calling `duration_in_seconds` which would crash on that sentinel. - In `_common_key_generation_helper`: same "-1" guard for team max duration so the creation path no longer crashes when the team has no configured limit. - In `prepare_key_update_data`: treat `duration is None` the same as `"-1"` — both set `expires = None` (never expires). - UI: replace the "Enter -1 for unlimited expiry" pattern in `KeyLifecycleSettings.tsx` and `regenerate_key_modal.tsx` with a "Never Expires" checkbox mirroring the budget unlimited approach. - Tests: add/fix tests for all new cases (null-as-infinite, team max "-1" skips, not-sent skips via model_fields_set). Co-Authored-By: Claude Sonnet 4.6 --- .../key_management_endpoints.py | 52 ++++++---- .../test_key_management_endpoints.py | 96 ++++++++++++++++++- .../KeyLifecycleSettings.tsx | 43 ++++++--- .../organisms/regenerate_key_modal.tsx | 43 ++++++++- 4 files changed, 193 insertions(+), 41 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 9eafb000a63..84cc3b6e8c6 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -555,18 +555,20 @@ async def _common_key_generation_helper( # noqa: PLR0915 and data.duration is not None ): team_max_duration = team_table.metadata["team_member_key_duration"] - team_max_seconds = duration_in_seconds(duration=team_max_duration) - if data.duration == "-1": - user_key_duration: float = float("inf") - else: - user_key_duration = duration_in_seconds(duration=data.duration) - if user_key_duration > team_max_seconds: - raise HTTPException( - status_code=400, - detail={ - "error": f"Key duration exceeds team maximum. Requested: {data.duration}; Team maximum: {team_max_duration}" - }, - ) + # "-1" on the team side means no limit is enforced + if team_max_duration != "-1": + team_max_seconds = duration_in_seconds(duration=team_max_duration) + if data.duration == "-1": + user_key_duration: float = float("inf") + else: + user_key_duration = duration_in_seconds(duration=data.duration) + if user_key_duration > team_max_seconds: + raise HTTPException( + status_code=400, + detail={ + "error": f"Key duration exceeds team maximum. Requested: {data.duration}; Team maximum: {team_max_duration}" + }, + ) # APPLY ENTERPRISE KEY MANAGEMENT PARAMS try: @@ -1483,8 +1485,8 @@ async def prepare_key_update_data( if "duration" in non_default_values: duration = non_default_values.pop("duration") - if duration == "-1": - # Set expires to None to indicate the key never expires + if duration == "-1" or duration is None: + # "-1" (legacy) or null (never-expires checkbox) both mean no expiry non_default_values["expires"] = None elif duration and (isinstance(duration, str)) and len(duration) > 0: duration_s = duration_in_seconds(duration=duration) @@ -3400,9 +3402,20 @@ async def _validate_regenerate_key_duration_against_team( user_api_key_cache: DualCache, ) -> None: """Raise HTTP 400 if the requested duration exceeds the team's max key duration.""" - if data is None or not data.duration: + if data is None: return + # Determine the user's requested duration in seconds. + # Distinguish "duration not sent" (leave unchanged) from "duration: null" (never expires). + if data.duration is None: + if "duration" not in data.model_fields_set: + return # Not provided — leave the existing expiry unchanged + user_seconds: float = float("inf") # Explicitly null = never expires + elif data.duration == "-1": + user_seconds = float("inf") # Legacy sentinel for never-expires + else: + user_seconds = duration_in_seconds(duration=data.duration) + team_id = getattr(key_in_db, "team_id", None) if not team_id: return @@ -3431,12 +3444,11 @@ async def _validate_regenerate_key_duration_against_team( return team_max_duration = team_table.metadata["team_member_key_duration"] - team_max_seconds = duration_in_seconds(duration=team_max_duration) + # "-1" on the team side means no limit is enforced + if team_max_duration == "-1": + return - if data.duration == "-1": - user_seconds: float = float("inf") - else: - user_seconds = duration_in_seconds(duration=data.duration) + team_max_seconds = duration_in_seconds(duration=team_max_duration) if user_seconds > team_max_seconds: raise HTTPException( 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 278dc522ab1..4c7f5981f5a 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 @@ -6565,6 +6565,31 @@ class TestCommonKeyGenerationHelperTeamDurationValidation: team_table=None, ) + @pytest.mark.asyncio + async def test_team_max_minus_one_skips_validation(self): + """team_member_key_duration='-1' means no limit — any user duration is accepted.""" + team = MagicMock(spec=LiteLLM_TeamTableCachedObj) + team.metadata = {"team_member_key_duration": "-1"} + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn", + new_callable=AsyncMock, + return_value={"key": "sk-test", "expires": None, "user_id": "user-1"}, + ), patch("litellm.proxy.proxy_server.prisma_client"), patch( + "litellm.proxy.proxy_server.llm_router" + ), patch( + "litellm.proxy.proxy_server.premium_user", False + ): + # Should not raise even though "999d" would otherwise exceed any finite team max + await _common_key_generation_helper( + data=GenerateKeyRequest(user_id="user-1", duration="999d"), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1" + ), + litellm_changed_by=None, + team_table=team, + ) + class TestValidateRegenerateKeyDurationAgainstTeam: """Tests for _validate_regenerate_key_duration_against_team.""" @@ -6588,7 +6613,8 @@ class TestValidateRegenerateKeyDurationAgainstTeam: ) @pytest.mark.asyncio - async def test_no_duration_skips_validation(self): + async def test_duration_not_sent_skips_validation(self): + """Duration field absent from request (leave existing expiry unchanged).""" from litellm.proxy.management_endpoints.key_management_endpoints import ( _validate_regenerate_key_duration_against_team, ) @@ -6596,7 +6622,8 @@ class TestValidateRegenerateKeyDurationAgainstTeam: mock_key = MagicMock(spec=LiteLLM_VerificationToken) mock_key.team_id = "team-123" - data = RegenerateKeyRequest(duration=None) + # No duration kwarg at all → not in model_fields_set + data = RegenerateKeyRequest() mock_prisma = AsyncMock() mock_cache = MagicMock() # Should not raise @@ -6607,6 +6634,71 @@ class TestValidateRegenerateKeyDurationAgainstTeam: user_api_key_cache=mock_cache, ) + @pytest.mark.asyncio + async def test_null_duration_treated_as_never_expires_raises_when_team_has_limit(self): + """duration=null (Never Expires checkbox) is treated as infinite and rejected when team has a limit.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_regenerate_key_duration_against_team, + ) + from litellm.proxy._types import RegenerateKeyRequest, LiteLLM_TeamTableCachedObj + + mock_key = MagicMock(spec=LiteLLM_VerificationToken) + mock_key.team_id = "team-123" + # Explicitly set duration=None → "duration" IS in model_fields_set + data = RegenerateKeyRequest(duration=None) + assert "duration" in data.model_fields_set + + mock_team = MagicMock(spec=LiteLLM_TeamTableCachedObj) + mock_team.metadata = {"team_member_key_duration": "5d"} + + mock_prisma = AsyncMock() + mock_cache = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + new_callable=AsyncMock, + return_value=mock_team, + ): + with pytest.raises(HTTPException) as exc_info: + await _validate_regenerate_key_duration_against_team( + data=data, + key_in_db=mock_key, + prisma_client=mock_prisma, + user_api_key_cache=mock_cache, + ) + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_team_max_duration_minus_one_skips_validation(self): + """team_member_key_duration='-1' means no team limit — skip validation regardless of user duration.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_regenerate_key_duration_against_team, + ) + from litellm.proxy._types import RegenerateKeyRequest, LiteLLM_TeamTableCachedObj + + mock_key = MagicMock(spec=LiteLLM_VerificationToken) + mock_key.team_id = "team-123" + data = RegenerateKeyRequest(duration="999d") + + mock_team = MagicMock(spec=LiteLLM_TeamTableCachedObj) + mock_team.metadata = {"team_member_key_duration": "-1"} + + mock_prisma = AsyncMock() + mock_cache = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + new_callable=AsyncMock, + return_value=mock_team, + ): + # Should not raise — team max is "-1" (no limit) + await _validate_regenerate_key_duration_against_team( + data=data, + key_in_db=mock_key, + prisma_client=mock_prisma, + user_api_key_cache=mock_cache, + ) + @pytest.mark.asyncio async def test_no_team_id_skips_validation(self): from litellm.proxy.management_endpoints.key_management_endpoints import ( diff --git a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx index 0f29a47d1dc..e7a0bff41a8 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"; @@ -11,7 +11,7 @@ interface KeyLifecycleSettingsProps { onAutoRotationChange: (enabled: boolean) => void; rotationInterval: string; onRotationIntervalChange: (interval: string) => void; - isCreateMode?: boolean; // If true, shows "leave empty to never expire" instead of "-1 to never expire" + isCreateMode?: boolean; } const KeyLifecycleSettings: React.FC = ({ @@ -31,6 +31,7 @@ const KeyLifecycleSettings: React.FC = ({ const [showCustomInput, setShowCustomInput] = useState(isCustomInterval); const [customInterval, setCustomInterval] = useState(isCustomInterval ? rotationInterval : ""); const [durationValue, setDurationValue] = useState(form?.getFieldValue?.("duration") || ""); + const [neverExpires, setNeverExpires] = useState(false); const handleIntervalChange = (value: string) => { if (value === "custom") { @@ -57,6 +58,20 @@ const KeyLifecycleSettings: React.FC = ({ form.setFieldsValue({ duration: value }); } }; + + const handleNeverExpiresChange = (e: any) => { + const checked = e.target.checked; + setNeverExpires(checked); + if (checked) { + setDurationValue(""); + if (form && typeof form.setFieldValue === "function") { + form.setFieldValue("duration", null); + } else if (form && typeof form.setFieldsValue === "function") { + form.setFieldsValue({ duration: null }); + } + } + }; + return (
{/* Key Expiry Section */} @@ -64,24 +79,24 @@ const KeyLifecycleSettings: React.FC = ({ Key Expiry Settings
-
diff --git a/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx b/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx index 2fad101c20f..6142bcffb78 100644 --- a/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx @@ -1,6 +1,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { Button, Col, Grid, Text, TextInput, Title } from "@tremor/react"; -import { Form, InputNumber, Modal } from "antd"; +import { Checkbox, Form, InputNumber, Modal } from "antd"; import { add } from "date-fns"; import { useEffect, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; @@ -22,6 +22,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat const [regenerateFormData, setRegenerateFormData] = useState(null); const [newExpiryTime, setNewExpiryTime] = useState(null); const [isRegenerating, setIsRegenerating] = useState(false); + const [neverExpires, setNeverExpires] = useState(false); // Track whether this is the user's own authentication key const [isOwnKey, setIsOwnKey] = useState(false); @@ -31,12 +32,14 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat useEffect(() => { if (visible && selectedToken && accessToken) { + const currentlyNeverExpires = !selectedToken.expires; + setNeverExpires(currentlyNeverExpires); form.setFieldsValue({ key_alias: selectedToken.key_alias, max_budget: selectedToken.max_budget, tpm_limit: selectedToken.tpm_limit, rpm_limit: selectedToken.rpm_limit, - duration: selectedToken.duration || "", + duration: currentlyNeverExpires ? null : (selectedToken.duration || ""), grace_period: "", }); @@ -56,6 +59,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat setIsRegenerating(false); setIsOwnKey(false); setCurrentAccessToken(null); + setNeverExpires(false); form.resetFields(); } }, [visible, form]); @@ -98,6 +102,13 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat try { const formValues = await form.validateFields(); + // Translate "Never Expires" checkbox to an explicit null duration + if (neverExpires) { + formValues.duration = null; + } else if (!formValues.duration || formValues.duration.trim() === "") { + delete formValues.duration; // Not provided — leave existing expiry unchanged + } + // Use the current access token for the API call const response = await regenerateKeyCall( currentAccessToken, @@ -217,13 +228,35 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat - - + + Expire Key (eg: 30s, 30h, 30d) + { + const checked = e.target.checked; + setNeverExpires(checked); + if (checked) { + form.setFieldValue("duration", null); + setRegenerateFormData((prev: any) => ({ ...prev, duration: null })); + } + }} + > + Never Expires + + + } + name="duration" + className="mt-8" + > +
Current expiry: {selectedToken?.expires ? new Date(selectedToken.expires).toLocaleString() : "Never"}
- {newExpiryTime &&
New expiry: {newExpiryTime}
} + {!neverExpires && newExpiryTime &&
New expiry: {newExpiryTime}
} + {neverExpires &&
New expiry: Never
}