From 9920f0ea42713f841eee5ae6d32fe082355525fb Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:17:20 +0800 Subject: [PATCH] fix(usage): distinguish unpriced usage from zero cost Refs #5799 --- .../src/features/usage/UsageRouteScreen.tsx | 73 ++-- .../usage/usageCostPresentation.test.ts | 50 +++ .../features/usage/usageCostPresentation.ts | 50 +++ .../src/components/usage/UsagePage.test.tsx | 163 ++++++++ apps/web/src/components/usage/UsagePage.tsx | 351 +++++++++++------- .../usage/UsageProviderChart.test.ts | 64 +++- .../components/usage/UsageProviderChart.tsx | 46 ++- packages/shared/src/usageFormat.test.ts | 26 ++ packages/shared/src/usageFormat.ts | 23 ++ packages/shared/src/usageMerge.test.ts | 6 + packages/shared/src/usageMerge.ts | 64 +++- 11 files changed, 744 insertions(+), 172 deletions(-) create mode 100644 apps/mobile/src/features/usage/usageCostPresentation.test.ts create mode 100644 apps/mobile/src/features/usage/usageCostPresentation.ts create mode 100644 apps/web/src/components/usage/UsagePage.test.tsx create mode 100644 packages/shared/src/usageFormat.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 54a9ac7bc21d..cd250686c2d6 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -18,6 +18,7 @@ import { AppText as Text } from "../../components/AppText"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; +import { presentUsageCost, presentUsageCostShare, USAGE_COST_GUIDE } from "./usageCostPresentation"; import { UsageDailyChart } from "./UsageDailyChart"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; @@ -150,20 +151,21 @@ function ChartCard(props: { const { merged, metric } = props; const colors = useProviderColors(); const hasActivity = merged.daily.some((day) => day.totalTokens > 0); + const cost = presentUsageCost(merged.costUsd, merged.costQuality.unpricedShare, 1); return ( - {metric === "cost" ? "Raw token cost" : "Processed tokens"} + {metric === "cost" ? cost.chartLabel : "Processed tokens"} - {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} + {metric === "cost" ? cost.headline : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" + ? cost.headlineDetail : `Across ${formatCount(merged.sessions)} sessions`} @@ -200,6 +202,10 @@ function ChartCard(props: { {formatDayShort(props.untilDay)} + + {cost.hasUnpriced ? ( + {USAGE_COST_GUIDE} + ) : null} ); } @@ -254,6 +260,11 @@ function ProviderSection(props: { {ordered.map((provider, index) => { const share = metric === "cost" ? provider.costShare : provider.tokenShare; + const providerCost = presentUsageCost( + provider.costUsd, + provider.unpricedShare, + provider.costShare, + ); return ( {PROVIDER_LABEL[provider.provider]} - {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} + {metric === "cost" ? providerCost.amount : formatTokens(provider.totalTokens)} @@ -282,8 +291,8 @@ function ProviderSection(props: { {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + ? `${presentUsageCostShare(providerCost, merged.costQuality.unpricedShare)} · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${providerCost.amount}`} ); @@ -362,30 +371,34 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { return ( - {merged.models.map((model, index) => ( - + {merged.models.map((model, index) => { + const modelCost = presentUsageCost(model.costUsd, model.unpricedShare, model.costShare); + return ( - - - {model.model} - - - {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens - + key={`${model.provider}:${model.model}`} + className={ + index === 0 + ? "flex-row items-center gap-3 p-4" + : "flex-row items-center gap-3 border-t border-border-subtle p-4" + } + > + + + + {model.model} + + + {presentUsageCostShare(modelCost, merged.costQuality.unpricedShare)} ·{" "} + {formatTokens(model.totalTokens)} tokens + + + {modelCost.amount} - {formatUsd(model.costUsd)} - - ))} + ); + })} ); } diff --git a/apps/mobile/src/features/usage/usageCostPresentation.test.ts b/apps/mobile/src/features/usage/usageCostPresentation.test.ts new file mode 100644 index 000000000000..007faed32b70 --- /dev/null +++ b/apps/mobile/src/features/usage/usageCostPresentation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { presentUsageCost, presentUsageCostShare } from "./usageCostPresentation"; + +describe("presentUsageCost", () => { + it("does not present fully unpriced mobile usage as free", () => { + const cost = presentUsageCost(0, 1, 0); + + expect(cost).toEqual({ + amount: "—", + headline: "—", + headlineDetail: "No matching API rates; token totals are still complete.", + chartLabel: "Raw token cost (partial)", + costShare: "—", + unpricedDetail: "100.0% unpriced", + hasUnpriced: true, + }); + expect(presentUsageCostShare(cost, 0.5)).toBe("No priced-cost share · 100.0% unpriced"); + }); + + it("marks mixed mobile usage as a lower-bound priced-cost estimate", () => { + const cost = presentUsageCost(12.34, 0.25, 0.4); + + expect(cost).toEqual({ + amount: "≥$12.34", + headline: "≥$12.34*", + headlineDetail: "* Partial API-rate estimate; 25.0% of requests have no matching rate.", + chartLabel: "Raw token cost (partial)", + costShare: "40.0%", + unpricedDetail: "25.0% unpriced", + hasUnpriced: true, + }); + expect(presentUsageCostShare(cost, 0.5)).toBe("40.0% of priced cost · 25.0% unpriced"); + }); + + it("preserves the existing mobile wording when every request is priced", () => { + const cost = presentUsageCost(12.34, 0, 0.4); + + expect(cost).toEqual({ + amount: "$12.34", + headline: "$12.34*", + headlineDetail: "* if billed at full API rate", + chartLabel: "Raw token cost", + costShare: "40.0%", + unpricedDetail: null, + hasUnpriced: false, + }); + expect(presentUsageCostShare(cost, 0)).toBe("40.0% of cost"); + }); +}); diff --git a/apps/mobile/src/features/usage/usageCostPresentation.ts b/apps/mobile/src/features/usage/usageCostPresentation.ts new file mode 100644 index 000000000000..ca198a8050be --- /dev/null +++ b/apps/mobile/src/features/usage/usageCostPresentation.ts @@ -0,0 +1,50 @@ +import { formatCostEstimate, formatPercent } from "@t3tools/shared/usageFormat"; + +export const USAGE_COST_GUIDE = + "Cost guide: ≥ means a lower-bound estimate from matching API rates; — means no matching rate. Token totals remain complete."; + +export interface UsageCostPresentation { + readonly amount: string; + readonly headline: string; + readonly headlineDetail: string; + readonly chartLabel: string; + readonly costShare: string; + readonly unpricedDetail: string | null; + readonly hasUnpriced: boolean; +} + +/** User-facing cost language shared by the mobile headline and breakdown rows. */ +export function presentUsageCost( + costUsd: number, + unpricedShare: number, + costShare: number, +): UsageCostPresentation { + const estimate = formatCostEstimate(costUsd, unpricedShare); + const hasUnpriced = unpricedShare > 0; + + return { + amount: estimate.value, + headline: estimate.value === "—" ? estimate.value : `${estimate.value}*`, + headlineDetail: !hasUnpriced + ? "* if billed at full API rate" + : estimate.value === "—" + ? estimate.detail + : `* ${estimate.detail}`, + chartLabel: hasUnpriced ? "Raw token cost (partial)" : "Raw token cost", + costShare: unpricedShare >= 1 ? "—" : formatPercent(costShare), + unpricedDetail: hasUnpriced ? `${formatPercent(unpricedShare)} unpriced` : null, + hasUnpriced, + }; +} + +/** Describes one provider/model's share without treating unpriced records as free. */ +export function presentUsageCostShare( + cost: UsageCostPresentation, + totalUnpricedShare: number, +): string { + if (cost.costShare === "—") { + return `No priced-cost share${cost.unpricedDetail === null ? "" : ` · ${cost.unpricedDetail}`}`; + } + const label = totalUnpricedShare > 0 ? "priced cost" : "cost"; + return `${cost.costShare} of ${label}${cost.unpricedDetail === null ? "" : ` · ${cost.unpricedDetail}`}`; +} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx new file mode 100644 index 000000000000..2e3785fef15b --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -0,0 +1,163 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import type { DailyTotals, ModelTotals, ProviderTotals } from "@t3tools/shared/usageMerge"; +import { formatUsageMetricLabel, UsageBreakdownTable, UsageProviderRows } from "./UsagePage"; + +function provider(overrides: Partial = {}): ProviderTotals { + return { + provider: "codex", + costUsd: 0, + totalTokens: 10_000, + records: 1, + costShare: 0, + tokenShare: 1, + unpricedShare: 1, + ...overrides, + }; +} + +function model(overrides: Partial = {}): ModelTotals { + return { + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 12.34, + totalTokens: 10_000, + records: 4, + costShare: 0.6, + unpricedShare: 0.25, + ...overrides, + }; +} + +function day(overrides: Partial = {}): DailyTotals { + return { + day: "2026-08-01", + costUsd: 0, + totalTokens: 10_000, + unpricedShare: 1, + byProvider: new Map([["codex", { costUsd: 0, totalTokens: 10_000, unpricedShare: 1 }]]), + ...overrides, + }; +} + +describe("formatUsageMetricLabel", () => { + it("marks the web cost headline partial when pricing coverage is incomplete", () => { + expect(formatUsageMetricLabel("cost", 0.25)).toBe("Raw token cost (partial)"); + expect(formatUsageMetricLabel("cost", 0)).toBe("Raw token cost"); + expect(formatUsageMetricLabel("tokens", 1)).toBe("Processed tokens"); + }); +}); + +describe("UsageProviderRows", () => { + it("does not assign a priced-cost share to a fully unpriced provider", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("No priced-cost share · 100.0% unpriced · 10K tokens"); + expect(markup).not.toContain("0.0% of priced cost"); + }); + + it("does not call fully unpriced provider usage free in token mode", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("100.0% of tokens · —"); + expect(markup).not.toContain("$0.00"); + }); + + it("labels partial bars against priced cost", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("≥$12.34"); + expect(markup).toContain("60.0% of priced cost · 25.0% unpriced"); + }); + + it("keeps the ordinary cost-share wording when every record is priced", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("$12.34"); + expect(markup).toContain("60.0% of cost · 10K tokens"); + expect(markup).not.toContain("priced cost"); + expect(markup).not.toContain("unpriced"); + }); +}); + +describe("UsageBreakdownTable", () => { + it("visibly explains partial estimates and describes the model table", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("≥ means a lower-bound estimate from matching API rates"); + expect(markup).toContain("— means no matching rate"); + expect(markup).toContain('aria-describedby="usage-cost-guidance"'); + expect(markup).toContain("Share of priced cost"); + expect(markup).toContain("≥$12.34"); + }); + + it("describes all-unpriced day values without calling them zero cost", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-describedby="usage-cost-guidance"'); + expect(markup).toContain("— means no matching rate"); + expect(markup).toContain("No matching API rates; token totals are still complete."); + }); + + it("labels an absent provider-day as no activity rather than priced zero", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('title="No activity">—'); + expect(markup).not.toContain("If billed at full API rate."); + expect(markup).not.toContain("$0.00"); + }); + + it("keeps all-priced model details free of partial-estimate guidance", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(">Share"); + expect(markup).not.toContain("Share of priced cost"); + expect(markup).not.toContain("usage-cost-guidance"); + expect(markup).not.toContain("lower-bound estimate"); + }); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b5b762aea731..ec8792978bf5 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -5,8 +5,10 @@ import { useMemo, useState } from "react"; import { cn } from "../../lib/utils"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import type { DailyTotals, ModelTotals, ProviderTotals } from "@t3tools/shared/usageMerge"; import { enumerateDays, + formatCostEstimate, formatCount, formatDayShort, formatPercent, @@ -24,6 +26,11 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +export function formatUsageMetricLabel(metric: UsageChartMetric, unpricedShare: number): string { + if (metric === "tokens") return "Processed tokens"; + return unpricedShare > 0 ? "Raw token cost (partial)" : "Raw token cost"; +} + export function UsagePage() { const [windowDays, setWindowDays] = useState(30); const [metric, setMetric] = useState("cost"); @@ -61,6 +68,7 @@ export function UsagePage() { const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + const costEstimate = formatCostEstimate(merged.costUsd, merged.costQuality.unpricedShare); return ( @@ -137,58 +145,40 @@ export function UsagePage() {
- {metric === "cost" ? "Raw token cost" : "Processed tokens"} + {formatUsageMetricLabel(metric, merged.costQuality.unpricedShare)} {metric === "cost" - ? `${formatUsd(merged.costUsd)}*` + ? costEstimate.value === "—" + ? costEstimate.value + : `${costEstimate.value}*` : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" + ? costEstimate.value === "—" + ? costEstimate.detail + : `* ${costEstimate.detail}` : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
- {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; - return ( -
-
- - - {PROVIDER_LABEL[provider.provider]} - - - {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} - -
-
-
-
- - {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} - -
- ); - })} +

