diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 42788227acc..d49bd6eb912 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3461,6 +3461,12 @@ async def _build_ui_spend_logs_response( """ SELECT session_id, COALESCE(SUM(spend), 0)::double precision AS session_total_spend, + COALESCE(SUM( + COALESCE( + request_duration_ms, + (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER + ) + ), 0)::double precision AS session_total_duration_ms, COUNT(*) FILTER ( WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools') )::int AS mcp_tool_call_count, @@ -3478,6 +3484,7 @@ async def _build_ui_spend_logs_response( session_spend_map = { row["session_id"]: { "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), } @@ -3499,6 +3506,7 @@ async def _build_ui_spend_logs_response( session_stats = session_spend_map.get(sid) if sid else None 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 aa20c3f6ed4..c93810bdce0 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 @@ -3516,6 +3516,78 @@ async def test_build_ui_spend_logs_response_sums_multi_round_session_spend(): assert call_args[2] == [api_key] +@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, + }, + ] + + mock_prisma = MagicMock() + mock_prisma.db.litellm_spendlogs.group_by = AsyncMock( + return_value=[{"session_id": session_id, "_count": {"session_id": 2}}] + ) + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "session_id": session_id, + "session_total_spend": 0.03, + "session_total_duration_ms": 5400.0, + "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=2, + page=1, + page_size=50, + total_pages=1, + enrich_session_counts=True, + ) + + rows = result["data"] + assert [row["session_total_duration_ms"] for row in rows] == [5400, 5400] + assert all(isinstance(row["session_total_duration_ms"], int) for row in rows) + assert [row["request_duration_ms"] for row in rows] == [1200, 4200] + + _, 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 + ) + + # --------------------------------------------------------------------------- # Tests for /spend/logs team-member permission # --------------------------------------------------------------------------- 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 2fdc8455ca1..f099edd249a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx @@ -75,6 +75,37 @@ describe("Cost column", () => { }); }); +describe("Duration column", () => { + it("shows the summed session duration, not the representative call's duration, for a multi-round session", () => { + renderRows([ + logEntry({ + request_id: "req-session-duration", + request_duration_ms: 1200, + session_id: "sess-1", + session_total_count: 3, + session_total_duration_ms: 5400, + }), + ]); + + expect(screen.getByText("5.40")).toBeInTheDocument(); + expect(screen.queryByText("1.20")).not.toBeInTheDocument(); + }); + + it("shows the call's own duration for a single-call session", () => { + renderRows([ + logEntry({ + request_id: "req-single-duration", + request_duration_ms: 1200, + session_id: "sess-2", + session_total_count: 1, + session_total_duration_ms: 1200, + }), + ]); + + expect(screen.getByText("1.20")).toBeInTheDocument(); + }); +}); + describe("row action cells", () => { it("reports the key hash through the injected dependency rather than a row field", async () => { const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx index 4e5a83ac7dd..ffdd16e26a9 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx @@ -160,13 +160,21 @@ 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 ms = + isMultiCallSession && log.session_total_duration_ms != null + ? log.session_total_duration_ms + : log.request_duration_ms; if (ms == null) return -; return ( - {(ms / 1000).toFixed(2)}} - /> +
+ {(ms / 1000).toFixed(2)}} + /> + {isMultiCallSession && 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 d4f784bf165..5b89c6f282c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -42,6 +42,7 @@ export type LogEntry = { request_duration_ms?: number; session_total_count?: number; session_total_spend?: number; + session_total_duration_ms?: number; mcp_tool_call_count?: number; mcp_tool_call_spend?: number; session_llm_count?: number;