Skip to content
Merged
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
9 changes: 7 additions & 2 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -607,10 +608,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
{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">{formatUsd(model.costUsd)}</Text>
<Text className="text-base tabular-nums text-foreground">
{isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)}
</Text>
</View>
))}
</SettingsSection>
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/usage/UsagePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const modelTotals = Object.freeze([
costUsd: 10,
totalTokens: 100,
records: 1,
unpricedRecords: 0,
costShare: 10 / 16,
},
{
Expand All @@ -95,6 +96,7 @@ const modelTotals = Object.freeze([
costUsd: 5,
totalTokens: 1_000,
records: 1,
unpricedRecords: 0,
costShare: 5 / 16,
},
{
Expand All @@ -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 = [
Expand Down Expand Up @@ -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(<UsagePage />);
const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
const unpricedRow = body.split("<tr").find((row) => 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";
Expand All @@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => {
"expensive-model",
"token-heavy-model",
"token-heavy-cheaper-model",
"unpriced-model",
]);
});
});
19 changes: 14 additions & 5 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react";

import {
isCompatibleUsageContractVersion,
isModelCostUnknown,
type DailyTotals,
type HourlyTotals,
} from "@t3tools/shared/usageMerge";
Expand Down Expand Up @@ -363,9 +364,13 @@ export function UsagePage() {
: formatTokens(merged.totalTokens)}
</span>
<span className="text-xs text-muted-foreground">
{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`}
</span>
</div>

Expand Down Expand Up @@ -511,10 +516,14 @@ export function UsagePage() {
</span>
</td>
<td className="py-2 text-right text-foreground tabular-nums">
{formatUsd(model.costUsd)}
{isModelCostUnknown(model) ? (
<span className="text-muted-foreground">Unpriced</span>
) : (
formatUsd(model.costUsd)
)}
</td>
<td className="py-2 text-right text-muted-foreground tabular-nums">
{formatPercent(model.costShare)}
{isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)}
</td>
<td className="py-2 text-right text-muted-foreground tabular-nums">
{formatTokens(model.totalTokens)}
Expand Down
34 changes: 33 additions & 1 deletion packages/shared/src/usageMerge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): UsageBucket {
return {
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion packages/shared/src/usageMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) ?? {
Expand Down Expand Up @@ -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);
Expand Down
Loading