mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge b5b1a98d4b into 3f4dc93b40
This commit is contained in:
commit
976a09f3f1
6 changed files with 232 additions and 29 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
17
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
17
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue