diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260724000000_add_spend_log_tool_index_start_time_idx/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260724000000_add_spend_log_tool_index_start_time_idx/migration.sql
new file mode 100644
index 00000000000..548c3bd5683
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260724000000_add_spend_log_tool_index_start_time_idx/migration.sql
@@ -0,0 +1,2 @@
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogToolIndex_start_time_idx" ON "LiteLLM_SpendLogToolIndex"("start_time");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 23a9c086c73..6713b212314 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -1094,6 +1094,7 @@ model LiteLLM_SpendLogToolIndex {
@@id([request_id, tool_name])
@@index([tool_name, start_time])
+ @@index([start_time])
}
// Prompt table for storing prompt configurations
diff --git a/litellm/constants.py b/litellm/constants.py
index b9b9c0ba604..62d351cffe9 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -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(
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_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))
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 7e8d08e7cad..972c831073f 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -27417,7 +27417,7 @@
},
"/v1/tool/spend": {
"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",
"parameters": [
{
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 93f45fd1198..9bf3da0066d 100644
--- a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py
+++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py
@@ -73,30 +73,41 @@ class SpendLogCleanup:
)
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.
- Returns the total number of logs deleted.
+ Helper method to delete a table's rows older than the cutoff in batches.
+ 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
run_count = 0
consecutive_failures = 0
while True:
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
- # 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
try:
deleted_result = await prisma_client.db.execute_raw(
- """
- DELETE FROM "LiteLLM_SpendLogs"
- WHERE ("request_id", "startTime") IN (
- SELECT "request_id", "startTime" FROM "LiteLLM_SpendLogs"
- WHERE "startTime" < $1::timestamptz
- LIMIT $2
- )
- """,
+ delete_sql,
cutoff_date,
self.batch_size,
)
@@ -105,9 +116,10 @@ class SpendLogCleanup:
# the whole run — subsequent batches may still succeed.
consecutive_failures += 1
verbose_proxy_logger.exception(
- "Spend log cleanup batch failed "
+ "%s cleanup batch failed "
"(run_count=%d, consecutive_failures=%d, batch_size=%d, "
"cutoff=%s, total_deleted_so_far=%d): %s: %s",
+ table_name,
run_count,
consecutive_failures,
self.batch_size,
@@ -118,8 +130,8 @@ class SpendLogCleanup:
)
if consecutive_failures >= SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES:
verbose_proxy_logger.error(
- "Aborting spend log cleanup after %d consecutive batch "
- "failures; total deleted before abort: %d",
+ "Aborting %s cleanup after %d consecutive batch failures; total deleted before abort: %d",
+ table_name,
consecutive_failures,
total_deleted,
)
@@ -134,15 +146,15 @@ class SpendLogCleanup:
deleted_count = deleted_result
else:
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"
)
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:
- 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
total_deleted += deleted_count
@@ -153,6 +165,26 @@ class SpendLogCleanup:
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:
"""
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)
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:
# .exception() captures the traceback; str(e) alone on a Prisma/DB
# timeout is often empty and gives operators no signal to diagnose.
diff --git a/litellm/proxy/management_endpoints/tool_management_endpoints.py b/litellm/proxy/management_endpoints/tool_management_endpoints.py
index ca606e07cee..b6a445ef327 100644
--- a/litellm/proxy/management_endpoints/tool_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/tool_management_endpoints.py
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
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.auth.user_api_key_auth import user_api_key_auth
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
attributions. ``total_spend`` is the deduplicated spend of every request that
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
@@ -226,9 +232,19 @@ async def get_tool_spend(
now = datetime.now(timezone.utc)
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
+ # 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(
"""
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
WHERE ti.start_time >= ($1::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
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
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
FROM "LiteLLM_SpendLogToolIndex" ti
WHERE ti.request_id = sl.request_id
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index 23a9c086c73..6713b212314 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -1094,6 +1094,7 @@ model LiteLLM_SpendLogToolIndex {
@@id([request_id, tool_name])
@@index([tool_name, start_time])
+ @@index([start_time])
}
// Prompt table for storing prompt configurations
diff --git a/schema.prisma b/schema.prisma
index 23a9c086c73..6713b212314 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -1094,6 +1094,7 @@ model LiteLLM_SpendLogToolIndex {
@@id([request_id, tool_name])
@@index([tool_name, start_time])
+ @@index([start_time])
}
// Prompt table for storing prompt configurations
diff --git a/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py
index 351f125052d..c908250fa64 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py
@@ -9,7 +9,7 @@ imports these inside function bodies to avoid circular imports.
import os
import sys
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch
@@ -203,6 +203,71 @@ class TestToolManagementEndpoints:
assert tuple(call.args[1:]) == expected_binds
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(
"query",
[
diff --git a/tests/test_litellm/proxy/test_spend_log_cleanup.py b/tests/test_litellm/proxy/test_spend_log_cleanup.py
index a309dd64011..f969b040a0d 100644
--- a/tests/test_litellm/proxy/test_spend_log_cleanup.py
+++ b/tests/test_litellm/proxy/test_spend_log_cleanup.py
@@ -157,8 +157,9 @@ async def test_cleanup_old_spend_logs_batch_deletion():
mock_prisma_client = MagicMock()
mock_db = MagicMock()
- # Mock execute_raw to return deleted counts
- mock_db.execute_raw = AsyncMock(side_effect=[1000, 500, 0])
+ # Mock execute_raw to return deleted counts (3 spend-log batches, then the
+ # tool-index cleanup's first batch returning 0)
+ mock_db.execute_raw = AsyncMock(side_effect=[1000, 500, 0, 0])
# Wire up mocks
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)
# 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
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
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
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()
delete_sql = mock_prisma_client.db.execute_raw.call_args_list[0][0][0]
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
@@ -270,7 +279,7 @@ async def test_cleanup_uses_delete_when_partitioning_not_enabled():
from unittest.mock import AsyncMock, 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.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
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.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)
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]
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
+@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
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 —
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx
index 0e2f16c5d93..ab1cd146adc 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx
@@ -137,4 +137,31 @@ describe("UsageTab", () => {
const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]");
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();
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx
index 9216dbfaad2..6e2da4456d3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx
@@ -108,6 +108,7 @@ const UsageTab: React.FC
+ Tool spend is capped at 30 days before the end of the selected range; showing spend since{" "} + {toolSpend?.start_date}. +
+ )}