diff --git a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py index a4c23937b98..7badb44e52f 100644 --- a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py +++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py @@ -51,7 +51,16 @@ class SpendLogCleanup: def _should_delete_spend_logs(self) -> bool: """ Determines if logs should be deleted based on the max retention period in settings. + Requires enterprise (premium) license. """ + from litellm.proxy.proxy_server import premium_user + + if premium_user is not True: + verbose_proxy_logger.warning( + "Spend log retention cleanup requires an enterprise license. Skipping." + ) + return False + retention_setting = self.general_settings.get( "maximum_spend_logs_retention_period" ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3e056c21614..c6b89b48f46 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -5663,8 +5663,9 @@ class ProxyConfig: Reschedule the spend log cleanup job based on current general_settings. This is called when maximum_spend_logs_retention_period is updated dynamically. If the retention period is None, the job will be removed. + Requires enterprise (premium) license. """ - global scheduler, general_settings, prisma_client + global scheduler, general_settings, prisma_client, premium_user if scheduler is None: return @@ -5675,9 +5676,9 @@ class ProxyConfig: except Exception: pass # Job might not exist, which is fine - # Schedule new job if retention period is set (not None) + # Schedule new job if retention period is set (not None) AND user is premium retention_period = general_settings.get("maximum_spend_logs_retention_period") - if retention_period is not None: + if retention_period is not None and premium_user is True: from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import ( SpendLogCleanup, ) @@ -5802,14 +5803,25 @@ class ProxyConfig: store_model_in_db = bool(value) general_settings["store_model_in_db"] = store_model_in_db - ## MAXIMUM SPEND LOGS RETENTION PERIOD ## + ## MAXIMUM SPEND LOGS RETENTION PERIOD (Enterprise only) ## if "maximum_spend_logs_retention_period" in _general_settings: - old_value = general_settings.get("maximum_spend_logs_retention_period") - new_value = _general_settings["maximum_spend_logs_retention_period"] - general_settings["maximum_spend_logs_retention_period"] = new_value - # Reschedule cleanup job if value changed (including when set to None) - if old_value != new_value: - await self._reschedule_spend_log_cleanup_job() + if premium_user is True: + old_value = general_settings.get( + "maximum_spend_logs_retention_period" + ) + new_value = _general_settings[ + "maximum_spend_logs_retention_period" + ] + general_settings[ + "maximum_spend_logs_retention_period" + ] = new_value + # Reschedule cleanup job if value changed (including when set to None) + if old_value != new_value: + await self._reschedule_spend_log_cleanup_job() + else: + verbose_proxy_logger.warning( + "maximum_spend_logs_retention_period requires an enterprise license. Ignoring setting." + ) def _update_config_fields( self, @@ -7972,7 +7984,7 @@ class ProxyStartupEvent: await cls._initialize_spend_tracking_background_jobs(scheduler=scheduler) ### SPEND LOG CLEANUP ### - if general_settings.get("maximum_spend_logs_retention_period") is not None: + if general_settings.get("maximum_spend_logs_retention_period") is not None and premium_user is True: spend_log_cleanup = SpendLogCleanup() cleanup_cron = general_settings.get("maximum_spend_logs_cleanup_cron") @@ -14821,6 +14833,19 @@ async def update_config( if config_info.general_settings is not None: existing = await _read_section("general_settings") updates = config_info.general_settings.dict(exclude_none=True) + + # Enterprise-only: maximum_spend_logs_retention_period + if ( + "maximum_spend_logs_retention_period" in updates + and premium_user is not True + ): + raise HTTPException( + status_code=403, + detail={ + "error": f"maximum_spend_logs_retention_period is an enterprise-only feature. {CommonProxyErrors.not_premium_user.value}" + }, + ) + for k, v in updates.items(): if k == "alert_to_webhook_url": if "alerting" not in existing: diff --git a/tests/test_litellm/proxy/test_spend_log_cleanup.py b/tests/test_litellm/proxy/test_spend_log_cleanup.py index a309dd64011..c3a3ffbae9a 100644 --- a/tests/test_litellm/proxy/test_spend_log_cleanup.py +++ b/tests/test_litellm/proxy/test_spend_log_cleanup.py @@ -119,7 +119,11 @@ def test_spend_log_cleanup_cron_scheduler_integration(): @pytest.mark.asyncio -async def test_should_delete_spend_logs(): +async def test_should_delete_spend_logs(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + # Test case 1: No retention set cleaner = SpendLogCleanup(general_settings={}) assert cleaner._should_delete_spend_logs() is False @@ -150,9 +154,26 @@ async def test_should_delete_spend_logs(): @pytest.mark.asyncio -async def test_cleanup_old_spend_logs_batch_deletion(): +async def test_should_delete_spend_logs_requires_premium(monkeypatch): + """Spend log retention cleanup should be blocked for non-premium users.""" + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", False) + + cleaner = SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": "7d"} + ) + assert cleaner._should_delete_spend_logs() is False + + +@pytest.mark.asyncio +async def test_cleanup_old_spend_logs_batch_deletion(monkeypatch): from unittest.mock import AsyncMock, MagicMock + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + # Setup Prisma client mock_prisma_client = MagicMock() mock_db = MagicMock() @@ -190,10 +211,14 @@ async def test_cleanup_old_spend_logs_batch_deletion(): @pytest.mark.asyncio -async def test_cleanup_old_spend_logs_retention_period_cutoff(): +async def test_cleanup_old_spend_logs_retention_period_cutoff(monkeypatch): """ Test that logs are filtered using correct cutoff based on retention """ + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + # Setup Prisma client mock_prisma_client = MagicMock() mock_db = MagicMock() @@ -223,7 +248,7 @@ async def test_cleanup_old_spend_logs_retention_period_cutoff(): @pytest.mark.asyncio -async def test_cleanup_drops_partitions_when_enabled_and_partitioned(): +async def test_cleanup_drops_partitions_when_enabled_and_partitioned(monkeypatch): """ With use_spend_logs_partitioning enabled and a partitioned table, cleanup must reclaim disk by dropping partitions AND still delete expired rows the @@ -232,6 +257,10 @@ async def test_cleanup_drops_partitions_when_enabled_and_partitioned(): """ from unittest.mock import AsyncMock, MagicMock + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + mock_prisma_client = MagicMock() mock_prisma_client.db.execute_raw = AsyncMock(return_value=0) @@ -261,7 +290,7 @@ async def test_cleanup_drops_partitions_when_enabled_and_partitioned(): @pytest.mark.asyncio -async def test_cleanup_uses_delete_when_partitioning_not_enabled(): +async def test_cleanup_uses_delete_when_partitioning_not_enabled(monkeypatch): """ Even against a partitioned table, the partition path must stay off until use_spend_logs_partitioning is explicitly enabled, so existing deployments @@ -269,6 +298,10 @@ async def test_cleanup_uses_delete_when_partitioning_not_enabled(): """ from unittest.mock import AsyncMock, MagicMock + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + mock_prisma_client = MagicMock() mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0]) @@ -293,13 +326,17 @@ async def test_cleanup_uses_delete_when_partitioning_not_enabled(): @pytest.mark.asyncio -async def test_cleanup_uses_delete_when_not_partitioned(): +async def test_cleanup_uses_delete_when_not_partitioned(monkeypatch): """ With the feature enabled but the table not actually partitioned (script not run yet), cleanup must keep using the batched DELETE path. """ from unittest.mock import AsyncMock, MagicMock + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + mock_prisma_client = MagicMock() mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0]) @@ -365,11 +402,15 @@ async def test_lock_not_released_when_not_acquired(): @pytest.mark.asyncio -async def test_integer_retention_treated_as_days(): +async def test_integer_retention_treated_as_days(monkeypatch): """ An integer value for maximum_spend_logs_retention_period should be treated as days (e.g., 3 → '3d' → 259200 seconds). """ + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + cleaner = SpendLogCleanup( general_settings={"maximum_spend_logs_retention_period": 3} ) @@ -378,10 +419,14 @@ async def test_integer_retention_treated_as_days(): assert cleaner.retention_seconds == 3 * 86400 # 3 days in seconds -def test_string_retention_still_works(): +def test_string_retention_still_works(monkeypatch): """ String values like '3d', '24h', '3600s' should continue to parse correctly. """ + import litellm.proxy.proxy_server as proxy_server_module + + monkeypatch.setattr(proxy_server_module, "premium_user", True) + cases = [ ("3d", 3 * 86400), ("24h", 24 * 3600), @@ -580,7 +625,9 @@ async def test_cleanup_releases_lock_after_persistent_batch_failures(monkeypatch """Even when batch deletion aborts due to consecutive failures, the pod lock must still be released so the next scheduled run isn't permanently blocked.""" import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module + import litellm.proxy.proxy_server as proxy_server_module + monkeypatch.setattr(proxy_server_module, "premium_user", True) monkeypatch.setattr( cleanup_module, "SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES", 2 ) diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx index 1c49035d36b..92a761dfd84 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx @@ -10,14 +10,16 @@ import { StoreRequestInSpendLogsParams, useStoreRequestInSpendLogs, } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { parseErrorMessage } from "@/components/shared/errorUtils"; import { ClockCircleOutlined } from "@ant-design/icons"; -import { Button, Card, Form, Input, Skeleton, Space, Switch, Typography } from "antd"; +import { Button, Card, Form, Input, Skeleton, Space, Switch, Tooltip, Typography } from "antd"; import React, { useMemo } from "react"; const LoggingSettings: React.FC = () => { const [form] = Form.useForm(); + const { premiumUser } = useAuthorized(); const { mutate, isPending } = useStoreRequestInSpendLogs(); const { mutate: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField(); const { data: proxyConfigData, isLoading: isLoadingConfig } = useProxyConfig(ConfigType.GENERAL_SETTINGS); @@ -109,17 +111,40 @@ const LoggingSettings: React.FC = () => { + Maximum Spend Logs Retention Period (Optional) + {!premiumUser && ( + + ✨ Enterprise Feature + + )} + + } name="maximum_spend_logs_retention_period" tooltip={ - proxyConfigData?.find((f) => f.field_name === "maximum_spend_logs_retention_period")?.field_description || - "Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit." + !premiumUser + ? "This feature requires an Enterprise license. Get a trial key at https://www.litellm.ai/enterprise#trial" + : proxyConfigData?.find((f) => f.field_name === "maximum_spend_logs_retention_period") + ?.field_description || + "Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit." } > {isLoadingConfig ? ( ) : ( - } /> + + } + disabled={!premiumUser} + /> + )}