[Fix] Key Expiry: treat null as never-expires, guard -1 team max, add Never Expires checkbox

- 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 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-03 14:49:59 -08:00
parent b2ed580eff
commit 62c174d455
4 changed files with 193 additions and 41 deletions

View file

@ -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(

View file

@ -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 (

View file

@ -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<KeyLifecycleSettingsProps> = ({
@ -31,6 +31,7 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
const [showCustomInput, setShowCustomInput] = useState(isCustomInterval);
const [customInterval, setCustomInterval] = useState(isCustomInterval ? rotationInterval : "");
const [durationValue, setDurationValue] = useState<string>(form?.getFieldValue?.("duration") || "");
const [neverExpires, setNeverExpires] = useState<boolean>(false);
const handleIntervalChange = (value: string) => {
if (value === "custom") {
@ -57,6 +58,20 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
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 (
<div className="space-y-6">
{/* Key Expiry Section */}
@ -64,24 +79,24 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
<span className="text-sm font-medium text-gray-700">Key Expiry Settings</span>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
<span>Expire Key</span>
<Tooltip
title={
isCreateMode
? "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to never expire."
: "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Use -1 to never expire."
}
>
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
</Tooltip>
<label className="text-sm font-medium text-gray-700 flex items-center space-x-3">
<span className="flex items-center space-x-1">
<span>Expire Key</span>
<Tooltip title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days).">
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
</Tooltip>
</span>
<Checkbox checked={neverExpires} onChange={handleNeverExpiresChange}>
Never Expires
</Checkbox>
</label>
<TextInput
name="duration"
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d or -1 to never expire"}
placeholder="e.g., 30s, 30m, 30h, 30d"
className="w-full"
value={durationValue}
onValueChange={handleDurationChange}
disabled={neverExpires}
/>
</div>
</div>

View file

@ -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<any>(null);
const [newExpiryTime, setNewExpiryTime] = useState<string | null>(null);
const [isRegenerating, setIsRegenerating] = useState(false);
const [neverExpires, setNeverExpires] = useState<boolean>(false);
// Track whether this is the user's own authentication key
const [isOwnKey, setIsOwnKey] = useState<boolean>(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
<Form.Item name="rpm_limit" label="RPM Limit">
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="duration" label="Expire Key (eg: 30s, 30h, 30d)" className="mt-8">
<TextInput placeholder="" />
<Form.Item
label={
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span>Expire Key (eg: 30s, 30h, 30d)</span>
<Checkbox
checked={neverExpires}
onChange={(e) => {
const checked = e.target.checked;
setNeverExpires(checked);
if (checked) {
form.setFieldValue("duration", null);
setRegenerateFormData((prev: any) => ({ ...prev, duration: null }));
}
}}
>
Never Expires
</Checkbox>
</div>
}
name="duration"
className="mt-8"
>
<TextInput placeholder="e.g., 30s, 30m, 30h, 30d" disabled={neverExpires} />
</Form.Item>
<div className="mt-2 text-sm text-gray-500">
Current expiry: {selectedToken?.expires ? new Date(selectedToken.expires).toLocaleString() : "Never"}
</div>
{newExpiryTime && <div className="mt-2 text-sm text-green-600">New expiry: {newExpiryTime}</div>}
{!neverExpires && newExpiryTime && <div className="mt-2 text-sm text-green-600">New expiry: {newExpiryTime}</div>}
{neverExpires && <div className="mt-2 text-sm text-green-600">New expiry: Never</div>}
<Form.Item
name="grace_period"
label="Grace Period (eg: 24h, 2d)"