Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions litellm/proxy/spend_tracking/spend_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,12 @@ def parse_date(date_str: str) -> datetime:

verbose_proxy_logger.debug("data= %s", json.dumps(data, indent=4, default=str))

session_scope_where = {
key: where_conditions[key]
for key in ("user", "team_id", "OR")
if key in where_conditions
}

return await _build_ui_spend_logs_response(
prisma_client,
data,
Expand All @@ -2169,6 +2175,7 @@ def parse_date(date_str: str) -> datetime:
page_size,
total_pages,
enrich_session_counts=not is_v2,
session_scope_where=session_scope_where,
)
except Exception as e:
verbose_proxy_logger.exception(f"Error in ui_view_spend_logs: {e}")
Expand Down Expand Up @@ -3453,6 +3460,7 @@ async def _build_ui_spend_logs_response(
page_size: int,
total_pages: int,
enrich_session_counts: bool = True,
session_scope_where: Optional[Dict[str, Any]] = None,
) -> dict:
"""
Build the paginated response for the UI spend-logs endpoint.
Expand All @@ -3477,12 +3485,19 @@ async def _build_ui_spend_logs_response(
total_pages: Total number of pages.
enrich_session_counts: Whether to add ``session_total_count`` to each
row. Defaults to ``True``.
session_scope_where: Optional Prisma ``where`` predicate carrying the
caller's authorization scope. It is merged into the session
aggregate query so the totals never sum logs the caller is not
allowed to see (``session_id`` is client-supplied and can collide
across tenants).

Returns:
A dict with ``data`` (enriched rows), ``total``, ``page``,
``page_size``, and ``total_pages``.
"""
count_map: dict[str, int] = {}
spend_map: dict[str, float] = {}
duration_map: dict[str, int] = {}
if enrich_session_counts:
session_ids = list(
{
Expand All @@ -3504,14 +3519,28 @@ async def _build_ui_spend_logs_response(
# is bounded by page_size (typically 25-50 distinct session IDs).
# If performance degrades at scale, consider short-lived caching or
# folding the count into the main query via a window function.
counts = await SpendLogsRepository(prisma_client).table.group_by(
grouped = await SpendLogsRepository(prisma_client).table.group_by(
by=["session_id"],
where={"session_id": {"in": session_ids}},
where={
"session_id": {"in": session_ids},
**(session_scope_where or {}),
},
count={"session_id": True},
sum={"spend": True, "request_duration_ms": True},
Comment thread
veria-ai[bot] marked this conversation as resolved.
)
count_map = {
r["session_id"]: r["_count"]["session_id"]
for r in counts
for r in grouped
if r.get("session_id")
}
spend_map = {
r["session_id"]: (r["_sum"]["spend"] or 0.0)
for r in grouped
if r.get("session_id")
}
duration_map = {
r["session_id"]: (r["_sum"]["request_duration_ms"] or 0)
for r in grouped
if r.get("session_id")
}

Expand All @@ -3521,6 +3550,16 @@ async def _build_ui_spend_logs_response(
row_dict = dict(row) if isinstance(row, dict) else row.model_dump()
sid = row_dict.get("session_id")
row_dict["session_total_count"] = count_map.get(sid, 1) if sid else 1
row_dict["session_total_spend"] = (
spend_map.get(sid, row_dict.get("spend", 0.0))
if sid
else row_dict.get("spend", 0.0)
)
row_dict["session_total_duration"] = (
duration_map.get(sid, row_dict.get("request_duration_ms"))
if sid
else row_dict.get("request_duration_ms")
)
enriched.append(row_dict)
response_data: list = enriched
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2829,7 +2829,11 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
mock_prisma = MagicMock()
mock_prisma.db.litellm_spendlogs.group_by = AsyncMock(
return_value=[
{"session_id": session_id, "_count": {"session_id": 2}},
{
"session_id": session_id,
"_count": {"session_id": 2},
"_sum": {"spend": 0.0, "request_duration_ms": 0},
},
]
)

Expand All @@ -2846,19 +2850,210 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
rows = result["data"]
assert len(rows) == 3

# Rows with the shared session_id should have session_total_count=2
assert rows[0]["session_total_count"] == 2
assert rows[1]["session_total_count"] == 2

# Row without a session_id defaults to 1
assert rows[2]["session_total_count"] == 1

# group_by should have been called with the session_id
mock_prisma.db.litellm_spendlogs.group_by.assert_called_once_with(
by=["session_id"],
where={"session_id": {"in": [session_id]}},
count={"session_id": True},
sum={"spend": True, "request_duration_ms": True},
)


@pytest.mark.asyncio
async def test_build_ui_spend_logs_response_session_aggregate_spend():
"""
Regression test: _build_ui_spend_logs_response must attach session_total_spend
and session_total_duration (summed from all requests in the session) to each
row that carries a session_id. Rows without a session_id fall back to their
own spend/request_duration_ms. The group_by call must include sum=.
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_build_ui_spend_logs_response,
)

session_id = "sess-abc-123"
dict_rows = [
{
"request_id": "req-1",
"session_id": session_id,
"spend": 0.01,
"request_duration_ms": 1000,
},
{
"request_id": "req-2",
"session_id": session_id,
"spend": 0.02,
"request_duration_ms": 2000,
},
{
"request_id": "req-3",
"session_id": None,
"spend": 0.05,
"request_duration_ms": 500,
},
]

mock_prisma = MagicMock()
mock_prisma.db.litellm_spendlogs.group_by = AsyncMock(
return_value=[
{
"session_id": session_id,
"_count": {"session_id": 2},
"_sum": {"spend": 0.03, "request_duration_ms": 3000},
},
]
)

result = await _build_ui_spend_logs_response(
prisma_client=mock_prisma,
data=dict_rows,
total_records=3,
page=1,
page_size=50,
total_pages=1,
enrich_session_counts=True,
)

rows = result["data"]
assert len(rows) == 3

assert rows[0]["session_total_spend"] == 0.03
assert rows[0]["session_total_duration"] == 3000
assert rows[1]["session_total_spend"] == 0.03
assert rows[1]["session_total_duration"] == 3000

assert rows[2]["session_total_spend"] == 0.05
assert rows[2]["session_total_duration"] == 500

mock_prisma.db.litellm_spendlogs.group_by.assert_called_once_with(
by=["session_id"],
where={"session_id": {"in": [session_id]}},
count={"session_id": True},
sum={"spend": True, "request_duration_ms": True},
)


@pytest.mark.asyncio
async def test_build_ui_spend_logs_response_merges_session_scope_where():
"""
Security regression #25708: the session aggregate group_by must merge the
caller's authorization scope so the totals never sum logs the caller is not
allowed to see (session_id is client-supplied and can collide across tenants).
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_build_ui_spend_logs_response,
)

session_id = "shared-sess"
dict_rows = [
{
"request_id": "req-1",
"session_id": session_id,
"spend": 0.01,
"request_duration_ms": 100,
},
]
mock_prisma = MagicMock()
mock_prisma.db.litellm_spendlogs.group_by = AsyncMock(
return_value=[
{
"session_id": session_id,
"_count": {"session_id": 1},
"_sum": {"spend": 0.01, "request_duration_ms": 100},
},
]
)

await _build_ui_spend_logs_response(
prisma_client=mock_prisma,
data=dict_rows,
total_records=1,
page=1,
page_size=50,
total_pages=1,
enrich_session_counts=True,
session_scope_where={"user": "user_1"},
)

where = mock_prisma.db.litellm_spendlogs.group_by.call_args.kwargs["where"]
assert where["user"] == "user_1"
assert where["session_id"] == {"in": [session_id]}


@pytest.mark.asyncio
async def test_ui_view_spend_logs_scopes_session_aggregate_to_non_admin_caller(
client, monkeypatch
):
"""
Security regression #25708: a non-admin's session aggregate must be scoped to
their authorized logs (user/team), not just session_id, so they cannot read
other tenants' spend/duration via a shared, client-supplied session_id.
"""
captured: dict = {}

async def mock_query_raw(sql_query, *params):
return [
{
"request_id": "req1",
"session_id": "shared-sess",
"spend": 0.01,
"request_duration_ms": 100,
}
]

async def mock_group_by(*args, **kwargs):
captured["where"] = kwargs.get("where")
return [
{
"session_id": "shared-sess",
"_count": {"session_id": 1},
"_sum": {"spend": 0.01, "request_duration_ms": 100},
}
]

class MockPrismaClient:
def __init__(self):
self.db = MagicMock()
self.db.litellm_spendlogs = MagicMock()
self.db.litellm_spendlogs.count = AsyncMock(return_value=1)
self.db.litellm_spendlogs.group_by = AsyncMock(side_effect=mock_group_by)
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: False,
)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._can_user_view_spend_log",
lambda user_api_key_dict: True,
)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
AsyncMock(return_value=[]),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
)

try:
response = client.get(
"/spend/logs/ui",
params={
"start_date": "2024-12-25 00:00:00",
"end_date": "2025-01-02 23:59:59",
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
assert captured["where"].get("user") == "user_1"
assert captured["where"]["session_id"] == {"in": ["shared-sess"]}
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)


# ---------------------------------------------------------------------------
Expand Down
14 changes: 11 additions & 3 deletions ui/litellm-dashboard/src/components/view_logs/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,21 @@
user?: string;
end_user?: string;
custom_llm_provider?: string;
metadata?: Record<string, any>;

Check warning on line 57 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
cache_hit: string;
cache_key?: string;
request_tags?: Record<string, any>;

Check warning on line 60 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
requester_ip_address?: string;
messages: string | any[] | Record<string, any>;

Check warning on line 62 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

Check warning on line 62 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
response: string | any[] | Record<string, any>;

Check warning on line 63 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

Check warning on line 63 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
proxy_server_request?: string | any[] | Record<string, any>;

Check warning on line 64 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

Check warning on line 64 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
session_id?: string;
status?: string;
completionStartTime?: string;
request_duration_ms?: number;
session_total_count?: number;
session_total_spend?: number;
session_total_duration?: number;
mcp_tool_call_count?: number;
mcp_tool_call_spend?: number;
session_llm_count?: number;
Expand All @@ -77,6 +78,8 @@
onSessionClick?: (sessionId: string) => void;
};

const isMultiCallSession = (row: LogEntry): boolean => (row.session_total_count || 1) > 1;

const SortableHeader = ({
label,
field,
Expand Down Expand Up @@ -119,12 +122,12 @@
)
: "Time",
accessorKey: "startTime",
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,

Check warning on line 125 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
},
{
header: "Type",
id: "type",
cell: (info: any) => {

Check warning on line 130 in ui/litellm-dashboard/src/components/view_logs/columns.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const row = info.row.original;
const sessionCount = row.session_total_count || 1;
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
Expand Down Expand Up @@ -228,13 +231,16 @@
accessorKey: "spend",
cell: (info: any) => {
const row = info.row.original;
const isSession = isMultiCallSession(row);
const displaySpend =
isSession && row.session_total_spend != null ? row.session_total_spend : info.getValue() || 0;
const mcpCount = row.mcp_tool_call_count || 0;
const mcpSpend = row.mcp_tool_call_spend || 0;

return (
<div className="flex flex-col">
<Tooltip title={`$${String(info.getValue() || 0)}`}>
<span>{getSpendString(info.getValue() || 0)}</span>
<Tooltip title={`$${String(displaySpend)}`}>
<span>{getSpendString(displaySpend)}</span>
</Tooltip>
{mcpCount > 0 && mcpSpend > 0 && (
<span className="text-[10px] text-amber-600">
Expand All @@ -259,7 +265,9 @@
: "Duration (s)",
accessorKey: "request_duration_ms",
cell: (info: any) => {
const ms = info.getValue();
const row = info.row.original;
const isSession = isMultiCallSession(row);
const ms = isSession && row.session_total_duration != null ? row.session_total_duration : info.getValue();
if (ms == null) return <span>-</span>;
const seconds = (ms / 1000).toFixed(2);
return (
Expand Down
Loading