diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx index 4a6ff77d99b..482901dfd14 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx @@ -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: () =>
})); 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx index 5d4fda765e7..80ddef29c9d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx @@ -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 = ({ accessToken }) => { +const UsageView: React.FC = ({ accessToken }) => { const [range, setRange] = useState("30d"); const { data, isPending, error } = useAutoRouterBenchmarks(accessToken, range); const [selectedKey, setSelectedKey] = useState(ALL_ROUTERS); @@ -321,4 +322,36 @@ const AutoRouterBenchmarksTab: React.FC = ({ acces ); }; +const AutoRouterBenchmarksTab: React.FC = ({ accessToken }) => { + const [visitedTabs, setVisitedTabs] = useState(["usage"]); + + const handleTabChange = (value: unknown) => { + if (typeof value !== "string") { + return; + } + + setVisitedTabs((currentTabs) => (currentTabs.includes(value) ? currentTabs : [...currentTabs, value])); + }; + + return ( + + + + Usage + + + Shadow Evals + + + + + + + + + + + ); +}; + export default AutoRouterBenchmarksTab; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx new file mode 100644 index 00000000000..467439122dd --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx @@ -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 => ({ + 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; + error?: Error | null; + detailError?: boolean; + isPending?: boolean; +}) => { + vi.mocked(useShadowEvalJobs).mockReturnValue({ + data: error || isPending ? undefined : jobs, + error, + isPending, + } as unknown as ReturnType); + vi.mocked(useShadowEvalJob).mockImplementation( + (jobId) => + ({ + data: jobId ? detailsById[jobId] : undefined, + isError: detailError ?? false, + }) as unknown as ReturnType, + ); + const start = { mutate: vi.fn(), isPending: false }; + const stop = { mutate: vi.fn(), isPending: false }; + vi.mocked(useStartShadowEval).mockReturnValue(start as unknown as ReturnType); + vi.mocked(useStopShadowEval).mockReturnValue(stop as unknown as ReturnType); + 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); + mockHooks({}); + render(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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 = { + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + 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(); + + 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 = { + 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(); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx new file mode 100644 index 00000000000..6bb00933218 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx @@ -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 = { + 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 }) => ( + + {status} + +); + +const SliceTable: React.FC<{ groupHeader: string; slices: readonly ShadowEvalSlice[] }> = ({ groupHeader, slices }) => ( + + + + {groupHeader} + {["Judged turns", "Router wins", "Current model wins", "Ties", "Judge confidence"].map((label) => ( + + {label} + + ))} + + + + {slices.map((slice) => ( + + + {slice.group} + {slice.turn_count < MIN_TURNS_FOR_CONFIDENCE && ( + (low sample) + )} + + {slice.turn_count.toLocaleString()} + + {pct(slice.shadow_win_rate_pct)} + + {pct(slice.real_win_rate_pct)} + {pct(slice.tie_rate_pct)} + {slice.avg_judge_confidence.toFixed(2)} + + ))} + +
+); + +const VerdictBar: React.FC<{ results: NonNullable }> = ({ 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 ( +
+
+ {segments + .filter((segment) => segment.value > 0) + .map((segment) => ( +
+ ))} +
+
+ {segments.map((segment) => ( + + + {segment.label} {pct(segment.value)} + + ))} +
+
+ ); +}; + +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

{emptyResultsText(job, resultsError)}

; + } + return ( + <> +
+

+ Router matched or beat your current model +

+

+ {pct(results.overall_shadow_win_rate_pct + results.overall_tie_rate_pct)} +

+

of {(job.judged_count ?? 0).toLocaleString()} judged responses

+
+ + {results.by_current_model.length > 0 && ( + + )} + {results.by_tier.length > 0 && ( +
0 ? "border-t" : ""}> + +
+ )} + + ); +}; + +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 ( + +
+
+ +
+

+ Shadowing {job.shadow_percentage}% via {job.router_name} +

+

+ {(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}` : ""} +

+
+
+ {active && !readOnly && ( + + )} +
+ {(job.error_count ?? 0) > 0 && job.last_error != null && ( +

+ Last failure: {job.last_error} +

+ )} + +
+ ); +}; + +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(RECOMMENDED_JUDGE_MODELS); + const chatModels = Object.entries(costMap as Record) + .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, +}) => ( +
+ + {children} +
+); + +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( + () => + (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 ( + 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(() => { + 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 ( + + + Start a shadow eval +

+ Duplicates a sampled slice of the key's traffic through the auto-router and has an LLM judge compare both + answers blind. The router's answers are never served to users; judge calls bill to the shadowed key. +

+
+ +
+ + + + + + + +
+ setPercentage(e.target.value)} + /> + % of traffic +
+
+ {percentage.trim() !== "" && !percentageValid && ( +

Enter a value from 0.1 to 100

+ )} +
+
+ + + + +
+ setMaxTurns(e.target.value)} + /> + turns judged, max +
+ {maxTurns.trim() !== "" && !maxTurnsValid && ( +

Enter a value from 1 to 2000

+ )} +
+ + + +
+ +
+
+ ); +}; + +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 ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +}; + +const PreviousJobs: React.FC<{ jobs: readonly ShadowEvalJob[] }> = ({ jobs }) => { + const [open, setOpen] = useState(false); + if (jobs.length === 0) return null; + return ( + + + {open && ( +
+ {jobs.map((job) => ( + + ))} +
+ )} +
+ ); +}; + +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 ( + 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 ( +
+
+

Shadow eval

+

+ Would the auto-router have answered as well as the models you use today? Find out on your real traffic, before + switching anything. +

+
+ + {error != null && ( +

Existing evaluations could not be loaded. Refresh the page to retry.

+ )} + + {isPending && error == null &&

Loading evaluations...

} + + {showcased.map((job) => ( + + ))} + + {!isViewOnly && } + + +
+ ); +}; + +export default ShadowEvalSection; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.test.ts new file mode 100644 index 00000000000..13b24bc00fc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.ts new file mode 100644 index 00000000000..027003df46f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.ts @@ -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 = (mutationFn: (variables: TVariables) => Promise) => { + 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; + }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 198058803eb..94ded01679d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -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, QueryKey, number>(infiniteKeyListOptions); +}; + export const deletedKeyKeys = createQueryKeys("deletedKeys"); export const useDeletedKeys = ( page: number, diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx index fde29fd5362..47dc429bcc5 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx @@ -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 ?? ""}`} /> - {isLoading ? loadingText : emptyText} + + {errorText ?? (isLoading ? loadingText : emptyText)} + {(item: SearchSelectOption) => (