fix(spend): sum multi-round session duration in logs UI

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
milan 2026-07-31 18:48:31 +00:00
parent 3a429f3098
commit 11225b7f13
5 changed files with 125 additions and 5 deletions

View file

@ -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"]

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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();

View file

@ -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 <span>-</span>;
return (
<CellTooltip
content={`${ms}ms`}
trigger={<span className="max-w-[15ch] truncate inline-block">{(ms / 1000).toFixed(2)}</span>}
/>
<div className="flex flex-col items-end">
<CellTooltip
content={`${ms}ms`}
trigger={<span className="max-w-[15ch] truncate inline-block">{(ms / 1000).toFixed(2)}</span>}
/>
{isMultiCallSession && <span className="text-[10px] text-gray-400">session total</span>}
</div>
);
},
},

View file

@ -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;