1const HAIKU_5_5_TIER_TOKENS: u64 = 100_000;
56
57fn is_version(model: &str, version: &str) -> bool {
61 fn suffix_ok(rest: &str) -> bool {
62 if matches!(rest, "" | "-latest" | ":0" | "-v1:0") {
63 return true;
64 }
65 let Some(date) = rest.strip_prefix('-').or_else(|| rest.strip_prefix('@')) else {
66 return false;
67 };
68 date.len() >= 8
69 && date.as_bytes()[..8].iter().all(u8::is_ascii_digit)
70 && matches!(&date[8..], "" | "-v1:0")
71 }
72 let dotted = version.rsplit_once('-').and_then(|(prefix, minor)| {
73 (prefix.ends_with(|c: char| c.is_ascii_digit())
74 && minor.bytes().all(|c| c.is_ascii_digit()))
75 .then(|| format!("{prefix}.{minor}"))
76 });
77 let matched = [Some(version), dotted.as_deref()]
78 .into_iter()
79 .flatten()
80 .any(|v| {
81 model.match_indices(v).any(|(i, _)| {
82 (i == 0 || matches!(model.as_bytes()[i - 1], b'-' | b'/' | b'.'))
83 && suffix_ok(&model[i + v.len()..])
84 })
85 });
86 matched
87}
88
89fn is_legacy_opus(m: &str) -> bool {
90 ["3-opus", "opus-3", "opus-4", "opus-4-0", "opus-4-1"]
91 .iter()
92 .any(|v| is_version(m, v))
93}
94
95fn is_sonnet_5(m: &str) -> bool {
96 is_version(m, "sonnet-5")
97}
98
99#[inline]
107fn model_prices(model: &str, prompt_tokens: u64) -> (f64, f64) {
108 match model {
109 "inferx/deepseek-v4-flash" | "deepseek-v4-flash" => (0.0, 0.0),
112 m if m.contains("fable") || m.contains("mythos") => (10.0, 50.0),
113 m if is_version(m, "opus-5-5") => (4.0, 20.0),
114 m if is_legacy_opus(m) => (15.0, 75.0),
115 m if m.contains("opus") => (5.0, 25.0),
116 m if is_version(m, "sonnet-5-5") => (2.0, 10.0),
117 m if is_sonnet_5(m) => (2.0, 10.0),
119 m if m.contains("sonnet") => (3.0, 15.0),
120 m if is_version(m, "haiku-5-5") => {
121 if prompt_tokens > HAIKU_5_5_TIER_TOKENS {
122 (0.5, 2.5)
123 } else {
124 (0.1, 0.5)
125 }
126 }
127 m if is_version(m, "haiku-3-5")
128 || is_version(m, "3-5-haiku")
129 || is_version(m, "3.5-haiku") =>
130 {
131 (0.8, 4.0)
132 }
133 m if is_version(m, "3-haiku") || is_version(m, "haiku-3") => (0.25, 1.25),
134 m if m.contains("haiku") => (1.0, 5.0),
135 _ => (3.0, 15.0), }
137}
138
139pub fn cache_read_multiplier(model: &str) -> f64 {
146 match model {
147 m if is_version(m, "fable-5-1") || is_version(m, "mythos-5-1") => 0.025,
148 m if is_version(m, "opus-5-5") || is_version(m, "sonnet-5-5") => 0.05,
149 _ => 0.1,
150 }
151}
152
153pub fn calculate_cost(
170 model: &str,
171 input_tokens: u64,
172 output_tokens: u64,
173 cache_read: u64,
174 cache_creation: u64,
175) -> f64 {
176 calculate_cost_split(
177 model,
178 input_tokens,
179 output_tokens,
180 cache_read,
181 cache_creation,
182 0,
183 )
184}
185
186pub fn calculate_cost_split(
194 model: &str,
195 input_tokens: u64,
196 output_tokens: u64,
197 cache_read: u64,
198 cache_write_5m: u64,
199 cache_write_1h: u64,
200) -> f64 {
201 let prompt = input_tokens
202 .saturating_add(cache_read)
203 .saturating_add(cache_write_5m)
204 .saturating_add(cache_write_1h);
205 let (input_price, output_price) = model_prices(model, prompt);
206 (input_tokens as f64 / 1_000_000.0) * input_price
207 + (cache_read as f64 / 1_000_000.0) * input_price * cache_read_multiplier(model)
208 + (cache_write_5m as f64 / 1_000_000.0) * input_price * 1.25
209 + (cache_write_1h as f64 / 1_000_000.0) * input_price * 2.0
210 + (output_tokens as f64 / 1_000_000.0) * output_price
211}
212
213#[allow(clippy::too_many_arguments)]
222pub fn calculate_cost_optional_split(
223 model: &str,
224 input_tokens: u64,
225 output_tokens: u64,
226 cache_read: u64,
227 cache_creation: u64,
228 cache_creation_5m: Option<u64>,
229 cache_creation_1h: Option<u64>,
230) -> f64 {
231 match (cache_creation_5m, cache_creation_1h) {
232 (None, None) => calculate_cost(
233 model,
234 input_tokens,
235 output_tokens,
236 cache_read,
237 cache_creation,
238 ),
239 (c5, c1) => {
240 let c5 = c5.unwrap_or(0);
241 let c1 = c1.unwrap_or(0);
242 let remainder = cache_creation.saturating_sub(c5.saturating_add(c1));
243 calculate_cost_split(
244 model,
245 input_tokens,
246 output_tokens,
247 cache_read,
248 c5,
249 c1.saturating_add(remainder),
250 )
251 }
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn optional_split_preserves_aggregate_known_ttls_and_request_tier() {
261 let cases = [
263 (0, 100_001, Some(0), None, 0, 100_001),
264 (0, 100_001, None, Some(0), 0, 100_001),
265 (60_000, 40_000, Some(10_000), None, 10_000, 30_000),
266 (60_000, 40_001, Some(10_000), None, 10_000, 30_001),
267 (60_000, 40_001, None, Some(10_000), 0, 40_001),
268 (60_000, 40_001, Some(10_000), Some(10_000), 10_000, 30_001),
269 (60_000, 1, Some(20_000), Some(20_001), 20_000, 20_001),
270 (60_000, 1, Some(40_001), None, 40_001, 0),
271 (60_000, 40_001, Some(40_001), None, 40_001, 0),
272 (60_000, 40_001, None, Some(40_001), 0, 40_001),
273 (60_000, 40_001, Some(10_000), Some(30_001), 10_000, 30_001),
274 (0, u64::MAX, Some(1), None, 1, u64::MAX - 1),
275 (0, 1, Some(u64::MAX), Some(u64::MAX), u64::MAX, u64::MAX),
276 ];
277 for (input, aggregate, c5, c1, billed5, billed1) in cases {
278 let actual = calculate_cost_optional_split(
279 "claude-haiku-5.5",
280 input,
281 1_000,
282 0,
283 aggregate,
284 c5,
285 c1,
286 );
287 assert_eq!(
288 actual,
289 calculate_cost_split("claude-haiku-5.5", input, 1_000, 0, billed5, billed1,),
290 "input={input}, aggregate={aggregate}, split={c5:?}/{c1:?}"
291 );
292 assert!(actual.is_finite() && actual > 0.0);
293 }
294 assert_eq!(
296 calculate_cost_optional_split("claude-haiku-5.5", 60_000, 1_000, 0, 40_001, None, None,),
297 calculate_cost("claude-haiku-5.5", 60_000, 1_000, 0, 40_001)
298 );
299 }
300
301 #[test]
302 fn historical_haiku_3_is_bounded_and_not_haiku_3_5() {
303 for model in [
304 "claude-3-haiku",
305 "claude-3-haiku-20240307",
306 "haiku-3",
307 "claude-haiku-3@20240307",
308 "anthropic.claude-3-haiku-20240307-v1:0",
309 ] {
310 assert_eq!(calculate_cost(model, 1_000_000, 0, 0, 0), 0.25, "{model}");
311 assert_eq!(calculate_cost(model, 0, 1_000_000, 0, 0), 1.25, "{model}");
312 }
313 for model in [
314 "claude-3-5-haiku",
315 "claude-3.5-haiku",
316 "haiku-3-5",
317 "haiku-3.5",
318 ] {
319 assert_eq!(
320 calculate_cost(model, 1_000_000, 1_000_000, 0, 0),
321 4.8,
322 "{model}"
323 );
324 }
325 for model in [
326 "haiku-30",
327 "haiku-3garbage",
328 "3-haikuish",
329 "not3-haiku",
330 "claude-3-haiku-20240307oops",
331 ] {
332 assert_eq!(
333 calculate_cost(model, 1_000_000, 1_000_000, 0, 0),
334 6.0,
335 "{model}"
336 );
337 }
338 }
339
340 #[test]
341 fn version_aliases_preserve_prices_and_cache_rates() {
342 for model in [
343 "github-copilot/claude-opus-4.8",
344 "claude-opus-4.7",
345 "claude-opus-4-8",
346 "claude-opus-4-7",
347 ] {
348 assert_eq!(calculate_cost(model, 1_000_000, 1_000_000, 0, 0), 30.0);
349 }
350 for model in [
351 "claude-opus-4.1",
352 "claude-opus-4-1",
353 "claude-opus-4-20250514",
354 "claude-opus-4.1@20250805",
355 "anthropic.claude-opus-4-1-20250805-v1:0",
356 "claude-3-opus-20240229",
357 ] {
358 assert_eq!(calculate_cost(model, 1_000_000, 1_000_000, 0, 0), 90.0);
359 }
360 for (dotted, dashed) in [
361 ("opus-5.5", "opus-5-5"),
362 ("sonnet-5.5", "sonnet-5-5"),
363 ("haiku-5.5", "haiku-5-5"),
364 ("fable-5.1", "fable-5-1"),
365 ("mythos-5.1", "mythos-5-1"),
366 ("haiku-3.5", "haiku-3-5"),
367 ] {
368 assert_eq!(
369 calculate_cost_split(dotted, 60_000, 1_000, 20_000, 5_000, 10_000),
370 calculate_cost_split(dashed, 60_000, 1_000, 20_000, 5_000, 10_000)
371 );
372 assert_eq!(cache_read_multiplier(dotted), cache_read_multiplier(dashed));
373 }
374 for model in [
375 "opus-4garbage",
376 "opus-4.10",
377 "opus-4-1oops",
378 "opus-4-12345678oops",
379 "opus-30",
380 "notopus-4",
381 "3-opusish",
382 ] {
383 assert!(!is_legacy_opus(model), "{model}");
384 }
385 assert_eq!(cache_read_multiplier("opus-5-50"), 0.1);
386 assert_eq!(cache_read_multiplier("fable-5.10"), 0.1);
387 }
388
389 #[test]
390 fn fable_pricing() {
391 let cost = calculate_cost("claude-fable-5", 1_000_000, 1_000_000, 0, 0);
393 assert!((cost - 60.0).abs() < 1e-9, "expected $60, got ${cost}");
394 }
395
396 #[test]
397 fn opus_pricing() {
398 let cost = calculate_cost("claude-opus-4-5", 1_000_000, 1_000_000, 0, 0);
400 assert!((cost - 30.0).abs() < 1e-9, "expected $30, got ${cost}");
401 }
402
403 #[test]
404 fn sonnet_pricing() {
405 let cost = calculate_cost("claude-sonnet-4-5", 1_000_000, 1_000_000, 0, 0);
407 assert!((cost - 18.0).abs() < 1e-9, "expected $18, got ${cost}");
408 }
409
410 #[test]
411 fn haiku_pricing() {
412 let cost = calculate_cost("claude-haiku-4-5", 1_000_000, 1_000_000, 0, 0);
414 assert!((cost - 6.0).abs() < 1e-9, "expected $6, got ${cost}");
415 }
416
417 #[test]
418 fn cache_read_bills_at_tenth_input_rate() {
419 let cost = calculate_cost("claude-sonnet-4-5", 0, 0, 1_000_000, 0);
421 assert!((cost - 0.30).abs() < 1e-9, "expected $0.30, got ${cost}");
422 }
423
424 #[test]
425 fn cache_creation_bills_at_125_percent_input_rate() {
426 let cost = calculate_cost("claude-sonnet-4-5", 0, 0, 0, 1_000_000);
428 assert!((cost - 3.75).abs() < 1e-9, "expected $3.75, got ${cost}");
429 }
430
431 #[test]
432 fn cache_write_1h_bills_at_double_input_rate() {
433 let cost = calculate_cost_split("claude-sonnet-4-5", 0, 0, 0, 0, 1_000_000);
435 assert!((cost - 6.0).abs() < 1e-9, "expected $6.00, got ${cost}");
436 }
437
438 #[test]
439 fn split_mixes_5m_and_1h_rates() {
440 let cost = calculate_cost_split("claude-sonnet-4-5", 0, 0, 0, 1_000_000, 1_000_000);
442 assert!((cost - 9.75).abs() < 1e-9, "expected $9.75, got ${cost}");
443 }
444
445 #[test]
446 fn wrapper_equals_split_with_zero_1h() {
447 let cases: &[(&str, u64, u64, u64, u64)] = &[
449 ("claude-sonnet-4-5", 1000, 2000, 3000, 4000),
450 ("claude-opus-4-5", 0, 0, 0, 1_000_000),
451 ("claude-haiku-4-5", 123, 456, 789, 1011),
452 ("gpt-99-turbo", 50, 60, 70, 80),
453 ("claude-fable-5", 0, 0, 0, 0),
454 ];
455 for &(m, i, o, r, w) in cases {
456 let a = calculate_cost(m, i, o, r, w);
457 let b = calculate_cost_split(m, i, o, r, w, 0);
458 assert!((a - b).abs() < 1e-12, "{m}: {a} != {b}");
459 }
460 }
461
462 #[test]
463 fn inferx_deepseek_v4_flash_is_free() {
464 let cost = calculate_cost("inferx/deepseek-v4-flash", 1_000_000, 1_000_000, 0, 0);
465 assert_eq!(cost, 0.0);
466 }
467
468 #[test]
469 fn unknown_model_falls_back_to_sonnet() {
470 let cost_unknown = calculate_cost("gpt-99-turbo", 1_000_000, 0, 0, 0);
471 let cost_sonnet = calculate_cost("claude-sonnet-4-5", 1_000_000, 0, 0, 0);
472 assert!((cost_unknown - cost_sonnet).abs() < 1e-9);
473 }
474
475 #[test]
476 fn zero_usage_is_zero_cost() {
477 assert_eq!(calculate_cost("claude-opus-4-5", 0, 0, 0, 0), 0.0);
478 }
479
480 #[test]
481 fn opus_4_and_4_1_are_legacy_priced() {
482 for m in [
483 "claude-opus-4-0",
484 "claude-opus-4-20250514",
485 "claude-opus-4-1",
486 "claude-opus-4-1-20250805",
487 "claude-opus-4",
488 "claude-3-opus-20240229",
489 "claude-opus-4-1@20250805",
490 "claude-opus-4@20250514",
491 "claude-opus-4-0@20250514",
492 "anthropic.claude-opus-4-1-20250805-v1:0",
493 "anthropic.claude-opus-4-20250514-v1:0",
494 ] {
495 let cost = calculate_cost(m, 1_000_000, 1_000_000, 0, 0);
496 assert!((cost - 90.0).abs() < 1e-9, "{m}: ${cost}");
497 }
498 for m in [
499 "claude-opus-4-5",
500 "claude-opus-4-5-20251101",
501 "claude-opus-4-6",
502 "claude-opus-4-7",
503 "claude-opus-4-8",
504 "claude-opus-5",
505 "claude-opus-4-5@20251101",
506 "claude-opus-4-6@20260101",
507 "anthropic.claude-opus-4-5-20251101-v1:0",
508 ] {
509 let cost = calculate_cost(m, 1_000_000, 1_000_000, 0, 0);
510 assert!((cost - 30.0).abs() < 1e-9, "{m}: ${cost}");
511 }
512 }
513
514 #[test]
515 fn documented_cache_read_multipliers() {
516 for (m, want) in [
519 ("claude-fable-5-1", 0.25),
520 ("claude-mythos-5-1", 0.25),
521 ("claude-opus-5-5", 0.20),
522 ("claude-sonnet-5-5", 0.10),
523 ("claude-opus-4-7", 0.50),
524 ("claude-fable-5", 1.0),
525 ("gpt-99-turbo", 0.30),
526 ] {
527 let cost = calculate_cost(m, 0, 0, 1_000_000, 0);
528 assert!((cost - want).abs() < 1e-9, "{m}: ${cost} != ${want}");
529 }
530 }
531
532 #[test]
533 fn sonnet_5_is_two_and_ten() {
534 for m in [
537 "claude-sonnet-5",
538 "claude-sonnet-5@20260101",
539 "claude-sonnet-5-20260101",
540 "anthropic/claude-sonnet-5",
541 ] {
542 let base = calculate_cost(m, 1_000_000, 1_000_000, 0, 0);
543 assert!((base - 12.0).abs() < 1e-9, "{m}: ${base}");
544 let read = calculate_cost(m, 0, 0, 1_000_000, 0);
545 assert!((read - 0.20).abs() < 1e-9, "{m}: read ${read}");
546 let w5 = calculate_cost_split(m, 0, 0, 0, 1_000_000, 0);
547 assert!((w5 - 2.50).abs() < 1e-9, "{m}: 5m ${w5}");
548 let w1 = calculate_cost_split(m, 0, 0, 0, 0, 1_000_000);
549 assert!((w1 - 4.0).abs() < 1e-9, "{m}: 1h ${w1}");
550 }
551 let s55 = calculate_cost("claude-sonnet-5-5", 1_000_000, 1_000_000, 0, 0);
553 assert!((s55 - 12.0).abs() < 1e-9);
554 let s46 = calculate_cost("claude-sonnet-4-6", 1_000_000, 1_000_000, 0, 0);
555 assert!((s46 - 18.0).abs() < 1e-9);
556 let s51 = calculate_cost("claude-sonnet-5-1", 1_000_000, 1_000_000, 0, 0);
558 assert!((s51 - 18.0).abs() < 1e-9);
559 }
560
561 #[test]
562 fn haiku_5_5_low_tier_up_to_100k_prompt() {
563 let m = "claude-haiku-5-5";
564 let cost = calculate_cost(m, 100_000, 1_000, 0, 0);
567 assert!((cost - 0.0105).abs() < 1e-12, "${cost}");
568 let cost = calculate_cost_split(m, 40_000, 0, 50_000, 10_000, 0);
571 assert!((cost - 0.00575).abs() < 1e-12, "${cost}");
572 }
573
574 #[test]
575 fn haiku_5_5_high_tier_over_100k_prompt() {
576 let m = "claude-haiku-5-5";
577 let cost = calculate_cost(m, 100_001, 0, 0, 0);
579 assert!((cost - 0.100_001 * 0.5).abs() < 1e-12, "${cost}");
580 let cost = calculate_cost_split(m, 1_000, 1_000_000, 90_000, 0, 10_000);
585 let want = 0.0005 + 0.0045 + 0.01 + 2.5;
586 assert!((cost - want).abs() < 1e-12, "${cost} != ${want}");
587 }
588}