Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<KeyLifecycleSettingsProps> = ({
Expand All @@ -21,6 +23,8 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
rotationInterval,
onRotationIntervalChange,
isCreateMode = false,
neverExpire = false,
onNeverExpireChange,
}) => {
// Predefined intervals
const predefinedIntervals = ["7d", "30d", "90d", "180d", "365d"];
Expand Down Expand Up @@ -67,21 +71,38 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
<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."
}
title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tooltip message is inaccurate in create mode

The tooltip was previously conditional based on isCreateMode. Now it always says "Leave empty to keep the current expiry unchanged." However, in create mode there is no "current expiry" — leaving the field empty means "never expire". This is a UX regression for the key creation flow (used in create_key_button.tsx with isCreateMode={true}).

The isCreateMode prop is still passed through to KeyLifecycleSettings by callers, so it can still be used to differentiate the tooltip message.

Suggested change
title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged."
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). Leave empty to keep the current expiry unchanged."}

>
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
</Tooltip>
{!isCreateMode && onNeverExpireChange && (
<Checkbox
checked={neverExpire}
onChange={(e) => {
const checked = e.target.checked;
onNeverExpireChange(checked);
if (checked) {
setDurationValue("");
if (form && typeof form.setFieldValue === "function") {
form.setFieldValue("duration", "");
} else if (form && typeof form.setFieldsValue === "function") {
form.setFieldsValue({ duration: "" });
}
}
}}
className="ml-2 text-sm font-normal text-gray-600"
>
Never Expire
</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={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d"}
className="w-full"
value={durationValue}
onValueChange={handleDurationChange}
disabled={!isCreateMode && neverExpire}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function KeyEditView({
);
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(keyData.auto_rotate || false);
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
const [neverExpire, setNeverExpire] = useState<boolean>(!keyData.expires);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unchecking "Never Expire" without a duration silently keeps key as never-expiring

neverExpire is initialized to true when keyData.expires is falsy. If a user opens the edit view for a never-expiring key, unchecks the "Never Expire" checkbox (intending to add an expiry), but then doesn't fill in a duration string and saves — the backend receives duration: "". Since "" matches neither the None / "-1" branch nor the len(duration) > 0 branch in prepare_key_update_data, expires is never updated and the key silently remains as never-expiring.

Consider adding a validation step in handleSubmit (or inside KeyLifecycleSettings when the checkbox is unchecked) to surface an error when "Never Expire" is unchecked but no duration is provided.

const [isKeySaving, setIsKeySaving] = useState(false);
const { data: projects } = useProjects();
const { data: uiSettingsData } = useUISettings();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -660,6 +665,8 @@ export function KeyEditView({
onAutoRotationChange={setAutoRotationEnabled}
rotationInterval={rotationInterval}
onRotationIntervalChange={setRotationInterval}
neverExpire={neverExpire}
onNeverExpireChange={setNeverExpire}
/>
<Form.Item name="duration" hidden initialValue="">
<Input />
Expand Down
Loading