feat(ui): show auto-router traffic sources and model spend

This commit is contained in:
moe-berri 2026-09-24 17:30:15 -07:00
parent 27d1974e2f
commit 70a87c03aa
17 changed files with 718 additions and 26 deletions

View file

@ -952,6 +952,7 @@ class LiteLLMRoutes(enum.Enum):
"/auto_router/availability",
# Per-session auto-router read - the endpoint scopes the row to the caller's own key hash
"/auto_router/session",
"/auto_router/usage",
"/cost/predict-cache",
# Agent registry - reads are role-scoped and writes are proxy-admin-gated
# inside agent_endpoints/endpoints.py

View file

@ -39,6 +39,7 @@ from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
refresh_proxy_server_request_body_snapshot,
)
from litellm.proxy.management_endpoints.auto_router_usage import router as usage_router
from litellm.proxy.management_endpoints.common_utils import (
_is_user_team_admin, # pyright: ignore[reportPrivateUsage] # shared owner of team-admin membership
)
@ -93,6 +94,7 @@ else:
pass
router: Final = APIRouter()
router.include_router(usage_router)
class _TeamTable(Protocol):

View file

@ -0,0 +1,109 @@
from collections.abc import Mapping, Sequence
from datetime import date, datetime, time, timedelta
from typing import Annotated, Final, Protocol
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, TypeAdapter
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
from litellm.proxy._types import UserAPIKeyAuth, user_api_key_has_admin_view
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import require_caller_user_id_for_non_admin
router: Final = APIRouter()
class AutoRouterUsage(BaseModel):
model: str
router_name: str | None
router_type: str | None
tier: str | None
requests: int
spend: float
class RoutingUsageDatabase(Protocol):
async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: ...
def routing_usage_database() -> RoutingUsageDatabase:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=503, detail="Database is not connected")
return prisma_client.db # pyright: ignore[reportReturnType] # wrapper forwards query_raw through __getattr__
ROUTING_USAGE_SQL: Final = f"""
WITH requests AS (
SELECT model, spend,
CASE WHEN jsonb_typeof(metadata->'routing_decision') = 'object'
AND metadata->'routing_decision' <> '{{}}'::jsonb
THEN COALESCE(NULLIF(metadata#>>'{{routing_decision,router_model_name}}', ''), NULLIF(model_group, ''))
END AS router_name,
NULLIF(metadata#>>'{{routing_decision,router_type}}', '') AS router_type,
NULLIF(metadata#>>'{{routing_decision,tier}}', '') AS tier
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamp AND "startTime" < $2::timestamp
AND ($3::text IS NULL OR "user" = $3)
AND ($4::text IS NULL OR api_key = $4)
AND NULLIF(metadata->>'{INTERNAL_CALL_ORIGIN_METADATA_KEY}', '') IS NULL
AND ($7::text IS NULL OR model = $7)
)
SELECT model, router_name, router_type, tier, COUNT(*)::int AS requests,
COALESCE(SUM(spend), 0)::float8 AS spend
FROM requests
WHERE ($5::text IS NULL OR router_name = $5)
AND ($6::text IS NULL OR router_type = $6)
GROUP BY model, router_name, router_type, tier
ORDER BY spend DESC, model, router_name, tier
"""
_USAGE_ROWS: Final = TypeAdapter(tuple[AutoRouterUsage, ...])
@router.get(
"/auto_router/usage",
tags=["auto router"], # mutable-ok: FastAPI's decorator requires a list
response_model=tuple[AutoRouterUsage, ...],
)
async def get_auto_router_usage(
start_date: date,
end_date: date,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
db: Annotated[RoutingUsageDatabase, Depends(routing_usage_database)],
destination_model: Annotated[str | None, Query(min_length=1)] = None,
router_name: Annotated[str | None, Query(min_length=1)] = None,
router_type: Annotated[str | None, Query(min_length=1)] = None,
user_id: Annotated[str | None, Query(min_length=1)] = None,
api_key: Annotated[str | None, Query(min_length=1)] = None,
) -> tuple[AutoRouterUsage, ...]:
"""Requests and destination-model spend from retained logs in inclusive UTC days.
Select one model or one router. Internal classifier and shadow-evaluation calls
are excluded. Non-admins can only see requests attributed to their own user.
"""
if (destination_model is None) == (router_name is None):
raise HTTPException(status_code=400, detail="Select exactly one model or router")
if end_date < start_date or end_date == date.max:
raise HTTPException(status_code=400, detail="Invalid date range")
if router_type is not None and router_name is None:
raise HTTPException(status_code=400, detail="router_type requires router_name")
scoped_user: Final = (
user_id
if user_api_key_has_admin_view(user_api_key_dict)
else require_caller_user_id_for_non_admin(user_api_key_dict)
)
if user_id is not None and user_id != scoped_user:
raise HTTPException(status_code=403, detail="Cannot view another user's routing usage")
rows: Final = await db.query_raw(
ROUTING_USAGE_SQL,
datetime.combine(start_date, time.min).isoformat(),
datetime.combine(end_date + timedelta(days=1), time.min).isoformat(),
scoped_user,
api_key,
router_name,
router_type,
destination_model,
)
return _USAGE_ROWS.validate_python(rows)

View file

@ -0,0 +1,90 @@
import json
from datetime import date
from typing import Final
from uuid import uuid4
import pytest
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.auto_router_usage import get_auto_router_usage
pytestmark = pytest.mark.asyncio(loop_scope="session")
async def test_actual_model_spend_sources_tiers_and_scope(db):
key: Final = f"routing-usage-{uuid4()}"
records: Final = (
("fast", 1.0, "a", "SIMPLE", None, "owner", "2026-01-01T00:00:00"),
("strong", 5.0, "a", "SIMPLE", None, "owner", "2026-01-01T12:00:00"),
("fast", 0.5, "a", None, None, "owner", "2026-01-01T12:00:00"),
("fast", 2.0, "b", "COMPLEX", None, "owner", "2026-01-01T12:00:00"),
("fast", 3.0, None, None, None, "owner", "2026-01-01T12:00:00"),
("fast", 90.0, "a", "SIMPLE", "shadow_eval_router", "owner", "2026-01-01T12:00:00"),
("fast", 80.0, None, None, "autorouter_classifier", "owner", "2026-01-01T12:00:00"),
("fast", 70.0, "a", "SIMPLE", None, "other-user", "2026-01-01T12:00:00"),
("fast", 60.0, "a", "SIMPLE", None, "owner", "2026-01-02T00:00:00"),
)
try:
for index, (model, spend, router, tier, origin, user, at) in enumerate(records):
metadata: Final = json.dumps(
{
"routing_decision": {"router_model_name": router, "router_type": "complexity", "tier": tier}
if router
else None,
"internal_call_origin": origin,
}
)
await db.execute_raw(
'INSERT INTO "LiteLLM_SpendLogs" (request_id, call_type, api_key, model, model_group, spend, '
'"user", "startTime", "endTime", metadata) '
"VALUES ($1, 'completion', $2, $3, 'renamed-alias', $4, $5, $6::timestamp, $6::timestamp, $7::jsonb)",
f"{key}-{index}",
key,
model,
spend,
user,
at,
metadata,
)
caller: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="owner")
model_usage: Final = await get_auto_router_usage(
date(2026, 1, 1),
date(2026, 1, 1),
caller,
db,
destination_model="fast",
api_key=key,
)
assert {(row.router_name, row.tier, row.requests, row.spend) for row in model_usage} == {
(None, None, 1, 3.0),
("b", "COMPLEX", 1, 2.0),
("a", "SIMPLE", 1, 1.0),
("a", None, 1, 0.5),
}
router_usage: Final = await get_auto_router_usage(
date(2026, 1, 1),
date(2026, 1, 1),
caller,
db,
router_name="a",
router_type="complexity",
api_key=key,
)
assert {(row.model, row.tier, row.requests, row.spend) for row in router_usage} == {
("fast", "SIMPLE", 1, 1.0),
("strong", "SIMPLE", 1, 5.0),
("fast", None, 1, 0.5),
}
assert (
await get_auto_router_usage(
date(2026, 1, 1),
date(2026, 1, 1),
caller,
db,
destination_model="fast",
api_key=f"{key}-other",
)
== ()
)
finally:
await db.execute_raw('DELETE FROM "LiteLLM_SpendLogs" WHERE api_key = $1', key)

View file

@ -0,0 +1,93 @@
from datetime import date
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.auto_router_usage import get_auto_router_usage
class Database:
def __init__(self) -> None:
self.query_raw = AsyncMock(return_value=[])
@pytest.mark.asyncio
@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
async def test_admin_filters_reach_query_as_parameters(role: LitellmUserRoles) -> None:
db = Database()
await get_auto_router_usage(
date(2026, 1, 1),
date(2026, 1, 2),
UserAPIKeyAuth(user_role=role),
db,
router_name="router'quoted",
router_type="complexity",
user_id="owner",
api_key="key-hash",
)
assert db.query_raw.call_args.args[1:] == (
"2026-01-01T00:00:00",
"2026-01-03T00:00:00",
"owner",
"key-hash",
"router'quoted",
"complexity",
None,
)
@pytest.mark.asyncio
async def test_non_admin_is_scoped_to_own_user_when_filter_omitted() -> None:
db = Database()
await get_auto_router_usage(
date(2026, 1, 1),
date(2026, 1, 1),
UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="own-user"),
db,
destination_model="model-a",
)
assert db.query_raw.call_args.args[1:] == (
"2026-01-01T00:00:00",
"2026-01-02T00:00:00",
"own-user",
None,
None,
None,
"model-a",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("caller", [None, "own-user"])
async def test_non_admin_cannot_read_other_users_or_unbound_service_account(caller: str | None) -> None:
db = Database()
with pytest.raises(HTTPException) as error:
await get_auto_router_usage(
date(2026, 1, 1),
date(2026, 1, 1),
UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id=caller),
db,
destination_model="model-a",
user_id="another-user",
)
assert error.value.status_code == 403
db.query_raw.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("model,router_name", [(None, None), ("model-a", "router-a")])
async def test_query_requires_one_specific_model_or_router(model: str | None, router_name: str | None) -> None:
db = Database()
with pytest.raises(HTTPException) as error:
await get_auto_router_usage(
date(2026, 1, 1),
date(2026, 1, 1),
UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
db,
destination_model=model,
router_name=router_name,
)
assert error.value.status_code == 400
db.query_raw.assert_not_called()

View file

@ -1,3 +1,6 @@
vi.mock("@/components/UsagePage/useRoutingUsage", () => ({
useRoutingUsage: () => ({ data: undefined, isError: false }),
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
@ -6,7 +9,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { ApiError } from "@/lib/http/client";
vi.mock("./useAutoRouterBenchmarks", () => ({ useAutoRouterBenchmarks: vi.fn() }));
vi.mock("./useAutoRouterBenchmarks", async (importOriginal) => ({
...(await importOriginal<typeof import("./useAutoRouterBenchmarks")>()),
useAutoRouterBenchmarks: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useAutoRouters: vi.fn() }));
vi.mock("./ShadowEvalSection", () => ({ default: () => <div data-testid="shadow-eval-section" /> }));
vi.mock("@/components/shared/advanced_date_picker", () => ({

View file

@ -1,6 +1,8 @@
"use client";
import React, { useState } from "react";
import { useRoutingUsage } from "@/components/UsagePage/useRoutingUsage";
import type { RoutingUsage } from "@/components/UsagePage/routingUsage";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { useAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
@ -25,6 +27,7 @@ import {
groupLabel,
pctLabel,
viewFor,
viewGroup,
type AutoRouterBenchmarksResponse,
type AutoRouterCacheStats,
type BenchmarkView,
@ -33,7 +36,7 @@ import {
import { classificationRatePer1kTurns, formatRangeLabel, usd } from "./costOptimizationUtils";
import ShadowEvalSection from "./ShadowEvalSection";
import TierTurnsChart from "./TierTurnsChart";
import { useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks";
import { benchmarksWindow, useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks";
import { DailyActivityRange } from "./useDailyActivityRange";
const Message: React.FC<{ children: React.ReactNode }> = ({ children }) => (
@ -273,6 +276,8 @@ const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {
};
interface BenchmarksBodyProps {
usage?: readonly RoutingUsage[];
usageUnavailable?: boolean;
isPending: boolean;
error: unknown;
data: AutoRouterBenchmarksResponse | undefined;
@ -280,7 +285,15 @@ interface BenchmarksBodyProps {
autoRouters: readonly AutoRouterDeployment[];
}
const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data, selectedKey, autoRouters }) => {
const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({
isPending,
error,
data,
selectedKey,
autoRouters,
usage,
usageUnavailable,
}) => {
if (isPending) return <Message>Loading auto-router usage...</Message>;
if (error instanceof ApiError && error.status === 403) {
return <Message>Auto-router usage is visible to proxy admin roles only</Message>;
@ -293,7 +306,7 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data,
<>
<HeroCard view={view} />
<TierTurnsChart view={view} autoRouters={autoRouters} />
<TierTurnsChart view={view} autoRouters={autoRouters} usage={usage} usageUnavailable={usageUnavailable} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric
@ -346,6 +359,18 @@ export const AutoRouterUsageView: React.FC<AutoRouterBenchmarksTabProps> = ({
const [selectedKey, setSelectedKey] = useState<string>(ALL_ROUTERS);
const { data: autoRouters } = useAutoRouters();
const selected = data ? viewGroup(viewFor(data, selectedKey)) : null;
const window = benchmarksWindow(dateValue, new Date());
const usageQuery = {
start_date: window.start_date ?? "",
end_date: window.end_date ?? "",
router_name: selected?.router_name,
router_type: selected?.router_type,
api_key: apiKey,
user_id: userId,
};
const hasUsageWindow = Boolean(window.start_date && window.end_date);
const usage = useRoutingUsage(usageQuery, Boolean(accessToken && selected && hasUsageWindow));
const groups = data?.groups ?? [];
const selectedLabel = data ? viewFor(data, selectedKey).label : "All auto-routers";
const rangeLabel = formatRangeLabel(dateValue.from, dateValue.to);
@ -388,6 +413,8 @@ export const AutoRouterUsageView: React.FC<AutoRouterBenchmarksTabProps> = ({
error={error}
data={data}
selectedKey={selectedKey}
usage={usage.data}
usageUnavailable={usage.isError}
autoRouters={autoRouters ?? []}
/>
</div>

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
@ -177,3 +177,31 @@ describe("TierTurnsChart", () => {
expect(container).toBeEmptyDOMElement();
});
});
it("switches the existing donut to actual spend and shows each destination model", () => {
render(
<TierTurnsChart
view={groupView()}
autoRouters={[]}
usage={[
{ model: "fast", router_name: "claude-auto", router_type: "complexity", tier: "SIMPLE", requests: 3, spend: 1 },
{
model: "strong",
router_name: "claude-auto",
router_type: "complexity",
tier: "SIMPLE",
requests: 1,
spend: 5,
},
{ model: "fallback", router_name: "claude-auto", router_type: "complexity", tier: null, requests: 1, spend: 4 },
]}
/>,
);
expect(screen.getByText("Simple 80% · $6.00")).toBeInTheDocument();
expect(screen.getByText("strong · 1 request (20%) · $5.00")).toBeInTheDocument();
expect(screen.getByText("Default / no tier 20% · $4.00")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Spend" }));
expect(screen.getByTestId("donut")).toHaveTextContent("$10.00");
expect(screen.getByText("Simple 60% · $6.00")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Spend" })).toHaveAttribute("aria-pressed", "true");
});

View file

@ -1,6 +1,7 @@
"use client";
import React from "react";
import React, { useState } from "react";
import { routingTiers, routingSpend, trafficShare, type RoutingUsage } from "@/components/UsagePage/routingUsage";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { hydrateTierLabels } from "@/components/add_model/build_complexity_router_config";
@ -79,31 +80,67 @@ const tierModelsFor = (
};
interface TierTurnsChartProps {
usage?: readonly RoutingUsage[];
usageUnavailable?: boolean;
view: BenchmarkView;
autoRouters: readonly AutoRouterDeployment[];
}
const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) => {
const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters, usage, usageUnavailable }) => {
const [mode, setMode] = useState<"turns" | "spend">("turns");
const group = viewGroup(view);
const entries = Object.entries(group?.tier_turns ?? {}).filter(([, turns]) => turns > 0);
if (!group || entries.length === 0) return null;
if (!group || (entries.length === 0 && !usage?.length)) return null;
const tierLabels = tierLabelsFor(group.router_name, group.router_type, autoRouters);
const total = entries.reduce((sum, [, turns]) => sum + turns, 0);
const slices = entries.map(([tier, turns]) => ({
tier: tierDisplayLabel(tier, tierLabels),
turns,
models: tierModelsFor(tier, group.router_name, group.router_type, autoRouters),
}));
const measured = usage !== undefined && usage.length > 0;
const spendUnavailable = !measured && (usageUnavailable || usage !== undefined);
const slices = measured
? routingTiers(usage).map((row) => ({
tier: tierDisplayLabel(row.tier ?? "Default / no tier", tierLabels),
turns: row.requests,
spend: row.spend,
models: row.models.map((model) => model.model),
modelUsage: row.models,
}))
: entries.map(([tier, turns]) => ({
tier: tierDisplayLabel(tier, tierLabels),
turns,
spend: 0,
models: tierModelsFor(tier, group.router_name, group.router_type, autoRouters),
modelUsage: [],
}));
const category = measured ? mode : "turns";
const total = slices.reduce((sum, slice) => sum + slice[category], 0);
const requests = slices.reduce((sum, slice) => sum + slice.turns, 0);
const colors = slices.map((_, idx) => DEFAULT_COLOR_CYCLE[idx % DEFAULT_COLOR_CYCLE.length]);
return (
<Card>
<CardHeader>
<CardTitle>Routing by tier</CardTitle>
<div className="flex items-center justify-between gap-3">
<CardTitle>Routing by tier</CardTitle>
{measured && (
<div className="flex rounded-lg bg-muted p-1" role="group" aria-label="Routing distribution">
{(["turns", "spend"] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={category === value}
onClick={() => setMode(value)}
className={`rounded-md px-3 py-1 text-sm transition-colors ${category === value ? "bg-card text-foreground shadow-xs" : "text-muted-foreground hover:text-foreground"}`}
>
{value === "turns" ? "Traffic" : "Spend"}
</button>
))}
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
Turns each tier served. Turns the classifier sent to the default model belong to no tier and are not counted
here, so this can total less than the router&apos;s turns.
{measured
? "Requests and model spend from retained logs in this period. Excludes classifier and shadow-evaluation calls."
: "Turns each tier served. Turns the classifier sent to the default model belong to no tier and are not counted here, so this can total less than the router's turns."}
{spendUnavailable && " Model spend is unavailable for this range."}
</p>
</CardHeader>
<CardContent>
@ -112,11 +149,15 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
className="h-80"
data={slices}
index="tier"
category="turns"
category={category}
colors={colors}
valueFormatter={(value) => value.toLocaleString()}
valueFormatter={category === "spend" ? routingSpend : (value) => value.toLocaleString()}
showLabel
label={`${total.toLocaleString()} total turns`}
label={
category === "spend"
? routingSpend(total)
: `${total.toLocaleString()} total ${measured ? "requests" : "turns"}`
}
/>
<ul className="flex flex-col gap-6">
{slices.map((slice, idx) => (
@ -127,11 +168,19 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
/>
<div className="min-w-0">
<p className="text-sm text-muted-foreground">
{slice.tier} {Math.round((100 * slice.turns) / total).toLocaleString()}%
{slice.tier} {trafficShare(slice[category], total)}
{measured && ` · ${routingSpend(slice.spend)}`}
</p>
{slice.models.length > 0 && (
<p className="text-xs break-words text-muted-foreground">{slice.models.join(", ")}</p>
)}
{measured
? slice.modelUsage.map((model) => (
<p key={model.model} className="text-xs break-words text-muted-foreground">
{model.model} · {model.requests.toLocaleString()} {model.requests === 1 ? "request" : "requests"} (
{trafficShare(model.requests, requests)}) · {routingSpend(model.spend)}
</p>
))
: slice.models.length > 0 && (
<p className="text-xs break-words text-muted-foreground">{slice.models.join(", ")}</p>
)}
</div>
</li>
))}

View file

@ -34,6 +34,7 @@ import { getApiKeyTruncation, getExportBlockedReason } from "@/components/Entity
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
import { Team } from "@/components/key_team_helpers/key_list";
import {
formatDate,
gatewayDailyActivityCall,
Organization,
tagListCall,
@ -873,7 +874,18 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<div className="flex justify-end mt-2 mb-4">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics modelMetrics={modelMetrics} />
<ActivityMetrics
modelMetrics={modelMetrics}
routingScope={
modelViewType === "individual" && startTime && endTime
? {
start_date: formatDate(startTime),
end_date: formatDate(endTime),
user_id: effectiveUserId,
}
: undefined
}
/>
</TabsContent>
<TabsContent value="keys" keepMounted>
<KeyActivityPanel

View file

@ -0,0 +1,51 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import ModelRoutingShare from "./ModelRoutingShare";
const scope = { start_date: "2026-01-01", end_date: "2026-01-02", user_id: "owner" };
const rows = [
{ model: "fast", router_name: null, router_type: null, tier: null, requests: 6, spend: 1.2 },
{ model: "fast", router_name: "router-a", router_type: "complexity", tier: "SIMPLE", requests: 4, spend: 0.8 },
];
afterEach(() => vi.restoreAllMocks());
describe("model traffic sources", () => {
it("loads the selected model and user, then opens direct and router request/spend figures", async () => {
const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(rows), {
headers: { "Content-Type": "application/json" },
}),
);
const client = new QueryClient();
render(
<QueryClientProvider client={client}>
<ModelRoutingShare model="fast" scope={scope} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "40% via auto-router" }));
expect(await screen.findByText("Direct")).toBeVisible();
expect(screen.getByText("router-a")).toBeVisible();
expect(screen.getByText("$1.20")).toBeVisible();
expect(screen.getByText("$0.80")).toBeVisible();
expect(screen.getByText("60%")).toBeVisible();
expect(screen.getByText(/Based on 10 retained requests/)).toBeVisible();
const request = fetch.mock.calls[0][0] as Request;
const query = new URL(request.url).searchParams;
expect(Object.fromEntries(query)).toEqual({ ...scope, destination_model: "fast" });
});
it("does not present unavailable or missing logs as zero-percent routed traffic", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("[]", { headers: { "Content-Type": "application/json" } }),
);
render(
<QueryClientProvider client={new QueryClient()}>
<ModelRoutingShare model="fast" scope={scope} />
</QueryClientProvider>,
);
expect(await screen.findByText("No retained requests for traffic sources")).toBeVisible();
expect(screen.queryByRole("button", { name: /via auto-router/ })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,48 @@
import { useRoutingUsage } from "../useRoutingUsage";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { routingSources, routingSpend, trafficShare, type RoutingUsageScope } from "../routingUsage";
export default function ModelRoutingShare({ model, scope }: { model: string; scope: RoutingUsageScope }) {
const { data, isPending, isError } = useRoutingUsage({ ...scope, destination_model: model });
if (isPending) return <p className="text-xs text-muted-foreground">Loading traffic sources...</p>;
if (isError) return <p className="text-xs text-muted-foreground">Traffic sources unavailable</p>;
const sources = routingSources(data ?? []);
const total = sources.reduce((sum, source) => sum + source.requests, 0);
if (total === 0) return <p className="text-xs text-muted-foreground">No retained requests for traffic sources</p>;
const routed = sources.filter((source) => source.name !== null).reduce((sum, source) => sum + source.requests, 0);
return (
<Popover>
<PopoverTrigger className="text-xs text-muted-foreground underline decoration-dotted underline-offset-4">
{trafficShare(routed, total)} via auto-router
</PopoverTrigger>
<PopoverContent align="start" className="w-96 max-w-[calc(100vw-2rem)]">
<PopoverTitle>Traffic sources</PopoverTitle>
<Table>
<TableHeader>
<TableRow>
<TableHead>Source</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Share</TableHead>
<TableHead className="text-right">Spend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sources.map((source) => (
<TableRow key={source.name ?? "__direct__"}>
<TableCell className="max-w-40 break-words whitespace-normal">{source.name ?? "Direct"}</TableCell>
<TableCell className="text-right tabular-nums">{source.requests.toLocaleString()}</TableCell>
<TableCell className="text-right tabular-nums">{trafficShare(source.requests, total)}</TableCell>
<TableCell className="text-right tabular-nums">{routingSpend(source.spend)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-xs text-muted-foreground">
Based on {total.toLocaleString()} retained requests in this period. Model spend excludes internal classifier
and shadow-evaluation calls. Usage totals can include requests whose logs have expired.
</p>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { routingSources, routingTiers, trafficShare, type RoutingUsage } from "./routingUsage";
const rows: RoutingUsage[] = [
{ model: "fast", router_name: "router-a", router_type: "complexity", tier: "SIMPLE", requests: 2, spend: 0.4 },
{ model: "strong", router_name: "router-a", router_type: "complexity", tier: "SIMPLE", requests: 1, spend: 1.2 },
{ model: "fast", router_name: "router-a", router_type: "complexity", tier: null, requests: 1, spend: 0.2 },
{ model: "fast", router_name: "router-b", router_type: "quality", tier: "2", requests: 1, spend: 0.2 },
{ model: "fast", router_name: null, router_type: null, tier: null, requests: 6, spend: 1.2 },
];
describe("routing usage", () => {
it("combines a router's tiers and separates direct traffic and other routers for one model", () => {
const sources = routingSources(rows.filter((row) => row.model === "fast"));
expect(sources).toEqual([
{ name: null, requests: 6, spend: 1.2 },
{ name: "router-a", requests: 3, spend: expect.closeTo(0.6) },
{ name: "router-b", requests: 1, spend: 0.2 },
]);
expect(trafficShare(4, 10)).toBe("40%");
});
it("keeps both models within a tier and includes fallback calls without inventing their tier", () => {
expect(routingTiers(rows.filter((row) => row.router_name === "router-a"))).toEqual([
{
tier: "SIMPLE",
requests: 3,
spend: expect.closeTo(1.6),
models: [
{ model: "fast", requests: 2, spend: 0.4 },
{ model: "strong", requests: 1, spend: 1.2 },
],
},
{ tier: null, requests: 1, spend: 0.2, models: [{ model: "fast", requests: 1, spend: 0.2 }] },
]);
});
it("handles zero-cost traffic and an empty range", () => {
expect(trafficShare(0, 0)).toBe("0%");
expect(routingTiers([])).toEqual([]);
expect(routingSources([{ ...rows[0], spend: 0 }])).toEqual([
{ name: null, requests: 0, spend: 0 },
{ name: "router-a", requests: 2, spend: 0 },
]);
});
});

View file

@ -0,0 +1,39 @@
import type { components, paths } from "@/lib/http/schema";
export type RoutingUsage = components["schemas"]["AutoRouterUsage"];
export type RoutingUsageQuery = paths["/auto_router/usage"]["get"]["parameters"]["query"];
export type RoutingUsageScope = Pick<RoutingUsageQuery, "start_date" | "end_date" | "user_id" | "api_key">;
export const routingSources = (rows: readonly RoutingUsage[]) => {
const names = [null, ...new Set(rows.flatMap((row) => (row.router_name === null ? [] : [row.router_name])))];
return names.map((name) => ({
name,
requests: rows.filter((row) => row.router_name === name).reduce((sum, row) => sum + row.requests, 0),
spend: rows.filter((row) => row.router_name === name).reduce((sum, row) => sum + row.spend, 0),
}));
};
export const routingTiers = (rows: readonly RoutingUsage[]) =>
[...new Set(rows.map((row) => row.tier))].map((tier) => {
const tierRows = rows.filter((row) => row.tier === tier);
return {
tier,
requests: tierRows.reduce((sum, row) => sum + row.requests, 0),
spend: tierRows.reduce((sum, row) => sum + row.spend, 0),
models: [...new Set(tierRows.map((row) => row.model))].map((model) => ({
model,
requests: tierRows.filter((row) => row.model === model).reduce((sum, row) => sum + row.requests, 0),
spend: tierRows.filter((row) => row.model === model).reduce((sum, row) => sum + row.spend, 0),
})),
};
});
export const trafficShare = (requests: number, total: number) =>
`${(total > 0 ? (100 * requests) / total : 0).toLocaleString(undefined, { maximumFractionDigits: 1 })}%`;
export const routingSpend = (spend: number) =>
spend.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: spend > 0 && spend < 0.01 ? 6 : 2,
});

View file

@ -0,0 +1,5 @@
import { $api } from "@/lib/http/api";
import type { RoutingUsageQuery } from "./routingUsage";
export const useRoutingUsage = (query: RoutingUsageQuery, enabled = true) =>
$api.useQuery("get", "/auto_router/usage", { params: { query } }, { enabled, retry: false, staleTime: 60_000 });

View file

@ -15,12 +15,15 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { ChevronDown } from "lucide-react";
import React, { useState } from "react";
import { Team } from "./key_team_helpers/key_list";
import ModelRoutingShare from "./UsagePage/components/ModelRoutingShare";
import type { RoutingUsageScope } from "./UsagePage/routingUsage";
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
import { keyActivityLabel } from "./UsagePage/keyActivityLabel";
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types";
import { averageResponseTimeMs, formatResponseTime, valueFormatter } from "./UsagePage/utils/value_formatters";
interface ActivityMetricsProps {
routingScope?: RoutingUsageScope;
modelMetrics: Record<string, ModelActivityData>;
hidePromptCachingMetrics?: boolean;
}
@ -41,8 +44,10 @@ const ModelSection = ({
modelName,
metrics,
hidePromptCachingMetrics = false,
routingScope,
}: {
modelName: string;
routingScope?: RoutingUsageScope;
metrics: ModelActivityData;
hidePromptCachingMetrics?: boolean;
}) => {
@ -54,6 +59,7 @@ const ModelSection = ({
<CardContent>
<p className="text-sm text-muted-foreground">Total Requests</p>
<h3 className="text-lg font-medium text-foreground">{metrics.total_requests.toLocaleString()}</h3>
{routingScope && <ModelRoutingShare model={modelName} scope={routingScope} />}
</CardContent>
</Card>
<Card>
@ -300,7 +306,11 @@ const ModelCollapsible = ({
);
};
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics, hidePromptCachingMetrics = false }) => {
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
modelMetrics,
hidePromptCachingMetrics = false,
routingScope,
}) => {
const modelNames = Object.keys(modelMetrics).sort((a, b) => {
if (a === "") return 1;
if (b === "") return -1;
@ -479,6 +489,7 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
>
<ModelSection
modelName={modelName || "Unknown Model"}
routingScope={routingScope}
metrics={modelMetrics[modelName]}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>

View file

@ -1493,6 +1493,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/auto_router/usage": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Auto Router Usage
* @description Requests and destination-model spend from retained logs in inclusive UTC days.
*
* Select one model or one router. Internal classifier and shadow-evaluation calls
* are excluded. Non-admins can only see requests attributed to their own user.
*/
get: operations["get_auto_router_usage_auto_router_usage_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auto_router/validate_complexity_router_config": {
parameters: {
query?: never;
@ -25307,6 +25330,21 @@ export interface components {
*/
turns: number;
};
/** AutoRouterUsage */
AutoRouterUsage: {
/** Model */
model: string;
/** Requests */
requests: number;
/** Router Name */
router_name: string | null;
/** Router Type */
router_type: string | null;
/** Spend */
spend: number;
/** Tier */
tier: string | null;
};
/** AwsSessionTag */
AwsSessionTag: {
/** Key */
@ -49006,6 +49044,43 @@ export interface operations {
};
};
};
get_auto_router_usage_auto_router_usage_get: {
parameters: {
query: {
start_date: string;
end_date: string;
destination_model?: string | null;
router_name?: string | null;
router_type?: string | null;
user_id?: string | null;
api_key?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AutoRouterUsage"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
validate_complexity_router_config_auto_router_validate_complexity_router_config_post: {
parameters: {
query?: never;