From fa562838064c1bbb0b59ce6c14fce4f69a9844dd Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 31 Jul 2026 11:51:36 -0700 Subject: [PATCH] feat(ui): show which log rows are the auto-router's own classifier calls (#35304) * feat(spend-logs): record when a spend log row is the auto-router's own classifier call The complexity router's classifier sub-call copies the parent request's metadata verbatim, so its spend log row carries the caller's key, team and user and is indistinguishable from traffic the caller actually sent. Nothing on the row says otherwise: call_type is "acompletion" either way, model_group is overwritten to the classifier's own model group so the row never looks auto-routed, and routing_decision is absent exactly as it is on an ordinary request. Record the fact the system already knows at call time. internal_call_origin is declared on SpendLogsMetadata, which is the allowlist _get_spend_logs_metadata projects onto, and stamped in _classifier_call_metadata; both classifier paths already route through that one function and it feeds the metadata and litellm_metadata buckets alike, so every request surface is covered at one site. The key is reserved rather than caller-supplied, so it joins routing_decision in the untrusted-metadata strip and a caller cannot label their own traffic as router overhead. The classifier call also inherited no session identity, so the router minted a fresh trace id and the row landed in a session of its own. Forwarding the parent's session puts it in the trace of the request that triggered it, which is where an operator looks for what the routing cost. * feat(ui): show which log rows are the auto-router's own classifier calls A classifier row now carries internal_call_origin and shares its parent's session, so the session trace lists it beside the request that triggered it. Without a marker in the sidebar it reads as another call the caller made, which is the confusion this resolves. The tag renders only for a recognized origin, so ordinary traffic and any future origin this build does not know about stay unlabelled rather than being asserted as classifier calls. --- .../spend_management_endpoints.py | 37 +++++++++++------ .../test_spend_management_endpoints.py | 41 +++++++++++++++++++ .../LogDetailsDrawer/ClassifyTag.test.tsx | 20 +++++++++ .../LogDetailsDrawer/ClassifyTag.tsx | 19 +++++++++ .../LogDetailsDrawer/DrawerHeader.tsx | 5 +++ .../LogDetailsDrawer/LogDetailsDrawer.tsx | 2 + 6 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.tsx diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 42788227acc..f7e429ddb31 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -11,6 +11,7 @@ from typing import ( Literal, Mapping, NamedTuple, + Sequence, Union, ) @@ -2025,19 +2026,7 @@ async def ui_view_spend_logs( data = await prisma_client.db.query_raw(sql_query, *sql_params) - # query_raw returns the JSONB `metadata` column as a string (the Prisma - # serialiser bypasses the model-layer JSON hydration we get on the ORM - # path). The UI reads `metadata.status` / `metadata.error_information` - # as object fields, so failure rows looked like successes (#29674). - # Re-hydrate to dict here. - for row in data: - if isinstance(row, dict): - md = row.get("metadata") - if isinstance(md, str): - try: - row["metadata"] = json.loads(md) - except (ValueError, TypeError): - row["metadata"] = {} + _hydrate_spend_log_metadata(data) # Calculate total pages total_pages = (total_records + page_size - 1) // page_size @@ -2078,6 +2067,27 @@ def _spend_log_field_has_content(value: Union[str, list, dict] | None) -> bool: return True +def _hydrate_spend_log_metadata(rows: Sequence[Any]) -> None: + """Re-hydrate the JSONB ``metadata`` column returned by ``query_raw`` as a string. + + The Prisma serialiser bypasses the model-layer JSON hydration we get on the ORM + path, while the UI reads ``metadata.status`` / ``metadata.error_information`` / + ``metadata.internal_call_origin`` as object fields. Property access on a string + is silently undefined, so failure rows looked like successes (#29674). Every + ``query_raw`` reader of this column goes through here so a new one cannot + reintroduce that. + """ + for row in rows: + if not isinstance(row, dict): + continue + md = row.get("metadata") + if isinstance(md, str): + try: + row["metadata"] = json.loads(md) + except (ValueError, TypeError): + row["metadata"] = {} + + def _cold_storage_object_key_from_metadata( metadata: Union[str, dict] | None, ) -> str | None: @@ -3361,6 +3371,7 @@ async def ui_view_session_spend_logs( LIMIT $2 OFFSET $3 """ result = await prisma_client.db.query_raw(sql_query, session_id, page_size, skip, *scope_params) + _hydrate_spend_log_metadata(result) total_pages = (total_records + page_size - 1) // page_size 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..f6216d1646e 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 @@ -1582,6 +1582,47 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch): app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_ui_view_session_spend_logs_rehydrates_metadata_jsonb_text(client, monkeypatch): + """The session sidebar reads metadata fields as object properties, so this endpoint + must re-hydrate the JSONB column that query_raw hands back as a string, exactly as + /spend/logs/ui does (#29674). Property access on a string is silently undefined, + so a row's origin, status and error information all read as absent without this. + """ + raw_row = { + "request_id": "req-classifier-1", + "session_id": "session-123", + "startTime": "2024-01-01T00:00:00Z", + "metadata": json.dumps({"internal_call_origin": "autorouter_classifier", "status": "success"}), + } + + class MockDB: + async def count(self, *args, **kwargs): + return 1 + + async def query_raw(self, sql_query, session_id, page_size, skip, *scope_params): + return [dict(raw_row)] + + class MockPrismaClient: + def __init__(self): + self.db = MockDB() + self.db.litellm_spendlogs = self.db + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient()) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + try: + response = client.get("/spend/logs/session/ui", params={"session_id": "session-123"}) + assert response.status_code == 200 + row = response.json()["data"][0] + assert isinstance(row["metadata"], dict) + assert row["metadata"]["internal_call_origin"] == "autorouter_classifier" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_session_spend_logs_scopes_non_admin_to_own_logs(client, monkeypatch): own_log = { diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.test.tsx new file mode 100644 index 00000000000..dbe751cb9e8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ClassifyTag } from "./ClassifyTag"; + +describe("ClassifyTag", () => { + it("renders for an auto-router classifier row", () => { + render(); + expect(screen.getByText("Classify")).toBeInTheDocument(); + }); + + it("renders nothing for ordinary traffic, which is what makes the tag meaningful", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for an unrecognized origin rather than labelling it as a classifier call", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.tsx new file mode 100644 index 00000000000..a466e0e0af3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/cva.config"; + +export const AUTOROUTER_CLASSIFIER_ORIGIN = "autorouter_classifier"; + +export function ClassifyTag({ origin, className }: { origin?: string | null; className?: string }) { + if (origin !== AUTOROUTER_CLASSIFIER_ORIGIN) return null; + return ( + + Classify + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx index 0bcb9050d7d..ee619291f66 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -3,6 +3,7 @@ import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; import moment from "moment"; import { LogEntry } from "../columns"; import { AutoRouterTag } from "@/components/shared/table_cells"; +import { ClassifyTag } from "./ClassifyTag"; import { getProviderLogoAndName } from "../../provider_info_helpers"; import { DRAWER_HEADER_PADDING, @@ -59,6 +60,7 @@ export function DrawerHeader({ @@ -83,11 +85,13 @@ export function DrawerHeader({ function ModelProviderSection({ model, modelGroup, + internalCallOrigin, providerLogo, providerName, }: { model: string; modelGroup?: string; + internalCallOrigin?: string | null; providerLogo?: string; providerName?: string; }) { @@ -114,6 +118,7 @@ function ModelProviderSection({ )} + ); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index cdf8e0b1c60..5b27cfc1d0f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -6,6 +6,7 @@ import { LogEntry } from "../columns"; import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants"; import { getEventDisplayName } from "../utils"; +import { ClassifyTag } from "./ClassifyTag"; import { DrawerHeader } from "./DrawerHeader"; import { useKeyboardNavigation } from "./useKeyboardNavigation"; import { LogDetailContent, GuardrailJumpLink } from "./LogDetailContent"; @@ -78,6 +79,7 @@ function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) { {getEventDisplayName(row.call_type, row.model)} +
{durationValue}s