This commit is contained in:
fengyue-xve 2026-11-05 07:32:49 +00:00 • committed by GitHub
commit 976a09f3f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 232 additions and 29 deletions

View file

@ -336,20 +336,21 @@ async def _sign_in(
)
if os.getenv("DATABASE_URL") is not None:
session_key_data: Final[Mapping[str, object]] = {
"user_role": LitellmUserRoles.PROXY_ADMIN,
"duration": LITELLM_UI_SESSION_DURATION,
"key_max_budget": litellm.max_ui_session_budget,
"models": [],
"aliases": {},
"config": {},
"spend": 0,
"user_id": key_user_id,
"team_id": "litellm-dashboard",
}
response = await generate_key_helper_fn(
llm_router=None,
request_type="key",
**{
"user_role": LitellmUserRoles.PROXY_ADMIN,
"duration": LITELLM_UI_SESSION_DURATION,
"key_max_budget": litellm.max_ui_session_budget,
"models": [],
"aliases": {},
"config": {},
"spend": 0,
"user_id": key_user_id,
"team_id": "litellm-dashboard",
},
**session_key_data,
)
else:
raise ProxyException(

View file

@ -504,7 +504,11 @@ def _effective_key_for_generate(data: GenerateKeyRequest, now: datetime) -> Lite
else {**preset_columns, "allowed_routes": list(default_allowed_routes)}
)
expires: Final = (
now + timedelta(seconds=duration_in_seconds(duration=data.duration)) if data.duration is not None else None
data.expires
if data.expires is not None
else now + timedelta(seconds=duration_in_seconds(duration=data.duration))
if data.duration is not None
else None
)
budget_reset_at: Final = (
get_budget_reset_time(budget_duration=data.budget_duration) if data.budget_duration is not None else None
@ -1407,6 +1411,21 @@ def _enforce_upperbound_key_params(
)
def _reject_conflicting_expires_and_duration(data: GenerateKeyRequest | UpdateKeyRequest) -> None:
"""`expires` is an absolute timestamp, `duration` is relative -- they are two
ways of saying when a key stops working. If a caller supplies both, honoring
either one would silently discard the other (GH#45236), so reject the request.
"""
if "expires" in data.model_fields_set and data.expires is not None and "duration" in data.model_fields_set:
raise HTTPException(
status_code=400,
detail={
"error": (
"`expires` and `duration` cannot be set together. "
"Use `expires` for an absolute timestamp or `duration` for a relative lifetime, not both."
)
},
)
def _team_admin_requests_member_key(
data: GenerateKeyRequest,
team_table: LiteLLM_TeamTableCachedObj | None,
@ -1479,6 +1498,7 @@ async def _common_key_generation_helper(
)
validate_budget_duration(data.budget_duration)
_reject_conflicting_expires_and_duration(data)
raise_on_invalid_key_logging_config(data.metadata)
if data.throttle_on_budget_exceeded is True and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
@ -2363,6 +2383,7 @@ async def generate_key_fn(
Parameters:
- duration: Optional[str] - Specify the length of time the token is valid for. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
- expires: Optional[datetime] - Absolute expiry timestamp for the key (ISO 8601). Use this or `duration`, not both.
- key_alias: Optional[str] - User defined key alias
- key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
- team_id: Optional[str] - The team id of the key
@ -3013,6 +3034,7 @@ async def prepare_key_update_data(
prisma_client: PrismaClient | None = None,
llm_router: Router | None = None,
):
_reject_conflicting_expires_and_duration(data)
if data.router_settings is not None or (
"router_settings" not in data.model_fields_set
and "team_id" in data.model_fields_set
@ -4067,6 +4089,7 @@ async def update_key_fn(
- 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.), null to never expire, or "-1" to never expire (deprecated, use null)
- expires: Optional[datetime] - Absolute expiry timestamp for the key (ISO 8601). Use this or `duration`, not both.
- 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
@ -5243,6 +5266,9 @@ def metadata_json_with_limits(
async def generate_key_helper_fn(
request_type: Literal["user", "key"], # identifies if this request is from /user/new or /key/generate
duration: str | None = None,
# Typed as `object` because this helper is called with `**data_json` spreads whose
# value type is `object`; the caller-supplied absolute expiry (a `datetime`).
expires: object = None,
models: list = [],
aliases: dict = {},
config: dict = {},
@ -5320,12 +5346,17 @@ async def generate_key_helper_fn(
else:
token = f"sk-{secrets.token_urlsafe(LENGTH_OF_LITELLM_GENERATED_KEY)}"
if duration is None: # allow tokens that never expire
expires = None
else:
if expires is not None and not isinstance(expires, datetime):
raise ValueError(f"`expires` must be a datetime, got {type(expires).__name__}")
key_expires: Final = (
expires
if expires is not None
# Add duration to current time for exact expiration (not standardized reset time)
duration_seconds: Final = duration_in_seconds(duration)
expires = datetime.now(timezone.utc) + timedelta(seconds=duration_seconds)
else datetime.now(timezone.utc) + timedelta(seconds=duration_in_seconds(duration))
if duration is not None
else None # allow tokens that never expire
)
if key_budget_duration is None: # one-time budget
key_reset_at = None
@ -5402,7 +5433,7 @@ async def generate_key_helper_fn(
key_data: Final = {
"token": token,
"key_alias": key_alias,
"expires": expires,
"expires": key_expires,
"models": models,
"aliases": aliases_json,
"config": config_json,

View file

@ -19755,20 +19755,21 @@ async def _rollback_onboarding_invite_claim(
async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str:
global master_key, general_settings
onboarding_key_data: Final[Mapping[str, object]] = {
"user_role": user_obj.user_role,
"duration": LITELLM_UI_SESSION_DURATION,
"key_max_budget": litellm.max_ui_session_budget,
"models": [],
"aliases": {},
"config": {},
"spend": 0,
"user_id": user_obj.user_id,
"team_id": UI_TEAM_ID,
}
response: Final = await generate_key_helper_fn(
llm_router=llm_router,
request_type="key",
**{
"user_role": user_obj.user_role,
"duration": LITELLM_UI_SESSION_DURATION,
"key_max_budget": litellm.max_ui_session_budget,
"models": [],
"aliases": {},
"config": {},
"spend": 0,
"user_id": user_obj.user_id,
"team_id": UI_TEAM_ID,
},
**onboarding_key_data,
)
key: Final = response["token"]

View file

@ -25,6 +25,7 @@ def _fallback_targets(rules: ContentPolicyFallbackRules) -> Iterator[object]:
class KeyRequestBase(GenerateRequestBase):
key: str | None = None
expires: datetime | None = None
tpd_limit: int | None = None
default_estimated_output_tokens: PositiveInt | None = None
default_estimated_output_tokens_per_model: Mapping[str, PositiveInt] | None = None

View file

@ -511,6 +511,100 @@ async def test_key_expiration_exact_duration_hours(monkeypatch):
), f"Expected expiration to be approximately 12 hours from creation, got {hours_diff} hours"
@pytest.mark.asyncio
async def test_generate_key_honors_explicit_expires_without_duration(monkeypatch):
"""GH#45236: an absolute `expires` timestamp supplied to /key/generate must be
persisted and returned as-is — not silently replaced by a duration-derived
expiry. No `duration` is given, so the supplied timestamp is the only
source of truth for the key's expiry, and the insert payload (what an
immediate GET /key/info reads back) must carry it."""
explicit_expires = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc)
mock_prisma_client = AsyncMock()
mock_prisma_client.insert_data = AsyncMock(
return_value=MagicMock(token="hashed_token_123", litellm_budget_table=None, object_permission=None)
)
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.litellm_verificationtoken = MagicMock()
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
response = await generate_key_fn(
data=GenerateKeyRequest(expires=explicit_expires),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-9876", user_id="admin"
),
)
assert response.expires == explicit_expires
key_insert = mock_prisma_client.insert_data.await_args_list[-1].kwargs
assert key_insert["table_name"] == "key"
assert key_insert["data"]["expires"] == explicit_expires
@pytest.mark.asyncio
async def test_generate_key_rejects_explicit_expires_with_duration():
"""GH#45236: `expires` (absolute) and `duration` (relative) are two ways to say
when a key dies. Accepting both would require silently picking one, so the
ambiguous request must be rejected with a clear 400."""
with pytest.raises(HTTPException) as exc:
await _common_key_generation_helper(
data=GenerateKeyRequest(
expires=datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc),
duration="1d",
),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-9876", user_id="admin"
),
litellm_changed_by=None,
team_table=None,
)
assert exc.value.status_code == 400
assert "expires" in str(exc.value.detail) and "duration" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_generate_key_expires_only_survives_config_upperbound_duration(monkeypatch):
"""With `upperbound_key_generate_params.duration` configured, generate fills a
duration onto requests that don't set one. An explicit `expires` timestamp
from the caller must still win over that config-derived duration — otherwise
the timestamp is silently ignored (the exact bug in GH#45236)."""
explicit_expires = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc)
from litellm.types.proxy.management_endpoints.ui_sso import (
LiteLLM_UpperboundKeyGenerateParams,
)
monkeypatch.setattr(
litellm,
"upperbound_key_generate_params",
LiteLLM_UpperboundKeyGenerateParams(duration="1d"),
)
mock_prisma_client = AsyncMock()
mock_prisma_client.insert_data = AsyncMock(
return_value=MagicMock(token="hashed_token_123", litellm_budget_table=None, object_permission=None)
)
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.litellm_verificationtoken = MagicMock()
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
response = await generate_key_fn(
data=GenerateKeyRequest(expires=explicit_expires),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-9876", user_id="admin"
),
)
assert response.expires == explicit_expires
key_insert = mock_prisma_client.insert_data.await_args_list[-1].kwargs
assert key_insert["data"]["expires"] == explicit_expires
@pytest.mark.asyncio
async def test_generate_key_persists_tpd_limit(monkeypatch):
mock_prisma_client = AsyncMock()
@ -2462,6 +2556,64 @@ async def test_prepare_key_update_data_duration_none_never_expires():
assert result["expires"] is None
@pytest.mark.asyncio
async def test_prepare_key_update_data_honors_explicit_expires():
"""GH#45236: an absolute `expires` timestamp on /key/update must be persisted,
replacing the stored expiry — previously it was dropped and the stored
expiry (here, from the original duration) stayed unchanged."""
explicit_expires = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc)
existing_key = LiteLLM_VerificationToken(
token="test-token",
expires="2030-01-01T00:00:00+00:00",
metadata={},
)
result = await prepare_key_update_data(
data=UpdateKeyRequest(key="test-token", expires=explicit_expires),
existing_key_row=existing_key,
)
assert result["expires"] == explicit_expires
assert "duration" not in result
@pytest.mark.asyncio
async def test_prepare_key_update_data_rejects_explicit_expires_with_duration():
"""GH#45236: `expires` + `duration` on /key/update is ambiguous — reject with a
clear 400 instead of silently letting one of them win."""
existing_key = LiteLLM_VerificationToken(
token="test-token",
expires="2030-01-01T00:00:00+00:00",
metadata={},
)
with pytest.raises(HTTPException) as exc:
await prepare_key_update_data(
data=UpdateKeyRequest(
key="test-token",
expires=datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc),
duration="1d",
),
existing_key_row=existing_key,
)
assert exc.value.status_code == 400
assert "expires" in str(exc.value.detail) and "duration" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_effective_key_for_generate_uses_explicit_expires():
"""The custom-key-policy view of a generate request must see the caller's
absolute `expires`, not a duration-derived timestamp."""
now = datetime(2031, 5, 4, 3, 2, 1, tzinfo=timezone.utc)
explicit_expires = datetime(2031, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
effective = _effective_key_for_generate(
data=GenerateKeyRequest(expires=explicit_expires), now=now
)
assert effective.expires == explicit_expires
@pytest.mark.asyncio
@pytest.mark.parametrize("cleared_value", [[], None])
async def test_prepare_key_update_data_budget_limits_clears_field(cleared_value):

View file

@ -8605,6 +8605,7 @@ export interface paths {
*
* Parameters:
* - duration: Optional[str] - Specify the length of time the token is valid for. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
* - expires: Optional[datetime] - Absolute expiry timestamp for the key (ISO 8601). Use this or `duration`, not both.
* - key_alias: Optional[str] - User defined key alias
* - key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
* - team_id: Optional[str] - The team id of the key
@ -9104,6 +9105,7 @@ export interface paths {
* - 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.), null to never expire, or "-1" to never expire (deprecated, use null)
* - expires: Optional[datetime] - Absolute expiry timestamp for the key (ISO 8601). Use this or `duration`, not both.
* - 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
@ -33638,6 +33640,11 @@ export interface components {
end_user_budget_id?: string | null;
/** Enforced Params */
enforced_params?: string[] | null;
/**
* Expires
* @description Absolute expiry timestamp for the key. Cannot be combined with `duration`.
*/
expires?: string | null;
/** Guardrails */
guardrails?: string[] | null;
/** Key */
@ -43130,6 +43137,11 @@ export interface components {
end_user_budget_id?: string | null;
/** Enforced Params */
enforced_params?: string[] | null;
/**
* Expires
* @description Absolute expiry timestamp for the key. Cannot be combined with `duration`.
*/
expires?: string | null;
/** Grace Period */
grace_period?: string | null;
/** Guardrails */
@ -49454,6 +49466,11 @@ export interface components {
end_user_budget_id?: string | null;
/** Enforced Params */
enforced_params?: string[] | null;
/**
* Expires
* @description Absolute expiry timestamp for the key. Cannot be combined with `duration`.
*/
expires?: string | null;
/** Guardrails */
guardrails?: string[] | null;
/** Key */