From 5ea5b5b3a59646a3b6001bd1c15a20c4595fc1ca Mon Sep 17 00:00:00 2001 From: flamboh Date: Sun, 4 Oct 2026 01:32:34 +0000 Subject: [PATCH] fix(usage): model shares and order follow the selected metric --- .../src/features/usage/UsageRouteScreen.tsx | 32 ++++++++++++---- apps/web/src/components/usage/UsagePage.tsx | 7 +++- .../components/usage/usageBreakdown.test.ts | 28 +++++++++++++- .../src/components/usage/usageBreakdown.ts | 9 +++++ packages/shared/src/usageMerge.test.ts | 38 +++++++++++++++++++ packages/shared/src/usageMerge.ts | 2 + 6 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 3022b9fe2208..5a8a4b1a15d4 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -361,7 +361,7 @@ export function UsageRouteScreen() { /> - + )} @@ -781,14 +781,22 @@ function MetricCell(props: { ); } -function ModelsSection(props: { readonly merged: MergedUsage }) { - const { merged } = props; +function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { + const { merged, metric } = props; const colors = useProviderColors(); if (merged.models.length === 0) return null; + // Ranked like the provider rows. .sort() on a copy, not .toSorted(): Hermes + // doesn't ship the ES2023 method. + const ordered = [...merged.models].sort((a, b) => + metric === "cost" + ? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens + : b.totalTokens - a.totalTokens || b.costUsd - a.costUsd, + ); + return ( - {merged.models.map((model, index) => ( + {ordered.map((model, index) => ( - {isModelCostUnknown(model) - ? `no known rates · ${formatTokens(model.totalTokens)} tokens` - : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} + {metric === "tokens" + ? `${formatPercent(model.tokenShare)} of tokens · ${ + isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) + }` + : isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} + {metric === "tokens" + ? formatTokens(model.totalTokens) + : isModelCostUnknown(model) + ? "Unpriced" + : formatUsd(model.costUsd)} ))} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 89bea86b7e0f..969ca5f4e907 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; import { UsageShareBar } from "./UsageShareBar"; import { costTypeSegments, + modelShare, sortModelsByTokens, speedCostSegments, tokenTypeSegments, @@ -718,6 +719,10 @@ export function UsagePage() { breakdownModels.map((model, index) => { const key = `${model.provider}:${model.model}`; const value = metric === "tokens" ? model.totalTokens : model.costUsd; + const share = modelShare( + model, + metric === "tokens" ? "tokens" : "cost", + ); return ( - {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)} + {share === null ? "" : formatPercent(share)} {formatTokens(model.totalTokens)} diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index ff0a611c7d45..a835ad4f953a 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -1,7 +1,12 @@ import type { ModelTotals } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown"; +import { + cacheHitRate, + costPerMillionTokens, + modelShare, + sortModelsByTokens, +} from "./usageBreakdown"; const model = ( name: string, @@ -24,6 +29,7 @@ const model = ( unpricedRecords: 0, unpricedTokens: 0, costShare: 0, + tokenShare: 0, ...overrides, }); @@ -44,6 +50,26 @@ describe("sortModelsByTokens", () => { }); }); +describe("modelShare", () => { + it("follows the selected metric", () => { + const priced = model("priced", 100, 9, { costShare: 0.9, tokenShare: 0.25 }); + + expect(modelShare(priced, "cost")).toBe(0.9); + expect(modelShare(priced, "tokens")).toBe(0.25); + }); + + it("has no cost share for an unknown cost but keeps its token share", () => { + const unpriced = model("unpriced", 300, 0, { + unpricedRecords: 1, + unpricedTokens: 300, + tokenShare: 0.75, + }); + + expect(modelShare(unpriced, "cost")).toBeNull(); + expect(modelShare(unpriced, "tokens")).toBe(0.75); + }); +}); + describe("model rates", () => { it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { const mixed = model("mixed", 4_000_000, 6, { diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts index 0249eb26aff2..ab449af9fbbb 100644 --- a/apps/web/src/components/usage/usageBreakdown.ts +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -14,6 +14,15 @@ export function sortModelsByTokens(models: readonly ModelTotals[]) { ); } +/** + * A model's share of the selected metric, or `null` for a cost share of an + * unknown cost. An unpriced model still has a real token share. + */ +export function modelShare(model: ModelTotals, metric: "cost" | "tokens"): number | null { + if (metric === "tokens") return model.tokenShare; + return isModelCostUnknown(model) ? null : model.costShare; +} + /** * Share of a model's input read from cache, or `null` without input. Cache * writes count as misses: that input was processed in full. diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index eb50f2fb19b3..8208e5b7ed1c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -514,6 +514,44 @@ describe("mergeUsage", () => { expect(merged.costQuality.cacheSavingsUsd).toBe(4); }); + it("derives model token shares independently of their cost shares", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 90 }), + bucket({ + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 10, + totals: { + uncachedInputTokens: 3 * 1160, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + const byModel = Object.fromEntries(merged.models.map((model) => [model.model, model])); + expect(byModel["claude-fable-5"]?.costShare).toBeCloseTo(0.9, 5); + expect(byModel["claude-fable-5"]?.tokenShare).toBeCloseTo(0.25, 5); + expect(byModel["gpt-5.6-sol"]?.costShare).toBeCloseTo(0.1, 5); + expect(byModel["gpt-5.6-sol"]?.tokenShare).toBeCloseTo(0.75, 5); + }); + it("marks a model with no known rates as unpriced rather than free", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 18c2c9f11c49..07718c099bef 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -51,6 +51,7 @@ export interface ModelTotals { */ readonly unpricedTokens: number; readonly costShare: number; + readonly tokenShare: number; } /** @@ -583,6 +584,7 @@ export function mergeUsage( unpricedRecords: totals.unpricedRecords, unpricedTokens: totals.unpricedTokens, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);