From e8545b293b79cdd73d30ef921db149ef1b5abee0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 02:24:10 -0700 Subject: [PATCH] feat(usage): show cost by token type, speed, and model detail (#15108) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/usage/UsageRouteScreen.tsx | 81 +++- .../src/features/usage/usageProviders.ts | 20 + .../server/src/usage/usageAggregation.test.ts | 25 +- apps/server/src/usage/usageAggregation.ts | 58 ++- apps/server/src/usage/usagePricing.test.ts | 46 +- apps/server/src/usage/usagePricing.ts | 67 ++- .../src/components/usage/UsageModelDialog.tsx | 166 +++++++ apps/web/src/components/usage/UsagePage.tsx | 423 +++++++++++------- .../components/usage/UsagePriceOverrides.tsx | 14 +- .../src/components/usage/UsageShareBar.tsx | 73 +++ .../components/usage/usageBreakdown.test.ts | 39 +- .../src/components/usage/usageBreakdown.ts | 72 ++- apps/web/src/state/usage.ts | 43 +- docs/user/usage.md | 8 +- packages/contracts/src/usage.ts | 25 +- packages/shared/src/usageMerge.test.ts | 61 +++ packages/shared/src/usageMerge.ts | 81 ++++ 17 files changed, 1097 insertions(+), 205 deletions(-) create mode 100644 apps/web/src/components/usage/UsageModelDialog.tsx create mode 100644 apps/web/src/components/usage/UsageShareBar.tsx diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 5bb2713d6c8d9ff74d2c3b6ba05b2958418f58f9..3022b9fe22084256cc708aaa7ae6b74aa4e141c0 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -42,7 +42,7 @@ import { UsageLimitsSection } from "./UsageLimitsPooled"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import type { UsageChartMetric } from "./usageChartData"; -import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; +import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders"; type UsageTab = "usage" | "limits"; const TAB_OPTIONS = [ @@ -360,6 +360,7 @@ export function UsageRouteScreen() { onCursorEnabled={refreshAfterCursorEnable} /> + )} @@ -688,6 +689,84 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H ); } +function CostSection(props: { readonly merged: MergedUsage }) { + const { categoryCost, speedCost } = props.merged; + const colors = useUsageMixColors(); + const byType = [ + { label: "Input", value: categoryCost.input, color: colors.input }, + { label: "Cache read", value: categoryCost.cacheRead, color: colors.cacheRead }, + { label: "Cache write", value: categoryCost.cacheWrite, color: colors.cacheWrite }, + { label: "Output", value: categoryCost.output, color: colors.output }, + // Reported cost with no rates to split it, or from older servers. Below a + // cent it is rounding, not usage. + { + label: "Other", + value: categoryCost.unsplit >= 0.005 ? categoryCost.unsplit : 0, + color: colors.other, + }, + ]; + const bySpeed = [ + { label: "Standard", value: speedCost.standard, color: colors.standard }, + { label: "Fast", value: speedCost.fast, color: colors.fast }, + { label: "Ultrafast", value: speedCost.ultrafast, color: colors.ultrafast }, + ]; + if (props.merged.costUsd <= 0) return null; + + return ( + + + {speedCost.fast + speedCost.ultrafast > 0 ? ( + + + + ) : null} + + ); +} + +/** One part-to-whole cost bar with its legend. Empty segments are left out. */ +function ShareBar(props: { + readonly label: string; + readonly segments: readonly { label: string; value: number; color: string }[]; + readonly aside?: string; +}) { + const visible = props.segments.filter((segment) => segment.value > 0); + if (visible.length === 0) return null; + + return ( + + + {props.label} + {props.aside ? ( + {props.aside} + ) : null} + + + {visible.map((segment) => ( + + ))} + + + {visible.map((segment) => ( + + + {segment.label} + {formatUsd(segment.value)} + + ))} + + + ); +} + function MetricCell(props: { readonly label: string; readonly value: string; diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 45a94ddae1f7dd606c060c9a7f1a101f2a2a4b81..5742cbca1043368b48d98dfb4bcde2917aeeac2c 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -38,3 +38,23 @@ export function useProviderColors(): Record { antigravity: "#8c7bd1", }; } + +/** + * Neutral steps for cost and token mixes, so they never borrow a provider's + * color. Matches the web steps: oklab mixes of the codex ink into the + * background, above the 15 ΔE separation floor for adjacent segments. + */ +export function useUsageMixColors() { + const { themeAppearance: scheme } = useAppearancePreferences(); + const dark = scheme === "dark"; + return { + input: dark ? "#737373" : "#848484", + cacheRead: dark ? "#282828" : "#c0c0c0", + cacheWrite: dark ? "#949494" : "#6d6d6d", + output: dark ? "#e6e6e6" : "#3c3c43", + other: dark ? "#494949" : "#a3a3a3", + standard: dark ? "#313131" : "#b8b8b8", + fast: dark ? "#838383" : "#797979", + ultrafast: dark ? "#e6e6e6" : "#3c3c43", + }; +} diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 7ae0712c12ba66e78ca94e96417f1ca519dbf6d0..ab260d3398b26790fd3ab6e083dbb9fbdbb427bc 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -12,7 +12,12 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, - fast: null, + fast: { + inputCostPerToken: 2e-5, + outputCostPerToken: 1e-4, + cacheReadCostPerToken: 2e-6, + cacheCreationCostPerToken: 2.5e-5, + }, ultrafast: null, }, ], @@ -77,6 +82,24 @@ describe("UsageAggregator", () => { ).toThrow("requires exact time bounds"); }); + it("splits a bucket's cost by category and speed", () => { + const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets; + + // Standard costs $0.005625 and fast twice that. + expect(bucket).toMatchObject({ + costUsd: expect.closeTo(0.016875), + categoryCostUsd: { + input: expect.closeTo(0.003), + cacheRead: expect.closeTo(0.003), + cacheWrite: expect.closeTo(0.000375), + output: expect.closeTo(0.0075), + }, + fastCostUsd: expect.closeTo(0.01125), + speedPremiumUsd: expect.closeTo(0.005625), + }); + expect(bucket).not.toHaveProperty("ultrafastCostUsd"); + }); + it("keeps only the first record for a repeated dedupe key", () => { const result = aggregate([ record({ dedupeKey: "msg_1:" }), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 92abfef7410892c55961eaa3a18bedb4c00b3199..040c258075d43ef6daac77fb887a2642296d1ac7 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -12,7 +12,13 @@ * * @module usageAggregation */ -import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts"; +import type { + UsageBucket, + UsageCategoryCost, + UsageDay, + UsageResolution, + UsageTokenTotals, +} from "@t3tools/contracts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; @@ -50,6 +56,10 @@ interface MutableBucket { totals: UsageTokenTotals; costUsd: number; cacheSavingsUsd: number; + categoryCostUsd: UsageCategoryCost | null; + fastCostUsd: number; + ultrafastCostUsd: number; + speedPremiumUsd: number; records: number; unpricedRecords: number; providerReportedRecords: number; @@ -153,6 +163,10 @@ export class UsageAggregator { totals: EMPTY_TOTALS, costUsd: 0, cacheSavingsUsd: 0, + categoryCostUsd: null, + fastCostUsd: 0, + ultrafastCostUsd: 0, + speedPremiumUsd: 0, records: 0, unpricedRecords: 0, providerReportedRecords: 0, @@ -165,6 +179,22 @@ export class UsageAggregator { bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; + if (priced.categoryCostUsd !== null) { + const sum = bucket.categoryCostUsd; + const add = priced.categoryCostUsd; + bucket.categoryCostUsd = + sum === null + ? add + : { + input: sum.input + add.input, + cacheRead: sum.cacheRead + add.cacheRead, + cacheWrite: sum.cacheWrite + add.cacheWrite, + output: sum.output + add.output, + }; + } + if (record.speed === "fast") bucket.fastCostUsd += priced.costUsd; + if (record.speed === "ultrafast") bucket.ultrafastCostUsd += priced.costUsd; + bucket.speedPremiumUsd += priced.speedPremiumUsd; bucket.cacheSavingsUsd += cacheSavingsUsd( this.#options.rates, record, @@ -182,6 +212,10 @@ export class UsageAggregator { for (const [key, bucket] of this.#buckets) { const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = key.split("\u0000"); + const category = bucket.categoryCostUsd; + const fastCostUsd = roundUsd(bucket.fastCostUsd); + const ultrafastCostUsd = roundUsd(bucket.ultrafastCostUsd); + const speedPremiumUsd = roundUsd(bucket.speedPremiumUsd); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), @@ -191,6 +225,20 @@ export class UsageAggregator { totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, + // Zero and unknown figures are omitted to keep payloads small. + ...(category === null + ? {} + : { + categoryCostUsd: { + input: roundUsd(category.input), + cacheRead: roundUsd(category.cacheRead), + cacheWrite: roundUsd(category.cacheWrite), + output: roundUsd(category.output), + }, + }), + ...(fastCostUsd === 0 ? {} : { fastCostUsd }), + ...(ultrafastCostUsd === 0 ? {} : { ultrafastCostUsd }), + ...(speedPremiumUsd === 0 ? {} : { speedPremiumUsd }), costSource: resolveCostSource(bucket), records: bucket.records, unpricedRecords: bucket.unpricedRecords, @@ -214,6 +262,14 @@ export class UsageAggregator { } } +/** + * Rounds to micro-dollars. The split and speed figures need no more precision, + * and shorter numbers keep them cheap on the wire. + */ +function roundUsd(value: number): number { + return Math.round(value * 1e6) / 1e6; +} + /** * A bucket mixes records from one model, but their cost provenance can differ * when only some records carried a reported cost. The weakest provenance in the diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index badd50a4901fd2346940c7795b8e2660dfe583ad..0a324dd734b44b1cd829d1d3af06724b9b76481d 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -47,7 +47,7 @@ describe("usage pricing", () => { }); for (const reportedCostUsd of [null, 99]) { - expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toEqual({ + expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toMatchObject({ costUsd: 13.5, costSource: "modelPriced", }); @@ -69,7 +69,7 @@ describe("usage pricing", () => { expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9); expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5); expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0); - expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({ + expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toMatchObject({ costUsd: 0.25, costSource: "providerReported", }); @@ -81,7 +81,7 @@ describe("usage pricing", () => { "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, }); - expect(priceUsage(table, record("example-model"), overrides)).toEqual({ + expect(priceUsage(table, record("example-model"), overrides)).toMatchObject({ costUsd: 14, costSource: "modelPriced", }); @@ -96,7 +96,7 @@ describe("usage pricing", () => { outputCostPerMillionTokens: 0, }, }); - expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toEqual({ + expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toMatchObject({ costUsd: 0, costSource: "modelPriced", }); @@ -110,6 +110,8 @@ describe("usage pricing", () => { expect(priceUsage(table, record(model, 99), overrides)).toEqual({ costUsd: 99, costSource: "providerReported", + categoryCostUsd: null, + speedPremiumUsd: 0, }); } }); @@ -136,6 +138,42 @@ describe("usage pricing", () => { ); }); + it("splits cost by category and prices the speed premium", () => { + const table = parseRateTable({ + "claude-opus-5-5": { + ...rate(4e-6, 4e-7), + cache_creation_input_token_cost: 5e-6, + provider_specific_entry: { fast: 2 }, + }, + }); + const split = (input: number, cacheRead: number, cacheWrite: number, output: number) => ({ + input: expect.closeTo(input), + cacheRead: expect.closeTo(cacheRead), + cacheWrite: expect.closeTo(cacheWrite), + output: expect.closeTo(output), + }); + + expect(priceUsage(table, record("claude-opus-5-5", null, "fast"))).toEqual({ + costUsd: expect.closeTo(58.8), + costSource: "modelPriced", + categoryCostUsd: split(8, 0.8, 10, 40), + speedPremiumUsd: expect.closeTo(29.4), + }); + // A reported cost keeps its total and splits in proportion to list rates. + expect(priceUsage(table, record("claude-opus-5-5", 29.4, "fast"))).toEqual({ + costUsd: 29.4, + costSource: "providerReported", + categoryCostUsd: split(4, 0.4, 5, 20), + speedPremiumUsd: expect.closeTo(14.7), + }); + // Without rates there is nothing to split it by. + expect(priceUsage(table, record("unknown-model", 29.4, "fast"))).toMatchObject({ + costUsd: 29.4, + categoryCostUsd: null, + speedPremiumUsd: 0, + }); + }); + it("prices Codex priority and ultrafast requests at their published tier rates", () => { const table = parseRateTable({ "gpt-6-astra": { diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 63190eebbf37dafc34a9aa94c3f11b89c1f7e131..51ef35d7a8ad2162402be0200db3bb77f82d3023 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -7,7 +7,12 @@ * * @module usagePricing */ -import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts"; +import type { + UsageCategoryCost, + UsageCostSource, + UsageModelPriceOverride, + UsageTokenTotals, +} from "@t3tools/contracts"; import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; @@ -246,11 +251,31 @@ export type PricedRecord = Pick< export interface PricedUsage { readonly costUsd: number; readonly costSource: UsageCostSource; + /** `costUsd` by token category, or `null` when no rates are known to split it. */ + readonly categoryCostUsd: UsageCategoryCost | null; + /** What `costUsd` exceeds the same tokens at standard rates. `0` without rates. */ + readonly speedPremiumUsd: number; +} + +function costByCategory(totals: UsageTokenTotals, rates: TokenRates): UsageCategoryCost { + return { + input: totals.uncachedInputTokens * rates.inputCostPerToken, + cacheRead: totals.cachedInputTokens * rates.cacheReadCostPerToken, + cacheWrite: totals.cacheCreationTokens * rates.cacheCreationCostPerToken, + output: totals.outputTokens * rates.outputCostPerToken, + }; +} + +function sumCategories(cost: UsageCategoryCost): number { + return cost.input + cost.cacheRead + cost.cacheWrite + cost.output; } /** * Prices one record's tokens. * + * A provider-reported cost is kept as is, and split by category and speed in + * proportion to the model's list rates when those are known. + * * `reasoningTokens` is intentionally not charged separately: it is already * counted inside `outputTokens`. */ @@ -261,21 +286,37 @@ export function priceUsage( ): PricedUsage { const { model, totals, reportedCostUsd } = record; const override = overrides?.get(model.trim()); - if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { - return { costUsd: reportedCostUsd, costSource: "providerReported" }; - } - + const reported = + override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd) + ? reportedCostUsd + : null; + const unsplit = (costUsd: number, costSource: UsageCostSource): PricedUsage => ({ + costUsd, + costSource, + categoryCostUsd: null, + speedPremiumUsd: 0, + }); const rate = override ?? lookupRate(table, record.rateModel ?? model); - if (rate === null) return { costUsd: 0, costSource: "unpriced" }; + if (rate === null) { + return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported"); + } - const rates = ratesAt(rate, record.speed); + const listCost = costByCategory(totals, ratesAt(rate, record.speed)); + const listCostUsd = sumCategories(listCost); + if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported"); + const premiumUsd = + record.speed === "standard" ? 0 : listCostUsd - sumCategories(costByCategory(totals, rate)); + const scale = reported === null ? 1 : reported / listCostUsd; return { - costUsd: - totals.uncachedInputTokens * rates.inputCostPerToken + - totals.cachedInputTokens * rates.cacheReadCostPerToken + - totals.cacheCreationTokens * rates.cacheCreationCostPerToken + - totals.outputTokens * rates.outputCostPerToken, - costSource: "modelPriced", + costUsd: reported ?? listCostUsd, + costSource: reported === null ? "modelPriced" : "providerReported", + categoryCostUsd: { + input: listCost.input * scale, + cacheRead: listCost.cacheRead * scale, + cacheWrite: listCost.cacheWrite * scale, + output: listCost.output * scale, + }, + speedPremiumUsd: premiumUsd * scale, }; } diff --git a/apps/web/src/components/usage/UsageModelDialog.tsx b/apps/web/src/components/usage/UsageModelDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..920680286f54e1fd7af3f66a2380c643aa1b2bae --- /dev/null +++ b/apps/web/src/components/usage/UsageModelDialog.tsx @@ -0,0 +1,166 @@ +import { formatPercent, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; +import { isModelCostUnknown, type ModelTotals } from "@t3tools/shared/usageMerge"; +import { useMemo } from "react"; + +import { mergeAnsweredUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { UsageShareBar } from "./UsageShareBar"; +import { + cacheHitRate, + costPerMillionTokens, + costTypeSegments, + speedCostSegments, + tokenTypeSegments, +} from "./usageBreakdown"; +import { PROVIDER_PRESENTATION } from "./usageProviders"; + +export interface UsageChartWindow { + readonly days: readonly string[]; + readonly hours: readonly string[]; + readonly resolution: "day" | "hour"; + readonly timeZone: string; + readonly referenceTime: string | undefined; +} + +/** + * One model's usage in the current window, opened from the Breakdown list. + * The trend and mixes come from the same merge as the page, narrowed to this + * model's buckets. + */ +export function UsageModelDialog({ + model, + environments, + metric, + chartWindow, + onSetPrice, + onClose, +}: { + readonly model: ModelTotals; + readonly environments: readonly EnvironmentUsageStatus[]; + readonly metric: UsageChartMetric; + readonly chartWindow: UsageChartWindow; + readonly onSetPrice: () => void; + readonly onClose: () => void; +}) { + const usage = useMemo( + () => + mergeAnsweredUsage( + environments, + (bucket) => bucket.provider === model.provider && bucket.model === model.model, + ), + [environments, model.provider, model.model], + ); + const providers = useMemo(() => [model.provider], [model.provider]); + const presentation = PROVIDER_PRESENTATION[model.provider]; + const costUnknown = isModelCostUnknown(model); + const hitRate = cacheHitRate(model); + const perMillion = costPerMillionTokens(model); + const stats = [ + { label: "Cost", value: costUnknown ? "Unpriced" : formatUsd(model.costUsd) }, + { label: "Tokens", value: formatTokens(model.totalTokens) }, + perMillion === null ? null : { label: "Per 1M tokens", value: formatUsd(perMillion) }, + hitRate === null ? null : { label: "Cache hit", value: formatPercent(hitRate) }, + ].filter((stat) => stat !== null); + + return ( + { + if (!open) onClose(); + }} + > + + +
+ + {model.model} +
+ + {presentation.label} + {costUnknown ? "" : ` · ${formatPercent(model.costShare)} of cost`} + +
+ +
+
+ {stats.map((stat) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+ + {/* Unpriced cost is unknown, not zero, so its trend shows tokens. */} + + +
+ {costUnknown ? null : ( + + )} + + {usage.speedCost.fast + usage.speedCost.ultrafast > 0 ? ( + } + /> + ) : null} +
+
+
+ {model.unpricedTokens > 0 ? ( + + + {formatTokens(model.unpricedTokens)} tokens have no known price + + + + ) : null} +
+
+ ); +} + +/** What the faster speeds cost above standard rates, beside the speed bar. */ +export function SpeedPremium({ premiumUsd }: { readonly premiumUsd: number }) { + return ( + + Premium {formatUsd(premiumUsd)} + + ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 5d17a34620014bcf73672525b7cfc4d4ea40132d..89bea86b7e0ff817fe48fab859bf530fdbbf4da2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -78,7 +78,14 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart } from "./UsageProviderChart"; -import { sortModelsByTokens } from "./usageBreakdown"; +import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; +import { UsageShareBar } from "./UsageShareBar"; +import { + costTypeSegments, + sortModelsByTokens, + speedCostSegments, + tokenTypeSegments, +} from "./usageBreakdown"; import { METRIC_OPTIONS, WINDOW_OPTIONS, @@ -127,6 +134,8 @@ export function UsagePage() { const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null); + const [selectedModelKey, setSelectedModelKey] = useState(null); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); const { days: windowDays, window } = windowSelection; @@ -182,6 +191,14 @@ export function UsagePage() { [breakdown, merged.models, metric], ); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + const selectedModel = + selectedModelKey === null + ? undefined + : merged.models.find((model) => `${model.provider}:${model.model}` === selectedModelKey); + const breakdownPeak = breakdownModels.reduce( + (peak, model) => Math.max(peak, metric === "tokens" ? model.totalTokens : model.costUsd), + 0, + ); const summaryRows: Array< | { readonly kind: "usage"; readonly provider: UsageProviderKind } | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } @@ -322,6 +339,7 @@ export function UsagePage() { isPartial={isPartial} duplicateSources={merged.duplicateSources} contractMismatches={merged.contractMismatches} + onOpenModelPrices={() => setPriceDialog({})} /> @@ -625,6 +643,34 @@ export function UsagePage() { + {merged.totalTokens > 0 ? ( +
+ {metric === "tokens" ? ( + + ) : ( + <> + + {merged.speedCost.fast + merged.speedCost.ultrafast > 0 ? ( + } + /> + ) : null} + + )} +
+ ) : null} +

Breakdown

@@ -651,55 +697,73 @@ export function UsagePage() {
{breakdown === "model" ? ( - - - - - - - +
- - - - - + + + + + + {breakdownModels.length === 0 ? ( - ) : ( - breakdownModels.map((model) => ( - - - - - - - )) + breakdownModels.map((model, index) => { + const key = `${model.provider}:${model.model}`; + const value = metric === "tokens" ? model.totalTokens : model.costUsd; + return ( + + + + + + + + ); + }) )}
ModelCostShareTokens
#ModelCostShareTokens
+ No activity in this window.
- - - {model.model} - - - {isModelCostUnknown(model) ? ( - Unpriced - ) : ( - formatUsd(model.costUsd) - )} - - {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
{index + 1} + {/* The button's overlay makes the whole row open the model. + Focus shows as the row's hover fill, not a ring. */} + +
+
0 && breakdownPeak > 0 + ? `max(0.5rem, ${(value / breakdownPeak) * 100}%)` + : 0, + backgroundColor: + PROVIDER_PRESENTATION[model.provider].color, + }} + /> +
+
+ {isModelCostUnknown(model) ? ( + Unpriced + ) : ( + formatUsd(model.costUsd) + )} + + {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)} + {formatTokens(model.totalTokens)}
@@ -772,6 +836,35 @@ export function UsagePage() { + {selectedModel !== undefined && !showingLimits ? ( + { + setSelectedModelKey(null); + setPriceDialog({ model: selectedModel.model }); + }} + onClose={() => setSelectedModelKey(null)} + /> + ) : null} + {priceDialog ? ( + { + if (!open) setPriceDialog(null); + }} + /> + ) : null} ); } @@ -989,6 +1082,7 @@ function UsageEnvironmentFilter({ isPartial, duplicateSources, contractMismatches, + onOpenModelPrices, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; @@ -998,8 +1092,8 @@ function UsageEnvironmentFilter({ readonly isPartial: boolean; readonly duplicateSources: readonly string[]; readonly contractMismatches: MergedUsage["contractMismatches"]; + readonly onOpenModelPrices: () => void; }) { - const [modelPricesOpen, setModelPricesOpen] = useState(false); const allSelected = selectedEnvironmentIds === null; const label = allSelected ? "All environments" @@ -1015,121 +1109,108 @@ function UsageEnvironmentFilter({ contractMismatches.length > 0; return ( - <> - - } - className="group/usage-environment min-w-0 max-w-full" + + } className="group/usage-environment min-w-0 max-w-full"> + {label} + + {showUsageStatus && pendingCount > 0 ? ( + <> + + + {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning + {isPartial ? "; totals are partial" : ""} + + + ) : showUsageStatus && hasIssue ? ( + + ) : ( + + )} + + + + onSelectionChange(checked ? null : new Set())} > - {label} - - {showUsageStatus && pendingCount > 0 ? ( - <> - - - {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still - scanning - {isPartial ? "; totals are partial" : ""} - - - ) : showUsageStatus && hasIssue ? ( - - ) : ( - - )} - - - - onSelectionChange(checked ? null : new Set())} - > - All environments - - - {environments.map((environment) => { - const checked = - selectedEnvironmentIds === null || - selectedEnvironmentIds.has(environment.environmentId); - const status = - environment.error !== null - ? "Unavailable" - : environment.summary !== null && - !isCompatibleUsageContractVersion( - environment.summary.contractVersion, - USAGE_CONTRACT_VERSION, - ) - ? "Update required" - : environment.summary === null - ? "Scanning…" - : environment.isPending - ? "Refreshing…" - : "Ready"; - return ( - { - const next = new Set(selectedEnvironments.map((entry) => entry.environmentId)); - if (nextChecked) next.add(environment.environmentId); - else next.delete(environment.environmentId); - onSelectionChange(next.size === environments.length ? null : next); - }} - > - - {environment.label} - {showUsageStatus ? ( - - {status} - - ) : null} - - - ); - })} - {environments.length === 0 ? ( -

No environments connected.

- ) : null} - {showUsageStatus && isPartial ? ( -

- Totals are partial while selected environments scan. -

- ) : null} - {showUsageStatus ? ( - - ) : null} - - setModelPricesOpen(true)}> - - Model prices - -
-
- {modelPricesOpen ? ( - - ) : null} - + All environments + + + {environments.map((environment) => { + const checked = + selectedEnvironmentIds === null || + selectedEnvironmentIds.has(environment.environmentId); + const status = + environment.error !== null + ? "Unavailable" + : environment.summary !== null && + !isCompatibleUsageContractVersion( + environment.summary.contractVersion, + USAGE_CONTRACT_VERSION, + ) + ? "Update required" + : environment.summary === null + ? "Scanning…" + : environment.isPending + ? "Refreshing…" + : "Ready"; + return ( + { + const next = new Set(selectedEnvironments.map((entry) => entry.environmentId)); + if (nextChecked) next.add(environment.environmentId); + else next.delete(environment.environmentId); + onSelectionChange(next.size === environments.length ? null : next); + }} + > + + {environment.label} + {showUsageStatus ? ( + + {status} + + ) : null} + + + ); + })} + {environments.length === 0 ? ( +

No environments connected.

+ ) : null} + {showUsageStatus && isPartial ? ( +

+ Totals are partial while selected environments scan. +

+ ) : null} + {showUsageStatus ? ( + + ) : null} + + + + Model prices + + +
); } @@ -1173,15 +1254,16 @@ function UsageSkeleton() {

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} - -
- ), - )} + +
+ +
+
+ + +
@@ -1195,3 +1277,16 @@ function UsageSkeleton() { ); } + +function MetricSkeletons({ labels }: { readonly labels: readonly string[] }) { + return ( +
+ {labels.map((label) => ( +
+ {label} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index 79ad4d473409ee2cebd6561479fa5ab56c2d6448..c9af1c0e1770eba8d7f38b36ea9df2bcd4bc8617 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -91,13 +91,16 @@ type SaveAttempt = { >; }; +/** Edits custom model prices. `initialModel` opens with a new row for that model. */ export function UsagePriceOverrides({ usage, initialSelectedEnvironmentIds, + initialModel, onOpenChange, }: { readonly usage: readonly EnvironmentUsageStatus[]; readonly initialSelectedEnvironmentIds: ReadonlySet | null; + readonly initialModel?: string | undefined; readonly onOpenChange: (open: boolean) => void; }) { const environments = useAtomValue(priceTargetsAtom); @@ -105,7 +108,16 @@ export function UsagePriceOverrides({ const selected = environments.filter( (environment) => selectedIds === null || selectedIds.has(environment.environmentId), ); - const [drafts, setDrafts] = useState([]); + // A model that already has a custom price somewhere is edited in its existing row. + const [drafts, setDrafts] = useState(() => + initialModel === undefined || + selected.some( + (environment) => + environment.prices !== null && Object.hasOwn(environment.prices, initialModel), + ) + ? [] + : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }], + ); const [pending, setPending] = useState(false); const [attempt, setAttempt] = useState(null); const focusRowRef = useRef(null); diff --git a/apps/web/src/components/usage/UsageShareBar.tsx b/apps/web/src/components/usage/UsageShareBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d94a912df536e64baf8009bead1ecb97e804c61 --- /dev/null +++ b/apps/web/src/components/usage/UsageShareBar.tsx @@ -0,0 +1,73 @@ +import { formatPercent } from "@t3tools/shared/usageFormat"; +import type { ReactNode } from "react"; + +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export interface ShareSegment { + readonly label: string; + readonly value: number; + readonly color: string; +} + +/** + * One part-to-whole bar with its legend, for cost or tokens split by type or + * speed. Empty segments are left out, and nothing renders without a total. + */ +export function UsageShareBar({ + label, + segments, + format, + aside, +}: { + readonly label: string; + readonly segments: readonly ShareSegment[]; + readonly format: (value: number) => string; + readonly aside?: ReactNode; +}) { + const visible = segments.filter((segment) => segment.value > 0); + const total = visible.reduce((sum, segment) => sum + segment.value, 0); + if (total <= 0) return null; + + return ( +
+
+

{label}

+ {aside} +
+
`${segment.label} ${format(segment.value)}`).join(", ")}`} + className="flex h-2 gap-0.5" + > + {visible.map((segment) => ( + + + } + /> + + {segment.label} · {format(segment.value)} · {formatPercent(segment.value / total)} + + + ))} +
+
+ {visible.map((segment) => ( + + + {segment.label} + {format(segment.value)} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index db84528ce3619662dc147c3e14c70112365216a2..ff0a611c7d451b3194b6c5745bad03b895746d7c 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -1,16 +1,30 @@ import type { ModelTotals } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { sortModelsByTokens } from "./usageBreakdown"; +import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown"; -const model = (name: string, totalTokens: number, costUsd: number): ModelTotals => ({ +const model = ( + name: string, + totalTokens: number, + costUsd: number, + overrides: Partial = {}, +): ModelTotals => ({ model: name, provider: "codex", costUsd, totalTokens, + tokens: { + uncachedInputTokens: totalTokens, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, records: 1, unpricedRecords: 0, + unpricedTokens: 0, costShare: 0, + ...overrides, }); describe("sortModelsByTokens", () => { @@ -29,3 +43,24 @@ describe("sortModelsByTokens", () => { expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]); }); }); + +describe("model rates", () => { + it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { + const mixed = model("mixed", 4_000_000, 6, { + tokens: { + uncachedInputTokens: 1_000_000, + cachedInputTokens: 1_000_000, + cacheCreationTokens: 2_000_000, + outputTokens: 0, + reasoningTokens: 0, + }, + records: 4, + unpricedRecords: 1, + unpricedTokens: 1_000_000, + }); + + expect(cacheHitRate(mixed)).toBe(0.25); + expect(costPerMillionTokens(mixed)).toBe(2); + expect(costPerMillionTokens({ ...mixed, unpricedRecords: 4 })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts index 8245b593e9a9fa96a8f47f533be0c2305bd86cfb..0249eb26aff28c5bcd6415a26d3111c3ac2084cd 100644 --- a/apps/web/src/components/usage/usageBreakdown.ts +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -1,7 +1,77 @@ -import type { ModelTotals } from "@t3tools/shared/usageMerge"; +import type { UsageTokenTotals } from "@t3tools/contracts"; +import { + isModelCostUnknown, + type CategoryCost, + type ModelTotals, + type SpeedCost, +} from "@t3tools/shared/usageMerge"; + +import type { ShareSegment } from "./UsageShareBar"; export function sortModelsByTokens(models: readonly ModelTotals[]) { return models.toSorted( (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, ); } + +/** + * Share of a model's input read from cache, or `null` without input. Cache + * writes count as misses: that input was processed in full. + */ +export function cacheHitRate({ tokens }: ModelTotals): number | null { + const input = tokens.uncachedInputTokens + tokens.cachedInputTokens + tokens.cacheCreationTokens; + return input === 0 ? null : tokens.cachedInputTokens / input; +} + +/** Effective USD per million priced tokens, or `null` when none were priced. */ +export function costPerMillionTokens(model: ModelTotals): number | null { + const pricedTokens = model.totalTokens - model.unpricedTokens; + return pricedTokens <= 0 || isModelCostUnknown(model) + ? null + : (model.costUsd / pricedTokens) * 1_000_000; +} + +/** A neutral step between background and foreground, so mixes never borrow a provider's color. */ +const ink = (percent: number) => + `color-mix(in oklab, var(--foreground) ${percent}%, var(--background))`; + +/** Adjacent segments stay above the 15 ΔE separation floor in both themes. */ +const TYPE_COLORS = { + input: ink(60), + cacheRead: ink(30), + cacheWrite: ink(72), + output: ink(100), + other: ink(44), +}; + +export function costTypeSegments(cost: CategoryCost): readonly ShareSegment[] { + return [ + { label: "Input", value: cost.input, color: TYPE_COLORS.input }, + { label: "Cache read", value: cost.cacheRead, color: TYPE_COLORS.cacheRead }, + { label: "Cache write", value: cost.cacheWrite, color: TYPE_COLORS.cacheWrite }, + { label: "Output", value: cost.output, color: TYPE_COLORS.output }, + // Reported cost with no rates to split it, or from older servers. Below a + // cent it is rounding, not usage. + { label: "Other", value: cost.unsplit >= 0.005 ? cost.unsplit : 0, color: TYPE_COLORS.other }, + ]; +} + +export function tokenTypeSegments( + tokens: Omit, +): readonly ShareSegment[] { + return [ + { label: "Input", value: tokens.uncachedInputTokens, color: TYPE_COLORS.input }, + { label: "Cache read", value: tokens.cachedInputTokens, color: TYPE_COLORS.cacheRead }, + { label: "Cache write", value: tokens.cacheCreationTokens, color: TYPE_COLORS.cacheWrite }, + { label: "Output", value: tokens.outputTokens, color: TYPE_COLORS.output }, + ]; +} + +/** Speeds are ordered by price, so they brighten from standard to ultrafast. */ +export function speedCostSegments(cost: SpeedCost): readonly ShareSegment[] { + return [ + { label: "Standard", value: cost.standard, color: ink(34) }, + { label: "Fast", value: cost.fast, color: ink(66) }, + { label: "Ultrafast", value: cost.ultrafast, color: ink(100) }, + ]; +} diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index f61bb90603845eca66c3607140b0dfb5cc387d16..7bd4657e7d56cc42697a1fef96e19d80bf4ca085 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -10,6 +10,7 @@ import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, type EnvironmentId, + type UsageBucket, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; @@ -79,6 +80,33 @@ export interface UsageView { readonly refresh: (input?: UsageSummaryInput) => Promise; } +/** + * Merges every environment that has answered. `keepBucket` narrows the merge, + * for example to one model; source ownership still applies, so the result + * matches that slice of the full merge. Session counts are per directory and + * are not narrowed. + */ +export function mergeAnsweredUsage( + environments: readonly EnvironmentUsageStatus[], + keepBucket?: (bucket: UsageBucket) => boolean, +): MergedUsage { + const answered: EnvironmentUsage[] = environments.flatMap(({ environmentId, label, summary }) => + summary === null + ? [] + : [ + { + environmentId, + label, + summary: + keepBucket === undefined + ? summary + : { ...summary, buckets: summary.buckets.filter(keepBucket) }, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); +} + export function useUsage( input: UsageSummaryInput, selectedEnvironmentIds: ReadonlySet | null = null, @@ -126,20 +154,7 @@ export function useUsage( [selectedEnvironments, windowKey], ); - const merged = useMemo(() => { - const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => - environment.summary === null - ? [] - : [ - { - environmentId: environment.environmentId, - label: environment.label, - summary: environment.summary, - }, - ], - ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [selectedEnvironments]); + const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]); const answeredCount = selectedEnvironments.filter( (environment) => environment.summary !== null, diff --git a/docs/user/usage.md b/docs/user/usage.md index 5c52122bf8a260142e3758bface1e902ee0321be..24db5f3f3b92b21a4a92f0911637461d5671feda 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -8,7 +8,10 @@ desktop when the terminal is not focused. Customize `usage.open` in **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent -cost. These estimates are not your subscription bill. +cost, split by token type and by speed. These estimates are not your subscription bill. +**Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be +split, such as a provider-reported cost for a model without public rates, shows as **Other**. +Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. @@ -47,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter; choose all environments or select individual destinations. Enter the exact model ID and USD rates per million input and output tokens. You can enter any model ID, including models -without public pricing. +without public pricing. When a model on **Usage** has no known price, select it under +**Breakdown** and choose **Set price** to open this table with that model added. Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for tokens that are free. Saved prices replace automatic pricing for all of that environment's diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 97db8594379a7b3b8402dc1c7c9c6e3b14c21e95..f6282b7b2935a2cd2d4f63bfd04b6d526d7126ca 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -18,7 +18,8 @@ import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from ". * client renders partial coverage when an environment reports an older version * rather than failing the whole page. * Adding providers or other array-element variants is additive: unknown - * entries are skipped on decode and do not require a version bump. + * entries are skipped on decode and do not require a version bump. So are + * optional bucket fields, which older clients ignore. */ export const USAGE_CONTRACT_VERSION = 6 as const; @@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({ }); export type UsageTokenTotals = typeof UsageTokenTotals.Type; +/** + * A bucket's cost split by token category, in USD. A provider-reported cost is + * split in proportion to the model's list rates. + */ +export const UsageCategoryCost = Schema.Struct({ + input: Schema.Number, + cacheRead: Schema.Number, + cacheWrite: Schema.Number, + output: Schema.Number, +}); +export type UsageCategoryCost = typeof UsageCategoryCost.Type; + /** * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start * instant of a rolling bucket and is present only for hourly requests. @@ -108,6 +121,16 @@ export const UsageBucket = Schema.Struct({ * rather than derived on the client. */ cacheSavingsUsd: Schema.Number, + /** + * `costUsd` by token category. Cost with no known rates stays out of it, and + * it is absent when nothing could be split or the server predates it. + */ + categoryCostUsd: Schema.optional(UsageCategoryCost), + /** Cost of fast and ultrafast requests. Absent when zero; the rest is standard. */ + fastCostUsd: Schema.optional(Schema.Number), + ultrafastCostUsd: Schema.optional(Schema.Number), + /** What fast and ultrafast requests cost above the standard rate. Absent when zero. */ + speedPremiumUsd: Schema.optional(Schema.Number), costSource: UsageCostSource, /** Distinct assistant responses, after de-duplication. */ records: NonNegativeInt, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 81d1e8565a6c7cc4bc843d975fcccb343880a457..eb50f2fb19b35fc2aa4167e4399376a0ed093e45 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -546,6 +546,67 @@ describe("mergeUsage", () => { ]); }); + it("splits cost by category and speed, counting older servers as unsplit standard cost", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ + costUsd: 10, + categoryCostUsd: { input: 1, cacheRead: 2, cacheWrite: 3, output: 4 }, + fastCostUsd: 6, + speedPremiumUsd: 3, + }), + bucket({ + provider: "codex", + model: "unknown-model", + costUsd: 0, + costSource: "unpriced", + unpricedRecords: 5, + }), + // Reported cost on one record, no rates for the other four. + bucket({ + provider: "codex", + model: "partly-reported", + costUsd: 0, + unpricedRecords: 4, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 5 })], + [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }], + USAGE_MERGE_COMPATIBLE_SINCE, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.categoryCost).toEqual({ + input: 1, + cacheRead: 2, + cacheWrite: 3, + output: 4, + unsplit: 5, + }); + expect(merged.speedCost).toEqual({ standard: 9, fast: 6, ultrafast: 0, premium: 3 }); + expect(merged.models.map(({ model, unpricedTokens }) => [model, unpricedTokens])).toEqual([ + ["claude-fable-5", 0], + ["unknown-model", 1160], + ["partly-reported", 928], + ]); + }); + it("orders models by cost descending", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 90c9a1ff426147adda1d890155c994b3240a4b3a..18c2c9f11c49ab4d88e35eed838923747f750fd6 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -14,6 +14,7 @@ import { type UsageSource, type UsageSourceFingerprint, type UsageSummary, + type UsageTokenTotals, } from "@t3tools/contracts"; export interface EnvironmentUsage { @@ -37,12 +38,18 @@ export interface ModelTotals { readonly provider: UsageProviderKind; readonly costUsd: number; readonly totalTokens: number; + readonly tokens: UsageTokenTotals; readonly records: number; /** * Records whose tokens are counted here but which contributed nothing to * `costUsd`. When it equals `records` the cost is unknown, not zero. */ readonly unpricedRecords: number; + /** + * Tokens with no known rates, which a custom price would cover. A cell that + * mixes these with reported costs counts its tokens by record share. + */ + readonly unpricedTokens: number; readonly costShare: number; } @@ -76,6 +83,27 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } +/** + * `costUsd` by token category. `unsplit` is cost no rates could split, + * including all cost from servers that predate the split. + */ +export interface CategoryCost { + readonly input: number; + readonly cacheRead: number; + readonly cacheWrite: number; + readonly output: number; + readonly unsplit: number; +} + +/** `costUsd` by request speed. Servers that predate speeds count as standard. */ +export interface SpeedCost { + readonly standard: number; + readonly fast: number; + readonly ultrafast: number; + /** What fast and ultrafast requests cost above the standard rate. */ + readonly premium: number; +} + export interface UsageContractMismatch { readonly environmentId: EnvironmentId; readonly direction: "serverBehind" | "clientBehind"; @@ -97,6 +125,8 @@ export interface MergedUsage { readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; + readonly categoryCost: CategoryCost; + readonly speedCost: SpeedCost; /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; @@ -307,6 +337,8 @@ const EMPTY_MERGED: MergedUsage = { unpricedShare: 0, cacheSavingsUsd: 0, }, + categoryCost: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, unsplit: 0 }, + speedCost: { standard: 0, fast: 0, ultrafast: 0, premium: 0 }, duplicateSources: [], contributingEnvironments: [], contractMismatches: [], @@ -364,6 +396,8 @@ export function mergeUsage( let cacheSavingsUsd = 0; let providerReportedRecords = 0; let unpricedRecords = 0; + const categoryCost = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }; + const speedCost = { fast: 0, ultrafast: 0, premium: 0 }; const providerAccumulator = new Map< UsageProviderKind, @@ -375,8 +409,10 @@ export function mergeUsage( provider: UsageProviderKind; costUsd: number; totalTokens: number; + tokens: UsageTokenTotals; records: number; unpricedRecords: number; + unpricedTokens: number; } >(); const dailyAccumulator = new Map< @@ -434,6 +470,15 @@ export function mergeUsage( records += bucket.records; unpricedRecords += bucket.unpricedRecords; if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; + if (bucket.categoryCostUsd !== undefined) { + categoryCost.input += bucket.categoryCostUsd.input; + categoryCost.cacheRead += bucket.categoryCostUsd.cacheRead; + categoryCost.cacheWrite += bucket.categoryCostUsd.cacheWrite; + categoryCost.output += bucket.categoryCostUsd.output; + } + speedCost.fast += bucket.fastCostUsd ?? 0; + speedCost.ultrafast += bucket.ultrafastCostUsd ?? 0; + speedCost.premium += bucket.speedPremiumUsd ?? 0; const provider = providerAccumulator.get(bucket.provider) ?? { costUsd: 0, @@ -451,13 +496,31 @@ export function mergeUsage( provider: bucket.provider, costUsd: 0, totalTokens: 0, + tokens: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, records: 0, unpricedRecords: 0, + unpricedTokens: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; + model.tokens = { + uncachedInputTokens: model.tokens.uncachedInputTokens + bucket.totals.uncachedInputTokens, + cachedInputTokens: model.tokens.cachedInputTokens + bucket.totals.cachedInputTokens, + cacheCreationTokens: model.tokens.cacheCreationTokens + bucket.totals.cacheCreationTokens, + outputTokens: model.tokens.outputTokens + bucket.totals.outputTokens, + reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens, + }; model.records += bucket.records; model.unpricedRecords += bucket.unpricedRecords; + if (bucket.records > 0) { + model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records; + } modelAccumulator.set(modelKey, model); const day = dailyAccumulator.get(bucket.day) ?? { @@ -515,8 +578,10 @@ export function mergeUsage( provider: totals.provider, costUsd: totals.costUsd, totalTokens: totals.totalTokens, + tokens: totals.tokens, records: totals.records, unpricedRecords: totals.unpricedRecords, + unpricedTokens: totals.unpricedTokens, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); @@ -555,6 +620,22 @@ export function mergeUsage( records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, }, + // Clamped so float error never shows as a negative remainder. + categoryCost: { + ...categoryCost, + unsplit: Math.max( + 0, + costUsd - + categoryCost.input - + categoryCost.cacheRead - + categoryCost.cacheWrite - + categoryCost.output, + ), + }, + speedCost: { + ...speedCost, + standard: Math.max(0, costUsd - speedCost.fast - speedCost.ultrafast), + }, duplicateSources: duplicates, contributingEnvironments, contractMismatches, -- 2.54.0