mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
commit
b9b27c2beb
13 changed files with 252 additions and 29 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogToolIndex_start_time_idx" ON "LiteLLM_SpendLogToolIndex"("start_time");
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
|
||||
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : 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 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
|
||||
counts its full spend toward each, so this attributes rather than partitions spend.
|
||||
</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>
|
||||
<CardContent>
|
||||
{topTools.length === 0 ? (
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -17976,6 +17976,11 @@ export interface paths {
|
|||
* 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.
|
||||
*/
|
||||
get: operations["get_tool_spend_v1_tool_spend_get"];
|
||||
put?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue