fix(ui): paginate request logs by session groups server-side (#39257)

* fix(ui): paginate request logs by session groups server-side

The logs table server-paginated raw spend logs and then collapsed
multi-call sessions client-side, so a page could render 3 rows while
the footer claimed 25 and sessions straddled pages. Adds an opt-in
group_by_session param to /spend/logs/ui that pages and counts one
representative row per session (DISTINCT ON, newest non-MCP call),
keeps the bounded count contract, enriches whole-session llm/agent
composition counts, and deletes the client-side collapse pipeline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxT89fiygmzz2ALcjpu7Ve

* feat(ui): add a 10 rows-per-page option and default request logs to it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxT89fiygmzz2ALcjpu7Ve

* fix(ui): key session aggregates per api key in the logs enrichment

Grouped pagination splits a reused session id into one row per api key,
but the enrichment still aggregated by session_id alone, so both rows
showed combined spend and counts. The aggregate query now groups by
(session_id, api_key), the count folds into it (the separate group_by
query is deleted), and each row reads its own key's totals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxT89fiygmzz2ALcjpu7Ve

* fix(ui): treat an empty api_key as a real session group value

The spend-log schema defaults api_key to an empty string; truthiness
guards in the enrichment treated it as missing, so keyless multi-call
sessions lost their count and spend. Only None means missing now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxT89fiygmzz2ALcjpu7Ve

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tin-berri 2026-09-02 17:54:17 -07:00 committed by GitHub
parent edbd4d1f98
commit ff1f21aea9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 512 additions and 152 deletions

View file

@ -55,6 +55,10 @@ router: Final = APIRouter()
SPEND_LOGS_PAGINATION_COUNT_CAP: Final = 10000
_SESSION_GROUP_KEY_SQL: Final = "COALESCE(NULLIF(session_id, ''), request_id), api_key"
_MCP_CALL_TYPES_SQL: Final = "('call_mcp_tool', 'list_mcp_tools')"
_AGENT_CALL_TYPE_SQL: Final = "'asend_message'"
_INTERNAL_HEALTH_CHECK_API_KEYS: Final = (
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
hash_token(token=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME),
@ -144,21 +148,16 @@ class _DailyTagSpendRow(TypedDict):
total_spend: float
class _SessionCountAggregate(TypedDict):
session_id: int
class _SessionCountRow(TypedDict):
session_id: str
_count: _SessionCountAggregate
class _SessionSpendRow(TypedDict):
session_id: str
api_key: ReadOnly[str]
session_total_count: ReadOnly[int]
session_total_spend: float
mcp_tool_call_count: int
mcp_tool_call_spend: float
session_cache_hit_count: ReadOnly[int]
session_llm_count: ReadOnly[int]
session_agent_count: ReadOnly[int]
class _SpendSumAggregate(TypedDict, total=False):
@ -242,18 +241,6 @@ async def _count_spend_logs(prisma_client: PrismaClient, where: Mapping[str, obj
return await _spend_logs_table(prisma_client).count(where=where)
async def _count_logs_per_session(
prisma_client: PrismaClient, session_ids: Sequence[str | None]
) -> Sequence[_SessionCountRow]:
"""Count spend log rows per session for the given session ids."""
rows: Final = await _spend_logs_table(prisma_client).group_by(
by=["session_id"],
where={"session_id": {"in": session_ids}},
count={"session_id": True},
)
return cast(Sequence[_SessionCountRow], rows) # cast-ok: group_by(count=) shape is fixed by the by/count args
async def _find_team_row(prisma_client: PrismaClient, team_id: str) -> _SupportsModelDump | None:
"""Read a single team row as a Prisma model instance."""
return await _team_table(prisma_client).find_unique(where={"team_id": team_id})
@ -2290,6 +2277,10 @@ async def ui_view_spend_logs(
default=False,
description="Exclude LiteLLM internal health check requests from results",
),
group_by_session: bool = fastapi.Query(
default=False,
description="Paginate over sessions instead of raw logs: one representative row per session, total counts sessions",
),
):
"""
View spend logs with pagination support.
@ -2644,12 +2635,16 @@ async def ui_view_spend_logs(
else:
_order_expr = order_column
joined_conditions: Final = " AND ".join(sql_conditions)
session_grouping: Final = group_by_session is True
count_group_clause: Final = f"GROUP BY {_SESSION_GROUP_KEY_SQL}" if session_grouping else ""
count_query: Final = f"""
SELECT COUNT(*) AS total_count
FROM (
SELECT 1
FROM "LiteLLM_SpendLogs"
WHERE {" AND ".join(sql_conditions)}
WHERE {joined_conditions}
{count_group_clause}
LIMIT ${p}
) AS bounded_matches
"""
@ -2660,21 +2655,36 @@ async def ui_view_spend_logs(
total_is_capped: Final = raw_total > SPEND_LOGS_PAGINATION_COUNT_CAP
total_records: Final = SPEND_LOGS_PAGINATION_COUNT_CAP if total_is_capped else raw_total
sql_query: Final = f"""
SELECT
request_id, call_type, api_key, spend, total_tokens,
select_columns: Final = """request_id, call_type, api_key, spend, total_tokens,
prompt_tokens, completion_tokens, "startTime", "endTime",
"completionStartTime", model, model_id, model_group,
custom_llm_provider, api_base, "user", metadata,
cache_hit, cache_key, request_tags, team_id,
organization_id, end_user, requester_ip_address,
session_id, status, mcp_namespaced_tool_name, agent_id,
COALESCE(request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms
COALESCE(request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms"""
sql_query: Final = (
f"""
SELECT * FROM (
SELECT DISTINCT ON ({_SESSION_GROUP_KEY_SQL})
{select_columns}
FROM "LiteLLM_SpendLogs"
WHERE {joined_conditions}
ORDER BY {_SESSION_GROUP_KEY_SQL}, call_type IN {_MCP_CALL_TYPES_SQL}, "startTime" DESC
) AS session_representatives
ORDER BY {_order_expr} {_sql_dir}{_nulls_clause}, request_id
LIMIT ${p} OFFSET ${p + 1}
"""
if session_grouping
else f"""
SELECT
{select_columns}
FROM "LiteLLM_SpendLogs"
WHERE {" AND ".join(sql_conditions)}
WHERE {joined_conditions}
ORDER BY {_order_expr} {_sql_dir}{_nulls_clause}
LIMIT ${p} OFFSET ${p + 1}
"""
)
sql_params.extend([page_size, skip])
data: Final = await prisma_client.db.query_raw(sql_query, *sql_params)
@ -4075,11 +4085,12 @@ async def _build_ui_spend_logs_response(
Build the paginated response for the UI spend-logs endpoint.
When ``enrich_session_counts`` is ``True`` (the default for the v1/UI
endpoint), each row is enriched with ``session_total_count`` so the
frontend knows which sessions are expandable (multi-call sessions).
For every row that carries a ``session_id``, a single ``GROUP BY`` query
fetches the total number of logs in each referenced session. Rows without
a ``session_id`` default to ``1``.
endpoint), each row is enriched with ``session_total_count`` plus spend
and call-type aggregates so the frontend knows which sessions are
expandable (multi-call sessions). One ``GROUP BY (session_id, api_key)``
query serves every referenced session, keyed per api key so two callers
reusing a session id never see each other's totals. Rows without a
``session_id`` default to ``1``.
When ``enrich_session_counts`` is ``False`` (v2 endpoint), rows are
serialised without the extra query.
@ -4101,7 +4112,6 @@ async def _build_ui_spend_logs_response(
A dict with ``data`` (enriched rows), ``total``, ``page``,
``page_size``, ``total_pages``, and ``total_is_capped``.
"""
count_map: dict[str, int] = {}
if enrich_session_counts:
session_ids: Final[Sequence[str | None]] = list(
{
@ -4110,15 +4120,8 @@ async def _build_ui_spend_logs_response(
if (row.get("session_id") if isinstance(row, dict) else getattr(row, "session_id", None))
}
)
if session_ids:
# NOTE: This GROUP BY runs on every v1/UI page load. The IN clause
# is bounded by page_size (typically 25-50 distinct session IDs).
# If performance degrades at scale, consider short-lived caching or
# folding the count into the main query via a window function.
counts: Final = await _count_logs_per_session(prisma_client, session_ids)
count_map = {r["session_id"]: r["_count"]["session_id"] for r in counts if r.get("session_id")}
session_spend_map: dict[str, dict[str, int | float]] = {}
session_spend_map: dict[tuple[str, str], dict[str, int | float]] = {}
if enrich_session_counts and session_ids:
from prisma.errors import PrismaError
@ -4130,38 +4133,46 @@ async def _build_ui_spend_logs_response(
{
(row.get("api_key") if isinstance(row, dict) else getattr(row, "api_key", None))
for row in data
if (row.get("api_key") if isinstance(row, dict) else getattr(row, "api_key", None))
if (row.get("api_key") if isinstance(row, dict) else getattr(row, "api_key", None)) is not None
}
)
rows: Final[Sequence[_SessionSpendRow]] = await _query_raw(
prisma_client,
"""
SELECT session_id,
f"""
SELECT session_id, api_key,
COUNT(*)::int AS session_total_count,
COALESCE(SUM(spend), 0)::double precision AS session_total_spend,
COUNT(*) FILTER (
WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools')
WHERE call_type IN {_MCP_CALL_TYPES_SQL}
)::int AS mcp_tool_call_count,
COALESCE(SUM(spend) FILTER (
WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools')
WHERE call_type IN {_MCP_CALL_TYPES_SQL}
), 0)::double precision AS mcp_tool_call_spend,
COUNT(*) FILTER (WHERE LOWER(cache_hit) = 'true')::int AS session_cache_hit_count
COUNT(*) FILTER (WHERE LOWER(cache_hit) = 'true')::int AS session_cache_hit_count,
COUNT(*) FILTER (
WHERE call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL}
)::int AS session_llm_count,
COUNT(*) FILTER (WHERE call_type = {_AGENT_CALL_TYPE_SQL})::int AS session_agent_count
FROM "LiteLLM_SpendLogs"
WHERE session_id = ANY($1::text[])
AND api_key = ANY($2::text[])
GROUP BY session_id
GROUP BY session_id, api_key
""",
session_ids,
authorized_api_keys,
)
session_spend_map = {
row["session_id"]: {
(row["session_id"], row["api_key"]): {
"session_total_count": int(row.get("session_total_count") or 0),
"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),
"session_cache_hit_count": int(row.get("session_cache_hit_count") or 0),
"session_llm_count": int(row.get("session_llm_count") or 0),
"session_agent_count": int(row.get("session_agent_count") or 0),
}
for row in rows
if row.get("session_id")
if row.get("session_id") and row.get("api_key") is not None
}
except PrismaError:
verbose_proxy_logger.debug(
@ -4174,14 +4185,17 @@ async def _build_ui_spend_logs_response(
for row in data:
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
session_stats = session_spend_map.get(sid) if sid else None
row_api_key = row_dict.get("api_key")
session_stats = session_spend_map.get((sid, row_api_key)) if sid and row_api_key is not None else None
row_dict["session_total_count"] = int(session_stats["session_total_count"]) if session_stats else 1
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"]
row_dict["session_cache_hit_count"] = session_stats["session_cache_hit_count"]
row_dict["session_llm_count"] = session_stats["session_llm_count"]
row_dict["session_agent_count"] = session_stats["session_agent_count"]
enriched.append(row_dict)
response_data: list = enriched
else:

View file

@ -21,6 +21,8 @@ interface ChatOptions {
apiKey?: string;
/** Sent as `user`, which lands in the spend log's end_user column. */
endUser?: string;
/** Sent as `litellm_trace_id`, which lands in the spend log's session_id column. */
traceId?: string;
}
/** POST /v1/chat/completions and return the completion id (the Logs Request ID). */
@ -34,6 +36,7 @@ export async function sendChatCompletion(request: APIRequestContext, opts: ChatO
model: opts.model,
messages: [{ role: "user", content: opts.prompt }],
...(opts.endUser ? { user: opts.endUser } : {}),
...(opts.traceId ? { litellm_trace_id: opts.traceId } : {}),
},
});
expect(res.ok(), `chat completion for ${opts.model} failed (${res.status()}): ${await res.text()}`).toBe(true);

View file

@ -0,0 +1,150 @@
import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
import { Page } from "../../fixtures/pages";
import { CHAT_MODEL_A, createVirtualKey, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
/**
* Session-grouped pagination (#38060): a page of N rows must render exactly N session rows, a
* session must never straddle pages, and two callers reusing one session id stay separate rows.
* All traffic is generated per run behind a unique key alias or session id, so concurrent specs
* cannot decide the outcome.
*/
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
/** Every tab stays mounted, so the DOM holds four tables at once; scope to the visible one. */
const requestLogsRows = (page: PlaywrightPage): Locator =>
page.locator("table").filter({ visible: true }).first().locator("tbody tr");
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
async function openLogs(page: PlaywrightPage): Promise<void> {
await navigateToPage(page, Page.Logs);
await dismissFeedbackPopup(page);
await expect(visibleTestId(page, "datatable-search")).toBeVisible({ timeout: 20_000 });
}
async function openFilterDrawer(page: PlaywrightPage): Promise<Locator> {
await visibleTestId(page, "datatable-filters-trigger").click();
const drawer = page.getByRole("dialog", { name: "Filters" });
await expect(drawer).toBeVisible({ timeout: 10_000 });
return drawer;
}
async function applyKeyAliasFilter(page: PlaywrightPage, drawer: Locator, alias: string): Promise<void> {
await drawer.getByRole("combobox", { name: "Search a key alias" }).click();
await page.keyboard.type(alias);
await page.getByRole("option", { name: alias, exact: true }).first().click();
await drawer.getByRole("button", { name: "Apply Filters" }).click();
await expect(drawer).not.toBeVisible({ timeout: 10_000 });
}
async function setRowsPerPage(page: PlaywrightPage, size: "25" | "50" | "100"): Promise<void> {
await visibleTestId(page, "pagination-page-size").click();
await page.getByRole("option", { name: size, exact: true }).click();
}
test.describe("Logs page session-grouped pagination", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("a 25-row page renders exactly 25 session rows and no session straddles pages", async ({ page, request }) => {
const suffix = uniqueSuffix();
const alias = `e2e-logs-pgn-${suffix}`;
const mine = await createVirtualKey(request, { key_alias: alias });
const soloIds: string[] = [];
for (let i = 0; i < 26; i++) {
soloIds.push(
await sendChatCompletion(request, {
model: CHAT_MODEL_A,
prompt: `logs-pgn-solo-${i}-${suffix}`,
apiKey: mine.key,
}),
);
}
const sessionA = `sess-pgn-a-${suffix}`;
const sessionB = `sess-pgn-b-${suffix}`;
let lastSessionCallId = "";
for (let i = 0; i < 7; i++) {
lastSessionCallId = await sendChatCompletion(request, {
model: CHAT_MODEL_A,
prompt: `logs-pgn-a-${i}-${suffix}`,
apiKey: mine.key,
traceId: sessionA,
});
}
for (let i = 0; i < 3; i++) {
lastSessionCallId = await sendChatCompletion(request, {
model: CHAT_MODEL_A,
prompt: `logs-pgn-b-${i}-${suffix}`,
apiKey: mine.key,
traceId: sessionB,
});
}
await waitForSpendLog(request, lastSessionCallId);
await waitForSpendLog(request, soloIds[soloIds.length - 1]);
// 36 calls in 28 session groups: 26 solos plus sessions of 7 and 3.
await openLogs(page);
const drawer = await openFilterDrawer(page);
await applyKeyAliasFilter(page, drawer, alias);
await setRowsPerPage(page, "25");
await expect(visibleTestId(page, "pagination-range")).toHaveText("Showing 1-25 of 28", { timeout: 30_000 });
await expect(requestLogsRows(page)).toHaveCount(25);
// The sessions are the newest groups, so their single representative rows sit on page 1.
await expect(requestLogsRows(page).filter({ hasText: sessionA })).toHaveCount(1);
await expect(requestLogsRows(page).filter({ hasText: sessionA })).toContainText("7");
await expect(requestLogsRows(page).filter({ hasText: sessionB })).toHaveCount(1);
await visibleTestId(page, "pagination-next").click();
await expect(visibleTestId(page, "pagination-range")).toHaveText("Showing 26-28 of 28", { timeout: 30_000 });
await expect(requestLogsRows(page)).toHaveCount(3);
await expect(requestLogsRows(page).filter({ hasText: sessionA })).toHaveCount(0);
await expect(requestLogsRows(page).filter({ hasText: sessionB })).toHaveCount(0);
});
test("two keys reusing one session id stay separate rows", async ({ page, request }) => {
const suffix = uniqueSuffix();
const mine = await createVirtualKey(request, { key_alias: `e2e-logs-pgn-mine-${suffix}` });
const theirs = await createVirtualKey(request, { key_alias: `e2e-logs-pgn-theirs-${suffix}` });
const sharedSession = `sess-pgn-shared-${suffix}`;
let lastId = "";
for (let i = 0; i < 2; i++) {
lastId = await sendChatCompletion(request, {
model: CHAT_MODEL_A,
prompt: `logs-pgn-shared-mine-${i}-${suffix}`,
apiKey: mine.key,
traceId: sharedSession,
});
}
lastId = await sendChatCompletion(request, {
model: CHAT_MODEL_A,
prompt: `logs-pgn-shared-theirs-${suffix}`,
apiKey: theirs.key,
traceId: sharedSession,
});
await waitForSpendLog(request, lastId);
await openLogs(page);
const drawer = await openFilterDrawer(page);
await drawer.getByPlaceholder("Enter session ID…").fill(sharedSession);
await drawer.getByRole("button", { name: "Apply Filters" }).click();
await expect(drawer).not.toBeVisible({ timeout: 10_000 });
// One row per caller: reusing a session id must not merge two keys' activity into one row.
await expect(requestLogsRows(page).filter({ hasText: sharedSession })).toHaveCount(2, { timeout: 30_000 });
// And each row carries ITS key's totals: two calls badge the first key's row,
// while the other key's single call renders as a plain LLM row.
const mineRow = requestLogsRows(page).filter({ hasText: sharedSession }).filter({ hasText: mine.token });
const theirsRow = requestLogsRows(page).filter({ hasText: sharedSession }).filter({ hasText: theirs.token });
await expect(mineRow).toHaveCount(1);
await expect(theirsRow).toHaveCount(1);
await expect(mineRow.getByText("2", { exact: true })).toBeVisible();
await expect(theirsRow.getByText("LLM", { exact: true })).toBeVisible();
});
});

View file

@ -3959,18 +3959,18 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
]
mock_prisma = MagicMock()
mock_prisma.db.litellm_spendlogs.group_by = AsyncMock(
return_value=[
{"session_id": session_id, "_count": {"session_id": 2}},
]
)
mock_prisma.db.litellm_spendlogs.group_by = AsyncMock()
mock_prisma.db.query_raw = AsyncMock(
return_value=[
{
"session_id": session_id,
"api_key": api_key,
"session_total_count": 2,
"session_total_spend": 15.0,
"mcp_tool_call_count": 1,
"mcp_tool_call_spend": 10.0,
"session_llm_count": 1,
"session_agent_count": 0,
}
]
)
@ -3995,6 +3995,8 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
assert rows[0]["mcp_tool_call_spend"] == 10.0
assert rows[1]["mcp_tool_call_count"] == 1
assert rows[1]["mcp_tool_call_spend"] == 10.0
assert rows[0]["session_llm_count"] == 1
assert rows[0]["session_agent_count"] == 0
# Every row in the session carries the full session spend, not just its own
assert rows[0]["session_total_spend"] == 15.0
@ -4003,13 +4005,126 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts():
# Row without a session_id defaults to 1
assert rows[2]["session_total_count"] == 1
# group_by should have been called with the session_id
mock_prisma.db.litellm_spendlogs.group_by.assert_called_once_with(
by=["session_id"],
where={"session_id": {"in": [session_id]}},
count={"session_id": True},
# The count is folded into the single aggregate query; no separate group_by call.
mock_prisma.db.litellm_spendlogs.group_by.assert_not_called()
@pytest.mark.asyncio
async def test_build_ui_spend_logs_response_key_split_session_gets_per_key_aggregates():
"""
Two keys reusing one session id are separate rows under grouped pagination,
and each row must carry ITS key's totals, never the combined session's:
the aggregate query and its lookup are keyed by (session_id, api_key).
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_build_ui_spend_logs_response,
)
session_id = "sess-shared"
dict_rows = [
{"request_id": "req-a", "session_id": session_id, "call_type": "completion", "api_key": "key-a"},
{"request_id": "req-b", "session_id": session_id, "call_type": "completion", "api_key": "key-b"},
]
mock_prisma = MagicMock()
mock_prisma.db.query_raw = AsyncMock(
return_value=[
{
"session_id": session_id,
"api_key": "key-a",
"session_total_count": 2,
"session_total_spend": 0.2,
"mcp_tool_call_count": 0,
"mcp_tool_call_spend": 0.0,
"session_cache_hit_count": 1,
"session_llm_count": 2,
"session_agent_count": 0,
},
{
"session_id": session_id,
"api_key": "key-b",
"session_total_count": 1,
"session_total_spend": 0.7,
"mcp_tool_call_count": 0,
"mcp_tool_call_spend": 0.0,
"session_cache_hit_count": 0,
"session_llm_count": 1,
"session_agent_count": 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 [(r["session_total_count"], r["session_total_spend"]) for r in rows] == [(2, 0.2), (1, 0.7)]
assert [r["session_cache_hit_count"] for r in rows] == [1, 0]
assert [r["session_llm_count"] for r in rows] == [2, 1]
aggregate_sql = mock_prisma.db.query_raw.mock_calls[0][1][0]
assert "GROUP BY session_id, api_key" in aggregate_sql
@pytest.mark.asyncio
async def test_build_ui_spend_logs_response_empty_api_key_keeps_session_aggregates():
"""
The spend-log schema defaults api_key to an empty string, which is a real
group value and not a missing one: a multi-call session logged under an
empty key must keep its count and spend instead of degrading to a plain
single-call row.
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_build_ui_spend_logs_response,
)
session_id = "sess-keyless"
dict_rows = [
{"request_id": "req-1", "session_id": session_id, "call_type": "completion", "api_key": ""},
]
mock_prisma = MagicMock()
mock_prisma.db.query_raw = AsyncMock(
return_value=[
{
"session_id": session_id,
"api_key": "",
"session_total_count": 3,
"session_total_spend": 0.09,
"mcp_tool_call_count": 0,
"mcp_tool_call_spend": 0.0,
"session_cache_hit_count": 0,
"session_llm_count": 3,
"session_agent_count": 0,
}
]
)
result = await _build_ui_spend_logs_response(
prisma_client=mock_prisma,
data=dict_rows,
total_records=1,
page=1,
page_size=50,
total_pages=1,
enrich_session_counts=True,
)
row = result["data"][0]
assert row["session_total_count"] == 3
assert row["session_total_spend"] == 0.09
# The empty key must reach the aggregate's authorized-keys filter too.
_, call_args, _ = mock_prisma.db.query_raw.mock_calls[0]
assert call_args[2] == [""]
@pytest.mark.asyncio
async def test_build_ui_spend_logs_response_sums_multi_round_session_spend():
@ -4033,14 +4148,13 @@ async def test_build_ui_spend_logs_response_sums_multi_round_session_spend():
]
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,
"api_key": api_key,
"session_total_count": 3,
"session_total_spend": 0.06,
"mcp_tool_call_count": 0,
"mcp_tool_call_spend": 0.0,
@ -4089,13 +4203,12 @@ async def test_build_ui_spend_logs_response_session_cache_hit_count():
]
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,
"api_key": api_key,
"session_total_count": 2,
"session_total_spend": 0.05,
"mcp_tool_call_count": 0,
"mcp_tool_call_spend": 0.0,

View file

@ -274,6 +274,9 @@ async def test_spend_logs_ui_uses_bounded_count_not_full_scan(monkeypatch):
"the page query must not carry a window count that forces a full-window "
f"scan. SQL was:\n{page_sql}"
)
assert "GROUP BY" not in count_sql and "DISTINCT ON" not in page_sql, (
"without group_by_session the endpoint must keep raw per-call pagination"
)
assert response["total"] == 137
assert response["total_is_capped"] is False
@ -499,3 +502,106 @@ async def test_global_spend_report_team_group_forwards_team_id(monkeypatch):
params = mock_prisma.db.query_raw.call_args[0][1:]
assert "team_x" in params, "team_id must be forwarded into the DB query params"
assert "sl.team_id = $3" in sql, f"team query must filter on team_id. SQL was:\n{sql}"
@pytest.mark.asyncio
async def test_spend_logs_ui_group_by_session_paginates_sessions(monkeypatch):
"""
With group_by_session=true, /spend/logs/ui must page and count SESSIONS,
not raw calls: the page query returns one representative row per session
(DISTINCT ON the session group key, preferring non-MCP calls, newest
first) and the bounded count counts groups. Otherwise the UI collapses a
server page of N calls into fewer visible rows while the footer still
claims N (issue #38060).
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.spend_tracking.spend_management_endpoints import (
SPEND_LOGS_PAGINATION_COUNT_CAP,
ui_view_spend_logs,
)
page_rows = [
{"request_id": "req-1", "metadata": "{}", "session_id": None},
{"request_id": "req-2", "metadata": "{}", "session_id": None},
]
mock_prisma = _make_ui_spend_logs_mock(count_total=12, page_rows=page_rows)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
mock_request = MagicMock()
mock_request.url.path = "/spend/logs/ui"
response = await ui_view_spend_logs(
request=mock_request,
api_key=None,
user_id=None,
request_id=None,
start_date="2026-02-16 00:00:00",
end_date="2026-02-16 23:59:59",
page=1,
page_size=50,
sort_by="startTime",
sort_order="desc",
user_api_key_dict=auth,
group_by_session=True,
)
group_key = "COALESCE(NULLIF(session_id, ''), request_id), api_key"
count_call = mock_prisma.db.query_raw.call_args_list[0]
count_sql = count_call[0][0]
assert f"GROUP BY {group_key}" in count_sql, f"grouped total must count sessions. SQL was:\n{count_sql}"
assert "COUNT(*) OVER ()" not in count_sql
assert "LIMIT" in count_sql and "FROM (" in count_sql, "the grouped count must stay bounded"
assert count_call[0][-1] == SPEND_LOGS_PAGINATION_COUNT_CAP + 1
page_sql = mock_prisma.db.query_raw.call_args_list[1][0][0]
assert f"DISTINCT ON ({group_key})" in page_sql, f"page must return one row per session. SQL was:\n{page_sql}"
assert f"ORDER BY {group_key}, call_type IN ('call_mcp_tool', 'list_mcp_tools'), \"startTime\" DESC" in page_sql, (
"the session representative must prefer the newest non-MCP call"
)
assert "COUNT(*) OVER ()" not in page_sql
assert response["total"] == 12
assert response["total_is_capped"] is False
assert response["total_pages"] == 1
@pytest.mark.asyncio
async def test_spend_logs_ui_request_id_lookup_with_grouping_returns_exact_row(monkeypatch):
"""
A request_id lookup with group_by_session=true must still resolve the
exact requested row: the filter runs before grouping, so the row is its
own group's representative and deep links keep working.
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.spend_tracking.spend_management_endpoints import ui_view_spend_logs
target_row = {"request_id": "req-deep-link", "metadata": "{}", "session_id": None}
mock_prisma = _make_ui_spend_logs_mock(count_total=1, page_rows=[target_row])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
mock_request = MagicMock()
mock_request.url.path = "/spend/logs/ui"
response = await ui_view_spend_logs(
request=mock_request,
api_key=None,
user_id=None,
request_id="req-deep-link",
start_date=None,
end_date=None,
page=1,
page_size=1,
sort_by="startTime",
sort_order="desc",
user_api_key_dict=auth,
group_by_session=True,
)
page_call = mock_prisma.db.query_raw.call_args_list[1]
assert "request_id = $" in page_call[0][0], "the request_id equality filter must survive grouping"
assert "req-deep-link" in page_call[0]
assert [row["request_id"] for row in response["data"]] == ["req-deep-link"]
assert response["total"] == 1

