fix(proxy): scope /spend/tags aggregates to the caller's own and permitted-team logs (#46194)

* fix(proxy): scope /spend/tags aggregates to the caller's own and permitted-team logs

Non-admin callers previously received the proxy-wide per-tag spend
aggregation. The endpoint now resolves the same read scope as
/spend/logs/ui and /spend/logs/session/ui, and the query honors the
start_date/end_date parameters it previously accepted but ignored.

* fix(proxy): use Annotated auth dependency on /spend/tags and sync dashboard API types

* fix(proxy): bind /spend/tags dates as UTC datetimes, not session-timezone strings

Raw YYYY-MM-DD strings cast to timestamptz are read in the database
session timezone, shifting the day window by that offset. Parse them
to UTC-aware datetimes first, like every sibling spend query in this
file, and reject malformed dates with a 400 before they reach the
database.
This commit is contained in:
Oliver Jensen 2026-10-15 10:09:21 +02:00 • committed by GitHub
parent ea777eb21b
commit 84e8204d7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 179 additions and 16 deletions

View file

@ -552,6 +552,20 @@ async def spend_user_fn(
)
def _parse_utc_day(value: str | None, param_name: str) -> datetime | None:
"""Parse an optional YYYY-MM-DD query param to a UTC datetime, so the SQL
day window never depends on the database session timezone."""
if value is None:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": f"{param_name} must be a YYYY-MM-DD date, got {value!r}"},
)
@router.get(
"/spend/tags",
tags=["Budget & Spend Tracking"],
@ -561,6 +575,8 @@ async def spend_user_fn(
},
)
async def view_spend_tags(
log_team_lookup: LogTeamLookupDependency,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
start_date: str | None = fastapi.Query(
default=None,
description="Time from which to start viewing key spend",
@ -573,6 +589,11 @@ async def view_spend_tags(
"""
LiteLLM Enterprise - View Spend Per Request Tag
- Admin callers (PROXY_ADMIN / PROXY_ADMIN_VIEW_ONLY) see tag aggregates
across every spend log.
- All other callers see aggregates over their own spend logs and those of
teams they may view, exactly like ``/spend/logs/ui``.
Example Request:
```
curl -X GET "http://0.0.0.0:8000/spend/tags" \
@ -594,16 +615,19 @@ async def view_spend_tags(
"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
)
# run the following SQL query on prisma
"""
SELECT
jsonb_array_elements_text(request_tags) AS individual_request_tag,
COUNT(*) AS log_count,
SUM(spend) AS total_spend
FROM "LiteLLM_SpendLogs"
GROUP BY individual_request_tag;
"""
response: Final = await get_spend_by_tags(start_date=start_date, end_date=end_date, prisma_client=prisma_client)
read_scope: Final = (
AllRows()
if is_admin_view_safe(user_api_key_dict=user_api_key_dict)
else await _spend_log_read_scope(user_api_key_dict, log_team_lookup)
if _can_user_view_spend_log(user_api_key_dict=user_api_key_dict)
else OwnedRows(user_api_key_dict.user_id)
)
response: Final = await get_spend_by_tags(
prisma_client=prisma_client,
read_scope=read_scope,
start_date=_parse_utc_day(start_date, "start_date"),
end_date=_parse_utc_day(end_date, "end_date"),
)
return response
except Exception as e:
@ -4377,17 +4401,41 @@ async def provider_budgets() -> ProviderBudgetResponse:
raise handle_exception_on_proxy(e)
async def get_spend_by_tags(prisma_client: PrismaClient, start_date=None, end_date=None):
async def get_spend_by_tags(
prisma_client: PrismaClient,
read_scope: ReadScope,
start_date: datetime | None = None,
end_date: datetime | None = None,
):
scope_clause, scope_params = read_scope_sql(read_scope, 1)
start_clause: Final = (
(f"\"startTime\" >= (${len(scope_params) + 1}::timestamptz AT TIME ZONE 'UTC')",) if start_date else ()
)
end_clause: Final = (
(
f"\"startTime\" < ((${len(scope_params) + len(start_clause) + 1}::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')",
)
if end_date
else ()
)
clauses: Final = tuple(clause for clause in (scope_clause, *start_clause, *end_clause) if clause)
where_sql: Final = f" WHERE {' AND '.join(clauses)}" if clauses else ""
params: Final = (
*scope_params,
*((start_date,) if start_date else ()),
*((end_date,) if end_date else ()),
)
response: Final[Sequence[Mapping[str, object]]] = await _query_raw(
prisma_client,
"""
f"""
SELECT
jsonb_array_elements_text(request_tags) AS individual_request_tag,
COUNT(*) AS log_count,
SUM(spend) AS total_spend
FROM "LiteLLM_SpendLogs"
FROM "LiteLLM_SpendLogs"{where_sql}
GROUP BY individual_request_tag;
""",
*params,
)
return response

View file

@ -1,4 +1,3 @@
from typing import Final
import asyncio
import collections
import datetime
@ -7,6 +6,7 @@ import json
import re
import sqlite3
from datetime import timezone
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import prisma
@ -278,8 +278,8 @@ from litellm.proxy._types import (
from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger
from litellm.proxy.management.teams import authz as team_access
from litellm.proxy.proxy_server import app
from litellm.proxy.utils import PrismaClient
from litellm.proxy.spend_tracking import spend_management_endpoints
from litellm.proxy.utils import PrismaClient
from litellm.router import Router
from litellm.types.utils import BudgetConfig
@ -4120,7 +4120,7 @@ async def test_view_spend_tags(client, monkeypatch):
]
# Mock the get_spend_by_tags function
async def mock_get_spend_by_tags(prisma_client, start_date=None, end_date=None):
async def mock_get_spend_by_tags(prisma_client, read_scope, start_date=None, end_date=None):
return mock_response
monkeypatch.setattr(
@ -4168,6 +4168,116 @@ async def test_view_spend_tags(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def _tag_query_prisma_client(assert_query_raw):
class MockDB:
async def query_raw(self, sql_query, *params):
return assert_query_raw(sql_query, params)
class MockPrismaClient:
def __init__(self):
self.db = MockDB()
return MockPrismaClient()
@pytest.mark.asyncio
async def test_view_spend_tags_scopes_internal_user_to_own_and_team_logs(client, monkeypatch):
def assert_query_raw(sql_query, params):
assert '("user" = $1 OR team_id = ANY($2::text[]))' in sql_query
assert params[0] == "user-1"
assert tuple(params[1]) == ("team-9",)
return [{"individual_request_tag": "own-tag", "log_count": 1, "total_spend": 0.01}]
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _tag_query_prisma_client(assert_query_raw))
async def permitted_teams(*args, **kwargs):
return ["team-9"]
monkeypatch.setitem(app.dependency_overrides, get_log_team_lookup, lambda: permitted_teams)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1"
)
try:
response = client.get("/spend/tags", headers={"Authorization": "Bearer sk-test"})
assert response.status_code == 200
assert [row["individual_request_tag"] for row in response.json()] == ["own-tag"]
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_tags_admin_sees_unscoped_aggregates_with_date_filter(client, monkeypatch):
def assert_query_raw(sql_query, params):
assert '"user"' not in sql_query
assert "team_id" not in sql_query
assert '"startTime" >= ($1' in sql_query
assert "$2::timestamptz + INTERVAL '1 day'" in sql_query
assert params == (
datetime.datetime(2024, 1, 1, tzinfo=timezone.utc),
datetime.datetime(2024, 1, 31, tzinfo=timezone.utc),
), "dates must be bound as UTC datetimes so the window is session-timezone independent"
return [{"individual_request_tag": "any-tag", "log_count": 2, "total_spend": 0.05}]
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _tag_query_prisma_client(assert_query_raw))
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
response = client.get(
"/spend/tags",
params={"start_date": "2024-01-01", "end_date": "2024-01-31"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert len(response.json()) == 1
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_tags_rejects_malformed_dates_with_400(client, monkeypatch):
def assert_query_raw(sql_query, params):
pytest.fail("malformed dates must never reach the database")
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _tag_query_prisma_client(assert_query_raw))
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
response = client.get(
"/spend/tags",
params={"start_date": "01/31/2024"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 400
assert "start_date" in response.text
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_tags_key_without_user_id_gets_no_rows(client, monkeypatch):
def assert_query_raw(sql_query, params):
assert "WHERE FALSE" in sql_query
assert params == ()
return []
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _tag_query_prisma_client(assert_query_raw))
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.TEAM, user_id=None
)
try:
response = client.get("/spend/tags", headers={"Authorization": "Bearer sk-test"})
assert response.status_code == 200
assert response.json() == []
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_tags_no_database(client, monkeypatch):
"""Test /spend/tags endpoint when database is not connected"""

View file

@ -15634,6 +15634,11 @@ export interface paths {
* View Spend Tags
* @description LiteLLM Enterprise - View Spend Per Request Tag
*
* - Admin callers (PROXY_ADMIN / PROXY_ADMIN_VIEW_ONLY) see tag aggregates
* across every spend log.
* - All other callers see aggregates over their own spend logs and those of
* teams they may view, exactly like ``/spend/logs/ui``.
*
* Example Request:
* ```
* curl -X GET "http://0.0.0.0:8000/spend/tags" -H "Authorization: Bearer $LITELLM_MASTER_KEY"