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.
This commit is contained in:
tin-berri 2026-07-31 11:51:36 -07:00 committed by GitHub
parent 2ef84db550
commit fa56283806
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 111 additions and 13 deletions

View file

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

View file

@ -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 = {

View file

@ -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(<ClassifyTag origin="autorouter_classifier" />);
expect(screen.getByText("Classify")).toBeInTheDocument();
});
it("renders nothing for ordinary traffic, which is what makes the tag meaningful", () => {
const { container } = render(<ClassifyTag origin={undefined} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing for an unrecognized origin rather than labelling it as a classifier call", () => {
const { container } = render(<ClassifyTag origin="something_else" />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -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 (
<Badge
variant="secondary"
title="Tier classification call made by the auto-router, not a request the caller sent"
className={cn("px-2 py-0 text-[10px] font-normal", className)}
>
Classify
</Badge>
);
}

View file

@ -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({
<ModelProviderSection
model={log.model}
modelGroup={log.model_group}
internalCallOrigin={log.metadata?.internal_call_origin}
providerLogo={providerInfo?.logo}
providerName={providerInfo?.displayName}
/>
@ -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({
</Text>
)}
<AutoRouterTag modelGroup={modelGroup} />
<ClassifyTag origin={internalCallOrigin} />
</Space>
</Space>
);

View file

@ -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) {
<span className="text-xs font-medium text-slate-900 truncate">
{getEventDisplayName(row.call_type, row.model)}
</span>
<ClassifyTag origin={row.metadata?.internal_call_origin} className="ml-auto" />
</div>
<div className="text-[10px] text-slate-500 mt-0 flex items-center gap-1.5 font-mono">
<span>{durationValue}s</span>