mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
[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:
parent
b2ed580eff
commit
62c174d455
4 changed files with 193 additions and 41 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue