diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index c4f078f2ff2..bbf69c4a77a 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -3,6 +3,6 @@ "no-console": { "max": 12, "target": 0 }, "complexity": { "max": 140, "target": 80 }, "max-depth": { "max": 70, "target": 30 }, - "local/no-large-inline-object-arg": { "max": 560, "target": 300 }, + "local/no-large-inline-object-arg": { "max": 559, "target": 300 }, "local/no-long-condition-chain": { "max": 265, "target": 120 } } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index 56eb3b93244..df23e5509bf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -140,20 +140,19 @@ describe("UsageTab", () => { // Total caching and the LiteLLM-injected share deliberately differ so these // assertions pin which one each figure uses: the caching headline and the // Total-saved tile take the injected share, the secondary keeps the total. - const { getByText } = renderWith([ - day("2026-07-12", { - compression_savings_spend: 0.04, - prompt_caching_savings_spend: 0.006, - gateway_injected_caching_savings_spend: 0.004, - compression_saved_tokens: 40000, - }), - day("2026-07-13", { - compression_savings_spend: 0.1, - prompt_caching_savings_spend: 0.01, - gateway_injected_caching_savings_spend: 0.006, - compression_saved_tokens: 100000, - }), - ]); + const firstDay: Partial = { + compression_savings_spend: 0.04, + prompt_caching_savings_spend: 0.006, + gateway_injected_caching_savings_spend: 0.004, + compression_saved_tokens: 40000, + }; + const secondDay: Partial = { + compression_savings_spend: 0.1, + prompt_caching_savings_spend: 0.01, + gateway_injected_caching_savings_spend: 0.006, + compression_saved_tokens: 100000, + }; + const { getByText } = renderWith([day("2026-07-12", firstDay), day("2026-07-13", secondDay)]); expect(getByText("$0.1500")).toBeInTheDocument(); expect(getByText("$0.1400")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 17cc1586029..83b202590cb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -9,11 +9,8 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import useCan from "@/app/(dashboard)/hooks/useCan"; import { getToolSpend, ToolSpendResponse } from "@/components/networking"; import { - autorouterOf, buildDailyToolSeries, - compressionOf, formatRangeLabel, - gatewayAttributedCachingOf, localIsoDay, MAX_POINTS_WITH_DOTS, SAVINGS_COLORS, @@ -21,13 +18,15 @@ import { SAVINGS_SERIES, SavingsAccumulation, SavingsPoint, + savingsSeriesOf, shortDate, + sumOverDays, toCumulative, topToolsBySpend, usd, withStartAnchor, } from "./costOptimizationUtils"; -import SavingsTiles, { useSavingsTotals } from "@/components/shared/SavingsTiles"; +import SavingsTiles from "@/components/shared/SavingsTiles"; import { DailyActivityRange } from "./useDailyActivityRange"; interface UsageTabProps { @@ -73,26 +72,9 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null; const toolSpendLoading = toolSpendEnabled && toolSpend === null; - const totals = useSavingsTotals(results); - const [accumulation, setAccumulation] = useState("cumulative"); - // The daily rollup arrives newest first; sort on the raw ISO date so the axis - // reads oldest to newest and the running total accumulates forward in time - // rather than backward. Sort here, before shortDate() drops the year and makes - // the labels unsortable. - const perInterval = useMemo( - () => - [...results] - .sort((a, b) => a.date.localeCompare(b.date)) - .map((d) => ({ - date: shortDate(d.date), - Compression: compressionOf(d.metrics), - "Prompt caching": gatewayAttributedCachingOf(d.metrics), - "Auto-router": autorouterOf(d.metrics), - })), - [results], - ); + const perInterval = useMemo(() => savingsSeriesOf(results), [results]); // Cumulative anchors on a synthetic $0 point at the range start so a short // range (down to a single day) rises from zero instead of floating as one dot. @@ -116,16 +98,12 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { // that actually saved are plotted; the range total keeps the signed truth. const byDriver = useMemo( () => - SAVINGS_DRIVERS.map(({ name, color }) => ({ + SAVINGS_DRIVERS.map(({ name, color, of }) => ({ driver: name, color, - usd: { - Compression: totals.compression, - "Prompt caching": totals.gatewayAttributedCaching, - "Auto-router": totals.autorouter, - }[name], + usd: sumOverDays(results, of), })).filter((d) => d.usd > 0), - [totals], + [results], ); const plottedDriverTotal = useMemo(() => byDriver.reduce((sum, d) => sum + d.usd, 0), [byDriver]); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts index 0f6339f3f55..9a2d7a0b0ec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts @@ -11,6 +11,7 @@ import { formatRangeLabel, isAnthropicModel, localIsoDay, + savingsSeriesOf, toCumulative, topToolsBySpend, usd, @@ -66,6 +67,28 @@ const modelDay = (date: string, models: Record>): }, }); +describe("savingsSeriesOf", () => { + it("plots the LiteLLM-injected caching share, sorted oldest first", () => { + // Total and injected caching deliberately differ: every chart derives from + // SAVINGS_DRIVERS, so the caching series must follow the injected figure. + const sharedSavings: Partial = { + compression_savings_spend: 0.1, + prompt_caching_savings_spend: 0.5, + autorouter_savings_spend: 0.05, + }; + const newestFirst = [day("2026-07-02", {}), day("2026-07-01", {})].map((d, i) => ({ + ...d, + metrics: metrics({ ...sharedSavings, gateway_injected_caching_savings_spend: i === 0 ? 0.2 : 0.3 }), + })); + + const series = savingsSeriesOf(newestFirst); + + expect(series.map((p) => p.date)).toEqual(["Jul 1", "Jul 2"]); + expect(series[0]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.3, "Auto-router": 0.05 }); + expect(series[1]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.2, "Auto-router": 0.05 }); + }); +}); + describe("computeCacheLeakage", () => { it("aggregates a key's tokens and savings across multiple days", () => { const results = [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts index ba40163a416..7019b0d3301 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts @@ -193,14 +193,38 @@ export type SavingsPoint = { * mapping. Colour travels with the driver so filtering cannot separate them. */ export const SAVINGS_DRIVERS = [ - { name: "Compression", color: "emerald" }, - { name: "Prompt caching", color: "blue" }, - { name: "Auto-router", color: "amber" }, + { name: "Compression", color: "emerald", of: compressionOf }, + { name: "Prompt caching", color: "blue", of: gatewayAttributedCachingOf }, + { name: "Auto-router", color: "amber", of: autorouterOf }, ] as const; export const SAVINGS_SERIES = SAVINGS_DRIVERS.map((d) => d.name); export const SAVINGS_COLORS = SAVINGS_DRIVERS.map((d) => d.color); +type SavingsDriverName = (typeof SAVINGS_DRIVERS)[number]["name"]; + +export const sumOverDays = (results: readonly DailyData[], of: (m: SpendMetrics) => number): number => + results.reduce((sum, d) => sum + of(d.metrics), 0); + +/** + * One point per day, each driver plotting the metric its SAVINGS_DRIVERS entry + * names. The rollup arrives newest first, so sort on the raw ISO date before + * shortDate() drops the year and makes the labels unsortable; the running total + * then accumulates forward in time. Deriving every chart's series and every + * total from the same driver list is what keeps a tile, a timeline and the + * donut from quietly plotting different metrics for the same driver name. + */ +export const savingsSeriesOf = (results: readonly DailyData[]): SavingsPoint[] => + [...results] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((d) => ({ + date: shortDate(d.date), + ...(Object.fromEntries(SAVINGS_DRIVERS.map(({ name, of }) => [name, of(d.metrics)])) as Record< + SavingsDriverName, + number + >), // fromEntries widens keys to string; the entries are exactly the driver names + })); + /** * Running total of each series across the selected window. The total restarts * at the beginning of the range rather than carrying in earlier spend, which is diff --git a/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx b/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx index 2a137c36a88..9b4f1b68bf2 100644 --- a/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx +++ b/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx @@ -6,32 +6,30 @@ import SummaryCard from "@/components/shared/SummaryCard"; import { autorouterOf, cachingOf, - gatewayAttributedCachingOf, compressionOf, + gatewayAttributedCachingOf, + SAVINGS_DRIVERS, savedTokensOf, + sumOverDays, usd, } from "@/app/(dashboard)/cost-optimization/_components/costOptimizationUtils"; import { DailyData } from "@/components/UsagePage/types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -// Exported because the by-driver donut has to slice the same numbers the tiles print, and two -// totalling paths over the same rows is how a chart and the tile above it end up disagreeing. -export const useSavingsTotals = (results: DailyData[]) => - useMemo(() => { - const sumOf = (of: (metrics: DailyData["metrics"]) => number) => results.reduce((sum, d) => sum + of(d.metrics), 0); - const compression = sumOf(compressionOf); - const caching = sumOf(cachingOf); - const autorouter = sumOf(autorouterOf); - const gatewayAttributedCaching = sumOf(gatewayAttributedCachingOf); - return { - compression, - caching, - autorouter, - gatewayAttributedCaching, - savedTokens: sumOf(savedTokensOf), - total: compression + gatewayAttributedCaching + autorouter, - }; - }, [results]); +// The total sums SAVINGS_DRIVERS, so it is by construction the sum of what the +// charts plot; the donut and timelines derive from the same list in costOptimizationUtils. +const useSavingsTotals = (results: DailyData[]) => + useMemo( + () => ({ + compression: sumOverDays(results, compressionOf), + caching: sumOverDays(results, cachingOf), + autorouter: sumOverDays(results, autorouterOf), + gatewayAttributedCaching: sumOverDays(results, gatewayAttributedCachingOf), + savedTokens: sumOverDays(results, savedTokensOf), + total: SAVINGS_DRIVERS.reduce((sum, { of }) => sum + sumOverDays(results, of), 0), + }), + [results], + ); const SavingsTiles = ({ results, isLoading }: { results: DailyData[]; isLoading: boolean }) => { const totals = useSavingsTotals(results); diff --git a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx index d499c74a535..967c3c7eff0 100644 --- a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx @@ -9,9 +9,6 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { hasProxyWideSpendView, spendScopeUserId } from "@/utils/roles"; import { - autorouterOf, - cachingOf, - compressionOf, formatRangeLabel, localIsoDay, MAX_POINTS_WITH_DOTS, @@ -19,6 +16,7 @@ import { SAVINGS_SERIES, SavingsAccumulation, SavingsPoint, + savingsSeriesOf, shortDate, toCumulative, usd, @@ -50,20 +48,7 @@ const KeySavingsTab: React.FC = ({ accessToken, keyToken, us const [accumulation, setAccumulation] = useState("cumulative"); - // Sort on the raw ISO date before shortDate() drops the year: the rollup arrives newest - // first, and the running total has to accumulate forward in time. - const perInterval = useMemo( - () => - [...results] - .sort((a, b) => a.date.localeCompare(b.date)) - .map((d) => ({ - date: shortDate(d.date), - Compression: compressionOf(d.metrics), - "Prompt caching": cachingOf(d.metrics), - "Auto-router": autorouterOf(d.metrics), - })), - [results], - ); + const perInterval = useMemo(() => savingsSeriesOf(results), [results]); const overTime = useMemo(() => { if (accumulation !== "cumulative") return perInterval;