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";
22import { useNavigation } from "@react-navigation/native";
33import {
44 isCompatibleUsageContractVersion,
5 isModelCostUnknown,
56 type DailyTotals,
67 type MergedUsage,
78} from "@t3tools/shared/usageMerge";
......@@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
607608 {model.model}
608609 </Text>
609610 <Text className="text-sm text-foreground-muted">
610 {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens
611 {isModelCostUnknown(model)
612 ? `no known rates · ${formatTokens(model.totalTokens)} tokens`
613 : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
611614 </Text>
612615 </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>
614619 </View>
615620 ))}
616621 </SettingsSection>
apps/web/src/components/usage/UsagePage.test.tsx+24
......@@ -87,6 +87,7 @@ const modelTotals = Object.freeze([
8787 costUsd: 10,
8888 totalTokens: 100,
8989 records: 1,
90 unpricedRecords: 0,
9091 costShare: 10 / 16,
9192 },
9293 {
......@@ -95,6 +96,7 @@ const modelTotals = Object.freeze([
9596 costUsd: 5,
9697 totalTokens: 1_000,
9798 records: 1,
99 unpricedRecords: 0,
98100 costShare: 5 / 16,
99101 },
100102 {
......@@ -103,8 +105,18 @@ const modelTotals = Object.freeze([
103105 costUsd: 1,
104106 totalTokens: 1_000,
105107 records: 1,
108 unpricedRecords: 0,
106109 costShare: 1 / 16,
107110 },
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 },
108120]);
109121
110122const environments = [
......@@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => {
190202 expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/);
191203 });
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
193216 it("sorts models by token usage when the token metric is selected", () => {
194217 testState.metric = "tokens";
195218 testState.breakdown = "model";
......@@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => {
202225 "expensive-model",
203226 "token-heavy-model",
204227 "token-heavy-cheaper-model",
228 "unpriced-model",
205229 ]);
206230 });
207231});
apps/web/src/components/usage/UsagePage.tsx+14-5
......@@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react";
1515
1616import {
1717 isCompatibleUsageContractVersion,
18 isModelCostUnknown,
1819 type DailyTotals,
1920 type HourlyTotals,
2021} from "@t3tools/shared/usageMerge";
......@@ -363,9 +364,13 @@ export function UsagePage() {
363364 : formatTokens(merged.totalTokens)}
364365 </span>
365366 <span className="text-xs text-muted-foreground">
366 {metric === "cost"
367 ? `${formatCount(merged.sessions)} sessions · API estimate`
368 : `${formatCount(merged.sessions)} sessions`}
367 {metric !== "cost"
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`}
369374 </span>
370375 </div>
371376
......@@ -511,10 +516,14 @@ export function UsagePage() {
511516 </span>
512517 </td>
513518 <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 )}
515524 </td>
516525 <td className="py-2 text-right text-muted-foreground tabular-nums">
517 {formatPercent(model.costShare)}
526 {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)}
518527 </td>
519528 <td className="py-2 text-right text-muted-foreground tabular-nums">
520529 {formatTokens(model.totalTokens)}
packages/shared/src/usageMerge.test.ts+33-1
......@@ -8,7 +8,7 @@ import {
88} from "@t3tools/contracts";
99import { describe, expect, it } from "vite-plus/test";
1010
11import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
11import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
1212
1313function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
1414 return {
......@@ -221,6 +221,38 @@ describe("mergeUsage", () => {
221221 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
222222 });
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
224256 it("keeps two machines apart when hostname and home path collide", () => {
225257 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make
226258 // one machine's usage vanish. Filesystem identity separates them.
packages/shared/src/usageMerge.ts+23-1
......@@ -37,9 +37,22 @@ export interface ModelTotals {
3737 readonly costUsd: number;
3838 readonly totalTokens: number;
3939 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;
4045 readonly costShare: number;
4146}
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
4356export interface DailyTotals {
4457 readonly day: string;
4558 readonly costUsd: number;
......@@ -249,7 +262,13 @@ export function mergeUsage(
249262 >();
250263 const modelAccumulator = new Map<
251264 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 }
253272 >();
254273 const dailyAccumulator = new Map<
255274 string,
......@@ -319,10 +338,12 @@ export function mergeUsage(
319338 costUsd: 0,
320339 totalTokens: 0,
321340 records: 0,
341 unpricedRecords: 0,
322342 };
323343 model.costUsd += bucket.costUsd;
324344 model.totalTokens += tokens;
325345 model.records += bucket.records;
346 model.unpricedRecords += bucket.unpricedRecords;
326347 modelAccumulator.set(modelKey, model);
327348
328349 const day = dailyAccumulator.get(bucket.day) ?? {
......@@ -381,6 +402,7 @@ export function mergeUsage(
381402 costUsd: totals.costUsd,
382403 totalTokens: totals.totalTokens,
383404 records: totals.records,
405 unpricedRecords: totals.unpricedRecords,
384406 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
385407 }))
386408 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);