This commit is contained in:
손세정 2026-09-12 16:26:22 +09:00 committed by GitHub
commit 33f90a8b4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 112 additions and 31 deletions

View file

@ -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<SpendMetrics> = {
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/);

View file

@ -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<UsageTabProps> = ({ accessToken, activity }) => {
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
const [accumulation, setAccumulation] = useState<SavingsAccumulation>("cumulative");
const [cachingScope, setCachingScope] = useState<CachingSavingsScope>("litellm-injected");
const savingsDrivers = useMemo(() => savingsDriversFor(cachingScope), [cachingScope]);
const perInterval = useMemo<SavingsPoint[]>(() => savingsSeriesOf(results), [results]);
const perInterval = useMemo<SavingsPoint[]>(() => 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<UsageTabProps> = ({ 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<UsageTabProps> = ({ accessToken, activity }) => {
return (
<div className="w-full space-y-6">
<div className="flex flex-wrap items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">Prompt caching scope</span>
<Tabs value={cachingScope} onValueChange={(value) => setCachingScope(value as CachingSavingsScope)}>
<TabsList aria-label="Prompt caching savings scope">
<TabsTrigger value="litellm-injected">LiteLLM injected</TabsTrigger>
<TabsTrigger value="all">All caching</TabsTrigger>
</TabsList>
</Tabs>
<span className="text-sm text-muted-foreground">Spend is bucketed by UTC day</span>
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
<SavingsTiles results={results} isLoading={loading || isFetchingMore} />
<SavingsTiles results={results} isLoading={loading || isFetchingMore} cachingScope={cachingScope} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
@ -144,7 +156,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
<CardAction className="flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<CustomLegend categories={SAVINGS_SERIES} colors={SAVINGS_COLORS} />
<Tabs value={accumulation} onValueChange={(value) => setAccumulation(value as SavingsAccumulation)}>
<TabsList>
<TabsList aria-label="Savings accumulation">
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>

View file

@ -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", () => {

View file

@ -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

View file

@ -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 (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
@ -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}
/>
<SummaryCard
label="Compression savings"
@ -50,10 +64,13 @@ const SavingsTiles = ({ results, isLoading }: { results: DailyData[]; isLoading:
/>
<SummaryCard
label="Prompt caching savings"
value={usd(totals.gatewayAttributedCaching)}
hint="LiteLLM injected"
secondary={{ label: "Total", value: usd(totals.caching) }}
info="What caching saved against paying the input rate for every token: the discount on tokens served from cache, less the premium providers charge to write a cache entry. The headline figure is the share LiteLLM earned by inserting the breakpoints itself, through configured injection points or auto prompt caching. The total beside it also counts requests that arrived with their own cache_control and providers that cache implicitly. Either can be negative on traffic that writes more cache than it reuses, which is why the headline is not always the smaller of the two."
value={usd(showAllCaching ? totals.caching : totals.gatewayAttributedCaching)}
hint={showAllCaching ? "All caching" : "LiteLLM injected"}
secondary={{
label: showAllCaching ? "LiteLLM injected" : "Total",
value: usd(showAllCaching ? totals.gatewayAttributedCaching : totals.caching),
}}
info={promptCachingInfo}
/>
<SummaryCard
label="Auto-router savings"