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 f85a667a074..0a134092e08 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 @@ -179,6 +179,30 @@ describe("UsageTab", () => { expect(series[2]["Prompt caching"]).toBeCloseTo(0.016, 5); }); + it("switches cards and charts from LiteLLM-injected to all caching savings", async () => { + const cachingMetrics: Partial = { + compression_savings_spend: 0.1, + prompt_caching_savings_spend: 0.5, + gateway_injected_caching_savings_spend: 0.2, + autorouter_savings_spend: 0.05, + }; + renderWith([day("2026-07-12", cachingMetrics)]); + + expect(readSeries(screen.getByTestId("area-chart")).at(-1)["Prompt caching"]).toBe(0.2); + expect(screen.getByTestId("summary-card-total-saved")).toHaveTextContent("$0.3500"); + + await userEvent.click(screen.getByRole("tab", { name: "All caching" })); + + expect(readSeries(screen.getByTestId("area-chart")).at(-1)["Prompt caching"]).toBe(0.5); + expect(screen.getByTestId("summary-card-total-saved")).toHaveTextContent("$0.6500"); + expect(screen.getByTestId("summary-card-prompt-caching-savings")).toHaveTextContent("All caching"); + expect(JSON.parse(screen.getByTestId("donut-chart").getAttribute("data-slices") ?? "[]")).toContainEqual({ + driver: "Prompt caching", + color: "blue", + usd: 0.5, + }); + }); + it("rises from $0 to the day's cumulative total for a single-day range", () => { // The original complaint: a one-day range plotted a single floating dot. The // synthetic start anchor gives the line a zero origin to climb from. @@ -295,7 +319,7 @@ describe("UsageTab", () => { expect(before.action).toBeTruthy(); expect(before.description).toBeTruthy(); // the toggle rides in the same action slot as the legend, so neither moves alone - expect(before.action.contains(screen.getByRole("tablist"))).toBe(true); + expect(before.action.contains(screen.getByRole("tablist", { name: "Savings accumulation" }))).toBe(true); // the subtitle lives outside that slot, so its length cannot reposition the controls expect(before.action.contains(before.description)).toBe(false); expect(before.description).toHaveTextContent(/Running total saved/); 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 83b202590cb..a793c862419 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 @@ -10,14 +10,15 @@ import useCan from "@/app/(dashboard)/hooks/useCan"; import { getToolSpend, ToolSpendResponse } from "@/components/networking"; import { buildDailyToolSeries, + CachingSavingsScope, formatRangeLabel, localIsoDay, MAX_POINTS_WITH_DOTS, SAVINGS_COLORS, - SAVINGS_DRIVERS, SAVINGS_SERIES, SavingsAccumulation, SavingsPoint, + savingsDriversFor, savingsSeriesOf, shortDate, sumOverDays, @@ -73,8 +74,10 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { const toolSpendLoading = toolSpendEnabled && toolSpend === null; const [accumulation, setAccumulation] = useState("cumulative"); + const [cachingScope, setCachingScope] = useState("litellm-injected"); + const savingsDrivers = useMemo(() => savingsDriversFor(cachingScope), [cachingScope]); - const perInterval = useMemo(() => savingsSeriesOf(results), [results]); + const perInterval = useMemo(() => savingsSeriesOf(results, cachingScope), [results, cachingScope]); // 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. @@ -98,12 +101,14 @@ 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, of }) => ({ - driver: name, - color, - usd: sumOverDays(results, of), - })).filter((d) => d.usd > 0), - [results], + savingsDrivers + .map(({ name, color, of }) => ({ + driver: name, + color, + usd: sumOverDays(results, of), + })) + .filter((d) => d.usd > 0), + [results, savingsDrivers], ); const plottedDriverTotal = useMemo(() => byDriver.reduce((sum, d) => sum + d.usd, 0), [byDriver]); @@ -126,11 +131,18 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { return (
+ Prompt caching scope + setCachingScope(value as CachingSavingsScope)}> + + LiteLLM injected + All caching + + Spend is bucketed by UTC day
- +
@@ -144,7 +156,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { setAccumulation(value as SavingsAccumulation)}> - + Cumulative {intervalLabel} 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 5d2c48e6440..b758f57cd29 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 @@ -88,6 +88,20 @@ describe("savingsSeriesOf", () => { 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 }); }); + + it("plots all provider and client caching savings when requested", () => { + const result = { + ...day("2026-07-01", {}), + metrics: metrics({ + prompt_caching_savings_spend: 0.5, + gateway_injected_caching_savings_spend: 0.2, + }), + }; + + const series = savingsSeriesOf([result], "all"); + + expect(series[0]["Prompt caching"]).toBe(0.5); + }); }); describe("computeCacheLeakage", () => { 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 464c779aa2b..c2543a5fa19 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 @@ -189,6 +189,8 @@ export type SavingsPoint = { "Auto-router": number; }; +export type CachingSavingsScope = "litellm-injected" | "all"; + /** * The savings drivers, each owning its own colour. * @@ -199,11 +201,18 @@ export type SavingsPoint = { * colours of the drivers above them, while the legend still reports the original * mapping. Colour travels with the driver so filtering cannot separate them. */ -export const SAVINGS_DRIVERS = [ - { name: "Compression", color: "emerald", of: compressionOf }, - { name: "Prompt caching", color: "blue", of: gatewayAttributedCachingOf }, - { name: "Auto-router", color: "amber", of: autorouterOf }, -] as const; +export const savingsDriversFor = (cachingScope: CachingSavingsScope) => + [ + { name: "Compression", color: "emerald", of: compressionOf }, + { + name: "Prompt caching", + color: "blue", + of: cachingScope === "all" ? cachingOf : gatewayAttributedCachingOf, + }, + { name: "Auto-router", color: "amber", of: autorouterOf }, + ] as const; + +export const SAVINGS_DRIVERS = savingsDriversFor("litellm-injected"); export const SAVINGS_SERIES = SAVINGS_DRIVERS.map((d) => d.name); export const SAVINGS_COLORS = SAVINGS_DRIVERS.map((d) => d.color); @@ -221,16 +230,21 @@ export const sumOverDays = (results: readonly DailyData[], of: (m: SpendMetrics) * 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] +export const savingsSeriesOf = ( + results: readonly DailyData[], + cachingScope: CachingSavingsScope = "litellm-injected", +): SavingsPoint[] => { + const savingsDrivers = savingsDriversFor(cachingScope); + return [...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< + ...(Object.fromEntries(savingsDrivers.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 diff --git a/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx b/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx index 9b4f1b68bf2..4edac521a99 100644 --- a/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx +++ b/ui/litellm-dashboard/src/components/shared/SavingsTiles.tsx @@ -5,20 +5,21 @@ import React, { useMemo } from "react"; import SummaryCard from "@/components/shared/SummaryCard"; import { autorouterOf, + CachingSavingsScope, cachingOf, compressionOf, gatewayAttributedCachingOf, - SAVINGS_DRIVERS, savedTokensOf, + savingsDriversFor, sumOverDays, usd, } from "@/app/(dashboard)/cost-optimization/_components/costOptimizationUtils"; import { DailyData } from "@/components/UsagePage/types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -// The total sums SAVINGS_DRIVERS, so it is by construction the sum of what the +// The total sums the selected 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[]) => +const useSavingsTotals = (results: DailyData[], cachingScope: CachingSavingsScope) => useMemo( () => ({ compression: sumOverDays(results, compressionOf), @@ -26,13 +27,26 @@ const useSavingsTotals = (results: DailyData[]) => autorouter: sumOverDays(results, autorouterOf), gatewayAttributedCaching: sumOverDays(results, gatewayAttributedCachingOf), savedTokens: sumOverDays(results, savedTokensOf), - total: SAVINGS_DRIVERS.reduce((sum, { of }) => sum + sumOverDays(results, of), 0), + total: savingsDriversFor(cachingScope).reduce((sum, { of }) => sum + sumOverDays(results, of), 0), }), - [results], + [results, cachingScope], ); -const SavingsTiles = ({ results, isLoading }: { results: DailyData[]; isLoading: boolean }) => { - const totals = useSavingsTotals(results); +interface SavingsTilesProps { + results: DailyData[]; + isLoading: boolean; + cachingScope?: CachingSavingsScope; +} + +const SavingsTiles = ({ results, isLoading, cachingScope = "litellm-injected" }: SavingsTilesProps) => { + const totals = useSavingsTotals(results, cachingScope); + const showAllCaching = cachingScope === "all"; + const totalSavedInfo = showAllCaching + ? "The sum of compression, all prompt caching, and auto-router savings. The caching term includes client-supplied cache controls and providers that cache implicitly." + : "The sum of compression, LiteLLM-injected prompt caching, and auto-router savings. Caching that clients or providers brought on their own appears only in the caching tile's Total figure."; + const promptCachingInfo = showAllCaching + ? "What all caching saved against paying the input rate for every token, including client-supplied cache controls and providers that cache implicitly. The secondary figure isolates the share LiteLLM earned by inserting cache breakpoints itself." + : "What LiteLLM-injected caching saved against paying the input rate for every token. The secondary Total also counts requests that arrived with their own cache_control and providers that cache implicitly."; return (
@@ -40,7 +54,7 @@ const SavingsTiles = ({ results, isLoading }: { results: DailyData[]; isLoading: label="Total saved" value={usd(totals.total)} hint={isLoading ? "Loading..." : "Compression + prompt caching + auto-router"} - info="The sum of the three tiles beside it. Its caching term is the LiteLLM-injected share, so this total is what the gateway itself delivered; caching that clients or providers brought on their own appears only in the caching tile's Total figure." + info={totalSavedInfo} />