mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
1bd603d1ac
commit
d8d6a704ab
4 changed files with 130 additions and 24 deletions
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue