mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
feat(ui): add the auto-router usage tab to cost optimization (#35995)
This commit is contained in:
parent
f01a4fc023
commit
ece652f6a7
7 changed files with 865 additions and 4 deletions
|
|
@ -0,0 +1,243 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
vi.mock("./useAutoRouterBenchmarks", () => ({ useAutoRouterBenchmarks: vi.fn() }));
|
||||
|
||||
import AutoRouterBenchmarksTab from "./AutoRouterBenchmarksTab";
|
||||
import type {
|
||||
AutoRouterBenchmarkGroup,
|
||||
AutoRouterBenchmarksResponse,
|
||||
AutoRouterCacheStats,
|
||||
} from "./autoRouterBenchmarks";
|
||||
import { useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks";
|
||||
|
||||
type HookResult = ReturnType<typeof useAutoRouterBenchmarks>;
|
||||
|
||||
const mockHook = (result: { data?: AutoRouterBenchmarksResponse; isPending?: boolean; error?: Error }) => {
|
||||
vi.mocked(useAutoRouterBenchmarks).mockReturnValue({
|
||||
data: result.data,
|
||||
isPending: result.isPending ?? false,
|
||||
error: result.error ?? null,
|
||||
} as unknown as HookResult);
|
||||
};
|
||||
|
||||
const cache = (overrides: Partial<AutoRouterCacheStats> = {}): AutoRouterCacheStats => ({
|
||||
coverage_pct: 99.6,
|
||||
hit_rate_pct: 93.3,
|
||||
same_model: { turns: 400, hits: 391, hit_rate_pct: 97.7 },
|
||||
first_visit: { turns: 37, hits: 9, hit_rate_pct: 24.3 },
|
||||
return_to_tier: { turns: 381, hits: 311, hit_rate_pct: 81.6 },
|
||||
unordered_turns: 0,
|
||||
return_misses_expired: 19,
|
||||
return_misses_within_ttl: 51,
|
||||
return_misses_unknown: 0,
|
||||
ttl_5m_turns: 0,
|
||||
ttl_1h_turns: 818,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type Totals = AutoRouterBenchmarksResponse["totals"];
|
||||
|
||||
const totals = (overrides: Partial<Totals> = {}): Totals => ({
|
||||
sessions: 94,
|
||||
turns: 3073,
|
||||
avg_turns_per_session: 32.7,
|
||||
avg_session_seconds: 7560,
|
||||
avg_tokens_per_session: 5_300_000,
|
||||
spend: 359.86,
|
||||
saved_spend: 2174.59,
|
||||
baseline_spend: 2534.45,
|
||||
saved_pct: 85.8,
|
||||
saved_per_session: 23.13,
|
||||
cache: cache(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const group = (overrides: Partial<AutoRouterBenchmarkGroup> = {}): AutoRouterBenchmarkGroup => ({
|
||||
router_name: "claude-auto",
|
||||
router_type: "complexity",
|
||||
...totals(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const response = (groups: AutoRouterBenchmarkGroup[], shared: Totals = totals()): AutoRouterBenchmarksResponse => ({
|
||||
start_date: "2026-07-06",
|
||||
end_date: "2026-08-05",
|
||||
routers_in_scope: groups.length,
|
||||
totals: shared,
|
||||
groups,
|
||||
});
|
||||
|
||||
const renderTab = () => render(<AutoRouterBenchmarksTab accessToken="sk-test" />);
|
||||
|
||||
describe("AutoRouterBenchmarksTab", () => {
|
||||
it("leads with total estimated savings, before the three session-shape metrics", () => {
|
||||
mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
|
||||
renderTab();
|
||||
|
||||
const labels = screen
|
||||
.getAllByText(/Total estimated savings|Avg turns per session|Avg session length|Avg tokens per session/)
|
||||
.map((node) => node.textContent);
|
||||
expect(labels).toEqual([
|
||||
"Total estimated savings",
|
||||
"Avg turns per session",
|
||||
"Avg session length",
|
||||
"Avg tokens per session",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the headline numbers the tiles exist for", () => {
|
||||
mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("$2,174.59")).toBeInTheDocument();
|
||||
expect(screen.getByText("-86%")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actual auto-router spend")).toBeInTheDocument();
|
||||
expect(screen.getByText("$359.86")).toBeInTheDocument();
|
||||
expect(screen.getByText("Estimated spend at highest-cost model")).toBeInTheDocument();
|
||||
expect(screen.getByText("$2,534.45")).toBeInTheDocument();
|
||||
expect(screen.getByText("32.7")).toBeInTheDocument();
|
||||
expect(screen.getByText("2.1h")).toBeInTheDocument();
|
||||
expect(screen.getByText("5.3M")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pairs the savings with the session count it was earned over", () => {
|
||||
mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("Total sessions")).toBeInTheDocument();
|
||||
expect(screen.getByText("94")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total turns")).toBeInTheDocument();
|
||||
expect(screen.getByText("3,073")).toBeInTheDocument();
|
||||
expect(screen.getByText("Avg saved per session")).toBeInTheDocument();
|
||||
expect(screen.getByText("$23.13")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a cost increase as a positive delta rather than a saving", () => {
|
||||
const overBaseline = { spend: 120, baseline_spend: 100, saved_spend: -20, saved_pct: -20 };
|
||||
const dearer = totals(overBaseline);
|
||||
mockHook({ data: response([group(dearer)], dearer) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("+20%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all three cache buckets with their turn counts and hit rates", () => {
|
||||
mockHook({ data: response([group()]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("Same model")).toBeInTheDocument();
|
||||
expect(screen.getByText("previous turn → same tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("First visit")).toBeInTheDocument();
|
||||
expect(screen.getByText("previous turn → a tier not used yet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Return to tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("previous turn → a tier used earlier")).toBeInTheDocument();
|
||||
expect(screen.getByText("400")).toBeInTheDocument();
|
||||
expect(screen.getByText("37")).toBeInTheDocument();
|
||||
expect(screen.getByText("381")).toBeInTheDocument();
|
||||
expect(screen.getByText("49%")).toBeInTheDocument();
|
||||
expect(screen.getByText("5%")).toBeInTheDocument();
|
||||
expect(screen.getByText("47%")).toBeInTheDocument();
|
||||
expect(screen.getByText("97.7%")).toBeInTheDocument();
|
||||
expect(screen.getByText("24.3%")).toBeInTheDocument();
|
||||
expect(screen.getByText("81.6%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("summarizes the cache column from the bucketed turns, not the session turns", () => {
|
||||
mockHook({ data: response([group()]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("93.3%")).toBeInTheDocument();
|
||||
expect(screen.getByText("818")).toBeInTheDocument();
|
||||
expect(screen.getByText(/turns measured/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("recomputes the expired-miss share from the miss counts", () => {
|
||||
mockHook({ data: response([group()]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("Expired-miss")).toBeInTheDocument();
|
||||
expect(screen.getByText("27.1%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the expired-miss row when every return turn hit", () => {
|
||||
const allHits = totals({
|
||||
cache: cache({ return_to_tier: { turns: 381, hits: 381, hit_rate_pct: 100 }, return_misses_expired: 0 }),
|
||||
});
|
||||
mockHook({ data: response([group(allHits)], allHits) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.queryByText("Expired-miss")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("mentions out-of-order turns only when there are any", () => {
|
||||
const unordered = totals({ cache: cache({ unordered_turns: 12 }) });
|
||||
mockHook({ data: response([group(unordered)], unordered) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText(/12 turns arrived out of order across pods and are not bucketed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the default selection instead of leaking the __all__ sentinel", () => {
|
||||
mockHook({ data: response([group()]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("All auto-routers")).toBeInTheDocument();
|
||||
expect(screen.queryByText("__all__")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so while the benchmarks are loading", () => {
|
||||
mockHook({ isPending: true });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("Loading auto-router usage...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the admin requirement when the proxy answers 403", () => {
|
||||
mockHook({ error: new ApiError("forbidden", 403, {}) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("Auto-router usage is visible to proxy admin roles only")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("degrades to a message when the endpoint is unavailable", () => {
|
||||
mockHook({ error: new ApiError("boom", 500, {}) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("Auto-router usage is unavailable right now")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when there are no auto-router sessions at all", () => {
|
||||
mockHook({ data: response([]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("No auto-router sessions in this window yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requests the default thirty day window and widens or narrows it from the picker", () => {
|
||||
mockHook({ data: response([group()]) });
|
||||
renderTab();
|
||||
|
||||
expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", "30d");
|
||||
expect(screen.getByText("Last 30 days")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "7d" }));
|
||||
expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", "7d");
|
||||
expect(screen.getByText("Last 7 days")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "24h" }));
|
||||
expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", "24h");
|
||||
expect(screen.getByText("Last 24 hours")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the window picker reachable while a window has no sessions", () => {
|
||||
mockHook({ data: response([]) });
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByRole("tab", { name: "30d" })).toBeInTheDocument();
|
||||
expect(screen.getByText("All auto-routers")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
import {
|
||||
ALL_ROUTERS,
|
||||
WINDOW_LABELS,
|
||||
bucketRows,
|
||||
bucketTurnsTotal,
|
||||
durationLabel,
|
||||
groupKey,
|
||||
expiredMissShare,
|
||||
groupLabel,
|
||||
pctLabel,
|
||||
viewFor,
|
||||
type AutoRouterBenchmarksResponse,
|
||||
type AutoRouterCacheStats,
|
||||
type BenchmarkView,
|
||||
type BenchmarkWindow,
|
||||
type BucketRow,
|
||||
} from "./autoRouterBenchmarks";
|
||||
import { usd } from "./costOptimizationUtils";
|
||||
import { useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks";
|
||||
|
||||
const Message: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{children}</p>
|
||||
);
|
||||
|
||||
const Metric: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-normal text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold text-foreground">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
|
||||
const stats = view.stats;
|
||||
const cheaper = stats.saved_spend >= 0;
|
||||
return (
|
||||
<Card className="overflow-hidden py-0">
|
||||
<div className="grid md:grid-cols-[4fr_3fr_5fr]">
|
||||
<div className="flex flex-col justify-center gap-3 p-6">
|
||||
<p className="text-sm text-muted-foreground">Total estimated savings</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-5xl font-semibold tracking-tight text-foreground">{usd(stats.saved_spend)}</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cheaper ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-destructive"}
|
||||
>
|
||||
{cheaper ? "-" : "+"}
|
||||
{Math.abs(stats.saved_pct).toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center px-6 pb-6 md:py-6">
|
||||
<dl className="divide-y text-sm">
|
||||
<div className="flex items-baseline justify-between gap-6 py-3">
|
||||
<dt className="text-muted-foreground">Actual auto-router spend</dt>
|
||||
<dd className="font-medium tabular-nums text-foreground">{usd(stats.spend)}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-6 py-3">
|
||||
<dt className="text-muted-foreground">Estimated spend at highest-cost model</dt>
|
||||
<dd className="font-medium tabular-nums text-foreground">{usd(stats.baseline_spend)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border-t md:border-t-0 md:border-l">
|
||||
<div className="grid flex-1 grid-cols-2 divide-x">
|
||||
<div className="flex flex-col justify-center gap-1 px-6 py-4">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Total sessions</p>
|
||||
<p className="text-3xl font-semibold text-foreground">{stats.sessions.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-1 px-6 py-4">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Total turns</p>
|
||||
<p className="text-3xl font-semibold text-foreground">{stats.turns.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="flex flex-col divide-y border-t text-sm">
|
||||
<div className="flex items-center justify-between gap-2 px-6 py-3">
|
||||
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground">Avg saved per session</dt>
|
||||
<dd className="text-lg font-semibold tabular-nums text-foreground">{usd(stats.saved_per_session)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const StackedTurnBar: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => {
|
||||
const segments = buckets.filter((b) => b.turns > 0);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div
|
||||
className="flex h-2.5 w-full gap-0.5 overflow-hidden rounded-sm"
|
||||
role="img"
|
||||
aria-label="Share of turns by bucket"
|
||||
>
|
||||
{segments.map((b) => (
|
||||
<div
|
||||
key={b.key}
|
||||
className={`${b.fill} first:rounded-l-sm last:rounded-r-sm`}
|
||||
style={{ width: `${b.sharePct}%` }}
|
||||
title={`${b.label}: ${b.turns.toLocaleString()} turns`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full gap-0.5 text-[11px] text-muted-foreground">
|
||||
{segments.map((b) => (
|
||||
<span key={b.key} className="whitespace-nowrap" style={{ width: `${b.sharePct}%` }}>
|
||||
{b.sharePct}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BucketTable: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => (
|
||||
<Table className="border-b">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="text-[11px] uppercase tracking-wide">Bucket</TableHead>
|
||||
<TableHead className="text-right text-[11px] uppercase tracking-wide">Turns</TableHead>
|
||||
<TableHead className="w-1/2" />
|
||||
<TableHead className="text-right text-[11px] uppercase tracking-wide">Hit rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{buckets.map((b) => (
|
||||
<TableRow key={b.key} className="hover:bg-transparent">
|
||||
<TableCell className="text-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`inline-block size-2 shrink-0 rounded-sm ${b.fill}`} aria-hidden />
|
||||
<span>
|
||||
{b.label}
|
||||
<span className="block text-xs font-normal text-muted-foreground">{b.sublabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right align-middle tabular-nums text-foreground">
|
||||
{b.turns.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="align-middle">
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-foreground" style={{ width: `${b.hitRatePct}%` }} aria-hidden />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right align-middle font-medium tabular-nums text-foreground">
|
||||
{pctLabel(b.hitRatePct)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {
|
||||
const buckets = bucketRows(cache);
|
||||
const total = bucketTurnsTotal(cache);
|
||||
const expiredMissPct = expiredMissShare(cache);
|
||||
return (
|
||||
<Card className="overflow-hidden py-0">
|
||||
<div className="grid lg:grid-cols-[1fr_3fr]">
|
||||
<div className="flex flex-col border-b p-6 lg:border-b-0 lg:border-r">
|
||||
<div className="flex flex-1 flex-col justify-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">Cache hit rate</p>
|
||||
<p className="text-5xl font-semibold tracking-tight text-foreground">{pctLabel(cache.hit_rate_pct)}</p>
|
||||
</div>
|
||||
{expiredMissPct === null ? null : (
|
||||
<div className="flex items-baseline justify-between gap-2 border-t pt-3">
|
||||
<TooltipProvider delay={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<p className="cursor-default text-sm text-muted-foreground underline decoration-dotted underline-offset-2">
|
||||
Expired-miss
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<TooltipContent className="max-w-64">
|
||||
percentage of return-to-tier cache misses caused by cache expiring
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<p className="font-medium tabular-nums text-foreground">{pctLabel(expiredMissPct)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Share of turns</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-lg font-semibold tabular-nums text-foreground">{total.toLocaleString()}</span> turns
|
||||
measured
|
||||
</p>
|
||||
</div>
|
||||
<StackedTurnBar buckets={buckets} />
|
||||
<BucketTable buckets={buckets} />
|
||||
{cache.unordered_turns > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cache.unordered_turns.toLocaleString()} turns arrived out of order across pods and are not bucketed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface BenchmarksBodyProps {
|
||||
isPending: boolean;
|
||||
error: unknown;
|
||||
data: AutoRouterBenchmarksResponse | undefined;
|
||||
selectedKey: string;
|
||||
}
|
||||
|
||||
const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data, selectedKey }) => {
|
||||
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>;
|
||||
}
|
||||
if (error || !data) return <Message>Auto-router usage is unavailable right now</Message>;
|
||||
if (data.groups.length === 0) return <Message>No auto-router sessions in this window yet</Message>;
|
||||
|
||||
const view = viewFor(data, selectedKey);
|
||||
const stats = view.stats;
|
||||
return (
|
||||
<>
|
||||
<HeroCard view={view} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Metric label="Avg turns per session" value={stats.avg_turns_per_session.toFixed(1)} />
|
||||
<Metric label="Avg session length" value={durationLabel(stats.avg_session_seconds)} />
|
||||
<Metric label="Avg tokens per session" value={formatNumberWithCommas(stats.avg_tokens_per_session, 1, true)} />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Compares your actual routed spend with the estimated cost of using only the most expensive model configured in
|
||||
the auto-router. It accounts for both the cache savings from staying on one model and the added cache costs from
|
||||
switching models.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">Auto-router prompt caching</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
every turn falls in exactly one bucket, by what the router did
|
||||
</p>
|
||||
</div>
|
||||
<CachingCard cache={stats.cache} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface AutoRouterBenchmarksTabProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const AutoRouterBenchmarksTab: React.FC<AutoRouterBenchmarksTabProps> = ({ accessToken }) => {
|
||||
const [range, setRange] = useState<BenchmarkWindow>("30d");
|
||||
const { data, isPending, error } = useAutoRouterBenchmarks(accessToken, range);
|
||||
const [selectedKey, setSelectedKey] = useState<string>(ALL_ROUTERS);
|
||||
|
||||
const groups = data?.groups ?? [];
|
||||
const selectedLabel = data ? viewFor(data, selectedKey).label : "All auto-routers";
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Auto-router usage</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{WINDOW_LABELS[range]}</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
|
||||
<Tabs value={range} onValueChange={(value) => setRange(value === "7d" || value === "24h" ? value : "30d")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="30d">30d</TabsTrigger>
|
||||
<TabsTrigger value="7d">7d</TabsTrigger>
|
||||
<TabsTrigger value="24h">24h</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="w-full sm:w-64">
|
||||
<Select value={selectedKey} onValueChange={(value: string | null) => setSelectedKey(value ?? ALL_ROUTERS)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{selectedLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ROUTERS}>All auto-routers</SelectItem>
|
||||
{groups.map((g) => (
|
||||
<SelectItem key={groupKey(g)} value={groupKey(g)}>
|
||||
{groupLabel(g, groups)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BenchmarksBody isPending={isPending} error={error} data={data} selectedKey={selectedKey} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoRouterBenchmarksTab;
|
||||
|
|
@ -4,19 +4,23 @@ import { describe, expect, it, vi } from "vitest";
|
|||
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
|
||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
|
||||
vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () => <div data-testid="caching-tab" /> }));
|
||||
vi.mock("./AutoRouterBenchmarksTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="autorouter-benchmarks-tab" />,
|
||||
}));
|
||||
|
||||
import CostOptimizationView from "./CostOptimizationView";
|
||||
|
||||
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
|
||||
|
||||
describe("CostOptimizationView", () => {
|
||||
it("renders the three cost-optimization tabs and no autorouter tab", () => {
|
||||
const { getByText, queryByText } = renderView();
|
||||
it("renders the four cost-optimization tabs", () => {
|
||||
const { getByText } = renderView();
|
||||
|
||||
expect(getByText("Usage")).toBeInTheDocument();
|
||||
expect(getByText("Prompt Compression")).toBeInTheDocument();
|
||||
expect(getByText("Prompt Caching")).toBeInTheDocument();
|
||||
expect(queryByText("Autorouter")).not.toBeInTheDocument();
|
||||
expect(getByText("Auto-Router Usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to the Usage tab and switches the active tab on click", () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Alert, Tabs } from "antd";
|
|||
import UsageTab from "./UsageTab";
|
||||
import PromptCompressionTab from "./PromptCompressionTab";
|
||||
import PromptCachingTab from "./PromptCachingTab";
|
||||
import AutoRouterBenchmarksTab from "./AutoRouterBenchmarksTab";
|
||||
import { useDailyActivityRange } from "./useDailyActivityRange";
|
||||
|
||||
interface CostOptimizationViewProps {
|
||||
|
|
@ -34,6 +35,11 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
label: "Prompt Caching",
|
||||
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
|
||||
},
|
||||
{
|
||||
key: "autorouter-usage",
|
||||
label: "Auto-Router Usage",
|
||||
children: <AutoRouterBenchmarksTab accessToken={accessToken} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -57,7 +63,7 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
<span>
|
||||
Have feedback? Join the discussion{" "}
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/discussions/32172"
|
||||
href="https://github.com/BerriAI/litellm/discussions/32168"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ALL_ROUTERS,
|
||||
bucketRows,
|
||||
bucketTurnsTotal,
|
||||
durationLabel,
|
||||
expiredMissShare,
|
||||
groupKey,
|
||||
groupLabel,
|
||||
pctLabel,
|
||||
viewFor,
|
||||
windowFor,
|
||||
type AutoRouterBenchmarkGroup,
|
||||
type AutoRouterBenchmarksResponse,
|
||||
type AutoRouterCacheStats,
|
||||
} from "./autoRouterBenchmarks";
|
||||
|
||||
const cache = (overrides: Partial<AutoRouterCacheStats> = {}): AutoRouterCacheStats => ({
|
||||
coverage_pct: 99.6,
|
||||
hit_rate_pct: 93.3,
|
||||
same_model: { turns: 400, hits: 391, hit_rate_pct: 97.7 },
|
||||
first_visit: { turns: 37, hits: 9, hit_rate_pct: 24.3 },
|
||||
return_to_tier: { turns: 381, hits: 311, hit_rate_pct: 81.6 },
|
||||
unordered_turns: 0,
|
||||
return_misses_expired: 19,
|
||||
return_misses_within_ttl: 51,
|
||||
return_misses_unknown: 0,
|
||||
ttl_5m_turns: 0,
|
||||
ttl_1h_turns: 818,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const totals = (overrides: Partial<AutoRouterBenchmarkGroup> = {}) => ({
|
||||
sessions: 94,
|
||||
turns: 3073,
|
||||
avg_turns_per_session: 32.7,
|
||||
avg_session_seconds: 7560,
|
||||
avg_tokens_per_session: 5_300_000,
|
||||
spend: 359.86,
|
||||
saved_spend: 2174.59,
|
||||
baseline_spend: 2534.45,
|
||||
saved_pct: 85.8,
|
||||
saved_per_session: 23.13,
|
||||
cache: cache(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const group = (overrides: Partial<AutoRouterBenchmarkGroup> = {}): AutoRouterBenchmarkGroup => ({
|
||||
router_name: "claude-auto",
|
||||
router_type: "complexity",
|
||||
...totals(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const response = (groups: AutoRouterBenchmarkGroup[]): AutoRouterBenchmarksResponse => ({
|
||||
start_date: "2026-07-06",
|
||||
end_date: "2026-08-05",
|
||||
routers_in_scope: groups.length,
|
||||
totals: totals(),
|
||||
groups,
|
||||
});
|
||||
|
||||
describe("viewFor", () => {
|
||||
it("maps the all-routers selection to the server totals, never a client sum", () => {
|
||||
const data = response([group(), group({ router_name: "gpt-auto", sessions: 7 })]);
|
||||
const view = viewFor(data, ALL_ROUTERS);
|
||||
expect(view.stats).toBe(data.totals);
|
||||
expect(view.label).toBe("All auto-routers");
|
||||
});
|
||||
|
||||
it("maps a selected router to that group's slice with a scope of one", () => {
|
||||
const other = group({ router_name: "gpt-auto", sessions: 7, saved_spend: 12.5 });
|
||||
const data = response([group(), other]);
|
||||
const view = viewFor(data, groupKey(other));
|
||||
expect(view.stats).toBe(other);
|
||||
expect(view.label).toBe("gpt-auto");
|
||||
});
|
||||
|
||||
it("falls back to the all-routers view when the selected key no longer exists", () => {
|
||||
const data = response([group()]);
|
||||
const view = viewFor(data, "vanished complexity");
|
||||
expect(view.stats).toBe(data.totals);
|
||||
expect(view.label).toBe("All auto-routers");
|
||||
});
|
||||
|
||||
it("distinguishes two groups sharing an alias by their router type", () => {
|
||||
const a = group({ router_type: "complexity" });
|
||||
const b = group({ router_type: "adaptive" });
|
||||
const data = response([a, b]);
|
||||
expect(groupKey(a)).not.toBe(groupKey(b));
|
||||
expect(viewFor(data, groupKey(b)).stats).toBe(b);
|
||||
expect(viewFor(data, groupKey(b)).label).toBe("claude-auto (adaptive)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupLabel", () => {
|
||||
it("uses the bare alias when it is unique", () => {
|
||||
const groups = [group(), group({ router_name: "gpt-auto" })];
|
||||
expect(groupLabel(groups[0], groups)).toBe("claude-auto");
|
||||
});
|
||||
|
||||
it("appends the router type only when the alias is duplicated", () => {
|
||||
const groups = [group({ router_type: "complexity" }), group({ router_type: "adaptive" })];
|
||||
expect(groupLabel(groups[0], groups)).toBe("claude-auto (complexity)");
|
||||
expect(groupLabel(groups[1], groups)).toBe("claude-auto (adaptive)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketRows", () => {
|
||||
it("keeps the three buckets summing to the bucketed turn total", () => {
|
||||
const stats = cache();
|
||||
const rows = bucketRows(stats);
|
||||
expect(rows.map((r) => r.turns)).toEqual([400, 37, 381]);
|
||||
expect(bucketTurnsTotal(stats)).toBe(818);
|
||||
});
|
||||
|
||||
it("renders the server's per-bucket rates as-is", () => {
|
||||
expect(bucketRows(cache()).map((r) => r.hitRatePct)).toEqual([97.7, 24.3, 81.6]);
|
||||
});
|
||||
|
||||
it("derives each bucket's share of the measured turns", () => {
|
||||
expect(bucketRows(cache()).map((r) => r.sharePct)).toEqual([49, 5, 47]);
|
||||
});
|
||||
|
||||
it("reports zero shares instead of dividing by zero when nothing was bucketed", () => {
|
||||
const empty = { turns: 0, hits: 0, hit_rate_pct: 0 };
|
||||
const rows = bucketRows(cache({ same_model: empty, first_visit: empty, return_to_tier: empty }));
|
||||
expect(rows.map((r) => r.sharePct)).toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiredMissShare", () => {
|
||||
it("recomputes the expired share from the miss counts", () => {
|
||||
expect(expiredMissShare(cache())).toBeCloseTo((100 * 19) / 70);
|
||||
});
|
||||
|
||||
it("is absent when every return turn hit", () => {
|
||||
expect(expiredMissShare(cache({ return_to_tier: { turns: 10, hits: 10, hit_rate_pct: 100 } }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowFor", () => {
|
||||
const noon = new Date("2026-08-05T12:00:00Z");
|
||||
|
||||
it("derives each picker range as UTC calendar days ending today", () => {
|
||||
expect(windowFor("30d", noon)).toEqual({ start_date: "2026-07-06", end_date: "2026-08-05" });
|
||||
expect(windowFor("7d", noon)).toEqual({ start_date: "2026-07-29", end_date: "2026-08-05" });
|
||||
expect(windowFor("24h", noon)).toEqual({ start_date: "2026-08-04", end_date: "2026-08-05" });
|
||||
});
|
||||
|
||||
it("uses UTC days, not the local calendar", () => {
|
||||
const lateEvening = new Date("2026-08-05T23:30:00-05:00");
|
||||
expect(windowFor("24h", lateEvening)).toEqual({ start_date: "2026-08-05", end_date: "2026-08-06" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatting", () => {
|
||||
it("renders session length in the largest sensible unit", () => {
|
||||
expect(durationLabel(42)).toBe("42s");
|
||||
expect(durationLabel(150)).toBe("2.5m");
|
||||
expect(durationLabel(7560)).toBe("2.1h");
|
||||
});
|
||||
|
||||
it("renders percentages at the requested precision", () => {
|
||||
expect(pctLabel(93.3)).toBe("93.3%");
|
||||
expect(pctLabel(85.8, 0)).toBe("86%");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type AutoRouterBenchmarksResponse = components["schemas"]["AutoRouterBenchmarksResponse"];
|
||||
export type AutoRouterBenchmarkTotals = components["schemas"]["AutoRouterBenchmarkTotals"];
|
||||
export type AutoRouterBenchmarkGroup = components["schemas"]["AutoRouterBenchmarkGroup"];
|
||||
export type AutoRouterCacheStats = components["schemas"]["AutoRouterCacheStats"];
|
||||
|
||||
export const ALL_ROUTERS = "__all__";
|
||||
|
||||
export type BenchmarkWindow = "30d" | "7d" | "24h";
|
||||
|
||||
const WINDOW_DAYS: Record<BenchmarkWindow, number> = { "30d": 30, "7d": 7, "24h": 1 };
|
||||
|
||||
export const WINDOW_LABELS: Record<BenchmarkWindow, string> = {
|
||||
"30d": "Last 30 days",
|
||||
"7d": "Last 7 days",
|
||||
"24h": "Last 24 hours",
|
||||
};
|
||||
|
||||
export const windowFor = (range: BenchmarkWindow, now: Date): { start_date: string; end_date: string } => ({
|
||||
start_date: new Date(now.getTime() - WINDOW_DAYS[range] * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
end_date: now.toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
export interface BenchmarkView {
|
||||
label: string;
|
||||
stats: AutoRouterBenchmarkTotals;
|
||||
}
|
||||
|
||||
export const groupKey = (group: AutoRouterBenchmarkGroup): string => `${group.router_name} ${group.router_type}`;
|
||||
|
||||
export const groupLabel = (group: AutoRouterBenchmarkGroup, groups: readonly AutoRouterBenchmarkGroup[]): string => {
|
||||
const duplicated = groups.some((g) => g !== group && g.router_name === group.router_name);
|
||||
return duplicated ? `${group.router_name} (${group.router_type})` : group.router_name;
|
||||
};
|
||||
|
||||
export const viewFor = (data: AutoRouterBenchmarksResponse, selectedKey: string): BenchmarkView => {
|
||||
const group = data.groups.find((g) => groupKey(g) === selectedKey);
|
||||
if (selectedKey === ALL_ROUTERS || !group) {
|
||||
return { label: "All auto-routers", stats: data.totals };
|
||||
}
|
||||
return { label: groupLabel(group, data.groups), stats: group };
|
||||
};
|
||||
|
||||
export interface BucketRow {
|
||||
key: "same_model" | "first_visit" | "return_to_tier";
|
||||
label: string;
|
||||
sublabel: string;
|
||||
turns: number;
|
||||
sharePct: number;
|
||||
hitRatePct: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
export const bucketTurnsTotal = (cache: AutoRouterCacheStats): number =>
|
||||
cache.same_model.turns + cache.first_visit.turns + cache.return_to_tier.turns;
|
||||
|
||||
const sharePctOf = (turns: number, total: number): number => (total > 0 ? Math.round((100 * turns) / total) : 0);
|
||||
|
||||
export const bucketRows = (cache: AutoRouterCacheStats): BucketRow[] => {
|
||||
const total = bucketTurnsTotal(cache);
|
||||
return [
|
||||
{
|
||||
key: "same_model",
|
||||
label: "Same model",
|
||||
sublabel: "previous turn → same tier",
|
||||
turns: cache.same_model.turns,
|
||||
sharePct: sharePctOf(cache.same_model.turns, total),
|
||||
hitRatePct: cache.same_model.hit_rate_pct,
|
||||
fill: "bg-foreground",
|
||||
},
|
||||
{
|
||||
key: "first_visit",
|
||||
label: "First visit",
|
||||
sublabel: "previous turn → a tier not used yet",
|
||||
turns: cache.first_visit.turns,
|
||||
sharePct: sharePctOf(cache.first_visit.turns, total),
|
||||
hitRatePct: cache.first_visit.hit_rate_pct,
|
||||
fill: "bg-foreground/30",
|
||||
},
|
||||
{
|
||||
key: "return_to_tier",
|
||||
label: "Return to tier",
|
||||
sublabel: "previous turn → a tier used earlier",
|
||||
turns: cache.return_to_tier.turns,
|
||||
sharePct: sharePctOf(cache.return_to_tier.turns, total),
|
||||
hitRatePct: cache.return_to_tier.hit_rate_pct,
|
||||
fill: "bg-foreground/60",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const expiredMissShare = (cache: AutoRouterCacheStats): number | null => {
|
||||
const misses = cache.return_to_tier.turns - cache.return_to_tier.hits;
|
||||
if (misses <= 0) return null;
|
||||
return (100 * cache.return_misses_expired) / misses;
|
||||
};
|
||||
|
||||
export const pctLabel = (value: number, digits: number = 1): string => `${value.toFixed(digits)}%`;
|
||||
|
||||
export const durationLabel = (seconds: number): string => {
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
|
||||
return `${(seconds / 3600).toFixed(1)}h`;
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { $api } from "@/lib/http/api";
|
||||
|
||||
import { windowFor, type BenchmarkWindow } from "./autoRouterBenchmarks";
|
||||
|
||||
export const useAutoRouterBenchmarks = (accessToken: string | null, range: BenchmarkWindow) =>
|
||||
$api.useQuery(
|
||||
"get",
|
||||
"/auto_router/benchmarks",
|
||||
{ params: { query: windowFor(range, new Date()) } },
|
||||
{ enabled: Boolean(accessToken), retry: false },
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue