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    (
32        "claude-3-5-sonnet",
33        ModelPrice {
34            input_usd_per_1m: 3.0,
35            output_usd_per_1m: 15.0,
36        },
37    ),
38    (
39        "claude-3-5-haiku",
40        ModelPrice {
41            input_usd_per_1m: 0.80,
42            output_usd_per_1m: 4.0,
43        },
44    ),
45    (
46        "claude-3-opus",
47        ModelPrice {
48            input_usd_per_1m: 15.0,
49            output_usd_per_1m: 75.0,
50        },
51    ),
52    (
53        "claude-3-sonnet",
54        ModelPrice {
55            input_usd_per_1m: 3.0,
56            output_usd_per_1m: 15.0,
57        },
58    ),
59    (
60        "claude-3-haiku",
61        ModelPrice {
62            input_usd_per_1m: 0.25,
63            output_usd_per_1m: 1.25,
64        },
65    ),
66    // generic claude fallback last among claude entries
67    (
68        "claude",
69        ModelPrice {
70            input_usd_per_1m: 3.0,
71            output_usd_per_1m: 15.0,
72        },
73    ),
74    (
75        "gpt-4o-mini",
76        ModelPrice {
77            input_usd_per_1m: 0.15,
78            output_usd_per_1m: 0.60,
79        },
80    ),
81    (
82        "gpt-4o",
83        ModelPrice {
84            input_usd_per_1m: 2.50,
85            output_usd_per_1m: 10.0,
86        },
87    ),
88    (
89        "gpt-4-turbo",
90        ModelPrice {
91            input_usd_per_1m: 10.0,
92            output_usd_per_1m: 30.0,
93        },
94    ),
95    (
96        "gpt-4",
97        ModelPrice {
98            input_usd_per_1m: 30.0,
99            output_usd_per_1m: 60.0,
100        },
101    ),
102    (
103        "gpt-3.5-turbo",
104        ModelPrice {
105            input_usd_per_1m: 0.50,
106            output_usd_per_1m: 1.50,
107        },
108    ),
109    (
110        "deepseek-reasoner",
111        ModelPrice {
112            input_usd_per_1m: 0.55,
113            output_usd_per_1m: 2.19,
114        },
115    ),
116    (
117        "deepseek-chat",
118        ModelPrice {
119            input_usd_per_1m: 0.27,
120            output_usd_per_1m: 1.10,
121        },
122    ),
123    (
124        "qwen",
125        ModelPrice {
126            input_usd_per_1m: 0.50,
127            output_usd_per_1m: 2.0,
128        },
129    ),
130];
131
132/// Looks up the list price for a model name by most-specific substring match.
133///
134/// Returns `None` for unknown models (the caller should treat an unknown rate as unknown
135/// cost, not mint one).
136pub fn price_for(model: &str) -> Option<ModelPrice> {
137    let lower = model.to_ascii_lowercase();
138    PRICE_TABLE
139        .iter()
140        .find(|(pattern, _)| lower.contains(pattern))
141        .map(|(_, price)| *price)
142}
143
144/// Estimates the USD cost of a call from its token counts and model name.
145///
146/// `None` when the model is not in the price table.
147pub fn estimate_cost_usd(
148    prompt_tokens: usize,
149    completion_tokens: usize,
150    model: &str,
151) -> Option<f64> {
152    let price = price_for(model)?;
153    Some(
154        prompt_tokens as f64 / 1e6 * price.input_usd_per_1m
155            + completion_tokens as f64 / 1e6 * price.output_usd_per_1m,
156    )
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn specific_beats_generic_claude() {
165        let opus = price_for("claude-3-opus-20240229").unwrap();
166        assert_eq!(opus.input_usd_per_1m, 15.0);
167        // claude-3-sonnet must NOT match the generic "claude" fallback
168        let sonnet = price_for("claude-3-sonnet-20240229").unwrap();
169        assert_eq!(sonnet.input_usd_per_1m, 3.0);
170    }
171
172    #[test]
173    fn gpt_4o_mini_beats_gpt_4o() {
174        let mini = price_for("gpt-4o-mini").unwrap();
175        assert_eq!(mini.input_usd_per_1m, 0.15);
176        let full = price_for("gpt-4o").unwrap();
177        assert_eq!(full.input_usd_per_1m, 2.50);
178    }
179
180    #[test]
181    fn unknown_model_returns_none() {
182        assert!(price_for("some-future-model").is_none());
183        assert!(estimate_cost_usd(10, 10, "unknown").is_none());
184    }
185
186    #[test]
187    fn estimate_is_per_1m_scaled() {
188        // 1M input + 1M output vs claude-3-5-sonnet (3 / 15)
189        let cost = estimate_cost_usd(1_000_000, 1_000_000, "claude-3-5-sonnet").unwrap();
190        assert!((cost - 18.0).abs() < 1e-9, "got {cost}");
191        // 1k tokens each
192        let small = estimate_cost_usd(1000, 1000, "claude-3-5-sonnet").unwrap();
193        assert!((small - 0.018).abs() < 1e-9, "got {small}");
194    }
195}