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
65      .input
66      .saturating_add(self.output)
67      .saturating_add(self.cache_read)
68  }
69
70  /// What this usage costs in dollars at these prices.
71  pub fn cost(self, pricing: AIPricing) -> f64 {
72    let per = |tokens: u64, price: f64| tokens as f64 / 1_000_000.0 * price;
73    per(self.input, pricing.input_per_million)
74      + per(self.output, pricing.output_per_million)
75      + per(self.cache_read, pricing.cache_read_per_million)
76  }
77}
78
79/// Session totals are the sum of every request's usage. Saturating rather
80/// than wrapping: a tally that silently rolls over to nearly zero is worse
81/// than one that sticks at the ceiling.
82impl std::ops::Add for AIUsage {
83  type Output = AIUsage;
84
85  fn add(self, other: AIUsage) -> AIUsage {
86    AIUsage {
87      input: self.input.saturating_add(other.input),
88      output: self.output.saturating_add(other.output),
89      cache_read: self.cache_read.saturating_add(other.cache_read),
90    }
91  }
92}
93
94impl std::iter::Sum for AIUsage {
95  fn sum<I: Iterator<Item = AIUsage>>(iter: I) -> AIUsage {
96    iter.fold(AIUsage::default(), |total, usage| total + usage)
97  }
98}
99
100/// A running cost readout.
101#[derive(IntoElement)]
102pub struct AICost {
103  usage: AIUsage,
104  pricing: AIPricing,
105  label: Option<SharedString>,
106  size: Size,
107  /// Show the input/output split under the total.
108  breakdown: bool,
109}
110
111impl AICost {
112  pub fn new(usage: AIUsage, pricing: AIPricing) -> Self {
113    AICost {
114      usage,
115      pricing,
116      label: None,
117      size: Size::Xs,
118      breakdown: false,
119    }
120  }
121
122  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
123    self.label = Some(label.into());
124    self
125  }
126
127  pub fn size(mut self, size: Size) -> Self {
128    self.size = size;
129    self
130  }
131
132  /// Break the total down by token kind.
133  pub fn breakdown(mut self, breakdown: bool) -> Self {
134    self.breakdown = breakdown;
135    self
136  }
137
138  /// The total in dollars.
139  pub fn total(&self) -> f64 {
140    self.usage.cost(self.pricing)
141  }
142}
143
144/// Money, at a precision that suits the amount: fractions of a cent still
145/// need to be legible, dollars don't need six decimal places.
146pub fn format_cost(dollars: f64) -> String {
147  if !dollars.is_finite() || dollars <= 0.0 {
148    return "$0.00".to_string();
149  }
150  if dollars < 0.01 {
151    format!("${dollars:.4}")
152  } else if dollars < 1.0 {
153    format!("${dollars:.3}")
154  } else {
155    format!("${dollars:.2}")
156  }
157}
158
159impl RenderOnce for AICost {
160  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
161    let t = theme(cx);
162    let font = t.font_size(self.size);
163    let dimmed = t.dimmed().hsla();
164    let text_color = t.text().hsla();
165    let total = self.total();
166
167    div()
168      .flex()
169      .flex_col()
170      .gap(px(2.0))
171      .text_size(px(font))
172      .child(
173        div()
174          .flex()
175          .items_center()
176          .gap(px(6.0))
177          .text_color(dimmed)
178          .child(
179            Icon::new(IconName::Coins)
180              .size(Size::Xs)
181              .color(ColorName::Gray),
182          )
183          .children(self.label)
184          .child(
185            div()
186              .text_color(text_color)
187              .child(SharedString::from(format_cost(total))),
188          ),
189      )
190      .when(self.breakdown, |column| {
191        column.child(
192          div()
193            .flex()
194            .gap(px(10.0))
195            .text_color(dimmed)
196            .child(SharedString::from(format!(
197              "in {}",
198              compact(self.usage.input)
199            )))
200            .child(SharedString::from(format!(
201              "out {}",
202              compact(self.usage.output)
203            )))
204            .when(self.usage.cache_read > 0, |row| {
205              row.child(SharedString::from(format!(
206                "cache {}",
207                compact(self.usage.cache_read)
208              )))
209            }),
210        )
211      })
212      .probe("AICost")
213  }
214}
215
216#[cfg(test)]
217mod tests {
218  use super::*;
219
220  #[test]
221  fn cost_is_per_million_not_per_token() {
222    // A million input tokens at $3/M is exactly $3.
223    let usage = AIUsage::new(1_000_000, 0);
224    assert_eq!(usage.cost(AIPricing::new(3.0, 15.0)), 3.0);
225    let usage = AIUsage::new(0, 1_000_000);
226    assert_eq!(usage.cost(AIPricing::new(3.0, 15.0)), 15.0);
227  }
228
229  #[test]
230  fn cache_reads_bill_at_their_own_rate() {
231    let pricing = AIPricing::new(3.0, 15.0).cache_read(0.3);
232    let usage = AIUsage::new(0, 0).cache_read(2_000_000);
233    assert!((usage.cost(pricing) - 0.6).abs() < 1e-9);
234  }
235
236  #[test]
237  fn usage_adds_without_overflowing() {
238    let huge = AIUsage {
239      input: u64::MAX,
240      output: u64::MAX,
241      cache_read: u64::MAX,
242    };
243    assert_eq!((huge + huge).input, u64::MAX);
244    assert_eq!(huge.total(), u64::MAX);
245    // Summing a session's requests uses the same saturating add.
246    let total: AIUsage = [AIUsage::new(1, 2), AIUsage::new(3, 4)].into_iter().sum();
247    assert_eq!(total, AIUsage::new(4, 6));
248  }
249
250  #[test]
251  fn tiny_amounts_stay_legible_and_bad_input_reads_as_zero() {
252    assert_eq!(format_cost(0.0), "$0.00");
253    assert_eq!(format_cost(-1.0), "$0.00");
254    assert_eq!(format_cost(f64::NAN), "$0.00");
255    assert_eq!(format_cost(0.00042), "$0.0004");
256    assert_eq!(format_cost(0.125), "$0.125");
257    assert_eq!(format_cost(12.3456), "$12.35");
258  }
259}