| 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,7 +361,7 @@ export function UsageRouteScreen() { |
| 361 | /> | 361 | /> |
| 362 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> | 362 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> |
| 363 | <CostSection merged={merged} /> | 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,14 +781,22 @@ function MetricCell(props: { |
| 781 | ); | 781 | ); |
| 782 | } | 782 | } |
| 783 | 783 | ||
| 784 | function ModelsSection(props: { readonly merged: MergedUsage }) { | 784 | function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { |
| 785 | const { merged } = props; | 785 | const { merged, metric } = props; |
| 786 | const colors = useProviderColors(); | 786 | const colors = useProviderColors(); |
| 787 | if (merged.models.length === 0) return null; | 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 | return ( | 797 | return ( |
| 790 | <SettingsSection title="By model"> | 798 | <SettingsSection title="By model"> |
| 791 | {merged.models.map((model, index) => ( | 799 | {ordered.map((model, index) => ( |
| 792 | <View | 800 | <View |
| 793 | key={`${model.provider}:${model.model}`} | 801 | key={`${model.provider}:${model.model}`} |
| 794 | className={ | 802 | className={ |
| ... | @@ -806,13 +814,21 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { | ... | @@ -806,13 +814,21 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { |
| 806 | {model.model} | 814 | {model.model} |
| 807 | </Text> | 815 | </Text> |
| 808 | <Text className="text-sm text-foreground-muted"> | 816 | <Text className="text-sm text-foreground-muted"> |
| 809 | {isModelCostUnknown(model) | 817 | {metric === "tokens" |
| 810 | ? `no known rates · ${formatTokens(model.totalTokens)} tokens` | 818 | ? `${formatPercent(model.tokenShare)} of tokens · ${ |
| 811 | : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} 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 | </Text> | 824 | </Text> |
| 813 | </View> | 825 | </View> |
| 814 | <Text className="text-base tabular-nums text-foreground"> | 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 | </Text> | 832 | </Text> |
| 817 | </View> | 833 | </View> |
| 818 | ))} | 834 | ))} |
apps/web/src/components/usage/UsagePage.tsx+6-1| ... | @@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; | ... | @@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; |
| 82 | import { UsageShareBar } from "./UsageShareBar"; | 82 | import { UsageShareBar } from "./UsageShareBar"; |
| 83 | import { | 83 | import { |
| 84 | costTypeSegments, | 84 | costTypeSegments, |
| 85 | modelShare, | ||
| 85 | sortModelsByTokens, | 86 | sortModelsByTokens, |
| 86 | speedCostSegments, | 87 | speedCostSegments, |
| 87 | tokenTypeSegments, | 88 | tokenTypeSegments, |
| ... | @@ -718,6 +719,10 @@ export function UsagePage() { | ... | @@ -718,6 +719,10 @@ export function UsagePage() { |
| 718 | breakdownModels.map((model, index) => { | 719 | breakdownModels.map((model, index) => { |
| 719 | const key = `${model.provider}:${model.model}`; | 720 | const key = `${model.provider}:${model.model}`; |
| 720 | const value = metric === "tokens" ? model.totalTokens : model.costUsd; | 721 | const value = metric === "tokens" ? model.totalTokens : model.costUsd; |
| 722 | const share = modelShare( | ||
| 723 | model, | ||
| 724 | metric === "tokens" ? "tokens" : "cost", | ||
| 725 | ); | ||
| 721 | return ( | 726 | return ( |
| 722 | <tr | 727 | <tr |
| 723 | key={key} | 728 | key={key} |
| ... | @@ -758,7 +763,7 @@ export function UsagePage() { | ... | @@ -758,7 +763,7 @@ export function UsagePage() { |
| 758 | )} | 763 | )} |
| 759 | </td> | 764 | </td> |
| 760 | <td className="hidden py-2.5 pl-6 sm:table-cell"> | 765 | <td className="hidden py-2.5 pl-6 sm:table-cell"> |
| 761 | {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)} | 766 | {share === null ? "" : formatPercent(share)} |
| 762 | </td> | 767 | </td> |
| 763 | <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td> | 768 | <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td> |
| 764 | </tr> | 769 | </tr> |
apps/web/src/components/usage/usageBreakdown.test.ts+27-1| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import type { ModelTotals } from "@t3tools/shared/usageMerge"; | 1 | import type { ModelTotals } from "@t3tools/shared/usageMerge"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 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 | const model = ( | 11 | const model = ( |
| 7 | name: string, | 12 | name: string, |
| ... | @@ -24,6 +29,7 @@ const model = ( | ... | @@ -24,6 +29,7 @@ const model = ( |
| 24 | unpricedRecords: 0, | 29 | unpricedRecords: 0, |
| 25 | unpricedTokens: 0, | 30 | unpricedTokens: 0, |
| 26 | costShare: 0, | 31 | costShare: 0, |
| 32 | tokenShare: 0, | ||
| 27 | ...overrides, | 33 | ...overrides, |
| 28 | }); | 34 | }); |
| 29 | 35 | ||
| ... | @@ -44,6 +50,26 @@ describe("sortModelsByTokens", () => { | ... | @@ -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 | describe("model rates", () => { | 73 | describe("model rates", () => { |
| 48 | it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { | 74 | it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { |
| 49 | const mixed = model("mixed", 4_000_000, 6, { | 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,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 | * Share of a model's input read from cache, or `null` without input. Cache | 27 | * Share of a model's input read from cache, or `null` without input. Cache |
| 19 | * writes count as misses: that input was processed in full. | 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,6 +514,44 @@ describe("mergeUsage", () => { |
| 514 | expect(merged.costQuality.cacheSavingsUsd).toBe(4); | 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 | it("marks a model with no known rates as unpriced rather than free", () => { | 555 | it("marks a model with no known rates as unpriced rather than free", () => { |
| 518 | const merged = mergeUsage( | 556 | const merged = mergeUsage( |
| 519 | [ | 557 | [ |
packages/shared/src/usageMerge.ts+2| ... | @@ -51,6 +51,7 @@ export interface ModelTotals { | ... | @@ -51,6 +51,7 @@ export interface ModelTotals { |
| 51 | */ | 51 | */ |
| 52 | readonly unpricedTokens: number; | 52 | readonly unpricedTokens: number; |
| 53 | readonly costShare: number; | 53 | readonly costShare: number; |
| 54 | readonly tokenShare: number; | ||
| 54 | } | 55 | } |
| 55 | 56 | ||
| 56 | /** | 57 | /** |
| ... | @@ -583,6 +584,7 @@ export function mergeUsage( | ... | @@ -583,6 +584,7 @@ export function mergeUsage( |
| 583 | unpricedRecords: totals.unpricedRecords, | 584 | unpricedRecords: totals.unpricedRecords, |
| 584 | unpricedTokens: totals.unpricedTokens, | 585 | unpricedTokens: totals.unpricedTokens, |
| 585 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, | 586 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 587 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, | ||
| 586 | })) | 588 | })) |
| 587 | .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); | 589 | .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); |
| 588 | 590 |