diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 84278f4619d..67e10b37e3c 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -324,7 +324,7 @@ SELECT model, SUM(spend) AS spend FROM "LiteLLM_SpendLogs" -WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') AND "startTime" <= ($2::timestamptz AT TIME ZONE 'UTC') +WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') AND "startTime" < ($2::timestamptz AT TIME ZONE 'UTC') {filter_sql} GROUP BY 1, 2, 3, 4 ORDER BY 1 @@ -3584,14 +3584,14 @@ async def view_spend_logs( start_date_obj: Final = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) end_date_obj: Final = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) - # Convert to ISO format strings for Prisma + # end_date is a calendar day, so the window ends at the next midnight (exclusive) start_date_iso: Final = start_date_obj.isoformat() - end_date_iso: Final = end_date_obj.isoformat() + end_date_iso: Final = (end_date_obj + timedelta(days=1)).isoformat() filter_query: Final[dict[str, object]] = { "startTime": { "gte": start_date_iso, # Greater than or equal to Start Date - "lte": end_date_iso, # Less than or equal to End Date + "lt": end_date_iso, # Before the day after End Date } } diff --git a/tests/unit/proxy/spend_tracking/test_spend_logs_date_window.py b/tests/unit/proxy/spend_tracking/test_spend_logs_date_window.py new file mode 100644 index 00000000000..9eea52ead78 --- /dev/null +++ b/tests/unit/proxy/spend_tracking/test_spend_logs_date_window.py @@ -0,0 +1,81 @@ +"""/spend/logs treats end_date as a whole calendar day (#40216).""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import Response + +import litellm.proxy.spend_tracking.spend_management_endpoints as spend_endpoints +from litellm.proxy import proxy_server +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _spend_logs_daily_summary_sql, + view_spend_logs, +) + + +def _admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin") + + +def test_daily_summary_sql_uses_exclusive_upper_bound(): + sql, _ = _spend_logs_daily_summary_sql( + start_date_iso="2026-09-07T00:00:00+00:00", + end_date_iso="2026-09-08T00:00:00+00:00", + api_key=None, + request_id=None, + user_id=None, + ) + assert "\"startTime\" < ($2::timestamptz AT TIME ZONE 'UTC')" in sql + assert "<= ($2" not in sql + + +@pytest.mark.asyncio +async def test_summary_queries_through_end_of_end_date(monkeypatch): + prisma = MagicMock() + prisma.db.query_raw = AsyncMock( + return_value=[{"day": "2026-09-08", "api_key": "k", "user": "u", "model": "m", "spend": 1.5}] + ) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + + result = await view_spend_logs( + fastapi_response=Response(), + api_key=None, + user_id=None, + request_id=None, + start_date="2026-09-08", + end_date="2026-09-08", + summarize=True, + user_api_key_dict=_admin(), + ) + + params = prisma.db.query_raw.call_args[0][1:] + assert params[0] == "2026-09-08T00:00:00+00:00" + assert params[1] == "2026-09-09T00:00:00+00:00" + # The real end-day row is returned and no synthetic zero row is appended. + assert len(result) == 1 + assert result[0]["spend"] == 1.5 + + +@pytest.mark.asyncio +async def test_unsummarized_filter_includes_end_date(monkeypatch): + find_spend_logs = AsyncMock(return_value=[]) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + monkeypatch.setattr(spend_endpoints, "_find_spend_logs", find_spend_logs) + + await view_spend_logs( + fastapi_response=Response(), + api_key=None, + user_id=None, + request_id=None, + start_date="2026-09-07", + end_date="2026-09-07", + summarize=False, + user_api_key_dict=_admin(), + ) + + where = find_spend_logs.call_args.kwargs["where"] + assert where["startTime"] == { + "gte": "2026-09-07T00:00:00+00:00", + "lt": "2026-09-08T00:00:00+00:00", + } diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index 23bbe521670..2bdde01a7b9 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -4497,7 +4497,7 @@ async def test_view_spend_logs_summarize_groups_by_day_in_sql(client, monkeypatc assert not hasattr(mock_prisma_client.db, "group_by") assert mock_prisma_client.db.captured_params == ( "2024-01-01T00:00:00+00:00", - "2024-01-03T00:00:00+00:00", + "2024-01-04T00:00:00+00:00", "hashed::sk-abc", "req-123", "u1", @@ -4591,7 +4591,7 @@ async def test_view_spend_logs_summarize_unhashed_api_key_without_padding(client data = response.json() assert mock_prisma_client.db.captured_params == ( "2024-01-01T00:00:00+00:00", - "2024-01-01T00:00:00+00:00", + "2024-01-02T00:00:00+00:00", "plain-key", ) assert data == [