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";...@@ -42,7 +42,7 @@ import { UsageLimitsSection } from "./UsageLimitsPooled";
42import { ControlPillMenu } from "../../components/ControlPill";42import { ControlPillMenu } from "../../components/ControlPill";
43import { SymbolView } from "../../components/AppSymbol";43import { SymbolView } from "../../components/AppSymbol";
44import type { UsageChartMetric } from "./usageChartData";44import type { UsageChartMetric } from "./usageChartData";
45import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";45import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders";
4646
47type UsageTab = "usage" | "limits";47type UsageTab = "usage" | "limits";
48const TAB_OPTIONS = [48const TAB_OPTIONS = [
...@@ -360,6 +360,7 @@ export function UsageRouteScreen() {...@@ -360,6 +360,7 @@ export function UsageRouteScreen() {
360 onCursorEnabled={refreshAfterCursorEnable}360 onCursorEnabled={refreshAfterCursorEnable}
361 />361 />
362 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />362 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
363 <CostSection merged={merged} />
363 <ModelsSection merged={merged} />364 <ModelsSection merged={merged} />
364 </>365 </>
365 )}366 )}
...@@ -688,6 +689,84 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H...@@ -688,6 +689,84 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
688 );689 );
689}690}
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
691function MetricCell(props: {770function MetricCell(props: {
692 readonly label: string;771 readonly label: string;
693 readonly value: string;772 readonly value: string;
apps/mobile/src/features/usage/usageProviders.ts+20
...@@ -38,3 +38,23 @@ export function useProviderColors(): Record<UsageProviderKind, string> {...@@ -38,3 +38,23 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
38 antigravity: "#8c7bd1",38 antigravity: "#8c7bd1",
39 };39 };
40}40}
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([...@@ -12,7 +12,12 @@ const rates: RateTable = new Map([
12 outputCostPerToken: 5e-5,12 outputCostPerToken: 5e-5,
13 cacheReadCostPerToken: 1e-6,13 cacheReadCostPerToken: 1e-6,
14 cacheCreationCostPerToken: 1.25e-5,14 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 },
16 ultrafast: null,21 ultrafast: null,
17 },22 },
18 ],23 ],
...@@ -77,6 +82,24 @@ describe("UsageAggregator", () => {...@@ -77,6 +82,24 @@ describe("UsageAggregator", () => {
77 ).toThrow("requires exact time bounds");82 ).toThrow("requires exact time bounds");
78 });83 });
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
80 it("keeps only the first record for a repeated dedupe key", () => {103 it("keeps only the first record for a repeated dedupe key", () => {
81 const result = aggregate([104 const result = aggregate([
82 record({ dedupeKey: "msg_1:" }),105 record({ dedupeKey: "msg_1:" }),
apps/server/src/usage/usageAggregation.ts+57-1
...@@ -12,7 +12,13 @@...@@ -12,7 +12,13 @@
12 *12 *
13 * @module usageAggregation13 * @module usageAggregation
14 */14 */
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
17import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts";23import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts";
18import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";24import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";
...@@ -50,6 +56,10 @@ interface MutableBucket {...@@ -50,6 +56,10 @@ interface MutableBucket {
50 totals: UsageTokenTotals;56 totals: UsageTokenTotals;
51 costUsd: number;57 costUsd: number;
52 cacheSavingsUsd: number;58 cacheSavingsUsd: number;
59 categoryCostUsd: UsageCategoryCost | null;
60 fastCostUsd: number;
61 ultrafastCostUsd: number;
62 speedPremiumUsd: number;
53 records: number;63 records: number;
54 unpricedRecords: number;64 unpricedRecords: number;
55 providerReportedRecords: number;65 providerReportedRecords: number;
...@@ -153,6 +163,10 @@ export class UsageAggregator {...@@ -153,6 +163,10 @@ export class UsageAggregator {
153 totals: EMPTY_TOTALS,163 totals: EMPTY_TOTALS,
154 costUsd: 0,164 costUsd: 0,
155 cacheSavingsUsd: 0,165 cacheSavingsUsd: 0,
166 categoryCostUsd: null,
167 fastCostUsd: 0,
168 ultrafastCostUsd: 0,
169 speedPremiumUsd: 0,
156 records: 0,170 records: 0,
157 unpricedRecords: 0,171 unpricedRecords: 0,
158 providerReportedRecords: 0,172 providerReportedRecords: 0,
...@@ -165,6 +179,22 @@ export class UsageAggregator {...@@ -165,6 +179,22 @@ export class UsageAggregator {
165179
166 bucket.totals = addTotals(bucket.totals, record.totals);180 bucket.totals = addTotals(bucket.totals, record.totals);
167 bucket.costUsd += priced.costUsd;181 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;
168 bucket.cacheSavingsUsd += cacheSavingsUsd(198 bucket.cacheSavingsUsd += cacheSavingsUsd(
169 this.#options.rates,199 this.#options.rates,
170 record,200 record,
...@@ -182,6 +212,10 @@ export class UsageAggregator {...@@ -182,6 +212,10 @@ export class UsageAggregator {
182 for (const [key, bucket] of this.#buckets) {212 for (const [key, bucket] of this.#buckets) {
183 const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] =213 const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] =
184 key.split("\u0000");214 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);
185 buckets.push({219 buckets.push({
186 day: day as UsageDay,220 day: day as UsageDay,
187 ...(hourStart === "" ? {} : { hourStart }),221 ...(hourStart === "" ? {} : { hourStart }),
...@@ -191,6 +225,20 @@ export class UsageAggregator {...@@ -191,6 +225,20 @@ export class UsageAggregator {
191 totals: bucket.totals,225 totals: bucket.totals,
192 costUsd: bucket.costUsd,226 costUsd: bucket.costUsd,
193 cacheSavingsUsd: bucket.cacheSavingsUsd,227 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 }),
194 costSource: resolveCostSource(bucket),242 costSource: resolveCostSource(bucket),
195 records: bucket.records,243 records: bucket.records,
196 unpricedRecords: bucket.unpricedRecords,244 unpricedRecords: bucket.unpricedRecords,
...@@ -214,6 +262,14 @@ export class UsageAggregator {...@@ -214,6 +262,14 @@ export class UsageAggregator {
214 }262 }
215}263}
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
217/**273/**
218 * A bucket mixes records from one model, but their cost provenance can differ274 * A bucket mixes records from one model, but their cost provenance can differ
219 * when only some records carried a reported cost. The weakest provenance in the275 * 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", () => {...@@ -47,7 +47,7 @@ describe("usage pricing", () => {
47 });47 });
4848
49 for (const reportedCostUsd of [null, 99]) {49 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({
51 costUsd: 13.5,51 costUsd: 13.5,
52 costSource: "modelPriced",52 costSource: "modelPriced",
53 });53 });
...@@ -69,7 +69,7 @@ describe("usage pricing", () => {...@@ -69,7 +69,7 @@ describe("usage pricing", () => {
69 expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9);69 expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9);
70 expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5);70 expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5);
71 expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0);71 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({
73 costUsd: 0.25,73 costUsd: 0.25,
74 costSource: "providerReported",74 costSource: "providerReported",
75 });75 });
...@@ -81,7 +81,7 @@ describe("usage pricing", () => {...@@ -81,7 +81,7 @@ describe("usage pricing", () => {
81 "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 },81 "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 },
82 });82 });
8383
84 expect(priceUsage(table, record("example-model"), overrides)).toEqual({84 expect(priceUsage(table, record("example-model"), overrides)).toMatchObject({
85 costUsd: 14,85 costUsd: 14,
86 costSource: "modelPriced",86 costSource: "modelPriced",
87 });87 });
...@@ -96,7 +96,7 @@ describe("usage pricing", () => {...@@ -96,7 +96,7 @@ describe("usage pricing", () => {
96 outputCostPerMillionTokens: 0,96 outputCostPerMillionTokens: 0,
97 },97 },
98 });98 });
99 expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toEqual({99 expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toMatchObject({
100 costUsd: 0,100 costUsd: 0,
101 costSource: "modelPriced",101 costSource: "modelPriced",
102 });102 });
...@@ -110,6 +110,8 @@ describe("usage pricing", () => {...@@ -110,6 +110,8 @@ describe("usage pricing", () => {
110 expect(priceUsage(table, record(model, 99), overrides)).toEqual({110 expect(priceUsage(table, record(model, 99), overrides)).toEqual({
111 costUsd: 99,111 costUsd: 99,
112 costSource: "providerReported",112 costSource: "providerReported",
113 categoryCostUsd: null,
114 speedPremiumUsd: 0,
113 });115 });
114 }116 }
115 });117 });
...@@ -136,6 +138,42 @@ describe("usage pricing", () => {...@@ -136,6 +138,42 @@ describe("usage pricing", () => {
136 );138 );
137 });139 });
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
139 it("prices Codex priority and ultrafast requests at their published tier rates", () => {177 it("prices Codex priority and ultrafast requests at their published tier rates", () => {
140 const table = parseRateTable({178 const table = parseRateTable({
141 "gpt-6-astra": {179 "gpt-6-astra": {
apps/server/src/usage/usagePricing.ts+54-13
...@@ -7,7 +7,12 @@...@@ -7,7 +7,12 @@
7 *7 *
8 * @module usagePricing8 * @module usagePricing
9 */9 */
10import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts";10import type {
11 UsageCategoryCost,
12 UsageCostSource,
13 UsageModelPriceOverride,
14 UsageTokenTotals,
15} from "@t3tools/contracts";
1116
12import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts";17import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
1318
...@@ -246,11 +251,31 @@ export type PricedRecord = Pick<...@@ -246,11 +251,31 @@ export type PricedRecord = Pick<
246export interface PricedUsage {251export interface PricedUsage {
247 readonly costUsd: number;252 readonly costUsd: number;
248 readonly costSource: UsageCostSource;253 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;
249}271}
250272
251/**273/**
252 * Prices one record's tokens.274 * Prices one record's tokens.
253 *275 *
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 *
254 * `reasoningTokens` is intentionally not charged separately: it is already279 * `reasoningTokens` is intentionally not charged separately: it is already
255 * counted inside `outputTokens`.280 * counted inside `outputTokens`.
256 */281 */
...@@ -261,21 +286,37 @@ export function priceUsage(...@@ -261,21 +286,37 @@ export function priceUsage(
261): PricedUsage {286): PricedUsage {
262 const { model, totals, reportedCostUsd } = record;287 const { model, totals, reportedCostUsd } = record;
263 const override = overrides?.get(model.trim());288 const override = overrides?.get(model.trim());
264 if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {289 const reported =
265 return { costUsd: reportedCostUsd, costSource: "providerReported" };290 override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)
266 }291 ? reportedCostUsd
267292 : null;
293 const unsplit = (costUsd: number, costSource: UsageCostSource): PricedUsage => ({
294 costUsd,
295 costSource,
296 categoryCostUsd: null,
297 speedPremiumUsd: 0,
298 });
268 const rate = override ?? lookupRate(table, record.rateModel ?? model);299 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;
272 return {310 return {
273 costUsd:311 costUsd: reported ?? listCostUsd,
274 totals.uncachedInputTokens * rates.inputCostPerToken +312 costSource: reported === null ? "modelPriced" : "providerReported",
275 totals.cachedInputTokens * rates.cacheReadCostPerToken +313 categoryCostUsd: {
276 totals.cacheCreationTokens * rates.cacheCreationCostPerToken +314 input: listCost.input * scale,
277 totals.outputTokens * rates.outputCostPerToken,315 cacheRead: listCost.cacheRead * scale,
278 costSource: "modelPriced",316 cacheWrite: listCost.cacheWrite * scale,
317 output: listCost.output * scale,
318 },
319 speedPremiumUsd: premiumUsd * scale,
279 };320 };
280}321}
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";...@@ -78,7 +78,14 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader";
78import { UsageLimitsSection } from "./UsageLimits";78import { UsageLimitsSection } from "./UsageLimits";
79import { UsagePriceOverrides } from "./UsagePriceOverrides";79import { UsagePriceOverrides } from "./UsagePriceOverrides";
80import { UsageProviderChart } from "./UsageProviderChart";80import { 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";
82import {89import {
83 METRIC_OPTIONS,90 METRIC_OPTIONS,
84 WINDOW_OPTIONS,91 WINDOW_OPTIONS,
...@@ -127,6 +134,8 @@ export function UsagePage() {...@@ -127,6 +134,8 @@ export function UsagePage() {
127 const [limitsNow, setLimitsNow] = useState(() => Date.now());134 const [limitsNow, setLimitsNow] = useState(() => Date.now());
128 const refreshingRef = useRef(false);135 const refreshingRef = useRef(false);
129 const [breakdown, setBreakdown] = useState<"model" | "time">("model");136 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);
130 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =139 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
131 useState<ReadonlySet<EnvironmentId> | null>(null);140 useState<ReadonlySet<EnvironmentId> | null>(null);
132 const { days: windowDays, window } = windowSelection;141 const { days: windowDays, window } = windowSelection;
...@@ -182,6 +191,14 @@ export function UsagePage() {...@@ -182,6 +191,14 @@ export function UsagePage() {
182 [breakdown, merged.models, metric],191 [breakdown, merged.models, metric],
183 );192 );
184 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);193 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 );
185 const summaryRows: Array<202 const summaryRows: Array<
186 | { readonly kind: "usage"; readonly provider: UsageProviderKind }203 | { readonly kind: "usage"; readonly provider: UsageProviderKind }
187 | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus }204 | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus }
...@@ -322,6 +339,7 @@ export function UsagePage() {...@@ -322,6 +339,7 @@ export function UsagePage() {
322 isPartial={isPartial}339 isPartial={isPartial}
323 duplicateSources={merged.duplicateSources}340 duplicateSources={merged.duplicateSources}
324 contractMismatches={merged.contractMismatches}341 contractMismatches={merged.contractMismatches}
342 onOpenModelPrices={() => setPriceDialog({})}
325 />343 />
326 </WorkspaceBreadcrumbItem>344 </WorkspaceBreadcrumbItem>
327 </WorkspaceBreadcrumb>345 </WorkspaceBreadcrumb>
...@@ -625,6 +643,34 @@ export function UsagePage() {...@@ -625,6 +643,34 @@ export function UsagePage() {
625 </div>643 </div>
626 </section>644 </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
628 <section className="flex flex-col gap-3">674 <section className="flex flex-col gap-3">
629 <div className="flex items-center justify-between gap-3">675 <div className="flex items-center justify-between gap-3">
630 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>676 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
...@@ -651,55 +697,73 @@ export function UsagePage() {...@@ -651,55 +697,73 @@ export function UsagePage() {
651 </div>697 </div>
652698
653 {breakdown === "model" ? (699 {breakdown === "model" ? (
654 <table className="w-full table-fixed text-sm">700 <table className="w-full 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>
661 <thead>701 <thead>
662 <tr className="border-b border-border text-left text-xs text-muted-foreground">702 <tr className="border-b border-border text-right text-xs text-muted-foreground">
663 <th className="py-2 font-normal">Model</th>703 <th className="py-2 pr-3 text-left font-normal">#</th>
664 <th className="py-2 text-right font-normal">Cost</th>704 <th className="w-full py-2 text-left font-normal">Model</th>
665 <th className="py-2 text-right font-normal">Share</th>705 <th className="py-2 pl-6 font-normal">Cost</th>
666 <th className="py-2 text-right font-normal">Tokens</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>
667 </tr>708 </tr>
668 </thead>709 </thead>
669 <tbody>710 <tbody>
670 {breakdownModels.length === 0 ? (711 {breakdownModels.length === 0 ? (
671 <tr>712 <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">
673 No activity in this window.714 No activity in this window.
674 </td>715 </td>
675 </tr>716 </tr>
676 ) : (717 ) : (
677 breakdownModels.map((model) => (718 breakdownModels.map((model, index) => {
678 <tr719 const key = `${model.provider}:${model.model}`;
679 key={`${model.provider}:${model.model}`}720 const value = metric === "tokens" ? model.totalTokens : model.costUsd;
680 className="border-b border-border/50 transition-colors hover:bg-muted/50"721 return (
681 >722 <tr
682 <td className="py-2 text-foreground">723 key={key}
683 <span className="flex items-center gap-2">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"
684 <ProviderMark provider={model.provider} className="size-3.5" />725 >
685 {model.model}726 <td className="py-2.5 pr-3 text-left text-xs">{index + 1}</td>
686 </span>727 <td className="py-2.5 text-left whitespace-normal">
687 </td>728 {/* The button's overlay makes the whole row open the model.
688 <td className="py-2 text-right text-foreground tabular-nums">729 Focus shows as the row's hover fill, not a ring. */}
689 {isModelCostUnknown(model) ? (730 <button
690 <span className="text-muted-foreground">Unpriced</span>731 type="button"
691 ) : (732 onClick={() => setSelectedModelKey(key)}
692 formatUsd(model.costUsd)733 className="flex items-center gap-2 text-left text-foreground outline-none after:absolute after:inset-0"
693 )}734 >
694 </td>735 <ProviderMark provider={model.provider} className="size-3.5" />
695 <td className="py-2 text-right text-muted-foreground tabular-nums">736 {model.model}
696 {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)}737 </button>
697 </td>738 <div aria-hidden className="mt-1.5 h-0.5 max-w-48">
698 <td className="py-2 text-right text-muted-foreground tabular-nums">739 <div
699 {formatTokens(model.totalTokens)}740 className="h-full rounded-full"
700 </td>741 style={{
701 </tr>742 // A short minimum keeps tiny shares a dash, not a dot.
702 ))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 })
703 )}767 )}
704 </tbody>768 </tbody>
705 </table>769 </table>
...@@ -772,6 +836,35 @@ export function UsagePage() {...@@ -772,6 +836,35 @@ export function UsagePage() {
772 </WorkspacePageContainer>836 </WorkspacePageContainer>
773 </ScrollArea>837 </ScrollArea>
774 </div>838 </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}
775 </SidebarInset>868 </SidebarInset>
776 );869 );
777}870}
...@@ -989,6 +1082,7 @@ function UsageEnvironmentFilter({...@@ -989,6 +1082,7 @@ function UsageEnvironmentFilter({
989 isPartial,1082 isPartial,
990 duplicateSources,1083 duplicateSources,
991 contractMismatches,1084 contractMismatches,
1085 onOpenModelPrices,
992}: {1086}: {
993 readonly environments: readonly EnvironmentUsageStatus[];1087 readonly environments: readonly EnvironmentUsageStatus[];
994 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];1088 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
...@@ -998,8 +1092,8 @@ function UsageEnvironmentFilter({...@@ -998,8 +1092,8 @@ function UsageEnvironmentFilter({
998 readonly isPartial: boolean;1092 readonly isPartial: boolean;
999 readonly duplicateSources: readonly string[];1093 readonly duplicateSources: readonly string[];
1000 readonly contractMismatches: MergedUsage["contractMismatches"];1094 readonly contractMismatches: MergedUsage["contractMismatches"];
1095 readonly onOpenModelPrices: () => void;
1001}) {1096}) {
1002 const [modelPricesOpen, setModelPricesOpen] = useState(false);
1003 const allSelected = selectedEnvironmentIds === null;1097 const allSelected = selectedEnvironmentIds === null;
1004 const label = allSelected1098 const label = allSelected
1005 ? "All environments"1099 ? "All environments"
...@@ -1015,121 +1109,108 @@ function UsageEnvironmentFilter({...@@ -1015,121 +1109,108 @@ function UsageEnvironmentFilter({
1015 contractMismatches.length > 0;1109 contractMismatches.length > 0;
10161110
1017 return (1111 return (
1018 <>1112 <Menu>
1019 <Menu>1113 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">
1020 <MenuTrigger1114 <span className="min-w-0 truncate">{label}</span>
1021 render={<InlineButton />}1115 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1022 className="group/usage-environment min-w-0 max-w-full"1116 {showUsageStatus && pendingCount > 0 ? (
1023 >1117 <>
1024 <span className="min-w-0 truncate">{label}</span>1118 <CircleDashedIcon className="size-3.5" aria-hidden />
1025 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">1119 <span className="sr-only">
1026 {showUsageStatus && pendingCount > 0 ? (1120 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning
1027 <>1121 {isPartial ? "; totals are partial" : ""}
1028 <CircleDashedIcon className="size-3.5" aria-hidden />1122 </span>
1029 <span className="sr-only">1123 </>
1030 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still1124 ) : showUsageStatus && hasIssue ? (
1031 scanning1125 <CircleAlertIcon
1032 {isPartial ? "; totals are partial" : ""}1126 className="size-3.5 text-warning-foreground"
1033 </span>1127 aria-label="Some environments could not report usage"
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}
1116 />1128 />
1117 ) : null}1129 ) : (
1118 <MenuSeparator />1130 <ChevronDownIcon
1119 <MenuItem onClick={() => setModelPricesOpen(true)}>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"
1120 <SlidersHorizontalIcon aria-hidden />1132 aria-hidden
1121 Model prices1133 />
1122 </MenuItem>1134 )}
1123 </MenuPopup>1135 </span>
1124 </Menu>1136 </MenuTrigger>
1125 {modelPricesOpen ? (1137 <MenuPopup align="start">
1126 <UsagePriceOverrides1138 <MenuCheckboxItem
1127 usage={environments}1139 checked={allSelected}
1128 initialSelectedEnvironmentIds={selectedEnvironmentIds}1140 closeOnClick={false}
1129 onOpenChange={setModelPricesOpen}1141 onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())}
1130 />1142 >
1131 ) : null}1143 All environments
1132 </>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>
1133 );1214 );
1134}1215}
11351216
...@@ -1173,15 +1254,16 @@ function UsageSkeleton() {...@@ -1173,15 +1254,16 @@ function UsageSkeleton() {
11731254
1174 <section className="flex flex-col gap-2">1255 <section className="flex flex-col gap-2">
1175 <h2 className="text-sm font-medium text-foreground">Totals</h2>1256 <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">1257 <MetricSkeletons
1177 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(1258 labels={["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"]}
1178 (label) => (1259 />
1179 <div key={label} className="flex flex-col gap-0.5">1260 </section>
1180 <span className="text-xs text-muted-foreground">{label}</span>1261
1181 <Skeleton className="h-6 w-16" />1262 <section className="grid gap-x-12 gap-y-8 lg:grid-cols-2">
1182 </div>1263 <div className="flex flex-col gap-2.5">
1183 ),1264 <Skeleton className="h-5 w-28" />
1184 )}1265 <Skeleton className="h-2" />
1266 <Skeleton className="h-4 w-72" />
1185 </div>1267 </div>
1186 </section>1268 </section>
11871269
...@@ -1195,3 +1277,16 @@ function UsageSkeleton() {...@@ -1195,3 +1277,16 @@ function UsageSkeleton() {
1195 </>1277 </>
1196 );1278 );
1197}1279}
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 = {...@@ -91,13 +91,16 @@ type SaveAttempt = {
91 >;91 >;
92};92};
9393
94/** Edits custom model prices. `initialModel` opens with a new row for that model. */
94export function UsagePriceOverrides({95export function UsagePriceOverrides({
95 usage,96 usage,
96 initialSelectedEnvironmentIds,97 initialSelectedEnvironmentIds,
98 initialModel,
97 onOpenChange,99 onOpenChange,
98}: {100}: {
99 readonly usage: readonly EnvironmentUsageStatus[];101 readonly usage: readonly EnvironmentUsageStatus[];
100 readonly initialSelectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;102 readonly initialSelectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
103 readonly initialModel?: string | undefined;
101 readonly onOpenChange: (open: boolean) => void;104 readonly onOpenChange: (open: boolean) => void;
102}) {105}) {
103 const environments = useAtomValue(priceTargetsAtom);106 const environments = useAtomValue(priceTargetsAtom);
...@@ -105,7 +108,16 @@ export function UsagePriceOverrides({...@@ -105,7 +108,16 @@ export function UsagePriceOverrides({
105 const selected = environments.filter(108 const selected = environments.filter(
106 (environment) => selectedIds === null || selectedIds.has(environment.environmentId),109 (environment) => selectedIds === null || selectedIds.has(environment.environmentId),
107 );110 );
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 );
109 const [pending, setPending] = useState(false);121 const [pending, setPending] = useState(false);
110 const [attempt, setAttempt] = useState<SaveAttempt | null>(null);122 const [attempt, setAttempt] = useState<SaveAttempt | null>(null);
111 const focusRowRef = useRef<string | null>(null);123 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 @@...@@ -1,16 +1,30 @@
1import type { ModelTotals } from "@t3tools/shared/usageMerge";1import type { ModelTotals } from "@t3tools/shared/usageMerge";
2import { describe, expect, it } from "vite-plus/test";2import { 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 => ({
7 model: name,12 model: name,
8 provider: "codex",13 provider: "codex",
9 costUsd,14 costUsd,
10 totalTokens,15 totalTokens,
16 tokens: {
17 uncachedInputTokens: totalTokens,
18 cachedInputTokens: 0,
19 cacheCreationTokens: 0,
20 outputTokens: 0,
21 reasoningTokens: 0,
22 },
11 records: 1,23 records: 1,
12 unpricedRecords: 0,24 unpricedRecords: 0,
25 unpricedTokens: 0,
13 costShare: 0,26 costShare: 0,
27 ...overrides,
14});28});
1529
16describe("sortModelsByTokens", () => {30describe("sortModelsByTokens", () => {
...@@ -29,3 +43,24 @@ describe("sortModelsByTokens", () => {...@@ -29,3 +43,24 @@ describe("sortModelsByTokens", () => {
29 expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]);43 expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]);
30 });44 });
31});45});
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 @@...@@ -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
3export function sortModelsByTokens(models: readonly ModelTotals[]) {11export function sortModelsByTokens(models: readonly ModelTotals[]) {
4 return models.toSorted(12 return models.toSorted(
5 (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd,13 (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd,
6 );14 );
7}15}
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";...@@ -10,6 +10,7 @@ import { useAtomValue } from "@effect/atom-react";
10import {10import {
11 USAGE_CONTRACT_VERSION,11 USAGE_CONTRACT_VERSION,
12 type EnvironmentId,12 type EnvironmentId,
13 type UsageBucket,
13 type UsageSummary,14 type UsageSummary,
14 type UsageSummaryInput,15 type UsageSummaryInput,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
...@@ -79,6 +80,33 @@ export interface UsageView {...@@ -79,6 +80,33 @@ export interface UsageView {
79 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;80 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
80}81}
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
82export function useUsage(110export function useUsage(
83 input: UsageSummaryInput,111 input: UsageSummaryInput,
84 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,112 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
...@@ -126,20 +154,7 @@ export function useUsage(...@@ -126,20 +154,7 @@ export function useUsage(
126 [selectedEnvironments, windowKey],154 [selectedEnvironments, windowKey],
127 );155 );
128156
129 const merged = useMemo(() => {157 const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]);
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]);
143158
144 const answeredCount = selectedEnvironments.filter(159 const answeredCount = selectedEnvironments.filter(
145 (environment) => environment.summary !== null,160 (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...@@ -8,7 +8,10 @@ desktop when the terminal is not focused. Customize `usage.open` in
88
9**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected9**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected
10environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent10environments. 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
13Totals depend on the history available on each server. Grok turns without a saved completed-turn16Totals depend on the history available on each server. Grok turns without a saved completed-turn
14record are missing from the totals.17record are missing from the totals.
...@@ -47,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod...@@ -47,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod
47edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter;50edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter;
48choose all environments or select individual destinations. Enter the exact model ID and USD51choose all environments or select individual destinations. Enter the exact model ID and USD
49rates per million input and output tokens. You can enter any model ID, including models52rates 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
52Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for56Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for
53tokens that are free. Saved prices replace automatic pricing for all of that environment's57tokens 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 "....@@ -18,7 +18,8 @@ import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from ".
18 * client renders partial coverage when an environment reports an older version18 * client renders partial coverage when an environment reports an older version
19 * rather than failing the whole page.19 * rather than failing the whole page.
20 * Adding providers or other array-element variants is additive: unknown20 * 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.
22 */23 */
23export const USAGE_CONTRACT_VERSION = 6 as const;24export const USAGE_CONTRACT_VERSION = 6 as const;
2425
...@@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({...@@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({
84});85});
85export type UsageTokenTotals = typeof UsageTokenTotals.Type;86export 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
87/**100/**
88 * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start101 * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start
89 * instant of a rolling bucket and is present only for hourly requests.102 * instant of a rolling bucket and is present only for hourly requests.
...@@ -108,6 +121,16 @@ export const UsageBucket = Schema.Struct({...@@ -108,6 +121,16 @@ export const UsageBucket = Schema.Struct({
108 * rather than derived on the client.121 * rather than derived on the client.
109 */122 */
110 cacheSavingsUsd: Schema.Number,123 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),
111 costSource: UsageCostSource,134 costSource: UsageCostSource,
112 /** Distinct assistant responses, after de-duplication. */135 /** Distinct assistant responses, after de-duplication. */
113 records: NonNegativeInt,136 records: NonNegativeInt,
packages/shared/src/usageMerge.test.ts+61
...@@ -546,6 +546,67 @@ describe("mergeUsage", () => {...@@ -546,6 +546,67 @@ describe("mergeUsage", () => {
546 ]);546 ]);
547 });547 });
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
549 it("orders models by cost descending", () => {610 it("orders models by cost descending", () => {
550 const merged = mergeUsage(611 const merged = mergeUsage(
551 [612 [
packages/shared/src/usageMerge.ts+81
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 type UsageSource,14 type UsageSource,
15 type UsageSourceFingerprint,15 type UsageSourceFingerprint,
16 type UsageSummary,16 type UsageSummary,
17 type UsageTokenTotals,
17} from "@t3tools/contracts";18} from "@t3tools/contracts";
1819
19export interface EnvironmentUsage {20export interface EnvironmentUsage {
...@@ -37,12 +38,18 @@ export interface ModelTotals {...@@ -37,12 +38,18 @@ export interface ModelTotals {
37 readonly provider: UsageProviderKind;38 readonly provider: UsageProviderKind;
38 readonly costUsd: number;39 readonly costUsd: number;
39 readonly totalTokens: number;40 readonly totalTokens: number;
41 readonly tokens: UsageTokenTotals;
40 readonly records: number;42 readonly records: number;
41 /**43 /**
42 * Records whose tokens are counted here but which contributed nothing to44 * Records whose tokens are counted here but which contributed nothing to
43 * `costUsd`. When it equals `records` the cost is unknown, not zero.45 * `costUsd`. When it equals `records` the cost is unknown, not zero.
44 */46 */
45 readonly unpricedRecords: number;47 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;
46 readonly costShare: number;53 readonly costShare: number;
47}54}
4855
...@@ -76,6 +83,27 @@ export interface CostQuality {...@@ -76,6 +83,27 @@ export interface CostQuality {
76 readonly cacheSavingsUsd: number;83 readonly cacheSavingsUsd: number;
77}84}
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
79export interface UsageContractMismatch {107export interface UsageContractMismatch {
80 readonly environmentId: EnvironmentId;108 readonly environmentId: EnvironmentId;
81 readonly direction: "serverBehind" | "clientBehind";109 readonly direction: "serverBehind" | "clientBehind";
...@@ -97,6 +125,8 @@ export interface MergedUsage {...@@ -97,6 +125,8 @@ export interface MergedUsage {
97 readonly daily: readonly DailyTotals[];125 readonly daily: readonly DailyTotals[];
98 readonly hourly: readonly HourlyTotals[];126 readonly hourly: readonly HourlyTotals[];
99 readonly costQuality: CostQuality;127 readonly costQuality: CostQuality;
128 readonly categoryCost: CategoryCost;
129 readonly speedCost: SpeedCost;
100 /** Environments whose data was dropped as a duplicate of another's. */130 /** Environments whose data was dropped as a duplicate of another's. */
101 readonly duplicateSources: readonly string[];131 readonly duplicateSources: readonly string[];
102 readonly contributingEnvironments: readonly EnvironmentId[];132 readonly contributingEnvironments: readonly EnvironmentId[];
...@@ -307,6 +337,8 @@ const EMPTY_MERGED: MergedUsage = {...@@ -307,6 +337,8 @@ const EMPTY_MERGED: MergedUsage = {
307 unpricedShare: 0,337 unpricedShare: 0,
308 cacheSavingsUsd: 0,338 cacheSavingsUsd: 0,
309 },339 },
340 categoryCost: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, unsplit: 0 },
341 speedCost: { standard: 0, fast: 0, ultrafast: 0, premium: 0 },
310 duplicateSources: [],342 duplicateSources: [],
311 contributingEnvironments: [],343 contributingEnvironments: [],
312 contractMismatches: [],344 contractMismatches: [],
...@@ -364,6 +396,8 @@ export function mergeUsage(...@@ -364,6 +396,8 @@ export function mergeUsage(
364 let cacheSavingsUsd = 0;396 let cacheSavingsUsd = 0;
365 let providerReportedRecords = 0;397 let providerReportedRecords = 0;
366 let unpricedRecords = 0;398 let unpricedRecords = 0;
399 const categoryCost = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
400 const speedCost = { fast: 0, ultrafast: 0, premium: 0 };
367401
368 const providerAccumulator = new Map<402 const providerAccumulator = new Map<
369 UsageProviderKind,403 UsageProviderKind,
...@@ -375,8 +409,10 @@ export function mergeUsage(...@@ -375,8 +409,10 @@ export function mergeUsage(
375 provider: UsageProviderKind;409 provider: UsageProviderKind;
376 costUsd: number;410 costUsd: number;
377 totalTokens: number;411 totalTokens: number;
412 tokens: UsageTokenTotals;
378 records: number;413 records: number;
379 unpricedRecords: number;414 unpricedRecords: number;
415 unpricedTokens: number;
380 }416 }
381 >();417 >();
382 const dailyAccumulator = new Map<418 const dailyAccumulator = new Map<
...@@ -434,6 +470,15 @@ export function mergeUsage(...@@ -434,6 +470,15 @@ export function mergeUsage(
434 records += bucket.records;470 records += bucket.records;
435 unpricedRecords += bucket.unpricedRecords;471 unpricedRecords += bucket.unpricedRecords;
436 if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;472 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
438 const provider = providerAccumulator.get(bucket.provider) ?? {483 const provider = providerAccumulator.get(bucket.provider) ?? {
439 costUsd: 0,484 costUsd: 0,
...@@ -451,13 +496,31 @@ export function mergeUsage(...@@ -451,13 +496,31 @@ export function mergeUsage(
451 provider: bucket.provider,496 provider: bucket.provider,
452 costUsd: 0,497 costUsd: 0,
453 totalTokens: 0,498 totalTokens: 0,
499 tokens: {
500 uncachedInputTokens: 0,
501 cachedInputTokens: 0,
502 cacheCreationTokens: 0,
503 outputTokens: 0,
504 reasoningTokens: 0,
505 },
454 records: 0,506 records: 0,
455 unpricedRecords: 0,507 unpricedRecords: 0,
508 unpricedTokens: 0,
456 };509 };
457 model.costUsd += bucket.costUsd;510 model.costUsd += bucket.costUsd;
458 model.totalTokens += tokens;511 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 };
459 model.records += bucket.records;519 model.records += bucket.records;
460 model.unpricedRecords += bucket.unpricedRecords;520 model.unpricedRecords += bucket.unpricedRecords;
521 if (bucket.records > 0) {
522 model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records;
523 }
461 modelAccumulator.set(modelKey, model);524 modelAccumulator.set(modelKey, model);
462525
463 const day = dailyAccumulator.get(bucket.day) ?? {526 const day = dailyAccumulator.get(bucket.day) ?? {
...@@ -515,8 +578,10 @@ export function mergeUsage(...@@ -515,8 +578,10 @@ export function mergeUsage(
515 provider: totals.provider,578 provider: totals.provider,
516 costUsd: totals.costUsd,579 costUsd: totals.costUsd,
517 totalTokens: totals.totalTokens,580 totalTokens: totals.totalTokens,
581 tokens: totals.tokens,
518 records: totals.records,582 records: totals.records,
519 unpricedRecords: totals.unpricedRecords,583 unpricedRecords: totals.unpricedRecords,
584 unpricedTokens: totals.unpricedTokens,
520 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,585 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
521 }))586 }))
522 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);587 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
...@@ -555,6 +620,22 @@ export function mergeUsage(...@@ -555,6 +620,22 @@ export function mergeUsage(
555 records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,620 records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
556 cacheSavingsUsd,621 cacheSavingsUsd,
557 },622 },
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 },
558 duplicateSources: duplicates,639 duplicateSources: duplicates,
559 contributingEnvironments,640 contributingEnvironments,
560 contractMismatches,641 contractMismatches,