authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-10 23:39:21-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:39:21-03:00
log48654c1182c41dbd5e99ef0d6620a13ba2400f26
treea3d381eb33560d96ece0c3a8240553ba854a78c4
parent27eb79dc719d70a99db495b0cb20bd6e3ac10b8d
signature Signed by PGP key B5690EEEBB952194

fix(usage): flag unpriced model activity instead of showing $0.00 (#11021)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

5 files changed, 101 insertions(+), 9 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+7-2
...@@ -2,6 +2,7 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";...@@ -2,6 +2,7 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import {3import {
4 isCompatibleUsageContractVersion,4 isCompatibleUsageContractVersion,
5 isModelCostUnknown,
5 type DailyTotals,6 type DailyTotals,
6 type MergedUsage,7 type MergedUsage,
7} from "@t3tools/shared/usageMerge";8} from "@t3tools/shared/usageMerge";
...@@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {...@@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
607 {model.model}608 {model.model}
608 </Text>609 </Text>
609 <Text className="text-sm text-foreground-muted">610 <Text className="text-sm text-foreground-muted">
610 {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens611 {isModelCostUnknown(model)
612 ? `no known rates · ${formatTokens(model.totalTokens)} tokens`
613 : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
611 </Text>614 </Text>
612 </View>615 </View>
613 <Text className="text-base tabular-nums text-foreground">{formatUsd(model.costUsd)}</Text>616 <Text className="text-base tabular-nums text-foreground">
617 {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)}
618 </Text>
614 </View>619 </View>
615 ))}620 ))}
616 </SettingsSection>621 </SettingsSection>
apps/web/src/components/usage/UsagePage.test.tsx+24
...@@ -87,6 +87,7 @@ const modelTotals = Object.freeze([...@@ -87,6 +87,7 @@ const modelTotals = Object.freeze([
87 costUsd: 10,87 costUsd: 10,
88 totalTokens: 100,88 totalTokens: 100,
89 records: 1,89 records: 1,
90 unpricedRecords: 0,
90 costShare: 10 / 16,91 costShare: 10 / 16,
91 },92 },
92 {93 {
...@@ -95,6 +96,7 @@ const modelTotals = Object.freeze([...@@ -95,6 +96,7 @@ const modelTotals = Object.freeze([
95 costUsd: 5,96 costUsd: 5,
96 totalTokens: 1_000,97 totalTokens: 1_000,
97 records: 1,98 records: 1,
99 unpricedRecords: 0,
98 costShare: 5 / 16,100 costShare: 5 / 16,
99 },101 },
100 {102 {
...@@ -103,8 +105,18 @@ const modelTotals = Object.freeze([...@@ -103,8 +105,18 @@ const modelTotals = Object.freeze([
103 costUsd: 1,105 costUsd: 1,
104 totalTokens: 1_000,106 totalTokens: 1_000,
105 records: 1,107 records: 1,
108 unpricedRecords: 0,
106 costShare: 1 / 16,109 costShare: 1 / 16,
107 },110 },
111 {
112 model: "unpriced-model",
113 provider: "codex" as const,
114 costUsd: 0,
115 totalTokens: 500,
116 records: 2,
117 unpricedRecords: 2,
118 costShare: 0,
119 },
108]);120]);
109121
110const environments = [122const environments = [
...@@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => {...@@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => {
190 expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/);202 expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/);
191 });203 });
192204
205 it("flags a model with no known rates instead of showing it as free", () => {
206 testState.breakdown = "model";
207
208 const markup = renderToStaticMarkup(<UsagePage />);
209 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
210 const unpricedRow = body.split("<tr").find((row) => row.includes("unpriced-model")) ?? "";
211
212 expect(unpricedRow).toContain("Unpriced");
213 expect(unpricedRow).not.toContain("$0.00");
214 });
215
193 it("sorts models by token usage when the token metric is selected", () => {216 it("sorts models by token usage when the token metric is selected", () => {
194 testState.metric = "tokens";217 testState.metric = "tokens";
195 testState.breakdown = "model";218 testState.breakdown = "model";
...@@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => {...@@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => {
202 "expensive-model",225 "expensive-model",
203 "token-heavy-model",226 "token-heavy-model",
204 "token-heavy-cheaper-model",227 "token-heavy-cheaper-model",
228 "unpriced-model",
205 ]);229 ]);
206 });230 });
207});231});
apps/web/src/components/usage/UsagePage.tsx+14-5
...@@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react";...@@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react";
1515
16import {16import {
17 isCompatibleUsageContractVersion,17 isCompatibleUsageContractVersion,
18 isModelCostUnknown,
18 type DailyTotals,19 type DailyTotals,
19 type HourlyTotals,20 type HourlyTotals,
20} from "@t3tools/shared/usageMerge";21} from "@t3tools/shared/usageMerge";
...@@ -363,9 +364,13 @@ export function UsagePage() {...@@ -363,9 +364,13 @@ export function UsagePage() {
363 : formatTokens(merged.totalTokens)}364 : formatTokens(merged.totalTokens)}
364 </span>365 </span>
365 <span className="text-xs text-muted-foreground">366 <span className="text-xs text-muted-foreground">
366 {metric === "cost"367 {metric !== "cost"
367 ? `${formatCount(merged.sessions)} sessions · API estimate`368 ? `${formatCount(merged.sessions)} sessions`
368 : `${formatCount(merged.sessions)} sessions`}369 : merged.costQuality.unpricedShare > 0
370 ? `${formatCount(merged.sessions)} sessions · API estimate excludes ${formatPercent(
371 merged.costQuality.unpricedShare,
372 )} unpriced records`
373 : `${formatCount(merged.sessions)} sessions · API estimate`}
369 </span>374 </span>
370 </div>375 </div>
371376
...@@ -511,10 +516,14 @@ export function UsagePage() {...@@ -511,10 +516,14 @@ export function UsagePage() {
511 </span>516 </span>
512 </td>517 </td>
513 <td className="py-2 text-right text-foreground tabular-nums">518 <td className="py-2 text-right text-foreground tabular-nums">
514 {formatUsd(model.costUsd)}519 {isModelCostUnknown(model) ? (
520 <span className="text-muted-foreground">Unpriced</span>
521 ) : (
522 formatUsd(model.costUsd)
523 )}
515 </td>524 </td>
516 <td className="py-2 text-right text-muted-foreground tabular-nums">525 <td className="py-2 text-right text-muted-foreground tabular-nums">
517 {formatPercent(model.costShare)}526 {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)}
518 </td>527 </td>
519 <td className="py-2 text-right text-muted-foreground tabular-nums">528 <td className="py-2 text-right text-muted-foreground tabular-nums">
520 {formatTokens(model.totalTokens)}529 {formatTokens(model.totalTokens)}
packages/shared/src/usageMerge.test.ts+33-1
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
1010
11import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";11import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
1212
13function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {13function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
14 return {14 return {
...@@ -221,6 +221,38 @@ describe("mergeUsage", () => {...@@ -221,6 +221,38 @@ describe("mergeUsage", () => {
221 expect(merged.costQuality.cacheSavingsUsd).toBe(4);221 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
222 });222 });
223223
224 it("marks a model with no known rates as unpriced rather than free", () => {
225 const merged = mergeUsage(
226 [
227 environment(
228 "env-a",
229 summary(
230 [
231 bucket({ costUsd: 75 }),
232 bucket({
233 provider: "codex",
234 model: "unknown-model",
235 costUsd: 0,
236 costSource: "unpriced",
237 unpricedRecords: 5,
238 }),
239 ],
240 [
241 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
242 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
243 ],
244 ),
245 ),
246 ],
247 USAGE_CONTRACT_VERSION,
248 );
249
250 expect(merged.models.find((model) => model.model === "unknown-model")?.unpricedRecords).toBe(5);
251 expect(merged.models.filter(isModelCostUnknown).map((model) => model.model)).toEqual([
252 "unknown-model",
253 ]);
254 });
255
224 it("keeps two machines apart when hostname and home path collide", () => {256 it("keeps two machines apart when hostname and home path collide", () => {
225 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make257 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make
226 // one machine's usage vanish. Filesystem identity separates them.258 // one machine's usage vanish. Filesystem identity separates them.
packages/shared/src/usageMerge.ts+23-1
...@@ -37,9 +37,22 @@ export interface ModelTotals {...@@ -37,9 +37,22 @@ export interface ModelTotals {
37 readonly costUsd: number;37 readonly costUsd: number;
38 readonly totalTokens: number;38 readonly totalTokens: number;
39 readonly records: number;39 readonly records: number;
40 /**
41 * Records whose tokens are counted here but which contributed nothing to
42 * `costUsd`. When it equals `records` the cost is unknown, not zero.
43 */
44 readonly unpricedRecords: number;
40 readonly costShare: number;45 readonly costShare: number;
41}46}
4247
48/**
49 * A model whose every record lacked rates has an unknown cost, not a zero one.
50 * Clients must not present its `costUsd` as a real dollar figure.
51 */
52export function isModelCostUnknown(model: ModelTotals): boolean {
53 return model.records > 0 && model.unpricedRecords >= model.records;
54}
55
43export interface DailyTotals {56export interface DailyTotals {
44 readonly day: string;57 readonly day: string;
45 readonly costUsd: number;58 readonly costUsd: number;
...@@ -249,7 +262,13 @@ export function mergeUsage(...@@ -249,7 +262,13 @@ export function mergeUsage(
249 >();262 >();
250 const modelAccumulator = new Map<263 const modelAccumulator = new Map<
251 string,264 string,
252 { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number }265 {
266 provider: UsageProviderKind;
267 costUsd: number;
268 totalTokens: number;
269 records: number;
270 unpricedRecords: number;
271 }
253 >();272 >();
254 const dailyAccumulator = new Map<273 const dailyAccumulator = new Map<
255 string,274 string,
...@@ -319,10 +338,12 @@ export function mergeUsage(...@@ -319,10 +338,12 @@ export function mergeUsage(
319 costUsd: 0,338 costUsd: 0,
320 totalTokens: 0,339 totalTokens: 0,
321 records: 0,340 records: 0,
341 unpricedRecords: 0,
322 };342 };
323 model.costUsd += bucket.costUsd;343 model.costUsd += bucket.costUsd;
324 model.totalTokens += tokens;344 model.totalTokens += tokens;
325 model.records += bucket.records;345 model.records += bucket.records;
346 model.unpricedRecords += bucket.unpricedRecords;
326 modelAccumulator.set(modelKey, model);347 modelAccumulator.set(modelKey, model);
327348
328 const day = dailyAccumulator.get(bucket.day) ?? {349 const day = dailyAccumulator.get(bucket.day) ?? {
...@@ -381,6 +402,7 @@ export function mergeUsage(...@@ -381,6 +402,7 @@ export function mergeUsage(
381 costUsd: totals.costUsd,402 costUsd: totals.costUsd,
382 totalTokens: totals.totalTokens,403 totalTokens: totals.totalTokens,
383 records: totals.records,404 records: totals.records,
405 unpricedRecords: totals.unpricedRecords,
384 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,406 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
385 }))407 }))
386 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);408 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);