From 5cfd46835d884a90e205a8b93fb87bc2c4de5fd5 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 16:00:13 +0700 Subject: [PATCH 1/2] fix(usage): say when cost is unavailable instead of $0.00 When the LiteLLM rate table cannot be loaded, the usage page already receives pricing.status=unavailable but still rendered $0.00. Show "Cost unavailable" for dollar columns and explain that token counts remain valid. --- .../src/features/usage/UsageRouteScreen.tsx | 59 ++++++++--- apps/web/src/components/usage/UsagePage.tsx | 99 +++++++++++++------ .../src/components/usage/usageCost.test.ts | 20 ++++ docs/user/usage.md | 3 +- packages/shared/src/usageFormat.test.ts | 19 ++++ packages/shared/src/usageFormat.ts | 16 ++- packages/shared/src/usageMerge.test.ts | 56 ++++++++++- packages/shared/src/usageMerge.ts | 24 ++++- 8 files changed, 247 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/components/usage/usageCost.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f95437..3070a3016d3ee 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -8,6 +8,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageCost, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -198,6 +199,7 @@ function ChartCard(props: { const { merged, metric } = props; const colors = useProviderColors(); const hasActivity = props.daily.some((period) => period.totalTokens > 0); + const costUnavailable = merged.pricingStatus === "unavailable"; return ( @@ -207,18 +209,28 @@ function ChartCard(props: { {metric === "cost" ? "Raw token cost" : "Processed tokens"} - {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} + {metric === "cost" + ? costUnavailable + ? formatUsageCost(merged.pricingStatus, merged.costUsd) + : `${formatUsd(merged.costUsd)}*` + : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" + ? costUnavailable + ? "Token counts are still valid." + : "* if billed at full API rate" : `Across ${formatCount(merged.sessions)} sessions`} - {hasActivity ? ( + {metric === "cost" && costUnavailable ? ( + + Cost unavailable + + ) : hasActivity ? ( - metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + metric === "cost" && !costUnavailable ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, ); return ( @@ -325,7 +338,7 @@ function ProviderSection(props: { {metric === "cost" - ? formatUsd(provider.costUsd) + ? formatUsageCost(merged.pricingStatus, provider.costUsd) : formatTokens(provider.totalTokens)} @@ -338,8 +351,12 @@ function ProviderSection(props: { {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + ? costUnavailable + ? `${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : costUnavailable + ? `${formatPercent(share)} of tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} ); @@ -367,11 +384,13 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H /> 0 - ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost` - : "vs full input rates" + merged.pricingStatus === "unavailable" + ? "requires the rate table" + : merged.costUsd > 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost` + : "vs full input rates" } /> - {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens + {merged.pricingStatus === "unavailable" + ? formatTokens(model.totalTokens) + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {formatUsd(model.costUsd)} + + {formatUsageCost(merged.pricingStatus, model.costUsd)} + ))} @@ -463,17 +486,25 @@ function UsageCoverageNotice(props: { props.merged.staleEnvironments.includes(environment.environmentId), ); const duplicateSources = props.merged.duplicateSources; + const costUnavailable = props.merged.pricingStatus === "unavailable"; if ( failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && - !props.isPartial + !props.isPartial && + !costUnavailable ) { return null; } return ( + {costUnavailable ? ( + + The model rate table could not be loaded, so costs are omitted. Token counts are still + valid. + + ) : null} {props.isPartial ? ( Some environments are still reporting. Totals are partial. diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 92e2c5b6fc346..9dccc9b102d4c 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,4 +1,4 @@ -import type { UsageProviderKind } from "@t3tools/contracts"; +import type { UsagePricingStatus, UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -16,6 +16,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageCost, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -66,13 +67,18 @@ export function UsagePage() { [isPast24Hours, merged.daily, merged.hourly], ); + const costUnavailable = merged.pricingStatus === "unavailable"; + const formatCost = (value: number) => formatUsageCost(merged.pricingStatus, value); + // Ranked by whatever the toggle is showing, so the bars always descend. const orderedProviders = useMemo( () => merged.providers.toSorted((a, b) => - metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + metric === "cost" && !costUnavailable + ? b.costUsd - a.costUsd + : b.totalTokens - a.totalTokens, ), - [merged.providers, metric], + [costUnavailable, merged.providers, metric], ); const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( @@ -179,6 +185,7 @@ export function UsagePage() { environments={environments} duplicateSources={merged.duplicateSources} staleEnvironments={merged.staleEnvironments} + pricingStatus={merged.pricingStatus} /> {/* Cost first: the financial answer, then the provider split. */} @@ -192,12 +199,16 @@ export function UsagePage() { {metric === "cost" - ? `${formatUsd(merged.costUsd)}*` + ? costUnavailable + ? formatCost(merged.costUsd) + : `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" + ? costUnavailable + ? "Token counts are still valid." + : "* if billed at full API rate" : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} @@ -213,7 +224,7 @@ export function UsagePage() { {metric === "cost" - ? formatUsd(provider.costUsd) + ? formatCost(provider.costUsd) : formatTokens(provider.totalTokens)} @@ -228,8 +239,12 @@ export function UsagePage() { {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + ? costUnavailable + ? `${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : costUnavailable + ? `${formatPercent(share)} of tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} ); @@ -263,16 +278,22 @@ export function UsagePage() { - + {metric === "cost" && costUnavailable ? ( +
+ Cost unavailable +
+ ) : ( + + )} @@ -299,11 +320,13 @@ export function UsagePage() { /> 0 - ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` - : "vs full input rates" + costUnavailable + ? "requires the rate table" + : merged.costUsd > 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" } /> @@ -365,10 +388,10 @@ export function UsagePage() { - {formatUsd(model.costUsd)} + {formatCost(model.costUsd)} - {formatPercent(model.costShare)} + {costUnavailable ? "—" : formatPercent(model.costShare)} {formatTokens(model.totalTokens)} @@ -415,11 +438,11 @@ export function UsagePage() { key={provider} className="py-2 text-right text-muted-foreground tabular-nums" > - {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} + {formatCost(period.byProvider.get(provider)?.costUsd ?? 0)} ))} - {formatUsd(period.costUsd)} + {formatCost(period.costUsd)} {formatTokens(period.totalTokens)} @@ -471,30 +494,44 @@ function Metric({ } /** - * Says plainly when the totals are incomplete: an environment that failed, or - * one whose transcripts another environment already reported. Environments - * that are still answering never reach this notice; the page shows the - * loading skeleton until every one is terminal. + * Says plainly when the totals are incomplete: an environment that failed, + * one whose transcripts another environment already reported, or a missing + * rate table. Environments that are still answering never reach this notice; + * the page shows the loading skeleton until every one is terminal. */ function UsageCoverageNotice({ environments, duplicateSources, staleEnvironments, + pricingStatus, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly duplicateSources: readonly string[]; readonly staleEnvironments: readonly string[]; + readonly pricingStatus: UsagePricingStatus; }) { const failed = environments.filter((environment) => environment.error !== null); const stale = environments.filter((environment) => staleEnvironments.includes(environment.environmentId), ); - if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { + const costUnavailable = pricingStatus === "unavailable"; + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + !costUnavailable + ) { return null; } return (
+ {costUnavailable ? ( + + The model rate table could not be loaded, so costs are omitted. Token counts are still + valid. + + ) : null} {failed.map((environment) => ( {environment.label} could not report usage. ))} diff --git a/apps/web/src/components/usage/usageCost.test.ts b/apps/web/src/components/usage/usageCost.test.ts new file mode 100644 index 0000000000000..163385b9f2ea8 --- /dev/null +++ b/apps/web/src/components/usage/usageCost.test.ts @@ -0,0 +1,20 @@ +import { formatUsageCost } from "@t3tools/shared/usageFormat"; +import { describe, expect, it } from "vite-plus/test"; + +describe("formatUsageCost", () => { + it("does not present a missing rate table as $0.00", () => { + expect(formatUsageCost("unavailable", 0)).toBe("Cost unavailable"); + }); + + it("keeps a genuine zero when rates loaded", () => { + expect(formatUsageCost("fresh", 0)).toBe("$0.00"); + }); + + it("formats a fresh dollar amount", () => { + expect(formatUsageCost("fresh", 12.3)).toBe("$12.30"); + }); + + it("formats a cached dollar amount", () => { + expect(formatUsageCost("cached", 4.5)).toBe("$4.50"); + }); +}); diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37c..b066dad8c791c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -3,7 +3,8 @@ The Usage page combines Codex and Claude Code activity from your connected environments. It reads the providers' local session history and shows API-equivalent token cost, processed tokens, cache savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token -cost shown here. +cost shown here. If the model rate table cannot be loaded, the page says cost is unavailable +instead of $0.00. Token counts are still valid. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index fb231fbacb206..de9737fb86039 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -6,9 +6,28 @@ import { formatDateTimeShort, formatHourShort, formatRelativeHourShort, + formatUsageCost, makeWindow, } from "./usageFormat.ts"; +describe("formatUsageCost", () => { + it("does not present a missing rate table as $0.00", () => { + expect(formatUsageCost("unavailable", 0)).toBe("Cost unavailable"); + }); + + it("keeps a genuine zero when rates loaded", () => { + expect(formatUsageCost("fresh", 0)).toBe("$0.00"); + }); + + it("formats a fresh dollar amount", () => { + expect(formatUsageCost("fresh", 12.3)).toBe("$12.30"); + }); + + it("formats a cached dollar amount", () => { + expect(formatUsageCost("cached", 4.5)).toBe("$4.50"); + }); +}); + describe("hourly usage formatting", () => { it("enumerates 24 fixed buckets across a rolling window", () => { const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z"); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd751829dd87b..2ed94fa6e89b9 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -4,7 +4,12 @@ * * @module usageFormat */ -import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; +import { + UsageDay, + type UsagePricingStatus, + type UsageResolution, + type UsageSummaryInput, +} from "@t3tools/contracts"; const CURRENCY = new Intl.NumberFormat("en-US", { style: "currency", @@ -19,6 +24,15 @@ export function formatUsd(value: number): string { return CURRENCY.format(value); } +/** + * Dollar column for the usage page. A missing rate table must not render as a + * real `$0.00`. + */ +export function formatUsageCost(status: UsagePricingStatus, costUsd: number): string { + if (status === "unavailable") return "Cost unavailable"; + return formatUsd(costUsd); +} + export function formatCount(value: number): string { return INTEGER.format(Math.round(value)); } diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index c2fa9e2a86a19..39b6f9006a867 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -3,6 +3,7 @@ import { type EnvironmentId, type UsageBucket, type UsageDay, + type UsagePricingStatus, type UsageProviderKind, type UsageSummary, } from "@t3tools/contracts"; @@ -42,6 +43,7 @@ function summary( distinctSessions?: number; }[], contractVersion: number = USAGE_CONTRACT_VERSION, + pricingStatus: UsagePricingStatus = "fresh", ): UsageSummary { return { contractVersion, @@ -64,7 +66,7 @@ function summary( distinctSessions: source.distinctSessions ?? 1, message: null, })), - pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, + pricing: { status: pricingStatus, source: "litellm", fetchedAt: null, knownModels: 10 }, scanDurationMs: 1, }; } @@ -253,10 +255,62 @@ describe("mergeUsage", () => { it("returns empty totals with no environments", () => { const merged = mergeUsage([], USAGE_CONTRACT_VERSION); expect(merged.costUsd).toBe(0); + expect(merged.pricingStatus).toBe("fresh"); expect(merged.daily).toHaveLength(0); expect(merged.hourly).toHaveLength(0); }); + it("keeps the worst pricing status among environments that contributed buckets", () => { + const sourceA = { provider: "claude" as const, hostId: "mac", homePath: "/a/.claude" }; + const sourceB = { provider: "claude" as const, hostId: "linux", homePath: "/b/.claude" }; + + const cachedAndFresh = mergeUsage( + [ + environment("env-a", summary([bucket()], [sourceA], USAGE_CONTRACT_VERSION, "cached")), + environment("env-b", summary([bucket()], [sourceB], USAGE_CONTRACT_VERSION, "fresh")), + ], + USAGE_CONTRACT_VERSION, + ); + expect(cachedAndFresh.pricingStatus).toBe("cached"); + + const mixedUnavailable = mergeUsage( + [ + environment("env-a", summary([bucket()], [sourceA], USAGE_CONTRACT_VERSION, "cached")), + environment("env-b", summary([bucket()], [sourceB], USAGE_CONTRACT_VERSION, "unavailable")), + ], + USAGE_CONTRACT_VERSION, + ); + expect(mixedUnavailable.pricingStatus).toBe("unavailable"); + }); + + it("ignores pricing status from environments that contributed no buckets", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket()], + [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }], + USAGE_CONTRACT_VERSION, + "fresh", + ), + ), + environment( + "env-b", + summary( + [], + [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }], + USAGE_CONTRACT_VERSION, + "unavailable", + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.pricingStatus).toBe("fresh"); + }); + it("derives hourly totals without losing the daily rollup", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 886b214183bc2..c1c5e3596ad8c 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -9,6 +9,7 @@ import type { EnvironmentId, UsageBucket, + UsagePricingStatus, UsageProviderKind, UsageSourceFingerprint, UsageSummary, @@ -75,6 +76,11 @@ export interface MergedUsage { readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; + /** + * Worst rate-table status among environments that contributed buckets. + * `unavailable` means dollar figures must not be shown as real zeros. + */ + readonly pricingStatus: UsagePricingStatus; /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; @@ -184,11 +190,22 @@ const EMPTY_MERGED: MergedUsage = { unpricedShare: 0, cacheSavingsUsd: 0, }, + pricingStatus: "fresh", duplicateSources: [], contributingEnvironments: [], staleEnvironments: [], }; +const PRICING_STATUS_RANK: Record = { + fresh: 0, + cached: 1, + unavailable: 2, +}; + +function worsePricingStatus(a: UsagePricingStatus, b: UsagePricingStatus): UsagePricingStatus { + return PRICING_STATUS_RANK[a] >= PRICING_STATUS_RANK[b] ? a : b; +} + /** * Merges every connected environment's summary. * @@ -253,13 +270,17 @@ export function mergeUsage( } >(); const contributingEnvironments: EnvironmentId[] = []; + let pricingStatus: UsagePricingStatus = "fresh"; for (const environment of current) { const { buckets, sessions: environmentSessions } = ownedContribution( environment, ownerByFingerprint, ); - if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); + if (buckets.length > 0) { + contributingEnvironments.push(environment.environmentId); + pricingStatus = worsePricingStatus(pricingStatus, environment.summary.pricing.status); + } sessions += environmentSessions; for (const bucket of buckets) { @@ -391,6 +412,7 @@ export function mergeUsage( records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, }, + pricingStatus, duplicateSources: duplicates, contributingEnvironments, staleEnvironments, From 2f92e4d16992b1d0c5663249178ecddffe9b0337 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 16:25:41 +0700 Subject: [PATCH 2/2] fix(usage): align unavailable-cost bars and table cells When rates are missing, provider bars now follow tokenShare so they match token ranking. Table dollar cells use an em dash instead of repeating "Cost unavailable", and the chart placeholder keeps the same footprint as the real chart. --- .../src/features/usage/UsageRouteScreen.tsx | 3 ++- apps/web/src/components/usage/UsagePage.tsx | 22 ++++++++++++++----- .../src/components/usage/usageCost.test.ts | 20 ----------------- 3 files changed, 18 insertions(+), 27 deletions(-) delete mode 100644 apps/web/src/components/usage/usageCost.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 3070a3016d3ee..a2406a728d930 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -322,7 +322,8 @@ function ProviderSection(props: { return ( {ordered.map((provider, index) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + const share = + metric === "cost" && !costUnavailable ? provider.costShare : provider.tokenShare; return ( {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + const share = + metric === "cost" && !costUnavailable + ? provider.costShare + : provider.tokenShare; return (
@@ -279,8 +282,13 @@ export function UsagePage() {
{metric === "cost" && costUnavailable ? ( -
- Cost unavailable +
+
+ Cost unavailable +
+
+   +
) : ( - {formatCost(model.costUsd)} + {costUnavailable ? "—" : formatUsd(model.costUsd)} {costUnavailable ? "—" : formatPercent(model.costShare)} @@ -438,11 +446,13 @@ export function UsagePage() { key={provider} className="py-2 text-right text-muted-foreground tabular-nums" > - {formatCost(period.byProvider.get(provider)?.costUsd ?? 0)} + {costUnavailable + ? "—" + : formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} ))} - {formatCost(period.costUsd)} + {costUnavailable ? "—" : formatUsd(period.costUsd)} {formatTokens(period.totalTokens)} diff --git a/apps/web/src/components/usage/usageCost.test.ts b/apps/web/src/components/usage/usageCost.test.ts deleted file mode 100644 index 163385b9f2ea8..0000000000000 --- a/apps/web/src/components/usage/usageCost.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { formatUsageCost } from "@t3tools/shared/usageFormat"; -import { describe, expect, it } from "vite-plus/test"; - -describe("formatUsageCost", () => { - it("does not present a missing rate table as $0.00", () => { - expect(formatUsageCost("unavailable", 0)).toBe("Cost unavailable"); - }); - - it("keeps a genuine zero when rates loaded", () => { - expect(formatUsageCost("fresh", 0)).toBe("$0.00"); - }); - - it("formats a fresh dollar amount", () => { - expect(formatUsageCost("fresh", 12.3)).toBe("$12.30"); - }); - - it("formats a cached dollar amount", () => { - expect(formatUsageCost("cached", 4.5)).toBe("$4.50"); - }); -});