authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 02:24:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 02:24:10-07:00
loge8545b293b79cdd73d30ef921db149ef1b5abee0
tree4adf3e7b795525b129ab5d90adf51bb7f3796388
parent263097a8da038a143f2b7b7817250f0ad390ea32
signature Signed by PGP key B5690EEEBB952194

feat(usage): show cost by token type, speed, and model detail (#15108)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

17 files changed, 1097 insertions(+), 205 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+80-1
......@@ -42,7 +42,7 @@ import { UsageLimitsSection } from "./UsageLimitsPooled";
4242import { ControlPillMenu } from "../../components/ControlPill";
4343import { SymbolView } from "../../components/AppSymbol";
4444import type { UsageChartMetric } from "./usageChartData";
45import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
45import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders";
4646
4747type UsageTab = "usage" | "limits";
4848const TAB_OPTIONS = [
......@@ -360,6 +360,7 @@ export function UsageRouteScreen() {
360360 onCursorEnabled={refreshAfterCursorEnable}
361361 />
362362 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
363 <CostSection merged={merged} />
363364 <ModelsSection merged={merged} />
364365 </>
365366 )}
......@@ -688,6 +689,84 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
688689 );
689690}
690691
692function CostSection(props: { readonly merged: MergedUsage }) {
693 const { categoryCost, speedCost } = props.merged;
694 const colors = useUsageMixColors();
695 const byType = [
696 { label: "Input", value: categoryCost.input, color: colors.input },
697 { label: "Cache read", value: categoryCost.cacheRead, color: colors.cacheRead },
698 { label: "Cache write", value: categoryCost.cacheWrite, color: colors.cacheWrite },
699 { label: "Output", value: categoryCost.output, color: colors.output },
700 // Reported cost with no rates to split it, or from older servers. Below a
701 // cent it is rounding, not usage.
702 {
703 label: "Other",
704 value: categoryCost.unsplit >= 0.005 ? categoryCost.unsplit : 0,
705 color: colors.other,
706 },
707 ];
708 const bySpeed = [
709 { label: "Standard", value: speedCost.standard, color: colors.standard },
710 { label: "Fast", value: speedCost.fast, color: colors.fast },
711 { label: "Ultrafast", value: speedCost.ultrafast, color: colors.ultrafast },
712 ];
713 if (props.merged.costUsd <= 0) return null;
714
715 return (
716 <SettingsSection title="Cost">
717 <ShareBar label="By type" segments={byType} />
718 {speedCost.fast + speedCost.ultrafast > 0 ? (
719 <View className="border-t border-border-subtle">
720 <ShareBar
721 label="By speed"
722 segments={bySpeed}
723 aside={`${formatUsd(speedCost.premium)} premium`}
724 />
725 </View>
726 ) : null}
727 </SettingsSection>
728 );
729}
730
731/** One part-to-whole cost bar with its legend. Empty segments are left out. */
732function ShareBar(props: {
733 readonly label: string;
734 readonly segments: readonly { label: string; value: number; color: string }[];
735 readonly aside?: string;
736}) {
737 const visible = props.segments.filter((segment) => segment.value > 0);
738 if (visible.length === 0) return null;
739
740 return (
741 <View className="gap-3 p-4">
742 <View className="flex-row items-baseline justify-between gap-3">
743 <Text className="text-sm text-foreground-muted">{props.label}</Text>
744 {props.aside ? (
745 <Text className="text-sm tabular-nums text-foreground-muted">{props.aside}</Text>
746 ) : null}
747 </View>
748 <View className="h-2 flex-row gap-0.5">
749 {visible.map((segment) => (
750 <View
751 key={segment.label}
752 className="h-full rounded-sm"
753 style={{ flex: segment.value, backgroundColor: segment.color }}
754 />
755 ))}
756 </View>
757 <View className="flex-row flex-wrap gap-x-4 gap-y-1.5">
758 {visible.map((segment) => (
759 <View key={segment.label} className="flex-row items-center gap-1.5">
760 <View className="size-2 rounded-sm" style={{ backgroundColor: segment.color }} />
761 <Text className="text-sm text-foreground-muted">{segment.label}</Text>
762 <Text className="text-sm tabular-nums text-foreground">{formatUsd(segment.value)}</Text>
763 </View>
764 ))}
765 </View>
766 </View>
767 );
768}
769
691770function MetricCell(props: {
692771 readonly label: string;
693772 readonly value: string;
apps/mobile/src/features/usage/usageProviders.ts+20
......@@ -38,3 +38,23 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
3838 antigravity: "#8c7bd1",
3939 };
4040}
41
42/**
43 * Neutral steps for cost and token mixes, so they never borrow a provider's
44 * color. Matches the web steps: oklab mixes of the codex ink into the
45 * background, above the 15 ΔE separation floor for adjacent segments.
46 */
47export function useUsageMixColors() {
48 const { themeAppearance: scheme } = useAppearancePreferences();
49 const dark = scheme === "dark";
50 return {
51 input: dark ? "#737373" : "#848484",
52 cacheRead: dark ? "#282828" : "#c0c0c0",
53 cacheWrite: dark ? "#949494" : "#6d6d6d",
54 output: dark ? "#e6e6e6" : "#3c3c43",
55 other: dark ? "#494949" : "#a3a3a3",
56 standard: dark ? "#313131" : "#b8b8b8",
57 fast: dark ? "#838383" : "#797979",
58 ultrafast: dark ? "#e6e6e6" : "#3c3c43",
59 };
60}
apps/server/src/usage/usageAggregation.test.ts+24-1
......@@ -12,7 +12,12 @@ const rates: RateTable = new Map([
1212 outputCostPerToken: 5e-5,
1313 cacheReadCostPerToken: 1e-6,
1414 cacheCreationCostPerToken: 1.25e-5,
15 fast: null,
15 fast: {
16 inputCostPerToken: 2e-5,
17 outputCostPerToken: 1e-4,
18 cacheReadCostPerToken: 2e-6,
19 cacheCreationCostPerToken: 2.5e-5,
20 },
1621 ultrafast: null,
1722 },
1823 ],
......@@ -77,6 +82,24 @@ describe("UsageAggregator", () => {
7782 ).toThrow("requires exact time bounds");
7883 });
7984
85 it("splits a bucket's cost by category and speed", () => {
86 const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets;
87
88 // Standard costs $0.005625 and fast twice that.
89 expect(bucket).toMatchObject({
90 costUsd: expect.closeTo(0.016875),
91 categoryCostUsd: {
92 input: expect.closeTo(0.003),
93 cacheRead: expect.closeTo(0.003),
94 cacheWrite: expect.closeTo(0.000375),
95 output: expect.closeTo(0.0075),
96 },
97 fastCostUsd: expect.closeTo(0.01125),
98 speedPremiumUsd: expect.closeTo(0.005625),
99 });
100 expect(bucket).not.toHaveProperty("ultrafastCostUsd");
101 });
102
80103 it("keeps only the first record for a repeated dedupe key", () => {
81104 const result = aggregate([
82105 record({ dedupeKey: "msg_1:" }),
apps/server/src/usage/usageAggregation.ts+57-1
......@@ -12,7 +12,13 @@
1212 *
1313 * @module usageAggregation
1414 */
15import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts";
15import type {
16 UsageBucket,
17 UsageCategoryCost,
18 UsageDay,
19 UsageResolution,
20 UsageTokenTotals,
21} from "@t3tools/contracts";
1622
1723import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts";
1824import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";
......@@ -50,6 +56,10 @@ interface MutableBucket {
5056 totals: UsageTokenTotals;
5157 costUsd: number;
5258 cacheSavingsUsd: number;
59 categoryCostUsd: UsageCategoryCost | null;
60 fastCostUsd: number;
61 ultrafastCostUsd: number;
62 speedPremiumUsd: number;
5363 records: number;
5464 unpricedRecords: number;
5565 providerReportedRecords: number;
......@@ -153,6 +163,10 @@ export class UsageAggregator {
153163 totals: EMPTY_TOTALS,
154164 costUsd: 0,
155165 cacheSavingsUsd: 0,
166 categoryCostUsd: null,
167 fastCostUsd: 0,
168 ultrafastCostUsd: 0,
169 speedPremiumUsd: 0,
156170 records: 0,
157171 unpricedRecords: 0,
158172 providerReportedRecords: 0,
......@@ -165,6 +179,22 @@ export class UsageAggregator {
165179
166180 bucket.totals = addTotals(bucket.totals, record.totals);
167181 bucket.costUsd += priced.costUsd;
182 if (priced.categoryCostUsd !== null) {
183 const sum = bucket.categoryCostUsd;
184 const add = priced.categoryCostUsd;
185 bucket.categoryCostUsd =
186 sum === null
187 ? add
188 : {
189 input: sum.input + add.input,
190 cacheRead: sum.cacheRead + add.cacheRead,
191 cacheWrite: sum.cacheWrite + add.cacheWrite,
192 output: sum.output + add.output,
193 };
194 }
195 if (record.speed === "fast") bucket.fastCostUsd += priced.costUsd;
196 if (record.speed === "ultrafast") bucket.ultrafastCostUsd += priced.costUsd;
197 bucket.speedPremiumUsd += priced.speedPremiumUsd;
168198 bucket.cacheSavingsUsd += cacheSavingsUsd(
169199 this.#options.rates,
170200 record,
......@@ -182,6 +212,10 @@ export class UsageAggregator {
182212 for (const [key, bucket] of this.#buckets) {
183213 const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] =
184214 key.split("\u0000");
215 const category = bucket.categoryCostUsd;
216 const fastCostUsd = roundUsd(bucket.fastCostUsd);
217 const ultrafastCostUsd = roundUsd(bucket.ultrafastCostUsd);
218 const speedPremiumUsd = roundUsd(bucket.speedPremiumUsd);
185219 buckets.push({
186220 day: day as UsageDay,
187221 ...(hourStart === "" ? {} : { hourStart }),
......@@ -191,6 +225,20 @@ export class UsageAggregator {
191225 totals: bucket.totals,
192226 costUsd: bucket.costUsd,
193227 cacheSavingsUsd: bucket.cacheSavingsUsd,
228 // Zero and unknown figures are omitted to keep payloads small.
229 ...(category === null
230 ? {}
231 : {
232 categoryCostUsd: {
233 input: roundUsd(category.input),
234 cacheRead: roundUsd(category.cacheRead),
235 cacheWrite: roundUsd(category.cacheWrite),
236 output: roundUsd(category.output),
237 },
238 }),
239 ...(fastCostUsd === 0 ? {} : { fastCostUsd }),
240 ...(ultrafastCostUsd === 0 ? {} : { ultrafastCostUsd }),
241 ...(speedPremiumUsd === 0 ? {} : { speedPremiumUsd }),
194242 costSource: resolveCostSource(bucket),
195243 records: bucket.records,
196244 unpricedRecords: bucket.unpricedRecords,
......@@ -214,6 +262,14 @@ export class UsageAggregator {
214262 }
215263}
216264
265/**
266 * Rounds to micro-dollars. The split and speed figures need no more precision,
267 * and shorter numbers keep them cheap on the wire.
268 */
269function roundUsd(value: number): number {
270 return Math.round(value * 1e6) / 1e6;
271}
272
217273/**
218274 * A bucket mixes records from one model, but their cost provenance can differ
219275 * when only some records carried a reported cost. The weakest provenance in the
apps/server/src/usage/usagePricing.test.ts+42-4
......@@ -47,7 +47,7 @@ describe("usage pricing", () => {
4747 });
4848
4949 for (const reportedCostUsd of [null, 99]) {
50 expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toEqual({
50 expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toMatchObject({
5151 costUsd: 13.5,
5252 costSource: "modelPriced",
5353 });
......@@ -69,7 +69,7 @@ describe("usage pricing", () => {
6969 expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9);
7070 expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5);
7171 expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0);
72 expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({
72 expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toMatchObject({
7373 costUsd: 0.25,
7474 costSource: "providerReported",
7575 });
......@@ -81,7 +81,7 @@ describe("usage pricing", () => {
8181 "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 },
8282 });
8383
84 expect(priceUsage(table, record("example-model"), overrides)).toEqual({
84 expect(priceUsage(table, record("example-model"), overrides)).toMatchObject({
8585 costUsd: 14,
8686 costSource: "modelPriced",
8787 });
......@@ -96,7 +96,7 @@ describe("usage pricing", () => {
9696 outputCostPerMillionTokens: 0,
9797 },
9898 });
99 expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toEqual({
99 expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toMatchObject({
100100 costUsd: 0,
101101 costSource: "modelPriced",
102102 });
......@@ -110,6 +110,8 @@ describe("usage pricing", () => {
110110 expect(priceUsage(table, record(model, 99), overrides)).toEqual({
111111 costUsd: 99,
112112 costSource: "providerReported",
113 categoryCostUsd: null,
114 speedPremiumUsd: 0,
113115 });
114116 }
115117 });
......@@ -136,6 +138,42 @@ describe("usage pricing", () => {
136138 );
137139 });
138140
141 it("splits cost by category and prices the speed premium", () => {
142 const table = parseRateTable({
143 "claude-opus-5-5": {
144 ...rate(4e-6, 4e-7),
145 cache_creation_input_token_cost: 5e-6,
146 provider_specific_entry: { fast: 2 },
147 },
148 });
149 const split = (input: number, cacheRead: number, cacheWrite: number, output: number) => ({
150 input: expect.closeTo(input),
151 cacheRead: expect.closeTo(cacheRead),
152 cacheWrite: expect.closeTo(cacheWrite),
153 output: expect.closeTo(output),
154 });
155
156 expect(priceUsage(table, record("claude-opus-5-5", null, "fast"))).toEqual({
157 costUsd: expect.closeTo(58.8),
158 costSource: "modelPriced",
159 categoryCostUsd: split(8, 0.8, 10, 40),
160 speedPremiumUsd: expect.closeTo(29.4),
161 });
162 // A reported cost keeps its total and splits in proportion to list rates.
163 expect(priceUsage(table, record("claude-opus-5-5", 29.4, "fast"))).toEqual({
164 costUsd: 29.4,
165 costSource: "providerReported",
166 categoryCostUsd: split(4, 0.4, 5, 20),
167 speedPremiumUsd: expect.closeTo(14.7),
168 });
169 // Without rates there is nothing to split it by.
170 expect(priceUsage(table, record("unknown-model", 29.4, "fast"))).toMatchObject({
171 costUsd: 29.4,
172 categoryCostUsd: null,
173 speedPremiumUsd: 0,
174 });
175 });
176
139177 it("prices Codex priority and ultrafast requests at their published tier rates", () => {
140178 const table = parseRateTable({
141179 "gpt-6-astra": {
apps/server/src/usage/usagePricing.ts+54-13
......@@ -7,7 +7,12 @@
77 *
88 * @module usagePricing
99 */
10import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts";
10import type {
11 UsageCategoryCost,
12 UsageCostSource,
13 UsageModelPriceOverride,
14 UsageTokenTotals,
15} from "@t3tools/contracts";
1116
1217import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
1318
......@@ -246,11 +251,31 @@ export type PricedRecord = Pick<
246251export interface PricedUsage {
247252 readonly costUsd: number;
248253 readonly costSource: UsageCostSource;
254 /** `costUsd` by token category, or `null` when no rates are known to split it. */
255 readonly categoryCostUsd: UsageCategoryCost | null;
256 /** What `costUsd` exceeds the same tokens at standard rates. `0` without rates. */
257 readonly speedPremiumUsd: number;
258}
259
260function costByCategory(totals: UsageTokenTotals, rates: TokenRates): UsageCategoryCost {
261 return {
262 input: totals.uncachedInputTokens * rates.inputCostPerToken,
263 cacheRead: totals.cachedInputTokens * rates.cacheReadCostPerToken,
264 cacheWrite: totals.cacheCreationTokens * rates.cacheCreationCostPerToken,
265 output: totals.outputTokens * rates.outputCostPerToken,
266 };
267}
268
269function sumCategories(cost: UsageCategoryCost): number {
270 return cost.input + cost.cacheRead + cost.cacheWrite + cost.output;
249271}
250272
251273/**
252274 * Prices one record's tokens.
253275 *
276 * A provider-reported cost is kept as is, and split by category and speed in
277 * proportion to the model's list rates when those are known.
278 *
254279 * `reasoningTokens` is intentionally not charged separately: it is already
255280 * counted inside `outputTokens`.
256281 */
......@@ -261,21 +286,37 @@ export function priceUsage(
261286): PricedUsage {
262287 const { model, totals, reportedCostUsd } = record;
263288 const override = overrides?.get(model.trim());
264 if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
265 return { costUsd: reportedCostUsd, costSource: "providerReported" };
266 }
267
289 const reported =
290 override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)
291 ? reportedCostUsd
292 : null;
293 const unsplit = (costUsd: number, costSource: UsageCostSource): PricedUsage => ({
294 costUsd,
295 costSource,
296 categoryCostUsd: null,
297 speedPremiumUsd: 0,
298 });
268299 const rate = override ?? lookupRate(table, record.rateModel ?? model);
269 if (rate === null) return { costUsd: 0, costSource: "unpriced" };
300 if (rate === null) {
301 return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported");
302 }
270303
271 const rates = ratesAt(rate, record.speed);
304 const listCost = costByCategory(totals, ratesAt(rate, record.speed));
305 const listCostUsd = sumCategories(listCost);
306 if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported");
307 const premiumUsd =
308 record.speed === "standard" ? 0 : listCostUsd - sumCategories(costByCategory(totals, rate));
309 const scale = reported === null ? 1 : reported / listCostUsd;
272310 return {
273 costUsd:
274 totals.uncachedInputTokens * rates.inputCostPerToken +
275 totals.cachedInputTokens * rates.cacheReadCostPerToken +
276 totals.cacheCreationTokens * rates.cacheCreationCostPerToken +
277 totals.outputTokens * rates.outputCostPerToken,
278 costSource: "modelPriced",
311 costUsd: reported ?? listCostUsd,
312 costSource: reported === null ? "modelPriced" : "providerReported",
313 categoryCostUsd: {
314 input: listCost.input * scale,
315 cacheRead: listCost.cacheRead * scale,
316 cacheWrite: listCost.cacheWrite * scale,
317 output: listCost.output * scale,
318 },
319 speedPremiumUsd: premiumUsd * scale,
279320 };
280321}
281322
apps/web/src/components/usage/UsageModelDialog.tsx created+166
......@@ -0,0 +1,166 @@
1import { formatPercent, formatTokens, formatUsd } from "@t3tools/shared/usageFormat";
2import { isModelCostUnknown, type ModelTotals } from "@t3tools/shared/usageMerge";
3import { useMemo } from "react";
4
5import { mergeAnsweredUsage, type EnvironmentUsageStatus } from "../../state/usage";
6import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
7import { Button } from "../ui/button";
8import {
9 Dialog,
10 DialogDescription,
11 DialogFooter,
12 DialogHeader,
13 DialogPanel,
14 DialogPopup,
15 DialogTitle,
16} from "../ui/dialog";
17import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
18import { UsageShareBar } from "./UsageShareBar";
19import {
20 cacheHitRate,
21 costPerMillionTokens,
22 costTypeSegments,
23 speedCostSegments,
24 tokenTypeSegments,
25} from "./usageBreakdown";
26import { PROVIDER_PRESENTATION } from "./usageProviders";
27
28export interface UsageChartWindow {
29 readonly days: readonly string[];
30 readonly hours: readonly string[];
31 readonly resolution: "day" | "hour";
32 readonly timeZone: string;
33 readonly referenceTime: string | undefined;
34}
35
36/**
37 * One model's usage in the current window, opened from the Breakdown list.
38 * The trend and mixes come from the same merge as the page, narrowed to this
39 * model's buckets.
40 */
41export function UsageModelDialog({
42 model,
43 environments,
44 metric,
45 chartWindow,
46 onSetPrice,
47 onClose,
48}: {
49 readonly model: ModelTotals;
50 readonly environments: readonly EnvironmentUsageStatus[];
51 readonly metric: UsageChartMetric;
52 readonly chartWindow: UsageChartWindow;
53 readonly onSetPrice: () => void;
54 readonly onClose: () => void;
55}) {
56 const usage = useMemo(
57 () =>
58 mergeAnsweredUsage(
59 environments,
60 (bucket) => bucket.provider === model.provider && bucket.model === model.model,
61 ),
62 [environments, model.provider, model.model],
63 );
64 const providers = useMemo(() => [model.provider], [model.provider]);
65 const presentation = PROVIDER_PRESENTATION[model.provider];
66 const costUnknown = isModelCostUnknown(model);
67 const hitRate = cacheHitRate(model);
68 const perMillion = costPerMillionTokens(model);
69 const stats = [
70 { label: "Cost", value: costUnknown ? "Unpriced" : formatUsd(model.costUsd) },
71 { label: "Tokens", value: formatTokens(model.totalTokens) },
72 perMillion === null ? null : { label: "Per 1M tokens", value: formatUsd(perMillion) },
73 hitRate === null ? null : { label: "Cache hit", value: formatPercent(hitRate) },
74 ].filter((stat) => stat !== null);
75
76 return (
77 <Dialog
78 open
79 onOpenChange={(open) => {
80 if (!open) onClose();
81 }}
82 >
83 <DialogPopup className="max-w-3xl">
84 <DialogHeader>
85 <div className="flex items-center gap-2">
86 <ProviderInstanceIcon
87 driverKind={presentation.driverKind}
88 displayName={presentation.label}
89 iconClassName="size-5"
90 />
91 <DialogTitle>{model.model}</DialogTitle>
92 </div>
93 <DialogDescription>
94 {presentation.label}
95 {costUnknown ? "" : ` · ${formatPercent(model.costShare)} of cost`}
96 </DialogDescription>
97 </DialogHeader>
98 <DialogPanel>
99 <div className="flex flex-col gap-8">
100 <div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
101 {stats.map((stat) => (
102 <div key={stat.label} className="flex min-w-0 flex-col gap-0.5">
103 <span className="text-xs text-muted-foreground">{stat.label}</span>
104 <span className="text-2xl font-semibold text-foreground">{stat.value}</span>
105 </div>
106 ))}
107 </div>
108
109 {/* Unpriced cost is unknown, not zero, so its trend shows tokens. */}
110 <UsageProviderChart
111 providers={providers}
112 days={chartWindow.days}
113 daily={usage.daily}
114 hours={chartWindow.hours}
115 hourly={usage.hourly}
116 metric={costUnknown ? "tokens" : metric}
117 referenceTime={chartWindow.referenceTime}
118 resolution={chartWindow.resolution}
119 timeZone={chartWindow.timeZone}
120 />
121
122 <div className="grid gap-x-10 gap-y-8 sm:grid-cols-2">
123 {costUnknown ? null : (
124 <UsageShareBar
125 label="Cost by type"
126 segments={costTypeSegments(usage.categoryCost)}
127 format={formatUsd}
128 />
129 )}
130 <UsageShareBar
131 label="Tokens by type"
132 segments={tokenTypeSegments(model.tokens)}
133 format={formatTokens}
134 />
135 {usage.speedCost.fast + usage.speedCost.ultrafast > 0 ? (
136 <UsageShareBar
137 label="Cost by speed"
138 segments={speedCostSegments(usage.speedCost)}
139 format={formatUsd}
140 aside={<SpeedPremium premiumUsd={usage.speedCost.premium} />}
141 />
142 ) : null}
143 </div>
144 </div>
145 </DialogPanel>
146 {model.unpricedTokens > 0 ? (
147 <DialogFooter variant="bare" className="items-center sm:justify-between">
148 <span className="text-xs text-muted-foreground">
149 {formatTokens(model.unpricedTokens)} tokens have no known price
150 </span>
151 <Button onClick={onSetPrice}>Set price</Button>
152 </DialogFooter>
153 ) : null}
154 </DialogPopup>
155 </Dialog>
156 );
157}
158
159/** What the faster speeds cost above standard rates, beside the speed bar. */
160export function SpeedPremium({ premiumUsd }: { readonly premiumUsd: number }) {
161 return (
162 <span className="text-xs text-muted-foreground">
163 Premium <span className="text-foreground tabular-nums">{formatUsd(premiumUsd)}</span>
164 </span>
165 );
166}
apps/web/src/components/usage/UsagePage.tsx+259-164
......@@ -78,7 +78,14 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader";
7878import { UsageLimitsSection } from "./UsageLimits";
7979import { UsagePriceOverrides } from "./UsagePriceOverrides";
8080import { UsageProviderChart } from "./UsageProviderChart";
81import { sortModelsByTokens } from "./usageBreakdown";
81import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";
82import { UsageShareBar } from "./UsageShareBar";
83import {
84 costTypeSegments,
85 sortModelsByTokens,
86 speedCostSegments,
87 tokenTypeSegments,
88} from "./usageBreakdown";
8289import {
8390 METRIC_OPTIONS,
8491 WINDOW_OPTIONS,
......@@ -127,6 +134,8 @@ export function UsagePage() {
127134 const [limitsNow, setLimitsNow] = useState(() => Date.now());
128135 const refreshingRef = useRef(false);
129136 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
137 const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null);
138 const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
130139 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
131140 useState<ReadonlySet<EnvironmentId> | null>(null);
132141 const { days: windowDays, window } = windowSelection;
......@@ -182,6 +191,14 @@ export function UsagePage() {
182191 [breakdown, merged.models, metric],
183192 );
184193 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
194 const selectedModel =
195 selectedModelKey === null
196 ? undefined
197 : merged.models.find((model) => `${model.provider}:${model.model}` === selectedModelKey);
198 const breakdownPeak = breakdownModels.reduce(
199 (peak, model) => Math.max(peak, metric === "tokens" ? model.totalTokens : model.costUsd),
200 0,
201 );
185202 const summaryRows: Array<
186203 | { readonly kind: "usage"; readonly provider: UsageProviderKind }
187204 | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus }
......@@ -322,6 +339,7 @@ export function UsagePage() {
322339 isPartial={isPartial}
323340 duplicateSources={merged.duplicateSources}
324341 contractMismatches={merged.contractMismatches}
342 onOpenModelPrices={() => setPriceDialog({})}
325343 />
326344 </WorkspaceBreadcrumbItem>
327345 </WorkspaceBreadcrumb>
......@@ -625,6 +643,34 @@ export function UsagePage() {
625643 </div>
626644 </section>
627645
646 {merged.totalTokens > 0 ? (
647 <section className="grid gap-x-12 gap-y-8 lg:grid-cols-2">
648 {metric === "tokens" ? (
649 <UsageShareBar
650 label="Tokens by type"
651 segments={tokenTypeSegments(merged)}
652 format={formatTokens}
653 />
654 ) : (
655 <>
656 <UsageShareBar
657 label="Cost by type"
658 segments={costTypeSegments(merged.categoryCost)}
659 format={formatUsd}
660 />
661 {merged.speedCost.fast + merged.speedCost.ultrafast > 0 ? (
662 <UsageShareBar
663 label="Cost by speed"
664 segments={speedCostSegments(merged.speedCost)}
665 format={formatUsd}
666 aside={<SpeedPremium premiumUsd={merged.speedCost.premium} />}
667 />
668 ) : null}
669 </>
670 )}
671 </section>
672 ) : null}
673
628674 <section className="flex flex-col gap-3">
629675 <div className="flex items-center justify-between gap-3">
630676 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
......@@ -651,55 +697,73 @@ export function UsagePage() {
651697 </div>
652698
653699 {breakdown === "model" ? (
654 <table className="w-full table-fixed text-sm">
655 <colgroup>
656 <col className="w-2/5" />
657 <col className="w-1/5" />
658 <col className="w-1/5" />
659 <col className="w-1/5" />
660 </colgroup>
700 <table className="w-full text-sm">
661701 <thead>
662 <tr className="border-b border-border text-left text-xs text-muted-foreground">
663 <th className="py-2 font-normal">Model</th>
664 <th className="py-2 text-right font-normal">Cost</th>
665 <th className="py-2 text-right font-normal">Share</th>
666 <th className="py-2 text-right font-normal">Tokens</th>
702 <tr className="border-b border-border text-right text-xs text-muted-foreground">
703 <th className="py-2 pr-3 text-left font-normal">#</th>
704 <th className="w-full py-2 text-left font-normal">Model</th>
705 <th className="py-2 pl-6 font-normal">Cost</th>
706 <th className="hidden py-2 pl-6 font-normal sm:table-cell">Share</th>
707 <th className="py-2 pl-6 font-normal">Tokens</th>
667708 </tr>
668709 </thead>
669710 <tbody>
670711 {breakdownModels.length === 0 ? (
671712 <tr>
672 <td colSpan={4} className="py-6 text-center text-muted-foreground">
713 <td colSpan={5} className="py-6 text-center text-muted-foreground">
673714 No activity in this window.
674715 </td>
675716 </tr>
676717 ) : (
677 breakdownModels.map((model) => (
678 <tr
679 key={`${model.provider}:${model.model}`}
680 className="border-b border-border/50 transition-colors hover:bg-muted/50"
681 >
682 <td className="py-2 text-foreground">
683 <span className="flex items-center gap-2">
684 <ProviderMark provider={model.provider} className="size-3.5" />
685 {model.model}
686 </span>
687 </td>
688 <td className="py-2 text-right text-foreground tabular-nums">
689 {isModelCostUnknown(model) ? (
690 <span className="text-muted-foreground">Unpriced</span>
691 ) : (
692 formatUsd(model.costUsd)
693 )}
694 </td>
695 <td className="py-2 text-right text-muted-foreground tabular-nums">
696 {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)}
697 </td>
698 <td className="py-2 text-right text-muted-foreground tabular-nums">
699 {formatTokens(model.totalTokens)}
700 </td>
701 </tr>
702 ))
718 breakdownModels.map((model, index) => {
719 const key = `${model.provider}:${model.model}`;
720 const value = metric === "tokens" ? model.totalTokens : model.costUsd;
721 return (
722 <tr
723 key={key}
724 className="relative border-b border-border/50 text-right whitespace-nowrap text-muted-foreground tabular-nums transition-colors hover:bg-muted/50 has-focus-visible:bg-muted/50"
725 >
726 <td className="py-2.5 pr-3 text-left text-xs">{index + 1}</td>
727 <td className="py-2.5 text-left whitespace-normal">
728 {/* The button's overlay makes the whole row open the model.
729 Focus shows as the row's hover fill, not a ring. */}
730 <button
731 type="button"
732 onClick={() => setSelectedModelKey(key)}
733 className="flex items-center gap-2 text-left text-foreground outline-none after:absolute after:inset-0"
734 >
735 <ProviderMark provider={model.provider} className="size-3.5" />
736 {model.model}
737 </button>
738 <div aria-hidden className="mt-1.5 h-0.5 max-w-48">
739 <div
740 className="h-full rounded-full"
741 style={{
742 // A short minimum keeps tiny shares a dash, not a dot.
743 width:
744 value > 0 && breakdownPeak > 0
745 ? `max(0.5rem, ${(value / breakdownPeak) * 100}%)`
746 : 0,
747 backgroundColor:
748 PROVIDER_PRESENTATION[model.provider].color,
749 }}
750 />
751 </div>
752 </td>
753 <td className="py-2.5 pl-6 text-foreground">
754 {isModelCostUnknown(model) ? (
755 <span className="text-muted-foreground">Unpriced</span>
756 ) : (
757 formatUsd(model.costUsd)
758 )}
759 </td>
760 <td className="hidden py-2.5 pl-6 sm:table-cell">
761 {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)}
762 </td>
763 <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td>
764 </tr>
765 );
766 })
703767 )}
704768 </tbody>
705769 </table>
......@@ -772,6 +836,35 @@ export function UsagePage() {
772836 </WorkspacePageContainer>
773837 </ScrollArea>
774838 </div>
839 {selectedModel !== undefined && !showingLimits ? (
840 <UsageModelDialog
841 model={selectedModel}
842 environments={selectedEnvironments}
843 metric={metric === "tokens" ? "tokens" : "cost"}
844 chartWindow={{
845 days,
846 hours,
847 resolution: isPast24Hours ? "hour" : "day",
848 timeZone: window.timeZone,
849 referenceTime: window.untilTime,
850 }}
851 onSetPrice={() => {
852 setSelectedModelKey(null);
853 setPriceDialog({ model: selectedModel.model });
854 }}
855 onClose={() => setSelectedModelKey(null)}
856 />
857 ) : null}
858 {priceDialog ? (
859 <UsagePriceOverrides
860 usage={environments}
861 initialSelectedEnvironmentIds={selectedEnvironmentIds}
862 initialModel={priceDialog.model}
863 onOpenChange={(open) => {
864 if (!open) setPriceDialog(null);
865 }}
866 />
867 ) : null}
775868 </SidebarInset>
776869 );
777870}
......@@ -989,6 +1082,7 @@ function UsageEnvironmentFilter({
9891082 isPartial,
9901083 duplicateSources,
9911084 contractMismatches,
1085 onOpenModelPrices,
9921086}: {
9931087 readonly environments: readonly EnvironmentUsageStatus[];
9941088 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
......@@ -998,8 +1092,8 @@ function UsageEnvironmentFilter({
9981092 readonly isPartial: boolean;
9991093 readonly duplicateSources: readonly string[];
10001094 readonly contractMismatches: MergedUsage["contractMismatches"];
1095 readonly onOpenModelPrices: () => void;
10011096}) {
1002 const [modelPricesOpen, setModelPricesOpen] = useState(false);
10031097 const allSelected = selectedEnvironmentIds === null;
10041098 const label = allSelected
10051099 ? "All environments"
......@@ -1015,121 +1109,108 @@ function UsageEnvironmentFilter({
10151109 contractMismatches.length > 0;
10161110
10171111 return (
1018 <>
1019 <Menu>
1020 <MenuTrigger
1021 render={<InlineButton />}
1022 className="group/usage-environment min-w-0 max-w-full"
1023 >
1024 <span className="min-w-0 truncate">{label}</span>
1025 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1026 {showUsageStatus && pendingCount > 0 ? (
1027 <>
1028 <CircleDashedIcon className="size-3.5" aria-hidden />
1029 <span className="sr-only">
1030 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still
1031 scanning
1032 {isPartial ? "; totals are partial" : ""}
1033 </span>
1034 </>
1035 ) : showUsageStatus && hasIssue ? (
1036 <CircleAlertIcon
1037 className="size-3.5 text-warning-foreground"
1038 aria-label="Some environments could not report usage"
1039 />
1040 ) : (
1041 <ChevronDownIcon
1042 className="size-3.5 opacity-0 transition-opacity group-hover/usage-environment:opacity-100 group-focus-visible/usage-environment:opacity-100 group-data-popup-open/usage-environment:opacity-100"
1043 aria-hidden
1044 />
1045 )}
1046 </span>
1047 </MenuTrigger>
1048 <MenuPopup align="start">
1049 <MenuCheckboxItem
1050 checked={allSelected}
1051 closeOnClick={false}
1052 onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())}
1053 >
1054 All environments
1055 </MenuCheckboxItem>
1056 <MenuSeparator />
1057 {environments.map((environment) => {
1058 const checked =
1059 selectedEnvironmentIds === null ||
1060 selectedEnvironmentIds.has(environment.environmentId);
1061 const status =
1062 environment.error !== null
1063 ? "Unavailable"
1064 : environment.summary !== null &&
1065 !isCompatibleUsageContractVersion(
1066 environment.summary.contractVersion,
1067 USAGE_CONTRACT_VERSION,
1068 )
1069 ? "Update required"
1070 : environment.summary === null
1071 ? "Scanning…"
1072 : environment.isPending
1073 ? "Refreshing…"
1074 : "Ready";
1075 return (
1076 <MenuCheckboxItem
1077 key={environment.environmentId}
1078 checked={checked}
1079 closeOnClick={false}
1080 onCheckedChange={(nextChecked) => {
1081 const next = new Set(selectedEnvironments.map((entry) => entry.environmentId));
1082 if (nextChecked) next.add(environment.environmentId);
1083 else next.delete(environment.environmentId);
1084 onSelectionChange(next.size === environments.length ? null : next);
1085 }}
1086 >
1087 <span className="flex min-w-0 items-center gap-3">
1088 <span className="min-w-0 flex-1 truncate">{environment.label}</span>
1089 {showUsageStatus ? (
1090 <span
1091 className={cn(
1092 "shrink-0 text-xs text-muted-foreground",
1093 environment.error !== null && "text-destructive",
1094 )}
1095 >
1096 {status}
1097 </span>
1098 ) : null}
1099 </span>
1100 </MenuCheckboxItem>
1101 );
1102 })}
1103 {environments.length === 0 ? (
1104 <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>
1105 ) : null}
1106 {showUsageStatus && isPartial ? (
1107 <p className="px-2 py-2 text-xs text-muted-foreground">
1108 Totals are partial while selected environments scan.
1109 </p>
1110 ) : null}
1111 {showUsageStatus ? (
1112 <UsageCoverageNotice
1113 environments={selectedEnvironments}
1114 duplicateSources={duplicateSources}
1115 contractMismatches={contractMismatches}
1112 <Menu>
1113 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">
1114 <span className="min-w-0 truncate">{label}</span>
1115 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1116 {showUsageStatus && pendingCount > 0 ? (
1117 <>
1118 <CircleDashedIcon className="size-3.5" aria-hidden />
1119 <span className="sr-only">
1120 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning
1121 {isPartial ? "; totals are partial" : ""}
1122 </span>
1123 </>
1124 ) : showUsageStatus && hasIssue ? (
1125 <CircleAlertIcon
1126 className="size-3.5 text-warning-foreground"
1127 aria-label="Some environments could not report usage"
11161128 />
1117 ) : null}
1118 <MenuSeparator />
1119 <MenuItem onClick={() => setModelPricesOpen(true)}>
1120 <SlidersHorizontalIcon aria-hidden />
1121 Model prices
1122 </MenuItem>
1123 </MenuPopup>
1124 </Menu>
1125 {modelPricesOpen ? (
1126 <UsagePriceOverrides
1127 usage={environments}
1128 initialSelectedEnvironmentIds={selectedEnvironmentIds}
1129 onOpenChange={setModelPricesOpen}
1130 />
1131 ) : null}
1132 </>
1129 ) : (
1130 <ChevronDownIcon
1131 className="size-3.5 opacity-0 transition-opacity group-hover/usage-environment:opacity-100 group-focus-visible/usage-environment:opacity-100 group-data-popup-open/usage-environment:opacity-100"
1132 aria-hidden
1133 />
1134 )}
1135 </span>
1136 </MenuTrigger>
1137 <MenuPopup align="start">
1138 <MenuCheckboxItem
1139 checked={allSelected}
1140 closeOnClick={false}
1141 onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())}
1142 >
1143 All environments
1144 </MenuCheckboxItem>
1145 <MenuSeparator />
1146 {environments.map((environment) => {
1147 const checked =
1148 selectedEnvironmentIds === null ||
1149 selectedEnvironmentIds.has(environment.environmentId);
1150 const status =
1151 environment.error !== null
1152 ? "Unavailable"
1153 : environment.summary !== null &&
1154 !isCompatibleUsageContractVersion(
1155 environment.summary.contractVersion,
1156 USAGE_CONTRACT_VERSION,
1157 )
1158 ? "Update required"
1159 : environment.summary === null
1160 ? "Scanning…"
1161 : environment.isPending
1162 ? "Refreshing…"
1163 : "Ready";
1164 return (
1165 <MenuCheckboxItem
1166 key={environment.environmentId}
1167 checked={checked}
1168 closeOnClick={false}
1169 onCheckedChange={(nextChecked) => {
1170 const next = new Set(selectedEnvironments.map((entry) => entry.environmentId));
1171 if (nextChecked) next.add(environment.environmentId);
1172 else next.delete(environment.environmentId);
1173 onSelectionChange(next.size === environments.length ? null : next);
1174 }}
1175 >
1176 <span className="flex min-w-0 items-center gap-3">
1177 <span className="min-w-0 flex-1 truncate">{environment.label}</span>
1178 {showUsageStatus ? (
1179 <span
1180 className={cn(
1181 "shrink-0 text-xs text-muted-foreground",
1182 environment.error !== null && "text-destructive",
1183 )}
1184 >
1185 {status}
1186 </span>
1187 ) : null}
1188 </span>
1189 </MenuCheckboxItem>
1190 );
1191 })}
1192 {environments.length === 0 ? (
1193 <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>
1194 ) : null}
1195 {showUsageStatus && isPartial ? (
1196 <p className="px-2 py-2 text-xs text-muted-foreground">
1197 Totals are partial while selected environments scan.
1198 </p>
1199 ) : null}
1200 {showUsageStatus ? (
1201 <UsageCoverageNotice
1202 environments={selectedEnvironments}
1203 duplicateSources={duplicateSources}
1204 contractMismatches={contractMismatches}
1205 />
1206 ) : null}
1207 <MenuSeparator />
1208 <MenuItem onClick={onOpenModelPrices}>
1209 <SlidersHorizontalIcon aria-hidden />
1210 Model prices
1211 </MenuItem>
1212 </MenuPopup>
1213 </Menu>
11331214 );
11341215}
11351216
......@@ -1173,15 +1254,16 @@ function UsageSkeleton() {
11731254
11741255 <section className="flex flex-col gap-2">
11751256 <h2 className="text-sm font-medium text-foreground">Totals</h2>
1176 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
1177 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
1178 (label) => (
1179 <div key={label} className="flex flex-col gap-0.5">
1180 <span className="text-xs text-muted-foreground">{label}</span>
1181 <Skeleton className="h-6 w-16" />
1182 </div>
1183 ),
1184 )}
1257 <MetricSkeletons
1258 labels={["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"]}
1259 />
1260 </section>
1261
1262 <section className="grid gap-x-12 gap-y-8 lg:grid-cols-2">
1263 <div className="flex flex-col gap-2.5">
1264 <Skeleton className="h-5 w-28" />
1265 <Skeleton className="h-2" />
1266 <Skeleton className="h-4 w-72" />
11851267 </div>
11861268 </section>
11871269
......@@ -1195,3 +1277,16 @@ function UsageSkeleton() {
11951277 </>
11961278 );
11971279}
1280
1281function MetricSkeletons({ labels }: { readonly labels: readonly string[] }) {
1282 return (
1283 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
1284 {labels.map((label) => (
1285 <div key={label} className="flex flex-col gap-0.5">
1286 <span className="text-xs text-muted-foreground">{label}</span>
1287 <Skeleton className="h-6 w-16" />
1288 </div>
1289 ))}
1290 </div>
1291 );
1292}
apps/web/src/components/usage/UsagePriceOverrides.tsx+13-1
......@@ -91,13 +91,16 @@ type SaveAttempt = {
9191 >;
9292};
9393
94/** Edits custom model prices. `initialModel` opens with a new row for that model. */
9495export function UsagePriceOverrides({
9596 usage,
9697 initialSelectedEnvironmentIds,
98 initialModel,
9799 onOpenChange,
98100}: {
99101 readonly usage: readonly EnvironmentUsageStatus[];
100102 readonly initialSelectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
103 readonly initialModel?: string | undefined;
101104 readonly onOpenChange: (open: boolean) => void;
102105}) {
103106 const environments = useAtomValue(priceTargetsAtom);
......@@ -105,7 +108,16 @@ export function UsagePriceOverrides({
105108 const selected = environments.filter(
106109 (environment) => selectedIds === null || selectedIds.has(environment.environmentId),
107110 );
108 const [drafts, setDrafts] = useState<readonly UsagePriceDraft[]>([]);
111 // A model that already has a custom price somewhere is edited in its existing row.
112 const [drafts, setDrafts] = useState<readonly UsagePriceDraft[]>(() =>
113 initialModel === undefined ||
114 selected.some(
115 (environment) =>
116 environment.prices !== null && Object.hasOwn(environment.prices, initialModel),
117 )
118 ? []
119 : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }],
120 );
109121 const [pending, setPending] = useState(false);
110122 const [attempt, setAttempt] = useState<SaveAttempt | null>(null);
111123 const focusRowRef = useRef<string | null>(null);
apps/web/src/components/usage/UsageShareBar.tsx created+73
......@@ -0,0 +1,73 @@
1import { formatPercent } from "@t3tools/shared/usageFormat";
2import type { ReactNode } from "react";
3
4import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5
6export interface ShareSegment {
7 readonly label: string;
8 readonly value: number;
9 readonly color: string;
10}
11
12/**
13 * One part-to-whole bar with its legend, for cost or tokens split by type or
14 * speed. Empty segments are left out, and nothing renders without a total.
15 */
16export function UsageShareBar({
17 label,
18 segments,
19 format,
20 aside,
21}: {
22 readonly label: string;
23 readonly segments: readonly ShareSegment[];
24 readonly format: (value: number) => string;
25 readonly aside?: ReactNode;
26}) {
27 const visible = segments.filter((segment) => segment.value > 0);
28 const total = visible.reduce((sum, segment) => sum + segment.value, 0);
29 if (total <= 0) return null;
30
31 return (
32 <div className="flex min-w-0 flex-col gap-2.5">
33 <div className="flex items-baseline justify-between gap-3">
34 <h3 className="text-sm font-medium text-foreground">{label}</h3>
35 {aside}
36 </div>
37 <div
38 role="img"
39 aria-label={`${label}: ${visible.map((segment) => `${segment.label} ${format(segment.value)}`).join(", ")}`}
40 className="flex h-2 gap-0.5"
41 >
42 {visible.map((segment) => (
43 <Tooltip key={segment.label}>
44 <TooltipTrigger
45 render={
46 <div
47 className="h-full min-w-1 rounded-xs first:rounded-l-full last:rounded-r-full"
48 style={{ flex: `${segment.value} 1 0`, backgroundColor: segment.color }}
49 />
50 }
51 />
52 <TooltipPopup>
53 {segment.label} · {format(segment.value)} · {formatPercent(segment.value / total)}
54 </TooltipPopup>
55 </Tooltip>
56 ))}
57 </div>
58 <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
59 {visible.map((segment) => (
60 <span key={segment.label} className="flex items-center gap-1.5">
61 <span
62 aria-hidden
63 className="size-2 rounded-xs"
64 style={{ backgroundColor: segment.color }}
65 />
66 <span className="text-muted-foreground">{segment.label}</span>
67 <span className="text-foreground tabular-nums">{format(segment.value)}</span>
68 </span>
69 ))}
70 </div>
71 </div>
72 );
73}
apps/web/src/components/usage/usageBreakdown.test.ts+37-2
......@@ -1,16 +1,30 @@
11import type { ModelTotals } from "@t3tools/shared/usageMerge";
22import { describe, expect, it } from "vite-plus/test";
33
4import { sortModelsByTokens } from "./usageBreakdown";
4import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown";
55
6const model = (name: string, totalTokens: number, costUsd: number): ModelTotals => ({
6const model = (
7 name: string,
8 totalTokens: number,
9 costUsd: number,
10 overrides: Partial<ModelTotals> = {},
11): ModelTotals => ({
712 model: name,
813 provider: "codex",
914 costUsd,
1015 totalTokens,
16 tokens: {
17 uncachedInputTokens: totalTokens,
18 cachedInputTokens: 0,
19 cacheCreationTokens: 0,
20 outputTokens: 0,
21 reasoningTokens: 0,
22 },
1123 records: 1,
1224 unpricedRecords: 0,
25 unpricedTokens: 0,
1326 costShare: 0,
27 ...overrides,
1428});
1529
1630describe("sortModelsByTokens", () => {
......@@ -29,3 +43,24 @@ describe("sortModelsByTokens", () => {
2943 expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]);
3044 });
3145});
46
47describe("model rates", () => {
48 it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => {
49 const mixed = model("mixed", 4_000_000, 6, {
50 tokens: {
51 uncachedInputTokens: 1_000_000,
52 cachedInputTokens: 1_000_000,
53 cacheCreationTokens: 2_000_000,
54 outputTokens: 0,
55 reasoningTokens: 0,
56 },
57 records: 4,
58 unpricedRecords: 1,
59 unpricedTokens: 1_000_000,
60 });
61
62 expect(cacheHitRate(mixed)).toBe(0.25);
63 expect(costPerMillionTokens(mixed)).toBe(2);
64 expect(costPerMillionTokens({ ...mixed, unpricedRecords: 4 })).toBeNull();
65 });
66});
apps/web/src/components/usage/usageBreakdown.ts+71-1
......@@ -1,7 +1,77 @@
1import type { ModelTotals } from "@t3tools/shared/usageMerge";
1import type { UsageTokenTotals } from "@t3tools/contracts";
2import {
3 isModelCostUnknown,
4 type CategoryCost,
5 type ModelTotals,
6 type SpeedCost,
7} from "@t3tools/shared/usageMerge";
8
9import type { ShareSegment } from "./UsageShareBar";
210
311export function sortModelsByTokens(models: readonly ModelTotals[]) {
412 return models.toSorted(
513 (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd,
614 );
715}
16
17/**
18 * Share of a model's input read from cache, or `null` without input. Cache
19 * writes count as misses: that input was processed in full.
20 */
21export function cacheHitRate({ tokens }: ModelTotals): number | null {
22 const input = tokens.uncachedInputTokens + tokens.cachedInputTokens + tokens.cacheCreationTokens;
23 return input === 0 ? null : tokens.cachedInputTokens / input;
24}
25
26/** Effective USD per million priced tokens, or `null` when none were priced. */
27export function costPerMillionTokens(model: ModelTotals): number | null {
28 const pricedTokens = model.totalTokens - model.unpricedTokens;
29 return pricedTokens <= 0 || isModelCostUnknown(model)
30 ? null
31 : (model.costUsd / pricedTokens) * 1_000_000;
32}
33
34/** A neutral step between background and foreground, so mixes never borrow a provider's color. */
35const ink = (percent: number) =>
36 `color-mix(in oklab, var(--foreground) ${percent}%, var(--background))`;
37
38/** Adjacent segments stay above the 15 ΔE separation floor in both themes. */
39const TYPE_COLORS = {
40 input: ink(60),
41 cacheRead: ink(30),
42 cacheWrite: ink(72),
43 output: ink(100),
44 other: ink(44),
45};
46
47export function costTypeSegments(cost: CategoryCost): readonly ShareSegment[] {
48 return [
49 { label: "Input", value: cost.input, color: TYPE_COLORS.input },
50 { label: "Cache read", value: cost.cacheRead, color: TYPE_COLORS.cacheRead },
51 { label: "Cache write", value: cost.cacheWrite, color: TYPE_COLORS.cacheWrite },
52 { label: "Output", value: cost.output, color: TYPE_COLORS.output },
53 // Reported cost with no rates to split it, or from older servers. Below a
54 // cent it is rounding, not usage.
55 { label: "Other", value: cost.unsplit >= 0.005 ? cost.unsplit : 0, color: TYPE_COLORS.other },
56 ];
57}
58
59export function tokenTypeSegments(
60 tokens: Omit<UsageTokenTotals, "reasoningTokens">,
61): readonly ShareSegment[] {
62 return [
63 { label: "Input", value: tokens.uncachedInputTokens, color: TYPE_COLORS.input },
64 { label: "Cache read", value: tokens.cachedInputTokens, color: TYPE_COLORS.cacheRead },
65 { label: "Cache write", value: tokens.cacheCreationTokens, color: TYPE_COLORS.cacheWrite },
66 { label: "Output", value: tokens.outputTokens, color: TYPE_COLORS.output },
67 ];
68}
69
70/** Speeds are ordered by price, so they brighten from standard to ultrafast. */
71export function speedCostSegments(cost: SpeedCost): readonly ShareSegment[] {
72 return [
73 { label: "Standard", value: cost.standard, color: ink(34) },
74 { label: "Fast", value: cost.fast, color: ink(66) },
75 { label: "Ultrafast", value: cost.ultrafast, color: ink(100) },
76 ];
77}
apps/web/src/state/usage.ts+29-14
......@@ -10,6 +10,7 @@ import { useAtomValue } from "@effect/atom-react";
1010import {
1111 USAGE_CONTRACT_VERSION,
1212 type EnvironmentId,
13 type UsageBucket,
1314 type UsageSummary,
1415 type UsageSummaryInput,
1516} from "@t3tools/contracts";
......@@ -79,6 +80,33 @@ export interface UsageView {
7980 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
8081}
8182
83/**
84 * Merges every environment that has answered. `keepBucket` narrows the merge,
85 * for example to one model; source ownership still applies, so the result
86 * matches that slice of the full merge. Session counts are per directory and
87 * are not narrowed.
88 */
89export function mergeAnsweredUsage(
90 environments: readonly EnvironmentUsageStatus[],
91 keepBucket?: (bucket: UsageBucket) => boolean,
92): MergedUsage {
93 const answered: EnvironmentUsage[] = environments.flatMap(({ environmentId, label, summary }) =>
94 summary === null
95 ? []
96 : [
97 {
98 environmentId,
99 label,
100 summary:
101 keepBucket === undefined
102 ? summary
103 : { ...summary, buckets: summary.buckets.filter(keepBucket) },
104 },
105 ],
106 );
107 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
108}
109
82110export function useUsage(
83111 input: UsageSummaryInput,
84112 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
......@@ -126,20 +154,7 @@ export function useUsage(
126154 [selectedEnvironments, windowKey],
127155 );
128156
129 const merged = useMemo(() => {
130 const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
131 environment.summary === null
132 ? []
133 : [
134 {
135 environmentId: environment.environmentId,
136 label: environment.label,
137 summary: environment.summary,
138 },
139 ],
140 );
141 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
142 }, [selectedEnvironments]);
157 const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]);
143158
144159 const answeredCount = selectedEnvironments.filter(
145160 (environment) => environment.summary !== null,
docs/user/usage.md+6-2
......@@ -8,7 +8,10 @@ desktop when the terminal is not focused. Customize `usage.open` in
88
99**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected
1010environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent
11cost. These estimates are not your subscription bill.
11cost, split by token type and by speed. These estimates are not your subscription bill.
12**Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be
13split, such as a provider-reported cost for a model without public rates, shows as **Other**.
14Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens.
1215
1316Totals depend on the history available on each server. Grok turns without a saved completed-turn
1417record are missing from the totals.
......@@ -47,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod
4750edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter;
4851choose all environments or select individual destinations. Enter the exact model ID and USD
4952rates per million input and output tokens. You can enter any model ID, including models
50without public pricing.
53without public pricing. When a model on **Usage** has no known price, select it under
54**Breakdown** and choose **Set price** to open this table with that model added.
5155
5256Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for
5357tokens that are free. Saved prices replace automatic pricing for all of that environment's
packages/contracts/src/usage.ts+24-1
......@@ -18,7 +18,8 @@ import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from ".
1818 * client renders partial coverage when an environment reports an older version
1919 * rather than failing the whole page.
2020 * Adding providers or other array-element variants is additive: unknown
21 * entries are skipped on decode and do not require a version bump.
21 * entries are skipped on decode and do not require a version bump. So are
22 * optional bucket fields, which older clients ignore.
2223 */
2324export const USAGE_CONTRACT_VERSION = 6 as const;
2425
......@@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({
8485});
8586export type UsageTokenTotals = typeof UsageTokenTotals.Type;
8687
88/**
89 * A bucket's cost split by token category, in USD. A provider-reported cost is
90 * split in proportion to the model's list rates.
91 */
92export const UsageCategoryCost = Schema.Struct({
93 input: Schema.Number,
94 cacheRead: Schema.Number,
95 cacheWrite: Schema.Number,
96 output: Schema.Number,
97});
98export type UsageCategoryCost = typeof UsageCategoryCost.Type;
99
87100/**
88101 * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start
89102 * instant of a rolling bucket and is present only for hourly requests.
......@@ -108,6 +121,16 @@ export const UsageBucket = Schema.Struct({
108121 * rather than derived on the client.
109122 */
110123 cacheSavingsUsd: Schema.Number,
124 /**
125 * `costUsd` by token category. Cost with no known rates stays out of it, and
126 * it is absent when nothing could be split or the server predates it.
127 */
128 categoryCostUsd: Schema.optional(UsageCategoryCost),
129 /** Cost of fast and ultrafast requests. Absent when zero; the rest is standard. */
130 fastCostUsd: Schema.optional(Schema.Number),
131 ultrafastCostUsd: Schema.optional(Schema.Number),
132 /** What fast and ultrafast requests cost above the standard rate. Absent when zero. */
133 speedPremiumUsd: Schema.optional(Schema.Number),
111134 costSource: UsageCostSource,
112135 /** Distinct assistant responses, after de-duplication. */
113136 records: NonNegativeInt,
packages/shared/src/usageMerge.test.ts+61
......@@ -546,6 +546,67 @@ describe("mergeUsage", () => {
546546 ]);
547547 });
548548
549 it("splits cost by category and speed, counting older servers as unsplit standard cost", () => {
550 const merged = mergeUsage(
551 [
552 environment(
553 "env-a",
554 summary(
555 [
556 bucket({
557 costUsd: 10,
558 categoryCostUsd: { input: 1, cacheRead: 2, cacheWrite: 3, output: 4 },
559 fastCostUsd: 6,
560 speedPremiumUsd: 3,
561 }),
562 bucket({
563 provider: "codex",
564 model: "unknown-model",
565 costUsd: 0,
566 costSource: "unpriced",
567 unpricedRecords: 5,
568 }),
569 // Reported cost on one record, no rates for the other four.
570 bucket({
571 provider: "codex",
572 model: "partly-reported",
573 costUsd: 0,
574 unpricedRecords: 4,
575 }),
576 ],
577 [
578 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
579 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
580 ],
581 ),
582 ),
583 environment(
584 "env-b",
585 summary(
586 [bucket({ costUsd: 5 })],
587 [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }],
588 USAGE_MERGE_COMPATIBLE_SINCE,
589 ),
590 ),
591 ],
592 USAGE_CONTRACT_VERSION,
593 );
594
595 expect(merged.categoryCost).toEqual({
596 input: 1,
597 cacheRead: 2,
598 cacheWrite: 3,
599 output: 4,
600 unsplit: 5,
601 });
602 expect(merged.speedCost).toEqual({ standard: 9, fast: 6, ultrafast: 0, premium: 3 });
603 expect(merged.models.map(({ model, unpricedTokens }) => [model, unpricedTokens])).toEqual([
604 ["claude-fable-5", 0],
605 ["unknown-model", 1160],
606 ["partly-reported", 928],
607 ]);
608 });
609
549610 it("orders models by cost descending", () => {
550611 const merged = mergeUsage(
551612 [
packages/shared/src/usageMerge.ts+81
......@@ -14,6 +14,7 @@ import {
1414 type UsageSource,
1515 type UsageSourceFingerprint,
1616 type UsageSummary,
17 type UsageTokenTotals,
1718} from "@t3tools/contracts";
1819
1920export interface EnvironmentUsage {
......@@ -37,12 +38,18 @@ export interface ModelTotals {
3738 readonly provider: UsageProviderKind;
3839 readonly costUsd: number;
3940 readonly totalTokens: number;
41 readonly tokens: UsageTokenTotals;
4042 readonly records: number;
4143 /**
4244 * Records whose tokens are counted here but which contributed nothing to
4345 * `costUsd`. When it equals `records` the cost is unknown, not zero.
4446 */
4547 readonly unpricedRecords: number;
48 /**
49 * Tokens with no known rates, which a custom price would cover. A cell that
50 * mixes these with reported costs counts its tokens by record share.
51 */
52 readonly unpricedTokens: number;
4653 readonly costShare: number;
4754}
4855
......@@ -76,6 +83,27 @@ export interface CostQuality {
7683 readonly cacheSavingsUsd: number;
7784}
7885
86/**
87 * `costUsd` by token category. `unsplit` is cost no rates could split,
88 * including all cost from servers that predate the split.
89 */
90export interface CategoryCost {
91 readonly input: number;
92 readonly cacheRead: number;
93 readonly cacheWrite: number;
94 readonly output: number;
95 readonly unsplit: number;
96}
97
98/** `costUsd` by request speed. Servers that predate speeds count as standard. */
99export interface SpeedCost {
100 readonly standard: number;
101 readonly fast: number;
102 readonly ultrafast: number;
103 /** What fast and ultrafast requests cost above the standard rate. */
104 readonly premium: number;
105}
106
79107export interface UsageContractMismatch {
80108 readonly environmentId: EnvironmentId;
81109 readonly direction: "serverBehind" | "clientBehind";
......@@ -97,6 +125,8 @@ export interface MergedUsage {
97125 readonly daily: readonly DailyTotals[];
98126 readonly hourly: readonly HourlyTotals[];
99127 readonly costQuality: CostQuality;
128 readonly categoryCost: CategoryCost;
129 readonly speedCost: SpeedCost;
100130 /** Environments whose data was dropped as a duplicate of another's. */
101131 readonly duplicateSources: readonly string[];
102132 readonly contributingEnvironments: readonly EnvironmentId[];
......@@ -307,6 +337,8 @@ const EMPTY_MERGED: MergedUsage = {
307337 unpricedShare: 0,
308338 cacheSavingsUsd: 0,
309339 },
340 categoryCost: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, unsplit: 0 },
341 speedCost: { standard: 0, fast: 0, ultrafast: 0, premium: 0 },
310342 duplicateSources: [],
311343 contributingEnvironments: [],
312344 contractMismatches: [],
......@@ -364,6 +396,8 @@ export function mergeUsage(
364396 let cacheSavingsUsd = 0;
365397 let providerReportedRecords = 0;
366398 let unpricedRecords = 0;
399 const categoryCost = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
400 const speedCost = { fast: 0, ultrafast: 0, premium: 0 };
367401
368402 const providerAccumulator = new Map<
369403 UsageProviderKind,
......@@ -375,8 +409,10 @@ export function mergeUsage(
375409 provider: UsageProviderKind;
376410 costUsd: number;
377411 totalTokens: number;
412 tokens: UsageTokenTotals;
378413 records: number;
379414 unpricedRecords: number;
415 unpricedTokens: number;
380416 }
381417 >();
382418 const dailyAccumulator = new Map<
......@@ -434,6 +470,15 @@ export function mergeUsage(
434470 records += bucket.records;
435471 unpricedRecords += bucket.unpricedRecords;
436472 if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;
473 if (bucket.categoryCostUsd !== undefined) {
474 categoryCost.input += bucket.categoryCostUsd.input;
475 categoryCost.cacheRead += bucket.categoryCostUsd.cacheRead;
476 categoryCost.cacheWrite += bucket.categoryCostUsd.cacheWrite;
477 categoryCost.output += bucket.categoryCostUsd.output;
478 }
479 speedCost.fast += bucket.fastCostUsd ?? 0;
480 speedCost.ultrafast += bucket.ultrafastCostUsd ?? 0;
481 speedCost.premium += bucket.speedPremiumUsd ?? 0;
437482
438483 const provider = providerAccumulator.get(bucket.provider) ?? {
439484 costUsd: 0,
......@@ -451,13 +496,31 @@ export function mergeUsage(
451496 provider: bucket.provider,
452497 costUsd: 0,
453498 totalTokens: 0,
499 tokens: {
500 uncachedInputTokens: 0,
501 cachedInputTokens: 0,
502 cacheCreationTokens: 0,
503 outputTokens: 0,
504 reasoningTokens: 0,
505 },
454506 records: 0,
455507 unpricedRecords: 0,
508 unpricedTokens: 0,
456509 };
457510 model.costUsd += bucket.costUsd;
458511 model.totalTokens += tokens;
512 model.tokens = {
513 uncachedInputTokens: model.tokens.uncachedInputTokens + bucket.totals.uncachedInputTokens,
514 cachedInputTokens: model.tokens.cachedInputTokens + bucket.totals.cachedInputTokens,
515 cacheCreationTokens: model.tokens.cacheCreationTokens + bucket.totals.cacheCreationTokens,
516 outputTokens: model.tokens.outputTokens + bucket.totals.outputTokens,
517 reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens,
518 };
459519 model.records += bucket.records;
460520 model.unpricedRecords += bucket.unpricedRecords;
521 if (bucket.records > 0) {
522 model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records;
523 }
461524 modelAccumulator.set(modelKey, model);
462525
463526 const day = dailyAccumulator.get(bucket.day) ?? {
......@@ -515,8 +578,10 @@ export function mergeUsage(
515578 provider: totals.provider,
516579 costUsd: totals.costUsd,
517580 totalTokens: totals.totalTokens,
581 tokens: totals.tokens,
518582 records: totals.records,
519583 unpricedRecords: totals.unpricedRecords,
584 unpricedTokens: totals.unpricedTokens,
520585 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
521586 }))
522587 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
......@@ -555,6 +620,22 @@ export function mergeUsage(
555620 records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
556621 cacheSavingsUsd,
557622 },
623 // Clamped so float error never shows as a negative remainder.
624 categoryCost: {
625 ...categoryCost,
626 unsplit: Math.max(
627 0,
628 costUsd -
629 categoryCost.input -
630 categoryCost.cacheRead -
631 categoryCost.cacheWrite -
632 categoryCost.output,
633 ),
634 },
635 speedCost: {
636 ...speedCost,
637 standard: Math.max(0, costUsd - speedCost.fast - speedCost.ultrafast),
638 },
558639 duplicateSources: duplicates,
559640 contributingEnvironments,
560641 contractMismatches,