diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 0ec2788fbaa..a319535f725 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -176,6 +176,7 @@ class _SessionSpendRow(TypedDict): api_key: ReadOnly[str] session_total_count: ReadOnly[int] session_total_spend: float + session_total_duration_ms: ReadOnly[int] mcp_tool_call_count: int mcp_tool_call_spend: float session_cache_hit_count: ReadOnly[int] @@ -194,6 +195,7 @@ _SESSION_MODEL_NAME_MAX_LEN: Final = 256 class _SessionSpendStats(NamedTuple): session_total_count: int session_total_spend: float + session_total_duration_ms: int mcp_tool_call_count: int mcp_tool_call_spend: float session_cache_hit_count: int @@ -4543,6 +4545,12 @@ async def _build_ui_spend_logs_response( SELECT session_id, api_key, COUNT(*)::int AS session_total_count, COALESCE(SUM(spend), 0)::double precision AS session_total_spend, + COALESCE(SUM( + COALESCE( + request_duration_ms, + (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER + ) + ), 0)::bigint AS session_total_duration_ms, COUNT(*) FILTER ( WHERE call_type IN {_MCP_CALL_TYPES_SQL} )::int AS mcp_tool_call_count, @@ -4584,6 +4592,7 @@ async def _build_ui_spend_logs_response( (row["session_id"], row["api_key"]): _SessionSpendStats( session_total_count=int(row.get("session_total_count") or 0), session_total_spend=float(row.get("session_total_spend") or 0.0), + session_total_duration_ms=int(row.get("session_total_duration_ms") or 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), session_cache_hit_count=int(row.get("session_cache_hit_count") or 0), @@ -4615,6 +4624,7 @@ async def _build_ui_spend_logs_response( row_dict["session_total_count"] = session_stats.session_total_count if session_stats else 1 if session_stats: row_dict["session_total_spend"] = session_stats.session_total_spend + row_dict["session_total_duration_ms"] = session_stats.session_total_duration_ms 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 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 60bff50f000..772c5f674d5 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 @@ -5353,6 +5353,87 @@ async def test_build_ui_spend_logs_response_sums_multi_round_session_tokens(): assert all(key not in rows[2] for key in token_keys) +@pytest.mark.asyncio +async def test_build_ui_spend_logs_response_sums_multi_round_session_duration(): + """ + Regression test: a multi-round session collapses into a single UI row, so that row + must carry the duration of every round summed, not just the representative call's. + Rows written before request_duration_ms existed are NULL, so the aggregate falls back + to endTime - startTime for them. + """ + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _build_ui_spend_logs_response, + ) + + session_id = "sess-multi-round-duration" + api_key = "hashed-key-xyz" + dict_rows = [ + { + "request_id": "req-1", + "session_id": session_id, + "call_type": "completion", + "api_key": api_key, + "spend": 0.01, + "request_duration_ms": 1200, + }, + { + "request_id": "req-2", + "session_id": session_id, + "call_type": "completion", + "api_key": api_key, + "spend": 0.02, + "request_duration_ms": 4200, + }, + { + "request_id": "req-3", + "session_id": None, + "call_type": "completion", + "api_key": api_key, + "spend": 0.03, + "request_duration_ms": 900, + }, + ] + + mock_prisma = MagicMock() + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "session_id": session_id, + "api_key": api_key, + "session_total_count": 2, + "session_total_spend": 0.03, + "session_total_duration_ms": 5400, + "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"] + session_rows = rows[:2] + assert [row["session_total_duration_ms"] for row in session_rows] == [5400, 5400] + assert all(isinstance(row["session_total_duration_ms"], int) for row in session_rows) + assert [row["request_duration_ms"] for row in rows] == [1200, 4200, 900] + assert "session_total_duration_ms" not in rows[2] + + _, call_args, _ = mock_prisma.db.query_raw.mock_calls[0] + sql = " ".join(call_args[0].split()) + assert ( + 'SUM( COALESCE( request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER ) )' + in sql + ) + + @pytest.mark.asyncio async def test_build_ui_spend_logs_response_session_cache_hit_count(): """ diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx index 9f0e659cb1f..626d290e0e8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx @@ -73,6 +73,57 @@ describe("Cost column", () => { expect(screen.queryByText("$0.010000")).not.toBeInTheDocument(); expect(screen.getByText("session total")).toBeInTheDocument(); }); + + it("does not label the per-call spend a session total when the aggregate is unavailable", () => { + const rowWithoutAggregate: Partial = { + request_id: "req-session-no-aggregate", + spend: 0.01, + session_id: "sess-1", + session_total_count: 3, + }; + renderRows([logEntry(rowWithoutAggregate)]); + + expect(screen.getByText("$0.010000")).toBeInTheDocument(); + expect(screen.queryByText("session total")).not.toBeInTheDocument(); + }); +}); + +describe("Duration column", () => { + const sessionRow: Partial = { + request_id: "req-session-duration", + request_duration_ms: 1200, + session_id: "sess-1", + session_total_count: 3, + }; + + it("shows the summed session duration, not the representative call's duration, for a multi-round session", () => { + const aggregatedRow: Partial = { ...sessionRow, session_total_duration_ms: 5400 }; + renderRows([logEntry(aggregatedRow)]); + + expect(screen.getByText("5.40")).toBeInTheDocument(); + expect(screen.queryByText("1.20")).not.toBeInTheDocument(); + expect(screen.getByText("session total")).toBeInTheDocument(); + }); + + it("does not label the per-call duration a session total when the aggregate is unavailable", () => { + renderRows([logEntry(sessionRow)]); + + expect(screen.getByText("1.20")).toBeInTheDocument(); + expect(screen.queryByText("session total")).not.toBeInTheDocument(); + }); + + it("shows the call's own duration for a single-call session", () => { + const singleCallRow: Partial = { + ...sessionRow, + request_id: "req-single-duration", + session_id: "sess-2", + session_total_count: 1, + session_total_duration_ms: 1200, + }; + renderRows([logEntry(singleCallRow)]); + + expect(screen.getByText("1.20")).toBeInTheDocument(); + }); }); describe("Tokens column", () => { diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx index 1ec1087a1a4..ea171df5082 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx @@ -163,7 +163,8 @@ export const getRequestLogsTableColumns = ({ const mcpCount = log.mcp_tool_call_count || 0; const mcpSpend = log.mcp_tool_call_spend || 0; const isMultiCallSession = (log.session_total_count || 1) > 1; - const spend = isMultiCallSession && log.session_total_spend != null ? log.session_total_spend : log.spend; + const sessionTotalSpend = isMultiCallSession ? log.session_total_spend : undefined; + const spend = sessionTotalSpend ?? log.spend; const money = ( @@ -173,7 +174,7 @@ export const getRequestLogsTableColumns = ({ return (
{spend ? : money} - {isMultiCallSession && session total} + {sessionTotalSpend != null && session total} {mcpCount > 0 && mcpSpend > 0 && ( incl. {getSpendString(mcpSpend)} from {mcpCount} MCP @@ -190,13 +191,19 @@ export const getRequestLogsTableColumns = ({ enableSorting: true, meta: { numeric: true }, cell: ({ row }) => { - const ms = row.original.request_duration_ms; + const log = row.original; + const isMultiCallSession = (log.session_total_count || 1) > 1; + const sessionTotalMs = isMultiCallSession ? log.session_total_duration_ms : undefined; + const ms = sessionTotalMs ?? log.request_duration_ms; if (ms == null) return -; return ( - {(ms / 1000).toFixed(2)}} - /> +
+ {(ms / 1000).toFixed(2)}} + /> + {sessionTotalMs != null && session total} +
); }, }, diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 0a2b22b95e4..ce4a72a6d38 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -43,6 +43,7 @@ export type LogEntry = { request_duration_ms?: number; session_total_count?: number; session_total_spend?: number; + session_total_duration_ms?: number; session_total_tokens?: number; session_total_prompt_tokens?: number; session_total_completion_tokens?: number;