Skip to content
Open
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
30 changes: 22 additions & 8 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export function UsageRouteScreen() {
/>
<ProviderSection merged={merged} metric={metric} />
<TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
<ModelsSection merged={merged} />
<ModelsSection merged={merged} metric={metric} />
</>
)}
</>
Expand Down Expand Up @@ -585,14 +585,20 @@ function MetricCell(props: {
);
}

function ModelsSection(props: { readonly merged: MergedUsage }) {
const { merged } = props;
function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) {
const { merged, metric } = props;
const colors = useProviderColors();
if (merged.models.length === 0) return null;

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

return (
<SettingsSection title="By model" card>
{merged.models.map((model, index) => (
{ordered.map((model, index) => (
<View
key={`${model.provider}:${model.model}`}
className={
Expand All @@ -610,13 +616,21 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
{model.model}
</Text>
<Text className="text-sm text-foreground-muted">
{isModelCostUnknown(model)
? `no known rates · ${formatTokens(model.totalTokens)} tokens`
: `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
{metric === "tokens"
? `${formatPercent(model.tokenShare)} of tokens · ${
isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd)
}`
: isModelCostUnknown(model)
? `no known rates · ${formatTokens(model.totalTokens)} tokens`
: `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
</Text>
</View>
<Text className="text-base tabular-nums text-foreground">
{isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)}
{metric === "tokens"
? formatTokens(model.totalTokens)
: isModelCostUnknown(model)
? "Unpriced"
: formatUsd(model.costUsd)}
</Text>
</View>
))}
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/usage/UsagePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const modelTotals = Object.freeze([
records: 1,
unpricedRecords: 0,
costShare: 10 / 16,
tokenShare: 100 / 2_600,
},
{
model: "token-heavy-model",
Expand All @@ -98,6 +99,7 @@ const modelTotals = Object.freeze([
records: 1,
unpricedRecords: 0,
costShare: 5 / 16,
tokenShare: 1_000 / 2_600,
},
{
model: "token-heavy-cheaper-model",
Expand All @@ -107,6 +109,7 @@ const modelTotals = Object.freeze([
records: 1,
unpricedRecords: 0,
costShare: 1 / 16,
tokenShare: 1_000 / 2_600,
},
{
model: "unpriced-model",
Expand All @@ -116,6 +119,7 @@ const modelTotals = Object.freeze([
records: 2,
unpricedRecords: 2,
costShare: 0,
tokenShare: 500 / 2_600,
},
]);

Expand Down Expand Up @@ -213,6 +217,33 @@ describe("UsagePage model breakdown", () => {
expect(unpricedRow).not.toContain("$0.00");
});

it("labels cost shares and leaves unpriced models without a cost share", () => {
testState.breakdown = "model";

const markup = renderToStaticMarkup(<UsagePage />);
const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
const rows = body.split("<tr");

expect(markup).toContain("Cost share");
expect(rows.find((row) => row.includes("expensive-model"))).toContain("62.5%");
expect(rows.find((row) => row.includes("unpriced-model"))).toContain("—");
});

it("labels token shares and includes unpriced models in token shares", () => {
testState.metric = "tokens";
testState.breakdown = "model";

const markup = renderToStaticMarkup(<UsagePage />);
const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
const rows = body.split("<tr");

expect(markup).toContain("Token share");
expect(rows.find((row) => row.includes("token-heavy-model"))).toContain("38.5%");
// Unpriced models still have a real token share to report.
expect(rows.find((row) => row.includes("unpriced-model"))).toContain("19.2%");
expect(body).not.toContain("—");
});

it("sorts models by token usage when the token metric is selected", () => {
testState.metric = "tokens";
testState.breakdown = "model";
Expand Down
24 changes: 20 additions & 4 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,9 @@ export function UsagePage() {
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="py-2 font-normal">Model</th>
<th className="py-2 text-right font-normal">Cost</th>
<th className="py-2 text-right font-normal">Share</th>
<th className="py-2 text-right font-normal">
{metric === "tokens" ? "Token share" : "Cost share"}
</th>
<th className="py-2 text-right font-normal">Tokens</th>
</tr>
</thead>
Expand All @@ -518,17 +520,31 @@ export function UsagePage() {
{model.model}
</span>
</td>
<td className="py-2 text-right text-foreground tabular-nums">
<td
className={cn(
"py-2 text-right tabular-nums",
metric === "cost" ? "text-foreground" : "text-muted-foreground",
)}
>
{isModelCostUnknown(model) ? (
<span className="text-muted-foreground">Unpriced</span>
) : (
formatUsd(model.costUsd)
)}
</td>
<td className="py-2 text-right text-muted-foreground tabular-nums">
{isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)}
{metric === "tokens"
? formatPercent(model.tokenShare)
: isModelCostUnknown(model)
? "—"
: formatPercent(model.costShare)}
</td>
<td className="py-2 text-right text-muted-foreground tabular-nums">
<td
className={cn(
"py-2 text-right tabular-nums",
metric === "tokens" ? "text-foreground" : "text-muted-foreground",
)}
>
{formatTokens(model.totalTokens)}
</td>
</tr>
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/usageMerge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,44 @@ describe("mergeUsage", () => {
expect(merged.costQuality.cacheSavingsUsd).toBe(4);
});

it("derives model token shares independently of their cost shares", () => {
const merged = mergeUsage(
[
environment(
"env-a",
summary(
[
bucket({ costUsd: 90 }),
bucket({
provider: "codex",
model: "gpt-5.6-sol",
costUsd: 10,
totals: {
uncachedInputTokens: 3 * 1160,
cachedInputTokens: 0,
cacheCreationTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
},
}),
],
[
{ provider: "claude", hostId: "mac", homePath: "/a/.claude" },
{ provider: "codex", hostId: "mac", homePath: "/a/.codex" },
],
),
),
],
USAGE_CONTRACT_VERSION,
);

const byModel = Object.fromEntries(merged.models.map((model) => [model.model, model]));
expect(byModel["claude-fable-5"]?.costShare).toBeCloseTo(0.9, 5);
expect(byModel["claude-fable-5"]?.tokenShare).toBeCloseTo(0.25, 5);
expect(byModel["gpt-5.6-sol"]?.costShare).toBeCloseTo(0.1, 5);
expect(byModel["gpt-5.6-sol"]?.tokenShare).toBeCloseTo(0.75, 5);
});

it("marks a model with no known rates as unpriced rather than free", () => {
const merged = mergeUsage(
[
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/usageMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface ModelTotals {
*/
readonly unpricedRecords: number;
readonly costShare: number;
readonly tokenShare: number;
}

/**
Expand Down Expand Up @@ -404,6 +405,7 @@ export function mergeUsage(
records: totals.records,
unpricedRecords: totals.unpricedRecords,
costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
}))
.sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);

Expand Down
Loading