From 48654c1182c41dbd5e99ef0d6620a13ba2400f26 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:39:21 -0300 Subject: [PATCH] fix(usage): flag unpriced model activity instead of showing $0.00 (#11021) Co-authored-by: Claude Opus 5 (1M context) --- .../src/features/usage/UsageRouteScreen.tsx | 9 +++-- .../src/components/usage/UsagePage.test.tsx | 24 +++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 19 ++++++++--- packages/shared/src/usageMerge.test.ts | 34 ++++++++++++++++++- packages/shared/src/usageMerge.ts | 24 ++++++++++++- 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 3e5cd0fc9e3d89ec9910dc0cdf02ee100fb03f55..ad54d6e323ee2ad5d5b61feed5a48ec3ba980bdf 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -2,6 +2,7 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, + isModelCostUnknown, type DailyTotals, type MergedUsage, } from "@t3tools/shared/usageMerge"; @@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { {model.model} - {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens + {isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {formatUsd(model.costUsd)} + + {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} + ))} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index e41843e6d9cc12ed19d4e6e2971d65806b4f133c..0743b91f6edffd68a8d6c6ce4bbd550e6fce00ad 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -87,6 +87,7 @@ const modelTotals = Object.freeze([ costUsd: 10, totalTokens: 100, records: 1, + unpricedRecords: 0, costShare: 10 / 16, }, { @@ -95,6 +96,7 @@ const modelTotals = Object.freeze([ costUsd: 5, totalTokens: 1_000, records: 1, + unpricedRecords: 0, costShare: 5 / 16, }, { @@ -103,8 +105,18 @@ const modelTotals = Object.freeze([ costUsd: 1, totalTokens: 1_000, records: 1, + unpricedRecords: 0, costShare: 1 / 16, }, + { + model: "unpriced-model", + provider: "codex" as const, + costUsd: 0, + totalTokens: 500, + records: 2, + unpricedRecords: 2, + costShare: 0, + }, ]); const environments = [ @@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => { expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); }); + it("flags a model with no known rates instead of showing it as free", () => { + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + const unpricedRow = body.split(" row.includes("unpriced-model")) ?? ""; + + expect(unpricedRow).toContain("Unpriced"); + expect(unpricedRow).not.toContain("$0.00"); + }); + it("sorts models by token usage when the token metric is selected", () => { testState.metric = "tokens"; testState.breakdown = "model"; @@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => { "expensive-model", "token-heavy-model", "token-heavy-cheaper-model", + "unpriced-model", ]); }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98028a45602cfca0afad1db9b7f1fa..21970c675596603223f5ac4700e109fecd77f2f7 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react"; import { isCompatibleUsageContractVersion, + isModelCostUnknown, type DailyTotals, type HourlyTotals, } from "@t3tools/shared/usageMerge"; @@ -363,9 +364,13 @@ export function UsagePage() { : formatTokens(merged.totalTokens)} - {metric === "cost" - ? `${formatCount(merged.sessions)} sessions · API estimate` - : `${formatCount(merged.sessions)} sessions`} + {metric !== "cost" + ? `${formatCount(merged.sessions)} sessions` + : merged.costQuality.unpricedShare > 0 + ? `${formatCount(merged.sessions)} sessions · API estimate excludes ${formatPercent( + merged.costQuality.unpricedShare, + )} unpriced records` + : `${formatCount(merged.sessions)} sessions · API estimate`} @@ -511,10 +516,14 @@ export function UsagePage() { - {formatUsd(model.costUsd)} + {isModelCostUnknown(model) ? ( + Unpriced + ) : ( + formatUsd(model.costUsd) + )} - {formatPercent(model.costShare)} + {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} {formatTokens(model.totalTokens)} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ffeb7cc96edf2a09df63eb3b261d98..24bbc3b7c2e8099aebec6ce461a55249ddf15c96 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; function bucket(overrides: Partial = {}): UsageBucket { return { @@ -221,6 +221,38 @@ describe("mergeUsage", () => { expect(merged.costQuality.cacheSavingsUsd).toBe(4); }); + it("marks a model with no known rates as unpriced rather than free", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 75 }), + bucket({ + provider: "codex", + model: "unknown-model", + costUsd: 0, + costSource: "unpriced", + unpricedRecords: 5, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.models.find((model) => model.model === "unknown-model")?.unpricedRecords).toBe(5); + expect(merged.models.filter(isModelCostUnknown).map((model) => model.model)).toEqual([ + "unknown-model", + ]); + }); + it("keeps two machines apart when hostname and home path collide", () => { // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make // one machine's usage vanish. Filesystem identity separates them. diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 95982bf507da05e67a4d3b17c8af5d7c7175a2a1..e0cb0510eb71a4b64a0cb2ab2df72d6ef806455e 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -37,9 +37,22 @@ export interface ModelTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + /** + * Records whose tokens are counted here but which contributed nothing to + * `costUsd`. When it equals `records` the cost is unknown, not zero. + */ + readonly unpricedRecords: number; readonly costShare: number; } +/** + * A model whose every record lacked rates has an unknown cost, not a zero one. + * Clients must not present its `costUsd` as a real dollar figure. + */ +export function isModelCostUnknown(model: ModelTotals): boolean { + return model.records > 0 && model.unpricedRecords >= model.records; +} + export interface DailyTotals { readonly day: string; readonly costUsd: number; @@ -249,7 +262,13 @@ export function mergeUsage( >(); const modelAccumulator = new Map< string, - { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + { + provider: UsageProviderKind; + costUsd: number; + totalTokens: number; + records: number; + unpricedRecords: number; + } >(); const dailyAccumulator = new Map< string, @@ -319,10 +338,12 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, + unpricedRecords: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; model.records += bucket.records; + model.unpricedRecords += bucket.unpricedRecords; modelAccumulator.set(modelKey, model); const day = dailyAccumulator.get(bucket.day) ?? { @@ -381,6 +402,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + unpricedRecords: totals.unpricedRecords, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); -- 2.54.0