Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 47 additions & 15 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
formatHourShort,
formatPercent,
formatTokens,
formatUsageCost,
formatUsd,
makeWindow,
} from "@t3tools/shared/usageFormat";
Expand Down Expand Up @@ -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 (
<View className="gap-4 rounded-[24px] border-continuous bg-card p-4">
Expand All @@ -207,18 +209,28 @@ function ChartCard(props: {
{metric === "cost" ? "Raw token cost" : "Processed tokens"}
</Text>
<Text className="text-4xl font-t3-bold tabular-nums text-foreground">
{metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)}
{metric === "cost"
? costUnavailable
? formatUsageCost(merged.pricingStatus, merged.costUsd)
: `${formatUsd(merged.costUsd)}*`
: formatTokens(merged.totalTokens)}
</Text>
<Text className="text-sm text-foreground-muted">
{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`}
</Text>
</View>
<MetricToggle metric={metric} onChange={props.onMetricChange} />
</View>

{hasActivity ? (
{metric === "cost" && costUnavailable ? (
<View style={{ height: CHART_HEIGHT }} className="items-center justify-center">
<Text className="text-base text-foreground-muted">Cost unavailable</Text>
</View>
) : hasActivity ? (
<UsageDailyChart
days={props.days}
daily={props.daily}
Expand Down Expand Up @@ -299,17 +311,19 @@ function ProviderSection(props: {
const { merged, metric } = props;
const colors = useProviderColors();
if (merged.providers.length === 0) return null;
const costUnavailable = merged.pricingStatus === "unavailable";

// Ranked by whatever the toggle is showing, so the rows always descend.
// .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method.
const ordered = [...merged.providers].sort((a, b) =>
metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
metric === "cost" && !costUnavailable ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
);

return (
<SettingsSection title="Providers" card>
{ordered.map((provider, index) => {
const share = metric === "cost" ? provider.costShare : provider.tokenShare;
const share =
metric === "cost" && !costUnavailable ? provider.costShare : provider.tokenShare;
return (
<View
key={provider.provider}
Expand All @@ -325,7 +339,7 @@ function ProviderSection(props: {
</View>
<Text className="text-lg tabular-nums text-foreground">
{metric === "cost"
? formatUsd(provider.costUsd)
? formatUsageCost(merged.pricingStatus, provider.costUsd)
: formatTokens(provider.totalTokens)}
</Text>
</View>
Expand All @@ -338,8 +352,12 @@ function ProviderSection(props: {
</View>
<Text className="text-sm text-foreground-muted">
{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)}`}
</Text>
</View>
);
Expand Down Expand Up @@ -367,11 +385,13 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
/>
<MetricCell
label="Cache savings"
value={formatUsd(merged.costQuality.cacheSavingsUsd)}
value={formatUsageCost(merged.pricingStatus, merged.costQuality.cacheSavingsUsd)}
detail={
merged.costUsd > 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"
}
/>
<MetricCell
Expand Down Expand Up @@ -438,10 +458,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
{model.model}
</Text>
<Text className="text-sm text-foreground-muted">
{formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens
{merged.pricingStatus === "unavailable"
? formatTokens(model.totalTokens)
: `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
</Text>
</View>
<Text className="text-base tabular-nums text-foreground">{formatUsd(model.costUsd)}</Text>
<Text className="text-base tabular-nums text-foreground">
{formatUsageCost(merged.pricingStatus, model.costUsd)}
</Text>
</View>
))}
</SettingsSection>
Expand All @@ -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 (
<View className="gap-1 rounded-[16px] border-continuous bg-card px-4 py-3">
{costUnavailable ? (
<Text className="text-sm text-foreground-muted">
The model rate table could not be loaded, so costs are omitted. Token counts are still
valid.
</Text>
) : null}
{props.isPartial ? (
<Text className="text-sm text-foreground-muted">
Some environments are still reporting. Totals are partial.
Expand Down
111 changes: 79 additions & 32 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -16,6 +16,7 @@ import {
formatHourShort,
formatPercent,
formatTokens,
formatUsageCost,
formatUsd,
makeWindow,
} from "@t3tools/shared/usageFormat";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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. */}
Expand All @@ -192,18 +199,25 @@ export function UsagePage() {
</span>
<span className="text-4xl font-semibold text-foreground tabular-nums">
{metric === "cost"
? `${formatUsd(merged.costUsd)}*`
? costUnavailable
? formatCost(merged.costUsd)
: `${formatUsd(merged.costUsd)}*`
: formatTokens(merged.totalTokens)}
</span>
<span className="text-xs text-muted-foreground">
{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.`}
</span>
</div>

{orderedProviders.map((provider) => {
const share = metric === "cost" ? provider.costShare : provider.tokenShare;
const share =
metric === "cost" && !costUnavailable
? provider.costShare
: provider.tokenShare;
return (
<div key={provider.provider} className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between">
Expand All @@ -213,7 +227,7 @@ export function UsagePage() {
</span>
<span className="text-sm text-foreground tabular-nums">
{metric === "cost"
? formatUsd(provider.costUsd)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
? formatCost(provider.costUsd)
: formatTokens(provider.totalTokens)}
</span>
</div>
Expand All @@ -228,8 +242,12 @@ export function UsagePage() {
</div>
<span className="text-xs text-muted-foreground">
{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)}`}
</span>
</div>
);
Expand Down Expand Up @@ -263,16 +281,27 @@ export function UsagePage() {
<UsageChartLegend />
</div>
</div>
<UsageProviderChart
days={days}
daily={merged.daily}
hours={hours}
hourly={merged.hourly}
metric={metric}
referenceTime={window.untilTime}
resolution={isPast24Hours ? "hour" : "day"}
timeZone={window.timeZone}
/>
{metric === "cost" && costUnavailable ? (
<div className="flex flex-col gap-1">
<div className="flex h-56 items-center justify-center text-sm text-muted-foreground">
Cost unavailable
</div>
<div className="flex justify-between pl-16 text-[10px] text-muted-foreground uppercase">
<span>&nbsp;</span>
</div>
</div>
) : (
<UsageProviderChart
days={days}
daily={merged.daily}
hours={hours}
hourly={merged.hourly}
metric={metric}
referenceTime={window.untilTime}
resolution={isPast24Hours ? "hour" : "day"}
timeZone={window.timeZone}
/>
)}
</div>
</section>

Expand All @@ -299,11 +328,13 @@ export function UsagePage() {
/>
<Metric
label="Cache savings"
value={formatUsd(merged.costQuality.cacheSavingsUsd)}
value={formatCost(merged.costQuality.cacheSavingsUsd)}
detail={
merged.costUsd > 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"
}
/>
</section>
Expand Down Expand Up @@ -365,10 +396,10 @@ export function UsagePage() {
</span>
</td>
<td className="py-2 text-right text-foreground tabular-nums">
{formatUsd(model.costUsd)}
{costUnavailable ? "—" : formatUsd(model.costUsd)}
</td>
<td className="py-2 text-right text-muted-foreground tabular-nums">
{formatPercent(model.costShare)}
{costUnavailable ? "—" : formatPercent(model.costShare)}
</td>
<td className="py-2 text-right text-muted-foreground tabular-nums">
{formatTokens(model.totalTokens)}
Expand Down Expand Up @@ -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)}
</td>
))}
<td className="py-2 text-right text-foreground tabular-nums">
{formatUsd(period.costUsd)}
{costUnavailable ? "—" : formatUsd(period.costUsd)}
</td>
<td className="py-2 text-right text-muted-foreground tabular-nums">
{formatTokens(period.totalTokens)}
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground">
{costUnavailable ? (
<span>
The model rate table could not be loaded, so costs are omitted. Token counts are still
valid.
</span>
) : null}
{failed.map((environment) => (
<span key={environment.label}>{environment.label} could not report usage.</span>
))}
Expand Down
3 changes: 2 additions & 1 deletion docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading