Merge pull request #26488 from BerriAI/litellm_sortable_spend_logs_columns

[Feature] UI - Spend Logs: sortable Model and TTFT columns
This commit is contained in:
yuneng-jiang
2026-04-25 09:29:43 -07:00
committed by GitHub
3 changed files with 274 additions and 10 deletions
@@ -1754,7 +1754,7 @@ async def ui_view_spend_logs( # noqa: PLR0915
),
sort_by: str = fastapi.Query(
default="startTime",
description="Sort logs by field: spend, total_tokens, startTime, or endTime",
description="Sort logs by field: spend, total_tokens, startTime, endTime, request_duration_ms, model, or ttft_ms",
),
sort_order: Optional[str] = fastapi.Query(
default="desc",
@@ -1798,6 +1798,8 @@ async def ui_view_spend_logs( # noqa: PLR0915
"startTime",
"endTime",
"request_duration_ms",
"model",
"ttft_ms",
}
if sort_by not in valid_sort_fields:
raise ProxyException(
@@ -2045,13 +2047,26 @@ async def ui_view_spend_logs( # noqa: PLR0915
sql_params.append(f"%{error_message}%")
p += 1
# Quote column names that need quoting in SQL
_sql_col = (
f'"{order_column}"'
if order_column in ("startTime", "endTime")
else order_column
)
# Build the ORDER BY expression. ttft_ms is computed from
# completionStartTime - startTime; non-streaming rows (where
# completionStartTime is null or equals endTime) yield NULL, so we
# append NULLS LAST in that case to keep them at the bottom regardless
# of direction. The other sort columns are non-null in the result set,
# so we leave the NULLS clause off and preserve their existing DESC
# semantics.
_sql_dir = "ASC" if order_direction == "asc" else "DESC"
_nulls_clause = ""
if order_column == "ttft_ms":
_order_expr = (
'CASE WHEN "completionStartTime" IS NULL '
'OR "completionStartTime" = "endTime" THEN NULL '
'ELSE (EXTRACT(EPOCH FROM ("completionStartTime" - "startTime")) * 1000) END'
)
_nulls_clause = " NULLS LAST"
elif order_column in ("startTime", "endTime"):
_order_expr = f'"{order_column}"'
else:
_order_expr = order_column
sql_query = f"""
SELECT
@@ -2065,7 +2080,7 @@ async def ui_view_spend_logs( # noqa: PLR0915
COALESCE(request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms
FROM "LiteLLM_SpendLogs"
WHERE {" AND ".join(sql_conditions)}
ORDER BY {_sql_col} {_sql_dir}
ORDER BY {_order_expr} {_sql_dir}{_nulls_clause}
LIMIT ${p} OFFSET ${p + 1}
"""
sql_params.extend([page_size, skip])
@@ -783,6 +783,233 @@ async def test_ui_view_spend_logs_sort_by_request_duration_ms(client, monkeypatc
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sort_order,expected_request_ids",
[
("asc", ["req_anthropic", "req_gpt35", "req_gpt4"]),
("desc", ["req_gpt4", "req_gpt35", "req_anthropic"]),
],
)
async def test_ui_view_spend_logs_sort_by_model(
client, monkeypatch, sort_order, expected_request_ids
):
"""Test that model is accepted as a valid sort_by field and orders alphabetically."""
base_logs = [
{
"request_id": "req_gpt4",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:00+00:00",
"endTime": "2025-01-01T00:00:01+00:00",
"model": "gpt-4",
},
{
"request_id": "req_anthropic",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:01+00:00",
"endTime": "2025-01-01T00:00:02+00:00",
"model": "claude-3-opus",
},
{
"request_id": "req_gpt35",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:02+00:00",
"endTime": "2025-01-01T00:00:03+00:00",
"model": "gpt-3.5-turbo",
},
]
async def mock_count(*args, **kwargs):
return len(base_logs)
async def mock_query_raw(sql_query, *params):
assert "model" in sql_query
# model is non-nullable in the schema, so NULLS LAST should NOT be
# appended — only ttft_ms gets that clause. This guards against
# accidentally widening the change to all sort columns.
assert "NULLS LAST" not in sql_query
reverse = "DESC" in sql_query
sorted_logs = sorted(
base_logs, key=lambda x: x.get("model", ""), reverse=reverse
)
page_size = params[-2] if len(params) >= 2 else 50
skip = params[-1] if len(params) >= 1 else 0
return sorted_logs[skip : skip + page_size]
class MockPrismaClient:
def __init__(self):
self.db = MagicMock()
self.db.litellm_spendlogs = MagicMock()
self.db.litellm_spendlogs.count = AsyncMock(side_effect=mock_count)
self.db.query_raw = AsyncMock(side_effect=mock_query_raw)
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:
response = client.get(
"/spend/logs/ui",
params={
"start_date": "2024-12-25 00:00:00",
"end_date": "2025-01-02 23:59:59",
"sort_by": "model",
"sort_order": sort_order,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
data = response.json()
actual_ids = [log["request_id"] for log in data["data"]]
assert actual_ids == expected_request_ids
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_sort_by_ttft_ms(client, monkeypatch):
"""sort_by=ttft_ms orders streaming rows by TTFT and pushes non-streaming rows last (NULLS LAST)."""
# req_fast_stream: TTFT = 100ms (streaming)
# req_slow_stream: TTFT = 2000ms (streaming)
# req_no_stream: completionStartTime == endTime (non-streaming, treated as NULL)
# req_null_stream: completionStartTime is null (non-streaming, NULL)
base_logs = [
{
"request_id": "req_fast_stream",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:00+00:00",
"completionStartTime": "2025-01-01T00:00:00.100000+00:00",
"endTime": "2025-01-01T00:00:01+00:00",
"model": "gpt-4",
"_ttft_ms": 100,
},
{
"request_id": "req_slow_stream",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:02+00:00",
"completionStartTime": "2025-01-01T00:00:04+00:00",
"endTime": "2025-01-01T00:00:05+00:00",
"model": "gpt-4",
"_ttft_ms": 2000,
},
{
"request_id": "req_no_stream",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:06+00:00",
"completionStartTime": "2025-01-01T00:00:07+00:00",
"endTime": "2025-01-01T00:00:07+00:00",
"model": "gpt-4",
"_ttft_ms": None,
},
{
"request_id": "req_null_stream",
"api_key": "sk-test-key",
"user": "user1",
"spend": 0.10,
"total_tokens": 100,
"startTime": "2025-01-01T00:00:08+00:00",
"completionStartTime": None,
"endTime": "2025-01-01T00:00:09+00:00",
"model": "gpt-4",
"_ttft_ms": None,
},
]
async def mock_count(*args, **kwargs):
return len(base_logs)
async def mock_query_raw(sql_query, *params):
# Endpoint must compute TTFT inline and use NULLS LAST.
assert "completionStartTime" in sql_query
assert "NULLS LAST" in sql_query
reverse = "DESC" in sql_query
non_null = [r for r in base_logs if r["_ttft_ms"] is not None]
nulls = [r for r in base_logs if r["_ttft_ms"] is None]
non_null.sort(key=lambda x: x["_ttft_ms"], reverse=reverse)
sorted_logs = non_null + nulls
page_size = params[-2] if len(params) >= 2 else 50
skip = params[-1] if len(params) >= 1 else 0
return [
{k: v for k, v in row.items() if k != "_ttft_ms"}
for row in sorted_logs[skip : skip + page_size]
]
class MockPrismaClient:
def __init__(self):
self.db = MagicMock()
self.db.litellm_spendlogs = MagicMock()
self.db.litellm_spendlogs.count = AsyncMock(side_effect=mock_count)
self.db.query_raw = AsyncMock(side_effect=mock_query_raw)
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:
# asc: fast stream, slow stream, then NULLs (non-streaming) last
response = client.get(
"/spend/logs/ui",
params={
"start_date": "2024-12-25 00:00:00",
"end_date": "2025-01-02 23:59:59",
"sort_by": "ttft_ms",
"sort_order": "asc",
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
actual_ids = [log["request_id"] for log in response.json()["data"]]
assert actual_ids[:2] == ["req_fast_stream", "req_slow_stream"]
assert set(actual_ids[2:]) == {"req_no_stream", "req_null_stream"}
# desc: slow stream, fast stream, then NULLs still last
response = client.get(
"/spend/logs/ui",
params={
"start_date": "2024-12-25 00:00:00",
"end_date": "2025-01-02 23:59:59",
"sort_by": "ttft_ms",
"sort_order": "desc",
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
actual_ids = [log["request_id"] for log in response.json()["data"]]
assert actual_ids[:2] == ["req_slow_stream", "req_fast_stream"]
assert set(actual_ids[2:]) == {"req_no_stream", "req_null_stream"}
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_spend_logs = [
@@ -15,6 +15,8 @@ export const LOGS_SORT_FIELD_MAP = {
spend: "spend",
total_tokens: "total_tokens",
request_duration_ms: "request_duration_ms",
model: "model",
ttft_ms: "ttft_ms",
} as const;
export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP;
@@ -272,7 +274,17 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
},
},
{
header: "TTFT (s)",
header: sortProps
? () => (
<SortableHeader
label="TTFT (s)"
field="ttft_ms"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "TTFT (s)",
accessorKey: "completionStartTime",
cell: (info: any) => {
const row = info.row.original;
@@ -328,7 +340,17 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
),
},
{
header: "Model",
header: sortProps
? () => (
<SortableHeader
label="Model"
field="model"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Model",
accessorKey: "model",
cell: (info: any) => {
const row = info.row.original;