feat(ui): add per-key Savings tab to key detail page (#37693)

* feat(ui): add per-key Savings tab to key detail page

Adds a "Savings" tab to the key detail view, showing the same four metrics
and time-series chart as the proxy-wide Cost Optimization view, but scoped
to a single API key.

For org admins, the tab shows the key's full savings across all requests.
Non-admins see only their own requests on the key, with a scope note
explaining the limitation.

Root cause: userDailyActivityCall and userDailyActivityAggregatedCall
never forwarded an api_key query parameter to the backend, even though
both handlers already accept and filter by it.

Changes:

- networking.tsx: Add optional apiKey param to both daily activity call
  wrappers (appended to variadic options tuple for backward compatibility).

- costOptimizationUtils.ts: Extract shared metrics helpers (compressionOf,
  cachingOf, autorouterOf, savedTokensOf, cacheHitRatio) and shortDate
  so both UsageTab and KeySavingsTab use the same formulas and prevent
  divergence.

- useDailyActivityRange.ts: Refactor into useScopedDailyActivityRange(
  accessToken, scope: {userId, apiKey?}) for reuse-by-parameter unbundling.
  Role resolution stays at the entry point (useDailyActivityRange), not in
  a scoped caller. Update test expectations for new 6-arg tuple.

- UsageTab.tsx: Simplify by importing extracted helpers and SummaryCard
  component instead of defining them inline. No behavioral change.

- key_info_view.tsx: Insert "Savings" tab trigger between "Overview" and
  "Settings"; wire TabsContent to new KeySavingsTab component with lazy
  mounting (no keepMounted) to defer daily-activity fetch until tab opened.

- NEW: components/shared/SummaryCard.tsx — Shared presenter for four-tile
  summary row (label + value + hint + optional info popover). Extracted
  from UsageTab so both surfaces show identical tile layout without CSS
  divergence.

- NEW: components/templates/KeySavingsTab.tsx — Per-key view with admin/
  non-admin scope branching, empty-state messaging, same chart toggles
  and info popovers as UsageTab.

- NEW: components/templates/KeySavingsTab.test.tsx — 7 tests covering mount,
  loading state, empty state, scoping, and scope-note visibility.

Authorization: No new permission check. Both backends gate api_key filter
by the same user role check that governs the request itself. Non-admins
must send their own user_id and can only see their own keys.

Tests: 6121 pass (1 pre-existing failure unrelated to this change).

Prior art / collision note:
- PR #37570 (budgets tab) lands in same TabsList hunks as "Savings" tab,
  but different tab names so conflict trivial if both merge.
- PR #37659 (my own) adds progress/cancelled/cancel to DailyActivityRange,
  but this PR uses stable three-field interface from staging.

* fix(ui): scope spend view by the backend's admin-view contract, not all_admin_roles

Greptile flagged org admin handling on the key savings tab. The live bug it
described does not fire today: useAuthorized supplies session-role labels and
all_admin_roles only carries the raw org_admin spelling, so an org admin was
already scoped. That safety was accidental, so replace the predicate with
spendScopeUserId / hasProxyWideSpendView in utils/roles.ts, mirroring the
backend's user_api_key_has_admin_view (proxy admin and admin viewer only, org
admin excluded in both spellings), and use it in both useDailyActivityRange
and KeySavingsTab

Reclassify the KeySavingsTab render test as an integration test per the
repo's unit/integration split, move scope-resolution coverage to roles.test.ts
as a full role matrix, use real session-role values instead of raw ones, and
assert tile totals against non-empty metrics. Replace the nested ternary in
the chart body (frontend-lint error) with flat conditional rendering

* fix(ui): show auto-router savings as the fourth key-savings tile

Cache hit rate had displaced auto-router savings from the fourth slot,
diverging from the org-wide Cost Optimization page's tile order. Match
it: Total / Compression / Prompt caching / Auto-router, with cache hit
rate as a fifth tile.

* fix(ui): drop cache hit rate from the key savings tiles

Keep the four tiles this page is meant to show: total, compression,
prompt caching, and auto-router savings.

* fix(ui): stop an empty api_key from widening a key-scoped activity read

The paginated and aggregated daily-activity wrappers disagreed on an
empty filter value: the paginated one appended it, the aggregated one
coerced it to undefined with || and dropped it. Since the aggregated
call is the one tried first, an empty key hash would have silently
turned a key-scoped read into a proxy-wide one and reported every
key's savings as this key's. Use ?? so both send the filter through
and it matches nothing instead.

* style(ui): satisfy prettier and the inline-object lint rule in key savings tests

* refactor(ui): drop the cacheHitRatio extraction left over from the removed tile

* fix(ui): pass daily-activity filters raw so both transports agree at the null boundary

* refactor(ui): share the savings tiles and totals between both surfaces

The per-key Savings tab and the proxy-wide Cost Optimization tab carried a byte-identical
four-tile block, three long metric-definition strings included, and five identical useMemo
totals. Both now render SavingsTiles and total through useSavingsTotals, so the donut cannot
slice numbers the tile above it disagrees with.

* docs(ui): say request, not mount, in the savings tab comment

The comment claimed mounting eagerly would fire the rollup sweep, which reads as a claim about
the bundle. Only the request is deferred; the module ships with the key page either way.

* test(ui): pin the daily-activity args array against the real caller signatures

The sibling unit test mocks networking, so it checks the positional array against itself and
stays green when the array and a networking signature drift apart. Swapping user_id and api_key
in the aggregated signature alone passes there and fails here on user_id=hash-abc.

* style(ui): hoist the daily-activity query options out of the call argument

The four-property object literal tripped local/no-large-inline-object-arg. The violation predates
this branch, which only moved the line into the annotated range, and the rule count drops 550 to 549.
This commit is contained in:
tin-berri 2026-08-21 14:50:02 -07:00 • committed by GitHub
parent 9821b451e3
commit 0c50286a55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 657 additions and 83 deletions

View file

