authorgravatar for 88523594+RakshithBhat03@users.noreply.github.comRakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> 2026-08-25 03:21:32+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 17:51:32-04:00
loga1379db81847cc70b2c9535fd8370b69f9ebf291
treebcaca3444e9d7a8ea7e8bf9438dc9e0268ce8bfe
parenta09f92171186677796ccdc28148406c7491601ce
signature Signed by PGP key B5690EEEBB952194

fix(web): sort usage models by token count (#8108)


2 files changed, 83 insertions(+), 5 deletions(-)

apps/web/src/components/usage/UsagePage.test.tsx+72-3
...@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";...@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
55
6const testState = vi.hoisted(() => ({6const testState = vi.hoisted(() => ({
7 useUsage: vi.fn(),7 useUsage: vi.fn(),
8 metric: "cost" as "cost" | "tokens",
9 breakdown: "time" as "model" | "time",
8}));10}));
911
10vi.mock("react", async (importOriginal) => {12vi.mock("react", async (importOriginal) => {
...@@ -24,9 +26,11 @@ vi.mock("react", async (importOriginal) => {...@@ -24,9 +26,11 @@ vi.mock("react", async (importOriginal) => {
24 untilTime: "2026-08-11T12:37:00.000Z",26 untilTime: "2026-08-11T12:37:00.000Z",
25 },27 },
26 }28 }
27 : initial === "model"29 : initial === "cost"
28 ? "time"30 ? testState.metric
29 : initial,31 : initial === "model"
32 ? testState.breakdown
33 : initial,
30 vi.fn(),34 vi.fn(),
31 ]),35 ]),
32 };36 };
...@@ -72,10 +76,40 @@ const providerTotals = (codex: number, claude: number) =>...@@ -72,10 +76,40 @@ const providerTotals = (codex: number, claude: number) =>
72 ["claude", { costUsd: claude, totalTokens: claude * 1_000 }],76 ["claude", { costUsd: claude, totalTokens: claude * 1_000 }],
73 ] as const);77 ] as const);
7478
79const modelTotals = Object.freeze([
80 {
81 model: "expensive-model",
82 provider: "claude" as const,
83 costUsd: 10,
84 totalTokens: 100,
85 records: 1,
86 costShare: 10 / 16,
87 },
88 {
89 model: "token-heavy-model",
90 provider: "codex" as const,
91 costUsd: 5,
92 totalTokens: 1_000,
93 records: 1,
94 costShare: 5 / 16,
95 },
96 {
97 model: "token-heavy-cheaper-model",
98 provider: "codex" as const,
99 costUsd: 1,
100 totalTokens: 1_000,
101 records: 1,
102 costShare: 1 / 16,
103 },
104]);
105
75beforeEach(() => {106beforeEach(() => {
107 testState.metric = "cost";
108 testState.breakdown = "time";
76 testState.useUsage.mockReturnValue({109 testState.useUsage.mockReturnValue({
77 merged: {110 merged: {
78 ...mergeUsage([], USAGE_CONTRACT_VERSION),111 ...mergeUsage([], USAGE_CONTRACT_VERSION),
112 models: modelTotals,
79 hourly: [113 hourly: [
80 {114 {
81 day: "2026-08-10",115 day: "2026-08-10",
...@@ -110,4 +144,39 @@ describe("UsagePage hourly breakdown", () => {...@@ -110,4 +144,39 @@ describe("UsagePage hourly breakdown", () => {
110 expect(body).toContain("$13.00");144 expect(body).toContain("$13.00");
111 expect(body.indexOf("$11.00")).toBeLessThan(body.indexOf("$13.00"));145 expect(body.indexOf("$11.00")).toBeLessThan(body.indexOf("$13.00"));
112 });146 });
147
148 it("keeps chronological ordering when the token metric is selected", () => {
149 testState.metric = "tokens";
150
151 const markup = renderToStaticMarkup(<UsagePage />);
152 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
153
154 expect(body).toMatch(/\$11\.00.*\$13\.00/);
155 });
156});
157
158describe("UsagePage model breakdown", () => {
159 it("sorts models by cost when the cost metric is selected", () => {
160 testState.breakdown = "model";
161
162 const markup = renderToStaticMarkup(<UsagePage />);
163 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
164
165 expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/);
166 });
167
168 it("sorts models by token usage when the token metric is selected", () => {
169 testState.metric = "tokens";
170 testState.breakdown = "model";
171
172 const markup = renderToStaticMarkup(<UsagePage />);
173 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
174
175 expect(body).toMatch(/token-heavy-model.*token-heavy-cheaper-model.*expensive-model/);
176 expect(modelTotals.map((model) => model.model)).toEqual([
177 "expensive-model",
178 "token-heavy-model",
179 "token-heavy-cheaper-model",
180 ]);
181 });
113});182});
apps/web/src/components/usage/UsagePage.tsx+11-2
...@@ -74,6 +74,15 @@ export function UsagePage() {...@@ -74,6 +74,15 @@ export function UsagePage() {
74 () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(),74 () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(),
75 [isPast24Hours, merged.daily, merged.hourly],75 [isPast24Hours, merged.daily, merged.hourly],
76 );76 );
77 const breakdownModels = useMemo(
78 () =>
79 breakdown === "model" && metric === "tokens"
80 ? merged.models.toSorted(
81 (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd,
82 )
83 : merged.models,
84 [breakdown, merged.models, metric],
85 );
77 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);86 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
78 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;87 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
7988
...@@ -350,14 +359,14 @@ export function UsagePage() {...@@ -350,14 +359,14 @@ export function UsagePage() {
350 </tr>359 </tr>
351 </thead>360 </thead>
352 <tbody>361 <tbody>
353 {merged.models.length === 0 ? (362 {breakdownModels.length === 0 ? (
354 <tr>363 <tr>
355 <td colSpan={4} className="py-6 text-center text-muted-foreground">364 <td colSpan={4} className="py-6 text-center text-muted-foreground">
356 No activity in this window.365 No activity in this window.
357 </td>366 </td>
358 </tr>367 </tr>
359 ) : (368 ) : (
360 merged.models.map((model) => (369 breakdownModels.map((model) => (
361 <tr370 <tr
362 key={`${model.provider}:${model.model}`}371 key={`${model.provider}:${model.model}`}
363 className="border-b border-border/50 transition-colors hover:bg-muted/50"372 className="border-b border-border/50 transition-colors hover:bg-muted/50"