Merge pull request #34582 from BerriAI/litellm_toolspend_30d_bound

fix(proxy): cap /v1/tool/spend window at 30 days and bound every SpendLogs read
This commit is contained in:
tin-berri 2026-07-24 20:05:42 -07:00 committed by GitHub
commit b9b27c2beb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 252 additions and 29 deletions

View file

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogToolIndex_start_time_idx" ON "LiteLLM_SpendLogToolIndex"("start_time");

View file

@ -1094,6 +1094,7 @@ model LiteLLM_SpendLogToolIndex {
@@id([request_id, tool_name]) @@id([request_id, tool_name])
@@index([tool_name, start_time]) @@index([tool_name, start_time])
@@index([start_time])
} }
// Prompt table for storing prompt configurations // Prompt table for storing prompt configurations

View file

@ -1455,6 +1455,7 @@ SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES = int(os.getenv("SPEND_LOG_CLEA
SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS = float( SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS = float(
os.getenv("SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.5) os.getenv("SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.5)
) )
TOOL_SPEND_MAX_WINDOW_DAYS = 30
SPEND_LOG_PARTITION_INTERVAL = os.getenv("SPEND_LOG_PARTITION_INTERVAL", "day") SPEND_LOG_PARTITION_INTERVAL = os.getenv("SPEND_LOG_PARTITION_INTERVAL", "day")
SPEND_LOG_PARTITION_PRECREATE_AHEAD = int(os.getenv("SPEND_LOG_PARTITION_PRECREATE_AHEAD", 7)) SPEND_LOG_PARTITION_PRECREATE_AHEAD = int(os.getenv("SPEND_LOG_PARTITION_PRECREATE_AHEAD", 7))
SPEND_LOG_QUEUE_SIZE_THRESHOLD = int(os.getenv("SPEND_LOG_QUEUE_SIZE_THRESHOLD", 100)) SPEND_LOG_QUEUE_SIZE_THRESHOLD = int(os.getenv("SPEND_LOG_QUEUE_SIZE_THRESHOLD", 100))

View file

@ -27417,7 +27417,7 @@
}, },
"/v1/tool/spend": { "/v1/tool/spend": {
"get": { "get": {
"description": "Spend attributed to each tool over a date range, for the Cost Optimization dashboard.\n\nJoins ``LiteLLM_SpendLogToolIndex`` (which tool names ran on which request) to\n``LiteLLM_SpendLogs`` (what the request cost). A request that used multiple tools\ncounts its full spend toward each of those tools, so per-tool numbers are\nattributions. ``total_spend`` is the deduplicated spend of every request that\ncalled at least one tool in the window, so it never double counts.", "description": "Spend attributed to each tool over a date range, for the Cost Optimization dashboard.\n\nJoins ``LiteLLM_SpendLogToolIndex`` (which tool names ran on which request) to\n``LiteLLM_SpendLogs`` (what the request cost). A request that used multiple tools\ncounts its full spend toward each of those tools, so per-tool numbers are\nattributions. ``total_spend`` is the deduplicated spend of every request that\ncalled at least one tool in the window, so it never double counts.\n\n``start_date`` is clamped to at most 30 days before ``end_date`` (serving up to\n31 calendar dates inclusive, the same width as the endpoint's default window):\na wider requested range is clamped, and the response's ``start_date`` reflects\nthe effective window actually served.",
"operationId": "get_tool_spend_v1_tool_spend_get", "operationId": "get_tool_spend_v1_tool_spend_get",
"parameters": [ "parameters": [
{ {

View file

@ -73,30 +73,41 @@ class SpendLogCleanup:
) )
return False return False
async def _delete_old_logs(self, prisma_client: PrismaClient, cutoff_date: datetime) -> int: async def _delete_old_rows_batched(
self,
prisma_client: PrismaClient,
cutoff_date: datetime,
table_name: str,
key_columns: tuple[str, ...],
time_column: str,
) -> int:
""" """
Helper method to delete old logs in batches. Helper method to delete a table's rows older than the cutoff in batches.
Returns the total number of logs deleted. Returns the total number of rows deleted.
""" """
key_list = ", ".join(f'"{col}"' for col in key_columns)
delete_sql = f"""
DELETE FROM "{table_name}"
WHERE ({key_list}) IN (
SELECT {key_list} FROM "{table_name}"
WHERE "{time_column}" < $1::timestamptz
LIMIT $2
)
"""
total_deleted = 0 total_deleted = 0
run_count = 0 run_count = 0
consecutive_failures = 0 consecutive_failures = 0
while True: while True:
if run_count > SPEND_LOG_RUN_LOOPS: if run_count > SPEND_LOG_RUN_LOOPS:
verbose_proxy_logger.info("Max logs deleted - 1,00,000, rest of the logs will be deleted in next run") verbose_proxy_logger.info(
"Max batches reached for %s cleanup, remaining rows will be deleted in next run", table_name
)
break break
# Step 1: Find logs and delete them in one go without fetching to application # Step 1: Find rows and delete them in one go without fetching to application
# Delete in batches, limited by self.batch_size # Delete in batches, limited by self.batch_size
try: try:
deleted_result = await prisma_client.db.execute_raw( deleted_result = await prisma_client.db.execute_raw(
""" delete_sql,
DELETE FROM "LiteLLM_SpendLogs"
WHERE ("request_id", "startTime") IN (
SELECT "request_id", "startTime" FROM "LiteLLM_SpendLogs"
WHERE "startTime" < $1::timestamptz
LIMIT $2
)
""",
cutoff_date, cutoff_date,
self.batch_size, self.batch_size,
) )
@ -105,9 +116,10 @@ class SpendLogCleanup:
# the whole run — subsequent batches may still succeed. # the whole run — subsequent batches may still succeed.
consecutive_failures += 1 consecutive_failures += 1
verbose_proxy_logger.exception( verbose_proxy_logger.exception(
"Spend log cleanup batch failed " "%s cleanup batch failed "
"(run_count=%d, consecutive_failures=%d, batch_size=%d, " "(run_count=%d, consecutive_failures=%d, batch_size=%d, "
"cutoff=%s, total_deleted_so_far=%d): %s: %s", "cutoff=%s, total_deleted_so_far=%d): %s: %s",
table_name,
run_count, run_count,
consecutive_failures, consecutive_failures,
self.batch_size, self.batch_size,
@ -118,8 +130,8 @@ class SpendLogCleanup:
) )
if consecutive_failures >= SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES: if consecutive_failures >= SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES:
verbose_proxy_logger.error( verbose_proxy_logger.error(
"Aborting spend log cleanup after %d consecutive batch " "Aborting %s cleanup after %d consecutive batch failures; total deleted before abort: %d",
"failures; total deleted before abort: %d", table_name,
consecutive_failures, consecutive_failures,
total_deleted, total_deleted,
) )
@ -134,15 +146,15 @@ class SpendLogCleanup:
deleted_count = deleted_result deleted_count = deleted_result
else: else:
verbose_proxy_logger.error( verbose_proxy_logger.error(
f"Unexpected execute_raw return type for spend log cleanup: {type(deleted_result)}; " f"Unexpected execute_raw return type for {table_name} cleanup: {type(deleted_result)}; "
"aborting cleanup to avoid infinite loop" "aborting cleanup to avoid infinite loop"
) )
break break
verbose_proxy_logger.info(f"Deleted {deleted_count} logs in this batch") verbose_proxy_logger.info(f"Deleted {deleted_count} {table_name} rows in this batch")
if deleted_count == 0: if deleted_count == 0:
verbose_proxy_logger.info(f"No more logs to delete. Total deleted: {total_deleted}") verbose_proxy_logger.info(f"No more {table_name} rows to delete. Total deleted: {total_deleted}")
break break
total_deleted += deleted_count total_deleted += deleted_count
@ -153,6 +165,26 @@ class SpendLogCleanup:
return total_deleted return total_deleted
async def _delete_old_logs(self, prisma_client: PrismaClient, cutoff_date: datetime) -> int:
return await self._delete_old_rows_batched(
prisma_client,
cutoff_date,
table_name="LiteLLM_SpendLogs",
key_columns=("request_id", "startTime"),
time_column="startTime",
)
async def _delete_old_tool_index_rows(self, prisma_client: PrismaClient, cutoff_date: datetime) -> int:
# SpendLogToolIndex rows are derived from spend logs, so they expire on the
# same cutoff; rows older than retention point at already-deleted logs.
return await self._delete_old_rows_batched(
prisma_client,
cutoff_date,
table_name="LiteLLM_SpendLogToolIndex",
key_columns=("request_id", "tool_name"),
time_column="start_time",
)
async def cleanup_old_spend_logs(self, prisma_client: PrismaClient) -> None: async def cleanup_old_spend_logs(self, prisma_client: PrismaClient) -> None:
""" """
Main cleanup function. Deletes old spend logs in batches. Main cleanup function. Deletes old spend logs in batches.
@ -209,6 +241,9 @@ class SpendLogCleanup:
total_deleted = await self._delete_old_logs(prisma_client, cutoff_date) total_deleted = await self._delete_old_logs(prisma_client, cutoff_date)
verbose_proxy_logger.info(f"Deleted {total_deleted} logs") verbose_proxy_logger.info(f"Deleted {total_deleted} logs")
index_deleted = await self._delete_old_tool_index_rows(prisma_client, cutoff_date)
verbose_proxy_logger.info(f"Deleted {index_deleted} expired tool index rows")
except Exception as e: except Exception as e:
# .exception() captures the traceback; str(e) alone on a Prisma/DB # .exception() captures the traceback; str(e) alone on a Prisma/DB
# timeout is often empty and gives operators no signal to diagnose. # timeout is often empty and gives operators no signal to diagnose.

View file

@ -21,6 +21,7 @@ if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient from litellm.proxy.utils import PrismaClient
from litellm._logging import verbose_proxy_logger from litellm._logging import verbose_proxy_logger
from litellm.constants import TOOL_SPEND_MAX_WINDOW_DAYS
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.repositories.object_permission_repository import ObjectPermissionRepository from litellm.repositories.object_permission_repository import ObjectPermissionRepository
@ -209,6 +210,11 @@ async def get_tool_spend(
counts its full spend toward each of those tools, so per-tool numbers are counts its full spend toward each of those tools, so per-tool numbers are
attributions. ``total_spend`` is the deduplicated spend of every request that attributions. ``total_spend`` is the deduplicated spend of every request that
called at least one tool in the window, so it never double counts. called at least one tool in the window, so it never double counts.
``start_date`` is clamped to at most 30 days before ``end_date`` (serving up to
31 calendar dates inclusive, the same width as the endpoint's default window):
a wider requested range is clamped, and the response's ``start_date`` reflects
the effective window actually served.
""" """
from litellm.proxy.proxy_server import prisma_client from litellm.proxy.proxy_server import prisma_client
@ -226,9 +232,19 @@ async def get_tool_spend(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
end_day = _parse_day_start(end_date) end_day = _parse_day_start(end_date)
start_dt = _parse_day_start(start_date) or ((end_day or now) - timedelta(days=30)) # Anchor the floor to a midnight so the clamp compares dates with dates:
# parsed start_dates are midnight-aligned, and a floor carrying now's
# time-of-day would invisibly truncate an explicit start_date to mid-day.
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
window_floor = (end_day or today) - timedelta(days=TOOL_SPEND_MAX_WINDOW_DAYS)
start_dt = _parse_day_start(start_date) or window_floor
if start_dt < window_floor:
start_dt = window_floor
end_exclusive = (end_day + timedelta(days=1)) if end_day else now end_exclusive = (end_day + timedelta(days=1)) if end_day else now
# ti.start_time defines the window in both queries; the sl."startTime" bounds
# exist only so the planner can use the SpendLogs startTime index, and carry a
# 1s margin because the two writers can disagree by ~1ms on the same request.
rows = await prisma_client.db.query_raw( rows = await prisma_client.db.query_raw(
""" """
SELECT to_char(ti.start_time, 'YYYY-MM-DD') AS date, SELECT to_char(ti.start_time, 'YYYY-MM-DD') AS date,
@ -240,6 +256,8 @@ async def get_tool_spend(
JOIN "LiteLLM_SpendLogs" sl ON sl.request_id = ti.request_id JOIN "LiteLLM_SpendLogs" sl ON sl.request_id = ti.request_id
WHERE ti.start_time >= ($1::timestamptz AT TIME ZONE 'UTC') WHERE ti.start_time >= ($1::timestamptz AT TIME ZONE 'UTC')
AND ti.start_time < ($2::timestamptz AT TIME ZONE 'UTC') AND ti.start_time < ($2::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') - interval '1 second'
AND sl."startTime" < ($2::timestamptz AT TIME ZONE 'UTC') + interval '1 second'
GROUP BY date, ti.tool_name GROUP BY date, ti.tool_name
ORDER BY date ASC, spend DESC ORDER BY date ASC, spend DESC
""", """,
@ -250,7 +268,9 @@ async def get_tool_spend(
""" """
SELECT COALESCE(SUM(sl.spend), 0)::double precision AS total_spend SELECT COALESCE(SUM(sl.spend), 0)::double precision AS total_spend
FROM "LiteLLM_SpendLogs" sl FROM "LiteLLM_SpendLogs" sl
WHERE EXISTS ( WHERE sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') - interval '1 second'
AND sl."startTime" < ($2::timestamptz AT TIME ZONE 'UTC') + interval '1 second'
AND EXISTS (
SELECT 1 SELECT 1
FROM "LiteLLM_SpendLogToolIndex" ti FROM "LiteLLM_SpendLogToolIndex" ti
WHERE ti.request_id = sl.request_id WHERE ti.request_id = sl.request_id

View file

@ -1094,6 +1094,7 @@ model LiteLLM_SpendLogToolIndex {
@@id([request_id, tool_name]) @@id([request_id, tool_name])
@@index([tool_name, start_time]) @@index([tool_name, start_time])
@@index([start_time])
} }
// Prompt table for storing prompt configurations // Prompt table for storing prompt configurations

View file

@ -1094,6 +1094,7 @@ model LiteLLM_SpendLogToolIndex {
@@id([request_id, tool_name]) @@id([request_id, tool_name])
@@index([tool_name, start_time]) @@index([tool_name, start_time])
@@index([start_time])
} }
// Prompt table for storing prompt configurations // Prompt table for storing prompt configurations

View file

@ -9,7 +9,7 @@ imports these inside function bodies to avoid circular imports.
import os import os
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@ -203,6 +203,71 @@ class TestToolManagementEndpoints:
assert tuple(call.args[1:]) == expected_binds assert tuple(call.args[1:]) == expected_binds
assert resp.json()["end_date"] == "2026-07-02" assert resp.json()["end_date"] == "2026-07-02"
def test_tool_spend_start_clamped_to_30_days_before_end(self):
# Clamped floor is end_date minus 30 days, serving up to 31 calendar dates
# inclusive: deliberately the same width as the endpoint's default window,
# so the dashboard's default range never triggers the clamp.
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-01-01&end_date=2026-07-01")
assert resp.status_code == 200
expected_binds = (
datetime(2026, 6, 1, tzinfo=timezone.utc).isoformat(),
datetime(2026, 7, 2, tzinfo=timezone.utc).isoformat(),
)
assert prisma.db.query_raw.await_count == 2
for call in prisma.db.query_raw.await_args_list:
assert tuple(call.args[1:]) == expected_binds
assert resp.json()["start_date"] == "2026-06-01"
assert resp.json()["end_date"] == "2026-07-01"
def test_tool_spend_range_within_cap_is_not_clamped(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-06-25&end_date=2026-07-01")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
assert call.args[1] == datetime(2026, 6, 25, tzinfo=timezone.utc).isoformat()
assert resp.json()["start_date"] == "2026-06-25"
def test_tool_spend_start_honored_when_end_date_omitted(self):
# Regression: with end_date omitted the floor anchors to today's UTC
# midnight, not now's time-of-day, so an explicit start_date exactly 30
# days back is served from midnight rather than truncated to mid-day.
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
floor_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get(f"/v1/tool/spend?start_date={floor_day.strftime('%Y-%m-%d')}")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
assert call.args[1] == floor_day.isoformat()
assert resp.json()["start_date"] == floor_day.strftime("%Y-%m-%d")
def test_tool_spend_clamp_without_end_date_lands_on_midnight(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
floor_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2020-01-01")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
assert call.args[1] == floor_day.isoformat()
assert resp.json()["start_date"] == floor_day.strftime("%Y-%m-%d")
def test_tool_spend_total_query_bounds_outer_spendlogs_scan(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
sql = call.args[0]
assert 'sl."startTime" >=' in sql
assert 'sl."startTime" <' in sql
@pytest.mark.parametrize( @pytest.mark.parametrize(
"query", "query",
[ [

View file

@ -157,8 +157,9 @@ async def test_cleanup_old_spend_logs_batch_deletion():
mock_prisma_client = MagicMock() mock_prisma_client = MagicMock()
mock_db = MagicMock() mock_db = MagicMock()
# Mock execute_raw to return deleted counts # Mock execute_raw to return deleted counts (3 spend-log batches, then the
mock_db.execute_raw = AsyncMock(side_effect=[1000, 500, 0]) # tool-index cleanup's first batch returning 0)
mock_db.execute_raw = AsyncMock(side_effect=[1000, 500, 0, 0])
# Wire up mocks # Wire up mocks
mock_prisma_client.db = mock_db mock_prisma_client.db = mock_db
@ -178,7 +179,7 @@ async def test_cleanup_old_spend_logs_batch_deletion():
await cleaner.cleanup_old_spend_logs(mock_prisma_client) await cleaner.cleanup_old_spend_logs(mock_prisma_client)
# Validate batching and deletion via raw SQL # Validate batching and deletion via raw SQL
assert mock_db.execute_raw.call_count == 3 assert mock_db.execute_raw.call_count == 4
# Check the first call argument # Check the first call argument
call_args_sql = mock_db.execute_raw.call_args_list[0][0][0] call_args_sql = mock_db.execute_raw.call_args_list[0][0][0]
@ -188,6 +189,10 @@ async def test_cleanup_old_spend_logs_batch_deletion():
# reusing x-litellm-call-id take out a fresh row alongside the expired one # reusing x-litellm-call-id take out a fresh row alongside the expired one
assert 'WHERE ("request_id", "startTime") IN' in call_args_sql assert 'WHERE ("request_id", "startTime") IN' in call_args_sql
# After spend logs, the derived tool index rows expire on the same cutoff
tool_index_sql = mock_db.execute_raw.call_args_list[3][0][0]
assert 'DELETE FROM "LiteLLM_SpendLogToolIndex"' in tool_index_sql
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cleanup_old_spend_logs_retention_period_cutoff(): async def test_cleanup_old_spend_logs_retention_period_cutoff():
@ -258,6 +263,10 @@ async def test_cleanup_drops_partitions_when_enabled_and_partitioned():
partition_manager.drop_partitions_older_than.assert_awaited_once() partition_manager.drop_partitions_older_than.assert_awaited_once()
delete_sql = mock_prisma_client.db.execute_raw.call_args_list[0][0][0] delete_sql = mock_prisma_client.db.execute_raw.call_args_list[0][0][0]
assert 'DELETE FROM "LiteLLM_SpendLogs"' in delete_sql assert 'DELETE FROM "LiteLLM_SpendLogs"' in delete_sql
# Partition drops only reclaim spend logs; the tool index must still be
# cleaned row-wise on the same run
all_sql = [c[0][0] for c in mock_prisma_client.db.execute_raw.call_args_list]
assert any('DELETE FROM "LiteLLM_SpendLogToolIndex"' in s for s in all_sql)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -270,7 +279,7 @@ async def test_cleanup_uses_delete_when_partitioning_not_enabled():
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
mock_prisma_client = MagicMock() mock_prisma_client = MagicMock()
mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0]) mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0])
partition_manager = MagicMock() partition_manager = MagicMock()
partition_manager.is_partitioned = AsyncMock(return_value=True) partition_manager.is_partitioned = AsyncMock(return_value=True)
@ -301,7 +310,7 @@ async def test_cleanup_uses_delete_when_not_partitioned():
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
mock_prisma_client = MagicMock() mock_prisma_client = MagicMock()
mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0]) mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0])
partition_manager = MagicMock() partition_manager = MagicMock()
partition_manager.is_partitioned = AsyncMock(return_value=False) partition_manager.is_partitioned = AsyncMock(return_value=False)
@ -320,7 +329,7 @@ async def test_cleanup_uses_delete_when_not_partitioned():
await cleaner.cleanup_old_spend_logs(mock_prisma_client) await cleaner.cleanup_old_spend_logs(mock_prisma_client)
partition_manager.drop_partitions_older_than.assert_not_awaited() partition_manager.drop_partitions_older_than.assert_not_awaited()
assert mock_prisma_client.db.execute_raw.await_count == 2 assert mock_prisma_client.db.execute_raw.await_count == 3
delete_sql = mock_prisma_client.db.execute_raw.call_args_list[0][0][0] delete_sql = mock_prisma_client.db.execute_raw.call_args_list[0][0][0]
assert 'DELETE FROM "LiteLLM_SpendLogs"' in delete_sql assert 'DELETE FROM "LiteLLM_SpendLogs"' in delete_sql
@ -437,6 +446,55 @@ async def test_delete_old_logs_continues_on_valid_int_return():
assert total_deleted == 800 assert total_deleted == 800
@pytest.mark.asyncio
async def test_delete_old_rows_stops_at_max_batches(monkeypatch):
"""The run-loop backstop must halt a cleanup that keeps finding rows, so a
huge backlog is spread across scheduled runs instead of one unbounded loop."""
import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module
monkeypatch.setattr(cleanup_module, "SPEND_LOG_RUN_LOOPS", 2)
mock_prisma_client = MagicMock()
mock_db = MagicMock()
mock_db.execute_raw = AsyncMock(return_value=1000)
mock_prisma_client.db = mock_db
cleaner = SpendLogCleanup(
general_settings={"maximum_spend_logs_retention_period": "7d"}
)
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date)
# run_count exceeds the cap only after 3 full batches (0, 1, 2)
assert mock_db.execute_raw.call_count == 3
assert total_deleted == 3000
@pytest.mark.asyncio
async def test_delete_old_tool_index_rows_deletes_on_composite_key():
"""Tool index rows are derived from spend logs and expire on the same cutoff;
the delete must match on the table's composite primary key."""
mock_prisma_client = MagicMock()
mock_db = MagicMock()
mock_db.execute_raw = AsyncMock(side_effect=[5, 0])
mock_prisma_client.db = mock_db
cleaner = SpendLogCleanup(
general_settings={"maximum_spend_logs_retention_period": "7d"}
)
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
total_deleted = await cleaner._delete_old_tool_index_rows(mock_prisma_client, cutoff_date)
assert total_deleted == 5
delete_sql = mock_db.execute_raw.call_args_list[0][0][0]
assert 'DELETE FROM "LiteLLM_SpendLogToolIndex"' in delete_sql
assert 'WHERE ("request_id", "tool_name") IN' in delete_sql
assert '"start_time" <' in delete_sql
assert mock_db.execute_raw.call_args_list[0][0][1] == cutoff_date
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_old_logs_continues_after_single_batch_failure(monkeypatch): async def test_delete_old_logs_continues_after_single_batch_failure(monkeypatch):
"""A single batch failure (e.g. DB timeout) must not abort the whole run — """A single batch failure (e.g. DB timeout) must not abort the whole run —

View file

@ -137,4 +137,31 @@ describe("UsageTab", () => {
const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]"); const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]");
expect(series[0]).toMatchObject({ tool_name: "search", spend: 4.0 }); expect(series[0]).toMatchObject({ tool_name: "search", spend: 4.0 });
}); });
it("notes the 30-day cap when the server clamps the tool spend window", async () => {
const toolSpend = {
by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }],
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
total_spend: 4.0,
start_date: "2026-07-05",
end_date: "2026-07-14",
};
const { findByText } = renderWith([day("2026-07-12", {})], toolSpend);
expect(await findByText(/capped at 30 days before the end of the selected range/)).toBeInTheDocument();
});
it("shows no cap note when the served window matches the request", async () => {
const toolSpend = {
by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }],
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
total_spend: 4.0,
start_date: "2026-07-01",
end_date: "2026-07-14",
};
const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], toolSpend);
await findAllByTestId("bar-chart");
expect(queryByText(/capped at 30 days before the end of the selected range/)).not.toBeInTheDocument();
});
}); });

View file

@ -108,6 +108,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null; const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
const toolSpendLoading = toolSpendEnabled && toolSpend === null; const toolSpendLoading = toolSpendEnabled && toolSpend === null;
const toolSpendWindowClamped = !!toolSpend?.start_date && !!startTime && toolSpend.start_date > isoDay(startTime);
const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]); const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]);
const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]); const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]);
@ -211,6 +212,12 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
Spend on requests that called each tool (MCP and client-side tools). A request that used multiple tools Spend on requests that called each tool (MCP and client-side tools). A request that used multiple tools
counts its full spend toward each, so this attributes rather than partitions spend. counts its full spend toward each, so this attributes rather than partitions spend.
</p> </p>
{toolSpendWindowClamped && (
<p className="text-xs text-muted-foreground">
Tool spend is capped at 30 days before the end of the selected range; showing spend since{" "}
{toolSpend?.start_date}.
</p>
)}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{topTools.length === 0 ? ( {topTools.length === 0 ? (

View file

@ -17976,6 +17976,11 @@ export interface paths {
* counts its full spend toward each of those tools, so per-tool numbers are * counts its full spend toward each of those tools, so per-tool numbers are
* attributions. ``total_spend`` is the deduplicated spend of every request that * attributions. ``total_spend`` is the deduplicated spend of every request that
* called at least one tool in the window, so it never double counts. * called at least one tool in the window, so it never double counts.
*
* ``start_date`` is clamped to at most 30 days before ``end_date`` (serving up to
* 31 calendar dates inclusive, the same width as the endpoint's default window):
* a wider requested range is clamped, and the response's ``start_date`` reflects
* the effective window actually served.
*/ */
get: operations["get_tool_spend_v1_tool_spend_get"]; get: operations["get_tool_spend_v1_tool_spend_get"];
put?: never; put?: never;