| 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 | 2 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 3 | import { |
| 4 | 4 | isCompatibleUsageContractVersion, |
| 5 | isModelCostUnknown, | |
| 5 | 6 | type DailyTotals, |
| 6 | 7 | type MergedUsage, |
| 7 | 8 | } from "@t3tools/shared/usageMerge"; |
| ... | ... | @@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { |
| 607 | 608 | {model.model} |
| 608 | 609 | </Text> |
| 609 | 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 | 614 | </Text> |
| 612 | 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 | 619 | </View> |
| 615 | 620 | ))} |
| 616 | 621 | </SettingsSection> |
apps/web/src/components/usage/UsagePage.test.tsx+24| ... | ... | @@ -87,6 +87,7 @@ const modelTotals = Object.freeze([ |
| 87 | 87 | costUsd: 10, |
| 88 | 88 | totalTokens: 100, |
| 89 | 89 | records: 1, |
| 90 | unpricedRecords: 0, | |
| 90 | 91 | costShare: 10 / 16, |
| 91 | 92 | }, |
| 92 | 93 | { |
| ... | ... | @@ -95,6 +96,7 @@ const modelTotals = Object.freeze([ |
| 95 | 96 | costUsd: 5, |
| 96 | 97 | totalTokens: 1_000, |
| 97 | 98 | records: 1, |
| 99 | unpricedRecords: 0, | |
| 98 | 100 | costShare: 5 / 16, |
| 99 | 101 | }, |
| 100 | 102 | { |
| ... | ... | @@ -103,8 +105,18 @@ const modelTotals = Object.freeze([ |
| 103 | 105 | costUsd: 1, |
| 104 | 106 | totalTokens: 1_000, |
| 105 | 107 | records: 1, |
| 108 | unpricedRecords: 0, | |
| 106 | 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 | 122 | const environments = [ |
| ... | ... | @@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => { |
| 190 | 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 | 216 | it("sorts models by token usage when the token metric is selected", () => { |
| 194 | 217 | testState.metric = "tokens"; |
| 195 | 218 | testState.breakdown = "model"; |
| ... | ... | @@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => { |
| 202 | 225 | "expensive-model", |
| 203 | 226 | "token-heavy-model", |
| 204 | 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 | 15 | |
| 16 | 16 | import { |
| 17 | 17 | isCompatibleUsageContractVersion, |
| 18 | isModelCostUnknown, | |
| 18 | 19 | type DailyTotals, |
| 19 | 20 | type HourlyTotals, |
| 20 | 21 | } from "@t3tools/shared/usageMerge"; |
| ... | ... | @@ -363,9 +364,13 @@ export function UsagePage() { |
| 363 | 364 | : formatTokens(merged.totalTokens)} |
| 364 | 365 | </span> |
| 365 | 366 | <span className="text-xs text-muted-foreground"> |
| 366 | {metric === "cost" | |
| 367 | ? `${formatCount(merged.sessions)} sessions · API estimate` | |
| 368 | : `${formatCount(merged.sessions)} sessions`} | |
| 367 | {metric !== "cost" | |
| 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 | 374 | </span> |
| 370 | 375 | </div> |
| 371 | 376 | |
| ... | ... | @@ -511,10 +516,14 @@ export function UsagePage() { |
| 511 | 516 | </span> |
| 512 | 517 | </td> |
| 513 | 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 | 524 | </td> |
| 516 | 525 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 517 | {formatPercent(model.costShare)} | |
| 526 | {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} | |
| 518 | 527 | </td> |
| 519 | 528 | <td className="py-2 text-right text-muted-foreground tabular-nums"> |
| 520 | 529 | {formatTokens(model.totalTokens)} |
packages/shared/src/usageMerge.test.ts+33-1| ... | ... | @@ -8,7 +8,7 @@ import { |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 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 | 13 | function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket { |
| 14 | 14 | return { |
| ... | ... | @@ -221,6 +221,38 @@ describe("mergeUsage", () => { |
| 221 | 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 | 256 | it("keeps two machines apart when hostname and home path collide", () => { |
| 225 | 257 | // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make |
| 226 | 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 | 37 | readonly costUsd: number; |
| 38 | 38 | readonly totalTokens: number; |
| 39 | 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 | 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 | 56 | export interface DailyTotals { |
| 44 | 57 | readonly day: string; |
| 45 | 58 | readonly costUsd: number; |
| ... | ... | @@ -249,7 +262,13 @@ export function mergeUsage( |
| 249 | 262 | >(); |
| 250 | 263 | const modelAccumulator = new Map< |
| 251 | 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 | 273 | const dailyAccumulator = new Map< |
| 255 | 274 | string, |
| ... | ... | @@ -319,10 +338,12 @@ export function mergeUsage( |
| 319 | 338 | costUsd: 0, |
| 320 | 339 | totalTokens: 0, |
| 321 | 340 | records: 0, |
| 341 | unpricedRecords: 0, | |
| 322 | 342 | }; |
| 323 | 343 | model.costUsd += bucket.costUsd; |
| 324 | 344 | model.totalTokens += tokens; |
| 325 | 345 | model.records += bucket.records; |
| 346 | model.unpricedRecords += bucket.unpricedRecords; | |
| 326 | 347 | modelAccumulator.set(modelKey, model); |
| 327 | 348 | |
| 328 | 349 | const day = dailyAccumulator.get(bucket.day) ?? { |
| ... | ... | @@ -381,6 +402,7 @@ export function mergeUsage( |
| 381 | 402 | costUsd: totals.costUsd, |
| 382 | 403 | totalTokens: totals.totalTokens, |
| 383 | 404 | records: totals.records, |
| 405 | unpricedRecords: totals.unpricedRecords, | |
| 384 | 406 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 385 | 407 | })) |
| 386 | 408 | .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); |