| author | |
| committer | |
| log | 0c8112013760617230d382e55447ddb75627a997 |
| tree | 60eccd6ca3e081b7f738a1ddb189d3ee8837f4f0 |
| parent | 9efb01690065923820dfa889af5745cc34750091 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 106 insertions(+), 10 deletions(-)
apps/mobile/src/features/usage/UsageRouteScreen.tsx+24-8| ... | ... | @@ -361,7 +361,7 @@ export function UsageRouteScreen() { |
| 361 | 361 | /> |
| 362 | 362 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> |
| 363 | 363 | <CostSection merged={merged} /> |
| 364 | <ModelsSection merged={merged} /> | |
| 364 | <ModelsSection merged={merged} metric={metric} /> | |
| 365 | 365 | </> |
| 366 | 366 | )} |
| 367 | 367 | </> |
| ... | ... | @@ -781,14 +781,22 @@ function MetricCell(props: { |
| 781 | 781 | ); |
| 782 | 782 | } |
| 783 | 783 | |
| 784 | function ModelsSection(props: { readonly merged: MergedUsage }) { | |
| 785 | const { merged } = props; | |
| 784 | function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { | |
| 785 | const { merged, metric } = props; | |
| 786 | 786 | const colors = useProviderColors(); |
| 787 | 787 | if (merged.models.length === 0) return null; |
| 788 | 788 | |
| 789 | // Ranked like the provider rows. .sort() on a copy, not .toSorted(): Hermes | |
| 790 | // doesn't ship the ES2023 method. | |
| 791 | const ordered = [...merged.models].sort((a, b) => | |
| 792 | metric === "cost" | |
| 793 | ? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens | |
| 794 | : b.totalTokens - a.totalTokens || b.costUsd - a.costUsd, | |
| 795 | ); | |
| 796 | ||
| 789 | 797 | return ( |
| 790 | 798 | <SettingsSection title="By model"> |
| 791 | {merged.models.map((model, index) => ( | |
| 799 | {ordered.map((model, index) => ( | |
| 792 | 800 | <View |
| 793 | 801 | key={`${model.provider}:${model.model}`} |
| 794 | 802 | className={ |
| ... | ... | @@ -806,13 +814,21 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { |
| 806 | 814 | {model.model} |
| 807 | 815 | </Text> |
| 808 | 816 | <Text className="text-sm text-foreground-muted"> |
| 809 | {isModelCostUnknown(model) | |
| 810 | ? `no known rates · ${formatTokens(model.totalTokens)} tokens` | |
| 811 | : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} | |
| 817 | {metric === "tokens" | |
| 818 | ? `${formatPercent(model.tokenShare)} of tokens · ${ | |
| 819 | isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) | |
| 820 | }` | |
| 821 | : isModelCostUnknown(model) | |
| 822 | ? `no known rates · ${formatTokens(model.totalTokens)} tokens` | |
| 823 | : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} | |
| 812 | 824 | </Text> |
| 813 | 825 | </View> |
| 814 | 826 | <Text className="text-base tabular-nums text-foreground"> |
| 815 | {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} | |
| 827 | {metric === "tokens" | |
| 828 | ? formatTokens(model.totalTokens) | |
| 829 | : isModelCostUnknown(model) | |
| 830 | ? "Unpriced" | |
| 831 | : formatUsd(model.costUsd)} | |
| 816 | 832 | </Text> |
| 817 | 833 | </View> |
| 818 | 834 | ))} |
apps/web/src/components/usage/UsagePage.tsx+6-1| ... | ... | @@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; |
| 82 | 82 | import { UsageShareBar } from "./UsageShareBar"; |
| 83 | 83 | import { |
| 84 | 84 | costTypeSegments, |
| 85 | modelShare, | |
| 85 | 86 | sortModelsByTokens, |
| 86 | 87 | speedCostSegments, |
| 87 | 88 | tokenTypeSegments, |
| ... | ... | @@ -718,6 +719,10 @@ export function UsagePage() { |
| 718 | 719 | breakdownModels.map((model, index) => { |
| 719 | 720 | const key = `${model.provider}:${model.model}`; |
| 720 | 721 | const value = metric === "tokens" ? model.totalTokens : model.costUsd; |
| 722 | const share = modelShare( | |
| 723 | model, | |
| 724 | metric === "tokens" ? "tokens" : "cost", | |
| 725 | ); | |
| 721 | 726 | return ( |
| 722 | 727 | <tr |
| 723 | 728 | key={key} |
| ... | ... | @@ -758,7 +763,7 @@ export function UsagePage() { |
| 758 | 763 | )} |
| 759 | 764 | </td> |
| 760 | 765 | <td className="hidden py-2.5 pl-6 sm:table-cell"> |
| 761 | {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)} | |
| 766 | {share === null ? "" : formatPercent(share)} | |
| 762 | 767 | </td> |
| 763 | 768 | <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td> |
| 764 | 769 | </tr> |
apps/web/src/components/usage/usageBreakdown.test.ts+27-1| ... | ... | @@ -1,7 +1,12 @@ |
| 1 | 1 | import type { ModelTotals } from "@t3tools/shared/usageMerge"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown"; | |
| 4 | import { | |
| 5 | cacheHitRate, | |
| 6 | costPerMillionTokens, | |
| 7 | modelShare, | |
| 8 | sortModelsByTokens, | |
| 9 | } from "./usageBreakdown"; | |
| 5 | 10 | |
| 6 | 11 | const model = ( |
| 7 | 12 | name: string, |
| ... | ... | @@ -24,6 +29,7 @@ const model = ( |
| 24 | 29 | unpricedRecords: 0, |
| 25 | 30 | unpricedTokens: 0, |
| 26 | 31 | costShare: 0, |
| 32 | tokenShare: 0, | |
| 27 | 33 | ...overrides, |
| 28 | 34 | }); |
| 29 | 35 | |
| ... | ... | @@ -44,6 +50,26 @@ describe("sortModelsByTokens", () => { |
| 44 | 50 | }); |
| 45 | 51 | }); |
| 46 | 52 | |
| 53 | describe("modelShare", () => { | |
| 54 | it("follows the selected metric", () => { | |
| 55 | const priced = model("priced", 100, 9, { costShare: 0.9, tokenShare: 0.25 }); | |
| 56 | ||
| 57 | expect(modelShare(priced, "cost")).toBe(0.9); | |
| 58 | expect(modelShare(priced, "tokens")).toBe(0.25); | |
| 59 | }); | |
| 60 | ||
| 61 | it("has no cost share for an unknown cost but keeps its token share", () => { | |
| 62 | const unpriced = model("unpriced", 300, 0, { | |
| 63 | unpricedRecords: 1, | |
| 64 | unpricedTokens: 300, | |
| 65 | tokenShare: 0.75, | |
| 66 | }); | |
| 67 | ||
| 68 | expect(modelShare(unpriced, "cost")).toBeNull(); | |
| 69 | expect(modelShare(unpriced, "tokens")).toBe(0.75); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 47 | 73 | describe("model rates", () => { |
| 48 | 74 | it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { |
| 49 | 75 | const mixed = model("mixed", 4_000_000, 6, { |
apps/web/src/components/usage/usageBreakdown.ts+9| ... | ... | @@ -14,6 +14,15 @@ export function sortModelsByTokens(models: readonly ModelTotals[]) { |
| 14 | 14 | ); |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | /** | |
| 18 | * A model's share of the selected metric, or `null` for a cost share of an | |
| 19 | * unknown cost. An unpriced model still has a real token share. | |
| 20 | */ | |
| 21 | export function modelShare(model: ModelTotals, metric: "cost" | "tokens"): number | null { | |
| 22 | if (metric === "tokens") return model.tokenShare; | |
| 23 | return isModelCostUnknown(model) ? null : model.costShare; | |
| 24 | } | |
| 25 | ||
| 17 | 26 | /** |
| 18 | 27 | * Share of a model's input read from cache, or `null` without input. Cache |
| 19 | 28 | * writes count as misses: that input was processed in full. |
packages/shared/src/usageMerge.test.ts+38| ... | ... | @@ -514,6 +514,44 @@ describe("mergeUsage", () => { |
| 514 | 514 | expect(merged.costQuality.cacheSavingsUsd).toBe(4); |
| 515 | 515 | }); |
| 516 | 516 | |
| 517 | it("derives model token shares independently of their cost shares", () => { | |
| 518 | const merged = mergeUsage( | |
| 519 | [ | |
| 520 | environment( | |
| 521 | "env-a", | |
| 522 | summary( | |
| 523 | [ | |
| 524 | bucket({ costUsd: 90 }), | |
| 525 | bucket({ | |
| 526 | provider: "codex", | |
| 527 | model: "gpt-5.6-sol", | |
| 528 | costUsd: 10, | |
| 529 | totals: { | |
| 530 | uncachedInputTokens: 3 * 1160, | |
| 531 | cachedInputTokens: 0, | |
| 532 | cacheCreationTokens: 0, | |
| 533 | outputTokens: 0, | |
| 534 | reasoningTokens: 0, | |
| 535 | }, | |
| 536 | }), | |
| 537 | ], | |
| 538 | [ | |
| 539 | { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, | |
| 540 | { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, | |
| 541 | ], | |
| 542 | ), | |
| 543 | ), | |
| 544 | ], | |
| 545 | USAGE_CONTRACT_VERSION, | |
| 546 | ); | |
| 547 | ||
| 548 | const byModel = Object.fromEntries(merged.models.map((model) => [model.model, model])); | |
| 549 | expect(byModel["claude-fable-5"]?.costShare).toBeCloseTo(0.9, 5); | |
| 550 | expect(byModel["claude-fable-5"]?.tokenShare).toBeCloseTo(0.25, 5); | |
| 551 | expect(byModel["gpt-5.6-sol"]?.costShare).toBeCloseTo(0.1, 5); | |
| 552 | expect(byModel["gpt-5.6-sol"]?.tokenShare).toBeCloseTo(0.75, 5); | |
| 553 | }); | |
| 554 | ||
| 517 | 555 | it("marks a model with no known rates as unpriced rather than free", () => { |
| 518 | 556 | const merged = mergeUsage( |
| 519 | 557 | [ |
packages/shared/src/usageMerge.ts+2| ... | ... | @@ -51,6 +51,7 @@ export interface ModelTotals { |
| 51 | 51 | */ |
| 52 | 52 | readonly unpricedTokens: number; |
| 53 | 53 | readonly costShare: number; |
| 54 | readonly tokenShare: number; | |
| 54 | 55 | } |
| 55 | 56 | |
| 56 | 57 | /** |
| ... | ... | @@ -583,6 +584,7 @@ export function mergeUsage( |
| 583 | 584 | unpricedRecords: totals.unpricedRecords, |
| 584 | 585 | unpricedTokens: totals.unpricedTokens, |
| 585 | 586 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 587 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, | |
| 586 | 588 | })) |
| 587 | 589 | .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); |
| 588 | 590 |