- Daily {metric === "tokens" ? "processed tokens" : "cost"} + Daily{" "} + {metric === "tokens" + ? "processed tokens" + : merged.costQuality.unpricedShare > 0 + ? "cost (partial)" + : "cost"}

@@ -269,94 +259,12 @@ export function UsagePage() {
- {breakdown === "model" ? ( - - - - - - - - - - - {merged.models.length === 0 ? ( - - - - ) : ( - merged.models.map((model) => ( - - - - - - - )) - )} - -
ModelCostShareTokens
- No activity in this window. -
- - - {model.model} - - - {formatUsd(model.costUsd)} - - {formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
- ) : ( - - - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - - - - - {recentDays.length === 0 ? ( - - - - ) : ( - recentDays.map((day) => ( - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - - - )) - )} - -
Day - {PROVIDER_LABEL[provider]} - TotalTokens
- No activity in this window. -
{formatDayShort(day.day)} - {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} - - {formatUsd(day.costUsd)} - - {formatTokens(day.totalTokens)} -
- )} + )} @@ -365,6 +273,190 @@ export function UsagePage() { ); } +/** Provider totals and share bars for the selected chart metric. */ +export function UsageProviderRows({ + providers, + metric, + totalUnpricedShare, +}: { + readonly providers: readonly ProviderTotals[]; + readonly metric: UsageChartMetric; + readonly totalUnpricedShare: number; +}) { + return providers.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + const providerCost = formatCostEstimate(provider.costUsd, provider.unpricedShare); + const costShareLabel = totalUnpricedShare > 0 ? "priced cost" : "cost"; + const costShareText = + provider.unpricedShare >= 1 + ? "No priced-cost share" + : `${formatPercent(share)} of ${costShareLabel}`; + return ( +
+
+ + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" ? providerCost.value : formatTokens(provider.totalTokens)} + +
+
+
+
+ + {metric === "cost" + ? `${costShareText}${provider.unpricedShare > 0 ? ` · ${formatPercent(provider.unpricedShare)} unpriced` : ""} · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${providerCost.value}`} + +
+ ); + }); +} + +const COST_GUIDANCE_ID = "usage-cost-guidance"; + +/** Model/day details with a shared, screen-reader-addressable pricing guide. */ +export function UsageBreakdownTable({ + breakdown, + models, + recentDays, + totalUnpricedShare, +}: { + readonly breakdown: "model" | "day"; + readonly models: readonly ModelTotals[]; + readonly recentDays: readonly DailyTotals[]; + readonly totalUnpricedShare: number; +}) { + const hasUnpriced = totalUnpricedShare > 0; + const describedBy = hasUnpriced ? COST_GUIDANCE_ID : undefined; + + return ( + <> + {hasUnpriced ? ( +

+ Cost guide: ≥ means a lower-bound estimate from matching API rates; — means no matching + rate. Token totals remain complete. +

+ ) : null} + + {breakdown === "model" ? ( + + + + + + + + + + + {models.length === 0 ? ( + + + + ) : ( + models.map((model) => { + const modelCost = formatCostEstimate(model.costUsd, model.unpricedShare); + return ( + + + + + + + ); + }) + )} + +
ModelCost + {hasUnpriced ? "Share of priced cost" : "Share"} + Tokens
+ No activity in this window. +
+ + + {model.model} + + + {modelCost.value} + + {model.unpricedShare >= 1 ? "—" : formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)} +
+ ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + + + ) : ( + recentDays.map((day) => ( + + + {PROVIDER_ORDER.map((provider) => { + const providerDay = day.byProvider.get(provider); + return ( + + ); + })} + + + + )) + )} + +
Day + {PROVIDER_LABEL[provider]} + TotalTokens
+ No activity in this window. +
{formatDayShort(day.day)} + {providerDay === undefined ? ( + — + ) : ( + + )} + + + + {formatTokens(day.totalTokens)} +
+ )} + + ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, @@ -395,6 +487,17 @@ function Metric({ ); } +function CostAmount({ + costUsd, + unpricedShare, +}: { + readonly costUsd: number; + readonly unpricedShare: number; +}) { + const estimate = formatCostEstimate(costUsd, unpricedShare); + return {estimate.value}; +} + /** * Says plainly when the totals are incomplete: an environment that failed, or * one whose transcripts another environment already reported. Environments diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index a36cd1e88330..ba776a29c1bb 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildDayColumns, niceScale } from "./UsageProviderChart"; +import { buildDayColumns, formatChartValue, niceScale } from "./UsageProviderChart"; describe("niceScale", () => { it("never puts the peak above the top of the scale", () => { @@ -49,9 +49,10 @@ describe("buildDayColumns", () => { day: "2026-08-01", costUsd: 30, totalTokens: 300, + unpricedShare: 0.5, byProvider: new Map([ - ["codex" as const, { costUsd: 10, totalTokens: 100 }], - ["claude" as const, { costUsd: 20, totalTokens: 200 }], + ["codex" as const, { costUsd: 10, totalTokens: 100, unpricedShare: 1 }], + ["claude" as const, { costUsd: 20, totalTokens: 200, unpricedShare: 0.25 }], ]), }, ], @@ -62,7 +63,10 @@ describe("buildDayColumns", () => { day: "2026-08-03", costUsd: 5, totalTokens: 50, - byProvider: new Map([["claude" as const, { costUsd: 5, totalTokens: 50 }]]), + unpricedShare: 0, + byProvider: new Map([ + ["claude" as const, { costUsd: 5, totalTokens: 50, unpricedShare: 0 }], + ]), }, ], ]); @@ -77,14 +81,45 @@ describe("buildDayColumns", () => { ]); }); + it("retains pricing coverage for cost hover values", () => { + const [first] = buildDayColumns(days, byDay, "cost"); + + expect(first).toMatchObject({ + unpricedShare: 0.5, + bands: [ + { provider: "codex", unpricedShare: 1 }, + { provider: "claude", unpricedShare: 0.25 }, + ], + }); + }); + + it("retains whether sparse provider-day columns have activity", () => { + const columns = buildDayColumns(days, byDay, "cost"); + + expect(columns[1]).toMatchObject({ + hasActivity: false, + bands: [ + { provider: "codex", hasActivity: false }, + { provider: "claude", hasActivity: false }, + ], + }); + expect(columns[2]).toMatchObject({ + hasActivity: true, + bands: [ + { provider: "codex", hasActivity: false }, + { provider: "claude", hasActivity: true }, + ], + }); + }); + it("keeps band values absolute rather than cumulative", () => { // Regression: the bands were once stack offsets, which drew Claude Code // permanently above Codex regardless of which provider spent more. const [first] = buildDayColumns(days, byDay, "cost"); expect(first?.bands).toEqual([ - { provider: "codex", value: 10 }, - { provider: "claude", value: 20 }, + { provider: "codex", value: 10, unpricedShare: 1, hasActivity: true }, + { provider: "claude", value: 20, unpricedShare: 0.25, hasActivity: true }, ]); }); @@ -95,3 +130,20 @@ describe("buildDayColumns", () => { } }); }); + +describe("formatChartValue", () => { + it("labels an inactive provider-day as no activity rather than priced zero", () => { + expect(formatChartValue("cost", 0, 0, false)).toBe("—"); + expect(formatChartValue("cost", 0, 0, true)).toBe("$0.00"); + }); + + it("does not show missing or partial cost rates as exact hover values", () => { + expect(formatChartValue("cost", 0, 1, true)).toBe("—"); + expect(formatChartValue("cost", 20, 0.25, true)).toBe("≥$20.00"); + expect(formatChartValue("cost", 20, 0, true)).toBe("$20.00"); + }); + + it("keeps token hover values independent of pricing coverage", () => { + expect(formatChartValue("tokens", 20_000, 1, true)).toBe("20K"); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 8f31d348eb04..99ef673bfe65 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -2,7 +2,12 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useCallback, useMemo, useRef, useState } from "react"; import type { DailyTotals } from "@t3tools/shared/usageMerge"; -import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; +import { + formatCostEstimate, + formatDayShort, + formatTokens, + formatUsd, +} from "@t3tools/shared/usageFormat"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const VIEW_WIDTH = 960; @@ -12,6 +17,17 @@ const PLOT_TOP = 8; export type UsageChartMetric = "tokens" | "cost"; +/** Formats a plotted value with the pricing coverage carried by its day column. */ +export function formatChartValue( + metric: UsageChartMetric, + value: number, + unpricedShare: number, + hasActivity: boolean, +): string { + if (metric === "cost" && !hasActivity) return "—"; + return metric === "tokens" ? formatTokens(value) : formatCostEstimate(value, unpricedShare).value; +} + interface UsageProviderChartProps { readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; @@ -23,8 +39,12 @@ export interface DayColumn { readonly bands: readonly { readonly provider: UsageProviderKind; readonly value: number; + readonly unpricedShare: number; + readonly hasActivity: boolean; }[]; readonly total: number; + readonly unpricedShare: number; + readonly hasActivity: boolean; } interface Point { @@ -172,8 +192,15 @@ export function buildDayColumns( const bands = PROVIDER_ORDER.map((provider) => ({ provider, value: valueFor(entry, provider, metric), + unpricedShare: entry?.byProvider.get(provider)?.unpricedShare ?? 0, + hasActivity: entry?.byProvider.has(provider) ?? false, })); - return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; + return { + bands, + total: bands.reduce((sum, band) => sum + band.value, 0), + unpricedShare: entry?.unpricedShare ?? 0, + hasActivity: entry !== undefined, + }; }); } @@ -336,6 +363,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
{formatDayShort(hoveredDay)}
{PROVIDER_ORDER.map((provider) => { const Mark = PROVIDER_MARK[provider]; + const band = hoveredColumn?.bands.find((entry) => entry.provider === provider); return (
@@ -343,8 +371,11 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr {PROVIDER_LABEL[provider]} - {format( - hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, + {formatChartValue( + metric, + band?.value ?? 0, + band?.unpricedShare ?? 0, + band?.hasActivity ?? false, )}
@@ -353,7 +384,12 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
Total - {format(hoveredColumn?.total ?? 0)} + {formatChartValue( + metric, + hoveredColumn?.total ?? 0, + hoveredColumn?.unpricedShare ?? 0, + hoveredColumn?.hasActivity ?? false, + )}
diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts new file mode 100644 index 000000000000..4fb0f88ff0c0 --- /dev/null +++ b/packages/shared/src/usageFormat.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatCostEstimate } from "./usageFormat.js"; + +describe("formatCostEstimate", () => { + it("does not present fully unpriced usage as zero cost", () => { + expect(formatCostEstimate(0, 1)).toEqual({ + value: "—", + detail: "No matching API rates; token totals are still complete.", + }); + }); + + it("marks a mixed priced and unpriced total as a lower bound", () => { + expect(formatCostEstimate(12.34, 0.25)).toEqual({ + value: "≥$12.34", + detail: "Partial API-rate estimate; 25.0% of requests have no matching rate.", + }); + }); + + it("keeps the existing API-rate label when every request is priced", () => { + expect(formatCostEstimate(12.34, 0)).toEqual({ + value: "$12.34", + detail: "If billed at full API rate.", + }); + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 21e11fee743d..2a8651c7a352 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -19,6 +19,29 @@ export function formatUsd(value: number): string { return CURRENCY.format(value); } +/** Marks incomplete API-rate estimates without making missing prices look free. */ +export function formatCostEstimate( + costUsd: number, + unpricedShare: number, +): { readonly value: string; readonly detail: string } { + if (unpricedShare >= 1) { + return { + value: "—", + detail: "No matching API rates; token totals are still complete.", + }; + } + if (unpricedShare > 0) { + return { + value: `≥${formatUsd(costUsd)}`, + detail: `Partial API-rate estimate; ${formatPercent(unpricedShare)} of requests have no matching rate.`, + }; + } + return { + value: formatUsd(costUsd), + detail: "If billed at full API rate.", + }; +} + 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 3c377a33f97e..93305333134d 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -187,6 +187,12 @@ describe("mergeUsage", () => { expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5); expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5); expect(merged.costQuality.cacheSavingsUsd).toBe(4); + expect(merged.providers.find((provider) => provider.provider === "codex")?.unpricedShare).toBe( + 1, + ); + expect(merged.models.find((model) => model.model === "gpt-5.6-sol")?.unpricedShare).toBe(1); + expect(merged.daily[0]?.unpricedShare).toBeCloseTo(0.5, 5); + expect(merged.daily[0]?.byProvider.get("codex")?.unpricedShare).toBe(1); }); it("keeps two machines apart when hostname and home path collide", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index fd73c0a31c02..51c0687d5c67 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -27,6 +27,7 @@ export interface ProviderTotals { readonly records: number; readonly costShare: number; readonly tokenShare: number; + readonly unpricedShare: number; } export interface ModelTotals { @@ -36,13 +37,18 @@ export interface ModelTotals { readonly totalTokens: number; readonly records: number; readonly costShare: number; + readonly unpricedShare: number; } export interface DailyTotals { readonly day: string; readonly costUsd: number; readonly totalTokens: number; - readonly byProvider: ReadonlyMap; + readonly unpricedShare: number; + readonly byProvider: ReadonlyMap< + UsageProviderKind, + { readonly costUsd: number; readonly totalTokens: number; readonly unpricedShare: number } + >; } export interface CostQuality { @@ -218,18 +224,29 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number } + { costUsd: number; totalTokens: number; records: number; unpricedRecords: number } >(); 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, { costUsd: number; totalTokens: number; - byProvider: Map; + records: number; + unpricedRecords: number; + byProvider: Map< + UsageProviderKind, + { costUsd: number; totalTokens: number; records: number; unpricedRecords: number } + >; } >(); const contributingEnvironments: EnvironmentId[] = []; @@ -260,10 +277,12 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, + unpricedRecords: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; provider.records += bucket.records; + provider.unpricedRecords += bucket.unpricedRecords; providerAccumulator.set(bucket.provider, provider); const modelKey = `${bucket.provider} ${bucket.model}`; @@ -272,22 +291,38 @@ 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) ?? { costUsd: 0, totalTokens: 0, - byProvider: new Map(), + records: 0, + unpricedRecords: 0, + byProvider: new Map< + UsageProviderKind, + { costUsd: number; totalTokens: number; records: number; unpricedRecords: number } + >(), }; day.costUsd += bucket.costUsd; day.totalTokens += tokens; - const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; + day.records += bucket.records; + day.unpricedRecords += bucket.unpricedRecords; + const dayProvider = day.byProvider.get(bucket.provider) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + unpricedRecords: 0, + }; dayProvider.costUsd += bucket.costUsd; dayProvider.totalTokens += tokens; + dayProvider.records += bucket.records; + dayProvider.unpricedRecords += bucket.unpricedRecords; day.byProvider.set(bucket.provider, dayProvider); dailyAccumulator.set(bucket.day, day); } @@ -303,6 +338,7 @@ export function mergeUsage( records: totals.records, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, + unpricedShare: totals.records === 0 ? 0 : totals.unpricedRecords / totals.records, })) .sort((a, b) => b.costUsd - a.costUsd); @@ -314,6 +350,7 @@ export function mergeUsage( totalTokens: totals.totalTokens, records: totals.records, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + unpricedShare: totals.records === 0 ? 0 : totals.unpricedRecords / totals.records, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); @@ -322,7 +359,20 @@ export function mergeUsage( day, costUsd: totals.costUsd, totalTokens: totals.totalTokens, - byProvider: totals.byProvider, + unpricedShare: totals.records === 0 ? 0 : totals.unpricedRecords / totals.records, + byProvider: new Map( + [...totals.byProvider].map(([provider, providerTotals]) => [ + provider, + { + costUsd: providerTotals.costUsd, + totalTokens: providerTotals.totalTokens, + unpricedShare: + providerTotals.records === 0 + ? 0 + : providerTotals.unpricedRecords / providerTotals.records, + }, + ]), + ), })) .sort((a, b) => a.day.localeCompare(b.day));