mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
2ef84db550
commit
fa56283806
6 changed files with 111 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue