| author | |
| committer | |
| log | 48654c1182c41dbd5e99ef0d6620a13ba2400f26 |
| tree | a3d381eb33560d96ece0c3a8240553ba854a78c4 |
| parent | 27eb79dc719d70a99db495b0cb20bd6e3ac10b8d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>5 files changed, 101 insertions(+), 9 deletions(-)
apps/mobile/src/features/usage/UsageRouteScreen.tsx+7-2| ... | @@ -2,6 +2,7 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | ... | @@ -2,6 +2,7 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | 2 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import { | 3 | import { |
| 4 | isCompatibleUsageContractVersion, | 4 | isCompatibleUsageContractVersion, |
| 5 | isModelCostUnknown, | ||
| 5 | type DailyTotals, | 6 | type DailyTotals, |
| 6 | type MergedUsage, | 7 | type MergedUsage, |
| 7 | } from "@t3tools/shared/usageMerge"; | 8 | } from "@t3tools/shared/usageMerge"; |
| ... | @@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { | ... | @@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { |
| 607 | {model.model} | 608 | {model.model} |
| 608 | </Text> | 609 | </Text> |
| 609 | <Text className="text-sm text-foreground-muted"> | 610 | <Text className="text-sm text-foreground-muted"> |
| 610 | {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens | 611 | {isModelCostUnknown(model) |
| 612 | ? `no known rates · ${formatTokens(model.totalTokens)} tokens` | ||
| 613 | : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} | ||
| 611 | </Text> | 614 | </Text> |
| 612 | </View> | 615 | </View> |
| 613 | <Text className="text-base tabular-nums text-foreground">{formatUsd(model.costUsd)}</Text> | 616 | <Text className="text-base tabular-nums text-foreground"> |
| 617 | {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} | ||
| 618 | </Text> | ||
| 614 | </View> | 619 | </View> |
| 615 | ))} | 620 | ))} |
| 616 | </SettingsSection> | 621 | </SettingsSection> |
apps/web/src/components/usage/UsagePage.test.tsx+24| ... | @@ -87,6 +87,7 @@ const modelTotals = Object.freeze([ | ... | @@ -87,6 +87,7 @@ const modelTotals = Object.freeze([ |
| 87 | costUsd: 10, | 87 | costUsd: 10, |
| 88 | totalTokens: 100, | 88 | totalTokens: 100, |
| 89 | records: 1, | 89 | records: 1, |
| 90 | unpricedRecords: 0, | ||
| 90 | costShare: 10 / 16, | 91 | costShare: 10 / 16, |
| 91 | }, | 92 | }, |
| 92 | { | 93 | { |
| ... | @@ -95,6 +96,7 @@ const modelTotals = Object.freeze([ | ... | @@ -95,6 +96,7 @@ const modelTotals = Object.freeze([ |
| 95 | costUsd: 5, | 96 | costUsd: 5, |
| 96 | totalTokens: 1_000, | 97 | totalTokens: 1_000, |
| 97 | records: 1, | 98 | records: 1, |
| 99 | unpricedRecords: 0, | ||
| 98 | costShare: 5 / 16, | 100 | costShare: 5 / 16, |
| 99 | }, | 101 | }, |
| 100 | { | 102 | { |
| ... | @@ -103,8 +105,18 @@ const modelTotals = Object.freeze([ | ... | @@ -103,8 +105,18 @@ const modelTotals = Object.freeze([ |
| 103 | costUsd: 1, | 105 | costUsd: 1, |
| 104 | totalTokens: 1_000, | 106 | totalTokens: 1_000, |
| 105 | records: 1, | 107 | records: 1, |
| 108 | unpricedRecords: 0, | ||
| 106 | costShare: 1 / 16, | 109 | costShare: 1 / 16, |
| 107 | }, | 110 | }, |
| 111 | { | ||
| 112 | model: "unpriced-model", | ||
| 113 | provider: "codex" as const, | ||
| 114 | costUsd: 0, | ||
| 115 | totalTokens: 500, | ||
| 116 | records: 2, | ||
| 117 | unpricedRecords: 2, | ||
| 118 | costShare: 0, | ||
| 119 | }, | ||
| 108 | ]); | 120 | ]); |
| 109 | 121 | ||
| 110 | const environments = [ | 122 | const environments = [ |
| ... | @@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => { | ... | @@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => { |
| 190 | expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); | 202 | expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); |
| 191 | }); | 203 | }); |
| 192 | 204 | ||
| 205 | it("flags a model with no known rates instead of showing it as free", () => { | ||
| 206 | testState.breakdown = "model"; | ||
| 207 | |||
| 208 | const markup = renderToStaticMarkup(<UsagePage />); | ||
| 209 | const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? ""; | ||
| 210 | const unpricedRow = body.split("<tr").find((row) => row.includes("unpriced-model")) ?? ""; | ||
| 211 | |||
| 212 | expect(unpricedRow).toContain("Unpriced"); | ||
| 213 | expect(unpricedRow).not.toContain("$0.00"); | ||
| 214 | }); | ||
| 215 | |||
| 193 | it("sorts models by token usage when the token metric is selected", () => { | 216 | it("sorts models by token usage when the token metric is selected", () => { |
| 194 | testState.metric = "tokens"; | 217 | testState.metric = "tokens"; |
| 195 | testState.breakdown = "model"; | 218 | testState.breakdown = "model"; |
| ... | @@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => { | ... | @@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => { |
| 202 | "expensive-model", | 225 | "expensive-model", |
| 203 | "token-heavy-model", | 226 | "token-heavy-model", |
| 204 | "token-heavy-cheaper-model", | 227 | "token-heavy-cheaper-model", |
| 228 | "unpriced-model", | ||
| 205 | ]); | 229 | ]); |
| 206 | }); | 230 | }); |
| 207 | }); | 231 | }); |
apps/web/src/components/usage/UsagePage.tsx+14-5| ... | @@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react"; | ... | @@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react"; |
| 15 | 15 | ||
| 16 | import { | 16 | import { |
| 17 | isCompatibleUsageContractVersion, | 17 | isCompatibleUsageContractVersion, |
| 18 | isModelCostUnknown, | ||
| 18 | type DailyTotals, | 19 | type DailyTotals, |
| 19 | type HourlyTotals, | 20 | type HourlyTotals, |
| 20 | } from "@t3tools/shared/usageMerge"; | 21 | } from "@t3tools/shared/usageMerge"; |
| ... | @@ -363,9 +364,13 @@ export function UsagePage() { | ... | @@ -363,9 +364,13 @@ export function UsagePage() { |
| 363 | : formatTokens(merged.totalTokens)} | 364 | : formatTokens(merged.totalTokens)} |
| 364 | </span> | 365 | </span> |
| 365 | <span className="text-xs text-muted-foreground"> | 366 | <span className="text-xs text-muted-foreground"> |
| 366 | {metric === "cost" | 367 | {metric !== "cost" |
| 367 | ? `${formatCount(merged.sessions)} sessions · API estimate` | 368 | ? `${formatCount(merged.sessions)} sessions` |
| 368 | : `${formatCount(merged.sessions)} sessions`} | 369 | : merged.costQuality.unpricedShare > 0 |
| 370 | ? `${formatCount(merged.sessions)} sessions · API estimate excludes ${formatPercent( | ||
| 371 | merged.costQuality.unpricedShare, | ||
| 372 | )} unpriced records` | ||
| 373 | : `${formatCount(merged.sessions)} sessions · API estimate`} | ||
| 369 | </span> | 374 | </span> |
| 370 | </div> | 375 | </div> |
| 371 | 376 | ||
| ... | @@ -511,10 +516,14 @@ export function UsagePage() { | ... | @@ -511,10 +516,14 @@ export function UsagePage() { |
| 511 | </span> | 516 | </span> |
| 512 | </td> | 517 | </td> |
| 513 | <td className="py-2 text-right text-foreground tabular-nums"> | 518 | <td className="py-2 text-right text-foreground tabular-nums"> |
| 514 | {formatUsd(model.costUsd)} | 519 | {isModelCostUnknown(model) ? ( |
| 520 | <span className="text-muted-foreground">Unpriced</span> | ||
| 521 | ) : ( | ||
| 522 | formatUsd(model.costUsd) | ||
| 523 | )} | ||
| 515 | </td> | 524 | </td> |
| 516 | <td className="py-2 text-right text-muted-foreground tabular-nums"> | 525 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 517 | {formatPercent(model.costShare)} | 526 | {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} |
| 518 | </td> | 527 | </td> |
| 519 | <td className="py-2 text-right text-muted-foreground tabular-nums"> | 528 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 520 | {formatTokens(model.totalTokens)} | 529 | {formatTokens(model.totalTokens)} |
packages/shared/src/usageMerge.test.ts+33-1| ... | @@ -8,7 +8,7 @@ import { | ... | @@ -8,7 +8,7 @@ import { |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { describe, expect, it } from "vite-plus/test"; | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | ||
| 11 | import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; | 11 | import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; |
| 12 | 12 | ||
| 13 | function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket { | 13 | function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket { |
| 14 | return { | 14 | return { |
| ... | @@ -221,6 +221,38 @@ describe("mergeUsage", () => { | ... | @@ -221,6 +221,38 @@ describe("mergeUsage", () => { |
| 221 | expect(merged.costQuality.cacheSavingsUsd).toBe(4); | 221 | expect(merged.costQuality.cacheSavingsUsd).toBe(4); |
| 222 | }); | 222 | }); |
| 223 | 223 | ||
| 224 | it("marks a model with no known rates as unpriced rather than free", () => { | ||
| 225 | const merged = mergeUsage( | ||
| 226 | [ | ||
| 227 | environment( | ||
| 228 | "env-a", | ||
| 229 | summary( | ||
| 230 | [ | ||
| 231 | bucket({ costUsd: 75 }), | ||
| 232 | bucket({ | ||
| 233 | provider: "codex", | ||
| 234 | model: "unknown-model", | ||
| 235 | costUsd: 0, | ||
| 236 | costSource: "unpriced", | ||
| 237 | unpricedRecords: 5, | ||
| 238 | }), | ||
| 239 | ], | ||
| 240 | [ | ||
| 241 | { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, | ||
| 242 | { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, | ||
| 243 | ], | ||
| 244 | ), | ||
| 245 | ), | ||
| 246 | ], | ||
| 247 | USAGE_CONTRACT_VERSION, | ||
| 248 | ); | ||
| 249 | |||
| 250 | expect(merged.models.find((model) => model.model === "unknown-model")?.unpricedRecords).toBe(5); | ||
| 251 | expect(merged.models.filter(isModelCostUnknown).map((model) => model.model)).toEqual([ | ||
| 252 | "unknown-model", | ||
| 253 | ]); | ||
| 254 | }); | ||
| 255 | |||
| 224 | it("keeps two machines apart when hostname and home path collide", () => { | 256 | it("keeps two machines apart when hostname and home path collide", () => { |
| 225 | // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make | 257 | // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make |
| 226 | // one machine's usage vanish. Filesystem identity separates them. | 258 | // one machine's usage vanish. Filesystem identity separates them. |
packages/shared/src/usageMerge.ts+23-1| ... | @@ -37,9 +37,22 @@ export interface ModelTotals { | ... | @@ -37,9 +37,22 @@ export interface ModelTotals { |
| 37 | readonly costUsd: number; | 37 | readonly costUsd: number; |
| 38 | readonly totalTokens: number; | 38 | readonly totalTokens: number; |
| 39 | readonly records: number; | 39 | readonly records: number; |
| 40 | /** | ||
| 41 | * Records whose tokens are counted here but which contributed nothing to | ||
| 42 | * `costUsd`. When it equals `records` the cost is unknown, not zero. | ||
| 43 | */ | ||
| 44 | readonly unpricedRecords: number; | ||
| 40 | readonly costShare: number; | 45 | readonly costShare: number; |
| 41 | } | 46 | } |
| 42 | 47 | ||
| 48 | /** | ||
| 49 | * A model whose every record lacked rates has an unknown cost, not a zero one. | ||
| 50 | * Clients must not present its `costUsd` as a real dollar figure. | ||
| 51 | */ | ||
| 52 | export function isModelCostUnknown(model: ModelTotals): boolean { | ||
| 53 | return model.records > 0 && model.unpricedRecords >= model.records; | ||
| 54 | } | ||
| 55 | |||
| 43 | export interface DailyTotals { | 56 | export interface DailyTotals { |
| 44 | readonly day: string; | 57 | readonly day: string; |
| 45 | readonly costUsd: number; | 58 | readonly costUsd: number; |
| ... | @@ -249,7 +262,13 @@ export function mergeUsage( | ... | @@ -249,7 +262,13 @@ export function mergeUsage( |
| 249 | >(); | 262 | >(); |
| 250 | const modelAccumulator = new Map< | 263 | const modelAccumulator = new Map< |
| 251 | string, | 264 | string, |
| 252 | { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } | 265 | { |
| 266 | provider: UsageProviderKind; | ||
| 267 | costUsd: number; | ||
| 268 | totalTokens: number; | ||
| 269 | records: number; | ||
| 270 | unpricedRecords: number; | ||
| 271 | } | ||
| 253 | >(); | 272 | >(); |
| 254 | const dailyAccumulator = new Map< | 273 | const dailyAccumulator = new Map< |
| 255 | string, | 274 | string, |
| ... | @@ -319,10 +338,12 @@ export function mergeUsage( | ... | @@ -319,10 +338,12 @@ export function mergeUsage( |
| 319 | costUsd: 0, | 338 | costUsd: 0, |
| 320 | totalTokens: 0, | 339 | totalTokens: 0, |
| 321 | records: 0, | 340 | records: 0, |
| 341 | unpricedRecords: 0, | ||
| 322 | }; | 342 | }; |
| 323 | model.costUsd += bucket.costUsd; | 343 | model.costUsd += bucket.costUsd; |
| 324 | model.totalTokens += tokens; | 344 | model.totalTokens += tokens; |
| 325 | model.records += bucket.records; | 345 | model.records += bucket.records; |
| 346 | model.unpricedRecords += bucket.unpricedRecords; | ||
| 326 | modelAccumulator.set(modelKey, model); | 347 | modelAccumulator.set(modelKey, model); |
| 327 | 348 | ||
| 328 | const day = dailyAccumulator.get(bucket.day) ?? { | 349 | const day = dailyAccumulator.get(bucket.day) ?? { |
| ... | @@ -381,6 +402,7 @@ export function mergeUsage( | ... | @@ -381,6 +402,7 @@ export function mergeUsage( |
| 381 | costUsd: totals.costUsd, | 402 | costUsd: totals.costUsd, |
| 382 | totalTokens: totals.totalTokens, | 403 | totalTokens: totals.totalTokens, |
| 383 | records: totals.records, | 404 | records: totals.records, |
| 405 | unpricedRecords: totals.unpricedRecords, | ||
| 384 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, | 406 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 385 | })) | 407 | })) |
| 386 | .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); | 408 | .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); |