From 190ea0802d372f4d75039d00cd150ec94be88fca Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:44:33 -0700 Subject: [PATCH] fix(spend): sum multi-round session cost in logs UI (#32796) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- .../spend_management_endpoints.py | 27 +++++--- .../test_spend_management_endpoints.py | 63 +++++++++++++++++++ .../src/components/view_logs/columns.test.tsx | 14 +++++ .../src/components/view_logs/columns.tsx | 4 +- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index bce2e3581b5..5a0b94d1524 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3392,7 +3392,7 @@ async def _build_ui_spend_logs_response( ) count_map = {r["session_id"]: r["_count"]["session_id"] for r in counts if r.get("session_id")} - mcp_spend_map: dict[str, dict[str, Union[int, float]]] = {} + session_spend_map: dict[str, dict[str, Union[int, float]]] = {} if enrich_session_counts and session_ids: from prisma.errors import PrismaError @@ -3410,19 +3410,24 @@ async def _build_ui_spend_logs_response( rows = await prisma_client.db.query_raw( """ SELECT session_id, - COUNT(*)::int AS mcp_tool_call_count, - COALESCE(SUM(spend), 0)::double precision AS mcp_tool_call_spend + COALESCE(SUM(spend), 0)::double precision AS session_total_spend, + COUNT(*) FILTER ( + WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools') + )::int AS mcp_tool_call_count, + COALESCE(SUM(spend) FILTER ( + WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools') + ), 0)::double precision AS mcp_tool_call_spend FROM "LiteLLM_SpendLogs" WHERE session_id = ANY($1::text[]) AND api_key = ANY($2::text[]) - AND call_type IN ('call_mcp_tool', 'list_mcp_tools') GROUP BY session_id """, session_ids, authorized_api_keys, ) - mcp_spend_map = { + session_spend_map = { row["session_id"]: { + "session_total_spend": float(row.get("session_total_spend") or 0.0), "mcp_tool_call_count": int(row.get("mcp_tool_call_count") or 0), "mcp_tool_call_spend": float(row.get("mcp_tool_call_spend") or 0.0), } @@ -3431,7 +3436,7 @@ async def _build_ui_spend_logs_response( } except PrismaError: verbose_proxy_logger.debug( - "Failed to enrich MCP session spend aggregates for spend logs UI", + "Failed to enrich session spend aggregates for spend logs UI", exc_info=True, ) @@ -3441,10 +3446,12 @@ 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 - mcp_stats = mcp_spend_map.get(sid) if sid else None - if mcp_stats: - row_dict["mcp_tool_call_count"] = mcp_stats["mcp_tool_call_count"] - row_dict["mcp_tool_call_spend"] = mcp_stats["mcp_tool_call_spend"] + session_stats = session_spend_map.get(sid) if sid else None + if session_stats: + row_dict["session_total_spend"] = session_stats["session_total_spend"] + if session_stats["mcp_tool_call_count"]: + row_dict["mcp_tool_call_count"] = session_stats["mcp_tool_call_count"] + row_dict["mcp_tool_call_spend"] = session_stats["mcp_tool_call_spend"] enriched.append(row_dict) response_data: list = enriched else: diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index c3899883d58..ffca5e5a368 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -3017,6 +3017,7 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): return_value=[ { "session_id": session_id, + "session_total_spend": 15.0, "mcp_tool_call_count": 1, "mcp_tool_call_spend": 10.0, } @@ -3044,6 +3045,10 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): assert rows[1]["mcp_tool_call_count"] == 1 assert rows[1]["mcp_tool_call_spend"] == 10.0 + # Every row in the session carries the full session spend, not just its own + assert rows[0]["session_total_spend"] == 15.0 + assert rows[1]["session_total_spend"] == 15.0 + # Row without a session_id defaults to 1 assert rows[2]["session_total_count"] == 1 @@ -3055,6 +3060,64 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): ) +@pytest.mark.asyncio +async def test_build_ui_spend_logs_response_sums_multi_round_session_spend(): + """ + Regression test for LIT-4342: for a multi-round session the UI must show the + summed cost of every round, not just the first call. _build_ui_spend_logs_response + enriches each row of a session with session_total_spend aggregated across the + whole session, scoped to the authorized api_keys of the page. + """ + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _build_ui_spend_logs_response, + ) + + session_id = "sess-multi-round" + api_key = "hashed-key-xyz" + # Three rounds of the same chat session with different per-call spend. + dict_rows = [ + {"request_id": "req-1", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.01}, + {"request_id": "req-2", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.02}, + {"request_id": "req-3", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.03}, + ] + + mock_prisma = MagicMock() + mock_prisma.db.litellm_spendlogs.group_by = AsyncMock( + return_value=[{"session_id": session_id, "_count": {"session_id": 3}}] + ) + # The raw aggregate query returns the full session spend (0.01 + 0.02 + 0.03). + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "session_id": session_id, + "session_total_spend": 0.06, + "mcp_tool_call_count": 0, + "mcp_tool_call_spend": 0.0, + } + ] + ) + + 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 [row["session_total_spend"] for row in rows] == [0.06, 0.06, 0.06] + # No MCP calls in this session, so MCP fields must not be attached. + assert all("mcp_tool_call_count" not in row for row in rows) + + # The aggregate must be scoped to the authorized api_keys of the page. + _, call_args, _ = mock_prisma.db.query_raw.mock_calls[0] + assert call_args[1] == [session_id] + assert call_args[2] == [api_key] + + # --------------------------------------------------------------------------- # Tests for /spend/logs team-member permission # --------------------------------------------------------------------------- diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx index afdd17813f3..c3d9bfdd0a5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx @@ -53,4 +53,18 @@ describe("Cost column", () => { await user.hover(formatted); expect(await screen.findByText("$0.00012345678")).toBeInTheDocument(); }); + + it("shows the summed session total, not the representative call's spend, for a multi-round session", () => { + const overrides: Partial = { + request_id: "req-session", + spend: 0.01, + session_id: "sess-1", + session_total_count: 3, + session_total_spend: 0.06, + }; + render( r.request_id} />); + expect(screen.getByText("$0.060000")).toBeInTheDocument(); + expect(screen.queryByText("$0.010000")).not.toBeInTheDocument(); + expect(screen.getByText("session total")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 1d0f3f33d08..0ce5e8e4717 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -207,7 +207,8 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] const row = info.row.original; const mcpCount = row.mcp_tool_call_count || 0; const mcpSpend = row.mcp_tool_call_spend || 0; - const spend = info.getValue(); + const isMultiCallSession = (row.session_total_count || 1) > 1; + const spend = isMultiCallSession && row.session_total_spend != null ? row.session_total_spend : info.getValue(); return (
@@ -216,6 +217,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] + {isMultiCallSession && session total} {mcpCount > 0 && mcpSpend > 0 && ( incl. {getSpendString(mcpSpend)} from {mcpCount} MCP