Allow sorting on /spend/logs/ui

This commit is contained in:
yuneng-jiang 2026-02-11 12:48:33 -08:00
parent b88b2520c0
commit 55225a09cf
2 changed files with 222 additions and 4 deletions

View file

@ -1687,6 +1687,14 @@ async def ui_view_spend_logs( # noqa: PLR0915
error_message: Optional[str] = fastapi.Query(
default=None, description="Filter logs by error message (partial string match)"
),
sort_by: Optional[str] = fastapi.Query(
default=None,
description="Sort logs by field: spend, total_tokens, startTime, or endTime (default: startTime)",
),
sort_order: Optional[str] = fastapi.Query(
default="desc",
description="Sort order: asc or desc",
),
):
"""
View spend logs with pagination support.
@ -1718,6 +1726,23 @@ async def ui_view_spend_logs( # noqa: PLR0915
code=status.HTTP_400_BAD_REQUEST,
)
# Validate sort_by and sort_order
valid_sort_fields = {"spend", "total_tokens", "startTime", "endTime"}
if sort_by is not None and sort_by not in valid_sort_fields:
raise ProxyException(
message=f"Invalid sort_by: {sort_by}. Must be one of: {', '.join(sorted(valid_sort_fields))}",
type="bad_request",
param="sort_by",
code=status.HTTP_400_BAD_REQUEST,
)
if sort_order is not None and sort_order.lower() not in {"asc", "desc"}:
raise ProxyException(
message=f"Invalid sort_order: {sort_order}. Must be one of: asc, desc",
type="bad_request",
param="sort_order",
code=status.HTTP_400_BAD_REQUEST,
)
try:
is_v2 = "/spend/logs/v2" in request.url.path
formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"]
@ -1830,6 +1855,11 @@ async def ui_view_spend_logs( # noqa: PLR0915
# Calculate skip value for pagination
skip = (page - 1) * page_size
# Build order clause from sort_by and sort_order
order_column = sort_by if sort_by else "startTime"
order_direction = (sort_order or "desc").lower()
order_clause = {order_column: order_direction}
# Get total count of records
total_records = await prisma_client.db.litellm_spendlogs.count(
where=where_conditions,
@ -1838,9 +1868,7 @@ async def ui_view_spend_logs( # noqa: PLR0915
# Get paginated data
data = await prisma_client.db.litellm_spendlogs.find_many(
where=where_conditions,
order={
"startTime": "desc",
},
order=order_clause,
skip=skip,
take=page_size,
)

View file

@ -12,7 +12,7 @@ sys.path.insert(
0, os.path.abspath("../../../..")
) # Adds the parent directory to the system path
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import litellm
import litellm.proxy.proxy_server as ps
@ -347,6 +347,196 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch):
assert data["data"][0]["user"] == "test_user_1"
# Mock spend logs with distinct values for sorting tests.
# req_a: spend=0.10, tokens=500, start/end earliest
# req_b: spend=0.05, tokens=200, start/end 2nd
# req_c: spend=0.20, tokens=50, start/end latest
# req_d: spend=0.01, tokens=100, start/end 3rd
_SORT_TEST_LOGS = [
{
"request_id": "req_a",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 500,
"startTime": "2025-01-01T00:00:00+00:00",
"endTime": "2025-01-01T00:01:00+00:00",
"model": "gpt-3.5-turbo",
},
{
"request_id": "req_b",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.05,
"total_tokens": 200,
"startTime": "2025-01-01T00:00:01+00:00",
"endTime": "2025-01-01T00:01:01+00:00",
"model": "gpt-3.5-turbo",
},
{
"request_id": "req_c",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.20,
"total_tokens": 50,
"startTime": "2025-01-01T00:00:03+00:00",
"endTime": "2025-01-01T00:01:03+00:00",
"model": "gpt-3.5-turbo",
},
{
"request_id": "req_d",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.01,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:02+00:00",
"endTime": "2025-01-01T00:01:02+00:00",
"model": "gpt-3.5-turbo",
},
]
def _sort_logs(logs, order_clause):
"""Sort logs by the given Prisma-style order clause, e.g. {'spend': 'asc'}."""
if not order_clause:
return list(logs)
key, direction = next(iter(order_clause.items()))
reverse = direction.lower() == "desc"
return sorted(logs, key=lambda x: x.get(key, 0), reverse=reverse)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sort_by,sort_order,expected_request_ids",
[
# spend: 0.01(d) < 0.05(b) < 0.10(a) < 0.20(c)
("spend", "asc", ["req_d", "req_b", "req_a", "req_c"]),
("spend", "desc", ["req_c", "req_a", "req_b", "req_d"]),
# total_tokens: 50(c) < 100(d) < 200(b) < 500(a)
("total_tokens", "asc", ["req_c", "req_d", "req_b", "req_a"]),
("total_tokens", "desc", ["req_a", "req_b", "req_d", "req_c"]),
# startTime: 00:00:00(a) < 00:00:01(b) < 00:00:02(d) < 00:00:03(c)
("startTime", "asc", ["req_a", "req_b", "req_d", "req_c"]),
("startTime", "desc", ["req_c", "req_d", "req_b", "req_a"]),
# endTime: same ordering as startTime
("endTime", "asc", ["req_a", "req_b", "req_d", "req_c"]),
("endTime", "desc", ["req_c", "req_d", "req_b", "req_a"]),
# default when sort_by not provided: startTime desc
(None, "desc", ["req_c", "req_d", "req_b", "req_a"]),
],
)
async def test_ui_view_spend_logs_sort_by_and_sort_order(
client, monkeypatch, sort_by, sort_order, expected_request_ids
):
"""Test that spend logs are returned in the correct order for each sort_by/sort_order."""
base_logs = list(_SORT_TEST_LOGS)
async def mock_find_many(*args, **kwargs):
order = kwargs.get("order", {})
return _sort_logs(base_logs, order)
async def mock_count(*args, **kwargs):
return len(base_logs)
class MockPrismaClient:
def __init__(self):
self.db = MagicMock()
self.db.litellm_spendlogs = MagicMock()
self.db.litellm_spendlogs.find_many = AsyncMock(side_effect=mock_find_many)
self.db.litellm_spendlogs.count = AsyncMock(side_effect=mock_count)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient())
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe",
lambda user_api_key_dict: True,
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
start_date = "2024-12-25 00:00:00"
end_date = "2025-01-02 23:59:59"
params = {
"start_date": start_date,
"end_date": end_date,
}
if sort_by is not None:
params["sort_by"] = sort_by
if sort_order is not None:
params["sort_order"] = sort_order
response = client.get(
"/spend/logs/ui",
params=params,
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
data = response.json()
assert "data" in data
actual_ids = [log["request_id"] for log in data["data"]]
assert actual_ids == expected_request_ids, (
f"Expected order {expected_request_ids}, got {actual_ids} "
f"(sort_by={sort_by}, sort_order={sort_order})"
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sort_by,sort_order",
[
("invalid", "asc"),
("spend", "invalid"),
],
)
async def test_ui_view_spend_logs_sort_validation_errors(
client, monkeypatch, sort_by, sort_order
):
"""Test that invalid sort_by and sort_order return 400."""
async def mock_count(*args, **kwargs):
return 0
class MockPrismaClient:
def __init__(self):
self.db = MagicMock()
self.db.litellm_spendlogs = MagicMock()
self.db.litellm_spendlogs.find_many = AsyncMock(return_value=[])
self.db.litellm_spendlogs.count = AsyncMock(side_effect=mock_count)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient())
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe",
lambda user_api_key_dict: True,
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
start_date = "2024-12-25 00:00:00"
end_date = "2025-01-02 23:59:59"
response = client.get(
"/spend/logs/ui",
params={
"start_date": start_date,
"end_date": end_date,
"sort_by": sort_by,
"sort_order": sort_order,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 400
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_team_id(client, monkeypatch):
# Mock data for the test