feat(ui): shadow evals tab beside auto-router usage (#36588)

This commit is contained in:
tin-berri 2026-08-13 17:19:45 -07:00 committed by GitHub
parent 72ee0bb1c4
commit 83e890bdde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1123 additions and 4 deletions

View file

@ -8,6 +8,7 @@ import { ApiError } from "@/lib/http/client";
vi.mock("./useAutoRouterBenchmarks", () => ({ useAutoRouterBenchmarks: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useAutoRouters: vi.fn() }));
vi.mock("./ShadowEvalSection", () => ({ default: () => <div data-testid="shadow-eval-section" /> }));
import { useAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
@ -274,6 +275,33 @@ describe("AutoRouterBenchmarksTab", () => {
expect(screen.getByText("Last 24 hours")).toBeInTheDocument();
});
it("shows usage by default and mounts shadow evals only when its sub-tab is selected", () => {
mockHook({ data: response([group()]) });
renderTab();
expect(screen.getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Total estimated savings")).toBeInTheDocument();
expect(screen.queryByTestId("shadow-eval-section")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Shadow Evals" }));
expect(screen.getByRole("tab", { name: "Shadow Evals" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("shadow-eval-section")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Usage" }));
expect(screen.getByText("Total estimated savings")).toBeInTheDocument();
expect(screen.getByTestId("shadow-eval-section")).toBeInTheDocument();
});
it("keeps the shadow evals sub-tab reachable while the usage body is in its error state", () => {
mockHook({ error: new ApiError("boom", 500, {}) });
renderTab();
expect(screen.getByText("Auto-router usage is unavailable right now")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Shadow Evals" }));
expect(screen.getByTestId("shadow-eval-section")).toBeInTheDocument();
});
it("keeps the window picker reachable while a window has no sessions", () => {
mockHook({ data: response([]) });
renderTab();

View file

@ -8,7 +8,7 @@ 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 { Tabs, TabsContent, 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";
@ -31,6 +31,7 @@ import {
type BucketRow,
} from "./autoRouterBenchmarks";
import { usd } from "./costOptimizationUtils";
import ShadowEvalSection from "./ShadowEvalSection";
import TierTurnsChart from "./TierTurnsChart";
import { useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks";
@ -268,7 +269,7 @@ interface AutoRouterBenchmarksTabProps {
accessToken: string | null;
}
const AutoRouterBenchmarksTab: React.FC<AutoRouterBenchmarksTabProps> = ({ accessToken }) => {
const UsageView: React.FC<AutoRouterBenchmarksTabProps> = ({ accessToken }) => {
const [range, setRange] = useState<BenchmarkWindow>("30d");
const { data, isPending, error } = useAutoRouterBenchmarks(accessToken, range);
const [selectedKey, setSelectedKey] = useState<string>(ALL_ROUTERS);
@ -321,4 +322,36 @@ const AutoRouterBenchmarksTab: React.FC<AutoRouterBenchmarksTabProps> = ({ acces
);
};
const AutoRouterBenchmarksTab: React.FC<AutoRouterBenchmarksTabProps> = ({ accessToken }) => {
const [visitedTabs, setVisitedTabs] = useState<readonly string[]>(["usage"]);
const handleTabChange = (value: unknown) => {
if (typeof value !== "string") {
return;
}
setVisitedTabs((currentTabs) => (currentTabs.includes(value) ? currentTabs : [...currentTabs, value]));
};
return (
<Tabs defaultValue="usage" onValueChange={handleTabChange} className="w-full gap-4">
<TabsList>
<TabsTrigger value="usage" className="px-3">
Usage
</TabsTrigger>
<TabsTrigger value="shadow-evals" className="px-3">
Shadow Evals
</TabsTrigger>
</TabsList>
<TabsContent value="usage" keepMounted={visitedTabs.includes("usage")}>
<UsageView accessToken={accessToken} />
</TabsContent>
<TabsContent value="shadow-evals" keepMounted={visitedTabs.includes("shadow-evals")}>
<ShadowEvalSection />
</TabsContent>
</Tabs>
);
};
export default AutoRouterBenchmarksTab;

View file

@ -0,0 +1,392 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useInfiniteKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { ApiError } from "@/lib/http/client";
vi.mock("./useShadowEval", () => ({
useShadowEvalJobs: vi.fn(),
useShadowEvalJob: vi.fn(),
useStartShadowEval: vi.fn(),
useStopShadowEval: vi.fn(),
}));
const authorizedRoleMock = vi.fn(() => ({ accessToken: "token", isViewOnly: false }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => authorizedRoleMock() }));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useInfiniteKeys: vi.fn(() => ({
data: {
pages: [
{
keys: [
{ token: "hash-alpha", token_id: "id-1", key_name: "sk-...alpha", key_alias: "prod-alpha" },
{ token: "hash-beta", token_id: "id-2", key_name: "sk-...beta", key_alias: "staging-beta" },
],
total_count: 2,
current_page: 1,
total_pages: 1,
},
],
},
isPending: false,
isError: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
})),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAutoRouters: vi.fn(() => ({
data: [
{ model_name: "claude-auto", litellm_params: { model: "auto_router/claude-auto" } },
{ model_name: "gpt-auto", litellm_params: { model: "auto_router/gpt-auto" } },
],
})),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
useModelCostMap: vi.fn(() => ({
data: {
"claude-sonnet-5": { litellm_provider: "anthropic", mode: "chat" },
"gpt-4o": { litellm_provider: "openai", mode: "chat" },
"gemini/gemini-2.5-pro": { litellm_provider: "gemini", mode: "chat" },
"text-embedding-3-large": { litellm_provider: "openai", mode: "embedding" },
},
})),
}));
import ShadowEvalSection from "./ShadowEvalSection";
import {
useShadowEvalJob,
useShadowEvalJobs,
useStartShadowEval,
useStopShadowEval,
type ShadowEvalJob,
} from "./useShadowEval";
const job = (overrides: Partial<ShadowEvalJob> = {}): ShadowEvalJob => ({
job_id: "job-1",
status: "running",
router_name: "claude-auto",
judge_model: "anthropic/claude-sonnet-5",
shadow_percentage: 10,
max_turns: 200,
judged_count: 42,
error_count: 1,
judge_spend: 3.21,
results: {
by_tier: [
{
group: "SIMPLE",
turn_count: 30,
real_win_rate_pct: 20.0,
shadow_win_rate_pct: 55.0,
tie_rate_pct: 25.0,
avg_judge_confidence: 0.81,
},
{
group: "REASONING",
turn_count: 12,
real_win_rate_pct: 50.0,
shadow_win_rate_pct: 33.3,
tie_rate_pct: 16.7,
avg_judge_confidence: 0.74,
},
],
by_current_model: [
{
group: "gpt-4o",
turn_count: 42,
real_win_rate_pct: 30.0,
shadow_win_rate_pct: 45.0,
tie_rate_pct: 25.0,
avg_judge_confidence: 0.8,
},
],
overall_shadow_win_rate_pct: 48.0,
overall_tie_rate_pct: 22.0,
},
created_at: "2026-08-07T00:00:00Z",
ends_at: "2026-09-07T00:00:00Z",
stopped_at: null,
api_key_id: "hashed-key-abc",
last_error: null,
...overrides,
});
const mockHooks = ({
jobs = [],
detailsById = {},
error = null,
detailError = false,
isPending = false,
}: {
jobs?: ShadowEvalJob[];
detailsById?: Record<string, ShadowEvalJob>;
error?: Error | null;
detailError?: boolean;
isPending?: boolean;
}) => {
vi.mocked(useShadowEvalJobs).mockReturnValue({
data: error || isPending ? undefined : jobs,
error,
isPending,
} as unknown as ReturnType<typeof useShadowEvalJobs>);
vi.mocked(useShadowEvalJob).mockImplementation(
(jobId) =>
({
data: jobId ? detailsById[jobId] : undefined,
isError: detailError ?? false,
}) as unknown as ReturnType<typeof useShadowEvalJob>,
);
const start = { mutate: vi.fn(), isPending: false };
const stop = { mutate: vi.fn(), isPending: false };
vi.mocked(useStartShadowEval).mockReturnValue(start as unknown as ReturnType<typeof useStartShadowEval>);
vi.mocked(useStopShadowEval).mockReturnValue(stop as unknown as ReturnType<typeof useStopShadowEval>);
return { start, stop };
};
describe("ShadowEvalSection", () => {
beforeEach(() => {
authorizedRoleMock.mockReturnValue({ accessToken: "token", isViewOnly: false });
});
it("shows a key picker load failure instead of posing as no matching keys", async () => {
const user = userEvent.setup();
const defaultKeysImpl = vi.mocked(useInfiniteKeys).getMockImplementation();
vi.mocked(useInfiniteKeys).mockReturnValue({
data: undefined,
isPending: false,
isError: true,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useInfiniteKeys>);
mockHooks({});
render(<ShadowEvalSection />);
await user.click(screen.getByPlaceholderText("Search keys by alias"));
expect(await screen.findByText("Keys could not be loaded. Refresh the page to retry.")).toBeInTheDocument();
expect(screen.queryByText("No matching keys")).not.toBeInTheDocument();
if (defaultKeysImpl) vi.mocked(useInfiniteKeys).mockImplementation(defaultKeysImpl);
});
it("offers the start form while the list is still loading", () => {
mockHooks({ isPending: true });
render(<ShadowEvalSection />);
expect(screen.getByText("Loading evaluations...")).toBeInTheDocument();
expect(screen.getByText("Start a shadow eval")).toBeInTheDocument();
});
it("re-offers the start form when the polled detail sees the job finish before the list does", () => {
mockHooks({
jobs: [job({ status: "running" })],
detailsById: { "job-1": job({ status: "completed" }) },
});
render(<ShadowEvalSection />);
expect(screen.getByText("Start a shadow eval")).toBeInTheDocument();
});
it("gives every active job its own card with a stop button, with the form still offered", () => {
mockHooks({
jobs: [
job({ job_id: "job-a", status: "running", api_key_id: "key-a" }),
job({ job_id: "job-b", status: "running", api_key_id: "key-b" }),
],
});
render(<ShadowEvalSection />);
expect(screen.getAllByRole("button", { name: "Stop" })).toHaveLength(2);
expect(screen.getByText("Start a shadow eval")).toBeInTheDocument();
expect(screen.queryByText(/Previous evaluations/)).not.toBeInTheDocument();
});
it("renders the active card from the list row while its detail is still loading", () => {
mockHooks({ jobs: [job({ status: "running" })], detailsById: {} });
render(<ShadowEvalSection />);
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
});
it("hides the start form and stop button from view-only admins", () => {
authorizedRoleMock.mockReturnValue({ accessToken: "token", isViewOnly: true });
mockHooks({ jobs: [job({ status: "running" })] });
render(<ShadowEvalSection />);
expect(screen.queryByText("Start a shadow eval")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
expect(screen.getByText("running")).toBeInTheDocument();
});
it("never labels a collapsed previous eval as empty from a countless list row", () => {
const countlessListRow: Partial<ShadowEvalJob> = {
job_id: "job-old",
status: "stopped",
judged_count: null,
error_count: null,
judge_spend: null,
results: null,
};
mockHooks({ jobs: [job({ status: "running" }), job(countlessListRow)] });
render(<ShadowEvalSection />);
fireEvent.click(screen.getByRole("button", { name: /Previous evaluations/ }));
expect(screen.getByText("view results")).toBeInTheDocument();
expect(screen.queryByText("no verdicts")).not.toBeInTheDocument();
expect(screen.queryByText(/0 judged/)).not.toBeInTheDocument();
});
it("surfaces a non-403 list failure instead of posing as an empty state", () => {
mockHooks({ error: new Error("boom") });
render(<ShadowEvalSection />);
expect(screen.getByText(/Existing evaluations could not be loaded/)).toBeInTheDocument();
expect(screen.getByText("Start a shadow eval")).toBeInTheDocument();
});
it("shows a failure line instead of loading forever when the detail fetch errors", () => {
mockHooks({
jobs: [job({ status: "completed", judged_count: 12, results: null })],
detailsById: {},
detailError: true,
});
render(<ShadowEvalSection />);
expect(screen.getByText(/Results could not be loaded/)).toBeInTheDocument();
expect(screen.queryByText("Loading results...")).not.toBeInTheDocument();
});
it("shows the failure line over the collecting copy when an active job's detail errors", () => {
mockHooks({ jobs: [job({ status: "running", results: null })], detailsById: {}, detailError: true });
render(<ShadowEvalSection />);
expect(screen.getByText(/Results could not be loaded/)).toBeInTheDocument();
expect(screen.queryByText(/Collecting verdicts/)).not.toBeInTheDocument();
});
it("never claims no verdicts for a judged job whose results have not loaded yet", () => {
mockHooks({ jobs: [job({ status: "completed", judged_count: 12, results: null })], detailsById: {} });
render(<ShadowEvalSection />);
expect(screen.getByText("Loading results...")).toBeInTheDocument();
expect(screen.queryByText(/No verdicts were recorded/)).not.toBeInTheDocument();
});
it("shows the start form when there are no jobs", () => {
mockHooks({});
render(<ShadowEvalSection />);
expect(screen.getByText("Start a shadow eval")).toBeInTheDocument();
expect(screen.getByText("Start shadow eval")).toBeInTheDocument();
});
it("renders the latest job's results with the headline stat, verdict split, and both stratifications", () => {
const j = job();
mockHooks({ jobs: [j], detailsById: { "job-1": j } });
render(<ShadowEvalSection />);
expect(screen.getByText("Router matched or beat your current model")).toBeInTheDocument();
expect(screen.getByText("70.0%")).toBeInTheDocument();
expect(screen.getByText("of 42 judged responses")).toBeInTheDocument();
expect(screen.getByText(/Tie 22.0%/)).toBeInTheDocument();
expect(screen.getByText(/Current model won 30.0%/)).toBeInTheDocument();
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
expect(screen.getByText("SIMPLE")).toBeInTheDocument();
expect(screen.getByText("REASONING")).toBeInTheDocument();
expect(screen.getByText("55.0%")).toBeInTheDocument();
});
it("shows the ends-in text while a job is still sampling", () => {
const j = job({ ends_at: new Date(Date.now() + 3 * 86_400_000).toISOString() });
mockHooks({ jobs: [j], detailsById: { "job-1": j } });
render(<ShadowEvalSection />);
expect(screen.getByText(/ends in 3 days/)).toBeInTheDocument();
});
it("flags rows with fewer than 30 judged turns as low sample", () => {
const j = job();
mockHooks({ jobs: [j], detailsById: { "job-1": j } });
render(<ShadowEvalSection />);
expect(screen.getAllByText("(low sample)")).toHaveLength(1);
});
it("surfaces the last failure so a growing error_count is diagnosable", () => {
const j = job({ error_count: 7, last_error: "judge call failed: LLM Provider NOT provided" });
mockHooks({ jobs: [j], detailsById: { "job-1": j } });
render(<ShadowEvalSection />);
expect(screen.getByText(/LLM Provider NOT provided/)).toBeInTheDocument();
});
it("stops the running job from the stop button", async () => {
const user = userEvent.setup();
const j = job();
const { stop } = mockHooks({ jobs: [j], detailsById: { "job-1": j } });
render(<ShadowEvalSection />);
await user.click(screen.getByText("Stop"));
expect(stop.mutate).toHaveBeenCalledWith("job-1");
});
it("hides the stop button and offers the start form once the latest job completed", () => {
const done = job({ status: "completed" });
mockHooks({ jobs: [done], detailsById: { "job-1": done } });
render(<ShadowEvalSection />);
expect(screen.queryByText("Stop")).not.toBeInTheDocument();
expect(screen.getByText("Start a shadow eval")).toBeInTheDocument();
});
it("renders nothing for non-admins when the proxy answers 403", () => {
mockHooks({ error: new ApiError("forbidden", 403, {}) });
const { container } = render(<ShadowEvalSection />);
expect(container).toBeEmptyDOMElement();
});
it("keeps the start button disabled until key, router, and judge model are picked, then submits them", async () => {
const user = userEvent.setup();
const { start } = mockHooks({});
render(<ShadowEvalSection />);
expect(screen.getByText("Start shadow eval")).toBeDisabled();
await user.click(screen.getByPlaceholderText("Search keys by alias"));
await user.click(await screen.findByText("prod-alpha"));
await user.click(screen.getByPlaceholderText("Select an auto-router"));
await user.click(await screen.findByText("gpt-auto"));
expect(screen.getByText("Start shadow eval")).toBeDisabled();
await user.click(screen.getByPlaceholderText("Select a judge model"));
await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ }));
await user.click(screen.getByText("Start shadow eval"));
const expectedBody = {
api_key_id: "hash-alpha",
router_name: "gpt-auto",
shadow_percentage: 10,
duration_days: 7,
max_turns: 200,
judge_model: "anthropic/claude-sonnet-5",
};
expect(start.mutate).toHaveBeenCalledWith(expectedBody);
});
it("keeps an older job's verdicts reachable through the previous evaluations list", async () => {
const user = userEvent.setup();
const emptyOverrides: Partial<ShadowEvalJob> = {
job_id: "job-new",
status: "running",
judged_count: 0,
error_count: 0,
results: null,
};
const current = job(emptyOverrides);
const older = job({ job_id: "job-old", status: "completed", results: null });
mockHooks({ jobs: [current, older], detailsById: { "job-new": current, "job-old": job({ job_id: "job-old" }) } });
render(<ShadowEvalSection />);
expect(screen.queryByText("SIMPLE")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Previous evaluations \(1\)/ }));
expect(screen.getByText("view results")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /10% via claude-auto/ }));
expect(await screen.findByText("SIMPLE")).toBeInTheDocument();
expect(screen.getByText("REASONING")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,531 @@
"use client";
import React, { useMemo, useState } from "react";
import { useInfiniteKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ApiError } from "@/lib/http/client";
import { usd } from "./costOptimizationUtils";
import {
useShadowEvalJob,
useShadowEvalJobs,
useStartShadowEval,
useStopShadowEval,
type ShadowEvalJob,
type ShadowEvalSlice,
} from "./useShadowEval";
const pct = (value: number): string => `${value.toFixed(1)}%`;
const MIN_TURNS_FOR_CONFIDENCE = 30;
const isActive = (job: ShadowEvalJob): boolean => job.status === "running";
const endsIn = (endsAt: string | null | undefined): string | null => {
if (!endsAt) return null;
const remainingMs = new Date(endsAt).getTime() - Date.now();
if (!Number.isFinite(remainingMs)) return null;
if (remainingMs <= 0) return "ending now";
const days = Math.round(remainingMs / 86_400_000);
return days >= 2 ? `ends in ${days} days` : "ends within a day";
};
const STATUS_STYLES: Record<string, string> = {
running: "bg-blue-50 text-blue-700",
completed: "bg-emerald-50 text-emerald-700",
stopped: "bg-secondary text-muted-foreground",
};
const StatusBadge: React.FC<{ status: string }> = ({ status }) => (
<Badge variant="secondary" className={STATUS_STYLES[status] ?? STATUS_STYLES.stopped}>
{status}
</Badge>
);
const SliceTable: React.FC<{ groupHeader: string; slices: readonly ShadowEvalSlice[] }> = ({ groupHeader, slices }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>{groupHeader}</TableHead>
{["Judged turns", "Router wins", "Current model wins", "Ties", "Judge confidence"].map((label) => (
<TableHead key={label} className="text-right">
{label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{slices.map((slice) => (
<TableRow key={slice.group}>
<TableCell className="font-medium text-foreground">
{slice.group}
{slice.turn_count < MIN_TURNS_FOR_CONFIDENCE && (
<span className="ml-2 text-xs font-normal text-muted-foreground">(low sample)</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">{slice.turn_count.toLocaleString()}</TableCell>
<TableCell className="text-right font-medium tabular-nums text-foreground">
{pct(slice.shadow_win_rate_pct)}
</TableCell>
<TableCell className="text-right tabular-nums">{pct(slice.real_win_rate_pct)}</TableCell>
<TableCell className="text-right tabular-nums">{pct(slice.tie_rate_pct)}</TableCell>
<TableCell className="text-right tabular-nums">{slice.avg_judge_confidence.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
const VerdictBar: React.FC<{ results: NonNullable<ShadowEvalJob["results"]> }> = ({ results }) => {
const routerWins = results.overall_shadow_win_rate_pct;
const ties = results.overall_tie_rate_pct;
const segments = [
{ label: "Router won", value: routerWins, fill: "bg-emerald-500" },
{ label: "Tie", value: ties, fill: "bg-emerald-200" },
{ label: "Current model won", value: Math.max(0, 100 - routerWins - ties), fill: "bg-muted-foreground/30" },
];
return (
<div className="space-y-2 border-b px-6 py-4">
<div className="flex h-2 w-full overflow-hidden rounded-full" role="img" aria-label="Verdict breakdown">
{segments
.filter((segment) => segment.value > 0)
.map((segment) => (
<div key={segment.label} className={segment.fill} style={{ width: `${segment.value}%` }} />
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{segments.map((segment) => (
<span key={segment.label} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${segment.fill}`} />
{segment.label} {pct(segment.value)}
</span>
))}
</div>
</div>
);
};
const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean): string => {
if (resultsError) return "Results could not be loaded. Retrying.";
if (isActive(job)) return "Collecting verdicts. Results appear as sampled requests are judged.";
if (job.judged_count === 0) return "No verdicts were recorded for this job.";
return "Loading results...";
};
const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({ job, resultsError = false }) => {
const results = job.results;
if (!results || (results.by_tier.length === 0 && results.by_current_model.length === 0)) {
return <p className="px-6 py-8 text-center text-sm text-muted-foreground">{emptyResultsText(job, resultsError)}</p>;
}
return (
<>
<div className="flex flex-col gap-1 border-b px-6 py-4">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
Router matched or beat your current model
</p>
<p className="text-3xl font-semibold text-foreground">
{pct(results.overall_shadow_win_rate_pct + results.overall_tie_rate_pct)}
</p>
<p className="text-xs text-muted-foreground">of {(job.judged_count ?? 0).toLocaleString()} judged responses</p>
</div>
<VerdictBar results={results} />
{results.by_current_model.length > 0 && (
<SliceTable groupHeader="Compared against" slices={results.by_current_model} />
)}
{results.by_tier.length > 0 && (
<div className={results.by_current_model.length > 0 ? "border-t" : ""}>
<SliceTable groupHeader="Prompt difficulty" slices={results.by_tier} />
</div>
)}
</>
);
};
const JobResults: React.FC<{
job: ShadowEvalJob;
onStop: () => void;
stopPending: boolean;
resultsError?: boolean;
readOnly?: boolean;
}> = ({ job, onStop, stopPending, resultsError = false, readOnly = false }) => {
const active = isActive(job);
const remaining = endsIn(job.ends_at);
return (
<Card className="overflow-hidden py-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
<div className="flex items-center gap-3">
<StatusBadge status={job.status} />
<div>
<p className="text-sm font-medium text-foreground">
Shadowing {job.shadow_percentage}% via <span className="font-mono text-xs">{job.router_name}</span>
</p>
<p className="text-xs text-muted-foreground">
{(job.judged_count ?? 0).toLocaleString()} of {job.max_turns.toLocaleString()} turns judged ·{" "}
{(job.error_count ?? 0).toLocaleString()} errored · {usd(job.judge_spend ?? 0)} judge spend
{active && remaining ? ` · ${remaining}` : ""}
</p>
</div>
</div>
{active && !readOnly && (
<Button variant="outline" size="sm" onClick={onStop} disabled={stopPending}>
{stopPending ? "Stopping..." : "Stop"}
</Button>
)}
</div>
{(job.error_count ?? 0) > 0 && job.last_error != null && (
<p className="border-b bg-red-50 px-6 py-2 text-xs text-destructive">
Last failure: <span className="font-mono">{job.last_error}</span>
</p>
)}
<ResultsBody job={job} resultsError={resultsError} />
</Card>
);
};
const RECOMMENDED_JUDGE_MODELS = ["anthropic/claude-sonnet-5", "openai/gpt-4o", "gemini/gemini-2.5-pro"] as const;
interface CostMapEntry {
litellm_provider?: string;
mode?: string;
}
const useJudgeModelOptions = (): SearchSelectOption[] => {
const { data: costMap } = useModelCostMap();
return useMemo(() => {
const pinned: SearchSelectOption[] = RECOMMENDED_JUDGE_MODELS.map((model) => ({
label: model,
value: model,
sublabel: "Recommended",
}));
if (!costMap) return pinned;
const pinnedNames = new Set<string>(RECOMMENDED_JUDGE_MODELS);
const chatModels = Object.entries(costMap as Record<string, CostMapEntry>)
.filter(([, value]) => value?.mode === "chat" && value?.litellm_provider)
.map(([key, value]) => (key.startsWith(`${value.litellm_provider}/`) ? key : `${value.litellm_provider}/${key}`));
const rest = [...new Set(chatModels)]
.filter((model) => !pinnedNames.has(model))
.toSorted((a, b) => a.localeCompare(b))
.map((model) => ({ label: model, value: model }));
return [...pinned, ...rest];
}, [costMap]);
};
const DURATION_OPTIONS = [
{ value: "1", label: "1 day" },
{ value: "3", label: "3 days" },
{ value: "7", label: "7 days" },
{ value: "14", label: "14 days" },
{ value: "30", label: "30 days" },
] as const;
const Field: React.FC<{ label: string; htmlFor?: string; className?: string; children: React.ReactNode }> = ({
label,
htmlFor,
className,
children,
}) => (
<div className={`space-y-1.5 ${className ?? ""}`}>
<Label htmlFor={htmlFor} className="text-xs">
{label}
</Label>
{children}
</div>
);
const KeySelect: React.FC<{ value: string; onChange: (token: string) => void }> = ({ value, onChange }) => {
const [search, setSearch] = useState("");
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteKeys(50, {
selectedKeyAlias: search || null,
});
const options = useMemo<SearchSelectOption[]>(
() =>
(data?.pages ?? [])
.flatMap((page) => page.keys)
.map((key) => ({
label: key.key_alias || key.key_name || key.token,
value: key.token,
sublabel: key.token,
})),
[data],
);
return (
<PaginatedSearchSelect
inputId="shadow-eval-key"
options={options}
value={value}
onValueChange={onChange}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isPending}
placeholder="Search keys by alias"
emptyText="No matching keys"
errorText={isError ? "Keys could not be loaded. Refresh the page to retry." : undefined}
/>
);
};
const StartForm: React.FC = () => {
const { accessToken } = useAuthorized();
const [apiKeyId, setApiKeyId] = useState("");
const [routerName, setRouterName] = useState("");
const [percentage, setPercentage] = useState("10");
const [durationDays, setDurationDays] = useState("7");
const [judgeModel, setJudgeModel] = useState("");
const [maxTurns, setMaxTurns] = useState("200");
const { data: autoRouters } = useAutoRouters();
const judgeModelOptions = useJudgeModelOptions();
const start = useStartShadowEval();
const routerOptions = useMemo<SearchSelectOption[]>(() => {
const names = new Set(
(autoRouters ?? []).map((deployment) => deployment.model_name).filter((name): name is string => Boolean(name)),
);
return [...names].toSorted().map((name) => ({ label: name, value: name }));
}, [autoRouters]);
const parsedPct = Number.parseFloat(percentage);
const percentageValid = parsedPct >= 0.1 && parsedPct <= 100;
const parsedMaxTurns = Number.parseInt(maxTurns, 10);
const maxTurnsValid = parsedMaxTurns >= 1 && parsedMaxTurns <= 2000;
const filled = [apiKeyId, routerName, judgeModel].every((field) => field !== "");
const boundsValid = percentageValid && maxTurnsValid;
const valid = Boolean(accessToken) && filled && boundsValid;
const handleStart = () => {
const startBody = {
api_key_id: apiKeyId,
router_name: routerName,
shadow_percentage: parsedPct,
duration_days: Number.parseInt(durationDays, 10),
max_turns: parsedMaxTurns,
judge_model: judgeModel,
};
start.mutate(startBody);
};
return (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm font-medium text-foreground">Start a shadow eval</CardTitle>
<p className="text-xs text-muted-foreground">
Duplicates a sampled slice of the key&apos;s traffic through the auto-router and has an LLM judge compare both
answers blind. The router&apos;s answers are never served to users; judge calls bill to the shadowed key.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Key to shadow" htmlFor="shadow-eval-key">
<KeySelect value={apiKeyId} onChange={setApiKeyId} />
</Field>
<Field label="Auto-router">
<SearchSelect
options={routerOptions}
value={routerName}
onValueChange={setRouterName}
placeholder="Select an auto-router"
emptyText="No auto-routers configured"
/>
</Field>
<Field label="Traffic sampled" htmlFor="shadow-eval-pct">
<div className="flex items-center gap-2">
<Input
id="shadow-eval-pct"
type="number"
min={0.1}
max={100}
step={0.1}
className="w-24"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
/>
<span className="text-sm text-muted-foreground">% of traffic</span>
</div>
<div>
{percentage.trim() !== "" && !percentageValid && (
<p className="text-xs text-destructive">Enter a value from 0.1 to 100</p>
)}
</div>
</Field>
<Field label="Duration">
<Select value={durationDays} onValueChange={(v: string | null) => setDurationDays(v ?? "7")}>
<SelectTrigger className="w-full">
<SelectValue>{DURATION_OPTIONS.find((o) => o.value === durationDays)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Turn budget">
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={2000}
className="w-24"
value={maxTurns}
onChange={(e) => setMaxTurns(e.target.value)}
/>
<span className="text-sm text-muted-foreground">turns judged, max</span>
</div>
{maxTurns.trim() !== "" && !maxTurnsValid && (
<p className="text-xs text-destructive">Enter a value from 1 to 2000</p>
)}
</Field>
<Field label="Judge model" className="sm:col-span-2">
<SearchSelect
options={judgeModelOptions}
value={judgeModel}
onValueChange={setJudgeModel}
placeholder="Select a judge model"
emptyText="No chat models available"
/>
</Field>
</div>
<Button disabled={!valid || start.isPending} onClick={handleStart}>
{start.isPending ? "Starting..." : "Start shadow eval"}
</Button>
</CardContent>
</Card>
);
};
const previousSummary = (job: ShadowEvalJob): string => {
const results = job.results;
if (results) return pct(results.overall_shadow_win_rate_pct + results.overall_tie_rate_pct);
return job.judged_count === 0 ? "no verdicts" : "view results";
};
const PreviousJob: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
const [expanded, setExpanded] = useState(false);
const { data: detail, isError } = useShadowEvalJob(expanded ? job.job_id : null);
const shown = detail ?? job;
return (
<div className="border-b last:border-b-0">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
className="flex w-full flex-wrap items-center justify-between gap-3 px-6 py-3 text-left hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<StatusBadge status={shown.status} />
<div>
<p className="text-sm font-medium text-foreground">
{shown.shadow_percentage}% via <span className="font-mono text-xs">{shown.router_name}</span>
</p>
<p className="text-xs text-muted-foreground">
{shown.judged_count != null &&
`${shown.judged_count.toLocaleString()} judged · ${(shown.error_count ?? 0).toLocaleString()} errored · ${usd(shown.judge_spend ?? 0)} judge spend · `}
{new Date(shown.created_at).toLocaleDateString()}
</p>
</div>
</div>
<span className="text-sm font-medium text-foreground">{previousSummary(shown)}</span>
</button>
{expanded && (
<div className="border-t">
<ResultsBody job={shown} resultsError={isError} />
</div>
)}
</div>
);
};
const PreviousJobs: React.FC<{ jobs: readonly ShadowEvalJob[] }> = ({ jobs }) => {
const [open, setOpen] = useState(false);
if (jobs.length === 0) return null;
return (
<Card className="overflow-hidden py-0">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
className="flex w-full items-center justify-between gap-3 px-6 py-3 text-left hover:bg-muted/50"
>
<span className="text-sm font-medium text-foreground">Previous evaluations ({jobs.length})</span>
<span className="text-xs text-muted-foreground">{open ? "Hide" : "Show"}</span>
</button>
{open && (
<div className="border-t">
{jobs.map((job) => (
<PreviousJob key={job.job_id} job={job} />
))}
</div>
)}
</Card>
);
};
const JobCard: React.FC<{ job: ShadowEvalJob; readOnly: boolean }> = ({ job, readOnly }) => {
const { data: detail, isError } = useShadowEvalJob(job.job_id);
const stop = useStopShadowEval();
const shown = detail ?? job;
return (
<JobResults
job={shown}
onStop={() => stop.mutate(shown.job_id)}
stopPending={stop.isPending}
resultsError={isError}
readOnly={readOnly}
/>
);
};
const ShadowEvalSection: React.FC = () => {
const { data: jobs, error, isPending } = useShadowEvalJobs();
const { isViewOnly } = useAuthorized();
const { showcased, listed } = useMemo(() => {
const active = (jobs ?? []).filter(isActive);
const finished = (jobs ?? []).filter((job) => !isActive(job));
const shown = active.length > 0 ? active : finished.slice(0, 1);
return { showcased: shown, listed: finished.filter((job) => !shown.includes(job)) };
}, [jobs]);
if (error instanceof ApiError && error.status === 403) return null;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="text-xl font-semibold text-foreground">Shadow eval</h2>
<p className="text-sm text-muted-foreground">
Would the auto-router have answered as well as the models you use today? Find out on your real traffic, before
switching anything.
</p>
</div>
{error != null && (
<p className="text-sm text-destructive">Existing evaluations could not be loaded. Refresh the page to retry.</p>
)}
{isPending && error == null && <p className="text-sm text-muted-foreground">Loading evaluations...</p>}
{showcased.map((job) => (
<JobCard key={job.job_id} job={job} readOnly={isViewOnly} />
))}
{!isViewOnly && <StartForm />}
<PreviousJobs jobs={listed} />
</div>
);
};
export default ShadowEvalSection;

View file

@ -0,0 +1,24 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/http/api", () => ({ $api: { useQuery: vi.fn() }, fetchClient: { POST: vi.fn() } }));
vi.mock("@/components/molecules/notifications_manager", () => ({ default: { fromBackend: vi.fn() } }));
import { shadowEvalListPollMs, shadowEvalPollMs } from "./useShadowEval";
describe("shadowEvalPollMs", () => {
it("keeps polling while the job is active or its status is not yet known", () => {
expect(shadowEvalPollMs("running")).toBe(15_000);
expect(shadowEvalPollMs(undefined)).toBe(15_000);
expect(shadowEvalPollMs("completed")).toBe(false);
expect(shadowEvalPollMs("stopped")).toBe(false);
});
});
describe("shadowEvalListPollMs", () => {
it("polls the list while any job is running, so finished jobs migrate to previous", () => {
expect(shadowEvalListPollMs([{ status: "running" } as never, { status: "stopped" } as never])).toBe(15_000);
expect(shadowEvalListPollMs([{ status: "completed" } as never])).toBe(false);
expect(shadowEvalListPollMs([])).toBe(false);
expect(shadowEvalListPollMs(undefined)).toBe(false);
});
});

View file

@ -0,0 +1,79 @@
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { $api, fetchClient } from "@/lib/http/api";
import type { components } from "@/lib/http/schema";
export type ShadowEvalJob = components["schemas"]["ShadowEvalJobResponse"];
export type ShadowEvalSlice = components["schemas"]["ShadowEvalSlice"];
export type StartShadowEvalRequest = components["schemas"]["StartShadowEvalRequest"];
const LIST_PATH = "/auto_router/shadow_eval" as const;
const DETAIL_PATH = "/auto_router/shadow_eval/{job_id}" as const;
const ACTIVE_POLL_MS = 15_000;
export const shadowEvalPollMs = (status: ShadowEvalJob["status"] | undefined): number | false =>
status === "running" || status === undefined ? ACTIVE_POLL_MS : false;
export const shadowEvalListPollMs = (jobs: ShadowEvalJob[] | undefined): number | false =>
jobs?.some((job) => job.status === "running") ? ACTIVE_POLL_MS : false;
const invalidateShadowEval = (queryClient: QueryClient) =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["get", LIST_PATH] }),
queryClient.invalidateQueries({ queryKey: ["get", DETAIL_PATH] }),
]);
export const useShadowEvalJobs = () => {
const { accessToken } = useAuthorized();
return $api.useQuery(
"get",
LIST_PATH,
{},
{
enabled: Boolean(accessToken),
retry: 1,
refetchInterval: (query) => shadowEvalListPollMs(query.state.data),
},
);
};
export const useShadowEvalJob = (jobId: string | null) => {
const { accessToken } = useAuthorized();
return $api.useQuery(
"get",
DETAIL_PATH,
{ params: { path: { job_id: jobId ?? "" } } },
{
enabled: Boolean(accessToken) && Boolean(jobId),
retry: 1,
refetchInterval: (query) => shadowEvalPollMs(query.state.data?.status),
},
);
};
const useShadowEvalMutation = <TVariables>(mutationFn: (variables: TVariables) => Promise<unknown>) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: () => invalidateShadowEval(queryClient),
onError: (error: unknown) => NotificationsManager.fromBackend(error),
});
};
export const useStartShadowEval = () =>
useShadowEvalMutation(async (body: StartShadowEvalRequest) => {
const { data } = await fetchClient.POST("/auto_router/shadow_eval/start", { body });
return data;
});
export const useStopShadowEval = () =>
useShadowEvalMutation(async (jobId: string) => {
const { data } = await fetchClient.POST("/auto_router/shadow_eval/{job_id}/stop", {
params: { path: { job_id: jobId } },
});
return data;
});

View file

@ -1,4 +1,11 @@
import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
UseQueryResult,
type InfiniteData,
type QueryKey,
} from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
@ -113,6 +120,27 @@ export const useKeys = (
});
};
const infiniteKeyKeys = createQueryKeys("infiniteKeys");
export const useInfiniteKeys = (pageSize: number, options: KeyListCallOptions = {}) => {
const { accessToken } = useAuthorized();
const infiniteKeyListOptions = {
queryKey: infiniteKeyKeys.list({ limit: pageSize, ...options }),
queryFn: async ({ pageParam }: { pageParam: number }) => {
if (!accessToken) throw new Error("Access token required");
return await keyListCall(accessToken, pageParam, pageSize, options);
},
initialPageParam: 1,
getNextPageParam: (lastPage: KeysResponse) =>
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
enabled: Boolean(accessToken),
staleTime: 30_000,
};
return useInfiniteQuery<KeysResponse, Error, InfiniteData<KeysResponse>, QueryKey, number>(infiniteKeyListOptions);
};
export const deletedKeyKeys = createQueryKeys("deletedKeys");
export const useDeletedKeys = (
page: number,

View file

@ -31,6 +31,7 @@ interface PaginatedSearchSelectProps {
isFetchingNextPage?: boolean;
placeholder?: string;
emptyText?: string;
errorText?: string;
loadingText?: string;
disabled?: boolean;
className?: string;
@ -50,6 +51,7 @@ export function PaginatedSearchSelect({
isFetchingNextPage = false,
placeholder = "Search…",
emptyText = "No results",
errorText,
loadingText = "Loading…",
disabled = false,
className,
@ -104,7 +106,9 @@ export function PaginatedSearchSelect({
className={`w-full ${className ?? ""}`}
/>
<ComboboxContent>
<ComboboxEmpty>{isLoading ? loadingText : emptyText}</ComboboxEmpty>
<ComboboxEmpty className={errorText == null ? undefined : "text-destructive"}>
{errorText ?? (isLoading ? loadingText : emptyText)}
</ComboboxEmpty>
<ComboboxList onScroll={handleScroll} data-testid="paginated-search-select-list">
{(item: SearchSelectOption) => (
<ComboboxItem key={item.value} value={item}>