diff --git a/litellm/proxy/management_endpoints/tool_management_endpoints.py b/litellm/proxy/management_endpoints/tool_management_endpoints.py index 3f528c8c489..b6a445ef327 100644 --- a/litellm/proxy/management_endpoints/tool_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tool_management_endpoints.py @@ -232,7 +232,11 @@ async def get_tool_spend( now = datetime.now(timezone.utc) end_day = _parse_day_start(end_date) - window_floor = (end_day or now) - timedelta(days=TOOL_SPEND_MAX_WINDOW_DAYS) + # 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 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 150499dfbf1..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 @@ -232,6 +232,31 @@ class TestToolManagementEndpoints: 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=[])