Skip to main content

guise/ai/
cost.rs

1//! `AICost` — what this conversation has cost so far.
2//!
3//! Per-token prices are small enough to feel free and add up fast enough to
4//! surprise, so a running total is worth the corner it takes. The arithmetic
5//! is here rather than in the caller because getting it wrong by a factor of a
6//! thousand is easy: prices are quoted per million tokens.
7
8use 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/// Prices in US dollars per million tokens, the unit providers quote.
17#[derive(Debug, Clone, Copy, Default, PartialEq)]
18pub struct AIPricing {
19    pub input_per_million: f64,
20    pub output_per_million: f64,
21    /// What a cache read costs, when the provider bills it separately.
22    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/// A token tally for one request or a whole session.
41#[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    /// Every token that passed through, for a context-window meter.
63    pub fn total(self) -> u64 {
64        self.input
65            .saturating_add(self.output)
66            .saturating_add(self.cache_read)
67    }
68
69    /// What this usage costs in dollars at these prices.
70    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
78/// Session totals are the sum of every request's usage. Saturating rather
79/// than wrapping: a tally that silently rolls over to nearly zero is worse
80/// than one that sticks at the ceiling.
81impl 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/// A running cost readout.
100#[derive(IntoElement)]
101pub struct AICost {
102    usage: AIUsage,
103    pricing: AIPricing,
104    label: Option<SharedString>,
105    size: Size,
106    /// Show the input/output split under the total.
107    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    /// Break the total down by token kind.
132    pub fn breakdown(mut self, breakdown: bool) -> Self {
133        self.breakdown = breakdown;
134        self
135    }
136
137    /// The total in dollars.
138    pub fn total(&self) -> f64 {
139        self.usage.cost(self.pricing)
140    }
141}
142
143/// Money, at a precision that suits the amount: fractions of a cent still
144/// need to be legible, dollars don't need six decimal places.
145pub 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        // A million input tokens at $3/M is exactly $3.
222        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        // Summing a session's requests uses the same saturating add.
245        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}