fix: gate log retention policy behind enterprise license

The maximum_spend_logs_retention_period feature was accessible to all
users without an enterprise license check. This adds enterprise gating
at multiple layers:

Backend:
- SpendLogCleanup._should_delete_spend_logs() now checks premium_user
- Startup scheduling in proxy_server.py checks premium_user
- Dynamic rescheduling in _reschedule_spend_log_cleanup_job checks premium_user
- _update_general_settings skips retention config for non-premium users
- /config/update API returns 403 for non-premium users setting retention

Frontend:
- LoggingSettings.tsx disables retention input for non-premium users
- Shows 'Enterprise Feature' link for non-premium users

Tests:
- All existing tests updated to mock premium_user=True
- New test_should_delete_spend_logs_requires_premium verifies the gate

Fixes BerriAI/litellm#enterprise-retention-gate
This commit is contained in:
Viktor AI 2026-06-19 16:09:00 +00:00
parent 1bd603d1ac
commit d8d6a704ab
4 changed files with 130 additions and 24 deletions

View file

@ -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"
)

View file

@ -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:

View file

@ -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
)

View file

@ -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 = () => {
</Form.Item>
<Form.Item
label="Maximum Spend Logs Retention Period (Optional)"
label={
<span>
Maximum Spend Logs Retention Period (Optional)
{!premiumUser && (
<a
href="https://www.litellm.ai/enterprise#trial"
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: 8, fontSize: 12 }}
>
Enterprise Feature
</a>
)}
</span>
}
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 ? (
<Skeleton.Input active block />
) : (
<Input placeholder="e.g., 7d, 30d" prefix={<ClockCircleOutlined />} />
<Tooltip title={!premiumUser ? "Requires Enterprise license" : undefined}>
<Input
placeholder="e.g., 7d, 30d"
prefix={<ClockCircleOutlined />}
disabled={!premiumUser}
/>
</Tooltip>
)}
</Form.Item>