1use 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 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}