1use gpui::prelude::*;
9use gpui::{div, px, App, IntoElement, SharedString, Window};
10
11use super::tokenmeter::compact;
12use crate::devtools::Probed;
13use crate::icon::{Icon, IconName};
14use crate::theme::{theme, ColorName, Size};
15
16#[derive(Debug, Clone, Copy, Default, PartialEq)]
18pub struct AIPricing {
19 pub input_per_million: f64,
20 pub output_per_million: f64,
21 pub cache_read_per_million: f64,
23}
24
25impl AIPricing {
26 pub fn new(input_per_million: f64, output_per_million: f64) -> Self {
27 AIPricing {
28 input_per_million,
29 output_per_million,
30 cache_read_per_million: 0.0,
31 }
32 }
33
34 pub fn cache_read(mut self, per_million: f64) -> Self {
35 self.cache_read_per_million = per_million;
36 self
37 }
38}
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub struct AIUsage {
43 pub input: u64,
44 pub output: u64,
45 pub cache_read: u64,
46}
47
48impl AIUsage {
49 pub fn new(input: u64, output: u64) -> Self {
50 AIUsage {
51 input,
52 output,
53 cache_read: 0,
54 }
55 }
56
57 pub fn cache_read(mut self, tokens: u64) -> Self {
58 self.cache_read = tokens;
59 self
60 }
61
62 pub fn total(self) -> u64 {
64 self.input
65 .saturating_add(self.output)
66 .saturating_add(self.cache_read)
67 }
68
69 pub fn cost(self, pricing: AIPricing) -> f64 {
71 let per = |tokens: u64, price: f64| tokens as f64 / 1_000_000.0 * price;
72 per(self.input, pricing.input_per_million)
73 + per(self.output, pricing.output_per_million)
74 + per(self.cache_read, pricing.cache_read_per_million)
75 }
76}
77
78impl std::ops::Add for AIUsage {
82 type Output = AIUsage;
83
84 fn add(self, other: AIUsage) -> AIUsage {
85 AIUsage {
86 input: self.input.saturating_add(other.input),
87 output: self.output.saturating_add(other.output),
88 cache_read: self.cache_read.saturating_add(other.cache_read),
89 }
90 }
91}
92
93impl std::iter::Sum for AIUsage {
94 fn sum<I: Iterator<Item = AIUsage>>(iter: I) -> AIUsage {
95 iter.fold(AIUsage::default(), |total, usage| total + usage)
96 }
97}
98
99#[derive(IntoElement)]
101pub struct AICost {
102 usage: AIUsage,
103 pricing: AIPricing,
104 label: Option<SharedString>,
105 size: Size,
106 breakdown: bool,
108}
109
110impl AICost {
111 pub fn new(usage: AIUsage, pricing: AIPricing) -> Self {
112 AICost {
113 usage,
114 pricing,
115 label: None,
116 size: Size::Xs,
117 breakdown: false,
118 }
119 }
120
121 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
122 self.label = Some(label.into());
123 self
124 }
125
126 pub fn size(mut self, size: Size) -> Self {
127 self.size = size;
128 self
129 }
130
131 pub fn breakdown(mut self, breakdown: bool) -> Self {
133 self.breakdown = breakdown;
134 self
135 }
136
137 pub fn total(&self) -> f64 {
139 self.usage.cost(self.pricing)
140 }
141}
142
143pub fn format_cost(dollars: f64) -> String {
146 if !dollars.is_finite() || dollars <= 0.0 {
147 return "$0.00".to_string();
148 }
149 if dollars < 0.01 {
150 format!("${dollars:.4}")
151 } else if dollars < 1.0 {
152 format!("${dollars:.3}")
153 } else {
154 format!("${dollars:.2}")
155 }
156}
157
158impl RenderOnce for AICost {
159 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
160 let t = theme(cx);
161 let font = t.font_size(self.size);
162 let dimmed = t.dimmed().hsla();
163 let text_color = t.text().hsla();
164 let total = self.total();
165
166 div()
167 .flex()
168 .flex_col()
169 .gap(px(2.0))
170 .text_size(px(font))
171 .child(
172 div()
173 .flex()
174 .items_center()
175 .gap(px(6.0))
176 .text_color(dimmed)
177 .child(
178 Icon::new(IconName::Coins)
179 .size(Size::Xs)
180 .color(ColorName::Gray),
181 )
182 .children(self.label)
183 .child(
184 div()
185 .text_color(text_color)
186 .child(SharedString::from(format_cost(total))),
187 ),
188 )
189 .when(self.breakdown, |column| {
190 column.child(
191 div()
192 .flex()
193 .gap(px(10.0))
194 .text_color(dimmed)
195 .child(SharedString::from(format!(
196 "in {}",
197 compact(self.usage.input)
198 )))
199 .child(SharedString::from(format!(
200 "out {}",
201 compact(self.usage.output)
202 )))
203 .when(self.usage.cache_read > 0, |row| {
204 row.child(SharedString::from(format!(
205 "cache {}",
206 compact(self.usage.cache_read)
207 )))
208 }),
209 )
210 })
211 .probe("AICost")
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn cost_is_per_million_not_per_token() {
221 let usage = AIUsage::new(1_000_000, 0);
223 assert_eq!(usage.cost(AIPricing::new(3.0, 15.0)), 3.0);
224 let usage = AIUsage::new(0, 1_000_000);
225 assert_eq!(usage.cost(AIPricing::new(3.0, 15.0)), 15.0);
226 }
227
228 #[test]
229 fn cache_reads_bill_at_their_own_rate() {
230 let pricing = AIPricing::new(3.0, 15.0).cache_read(0.3);
231 let usage = AIUsage::new(0, 0).cache_read(2_000_000);
232 assert!((usage.cost(pricing) - 0.6).abs() < 1e-9);
233 }
234
235 #[test]
236 fn usage_adds_without_overflowing() {
237 let huge = AIUsage {
238 input: u64::MAX,
239 output: u64::MAX,
240 cache_read: u64::MAX,
241 };
242 assert_eq!((huge + huge).input, u64::MAX);
243 assert_eq!(huge.total(), u64::MAX);
244 let total: AIUsage = [AIUsage::new(1, 2), AIUsage::new(3, 4)].into_iter().sum();
246 assert_eq!(total, AIUsage::new(4, 6));
247 }
248
249 #[test]
250 fn tiny_amounts_stay_legible_and_bad_input_reads_as_zero() {
251 assert_eq!(format_cost(0.0), "$0.00");
252 assert_eq!(format_cost(-1.0), "$0.00");
253 assert_eq!(format_cost(f64::NAN), "$0.00");
254 assert_eq!(format_cost(0.00042), "$0.0004");
255 assert_eq!(format_cost(0.125), "$0.125");
256 assert_eq!(format_cost(12.3456), "$12.35");
257 }
258}