| author | |
| committer | |
| log | e8545b293b79cdd73d30ef921db149ef1b5abee0 |
| tree | 4adf3e7b795525b129ab5d90adf51bb7f3796388 |
| parent | 263097a8da038a143f2b7b7817250f0ad390ea32 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 42 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 43 | 43 | import { SymbolView } from "../../components/AppSymbol"; |
| 44 | 44 | import type { UsageChartMetric } from "./usageChartData"; |
| 45 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; | |
| 45 | import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders"; | |
| 46 | 46 | |
| 47 | 47 | type UsageTab = "usage" | "limits"; |
| 48 | 48 | const TAB_OPTIONS = [ |
| ... | ... | @@ -360,6 +360,7 @@ export function UsageRouteScreen() { |
| 360 | 360 | onCursorEnabled={refreshAfterCursorEnable} |
| 361 | 361 | /> |
| 362 | 362 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> |
| 363 | <CostSection merged={merged} /> | |
| 363 | 364 | <ModelsSection merged={merged} /> |
| 364 | 365 | </> |
| 365 | 366 | )} |
| ... | ... | @@ -688,6 +689,84 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H |
| 688 | 689 | ); |
| 689 | 690 | } |
| 690 | 691 | |
| 692 | function 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. */ | |
| 732 | function 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 | ||
| 691 | 770 | function MetricCell(props: { |
| 692 | 771 | readonly label: string; |
| 693 | 772 | readonly value: string; |
apps/mobile/src/features/usage/usageProviders.ts+20| ... | ... | @@ -38,3 +38,23 @@ export function useProviderColors(): Record<UsageProviderKind, string> { |
| 38 | 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 | */ | |
| 47 | export 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 | 12 | outputCostPerToken: 5e-5, |
| 13 | 13 | cacheReadCostPerToken: 1e-6, |
| 14 | 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 | 21 | ultrafast: null, |
| 17 | 22 | }, |
| 18 | 23 | ], |
| ... | ... | @@ -77,6 +82,24 @@ describe("UsageAggregator", () => { |
| 77 | 82 | ).toThrow("requires exact time bounds"); |
| 78 | 83 | }); |
| 79 | 84 | |
| 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 | 103 | it("keeps only the first record for a repeated dedupe key", () => { |
| 81 | 104 | const result = aggregate([ |
| 82 | 105 | record({ dedupeKey: "msg_1:" }), |
apps/server/src/usage/usageAggregation.ts+57-1| ... | ... | @@ -12,7 +12,13 @@ |
| 12 | 12 | * |
| 13 | 13 | * @module usageAggregation |
| 14 | 14 | */ |
| 15 | import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts"; | |
| 15 | import type { | |
| 16 | UsageBucket, | |
| 17 | UsageCategoryCost, | |
| 18 | UsageDay, | |
| 19 | UsageResolution, | |
| 20 | UsageTokenTotals, | |
| 21 | } from "@t3tools/contracts"; | |
| 16 | 22 | |
| 17 | 23 | import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; |
| 18 | 24 | import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; |
| ... | ... | @@ -50,6 +56,10 @@ interface MutableBucket { |
| 50 | 56 | totals: UsageTokenTotals; |
| 51 | 57 | costUsd: number; |
| 52 | 58 | cacheSavingsUsd: number; |
| 59 | categoryCostUsd: UsageCategoryCost | null; | |
| 60 | fastCostUsd: number; | |
| 61 | ultrafastCostUsd: number; | |
| 62 | speedPremiumUsd: number; | |
| 53 | 63 | records: number; |
| 54 | 64 | unpricedRecords: number; |
| 55 | 65 | providerReportedRecords: number; |
| ... | ... | @@ -153,6 +163,10 @@ export class UsageAggregator { |
| 153 | 163 | totals: EMPTY_TOTALS, |
| 154 | 164 | costUsd: 0, |
| 155 | 165 | cacheSavingsUsd: 0, |
| 166 | categoryCostUsd: null, | |
| 167 | fastCostUsd: 0, | |
| 168 | ultrafastCostUsd: 0, | |
| 169 | speedPremiumUsd: 0, | |
| 156 | 170 | records: 0, |
| 157 | 171 | unpricedRecords: 0, |
| 158 | 172 | providerReportedRecords: 0, |
| ... | ... | @@ -165,6 +179,22 @@ export class UsageAggregator { |
| 165 | 179 | |
| 166 | 180 | bucket.totals = addTotals(bucket.totals, record.totals); |
| 167 | 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 | 198 | bucket.cacheSavingsUsd += cacheSavingsUsd( |
| 169 | 199 | this.#options.rates, |
| 170 | 200 | record, |
| ... | ... | @@ -182,6 +212,10 @@ export class UsageAggregator { |
| 182 | 212 | for (const [key, bucket] of this.#buckets) { |
| 183 | 213 | const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = |
| 184 | 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 | 219 | buckets.push({ |
| 186 | 220 | day: day as UsageDay, |
| 187 | 221 | ...(hourStart === "" ? {} : { hourStart }), |
| ... | ... | @@ -191,6 +225,20 @@ export class UsageAggregator { |
| 191 | 225 | totals: bucket.totals, |
| 192 | 226 | costUsd: bucket.costUsd, |
| 193 | 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 | 242 | costSource: resolveCostSource(bucket), |
| 195 | 243 | records: bucket.records, |
| 196 | 244 | unpricedRecords: bucket.unpricedRecords, |
| ... | ... | @@ -214,6 +262,14 @@ export class UsageAggregator { |
| 214 | 262 | } |
| 215 | 263 | } |
| 216 | 264 | |
| 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 | */ | |
| 269 | function roundUsd(value: number): number { | |
| 270 | return Math.round(value * 1e6) / 1e6; | |
| 271 | } | |
| 272 | ||
| 217 | 273 | /** |
| 218 | 274 | * A bucket mixes records from one model, but their cost provenance can differ |
| 219 | 275 | * 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 | 47 | }); |
| 48 | 48 | |
| 49 | 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 | 51 | costUsd: 13.5, |
| 52 | 52 | costSource: "modelPriced", |
| 53 | 53 | }); |
| ... | ... | @@ -69,7 +69,7 @@ describe("usage pricing", () => { |
| 69 | 69 | expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9); |
| 70 | 70 | expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5); |
| 71 | 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 | 73 | costUsd: 0.25, |
| 74 | 74 | costSource: "providerReported", |
| 75 | 75 | }); |
| ... | ... | @@ -81,7 +81,7 @@ describe("usage pricing", () => { |
| 81 | 81 | "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, |
| 82 | 82 | }); |
| 83 | 83 | |
| 84 | expect(priceUsage(table, record("example-model"), overrides)).toEqual({ | |
| 84 | expect(priceUsage(table, record("example-model"), overrides)).toMatchObject({ | |
| 85 | 85 | costUsd: 14, |
| 86 | 86 | costSource: "modelPriced", |
| 87 | 87 | }); |
| ... | ... | @@ -96,7 +96,7 @@ describe("usage pricing", () => { |
| 96 | 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 | 100 | costUsd: 0, |
| 101 | 101 | costSource: "modelPriced", |
| 102 | 102 | }); |
| ... | ... | @@ -110,6 +110,8 @@ describe("usage pricing", () => { |
| 110 | 110 | expect(priceUsage(table, record(model, 99), overrides)).toEqual({ |
| 111 | 111 | costUsd: 99, |
| 112 | 112 | costSource: "providerReported", |
| 113 | categoryCostUsd: null, | |
| 114 | speedPremiumUsd: 0, | |
| 113 | 115 | }); |
| 114 | 116 | } |
| 115 | 117 | }); |
| ... | ... | @@ -136,6 +138,42 @@ describe("usage pricing", () => { |
| 136 | 138 | ); |
| 137 | 139 | }); |
| 138 | 140 | |
| 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 | 177 | it("prices Codex priority and ultrafast requests at their published tier rates", () => { |
| 140 | 178 | const table = parseRateTable({ |
| 141 | 179 | "gpt-6-astra": { |
apps/server/src/usage/usagePricing.ts+54-13| ... | ... | @@ -7,7 +7,12 @@ |
| 7 | 7 | * |
| 8 | 8 | * @module usagePricing |
| 9 | 9 | */ |
| 10 | import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts"; | |
| 10 | import type { | |
| 11 | UsageCategoryCost, | |
| 12 | UsageCostSource, | |
| 13 | UsageModelPriceOverride, | |
| 14 | UsageTokenTotals, | |
| 15 | } from "@t3tools/contracts"; | |
| 11 | 16 | |
| 12 | 17 | import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; |
| 13 | 18 | |
| ... | ... | @@ -246,11 +251,31 @@ export type PricedRecord = Pick< |
| 246 | 251 | export interface PricedUsage { |
| 247 | 252 | readonly costUsd: number; |
| 248 | 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 | ||
| 260 | function 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 | ||
| 269 | function sumCategories(cost: UsageCategoryCost): number { | |
| 270 | return cost.input + cost.cacheRead + cost.cacheWrite + cost.output; | |
| 249 | 271 | } |
| 250 | 272 | |
| 251 | 273 | /** |
| 252 | 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 | 279 | * `reasoningTokens` is intentionally not charged separately: it is already |
| 255 | 280 | * counted inside `outputTokens`. |
| 256 | 281 | */ |
| ... | ... | @@ -261,21 +286,37 @@ export function priceUsage( |
| 261 | 286 | ): PricedUsage { |
| 262 | 287 | const { model, totals, reportedCostUsd } = record; |
| 263 | 288 | 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 | }); | |
| 268 | 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 | } | |
| 270 | 303 | |
| 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 | 310 | 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, | |
| 279 | 320 | }; |
| 280 | 321 | } |
| 281 | 322 |
apps/web/src/components/usage/UsageModelDialog.tsx created+166| ... | ... | @@ -0,0 +1,166 @@ |
| 1 | import { formatPercent, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; | |
| 2 | import { isModelCostUnknown, type ModelTotals } from "@t3tools/shared/usageMerge"; | |
| 3 | import { useMemo } from "react"; | |
| 4 | ||
| 5 | import { mergeAnsweredUsage, type EnvironmentUsageStatus } from "../../state/usage"; | |
| 6 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | |
| 7 | import { Button } from "../ui/button"; | |
| 8 | import { | |
| 9 | Dialog, | |
| 10 | DialogDescription, | |
| 11 | DialogFooter, | |
| 12 | DialogHeader, | |
| 13 | DialogPanel, | |
| 14 | DialogPopup, | |
| 15 | DialogTitle, | |
| 16 | } from "../ui/dialog"; | |
| 17 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | |
| 18 | import { UsageShareBar } from "./UsageShareBar"; | |
| 19 | import { | |
| 20 | cacheHitRate, | |
| 21 | costPerMillionTokens, | |
| 22 | costTypeSegments, | |
| 23 | speedCostSegments, | |
| 24 | tokenTypeSegments, | |
| 25 | } from "./usageBreakdown"; | |
| 26 | import { PROVIDER_PRESENTATION } from "./usageProviders"; | |
| 27 | ||
| 28 | export 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 | */ | |
| 41 | export 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. */ | |
| 160 | export 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 | 78 | import { UsageLimitsSection } from "./UsageLimits"; |
| 79 | 79 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; |
| 80 | 80 | import { UsageProviderChart } from "./UsageProviderChart"; |
| 81 | import { sortModelsByTokens } from "./usageBreakdown"; | |
| 81 | import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; | |
| 82 | import { UsageShareBar } from "./UsageShareBar"; | |
| 83 | import { | |
| 84 | costTypeSegments, | |
| 85 | sortModelsByTokens, | |
| 86 | speedCostSegments, | |
| 87 | tokenTypeSegments, | |
| 88 | } from "./usageBreakdown"; | |
| 82 | 89 | import { |
| 83 | 90 | METRIC_OPTIONS, |
| 84 | 91 | WINDOW_OPTIONS, |
| ... | ... | @@ -127,6 +134,8 @@ export function UsagePage() { |
| 127 | 134 | const [limitsNow, setLimitsNow] = useState(() => Date.now()); |
| 128 | 135 | const refreshingRef = useRef(false); |
| 129 | 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 | 139 | const [selectedEnvironmentIds, setSelectedEnvironmentIds] = |
| 131 | 140 | useState<ReadonlySet<EnvironmentId> | null>(null); |
| 132 | 141 | const { days: windowDays, window } = windowSelection; |
| ... | ... | @@ -182,6 +191,14 @@ export function UsagePage() { |
| 182 | 191 | [breakdown, merged.models, metric], |
| 183 | 192 | ); |
| 184 | 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 | 202 | const summaryRows: Array< |
| 186 | 203 | | { readonly kind: "usage"; readonly provider: UsageProviderKind } |
| 187 | 204 | | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } |
| ... | ... | @@ -322,6 +339,7 @@ export function UsagePage() { |
| 322 | 339 | isPartial={isPartial} |
| 323 | 340 | duplicateSources={merged.duplicateSources} |
| 324 | 341 | contractMismatches={merged.contractMismatches} |
| 342 | onOpenModelPrices={() => setPriceDialog({})} | |
| 325 | 343 | /> |
| 326 | 344 | </WorkspaceBreadcrumbItem> |
| 327 | 345 | </WorkspaceBreadcrumb> |
| ... | ... | @@ -625,6 +643,34 @@ export function UsagePage() { |
| 625 | 643 | </div> |
| 626 | 644 | </section> |
| 627 | 645 | |
| 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 | 674 | <section className="flex flex-col gap-3"> |
| 629 | 675 | <div className="flex items-center justify-between gap-3"> |
| 630 | 676 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| ... | ... | @@ -651,55 +697,73 @@ export function UsagePage() { |
| 651 | 697 | </div> |
| 652 | 698 | |
| 653 | 699 | {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"> | |
| 661 | 701 | <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> | |
| 667 | 708 | </tr> |
| 668 | 709 | </thead> |
| 669 | 710 | <tbody> |
| 670 | 711 | {breakdownModels.length === 0 ? ( |
| 671 | 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 | 714 | No activity in this window. |
| 674 | 715 | </td> |
| 675 | 716 | </tr> |
| 676 | 717 | ) : ( |
| 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 | }) | |
| 703 | 767 | )} |
| 704 | 768 | </tbody> |
| 705 | 769 | </table> |
| ... | ... | @@ -772,6 +836,35 @@ export function UsagePage() { |
| 772 | 836 | </WorkspacePageContainer> |
| 773 | 837 | </ScrollArea> |
| 774 | 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 | 868 | </SidebarInset> |
| 776 | 869 | ); |
| 777 | 870 | } |
| ... | ... | @@ -989,6 +1082,7 @@ function UsageEnvironmentFilter({ |
| 989 | 1082 | isPartial, |
| 990 | 1083 | duplicateSources, |
| 991 | 1084 | contractMismatches, |
| 1085 | onOpenModelPrices, | |
| 992 | 1086 | }: { |
| 993 | 1087 | readonly environments: readonly EnvironmentUsageStatus[]; |
| 994 | 1088 | readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; |
| ... | ... | @@ -998,8 +1092,8 @@ function UsageEnvironmentFilter({ |
| 998 | 1092 | readonly isPartial: boolean; |
| 999 | 1093 | readonly duplicateSources: readonly string[]; |
| 1000 | 1094 | readonly contractMismatches: MergedUsage["contractMismatches"]; |
| 1095 | readonly onOpenModelPrices: () => void; | |
| 1001 | 1096 | }) { |
| 1002 | const [modelPricesOpen, setModelPricesOpen] = useState(false); | |
| 1003 | 1097 | const allSelected = selectedEnvironmentIds === null; |
| 1004 | 1098 | const label = allSelected |
| 1005 | 1099 | ? "All environments" |
| ... | ... | @@ -1015,121 +1109,108 @@ function UsageEnvironmentFilter({ |
| 1015 | 1109 | contractMismatches.length > 0; |
| 1016 | 1110 | |
| 1017 | 1111 | 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" | |
| 1116 | 1128 | /> |
| 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> | |
| 1133 | 1214 | ); |
| 1134 | 1215 | } |
| 1135 | 1216 | |
| ... | ... | @@ -1173,15 +1254,16 @@ function UsageSkeleton() { |
| 1173 | 1254 | |
| 1174 | 1255 | <section className="flex flex-col gap-2"> |
| 1175 | 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"> | |
| 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" /> | |
| 1185 | 1267 | </div> |
| 1186 | 1268 | </section> |
| 1187 | 1269 | |
| ... | ... | @@ -1195,3 +1277,16 @@ function UsageSkeleton() { |
| 1195 | 1277 | </> |
| 1196 | 1278 | ); |
| 1197 | 1279 | } |
| 1280 | ||
| 1281 | function 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 | 91 | >; |
| 92 | 92 | }; |
| 93 | 93 | |
| 94 | /** Edits custom model prices. `initialModel` opens with a new row for that model. */ | |
| 94 | 95 | export function UsagePriceOverrides({ |
| 95 | 96 | usage, |
| 96 | 97 | initialSelectedEnvironmentIds, |
| 98 | initialModel, | |
| 97 | 99 | onOpenChange, |
| 98 | 100 | }: { |
| 99 | 101 | readonly usage: readonly EnvironmentUsageStatus[]; |
| 100 | 102 | readonly initialSelectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; |
| 103 | readonly initialModel?: string | undefined; | |
| 101 | 104 | readonly onOpenChange: (open: boolean) => void; |
| 102 | 105 | }) { |
| 103 | 106 | const environments = useAtomValue(priceTargetsAtom); |
| ... | ... | @@ -105,7 +108,16 @@ export function UsagePriceOverrides({ |
| 105 | 108 | const selected = environments.filter( |
| 106 | 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 | 121 | const [pending, setPending] = useState(false); |
| 110 | 122 | const [attempt, setAttempt] = useState<SaveAttempt | null>(null); |
| 111 | 123 | const focusRowRef = useRef<string | null>(null); |
apps/web/src/components/usage/UsageShareBar.tsx created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import { formatPercent } from "@t3tools/shared/usageFormat"; | |
| 2 | import type { ReactNode } from "react"; | |
| 3 | ||
| 4 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 5 | ||
| 6 | export 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 | */ | |
| 16 | export 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 | 1 | import type { ModelTotals } from "@t3tools/shared/usageMerge"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { sortModelsByTokens } from "./usageBreakdown"; | |
| 4 | import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown"; | |
| 5 | 5 | |
| 6 | const model = (name: string, totalTokens: number, costUsd: number): ModelTotals => ({ | |
| 6 | const model = ( | |
| 7 | name: string, | |
| 8 | totalTokens: number, | |
| 9 | costUsd: number, | |
| 10 | overrides: Partial<ModelTotals> = {}, | |
| 11 | ): ModelTotals => ({ | |
| 7 | 12 | model: name, |
| 8 | 13 | provider: "codex", |
| 9 | 14 | costUsd, |
| 10 | 15 | totalTokens, |
| 16 | tokens: { | |
| 17 | uncachedInputTokens: totalTokens, | |
| 18 | cachedInputTokens: 0, | |
| 19 | cacheCreationTokens: 0, | |
| 20 | outputTokens: 0, | |
| 21 | reasoningTokens: 0, | |
| 22 | }, | |
| 11 | 23 | records: 1, |
| 12 | 24 | unpricedRecords: 0, |
| 25 | unpricedTokens: 0, | |
| 13 | 26 | costShare: 0, |
| 27 | ...overrides, | |
| 14 | 28 | }); |
| 15 | 29 | |
| 16 | 30 | describe("sortModelsByTokens", () => { |
| ... | ... | @@ -29,3 +43,24 @@ describe("sortModelsByTokens", () => { |
| 29 | 43 | expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]); |
| 30 | 44 | }); |
| 31 | 45 | }); |
| 46 | ||
| 47 | describe("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 | import type { ModelTotals } from "@t3tools/shared/usageMerge"; | |
| 1 | import type { UsageTokenTotals } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | isModelCostUnknown, | |
| 4 | type CategoryCost, | |
| 5 | type ModelTotals, | |
| 6 | type SpeedCost, | |
| 7 | } from "@t3tools/shared/usageMerge"; | |
| 8 | ||
| 9 | import type { ShareSegment } from "./UsageShareBar"; | |
| 2 | 10 | |
| 3 | 11 | export function sortModelsByTokens(models: readonly ModelTotals[]) { |
| 4 | 12 | return models.toSorted( |
| 5 | 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 | */ | |
| 21 | export 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. */ | |
| 27 | export 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. */ | |
| 35 | const 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. */ | |
| 39 | const TYPE_COLORS = { | |
| 40 | input: ink(60), | |
| 41 | cacheRead: ink(30), | |
| 42 | cacheWrite: ink(72), | |
| 43 | output: ink(100), | |
| 44 | other: ink(44), | |
| 45 | }; | |
| 46 | ||
| 47 | export 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 | ||
| 59 | export 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. */ | |
| 71 | export 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 | 10 | import { |
| 11 | 11 | USAGE_CONTRACT_VERSION, |
| 12 | 12 | type EnvironmentId, |
| 13 | type UsageBucket, | |
| 13 | 14 | type UsageSummary, |
| 14 | 15 | type UsageSummaryInput, |
| 15 | 16 | } from "@t3tools/contracts"; |
| ... | ... | @@ -79,6 +80,33 @@ export interface UsageView { |
| 79 | 80 | readonly refresh: (input?: UsageSummaryInput) => Promise<void>; |
| 80 | 81 | } |
| 81 | 82 | |
| 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 | */ | |
| 89 | export 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 | ||
| 82 | 110 | export function useUsage( |
| 83 | 111 | input: UsageSummaryInput, |
| 84 | 112 | selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null, |
| ... | ... | @@ -126,20 +154,7 @@ export function useUsage( |
| 126 | 154 | [selectedEnvironments, windowKey], |
| 127 | 155 | ); |
| 128 | 156 | |
| 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]); | |
| 143 | 158 | |
| 144 | 159 | const answeredCount = selectedEnvironments.filter( |
| 145 | 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 | 8 | |
| 9 | 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected |
| 10 | 10 | environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent |
| 11 | cost. These estimates are not your subscription bill. | |
| 11 | cost, 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 | |
| 13 | split, such as a provider-reported cost for a model without public rates, shows as **Other**. | |
| 14 | Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens. | |
| 12 | 15 | |
| 13 | 16 | Totals depend on the history available on each server. Grok turns without a saved completed-turn |
| 14 | 17 | record are missing from the totals. |
| ... | ... | @@ -47,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod |
| 47 | 50 | edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter; |
| 48 | 51 | choose all environments or select individual destinations. Enter the exact model ID and USD |
| 49 | 52 | rates per million input and output tokens. You can enter any model ID, including models |
| 50 | without public pricing. | |
| 53 | without 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. | |
| 51 | 55 | |
| 52 | 56 | Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for |
| 53 | 57 | tokens 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 | 18 | * client renders partial coverage when an environment reports an older version |
| 19 | 19 | * rather than failing the whole page. |
| 20 | 20 | * 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 | */ |
| 23 | 24 | export const USAGE_CONTRACT_VERSION = 6 as const; |
| 24 | 25 | |
| ... | ... | @@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({ |
| 84 | 85 | }); |
| 85 | 86 | export type UsageTokenTotals = typeof UsageTokenTotals.Type; |
| 86 | 87 | |
| 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 | */ | |
| 92 | export const UsageCategoryCost = Schema.Struct({ | |
| 93 | input: Schema.Number, | |
| 94 | cacheRead: Schema.Number, | |
| 95 | cacheWrite: Schema.Number, | |
| 96 | output: Schema.Number, | |
| 97 | }); | |
| 98 | export type UsageCategoryCost = typeof UsageCategoryCost.Type; | |
| 99 | ||
| 87 | 100 | /** |
| 88 | 101 | * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start |
| 89 | 102 | * instant of a rolling bucket and is present only for hourly requests. |
| ... | ... | @@ -108,6 +121,16 @@ export const UsageBucket = Schema.Struct({ |
| 108 | 121 | * rather than derived on the client. |
| 109 | 122 | */ |
| 110 | 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 | 134 | costSource: UsageCostSource, |
| 112 | 135 | /** Distinct assistant responses, after de-duplication. */ |
| 113 | 136 | records: NonNegativeInt, |
packages/shared/src/usageMerge.test.ts+61| ... | ... | @@ -546,6 +546,67 @@ describe("mergeUsage", () => { |
| 546 | 546 | ]); |
| 547 | 547 | }); |
| 548 | 548 | |
| 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 | 610 | it("orders models by cost descending", () => { |
| 550 | 611 | const merged = mergeUsage( |
| 551 | 612 | [ |
packages/shared/src/usageMerge.ts+81| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | type UsageSource, |
| 15 | 15 | type UsageSourceFingerprint, |
| 16 | 16 | type UsageSummary, |
| 17 | type UsageTokenTotals, | |
| 17 | 18 | } from "@t3tools/contracts"; |
| 18 | 19 | |
| 19 | 20 | export interface EnvironmentUsage { |
| ... | ... | @@ -37,12 +38,18 @@ export interface ModelTotals { |
| 37 | 38 | readonly provider: UsageProviderKind; |
| 38 | 39 | readonly costUsd: number; |
| 39 | 40 | readonly totalTokens: number; |
| 41 | readonly tokens: UsageTokenTotals; | |
| 40 | 42 | readonly records: number; |
| 41 | 43 | /** |
| 42 | 44 | * Records whose tokens are counted here but which contributed nothing to |
| 43 | 45 | * `costUsd`. When it equals `records` the cost is unknown, not zero. |
| 44 | 46 | */ |
| 45 | 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 | 53 | readonly costShare: number; |
| 47 | 54 | } |
| 48 | 55 | |
| ... | ... | @@ -76,6 +83,27 @@ export interface CostQuality { |
| 76 | 83 | readonly cacheSavingsUsd: number; |
| 77 | 84 | } |
| 78 | 85 | |
| 86 | /** | |
| 87 | * `costUsd` by token category. `unsplit` is cost no rates could split, | |
| 88 | * including all cost from servers that predate the split. | |
| 89 | */ | |
| 90 | export 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. */ | |
| 99 | export 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 | ||
| 79 | 107 | export interface UsageContractMismatch { |
| 80 | 108 | readonly environmentId: EnvironmentId; |
| 81 | 109 | readonly direction: "serverBehind" | "clientBehind"; |
| ... | ... | @@ -97,6 +125,8 @@ export interface MergedUsage { |
| 97 | 125 | readonly daily: readonly DailyTotals[]; |
| 98 | 126 | readonly hourly: readonly HourlyTotals[]; |
| 99 | 127 | readonly costQuality: CostQuality; |
| 128 | readonly categoryCost: CategoryCost; | |
| 129 | readonly speedCost: SpeedCost; | |
| 100 | 130 | /** Environments whose data was dropped as a duplicate of another's. */ |
| 101 | 131 | readonly duplicateSources: readonly string[]; |
| 102 | 132 | readonly contributingEnvironments: readonly EnvironmentId[]; |
| ... | ... | @@ -307,6 +337,8 @@ const EMPTY_MERGED: MergedUsage = { |
| 307 | 337 | unpricedShare: 0, |
| 308 | 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 | 342 | duplicateSources: [], |
| 311 | 343 | contributingEnvironments: [], |
| 312 | 344 | contractMismatches: [], |
| ... | ... | @@ -364,6 +396,8 @@ export function mergeUsage( |
| 364 | 396 | let cacheSavingsUsd = 0; |
| 365 | 397 | let providerReportedRecords = 0; |
| 366 | 398 | let unpricedRecords = 0; |
| 399 | const categoryCost = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }; | |
| 400 | const speedCost = { fast: 0, ultrafast: 0, premium: 0 }; | |
| 367 | 401 | |
| 368 | 402 | const providerAccumulator = new Map< |
| 369 | 403 | UsageProviderKind, |
| ... | ... | @@ -375,8 +409,10 @@ export function mergeUsage( |
| 375 | 409 | provider: UsageProviderKind; |
| 376 | 410 | costUsd: number; |
| 377 | 411 | totalTokens: number; |
| 412 | tokens: UsageTokenTotals; | |
| 378 | 413 | records: number; |
| 379 | 414 | unpricedRecords: number; |
| 415 | unpricedTokens: number; | |
| 380 | 416 | } |
| 381 | 417 | >(); |
| 382 | 418 | const dailyAccumulator = new Map< |
| ... | ... | @@ -434,6 +470,15 @@ export function mergeUsage( |
| 434 | 470 | records += bucket.records; |
| 435 | 471 | unpricedRecords += bucket.unpricedRecords; |
| 436 | 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; | |
| 437 | 482 | |
| 438 | 483 | const provider = providerAccumulator.get(bucket.provider) ?? { |
| 439 | 484 | costUsd: 0, |
| ... | ... | @@ -451,13 +496,31 @@ export function mergeUsage( |
| 451 | 496 | provider: bucket.provider, |
| 452 | 497 | costUsd: 0, |
| 453 | 498 | totalTokens: 0, |
| 499 | tokens: { | |
| 500 | uncachedInputTokens: 0, | |
| 501 | cachedInputTokens: 0, | |
| 502 | cacheCreationTokens: 0, | |
| 503 | outputTokens: 0, | |
| 504 | reasoningTokens: 0, | |
| 505 | }, | |
| 454 | 506 | records: 0, |
| 455 | 507 | unpricedRecords: 0, |
| 508 | unpricedTokens: 0, | |
| 456 | 509 | }; |
| 457 | 510 | model.costUsd += bucket.costUsd; |
| 458 | 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 | 519 | model.records += bucket.records; |
| 460 | 520 | model.unpricedRecords += bucket.unpricedRecords; |
| 521 | if (bucket.records > 0) { | |
| 522 | model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records; | |
| 523 | } | |
| 461 | 524 | modelAccumulator.set(modelKey, model); |
| 462 | 525 | |
| 463 | 526 | const day = dailyAccumulator.get(bucket.day) ?? { |
| ... | ... | @@ -515,8 +578,10 @@ export function mergeUsage( |
| 515 | 578 | provider: totals.provider, |
| 516 | 579 | costUsd: totals.costUsd, |
| 517 | 580 | totalTokens: totals.totalTokens, |
| 581 | tokens: totals.tokens, | |
| 518 | 582 | records: totals.records, |
| 519 | 583 | unpricedRecords: totals.unpricedRecords, |
| 584 | unpricedTokens: totals.unpricedTokens, | |
| 520 | 585 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 521 | 586 | })) |
| 522 | 587 | .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); |
| ... | ... | @@ -555,6 +620,22 @@ export function mergeUsage( |
| 555 | 620 | records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, |
| 556 | 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 | 639 | duplicateSources: duplicates, |
| 559 | 640 | contributingEnvironments, |
| 560 | 641 | contractMismatches, |