@ -1,19 +1,18 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Info } from "lucide-react";
import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
import { SpendMetrics } from "@/components/UsagePage/types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import {
autorouterOf,
buildDailyToolSeries,
cachingOf,
compressionOf,
formatRangeLabel,
localIsoDay,
MAX_POINTS_WITH_DOTS,
@ -22,11 +21,13 @@ import {
SAVINGS_SERIES,
SavingsAccumulation,
SavingsPoint,
shortDate,
toCumulative,
topToolsBySpend,
usd,
withStartAnchor,
} from "./costOptimizationUtils";
import SavingsTiles, { useSavingsTotals } from "@/components/shared/SavingsTiles";
import { DailyActivityRange } from "./useDailyActivityRange";
interface UsageTabProps {
@ -41,42 +42,8 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = {
end_date: null,
};
const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ?? 0;
const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0;
const autorouterOf = (m: SpendMetrics): number => m.autorouter_savings_spend ?? 0;
const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0;
const SummaryCard = ({ label, value, hint, info }: { label: string; value: string; hint?: string; info?: string }) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
{info && (
<Popover>
<PopoverTrigger
aria-label={`How ${label.toLowerCase()} is calculated`}
data-testid={`summary-card-info-${label.toLowerCase().replace(/\s+/g, "-")}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Info className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-64 text-sm text-muted-foreground">
{info}
</PopoverContent>
</Popover>
)}
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
);
const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
@ -106,11 +73,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]);
const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]);
const autorouterTotal = useMemo(() => results.reduce((sum, d) => sum + autorouterOf(d.metrics), 0), [results]);
const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]);
const totalSaved = compressionTotal + cachingTotal + autorouterTotal;
const totals = useSavingsTotals(results);
const [accumulation, setAccumulation] = useState<SavingsAccumulation>("cumulative");
@ -156,9 +119,11 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
SAVINGS_DRIVERS.map(({ name, color }) => ({
driver: name,
color,
usd: { Compression: compressionTotal, "Prompt caching": cachingTotal, "Auto-router": autorouterTotal }[name],
usd: { Compression: totals.compression, "Prompt caching": totals.caching, "Auto-router": totals.autorouter }[
name
],
})).filter((d) => d.usd > 0),
[compressionTotal, cachingTotal, autorouterTotal],
[totals],
);
const plottedDriverTotal = useMemo(() => byDriver.reduce((sum, d) => sum + d.usd, 0), [byDriver]);
@ -185,31 +150,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<SummaryCard
label="Total saved"
value={usd(totalSaved)}
hint={loading || isFetchingMore ? "Loading..." : "Compression + prompt caching + auto-router"}
/>
<SummaryCard
label="Compression savings"
value={usd(compressionTotal)}
hint={`${formatNumberWithCommas(savedTokensTotal)} tokens compressed`}
info="Tokens Headroom removed before the call, priced at the model's input rate."
/>
<SummaryCard
label="Prompt caching savings"
value={usd(cachingTotal)}
hint="Cache reads, net of write premium"
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. Can be negative on traffic that writes more cache than it reuses."
/>
<SummaryCard
label="Auto-router savings"
value={usd(autorouterTotal)}
hint="vs. the priciest model it could pick"
info="What this traffic would have cost had every request gone to the most expensive model the auto-router can route to, minus what it actually cost. Switching leaves the new model with a cold cache, so it pays to write the prompt again while the baseline is priced as already warm; a route that thrashes the cache can total below zero, and a genuine first turn, where neither side had anything cached, is undercounted."
/>
</div>
<SavingsTiles results={results} isLoading={loading || isFetchingMore} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">

View file

@ -12,6 +12,14 @@ export const usd = (value: number): string => {
export const pct = (ratio: number): string => `${formatNumberWithCommas(ratio * 100, 1)}%`;
export const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
export const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ?? 0;
export const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0;
export const autorouterOf = (m: SpendMetrics): number => m.autorouter_savings_spend ?? 0;
export const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0;
export type CacheLeakageDimension = "key" | "model";
export interface CacheLeakageRow {

View file

@ -0,0 +1,35 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useScopedDailyActivityRange } from "./useDailyActivityRange";
// The sibling unit test mocks networking, so it pins the positional args array against itself and
// would still pass if the array and the two networking signatures drifted apart. Nothing else runs
// the real callers, which is what makes the ordering a comment-enforced rule. This drives the hook
// through the actual query serializer instead, so an argument appended or inserted on one side only
// lands its value on the wrong filter and fails here.
describe("useScopedDailyActivityRange wiring", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
it("lands each scope field on its own query param through the real networking callers", async () => {
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ results: [], metadata: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
global.fetch = mockFetch;
renderHook(() => useScopedDailyActivityRange("sk-token", { userId: "u1", apiKey: "hash-abc" }));
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
const url = String(mockFetch.mock.calls[0][0]);
expect(url).toContain("user_id=u1");
expect(url).toContain("api_key=hash-abc");
});
});

View file

@ -33,15 +33,24 @@ describe("useDailyActivityRange", () => {
it("queries every user's activity for an admin", () => {
renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null, true]);
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null, true, null]);
});
it("scopes the query to the caller for a non-admin", () => {
renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user"));
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true]);
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]);
});
it.each(["org_admin", "Org Admin"])(
"scopes the query to the caller for %s, who has no admin view on this endpoint",
(role) => {
renderHook(() => useDailyActivityRange("test-token", "u1", role));
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]);
},
);
it("fetches through the single-shot aggregated endpoint first so days never fragment across pages", () => {
renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));

View file

@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking";
import { DailyData } from "@/components/UsagePage/types";
import { all_admin_roles } from "@/utils/roles";
import { spendScopeUserId } from "@/utils/roles";
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@ -23,10 +23,23 @@ export interface DailyActivityRange {
cancel: () => void;
}
export const useDailyActivityRange = (
/**
* Which slice of daily activity to read. Both fields are passed straight through to the
* endpoint as filters, so the caller — not this hook — decides what the viewer may see.
*
* `userId: null` asks for the whole proxy, which the backend only honours for admins;
* a non-admin must send its own id or the request is rejected. That role decision lives in
* `useDailyActivityRange` below rather than in here, so a caller scoping to one key is not
* silently re-scoped to a user as well.
*/
export interface DailyActivityScope {
userId: string | null;
apiKey?: string | null;
}
export const useScopedDailyActivityRange = (
accessToken: string | null,
userId: string | null,
userRole: string,
scope: DailyActivityScope,
): DailyActivityRange => {
const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []);
const initialTo = useMemo(() => new Date(), []);
@ -34,14 +47,19 @@ export const useDailyActivityRange = (
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;
const effectiveUserId = all_admin_roles.includes(userRole) ? null : userId;
const { userId, apiKey = null } = scope;
const { data, loading, isFetchingMore, progress, cancelled, cancel } = usePaginatedDailyActivity({
const activityQueryOptions = {
fetchFn: userDailyActivityCall,
aggregatedFetchFn: userDailyActivityAggregatedCall,
args: [accessToken, startTime, endTime, effectiveUserId, true],
// Positional, and read by two functions whose signatures diverge at index 3: the paginated
// call takes `page` there (injected by the hook) and the aggregated one does not. Anything
// appended here must therefore be appended to BOTH networking signatures, in this order.
args: [accessToken, startTime, endTime, userId, true, apiKey],
enabled: !!accessToken && !!startTime && !!endTime,
});
};
const { data, loading, isFetchingMore, progress, cancelled, cancel } =
usePaginatedDailyActivity(activityQueryOptions);
return {
dateValue,
@ -54,3 +72,12 @@ export const useDailyActivityRange = (
cancel,
};
};
export const useDailyActivityRange = (
accessToken: string | null,
userId: string | null,
userRole: string,
): DailyActivityRange =>
useScopedDailyActivityRange(accessToken, {
userId: spendScopeUserId(userRole, userId),
});

View file

@ -627,3 +627,85 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => {
expect(String(mockFetch.mock.calls[1][0])).not.toContain("tier_labels");
});
});
describe("daily activity api_key filter", () => {
const originalFetch = global.fetch;
const captureFetch = () => {
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ results: [], metadata: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
global.fetch = mockFetch;
return mockFetch;
};
const requestedUrl = (mockFetch: ReturnType<typeof captureFetch>): string => String(mockFetch.mock.calls[0][0]);
const start = new Date("2025-01-01T00:00:00Z");
const end = new Date("2025-01-31T00:00:00Z");
afterEach(() => {
global.fetch = originalFetch;
});
it("sends the key hash as api_key from the paginated caller", async () => {
const mockFetch = captureFetch();
await Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "hash-abc");
expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc");
});
it("sends the key hash as api_key from the aggregated caller", async () => {
const mockFetch = captureFetch();
await Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "hash-abc");
expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc");
});
// The two wrappers serialize the same optional filters through different transports, so an
// absent key has to drop the param in both. Dropping it on one side and sending it empty on
// the other would widen a key-scoped read into an unscoped one.
it.each([
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, null)],
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, null)],
])("omits api_key entirely from the %s caller when no key is given", async (_label, call) => {
const mockFetch = captureFetch();
await call();
expect(requestedUrl(mockFetch)).not.toContain("api_key");
});
// An empty key must not be coerced into "no filter". Dropping it would turn a key-scoped read
// into a proxy-wide one and report every key's savings as this key's, so both callers send it
// through and let the filter match nothing instead.
it.each([
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "")],
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "")],
])("keeps an empty api_key as a filter rather than widening the %s read", async (_label, call) => {
const mockFetch = captureFetch();
await call();
expect(requestedUrl(mockFetch)).toContain("api_key=");
});
// user_id rides the same two transports and widens the same way, so it gets the same guard.
// The aggregated caller used to drop "" via `||`; without this the two filters could drift
// apart again on one side only.
it.each([
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, "", false, null)],
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, "", false, null)],
])("keeps an empty user_id as a filter rather than widening the %s read", async (_label, call) => {
const mockFetch = captureFetch();
await call();
expect(requestedUrl(mockFetch)).toContain("user_id=");
});
});

View file

@ -1410,6 +1410,7 @@ export const userDailyActivityCall = async (
page: number = 1,
userId: string | null = null,
includeCurrentUtcDay: boolean = false,
apiKey: string | null = null,
) => {
/**
* Get daily user activity on proxy
@ -1423,6 +1424,7 @@ export const userDailyActivityCall = async (
extraQueryParams: {
user_id: userId,
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
api_key: apiKey,
},
});
};
@ -2505,12 +2507,12 @@ export const userDailyActivityAggregatedCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
...options: [userId?: string | null, includeCurrentUtcDay?: boolean]
...options: [userId?: string | null, includeCurrentUtcDay?: boolean, apiKey?: string | null]
) => {
/**
* Get aggregated daily user activity (no pagination)
*/
const [userId = null, includeCurrentUtcDay = false] = options;
const [userId = null, includeCurrentUtcDay = false, apiKey = null] = options;
try {
const formatDate = (date: Date) => {
const year = date.getFullYear();
@ -2524,8 +2526,12 @@ export const userDailyActivityAggregatedCall = async (
start_date: formatDate(startTime),
end_date: formatDate(endTime),
timezone: new Date().getTimezoneOffset().toString(),
user_id: userId || undefined,
// Passed raw, matching the paginated caller: both serializers drop null and undefined,
// and both keep "". An empty filter must not vanish, or a request scoped to one user or
// key would silently widen into an unscoped, proxy-wide read.
user_id: userId,
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
api_key: apiKey,
},
});
} catch (error) {

View file

@ -0,0 +1,65 @@
"use client";
import React, { useMemo } from "react";
import SummaryCard from "@/components/shared/SummaryCard";
import {
autorouterOf,
cachingOf,
compressionOf,
savedTokensOf,
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);
return {
compression,
caching,
autorouter,
savedTokens: sumOf(savedTokensOf),
total: compression + caching + autorouter,
};
}, [results]);
const SavingsTiles = ({ results, isLoading }: { results: DailyData[]; isLoading: boolean }) => {
const totals = useSavingsTotals(results);
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<SummaryCard
label="Total saved"
value={usd(totals.total)}
hint={isLoading ? "Loading..." : "Compression + prompt caching + auto-router"}
/>
<SummaryCard
label="Compression savings"
value={usd(totals.compression)}
hint={`${formatNumberWithCommas(totals.savedTokens)} tokens compressed`}
info="Tokens Headroom removed before the call, priced at the model's input rate."
/>
<SummaryCard
label="Prompt caching savings"
value={usd(totals.caching)}
hint="Cache reads, net of write premium"
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. Can be negative on traffic that writes more cache than it reuses."
/>
<SummaryCard
label="Auto-router savings"
value={usd(totals.autorouter)}
hint="vs. the priciest model it could pick"
info="What this traffic would have cost had every request gone to the most expensive model the auto-router can route to, minus what it actually cost. Switching leaves the new model with a cold cache, so it pays to write the prompt again while the baseline is priced as already warm; a route that thrashes the cache can total below zero, and a genuine first turn, where neither side had anything cached, is undercounted."
/>
</div>
);
};
export default SavingsTiles;

View file

@ -0,0 +1,50 @@
"use client";
import React from "react";
import { Info } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export interface SummaryCardProps {
label: string;
value: string;
hint?: string;
/** Rendered behind an info affordance. Use it for how a figure is derived, not for restating the label. */
info?: string;
}
/**
* A labelled figure with an optional hint line and an optional "how is this calculated" popover.
* Shared by the proxy-wide Cost Optimization usage tab and the per-key savings tab so both
* surfaces present the same figures identically.
*/
const slugOf = (label: string): string => label.toLowerCase().replace(/\s+/g, "-");
const SummaryCard = ({ label, value, hint, info }: SummaryCardProps) => (
<Card data-testid={`summary-card-${slugOf(label)}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
{info && (
<Popover>
<PopoverTrigger
aria-label={`How ${label.toLowerCase()} is calculated`}
data-testid={`summary-card-info-${slugOf(label)}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Info className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-64 text-sm text-muted-foreground">
{info}
</PopoverContent>
</Popover>
)}
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
);
export default SummaryCard;

View file

@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import KeySavingsTab from "./KeySavingsTab";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import * as useScopedDailyActivityRangeModule from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
const metrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
...overrides,
});
const day = (date: string, overrides: Partial<SpendMetrics>): DailyData => ({
date,
metrics: metrics(overrides),
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
},
});
const mockActivity = (
overrides: Partial<useScopedDailyActivityRangeModule.DailyActivityRange> = {},
): useScopedDailyActivityRangeModule.DailyActivityRange => ({
dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") },
onDateChange: vi.fn(),
results: [] as DailyData[],
loading: false,
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
cancel: vi.fn(),
...overrides,
});
const scopedRange = () => vi.spyOn(useScopedDailyActivityRangeModule, "useScopedDailyActivityRange");
const renderTab = (props: Partial<React.ComponentProps<typeof KeySavingsTab>> = {}) =>
render(
<KeySavingsTab
accessToken="test-token"
keyToken="key-abc123"
userId="user-123"
userRole="Internal User"
{...props}
/>,
);
describe("KeySavingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("totals each savings driver across the days in range", () => {
const firstDay: Partial<SpendMetrics> = {
compression_savings_spend: 1.5,
prompt_caching_savings_spend: 0.25,
autorouter_savings_spend: 2,
compression_saved_tokens: 400,
cache_read_input_tokens: 300,
prompt_tokens: 1000,
};
const secondDay: Partial<SpendMetrics> = {
compression_savings_spend: 0.5,
prompt_caching_savings_spend: 0.75,
autorouter_savings_spend: 1,
compression_saved_tokens: 600,
cache_read_input_tokens: 200,
prompt_tokens: 1000,
};
scopedRange().mockReturnValue(
mockActivity({ results: [day("2025-01-01", firstDay), day("2025-01-02", secondDay)] }),
);
renderTab();
expect(screen.getByTestId("summary-card-total-saved")).toHaveTextContent("$6.00");
expect(screen.getByTestId("summary-card-compression-savings")).toHaveTextContent("$2.00");
expect(screen.getByTestId("summary-card-compression-savings")).toHaveTextContent("1,000 tokens compressed");
expect(screen.getByTestId("summary-card-prompt-caching-savings")).toHaveTextContent("$1.00");
expect(screen.getByTestId("summary-card-auto-router-savings")).toHaveTextContent("$3.00");
});
it("separates a key with no traffic from one still loading", () => {
scopedRange().mockReturnValue(mockActivity());
const { unmount } = renderTab();
expect(screen.getByTestId("key-savings-empty")).toHaveTextContent("No usage recorded for this key");
unmount();
scopedRange().mockReturnValue(mockActivity({ loading: true }));
renderTab();
expect(screen.getByTestId("key-savings-empty")).toHaveTextContent("Loading savings");
});
it("asks the endpoint for this key alone, scoped to the viewer's own rows", () => {
const hook = scopedRange().mockReturnValue(mockActivity());
renderTab({ userId: "user-456", userRole: "Internal User" });
expect(hook).toHaveBeenCalledWith("test-token", { userId: "user-456", apiKey: "key-abc123" });
expect(screen.getByTestId("key-savings-scope-note")).toHaveTextContent("Showing your own requests");
});
it("reads the whole key for a proxy admin, with no scope note to contradict it", () => {
const hook = scopedRange().mockReturnValue(mockActivity());
renderTab({ userId: "admin-123", userRole: "Admin" });
expect(hook).toHaveBeenCalledWith("test-token", { userId: null, apiKey: "key-abc123" });
expect(screen.queryByTestId("key-savings-scope-note")).not.toBeInTheDocument();
});
it("keeps the scope note for an org admin, whose figures cover only their own requests", () => {
const hook = scopedRange().mockReturnValue(mockActivity());
renderTab({ userId: "org-admin-1", userRole: "Org Admin" });
expect(hook).toHaveBeenCalledWith("test-token", { userId: "org-admin-1", apiKey: "key-abc123" });
expect(screen.getByTestId("key-savings-scope-note")).toHaveTextContent("Showing your own requests");
});
});

View file

@ -0,0 +1,144 @@
"use client";
import React, { useMemo, useState } from "react";
import { AreaChart, BarChart, CustomLegend } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import SavingsTiles from "@/components/shared/SavingsTiles";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { hasProxyWideSpendView, spendScopeUserId } from "@/utils/roles";
import {
autorouterOf,
cachingOf,
compressionOf,
formatRangeLabel,
localIsoDay,
MAX_POINTS_WITH_DOTS,
SAVINGS_COLORS,
SAVINGS_SERIES,
SavingsAccumulation,
SavingsPoint,
shortDate,
toCumulative,
usd,
withStartAnchor,
} from "@/app/(dashboard)/cost-optimization/_components/costOptimizationUtils";
import { useScopedDailyActivityRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
interface KeySavingsTabProps {
accessToken: string | null;
/** The key's token hash — what spend rows are keyed by, not the one-time plaintext secret. */
keyToken: string;
userId: string | null;
userRole: string;
}
const KeySavingsTab: React.FC<KeySavingsTabProps> = ({ accessToken, keyToken, userId, userRole }) => {
// Proxy admins read the whole key. For anyone else the endpoint applies the caller's own user_id
// alongside the key filter, so the figures cover only that viewer's requests on this key -- said
// plainly in the scope note below rather than left to be misread as the key's total.
const readsWholeKey = hasProxyWideSpendView(userRole);
const activity = useScopedDailyActivityRange(accessToken, {
userId: spendScopeUserId(userRole, userId),
apiKey: keyToken,
});
const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;
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 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 && `${rangeLabel} (UTC)`,
]
.filter(Boolean)
.join(" · ");
const isLoading = loading || isFetchingMore;
const hasRows = results.length > 0;
const chartProps = {
data: overTime,
index: "date",
categories: SAVINGS_SERIES,
colors: SAVINGS_COLORS,
valueFormatter: usd,
showLegend: false,
};
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">Spend is bucketed by UTC day</span>
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
{!readsWholeKey && (
<p className="text-sm text-muted-foreground" data-testid="key-savings-scope-note">
Showing your own requests on this key. A key shared across a team will have spend from other members that is
not counted here.
</p>
)}
<SavingsTiles results={results} isLoading={isLoading} />
<Card>
<CardHeader>
<CardTitle>Savings</CardTitle>
<CardDescription>{savingsSubtitle}</CardDescription>
<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>
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>
</Tabs>
</CardAction>
</CardHeader>
<CardContent>
{/* Distinguishes "still fetching" from "this key genuinely had no traffic": an empty
chart alone reads as a broken panel, and a $0.00 tile reads as a real zero. */}
{!hasRows && (
<p className="py-12 text-center text-sm text-muted-foreground" data-testid="key-savings-empty">
{isLoading ? "Loading savings..." : "No usage recorded for this key in this range."}
</p>
)}
{hasRows && accumulation === "cumulative" && (
<AreaChart {...chartProps} showDots={overTime.length <= MAX_POINTS_WITH_DOTS} />
)}
{/* Not stacked: auto-router can go negative on a cold-cache write, and stacking would
draw that below the axis while the rest of the bar still read as the total. */}
{hasRows && accumulation !== "cumulative" && <BarChart {...chartProps} />}
</CardContent>
</Card>
</div>
);
};
export default KeySavingsTab;

View file

@ -14,6 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EntityLink } from "@/components/shared/EntityLink";
import { teamDetailHref } from "@/utils/entityLinks";
import { KeyInfoHeader } from "./KeyInfoHeader";
import KeySavingsTab from "./KeySavingsTab";
import { useEffect, useState } from "react";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles";
import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers";
@ -603,6 +604,9 @@ export default function KeyInfoView({
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
</TabsTrigger>
<TabsTrigger value="savings" className="flex-none rounded-none px-4 py-2">
Savings
</TabsTrigger>
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
</TabsTrigger>
@ -738,6 +742,17 @@ export default function KeyInfoView({
</div>
</TabsContent>
{/* Savings Panel. No keepMounted: this tab sweeps the daily rollup, and staying mounted
would fire that request on every key page open for people who never look at it. */}
<TabsContent value="savings">
<KeySavingsTab
accessToken={accessToken}
keyToken={currentKeyData.token}
userId={userID}
userRole={userRole}
/>
</TabsContent>
{/* Settings Panel */}
<TabsContent value="settings" keepMounted>
<Card className="block p-6">

View file

@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
import {
all_admin_roles,
effectiveSessionRole,
hasProxyWideSpendView,
isAdminRole,
spendScopeUserId,
isOrgAdminForAnyOrg,
isOrgAdminSessionRole,
isProxyAdminRole,
@ -325,4 +327,49 @@ describe("roles", () => {
expect(teamListScopeUserId(effectiveSessionRole("org_admin"), SESSION_USER_ID)).toBeNull();
});
});
describe("spendScopeUserId", () => {
const SESSION_USER_ID = "user-1234";
it.each(["proxy_admin", "proxy_admin_viewer"])(
"drops the user id for %s, whom the daily-activity endpoints let read every user's spend",
(rawRole) => {
expect(hasProxyWideSpendView(effectiveSessionRole(rawRole))).toBe(true);
expect(spendScopeUserId(effectiveSessionRole(rawRole), SESSION_USER_ID)).toBeNull();
},
);
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])(
"accepts %s in either the session-role or raw spelling, since all_admin_roles carries both",
(role) => {
expect(spendScopeUserId(role, SESSION_USER_ID)).toBeNull();
},
);
it.each(["internal_user", "internal_user_viewer", "internal_viewer", "app_user"])(
"scopes %s to its own user id, which is the only one the endpoint authorizes",
(rawRole) => {
expect(spendScopeUserId(effectiveSessionRole(rawRole), SESSION_USER_ID)).toBe(SESSION_USER_ID);
},
);
it("scopes an unknown or absent role rather than asking for the whole proxy", () => {
expect(spendScopeUserId(null, SESSION_USER_ID)).toBe(SESSION_USER_ID);
expect(spendScopeUserId("Undefined Role", SESSION_USER_ID)).toBe(SESSION_USER_ID);
});
// The backend's user_api_key_has_admin_view covers PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY only,
// so an org admin asking for the whole proxy is silently narrowed to its own rows and the
// figures would read as the key's total. Both spellings have to scope, unlike teamListScopeUserId.
it.each(["org_admin", "Org Admin"])("scopes %s, whom the backend does not grant an admin view", (role) => {
expect(hasProxyWideSpendView(role)).toBe(false);
expect(spendScopeUserId(role, SESSION_USER_ID)).toBe(SESSION_USER_ID);
});
it("differs from all_admin_roles by exactly org admin, which is the whole point of not reusing it", () => {
const scopedByAllAdminRoles = all_admin_roles.filter((role) => spendScopeUserId(role, SESSION_USER_ID) !== null);
expect(scopedByAllAdminRoles).toEqual(["org_admin"]);
});
});
});

View file

@ -109,3 +109,15 @@ const sessionRolesWithBroadTeamList: string[] = ["Admin", "Admin Viewer", "Org A
export const teamListScopeUserId = (userRole: string | null, userId: string | null): string | null =>
sessionRolesWithBroadTeamList.includes(userRole ?? "") ? null : userId;
// Mirrors the backend's `user_api_key_has_admin_view`, which the daily-activity endpoints gate on:
// proxy admins read every user's spend, and everyone else is forced to their own user_id. Org admin
// is deliberately absent, so this is `all_admin_roles` minus org admin rather than a reuse of it.
// Both spellings are listed because that constant carries both and either may reach a call site.
const rolesWithProxyWideSpendView: string[] = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"];
export const hasProxyWideSpendView = (userRole: string | null): boolean =>
rolesWithProxyWideSpendView.includes(userRole ?? "");
export const spendScopeUserId = (userRole: string | null, userId: string | null): string | null =>
hasProxyWideSpendView(userRole) ? null : userId;