mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 10:24:03 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user