diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f95437..a2406a728d930 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 ( {ordered.map((provider, index) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + const share = + metric === "cost" && !costUnavailable ? provider.costShare : provider.tokenShare; return ( {metric === "cost" - ? formatUsd(provider.costUsd) + ? formatUsageCost(merged.pricingStatus, provider.costUsd) : formatTokens(provider.totalTokens)} @@ -338,8 +352,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 +385,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 +487,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..722a01a071c90 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,18 +199,25 @@ 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.`} {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + const share = + metric === "cost" && !costUnavailable + ? provider.costShare + : provider.tokenShare; return (
@@ -213,7 +227,7 @@ export function UsagePage() { {metric === "cost" - ? formatUsd(provider.costUsd) + ? formatCost(provider.costUsd) : formatTokens(provider.totalTokens)}
@@ -228,8 +242,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 +281,27 @@ export function UsagePage() { - + {metric === "cost" && costUnavailable ? ( +
+
+ Cost unavailable +
+
+   +
+
+ ) : ( + + )} @@ -299,11 +328,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 +396,10 @@ export function UsagePage() { - {formatUsd(model.costUsd)} + {costUnavailable ? "—" : formatUsd(model.costUsd)} - {formatPercent(model.costShare)} + {costUnavailable ? "—" : formatPercent(model.costShare)} {formatTokens(model.totalTokens)} @@ -415,11 +446,13 @@ export function UsagePage() { key={provider} className="py-2 text-right text-muted-foreground tabular-nums" > - {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} + {costUnavailable + ? "—" + : formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} ))} - {formatUsd(period.costUsd)} + {costUnavailable ? "—" : formatUsd(period.costUsd)} {formatTokens(period.totalTokens)} @@ -471,30 +504,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/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,