From 26ffb5d04ea693195aa0479a9e93d5addaffdbef Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 3 Aug 2026 17:41:49 -0700 Subject: [PATCH] fix(spend): scope org report team fallback to unstamped rows and bound report date ranges --- .../spend_management_endpoints.py | 21 ++++++- .../test_spend_management_endpoints.py | 63 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 4b202a5054d..799e8d33b6e 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1458,6 +1458,8 @@ async def get_global_spend_report( _SPEND_REPORT_SCOPE_COLUMNS = frozenset({"api_key", "user", "team_id"}) +_SPEND_REPORT_MAX_RANGE_DAYS = 366 + def _scoped_spend_report_sql(scope_column: str) -> str: """Spend grouped by api_key with a per-model breakdown, cut to one scope column. @@ -1520,7 +1522,13 @@ _ORG_SPEND_REPORT_SQL = """ WHERE sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC') - AND (sl.organization_id = $3 OR sl.team_id = ANY($4::text[])) + AND ( + sl.organization_id = $3 + OR ( + (sl.organization_id IS NULL OR sl.organization_id = '') + AND sl.team_id = ANY($4::text[]) + ) + ) GROUP BY sl.api_key, sl.team_id, @@ -1579,6 +1587,17 @@ def _parse_spend_report_date_range(start_date: str | None, end_date: str | None) status_code=status.HTTP_400_BAD_REQUEST, detail="start_date and end_date must be in YYYY-MM-DD format", ) + start_date_obj, end_date_obj = parsed + if end_date_obj < start_date_obj: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="start_date must be on or before end_date", + ) + if end_date_obj - start_date_obj > timedelta(days=_SPEND_REPORT_MAX_RANGE_DAYS): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Date range too large; maximum is {_SPEND_REPORT_MAX_RANGE_DAYS} days", + ) return parsed diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index e0f7ba7a9b3..057193a69db 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -5096,7 +5096,11 @@ def test_org_spend_report_proxy_admin_override(client, monkeypatch): assert response.status_code == 200 args, _ = mock_prisma.db.query_raw.await_args sql, _, _, org_param, team_ids_param = args - assert "(sl.organization_id = $3 OR sl.team_id = ANY($4::text[]))" in sql + normalized_sql = " ".join(sql.split()) + assert ( + "AND ( sl.organization_id = $3 OR ( (sl.organization_id IS NULL OR sl.organization_id = '') " + "AND sl.team_id = ANY($4::text[]) ) )" + ) in normalized_sql assert org_param == "org-x" assert team_ids_param == ("team-a", "team-b") finally: @@ -5238,3 +5242,60 @@ def test_scoped_spend_report_invalid_date_format_400(client, monkeypatch): mock_prisma.db.query_raw.assert_not_awaited() finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_scoped_spend_report_reversed_range_400(client, monkeypatch): + mock_prisma = _spend_report_mock_prisma() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice", api_key="hashed-k" + ) + try: + response = client.get( + "/key/spend/report", + params={"start_date": "2026-08-04", "end_date": "2026-08-01"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 400 + mock_prisma.db.query_raw.assert_not_awaited() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_scoped_spend_report_range_over_max_400(client, monkeypatch): + mock_prisma = _spend_report_mock_prisma() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice", api_key="hashed-k" + ) + try: + response = client.get( + "/key/spend/report", + params={"start_date": "0001-01-01", "end_date": "9999-12-31"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 400 + mock_prisma.db.query_raw.assert_not_awaited() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_scoped_spend_report_range_at_max_allowed(client, monkeypatch): + mock_prisma = _spend_report_mock_prisma(query_raw_returns=[]) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice", api_key="hashed-k" + ) + try: + response = client.get( + "/key/spend/report", + params={"start_date": "2025-08-03", "end_date": "2026-08-04"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + mock_prisma.db.query_raw.assert_awaited_once() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None)