Merge pull request #34453 from BerriAI/litellm_hourly_savings_timeseries

fix(cost-optimization): anchor the savings line at a $0 range start
This commit is contained in:
tin-berri 2026-07-25 16:15:52 -07:00 • committed by GitHub
commit 054976e197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 361 additions and 30 deletions

View file

@ -18,6 +18,7 @@ vi.mock("@/components/shared/charts", () => ({
AreaChart: () => <div />,
DonutChart: () => <div />,
BarChart: () => <div />,
CustomLegend: () => <div />,
DEFAULT_COLOR_CYCLE: ["emerald"],
}));

View file

@ -1,5 +1,6 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolSpendResponse } from "@/components/networking";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
@ -25,6 +26,9 @@ vi.mock("@/components/shared/charts", () => ({
BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
<div data-testid="bar-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)} />
),
CustomLegend: ({ categories }: { categories: readonly string[] }) => (
<div data-testid="chart-legend">{categories.join(",")}</div>
),
DEFAULT_COLOR_CYCLE: ["emerald", "blue", "violet", "amber"],
}));
@ -58,13 +62,20 @@ const day = (date: string, metrics: Partial<SpendMetrics>): DailyData => ({
},
});
const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => {
interface RenderOptions {
toolSpend?: ToolSpendResponse;
from?: Date;
to?: Date;
}
const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options;
mockGetToolSpend.mockResolvedValue(toolSpend);
return render(
<UsageTab
accessToken="test-token"
activity={{
dateValue: { from: new Date("2026-07-01"), to: new Date("2026-07-14") },
dateValue: { from, to },
onDateChange: vi.fn(),
results,
loading: false,
@ -74,7 +85,13 @@ const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => {
);
};
const readSeries = (element: HTMLElement) => JSON.parse(element.getAttribute("data-series") ?? "[]");
describe("UsageTab", () => {
beforeEach(() => {
mockGetToolSpend.mockReset();
});
it("sums compression and caching dollars across days into the summary cards", () => {
const { getByText } = renderWith([
day("2026-07-12", {
@ -95,16 +112,88 @@ describe("UsageTab", () => {
expect(getByText("140,000 tokens compressed")).toBeInTheDocument();
});
it("builds a per-day time series and per-driver donut from the daily rows", () => {
const { getByTestId } = renderWith([
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
]);
const twoDays = () => [
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
];
const series = JSON.parse(getByTestId("area-chart").getAttribute("data-series") ?? "[]");
it("opens on a running total anchored at $0 at the start of the range", () => {
const { getByTestId } = renderWith(twoDays());
// Cumulative prepends a synthetic $0 point at the range start (Jul 1) so the
// line rises from zero rather than floating; the daily running totals follow.
const series = readSeries(getByTestId("area-chart"));
expect(series).toHaveLength(3);
expect(series[0]).toMatchObject({ date: "Jul 1", Compression: 0, "Prompt caching": 0 });
expect(series[1]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[2].Compression).toBeCloseTo(0.14, 5);
expect(series[2]["Prompt caching"]).toBeCloseTo(0.016, 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.
const oneDay = new Date(2026, 6, 24);
const { getByTestId } = renderWith(
[day("2026-07-24", { compression_savings_spend: 0.2, prompt_caching_savings_spend: 0.05 })],
{ from: oneDay, to: oneDay },
);
const series = readSeries(getByTestId("area-chart"));
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ date: "Jul 24", Compression: 0, "Prompt caching": 0 });
expect(series[1]).toMatchObject({ date: "Jul 24", Compression: 0.2, "Prompt caching": 0.05 });
});
it("plots the daily series oldest first even though the rollup arrives newest first", async () => {
// The daily activity endpoint returns days newest first; the chart must
// still read left to right in time, and the running total must climb toward
// the newest day, not fall away from it.
const newestFirst = [
day("2026-07-13", { prompt_caching_savings_spend: 0.1 }),
day("2026-07-12", { prompt_caching_savings_spend: 0.04 }),
];
const { getByTestId, getByRole } = renderWith(newestFirst);
// The $0 anchor leads, then the days climb oldest to newest.
const cumulative = readSeries(getByTestId("area-chart"));
expect(cumulative.map((p: { date: string }) => p.date)).toEqual(["Jul 1", "Jul 12", "Jul 13"]);
expect(cumulative[1]["Prompt caching"]).toBeCloseTo(0.04, 5);
expect(cumulative[2]["Prompt caching"]).toBeCloseTo(0.14, 5);
expect(cumulative[2]["Prompt caching"]).toBeGreaterThan(cumulative[1]["Prompt caching"]);
await userEvent.click(getByRole("tab", { name: "Per day" }));
const perDay = readSeries(getByTestId("bar-chart"));
expect(perDay.map((p: { date: string }) => p.date)).toEqual(["Jul 12", "Jul 13"]);
});
it("draws bars of the raw per-interval readings on the other tab", async () => {
const { getByRole, getByTestId, queryByTestId } = renderWith(twoDays());
// Cumulative opens on the area line.
expect(getByTestId("area-chart")).toBeInTheDocument();
await userEvent.click(getByRole("tab", { name: "Per day" }));
// Per day switches to a bar chart of the unaccumulated daily savings, with no
// synthetic anchor prepended.
expect(queryByTestId("area-chart")).toBeNull();
const series = readSeries(getByTestId("bar-chart"));
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[1]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.01 });
});
it("says what the line means and over what range", async () => {
const { getByText, getByRole } = renderWith(twoDays());
expect(getByText("Running total saved · Jul 1 – Jul 14")).toBeInTheDocument();
await userEvent.click(getByRole("tab", { name: "Per day" }));
expect(getByText("Saved per day · Jul 1 – Jul 14")).toBeInTheDocument();
});
it("builds the per-driver donut from the range totals, not the running total", () => {
const { getByTestId } = renderWith(twoDays());
const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]");
expect(slices).toEqual([
@ -131,7 +220,7 @@ describe("UsageTab", () => {
start_date: "2026-07-12",
end_date: "2026-07-12",
};
const { findAllByTestId } = renderWith([day("2026-07-12", {})], toolSpend);
const { findAllByTestId } = renderWith([day("2026-07-12", {})], { toolSpend });
const bars = await findAllByTestId("bar-chart");
const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]");
@ -146,7 +235,7 @@ describe("UsageTab", () => {
start_date: "2026-07-05",
end_date: "2026-07-14",
};
const { findByText } = renderWith([day("2026-07-12", {})], toolSpend);
const { findByText } = renderWith([day("2026-07-12", {})], { toolSpend });
expect(await findByText(/capped at 30 days before the end of the selected range/)).toBeInTheDocument();
});
@ -159,7 +248,7 @@ describe("UsageTab", () => {
start_date: "2026-07-01",
end_date: "2026-07-14",
};
const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], toolSpend);
const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], { toolSpend });
await findAllByTestId("bar-chart");
expect(queryByText(/capped at 30 days before the end of the selected range/)).not.toBeInTheDocument();

View file

@ -3,14 +3,27 @@
import React, { useEffect, useMemo, useState } from "react";
import { Info } from "lucide-react";
import { AreaChart, BarChart, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts";
import { AreaChart, BarChart, CustomLegend, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
import { SpendMetrics } from "@/components/UsagePage/types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { buildDailyToolSeries, topToolsBySpend, usd } from "./costOptimizationUtils";
import {
buildDailyToolSeries,
formatRangeLabel,
localIsoDay,
MAX_POINTS_WITH_DOTS,
SAVINGS_SERIES,
SavingsAccumulation,
SavingsPoint,
toCumulative,
topToolsBySpend,
usd,
withStartAnchor,
} from "./costOptimizationUtils";
import { DailyActivityRange } from "./useDailyActivityRange";
interface UsageTabProps {
@ -26,6 +39,8 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = {
end_date: null,
};
const SAVINGS_COLORS = ["emerald", "blue"] as const;
const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
@ -95,16 +110,41 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]);
const totalSaved = compressionTotal + cachingTotal;
const overTime = useMemo(
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.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[...results]
.sort((a, b) => a.date.localeCompare(b.date))
.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[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.
const overTime = useMemo(() => {
if (accumulation !== "cumulative") return perInterval;
const startLabel = startTime ? shortDate(localIsoDay(startTime)) : "";
return withStartAnchor(toCumulative(perInterval), startLabel);
}, [accumulation, perInterval, startTime]);
const intervalLabel = "Per day";
const rangeLabel = formatRangeLabel(startTime ?? undefined, endTime ?? undefined);
const savingsSubtitle = [
accumulation === "cumulative" ? "Running total saved" : `Saved ${intervalLabel.toLowerCase()}`,
rangeLabel,
]
.filter(Boolean)
.join(" \u00b7 ");
const byDriver = useMemo(
() =>
[
@ -159,16 +199,44 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Savings over time</CardTitle>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Savings</CardTitle>
<p className="text-sm text-muted-foreground">{savingsSubtitle}</p>
</div>
<div className="flex items-center gap-4">
<CustomLegend categories={SAVINGS_SERIES} colors={SAVINGS_COLORS} />
<Tabs value={accumulation} onValueChange={(value) => setAccumulation(value as SavingsAccumulation)}>
<TabsList>
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
</CardHeader>
<CardContent>
<AreaChart
data={overTime}
index="date"
categories={["Compression", "Prompt caching"]}
colors={["emerald", "blue"]}
valueFormatter={usd}
/>
{accumulation === "cumulative" ? (
<AreaChart
data={overTime}
index="date"
categories={SAVINGS_SERIES}
colors={SAVINGS_COLORS}
valueFormatter={usd}
showLegend={false}
showDots={overTime.length <= MAX_POINTS_WITH_DOTS}
/>
) : (
<BarChart
data={overTime}
index="date"
categories={SAVINGS_SERIES}
colors={SAVINGS_COLORS}
stack
valueFormatter={usd}
showLegend={false}
/>
)}
</CardContent>
</Card>
<Card>

View file

@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking";
import { buildDailyToolSeries, computeCacheLeakage, isAnthropicModel, topToolsBySpend } from "./costOptimizationUtils";
import {
buildDailyToolSeries,
computeCacheLeakage,
formatRangeLabel,
isAnthropicModel,
localIsoDay,
toCumulative,
topToolsBySpend,
withStartAnchor,
} from "./costOptimizationUtils";
const metrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
spend: 0,
@ -221,3 +230,79 @@ describe("topToolsBySpend", () => {
expect(topToolsBySpend(byTool, 2).map((t) => t.tool_name)).toEqual(["b", "c"]);
});
});
describe("localIsoDay", () => {
it("reads the date off the viewer's clock rather than shifting it to UTC", () => {
expect(localIsoDay(new Date(2026, 6, 23, 23, 30))).toBe("2026-07-23");
expect(localIsoDay(new Date(2026, 0, 5, 0, 30))).toBe("2026-01-05");
});
});
describe("toCumulative", () => {
const point = (date: string, compression: number, caching: number) => ({
date,
Compression: compression,
"Prompt caching": caching,
});
it("turns each reading into everything saved up to that point", () => {
const running = toCumulative([point("Jul 1", 1, 10), point("Jul 2", 2, 20), point("Jul 3", 3, 30)]);
expect(running.map((p) => p.Compression)).toEqual([1, 3, 6]);
expect(running.map((p) => p["Prompt caching"])).toEqual([10, 30, 60]);
});
it("accumulates each driver on its own, so one flat series cannot lift the other", () => {
const running = toCumulative([point("Jul 1", 0, 5), point("Jul 2", 0, 5)]);
expect(running.map((p) => p.Compression)).toEqual([0, 0]);
expect(running.map((p) => p["Prompt caching"])).toEqual([5, 10]);
});
it("never falls, even across a quiet interval", () => {
const running = toCumulative([point("Jul 1", 4, 0), point("Jul 2", 0, 0), point("Jul 3", 1, 0)]);
expect(running.map((p) => p.Compression)).toEqual([4, 4, 5]);
});
it("keeps the labels and length of the readings it was given", () => {
const running = toCumulative([point("9am", 1, 1), point("10am", 1, 1)]);
expect(running.map((p) => p.date)).toEqual(["9am", "10am"]);
expect(toCumulative([])).toEqual([]);
});
});
describe("withStartAnchor", () => {
const point = (date: string, compression: number, caching: number) => ({
date,
Compression: compression,
"Prompt caching": caching,
});
it("lifts a single-day cumulative off a floating dot by prepending a $0 origin", () => {
const anchored = withStartAnchor([point("Jul 24", 12, 30)], "Jul 24");
expect(anchored).toEqual([point("Jul 24", 0, 0), point("Jul 24", 12, 30)]);
});
it("starts the range at zero without disturbing the running totals that follow", () => {
const anchored = withStartAnchor([point("Jul 16", 5, 1), point("Jul 17", 9, 4)], "Jul 16");
expect(anchored.map((p) => p.Compression)).toEqual([0, 5, 9]);
expect(anchored.map((p) => p["Prompt caching"])).toEqual([0, 1, 4]);
});
it("leaves an empty series alone so the chart's own no-data state can show", () => {
expect(withStartAnchor([], "Jul 24")).toEqual([]);
});
});
describe("formatRangeLabel", () => {
it("reads as a range across days", () => {
expect(formatRangeLabel(new Date(2026, 6, 16), new Date(2026, 6, 23))).toBe("Jul 16 \u2013 Jul 23");
});
it("collapses to one date when both ends are the same day", () => {
expect(formatRangeLabel(new Date(2026, 6, 23), new Date(2026, 6, 23))).toBe("Jul 23");
});
it("is empty until both ends are picked", () => {
expect(formatRangeLabel(undefined, new Date(2026, 6, 23))).toBe("");
expect(formatRangeLabel(new Date(2026, 6, 23), undefined)).toBe("");
});
});

View file

@ -149,3 +149,63 @@ const seedPoint = (date: string, toolNames: readonly string[]): DailyToolSpendPo
export const topToolsBySpend = (byTool: readonly ToolSpendEntry[], limit = 8): ToolSpendEntry[] =>
[...byTool].sort((a, b) => b.spend - a.spend).slice(0, limit);
export const localIsoDay = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
export type SavingsAccumulation = "cumulative" | "per-interval";
// A type alias, not an interface: only aliases get the implicit index signature
// that the chart wrappers' `Record<string, unknown>` datum bound requires.
export type SavingsPoint = {
date: string;
Compression: number;
"Prompt caching": number;
};
export const SAVINGS_SERIES = ["Compression", "Prompt caching"] as const;
/**
* 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
* what "running total saved, <range>" claims on the card.
*/
export const toCumulative = (points: readonly SavingsPoint[]): SavingsPoint[] =>
points.reduce<SavingsPoint[]>((acc, point) => {
const previous = acc[acc.length - 1];
return [
...acc,
{
date: point.date,
Compression: (previous?.Compression ?? 0) + point.Compression,
"Prompt caching": (previous?.["Prompt caching"] ?? 0) + point["Prompt caching"],
},
];
}, []);
/**
* Prepend a synthetic $0 point at the start of the range so the cumulative line
* rises from zero instead of floating as a single dot. The daily rollup only
* resolves whole days, so a one-day range would otherwise be one point; with the
* anchor it reads as "start of range $0 climbing to the range's running total".
* An empty series is left untouched so the chart's own "No data" state shows.
*/
export const withStartAnchor = (cumulative: readonly SavingsPoint[], startLabel: string): SavingsPoint[] =>
cumulative.length === 0
? [...cumulative]
: [{ date: startLabel, Compression: 0, "Prompt caching": 0 }, ...cumulative];
/** "Jul 16 – Jul 23", collapsing to a single date when the range is one day. */
export const formatRangeLabel = (from: Date | undefined, to: Date | undefined): string => {
if (!from || !to) return "";
const short = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const start = short(from);
const end = short(to);
return start === end ? start : `${start} – ${end}`;
};
/**
* Dots mark each reading, as in the design. Past this many readings they crowd
* into a solid band and stop being readable, so the line carries it alone.
*/
export const MAX_POINTS_WITH_DOTS = 31;

View file

@ -40,4 +40,12 @@ describe("AreaChart", () => {
expect(area.getAttribute("fill")).toMatch(/^url\(#fill-/);
}
});
it("marks each reading with a dot only when asked", () => {
const withoutDots = render(<AreaChart data={data} index="date" categories={["tokens"]} />);
expect(withoutDots.container.querySelectorAll("circle.recharts-dot")).toHaveLength(0);
const withDots = render(<AreaChart data={data} index="date" categories={["tokens"]} showDots />);
expect(withDots.container.querySelectorAll("circle.recharts-dot").length).toBeGreaterThanOrEqual(data.length);
});
});

View file

@ -17,6 +17,7 @@ export type AreaChartProps<TDatum extends Record<string, unknown>> = {
showLegend?: boolean;
showGridLines?: boolean;
showTooltip?: boolean;
showDots?: boolean;
customTooltip?: ChartTooltipComponent;
className?: string;
style?: React.CSSProperties;
@ -32,6 +33,7 @@ export function AreaChart<TDatum extends Record<string, unknown>>({
showLegend = true,
showGridLines = true,
showTooltip = true,
showDots = false,
customTooltip,
className,
style,
@ -94,7 +96,7 @@ export function AreaChart<TDatum extends Record<string, unknown>>({
strokeWidth={2}
fill={`url(#fill-${gradientId}-${i})`}
fillOpacity={1}
dot={false}
dot={showDots ? { r: 3.5, strokeWidth: 2, stroke: fills[i], fill: "var(--background, #fff)" } : false}
isAnimationActive={false}
/>
))}

View file

@ -20,6 +20,13 @@ describe("BarChart", () => {
expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"]));
});
it("renders the No data placeholder instead of a chart when data is empty", () => {
const { container, getByText } = render(<BarChart data={[]} index="date" categories={["passed"]} />);
expect(getByText("No data")).toBeTruthy();
expect(container.querySelector('[data-slot="chart"]')).toBeNull();
});
it("falls back to the tremor default color cycle when no colors are passed", () => {
const { container } = render(<BarChart data={data} index="date" categories={["passed", "blocked"]} />);

View file

@ -46,6 +46,17 @@ export function BarChart<TDatum extends Record<string, unknown>>({
className,
style,
}: BarChartProps<TDatum>) {
if (data.length === 0) {
return (
<div
className={cn("flex h-80 w-full items-center justify-center rounded-lg border border-dashed", className)}
style={style}
>
<p className="text-sm text-muted-foreground">No data</p>
</div>
);
}
const fills = categoryFills(categories.length, colors);
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
const vertical = layout === "vertical";