1import { formatPercent, formatTokens, formatUsd } from "@t3tools/shared/usageFormat";
2import { isModelCostUnknown, type ModelTotals } from "@t3tools/shared/usageMerge";
3import { useMemo } from "react";
4
5import { mergeAnsweredUsage, type EnvironmentUsageStatus } from "../../state/usage";
6import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
7import { Button } from "../ui/button";
8import {
9 Dialog,
10 DialogDescription,
11 DialogFooter,
12 DialogHeader,
13 DialogPanel,
14 DialogPopup,
15 DialogTitle,
16} from "../ui/dialog";
17import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
18import { UsageShareBar } from "./UsageShareBar";
19import {
20 cacheHitRate,
21 costPerMillionTokens,
22 costTypeSegments,
23 speedCostSegments,
24 tokenTypeSegments,
25} from "./usageBreakdown";
26import { PROVIDER_PRESENTATION } from "./usageProviders";
27
28export interface UsageChartWindow {
29 readonly days: readonly string[];
30 readonly hours: readonly string[];
31 readonly resolution: "day" | "hour";
32 readonly timeZone: string;
33 readonly referenceTime: string | undefined;
34}
35
36/**
37 * One model's usage in the current window, opened from the Breakdown list.
38 * The trend and mixes come from the same merge as the page, narrowed to this
39 * model's buckets.
40 */
41export function UsageModelDialog({
42 model,
43 environments,
44 metric,
45 chartWindow,
46 onSetPrice,
47 onClose,
48}: {
49 readonly model: ModelTotals;
50 readonly environments: readonly EnvironmentUsageStatus[];
51 readonly metric: UsageChartMetric;
52 readonly chartWindow: UsageChartWindow;
53 readonly onSetPrice: () => void;
54 readonly onClose: () => void;
55}) {
56 const usage = useMemo(
57 () =>
58 mergeAnsweredUsage(
59 environments,
60 (bucket) => bucket.provider === model.provider && bucket.model === model.model,
61 ),
62 [environments, model.provider, model.model],
63 );
64 const providers = useMemo(() => [model.provider], [model.provider]);
65 const presentation = PROVIDER_PRESENTATION[model.provider];
66 const costUnknown = isModelCostUnknown(model);
67 const hitRate = cacheHitRate(model);
68 const perMillion = costPerMillionTokens(model);
69 const stats = [
70 { label: "Cost", value: costUnknown ? "Unpriced" : formatUsd(model.costUsd) },
71 { label: "Tokens", value: formatTokens(model.totalTokens) },
72 perMillion === null ? null : { label: "Per 1M tokens", value: formatUsd(perMillion) },
73 hitRate === null ? null : { label: "Cache hit", value: formatPercent(hitRate) },
74 ].filter((stat) => stat !== null);
75
76 return (
77 <Dialog
78 open
79 onOpenChange={(open) => {
80 if (!open) onClose();
81 }}
82 >
83 <DialogPopup className="max-w-3xl">
84 <DialogHeader>
85 <div className="flex items-center gap-2">
86 <ProviderInstanceIcon
87 driverKind={presentation.driverKind}
88 displayName={presentation.label}
89 iconClassName="size-5"
90 />
91 <DialogTitle>{model.model}</DialogTitle>
92 </div>
93 <DialogDescription>
94 {presentation.label}
95 {costUnknown ? "" : ` · ${formatPercent(model.costShare)} of cost`}
96 </DialogDescription>
97 </DialogHeader>
98 <DialogPanel>
99 <div className="flex flex-col gap-8">
100 <div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
101 {stats.map((stat) => (
102 <div key={stat.label} className="flex min-w-0 flex-col gap-0.5">
103 <span className="text-xs text-muted-foreground">{stat.label}</span>
104 <span className="text-2xl font-semibold text-foreground">{stat.value}</span>
105 </div>
106 ))}
107 </div>
108
109 {/* Unpriced cost is unknown, not zero, so its trend shows tokens. */}
110 <UsageProviderChart
111 providers={providers}
112 days={chartWindow.days}
113 daily={usage.daily}
114 hours={chartWindow.hours}
115 hourly={usage.hourly}
116 metric={costUnknown ? "tokens" : metric}
117 referenceTime={chartWindow.referenceTime}
118 resolution={chartWindow.resolution}
119 timeZone={chartWindow.timeZone}
120 />
121
122 <div className="grid gap-x-10 gap-y-8 sm:grid-cols-2">
123 {costUnknown ? null : (
124 <UsageShareBar
125 label="Cost by type"
126 segments={costTypeSegments(usage.categoryCost)}
127 format={formatUsd}
128 />
129 )}
130 <UsageShareBar
131 label="Tokens by type"
132 segments={tokenTypeSegments(model.tokens)}
133 format={formatTokens}
134 />
135 {usage.speedCost.fast + usage.speedCost.ultrafast > 0 ? (
136 <UsageShareBar
137 label="Cost by speed"
138 segments={speedCostSegments(usage.speedCost)}
139 format={formatUsd}
140 aside={<SpeedPremium premiumUsd={usage.speedCost.premium} />}
141 />
142 ) : null}
143 </div>
144 </div>
145 </DialogPanel>
146 {model.unpricedTokens > 0 ? (
147 <DialogFooter variant="bare" className="items-center sm:justify-between">
148 <span className="text-xs text-muted-foreground">
149 {formatTokens(model.unpricedTokens)} tokens have no known price
150 </span>
151 <Button onClick={onSetPrice}>Set price</Button>
152 </DialogFooter>
153 ) : null}
154 </DialogPopup>
155 </Dialog>
156 );
157}
158
159/** What the faster speeds cost above standard rates, beside the speed bar. */
160export function SpeedPremium({ premiumUsd }: { readonly premiumUsd: number }) {
161 return (
162 <span className="text-xs text-muted-foreground">
163 Premium <span className="text-foreground tabular-nums">{formatUsd(premiumUsd)}</span>
164 </span>
165 );
166}