refactor(ui): derive every savings chart and total from the driver list

Four call sites each named the metric their caching series plots, and they
drifted: the tiles and donut moved to the injected share while the key page's
timeline kept the total. SAVINGS_DRIVERS now carries each driver's accessor,
savingsSeriesOf builds the one series both timelines render, the donut and the
Total saved tile sum the same list, so a driver's source of truth cannot vary
by surface. Also ratchets the inline-object budget the extracted fixtures freed
This commit is contained in:
Tin Chi Lo 2026-08-27 20:31:46 -07:00
parent 5851327a6e
commit 94d6ff942a
7 changed files with 90 additions and 83 deletions

View file

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

View file

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

View file

@ -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<UsageTabProps> = ({ accessToken, activity }) => {
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
const totals = useSavingsTotals(results);
const [accumulation, setAccumulation] = useState<SavingsAccumulation>("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<SavingsPoint[]>(
() =>
[...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<SavingsPoint[]>(() => 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<UsageTabProps> = ({ 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]);

View file

@ -11,6 +11,7 @@ import {
formatRangeLabel,
isAnthropicModel,
localIsoDay,
savingsSeriesOf,
toCumulative,
topToolsBySpend,
usd,
@ -66,6 +67,28 @@ const modelDay = (date: string, models: Record<string, Partial<SpendMetrics>>):
},
});
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<SpendMetrics> = {
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 = [

View file

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

View file

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

View file

@ -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<KeySavingsTabProps> = ({ accessToken, keyToken, us
const [accumulation, setAccumulation] = useState<SavingsAccumulation>("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<SavingsPoint[]>(
() =>
[...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<SavingsPoint[]>(() => savingsSeriesOf(results), [results]);
const overTime = useMemo(() => {
if (accumulation !== "cumulative") return perInterval;