From e794fe6b3220e217922eb427ee5a7fd2ed05a164 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 12 Sep 2026 06:37:07 +0000 Subject: [PATCH] fix(usage): make model shares follow the selected metric --- .../src/features/usage/UsageRouteScreen.tsx | 30 +++++++++++---- .../src/components/usage/UsagePage.test.tsx | 31 +++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 24 ++++++++++-- packages/shared/src/usageMerge.test.ts | 38 +++++++++++++++++++ packages/shared/src/usageMerge.ts | 2 + 5 files changed, 113 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index b3232acc6810..1d22fd8a5c8a 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -310,7 +310,7 @@ export function UsageRouteScreen() { /> - + )} @@ -585,14 +585,20 @@ 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 by whatever the toggle is showing, matching 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, + ); + 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.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 0743b91f6edf..f273bbf8214e 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -89,6 +89,7 @@ const modelTotals = Object.freeze([ records: 1, unpricedRecords: 0, costShare: 10 / 16, + tokenShare: 100 / 2_600, }, { model: "token-heavy-model", @@ -98,6 +99,7 @@ const modelTotals = Object.freeze([ records: 1, unpricedRecords: 0, costShare: 5 / 16, + tokenShare: 1_000 / 2_600, }, { model: "token-heavy-cheaper-model", @@ -107,6 +109,7 @@ const modelTotals = Object.freeze([ records: 1, unpricedRecords: 0, costShare: 1 / 16, + tokenShare: 1_000 / 2_600, }, { model: "unpriced-model", @@ -116,6 +119,7 @@ const modelTotals = Object.freeze([ records: 2, unpricedRecords: 2, costShare: 0, + tokenShare: 500 / 2_600, }, ]); @@ -213,6 +217,33 @@ describe("UsagePage model breakdown", () => { expect(unpricedRow).not.toContain("$0.00"); }); + it("labels cost shares and leaves unpriced models without a cost share", () => { + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + const rows = body.split(" row.includes("expensive-model"))).toContain("62.5%"); + expect(rows.find((row) => row.includes("unpriced-model"))).toContain("—"); + }); + + it("labels token shares and includes unpriced models in token shares", () => { + testState.metric = "tokens"; + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + const rows = body.split(" row.includes("token-heavy-model"))).toContain("38.5%"); + // Unpriced models still have a real token share to report. + expect(rows.find((row) => row.includes("unpriced-model"))).toContain("19.2%"); + expect(body).not.toContain("—"); + }); + it("sorts models by token usage when the token metric is selected", () => { testState.metric = "tokens"; testState.breakdown = "model"; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2da7414d9337..43f57235df41 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -495,7 +495,9 @@ export function UsagePage() { Model Cost - Share + + {metric === "tokens" ? "Token share" : "Cost share"} + Tokens @@ -518,7 +520,12 @@ export function UsagePage() { {model.model} - + {isModelCostUnknown(model) ? ( Unpriced ) : ( @@ -526,9 +533,18 @@ export function UsagePage() { )} - {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} + {metric === "tokens" + ? formatPercent(model.tokenShare) + : isModelCostUnknown(model) + ? "—" + : formatPercent(model.costShare)} - + {formatTokens(model.totalTokens)} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 24bbc3b7c2e8..daa782329611 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -221,6 +221,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 e0cb0510eb71..fecb7b47d1a0 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -43,6 +43,7 @@ export interface ModelTotals { */ readonly unpricedRecords: number; readonly costShare: number; + readonly tokenShare: number; } /** @@ -404,6 +405,7 @@ export function mergeUsage( records: totals.records, unpricedRecords: totals.unpricedRecords, 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);