mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
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>
This commit is contained in:
parent
b8bb95be8d
commit
190ea0802d
4 changed files with 97 additions and 11 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<LogEntry> = {
|
||||
request_id: "req-session",
|
||||
spend: 0.01,
|
||||
session_id: "sess-1",
|
||||
session_total_count: 3,
|
||||
session_total_spend: 0.06,
|
||||
};
|
||||
render(<DataTable data={[logEntry(overrides)]} columns={createColumns()} getRowId={(r) => r.request_id} />);
|
||||
expect(screen.getByText("$0.060000")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.010000")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("session total")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -207,7 +207,8 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
|
|||
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 (
|
||||
<div className="flex flex-col items-end">
|
||||
|
|
@ -216,6 +217,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
|
|||
<MoneyCell value={spend} decimals={6} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{isMultiCallSession && <span className="text-[10px] text-gray-400">session total</span>}
|
||||
{mcpCount > 0 && mcpSpend > 0 && (
|
||||
<span className="text-[10px] text-amber-600">
|
||||
incl. {getSpendString(mcpSpend)} from {mcpCount} MCP
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue