authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 19:11:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 23:11:20-03:00
log0c8112013760617230d382e55447ddb75627a997
tree60eccd6ca3e081b7f738a1ddb189d3ee8837f4f0
parent9efb01690065923820dfa889af5745cc34750091
signature Signed by PGP key B5690EEEBB952194

fix(usage): model shares and order follow the selected metric (#11391)


6 files changed, 106 insertions(+), 10 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+24-8
...@@ -361,7 +361,7 @@ export function UsageRouteScreen() {...@@ -361,7 +361,7 @@ export function UsageRouteScreen() {
361 />361 />
362 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />362 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
363 <CostSection merged={merged} />363 <CostSection merged={merged} />
364 <ModelsSection merged={merged} />364 <ModelsSection merged={merged} metric={metric} />
365 </>365 </>
366 )}366 )}
367 </>367 </>
...@@ -781,14 +781,22 @@ function MetricCell(props: {...@@ -781,14 +781,22 @@ function MetricCell(props: {
781 );781 );
782}782}
783783
784function ModelsSection(props: { readonly merged: MergedUsage }) {784function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) {
785 const { merged } = props;785 const { merged, metric } = props;
786 const colors = useProviderColors();786 const colors = useProviderColors();
787 if (merged.models.length === 0) return null;787 if (merged.models.length === 0) return null;
788788
789 // Ranked like the provider rows. .sort() on a copy, not .toSorted(): Hermes
790 // doesn't ship the ES2023 method.
791 const ordered = [...merged.models].sort((a, b) =>
792 metric === "cost"
793 ? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens
794 : b.totalTokens - a.totalTokens || b.costUsd - a.costUsd,
795 );
796
789 return (797 return (
790 <SettingsSection title="By model">798 <SettingsSection title="By model">
791 {merged.models.map((model, index) => (799 {ordered.map((model, index) => (
792 <View800 <View
793 key={`${model.provider}:${model.model}`}801 key={`${model.provider}:${model.model}`}
794 className={802 className={
...@@ -806,13 +814,21 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {...@@ -806,13 +814,21 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
806 {model.model}814 {model.model}
807 </Text>815 </Text>
808 <Text className="text-sm text-foreground-muted">816 <Text className="text-sm text-foreground-muted">
809 {isModelCostUnknown(model)817 {metric === "tokens"
810 ? `no known rates · ${formatTokens(model.totalTokens)} tokens`818 ? `${formatPercent(model.tokenShare)} of tokens · ${
811 : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}819 isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd)
820 }`
821 : isModelCostUnknown(model)
822 ? `no known rates · ${formatTokens(model.totalTokens)} tokens`
823 : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
812 </Text>824 </Text>
813 </View>825 </View>
814 <Text className="text-base tabular-nums text-foreground">826 <Text className="text-base tabular-nums text-foreground">
815 {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)}827 {metric === "tokens"
828 ? formatTokens(model.totalTokens)
829 : isModelCostUnknown(model)
830 ? "Unpriced"
831 : formatUsd(model.costUsd)}
816 </Text>832 </Text>
817 </View>833 </View>
818 ))}834 ))}
apps/web/src/components/usage/UsagePage.tsx+6-1
...@@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";...@@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";
82import { UsageShareBar } from "./UsageShareBar";82import { UsageShareBar } from "./UsageShareBar";
83import {83import {
84 costTypeSegments,84 costTypeSegments,
85 modelShare,
85 sortModelsByTokens,86 sortModelsByTokens,
86 speedCostSegments,87 speedCostSegments,
87 tokenTypeSegments,88 tokenTypeSegments,
...@@ -718,6 +719,10 @@ export function UsagePage() {...@@ -718,6 +719,10 @@ export function UsagePage() {
718 breakdownModels.map((model, index) => {719 breakdownModels.map((model, index) => {
719 const key = `${model.provider}:${model.model}`;720 const key = `${model.provider}:${model.model}`;
720 const value = metric === "tokens" ? model.totalTokens : model.costUsd;721 const value = metric === "tokens" ? model.totalTokens : model.costUsd;
722 const share = modelShare(
723 model,
724 metric === "tokens" ? "tokens" : "cost",
725 );
721 return (726 return (
722 <tr727 <tr
723 key={key}728 key={key}
...@@ -758,7 +763,7 @@ export function UsagePage() {...@@ -758,7 +763,7 @@ export function UsagePage() {
758 )}763 )}
759 </td>764 </td>
760 <td className="hidden py-2.5 pl-6 sm:table-cell">765 <td className="hidden py-2.5 pl-6 sm:table-cell">
761 {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)}766 {share === null ? "" : formatPercent(share)}
762 </td>767 </td>
763 <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td>768 <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td>
764 </tr>769 </tr>
apps/web/src/components/usage/usageBreakdown.test.ts+27-1
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import type { ModelTotals } from "@t3tools/shared/usageMerge";1import type { ModelTotals } from "@t3tools/shared/usageMerge";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown";4import {
5 cacheHitRate,
6 costPerMillionTokens,
7 modelShare,
8 sortModelsByTokens,
9} from "./usageBreakdown";
510
6const model = (11const model = (
7 name: string,12 name: string,
...@@ -24,6 +29,7 @@ const model = (...@@ -24,6 +29,7 @@ const model = (
24 unpricedRecords: 0,29 unpricedRecords: 0,
25 unpricedTokens: 0,30 unpricedTokens: 0,
26 costShare: 0,31 costShare: 0,
32 tokenShare: 0,
27 ...overrides,33 ...overrides,
28});34});
2935
...@@ -44,6 +50,26 @@ describe("sortModelsByTokens", () => {...@@ -44,6 +50,26 @@ describe("sortModelsByTokens", () => {
44 });50 });
45});51});
4652
53describe("modelShare", () => {
54 it("follows the selected metric", () => {
55 const priced = model("priced", 100, 9, { costShare: 0.9, tokenShare: 0.25 });
56
57 expect(modelShare(priced, "cost")).toBe(0.9);
58 expect(modelShare(priced, "tokens")).toBe(0.25);
59 });
60
61 it("has no cost share for an unknown cost but keeps its token share", () => {
62 const unpriced = model("unpriced", 300, 0, {
63 unpricedRecords: 1,
64 unpricedTokens: 300,
65 tokenShare: 0.75,
66 });
67
68 expect(modelShare(unpriced, "cost")).toBeNull();
69 expect(modelShare(unpriced, "tokens")).toBe(0.75);
70 });
71});
72
47describe("model rates", () => {73describe("model rates", () => {
48 it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => {74 it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => {
49 const mixed = model("mixed", 4_000_000, 6, {75 const mixed = model("mixed", 4_000_000, 6, {
apps/web/src/components/usage/usageBreakdown.ts+9
...@@ -14,6 +14,15 @@ export function sortModelsByTokens(models: readonly ModelTotals[]) {...@@ -14,6 +14,15 @@ export function sortModelsByTokens(models: readonly ModelTotals[]) {
14 );14 );
15}15}
1616
17/**
18 * A model's share of the selected metric, or `null` for a cost share of an
19 * unknown cost. An unpriced model still has a real token share.
20 */
21export function modelShare(model: ModelTotals, metric: "cost" | "tokens"): number | null {
22 if (metric === "tokens") return model.tokenShare;
23 return isModelCostUnknown(model) ? null : model.costShare;
24}
25
17/**26/**
18 * Share of a model's input read from cache, or `null` without input. Cache27 * 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.28 * writes count as misses: that input was processed in full.
packages/shared/src/usageMerge.test.ts+38
...@@ -514,6 +514,44 @@ describe("mergeUsage", () => {...@@ -514,6 +514,44 @@ describe("mergeUsage", () => {
514 expect(merged.costQuality.cacheSavingsUsd).toBe(4);514 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
515 });515 });
516516
517 it("derives model token shares independently of their cost shares", () => {
518 const merged = mergeUsage(
519 [
520 environment(
521 "env-a",
522 summary(
523 [
524 bucket({ costUsd: 90 }),
525 bucket({
526 provider: "codex",
527 model: "gpt-5.6-sol",
528 costUsd: 10,
529 totals: {
530 uncachedInputTokens: 3 * 1160,
531 cachedInputTokens: 0,
532 cacheCreationTokens: 0,
533 outputTokens: 0,
534 reasoningTokens: 0,
535 },
536 }),
537 ],
538 [
539 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
540 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
541 ],
542 ),
543 ),
544 ],
545 USAGE_CONTRACT_VERSION,
546 );
547
548 const byModel = Object.fromEntries(merged.models.map((model) => [model.model, model]));
549 expect(byModel["claude-fable-5"]?.costShare).toBeCloseTo(0.9, 5);
550 expect(byModel["claude-fable-5"]?.tokenShare).toBeCloseTo(0.25, 5);
551 expect(byModel["gpt-5.6-sol"]?.costShare).toBeCloseTo(0.1, 5);
552 expect(byModel["gpt-5.6-sol"]?.tokenShare).toBeCloseTo(0.75, 5);
553 });
554
517 it("marks a model with no known rates as unpriced rather than free", () => {555 it("marks a model with no known rates as unpriced rather than free", () => {
518 const merged = mergeUsage(556 const merged = mergeUsage(
519 [557 [
packages/shared/src/usageMerge.ts+2
...@@ -51,6 +51,7 @@ export interface ModelTotals {...@@ -51,6 +51,7 @@ export interface ModelTotals {
51 */51 */
52 readonly unpricedTokens: number;52 readonly unpricedTokens: number;
53 readonly costShare: number;53 readonly costShare: number;
54 readonly tokenShare: number;
54}55}
5556
56/**57/**
...@@ -583,6 +584,7 @@ export function mergeUsage(...@@ -583,6 +584,7 @@ export function mergeUsage(
583 unpricedRecords: totals.unpricedRecords,584 unpricedRecords: totals.unpricedRecords,
584 unpricedTokens: totals.unpricedTokens,585 unpricedTokens: totals.unpricedTokens,
585 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,586 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
587 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
586 }))588 }))
587 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);589 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
588590