| 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,7 +42,7 @@ import { UsageLimitsSection } from "./UsageLimitsPooled"; |
| 42 | import { ControlPillMenu } from "../../components/ControlPill"; | 42 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 43 | import { SymbolView } from "../../components/AppSymbol"; | 43 | import { SymbolView } from "../../components/AppSymbol"; |
| 44 | import type { UsageChartMetric } from "./usageChartData"; | 44 | import type { UsageChartMetric } from "./usageChartData"; |
| 45 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; | 45 | import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders"; |
| 46 | 46 | ||
| 47 | type UsageTab = "usage" | "limits"; | 47 | type UsageTab = "usage" | "limits"; |
| 48 | const TAB_OPTIONS = [ | 48 | const 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 | } |
| 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 | function MetricCell(props: { | 770 | function 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 | */ | ||
| 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,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 | }); |
| 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 | 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 usageAggregation | 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 | import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; | 23 | import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; |
| 18 | import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; | 24 | import { 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 { |
| 165 | 179 | ||
| 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 | } |
| 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 | * A bucket mixes records from one model, but their cost provenance can differ | 274 | * 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 the | 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,7 +47,7 @@ describe("usage pricing", () => { |
| 47 | }); | 47 | }); |
| 48 | 48 | ||
| 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 | }); |
| 83 | 83 | ||
| 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 | }); |
| 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 | 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 usagePricing | 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 | import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; | 17 | import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; |
| 13 | 18 | ||
| ... | @@ -246,11 +251,31 @@ export type PricedRecord = Pick< | ... | @@ -246,11 +251,31 @@ export type PricedRecord = Pick< |
| 246 | export interface PricedUsage { | 251 | export 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 | |||
| 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 | * 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 already | 279 | * `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 |
| 267 | 292 | : 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 | } | ||
| 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 | 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 | } |
| 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,7 +78,14 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 78 | import { UsageLimitsSection } from "./UsageLimits"; | 78 | import { UsageLimitsSection } from "./UsageLimits"; |
| 79 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; | 79 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; |
| 80 | import { UsageProviderChart } from "./UsageProviderChart"; | 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 | import { | 89 | import { |
| 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> |
| 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 | <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> |
| 652 | 698 | ||
| 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 | <tr | 719 | 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 = allSelected | 1098 | 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; |
| 1016 | 1110 | ||
| 1017 | return ( | 1111 | return ( |
| 1018 | <> | 1112 | <Menu> |
| 1019 | <Menu> | 1113 | <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full"> |
| 1020 | <MenuTrigger | 1114 | <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"} still | 1124 | ) : showUsageStatus && hasIssue ? ( |
| 1031 | scanning | 1125 | <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 prices | 1133 | /> |
| 1122 | </MenuItem> | 1134 | )} |
| 1123 | </MenuPopup> | 1135 | </span> |
| 1124 | </Menu> | 1136 | </MenuTrigger> |
| 1125 | {modelPricesOpen ? ( | 1137 | <MenuPopup align="start"> |
| 1126 | <UsagePriceOverrides | 1138 | <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 | } |
| 1135 | 1216 | ||
| ... | @@ -1173,15 +1254,16 @@ function UsageSkeleton() { | ... | @@ -1173,15 +1254,16 @@ function UsageSkeleton() { |
| 1173 | 1254 | ||
| 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> |
| 1187 | 1269 | ||
| ... | @@ -1195,3 +1277,16 @@ function UsageSkeleton() { | ... | @@ -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,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 | export function UsagePriceOverrides({ | 95 | export 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 @@ | ||
| 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,16 +1,30 @@ |
| 1 | import type { ModelTotals } from "@t3tools/shared/usageMerge"; | 1 | import type { ModelTotals } from "@t3tools/shared/usageMerge"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 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 | 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 | }); |
| 15 | 29 | ||
| 16 | describe("sortModelsByTokens", () => { | 30 | describe("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 | |||
| 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,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 | export function sortModelsByTokens(models: readonly ModelTotals[]) { | 11 | export 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 | */ | ||
| 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,6 +10,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 10 | import { | 10 | import { |
| 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 | } |
| 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 | export function useUsage( | 110 | export 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 | ); |
| 128 | 156 | ||
| 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]); | ||
| 143 | 158 | ||
| 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 |
| 8 | 8 | ||
| 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected | 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected |
| 10 | environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent | 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 | Totals depend on the history available on each server. Grok turns without a saved completed-turn | 16 | Totals depend on the history available on each server. Grok turns without a saved completed-turn |
| 14 | record are missing from the totals. | 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,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod |
| 47 | edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter; | 50 | edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter; |
| 48 | choose all environments or select individual destinations. Enter the exact model ID and USD | 51 | choose all environments or select individual destinations. Enter the exact model ID and USD |
| 49 | rates per million input and output tokens. You can enter any model ID, including models | 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 | Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for | 56 | Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for |
| 53 | tokens that are free. Saved prices replace automatic pricing for all of that environment's | 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,7 +18,8 @@ import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from ". |
| 18 | * client renders partial coverage when an environment reports an older version | 18 | * 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: unknown | 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 | export const USAGE_CONTRACT_VERSION = 6 as const; | 24 | export const USAGE_CONTRACT_VERSION = 6 as const; |
| 24 | 25 | ||
| ... | @@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({ | ... | @@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({ |
| 84 | }); | 85 | }); |
| 85 | export type UsageTokenTotals = typeof UsageTokenTotals.Type; | 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 | * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start | 101 | * 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 | }); |
| 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 | 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"; |
| 18 | 19 | ||
| 19 | export interface EnvironmentUsage { | 20 | export 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 to | 44 | * 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 | } |
| 48 | 55 | ||
| ... | @@ -76,6 +83,27 @@ export interface CostQuality { | ... | @@ -76,6 +83,27 @@ export interface CostQuality { |
| 76 | readonly cacheSavingsUsd: number; | 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 | export interface UsageContractMismatch { | 107 | export 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 }; | ||
| 367 | 401 | ||
| 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; | ||
| 437 | 482 | ||
| 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); |
| 462 | 525 | ||
| 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, |