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() {
361361 />
362362 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
363363 <CostSection merged={merged} />
364 <ModelsSection merged={merged} />
364 <ModelsSection merged={merged} metric={metric} />
365365 </>
366366 )}
367367 </>
......@@ -781,14 +781,22 @@ function MetricCell(props: {
781781 );
782782}
783783
784function ModelsSection(props: { readonly merged: MergedUsage }) {
785 const { merged } = props;
784function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) {
785 const { merged, metric } = props;
786786 const colors = useProviderColors();
787787 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
789797 return (
790798 <SettingsSection title="By model">
791 {merged.models.map((model, index) => (
799 {ordered.map((model, index) => (
792800 <View
793801 key={`${model.provider}:${model.model}`}
794802 className={
......@@ -806,13 +814,21 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
806814 {model.model}
807815 </Text>
808816 <Text className="text-sm text-foreground-muted">
809 {isModelCostUnknown(model)
810 ? `no known rates · ${formatTokens(model.totalTokens)} tokens`
811 : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
817 {metric === "tokens"
818 ? `${formatPercent(model.tokenShare)} of 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`}
812824 </Text>
813825 </View>
814826 <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)}
816832 </Text>
817833 </View>
818834 ))}
apps/web/src/components/usage/UsagePage.tsx+6-1
......@@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";
8282import { UsageShareBar } from "./UsageShareBar";
8383import {
8484 costTypeSegments,
85 modelShare,
8586 sortModelsByTokens,
8687 speedCostSegments,
8788 tokenTypeSegments,
......@@ -718,6 +719,10 @@ export function UsagePage() {
718719 breakdownModels.map((model, index) => {
719720 const key = `${model.provider}:${model.model}`;
720721 const value = metric === "tokens" ? model.totalTokens : model.costUsd;
722 const share = modelShare(
723 model,
724 metric === "tokens" ? "tokens" : "cost",
725 );
721726 return (
722727 <tr
723728 key={key}
......@@ -758,7 +763,7 @@ export function UsagePage() {
758763 )}
759764 </td>
760765 <td className="hidden py-2.5 pl-6 sm:table-cell">
761 {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)}
766 {share === null ? "" : formatPercent(share)}
762767 </td>
763768 <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td>
764769 </tr>
apps/web/src/components/usage/usageBreakdown.test.ts+27-1
......@@ -1,7 +1,12 @@
11import type { ModelTotals } from "@t3tools/shared/usageMerge";
22import { 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
611const model = (
712 name: string,
......@@ -24,6 +29,7 @@ const model = (
2429 unpricedRecords: 0,
2530 unpricedTokens: 0,
2631 costShare: 0,
32 tokenShare: 0,
2733 ...overrides,
2834});
2935
......@@ -44,6 +50,26 @@ describe("sortModelsByTokens", () => {
4450 });
4551});
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
4773describe("model rates", () => {
4874 it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => {
4975 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[]) {
1414 );
1515}
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
1726/**
1827 * Share of a model's input read from cache, or `null` without input. Cache
1928 * writes count as misses: that input was processed in full.
packages/shared/src/usageMerge.test.ts+38
......@@ -514,6 +514,44 @@ describe("mergeUsage", () => {
514514 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
515515 });
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
517555 it("marks a model with no known rates as unpriced rather than free", () => {
518556 const merged = mergeUsage(
519557 [
packages/shared/src/usageMerge.ts+2
......@@ -51,6 +51,7 @@ export interface ModelTotals {
5151 */
5252 readonly unpricedTokens: number;
5353 readonly costShare: number;
54 readonly tokenShare: number;
5455}
5556
5657/**
......@@ -583,6 +584,7 @@ export function mergeUsage(
583584 unpricedRecords: totals.unpricedRecords,
584585 unpricedTokens: totals.unpricedTokens,
585586 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
587 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
586588 }))
587589 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
588590