diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 84cc3b6e8c6..82db6e140e8 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -534,11 +534,7 @@ 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": - user_duration = float("inf") # Infinite duration - else: - user_duration = duration_in_seconds(duration=value) + user_duration = duration_in_seconds(duration=value) if user_duration > upperbound_duration: raise HTTPException( status_code=400, @@ -547,28 +543,28 @@ async def _common_key_generation_helper( # noqa: PLR0915 }, ) - # Validate key duration against the team's max key duration + # Validate key duration against the team's max key duration. + # Only runs when the caller explicitly included "duration" in the request + # (distinguishes "omitted" from "null = never expires"). if ( team_table is not None and team_table.metadata is not None and team_table.metadata.get("team_member_key_duration") - and data.duration is not None + and "duration" in data.model_fields_set ): team_max_duration = team_table.metadata["team_member_key_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}" - }, - ) + team_max_seconds = duration_in_seconds(duration=team_max_duration) + if data.duration is None: + user_key_duration: float = float("inf") # null = never expires = infinite + 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: @@ -1485,8 +1481,8 @@ async def prepare_key_update_data( if "duration" in non_default_values: duration = non_default_values.pop("duration") - if duration == "-1" or duration is None: - # "-1" (legacy) or null (never-expires checkbox) both mean no expiry + if duration is None: + # null (never-expires checkbox) means no expiry non_default_values["expires"] = None elif duration and (isinstance(duration, str)) and len(duration) > 0: duration_s = duration_in_seconds(duration=duration) @@ -1809,7 +1805,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.) or null to never expire - 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 @@ -3411,8 +3407,6 @@ async def _validate_regenerate_key_duration_against_team( 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) @@ -3444,10 +3438,6 @@ async def _validate_regenerate_key_duration_against_team( return team_max_duration = team_table.metadata["team_member_key_duration"] - # "-1" on the team side means no limit is enforced - if team_max_duration == "-1": - return - team_max_seconds = duration_in_seconds(duration=team_max_duration) if user_seconds > team_max_seconds: diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py index f7101085f0f..6cf0bb8da6c 100644 --- a/tests/proxy_unit_tests/test_proxy_utils.py +++ b/tests/proxy_unit_tests/test_proxy_utils.py @@ -678,8 +678,8 @@ async def test_prepare_key_update_data(): updated_data = await prepare_key_update_data(data, existing_key_row) assert updated_data["metadata"] is None - # Test duration "-1" sets expires to None (never expires) - data = UpdateKeyRequest(key="test_key", duration="-1") + # Test duration=null sets expires to None (never expires) + data = UpdateKeyRequest(key="test_key", duration=None) updated_data = await prepare_key_update_data(data, existing_key_row) assert updated_data["expires"] is None diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 4c7f5981f5a..3fb4b1149d6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1058,7 +1058,7 @@ async def test_update_service_account_works_with_team_id(): @pytest.mark.asyncio async def test_prepare_key_update_data_duration_never_expires(): - """Test that duration="-1" sets expires to None (never expires).""" + """Test that duration=null 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, @@ -1076,8 +1076,8 @@ async def test_prepare_key_update_data_duration_never_expires(): metadata={}, ) - # Test setting duration to "-1" (never expires) - update_request = UpdateKeyRequest(key="test-token", duration="-1") + # Test setting duration to null (never expires) + update_request = UpdateKeyRequest(key="test-token", duration=None) result = await prepare_key_update_data( data=update_request, existing_key_row=existing_key @@ -6480,13 +6480,13 @@ class TestCommonKeyGenerationHelperTeamDurationValidation: @pytest.mark.asyncio async def test_never_expires_exceeds_team_max_raises(self): - """Duration '-1' (never expires) exceeds any finite team max.""" + """duration=null (never expires) exceeds any finite team max.""" team = MagicMock(spec=LiteLLM_TeamTableCachedObj) team.metadata = {"team_member_key_duration": "30d"} with pytest.raises(HTTPException) as exc_info: await _common_key_generation_helper( - data=GenerateKeyRequest(user_id="user-1", duration="-1"), + data=GenerateKeyRequest(user_id="user-1", duration=None), user_api_key_dict=UserAPIKeyAuth( user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1" ), @@ -6521,8 +6521,8 @@ class TestCommonKeyGenerationHelperTeamDurationValidation: ) @pytest.mark.asyncio - async def test_no_duration_skips_team_validation(self): - """No duration provided — team validation is skipped.""" + async def test_duration_not_sent_skips_team_validation(self): + """Duration field absent from request — team validation is skipped.""" team = MagicMock(spec=LiteLLM_TeamTableCachedObj) team.metadata = {"team_member_key_duration": "5d"} @@ -6536,7 +6536,7 @@ class TestCommonKeyGenerationHelperTeamDurationValidation: "litellm.proxy.proxy_server.premium_user", False ): await _common_key_generation_helper( - data=GenerateKeyRequest(user_id="user-1", duration=None), + data=GenerateKeyRequest(user_id="user-1"), user_api_key_dict=UserAPIKeyAuth( user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1" ), @@ -6566,10 +6566,10 @@ class TestCommonKeyGenerationHelperTeamDurationValidation: ) @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.""" + async def test_team_max_not_set_skips_validation(self): + """No team_member_key_duration in metadata — any user duration is accepted.""" team = MagicMock(spec=LiteLLM_TeamTableCachedObj) - team.metadata = {"team_member_key_duration": "-1"} + team.metadata = {} # no team_member_key_duration with patch( "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn", @@ -6669,8 +6669,8 @@ class TestValidateRegenerateKeyDurationAgainstTeam: 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.""" + async def test_team_max_not_set_skips_validation(self): + """No team_member_key_duration in metadata — skip validation regardless of user duration.""" from litellm.proxy.management_endpoints.key_management_endpoints import ( _validate_regenerate_key_duration_against_team, ) @@ -6681,7 +6681,7 @@ class TestValidateRegenerateKeyDurationAgainstTeam: data = RegenerateKeyRequest(duration="999d") mock_team = MagicMock(spec=LiteLLM_TeamTableCachedObj) - mock_team.metadata = {"team_member_key_duration": "-1"} + mock_team.metadata = {} # no team_member_key_duration mock_prisma = AsyncMock() mock_cache = MagicMock() @@ -6691,7 +6691,7 @@ class TestValidateRegenerateKeyDurationAgainstTeam: new_callable=AsyncMock, return_value=mock_team, ): - # Should not raise — team max is "-1" (no limit) + # Should not raise — no team max set await _validate_regenerate_key_duration_against_team( data=data, key_in_db=mock_key, @@ -6790,7 +6790,7 @@ class TestValidateRegenerateKeyDurationAgainstTeam: mock_key = MagicMock(spec=LiteLLM_VerificationToken) mock_key.team_id = "team-123" - data = RegenerateKeyRequest(duration="-1") + data = RegenerateKeyRequest(duration=None) # null = never expires = infinite mock_team = MagicMock(spec=LiteLLM_TeamTableCachedObj) mock_team.metadata = {"team_member_key_duration": "30d"}