Skip to main content

lc_callbacks/
pricing.rs

1// lc-callbacks/src/pricing.rs
2//! USD cost estimation for token usage (E3, v0.22.1 §S6).
3//!
4//! LLM providers bill per token, at rates that differ by model and by input-vs-output.
5//! This module turns a `(prompt_tokens, completion_tokens, model)` triple into a USD
6//! estimate, so traces and cost dashboards can answer "what did this agent run cost?"
7//! without hardcoding floats in every caller.
8//!
9//! Rates are a static snapshot of the listed model prices (per 1M tokens) and are
10//! deliberately approximate — list price, not the effective price after tiered volume
11//! discounts or other negotiated terms. Unknown models yield `None` rather than a guess
12//! (an invented rate is worse than no rate).
13//!
14//! ## Design
15//!
16//! [`price_for`] matches by model-name substring, longest/most-specific first, so
17//! `claude-3-5-sonnet` resolves to its own rate instead of the `claude-3-sonnet` catch.
18//! The table lives as an ordered slice so a single `find` returns the right entry.
19
20/// USD price of a model, per 1M tokens.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct ModelPrice {
23    /// USD per 1M input (prompt) tokens.
24    pub input_usd_per_1m: f64,
25    /// USD per 1M output (completion) tokens.
26    pub output_usd_per_1m: f64,
27}
28
29/// `(model substring, price)`. Ordered most-specific first so `find` picks the best match.
30const PRICE_TABLE: &[(&str, ModelPrice)] = &[
31    ("claude-3-5-sonnet", ModelPrice { input_usd_per_1m: 3.0, output_usd_per_1m: 15.0 }),
32    ("claude-3-5-haiku", ModelPrice { input_usd_per_1m: 0.80, output_usd_per_1m: 4.0 }),
33    ("claude-3-opus", ModelPrice { input_usd_per_1m: 15.0, output_usd_per_1m: 75.0 }),
34    ("claude-3-sonnet", ModelPrice { input_usd_per_1m: 3.0, output_usd_per_1m: 15.0 }),
35    ("claude-3-haiku", ModelPrice { input_usd_per_1m: 0.25, output_usd_per_1m: 1.25 }),
36    // generic claude fallback last among claude entries
37    ("claude", ModelPrice { input_usd_per_1m: 3.0, output_usd_per_1m: 15.0 }),
38    ("gpt-4o-mini", ModelPrice { input_usd_per_1m: 0.15, output_usd_per_1m: 0.60 }),
39    ("gpt-4o", ModelPrice { input_usd_per_1m: 2.50, output_usd_per_1m: 10.0 }),
40    ("gpt-4-turbo", ModelPrice { input_usd_per_1m: 10.0, output_usd_per_1m: 30.0 }),
41    ("gpt-4", ModelPrice { input_usd_per_1m: 30.0, output_usd_per_1m: 60.0 }),
42    ("gpt-3.5-turbo", ModelPrice { input_usd_per_1m: 0.50, output_usd_per_1m: 1.50 }),
43    ("deepseek-reasoner", ModelPrice { input_usd_per_1m: 0.55, output_usd_per_1m: 2.19 }),
44    ("deepseek-chat", ModelPrice { input_usd_per_1m: 0.27, output_usd_per_1m: 1.10 }),
45    ("qwen", ModelPrice { input_usd_per_1m: 0.50, output_usd_per_1m: 2.0 }),
46];
47
48/// Looks up the list price for a model name by most-specific substring match.
49///
50/// Returns `None` for unknown models (the caller should treat an unknown rate as unknown
51/// cost, not mint one).
52pub fn price_for(model: &str) -> Option<ModelPrice> {
53    let lower = model.to_ascii_lowercase();
54    PRICE_TABLE
55        .iter()
56        .find(|(pattern, _)| lower.contains(pattern))
57        .map(|(_, price)| *price)
58}
59
60/// Estimates the USD cost of a call from its token counts and model name.
61///
62/// `None` when the model is not in the price table.
63pub fn estimate_cost_usd(prompt_tokens: usize, completion_tokens: usize, model: &str) -> Option<f64> {
64    let price = price_for(model)?;
65    Some(
66        prompt_tokens as f64 / 1e6 * price.input_usd_per_1m
67            + completion_tokens as f64 / 1e6 * price.output_usd_per_1m,
68    )
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn specific_beats_generic_claude() {
77        let opus = price_for("claude-3-opus-20240229").unwrap();
78        assert_eq!(opus.input_usd_per_1m, 15.0);
79        // claude-3-sonnet must NOT match the generic "claude" fallback
80        let sonnet = price_for("claude-3-sonnet-20240229").unwrap();
81        assert_eq!(sonnet.input_usd_per_1m, 3.0);
82    }
83
84    #[test]
85    fn gpt_4o_mini_beats_gpt_4o() {
86        let mini = price_for("gpt-4o-mini").unwrap();
87        assert_eq!(mini.input_usd_per_1m, 0.15);
88        let full = price_for("gpt-4o").unwrap();
89        assert_eq!(full.input_usd_per_1m, 2.50);
90    }
91
92    #[test]
93    fn unknown_model_returns_none() {
94        assert!(price_for("some-future-model").is_none());
95        assert!(estimate_cost_usd(10, 10, "unknown").is_none());
96    }
97
98    #[test]
99    fn estimate_is_per_1m_scaled() {
100        // 1M input + 1M output vs claude-3-5-sonnet (3 / 15)
101        let cost = estimate_cost_usd(1_000_000, 1_000_000, "claude-3-5-sonnet").unwrap();
102        assert!((cost - 18.0).abs() < 1e-9, "got {cost}");
103        // 1k tokens each
104        let small = estimate_cost_usd(1000, 1000, "claude-3-5-sonnet").unwrap();
105        assert!((small - 0.018).abs() < 1e-9, "got {small}");
106    }
107}