View file

@ -2042,6 +2042,7 @@ interface UiSpendLogsParams {
min_spend?: number;
max_spend?: number;
exclude_internal_health_checks?: boolean;
group_by_session?: boolean;
}
interface UiSpendLogsCallOptions {

View file

@ -138,33 +138,39 @@ describe("RequestLogsPanel", () => {
respondWith([]);
});
describe("multi-call session collapsing", () => {
const sessionRows = [
logEntry({ request_id: "req-mcp", call_type: "call_mcp_tool", session_id: "sess-1", session_total_count: 3 }),
logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
logEntry({ request_id: "req-llm-2", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
];
it("collapses a multi-call session to a single representative row", async () => {
respondWith(sessionRows);
describe("server-grouped session pagination (#38060)", () => {
it("requests session-grouped pages of 10 rows by default", async () => {
renderPanel();
await waitFor(() => expect(row("req-mcp") ?? row("req-llm") ?? row("req-llm-2")).not.toBeNull());
const rendered = ["req-mcp", "req-llm", "req-llm-2"].filter((id) => row(id) !== null);
expect(rendered).toHaveLength(1);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCall()?.params?.group_by_session).toBe(true);
expect(lastCall()?.page_size).toBe(10);
});
it("prefers an LLM call over an MCP call as the session's representative", async () => {
respondWith(sessionRows);
it("renders every row the server returns without client-side collapsing", async () => {
respondWith([
logEntry({ request_id: "req-a", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
logEntry({ request_id: "req-b", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
logEntry({ request_id: "req-c", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
]);
renderPanel();
await waitFor(() => expect(row("req-llm")).not.toBeNull());
expect(row("req-mcp")).toBeNull();
await waitFor(() => expect(row("req-a")).not.toBeNull());
expect(row("req-b")).not.toBeNull();
expect(row("req-c")).not.toBeNull();
});
it("shows the session's call count and composition on the representative row", async () => {
respondWith(sessionRows);
it("shows the session's call count on the server-picked representative row", async () => {
respondWith([
logEntry({
request_id: "req-llm",
call_type: "acompletion",
session_id: "sess-1",
session_total_count: 3,
session_llm_count: 2,
mcp_tool_call_count: 1,
}),
]);
renderPanel();
await waitFor(() => expect(row("req-llm")).not.toBeNull());
@ -296,6 +302,7 @@ describe("RequestLogsPanel", () => {
if (!byIdCall) throw new Error("expected a by-id uiSpendLogsCall");
expect(byIdCall.page).toBe(1);
expect(byIdCall.page_size).toBe(1);
expect(byIdCall.params?.group_by_session).toBeUndefined();
});
it("closing the drawer removes ?log_id= from the URL and closes the drawer", async () => {

View file

@ -10,7 +10,7 @@ import type { KeyResponse } from "../key_team_helpers/key_list";
import { keyInfoV1Call, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import type { LogEntry } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { LOGS_PAGE_SIZE_OPTIONS } from "./constants";
import {
DEFAULT_LOGS_SORTING,
formatLogsWindow,
@ -24,7 +24,7 @@ import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar";
import { RequestLogsTable } from "./RequestLogsTable";
const PAGE_SIZE = 50;
const PAGE_SIZE = LOGS_PAGE_SIZE_OPTIONS[0];
const DEFAULT_INTERVAL = { value: 24, unit: "hours" };
interface RequestLogsPanelProps {
@ -35,12 +35,6 @@ interface RequestLogsPanelProps {
isActive: boolean;
}
interface SessionComposition {
llm: number;
agent: number;
mcp: number;
}
export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [sorting, setSorting] = useState<SortingState>(DEFAULT_LOGS_SORTING);
@ -157,49 +151,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const isDrawerOpen = displayLog !== null || displaySessionId !== null;
const rows = useMemo<LogEntry[]>(() => {
const searchedLogs = filteredLogs.data;
const sessionCompositionById = searchedLogs.reduce<Record<string, SessionComposition>>((acc, log) => {
if (!log.session_id) return acc;
if (!acc[log.session_id]) {
acc[log.session_id] = { llm: 0, agent: 0, mcp: 0 };
}
if (MCP_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].mcp += 1;
} else if (AGENT_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].agent += 1;
} else {
acc[log.session_id].llm += 1;
}
return acc;
}, {});
const sessionRepresentativeMap = new Map<string, { requestId: string; isMcp: boolean }>();
for (const log of searchedLogs) {
if (!log.session_id || (log.session_total_count || 1) <= 1) continue;
const isMcp = MCP_CALL_TYPES.includes(log.call_type);
const existing = sessionRepresentativeMap.get(log.session_id);
if (!existing || (existing.isMcp && !isMcp)) {
sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp });
}
}
return searchedLogs
.map((log) => {
const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined;
return {
...log,
session_llm_count: sessionComposition?.llm ?? undefined,
session_mcp_count: sessionComposition?.mcp ?? undefined,
session_agent_count: sessionComposition?.agent ?? undefined,
};
})
.filter((log) => {
if (!log.session_id || (log.session_total_count || 1) <= 1) return true;
return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id;
});
}, [filteredLogs.data]);
const rows: LogEntry[] = filteredLogs.data;
const searchTerm = useMemo(() => {
const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.REQUEST_ID);
@ -258,13 +210,12 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
);
const handleSessionClick = useCallback(
(sessionId: string) => {
if (!sessionId) return;
const log = rows.find((candidate) => candidate.session_id === sessionId) ?? null;
(log: LogEntry) => {
if (!log.session_id) return;
setSelectedLog(log);
openSession(sessionId, log?.request_id ?? null);
openSession(log.session_id, log.request_id);
},
[rows, openSession],
[openSession],
);
const handleSelectLog = useCallback(

View file

@ -8,6 +8,7 @@ import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components
import type { Team } from "../key_team_helpers/key_list";
import type { LogEntry } from "./columns";
import { LOGS_PAGE_SIZE_OPTIONS } from "./constants";
import { LOG_FILTER_LABELS, type LogsWindow } from "./log_filter_logic";
import { RequestLogsFilters } from "./RequestLogsFilters";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
@ -28,7 +29,7 @@ interface RequestLogsTableProps {
onRefresh: () => void;
onRowClick: (log: LogEntry) => void;
onKeyHashClick: (keyHash: string) => void;
onSessionClick: (sessionId: string) => void;
onSessionClick: (log: LogEntry) => void;
teams: Team[];
logsWindow: LogsWindow;
toolbarChildren?: ReactNode;
@ -91,6 +92,7 @@ export function RequestLogsTable({
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}
pageSizeOptions={LOGS_PAGE_SIZE_OPTIONS}
rowCount={rowCount}
filterMode="server"
columnFilters={columnFilters}

View file

@ -85,13 +85,19 @@ describe("row action cells", () => {
expect(deps.onKeyHashClick).toHaveBeenCalledWith("sk-hash-9");
});
it("reports the session id from the session cell", async () => {
it("reports the clicked row from the session cell, so two rows sharing a session id stay distinguishable", async () => {
const user = userEvent.setup();
const deps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() };
renderRows([logEntry({ request_id: "req-sess", session_id: "sess-42" })], deps);
renderRows(
[
logEntry({ request_id: "req-key-a", session_id: "sess-42", api_key: "key-a" }),
logEntry({ request_id: "req-key-b", session_id: "sess-42", api_key: "key-b" }),
],
deps,
);
await user.click(screen.getByText("sess-42"));
expect(deps.onSessionClick).toHaveBeenCalledWith("sess-42");
await user.click(screen.getAllByText("sess-42")[1]);
expect(deps.onSessionClick).toHaveBeenCalledWith(expect.objectContaining({ request_id: "req-key-b" }));
});
});

View file

@ -13,7 +13,7 @@ import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } fr
export interface RequestLogsTableColumnsDeps {
onKeyHashClick: (keyHash: string) => void;
onSessionClick: (sessionId: string) => void;
onSessionClick: (log: LogEntry) => void;
}
const readMetaString = (metadata: Record<string, unknown> | undefined, key: string): string | undefined => {
@ -61,7 +61,7 @@ export const getRequestLogsTableColumns = ({
const isAgent = AGENT_CALL_TYPES.includes(log.call_type);
const sessionLlmCount = log.session_llm_count ?? (isMcp || isAgent ? 0 : sessionCount);
const sessionAgentCount = log.session_agent_count ?? (isAgent ? sessionCount : 0);
const sessionMcpCount = log.session_mcp_count ?? (isMcp ? sessionCount : 0);
const sessionMcpCount = log.mcp_tool_call_count ?? (isMcp ? sessionCount : 0);
if (isMcp) return <McpBadge />;
if (isAgent && sessionCount <= 1) return <AgentBadge />;
@ -113,7 +113,7 @@ export const getRequestLogsTableColumns = ({
header: "Session ID",
size: 120,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.session_id} onClick={onSessionClick} />,
cell: ({ row }) => <IdCell value={row.original.session_id} onClick={() => onSessionClick(row.original)} />,
},
{
id: "request_id",

View file

@ -46,6 +46,5 @@ export type LogEntry = {
mcp_tool_call_count?: number;
mcp_tool_call_spend?: number;
session_llm_count?: number;
session_mcp_count?: number;
session_agent_count?: number;
};

View file

@ -12,6 +12,9 @@ export const ERROR_CODE_OPTIONS: { label: string; value: string }[] = [
{ label: "529 - Overloaded", value: "529" },
];
/** Page sizes the logs tables offer; the first entry is the default. */
export const LOGS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
/** Call types that represent MCP tool invocations (shared across columns, index, drawer). */
export const MCP_CALL_TYPES = ["call_mcp_tool", "list_mcp_tools"];

View file

@ -181,6 +181,7 @@ export function useLogFilterLogic({
sort_by: sortBy,
sort_order: sortOrder,
exclude_internal_health_checks: excludeInternalHealthChecks,
group_by_session: true,
},
});
},

View file

@ -56755,6 +56755,8 @@ export interface operations {
sort_order?: string | null;
/** @description Exclude LiteLLM internal health check requests from results */
exclude_internal_health_checks?: boolean;
/** @description Paginate over sessions instead of raw logs: one representative row per session, total counts sessions */
group_by_session?: boolean;
};
header?: never;
path?: never;
@ -56867,6 +56869,8 @@ export interface operations {
sort_order?: string | null;
/** @description Exclude LiteLLM internal health check requests from results */
exclude_internal_health_checks?: boolean;
/** @description Paginate over sessions instead of raw logs: one representative row per session, total counts sessions */
group_by_session?: boolean;
};
header?: never;
path?: never;