Skip to main content

statsai_pricing/
lib.rs

1//! Model pricing helpers for `statsai`.
2//!
3//! Provides static model pricing lookup and cost estimation
4//! decoupled from any specific adapter.
5
6use statsai_core::{Confidence, CostInfo, ModelInfo, UsageCounts};
7
8#[must_use]
9pub fn normalize_model_name(name: &str) -> String {
10    let name = name.trim();
11    let name = name
12        .strip_prefix("anthropic/")
13        .or_else(|| name.strip_prefix("openai/"))
14        .unwrap_or(name);
15
16    let lower = name.to_ascii_lowercase();
17
18    match lower.as_str() {
19        "claude-3-5-sonnet-20241022" | "claude-sonnet-3-5" => "claude-sonnet-3-5".to_string(),
20        "claude-3-7-sonnet" | "claude-sonnet-3-7" => "claude-sonnet-3-7".to_string(),
21        "claude-opus-4" => "claude-opus-4".to_string(),
22        "claude-opus-4-5" | "claude-opus-4-5-thinking" | "claude-opus-4.5" => {
23            "claude-opus-4-5".to_string()
24        }
25        "claude-sonnet-4" => "claude-sonnet-4".to_string(),
26        "claude-sonnet-4-5" | "claude-sonnet-4.5" => "claude-sonnet-4-5".to_string(),
27        "claude-haiku-3-5" | "claude-haiku-3.5" => "claude-haiku-3-5".to_string(),
28        "gpt-5" | "gpt-5-chat-latest" => "gpt-5".to_string(),
29        "gpt-5.1" | "gpt-5.1-chat-latest" => "gpt-5.1".to_string(),
30        "gpt-5-codex" | "gpt-5.1-codex" => "gpt-5-codex".to_string(),
31        "gpt-5.1-codex-max" => "gpt-5.1-codex-max".to_string(),
32        "gpt-5.1-codex-mini" => "gpt-5-mini".to_string(),
33        "gpt-5.2" | "gpt-5.2-chat-latest" | "gpt-5.2-codex" => "gpt-5.2".to_string(),
34        "gpt-5.3-codex" => "gpt-5.3-codex".to_string(),
35        "gpt-5.4" => "gpt-5.4".to_string(),
36        "gpt-5.4-mini" => "gpt-5.4-mini".to_string(),
37        "gpt-5.5" => "gpt-5.5".to_string(),
38        "gpt-5-mini" => "gpt-5-mini".to_string(),
39        "gpt-5-nano" => "gpt-5-nano".to_string(),
40        _ => name.to_ascii_lowercase(),
41    }
42}
43
44#[derive(Debug, Clone, Copy)]
45pub struct ModelPricing {
46    pub input_per_million: f64,
47    pub cached_input_per_million: f64,
48    pub output_per_million: f64,
49}
50
51#[must_use]
52pub fn pricing_for_model(model_name: &str) -> Option<ModelPricing> {
53    let normalized = model_name.to_ascii_lowercase();
54    match normalized.as_str() {
55        "gpt-5.5" => Some(ModelPricing {
56            input_per_million: 5.0,
57            cached_input_per_million: 0.5,
58            output_per_million: 30.0,
59        }),
60        "gpt-5.4" => Some(ModelPricing {
61            input_per_million: 2.5,
62            cached_input_per_million: 0.25,
63            output_per_million: 15.0,
64        }),
65        "gpt-5.4-mini" => Some(ModelPricing {
66            input_per_million: 0.75,
67            cached_input_per_million: 0.075,
68            output_per_million: 4.5,
69        }),
70        "gpt-5.3-codex" | "gpt-5.2" | "gpt-5.2-chat-latest" | "gpt-5.2-codex" => {
71            Some(ModelPricing {
72                input_per_million: 1.75,
73                cached_input_per_million: 0.175,
74                output_per_million: 14.0,
75            })
76        }
77        "gpt-5-codex"
78        | "gpt-5.1-codex"
79        | "gpt-5.1-codex-max"
80        | "gpt-5"
81        | "gpt-5.1"
82        | "gpt-5-chat-latest"
83        | "gpt-5.1-chat-latest" => Some(ModelPricing {
84            input_per_million: 1.25,
85            cached_input_per_million: 0.125,
86            output_per_million: 10.0,
87        }),
88        "gpt-5-mini" | "gpt-5.1-codex-mini" => Some(ModelPricing {
89            input_per_million: 0.25,
90            cached_input_per_million: 0.025,
91            output_per_million: 2.0,
92        }),
93        "gpt-5-nano" => Some(ModelPricing {
94            input_per_million: 0.05,
95            cached_input_per_million: 0.005,
96            output_per_million: 0.4,
97        }),
98        _ => None,
99    }
100}
101
102#[must_use]
103pub fn estimate_cost(provider: &str, model: Option<&ModelInfo>, usage: &UsageCounts) -> CostInfo {
104    let Some(model_name) =
105        model.and_then(|model| model.normalized_name.as_deref().or(model.name.as_deref()))
106    else {
107        return unknown_cost();
108    };
109    let Some(pricing) = pricing_for_model(model_name) else {
110        return unknown_cost();
111    };
112
113    let input = usage.input_tokens.unwrap_or(0);
114    let cached = usage.cache_read_tokens.unwrap_or(0);
115    let output = usage.output_tokens.unwrap_or(0);
116    let reasoning = usage.reasoning_tokens.unwrap_or(0);
117    let cost = (input as f64 * pricing.input_per_million
118        + cached as f64 * pricing.cached_input_per_million
119        + (output + reasoning) as f64 * pricing.output_per_million)
120        / 1_000_000.0;
121    let cost_cents = (cost * 100.0).round() as i64;
122
123    CostInfo {
124        currency: "USD".to_string(),
125        estimated_api_equivalent_usd: Some(cost_cents),
126        provider_reported_usd: None,
127        pricing_source: Some(format!("{provider}_api_pricing:{model_name}")),
128        pricing_version: Some("static:2026-05".to_string()),
129        confidence: Confidence::Medium,
130    }
131}
132
133#[must_use]
134pub fn unknown_cost() -> CostInfo {
135    CostInfo {
136        currency: "USD".to_string(),
137        estimated_api_equivalent_usd: None,
138        provider_reported_usd: None,
139        pricing_source: Some("unknown".to_string()),
140        pricing_version: None,
141        confidence: Confidence::Low,
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use statsai_core::UsageCounts;
149
150    #[test]
151    fn normalizes_claude_thinking_variant() {
152        assert_eq!(
153            normalize_model_name("claude-opus-4-5-thinking"),
154            "claude-opus-4-5"
155        );
156    }
157
158    #[test]
159    fn normalizes_codex_aliases() {
160        assert_eq!(normalize_model_name("gpt-5.1-codex"), "gpt-5-codex");
161        assert_eq!(normalize_model_name("gpt-5.1-codex-mini"), "gpt-5-mini");
162    }
163
164    #[test]
165    fn normalizes_provider_prefixes() {
166        assert_eq!(
167            normalize_model_name("anthropic/claude-sonnet-4-5"),
168            "claude-sonnet-4-5"
169        );
170        assert_eq!(normalize_model_name("openai/gpt-5"), "gpt-5");
171    }
172
173    #[test]
174    fn normalizes_unknown_model_to_lowercase() {
175        assert_eq!(normalize_model_name("SomeNewModel"), "somenewmodel");
176    }
177
178    #[test]
179    fn normalizes_whitespace() {
180        assert_eq!(normalize_model_name("  gpt-5  "), "gpt-5");
181    }
182
183    #[test]
184    fn estimates_cost_for_known_model() {
185        let model = statsai_core::ModelInfo {
186            name: Some("gpt-5".to_string()),
187            normalized_name: Some("gpt-5".to_string()),
188            provider_model_id: Some("gpt-5".to_string()),
189        };
190        let usage = UsageCounts {
191            input_tokens: Some(1_000_000),
192            output_tokens: Some(500_000),
193            ..UsageCounts::default()
194        };
195        let cost = estimate_cost("codex", Some(&model), &usage);
196        assert!(cost.estimated_api_equivalent_usd.is_some());
197        assert!(cost
198            .pricing_source
199            .as_deref()
200            .unwrap()
201            .starts_with("codex_api_pricing"));
202    }
203
204    #[test]
205    fn unknown_model_returns_unknown_cost() {
206        let model = statsai_core::ModelInfo {
207            name: Some("unknown-model".to_string()),
208            normalized_name: Some("unknown-model".to_string()),
209            provider_model_id: Some("unknown-model".to_string()),
210        };
211        let usage = UsageCounts {
212            total_tokens: Some(100),
213            ..UsageCounts::default()
214        };
215        let cost = estimate_cost("codex", Some(&model), &usage);
216        assert_eq!(cost.confidence, Confidence::Low);
217        assert!(cost.estimated_api_equivalent_usd.is_none());
218    }
219
220    #[test]
221    fn missing_model_returns_unknown_cost() {
222        let usage = UsageCounts {
223            total_tokens: Some(100),
224            ..UsageCounts::default()
225        };
226        let cost = estimate_cost("codex", None, &usage);
227        assert_eq!(cost.confidence, Confidence::Low);
228    }
229
230    #[test]
231    fn cached_input_reduces_billable() {
232        let model = statsai_core::ModelInfo {
233            name: Some("gpt-5".to_string()),
234            normalized_name: Some("gpt-5".to_string()),
235            provider_model_id: Some("gpt-5".to_string()),
236        };
237        let usage = UsageCounts {
238            input_tokens: Some(200_000),
239            cache_read_tokens: Some(800_000),
240            output_tokens: Some(0),
241            ..UsageCounts::default()
242        };
243        let cost = estimate_cost("codex", Some(&model), &usage);
244        // Uncached input = 200K at $1.25/M, cached input = 800K at $0.125/M -> 35 cents.
245        assert_eq!(cost.estimated_api_equivalent_usd, Some(35));
246    }
247
248    #[test]
249    fn reasoning_tokens_are_billed_as_output() {
250        let model = statsai_core::ModelInfo {
251            name: Some("gpt-5".to_string()),
252            normalized_name: Some("gpt-5".to_string()),
253            provider_model_id: Some("gpt-5".to_string()),
254        };
255        let usage = UsageCounts {
256            output_tokens: Some(100_000),
257            reasoning_tokens: Some(50_000),
258            ..UsageCounts::default()
259        };
260        let cost = estimate_cost("codex", Some(&model), &usage);
261        assert_eq!(cost.estimated_api_equivalent_usd, Some(150));
262    }
263}