1use chrono::{DateTime, Utc};
2
3#[derive(Debug, Clone, Default)]
4pub struct TokenUsage {
5 pub input_tokens: u64,
6 pub output_tokens: u64,
7 pub cache_creation_tokens: u64,
8 pub cache_read_tokens: u64,
9}
10
11pub struct ModelPricing {
12 pub input_per_mtok: f64,
13 pub output_per_mtok: f64,
14 pub cache_write_per_mtok: f64,
15 pub cache_read_per_mtok: f64,
16}
17
18impl ModelPricing {
19 pub fn for_model(model: Option<&str>) -> Self {
28 let m = model.unwrap_or("").to_lowercase();
29 if m.trim().is_empty() {
30 sonnet_pricing()
31 } else if is_claude_fable_tier(&m) {
32 Self {
35 input_per_mtok: 10.0,
36 output_per_mtok: 50.0,
37 cache_write_per_mtok: 12.50,
38 cache_read_per_mtok: 1.00,
39 }
40 } else if is_claude_opus_fast(&m) {
41 if has_any(&m, &["opus-4-8", "opus-4.8"]) {
45 Self {
46 input_per_mtok: 10.0,
47 output_per_mtok: 50.0,
48 cache_write_per_mtok: 12.50,
49 cache_read_per_mtok: 1.00,
50 }
51 } else {
52 Self {
53 input_per_mtok: 30.0,
54 output_per_mtok: 150.0,
55 cache_write_per_mtok: 37.50,
56 cache_read_per_mtok: 3.00,
57 }
58 }
59 } else if is_claude_opus_latest(&m) {
60 Self {
61 input_per_mtok: 5.0,
62 output_per_mtok: 25.0,
63 cache_write_per_mtok: 6.25,
64 cache_read_per_mtok: 0.50,
65 }
66 } else if m.contains("opus") {
67 Self {
68 input_per_mtok: 15.0,
69 output_per_mtok: 75.0,
70 cache_write_per_mtok: 18.75,
71 cache_read_per_mtok: 1.50,
72 }
73 } else if is_claude_haiku_latest(&m) {
74 Self {
75 input_per_mtok: 1.0,
76 output_per_mtok: 5.0,
77 cache_write_per_mtok: 1.25,
78 cache_read_per_mtok: 0.10,
79 }
80 } else if is_claude_haiku_3(&m) {
81 Self {
83 input_per_mtok: 0.25,
84 output_per_mtok: 1.25,
85 cache_write_per_mtok: 0.3125,
86 cache_read_per_mtok: 0.025,
87 }
88 } else if m.contains("haiku") {
89 Self {
91 input_per_mtok: 0.80,
92 output_per_mtok: 4.0,
93 cache_write_per_mtok: 1.00,
94 cache_read_per_mtok: 0.08,
95 }
96 } else if m.contains("sonnet") {
97 sonnet_pricing()
98 } else if has_any(&m, &["gpt-5.5-pro"]) {
99 openai_pricing(30.0, 30.0, 180.0)
100 } else if has_any(&m, &["gpt-5.5"]) {
101 openai_pricing(5.0, 0.50, 30.0)
102 } else if has_any(&m, &["gpt-5.4-pro"]) {
103 openai_pricing(30.0, 30.0, 180.0)
104 } else if has_any(&m, &["gpt-5.4-mini"]) {
105 openai_pricing(0.75, 0.075, 4.50)
106 } else if has_any(&m, &["gpt-5.4-nano"]) {
107 openai_pricing(0.20, 0.02, 1.25)
108 } else if has_any(&m, &["gpt-5.4"]) {
109 openai_pricing(2.50, 0.25, 15.0)
110 } else if has_any(&m, &["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2"]) {
111 openai_pricing(1.75, 0.175, 14.0)
112 } else if has_any(&m, &["gpt-5-pro"]) {
113 openai_pricing(15.0, 15.0, 120.0)
114 } else if is_gpt5(&m) {
115 Self {
116 input_per_mtok: 1.25,
117 output_per_mtok: 10.0,
118 cache_write_per_mtok: 1.25,
119 cache_read_per_mtok: 0.125,
120 }
121 } else if has_any(&m, &["gpt-4.1-mini"]) {
122 openai_pricing(0.40, 0.10, 1.60)
123 } else if has_any(&m, &["gpt-4.1-nano"]) {
124 openai_pricing(0.10, 0.025, 0.40)
125 } else if has_any(&m, &["gpt-4.1"]) {
126 openai_pricing(2.0, 0.50, 8.0)
127 } else if has_any(&m, &["gpt-4.5"]) {
128 openai_pricing(75.0, 37.50, 150.0)
130 } else if has_any(&m, &["gpt-4o-mini"]) {
131 openai_pricing(0.15, 0.075, 0.60)
132 } else if has_any(&m, &["gpt-4o-2024-05-13"]) {
133 openai_pricing(5.0, 5.0, 15.0)
134 } else if has_any(&m, &["gpt-4-turbo", "gpt-4-1106", "gpt-4-0125"]) {
135 openai_pricing(10.0, 10.0, 30.0)
137 } else if has_any(&m, &["gpt-4-32k"]) {
138 openai_pricing(60.0, 60.0, 120.0)
139 } else if is_gpt4_classic(&m) {
140 openai_pricing(30.0, 30.0, 60.0)
142 } else if is_gpt4(&m) {
143 openai_pricing(2.50, 1.25, 10.0)
145 } else {
146 free_pricing()
147 }
148 }
149
150 pub fn name(model: Option<&str>) -> &'static str {
152 let m = model.unwrap_or("").to_lowercase();
153 if m.trim().is_empty() {
154 "Sonnet"
155 } else if m.contains("fable") {
156 "Fable"
157 } else if m.contains("mythos") {
158 "Mythos"
159 } else if m.contains("opus") {
160 "Opus"
161 } else if m.contains("haiku") {
162 "Haiku"
163 } else if m.contains("sonnet") {
164 "Sonnet"
165 } else if has_any(&m, &["gpt-oss", "gpt_oss", "gptoss"]) {
166 "GPT-OSS"
167 } else if is_gpt5(&m) {
168 "GPT-5"
169 } else if is_gpt4(&m) {
170 "GPT-4"
171 } else if has_any(&m, &["qwen", "qwq"]) {
172 "Qwen"
173 } else if has_any(&m, &["deepseek"]) {
174 "DeepSeek"
175 } else if has_any(&m, &["gemma"]) {
176 "Gemma"
177 } else if has_any(&m, &["llama", "codellama"]) {
178 "Llama"
179 } else if has_any(
180 &m,
181 &[
182 "mistral",
183 "mixtral",
184 "codestral",
185 "devstral",
186 "ministral",
187 "magistral",
188 ],
189 ) {
190 "Mistral"
191 } else if has_any(&m, &["phi-", "phi_", "phi3", "phi4"]) {
192 "Phi"
193 } else if has_any(&m, &["chatglm", "glm-"]) {
194 "GLM"
195 } else if has_any(&m, &["granite"]) {
196 "Granite"
197 } else if has_any(&m, &["falcon"]) {
198 "Falcon"
199 } else if has_any(&m, &["olmo"]) {
200 "OLMo"
201 } else if has_any(&m, &["bloom"]) {
202 "BLOOM"
203 } else if has_any(&m, &["starcoder"]) {
204 "StarCoder"
205 } else if has_any(&m, &["gemini"]) {
206 "Gemini"
207 } else if has_any(&m, &["grok"]) {
208 "Grok"
209 } else if has_any(&m, &["command-r", "command_r", "command r", "cohere"]) {
210 "Command R"
211 } else if has_any(&m, &["nemotron"]) {
212 "Nemotron"
213 } else if has_any(&m, &["kimi", "moonshot"]) {
214 "Kimi"
215 } else if has_any(&m, &["ernie"]) {
216 "ERNIE"
217 } else if has_any(&m, &["hunyuan"]) {
218 "Hunyuan"
219 } else if has_any(&m, &["internlm"]) {
220 "InternLM"
221 } else if has_any(&m, &["baichuan"]) {
222 "Baichuan"
223 } else if has_any(&m, &["minimax"]) {
224 "MiniMax"
225 } else if has_any(&m, &["doubao", "seed"]) {
226 "Seed"
227 } else if has_any(&m, &["yi-"]) || m == "yi" {
228 "Yi"
229 } else if has_any(&m, &["nova"]) {
230 "Nova"
231 } else if has_any(&m, &["titan"]) {
232 "Titan"
233 } else if has_any(&m, &["sonar", "perplexity"]) {
234 "Sonar"
235 } else if has_any(&m, &["solar"]) {
236 "SOLAR"
237 } else if has_any(&m, &["dbrx"]) {
238 "DBRX"
239 } else if has_any(&m, &["jamba"]) {
240 "Jamba"
241 } else if has_any(&m, &["llava"]) {
242 "LLaVA"
243 } else {
244 "Other"
245 }
246 }
247}
248
249fn is_gpt5(m: &str) -> bool {
250 m.contains("gpt-5") || m.contains("gpt5")
251}
252
253fn is_gpt4(m: &str) -> bool {
254 m.contains("gpt-4") || m.contains("gpt4")
255}
256
257fn is_claude_opus_fast(m: &str) -> bool {
258 is_claude_opus_latest(m) && m.contains("fast")
262}
263
264fn is_claude_fable_tier(m: &str) -> bool {
265 has_any(m, &["fable", "mythos"])
268}
269
270fn is_claude_opus_latest(m: &str) -> bool {
271 has_any(
272 m,
273 &[
274 "opus-4-5", "opus-4.5", "opus-4-6", "opus-4.6", "opus-4-7", "opus-4.7", "opus-4-8",
275 "opus-4.8",
276 ],
277 )
278}
279
280fn is_claude_haiku_latest(m: &str) -> bool {
281 has_any(m, &["haiku-4-5", "haiku-4.5"])
282}
283
284fn is_claude_haiku_3(m: &str) -> bool {
285 m.contains("3-haiku") && !m.contains("3-5-haiku")
288}
289
290fn is_gpt4_classic(m: &str) -> bool {
291 (m.contains("gpt-4") || m.contains("gpt4"))
295 && !m.contains("gpt-4o")
296 && !m.contains("gpt4o")
297 && !m.contains("gpt-4.1")
298 && !m.contains("gpt4.1")
299 && !m.contains("gpt-4.5")
300 && !m.contains("gpt4.5")
301}
302
303fn has_any(m: &str, needles: &[&str]) -> bool {
304 needles.iter().any(|needle| m.contains(needle))
305}
306
307fn sonnet_pricing() -> ModelPricing {
308 ModelPricing {
309 input_per_mtok: 3.0,
310 output_per_mtok: 15.0,
311 cache_write_per_mtok: 3.75,
312 cache_read_per_mtok: 0.30,
313 }
314}
315
316fn free_pricing() -> ModelPricing {
317 ModelPricing {
318 input_per_mtok: 0.0,
319 output_per_mtok: 0.0,
320 cache_write_per_mtok: 0.0,
321 cache_read_per_mtok: 0.0,
322 }
323}
324
325fn openai_pricing(input: f64, cached_input: f64, output: f64) -> ModelPricing {
326 ModelPricing {
327 input_per_mtok: input,
328 output_per_mtok: output,
329 cache_write_per_mtok: input,
330 cache_read_per_mtok: cached_input,
331 }
332}
333
334impl TokenUsage {
335 pub fn add(&mut self, other: &TokenUsage) {
336 self.input_tokens += other.input_tokens;
337 self.output_tokens += other.output_tokens;
338 self.cache_creation_tokens += other.cache_creation_tokens;
339 self.cache_read_tokens += other.cache_read_tokens;
340 }
341
342 pub fn cost_for_model(&self, model: Option<&str>) -> f64 {
344 let p = ModelPricing::for_model(model);
345 (self.input_tokens as f64 * p.input_per_mtok
346 + self.cache_creation_tokens as f64 * p.cache_write_per_mtok
347 + self.output_tokens as f64 * p.output_per_mtok
348 + self.cache_read_tokens as f64 * p.cache_read_per_mtok)
349 / 1_000_000.0
350 }
351
352 pub fn total_tokens(&self) -> u64 {
353 self.input_tokens + self.output_tokens + self.cache_creation_tokens + self.cache_read_tokens
354 }
355}
356
357#[derive(Debug, Clone)]
358pub struct SessionInfo {
359 pub project: String,
360 pub session_id: String,
361 pub file_path: Option<String>,
362 pub date: Option<DateTime<Utc>>,
363 pub message_count: usize,
364 pub duration_ms: u64,
365 pub model: Option<String>,
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
375 fn cost_zero_usage() {
376 let u = TokenUsage::default();
377 assert_eq!(u.cost_for_model(None), 0.0);
378 assert_eq!(u.cost_for_model(Some("claude-opus-4-6")), 0.0);
379 assert_eq!(u.cost_for_model(Some("claude-haiku-4-5")), 0.0);
380 assert_eq!(u.total_tokens(), 0);
381 }
382
383 #[test]
386 fn sonnet_input_1m() {
387 let u = TokenUsage {
388 input_tokens: 1_000_000,
389 ..Default::default()
390 };
391 assert!((u.cost_for_model(None) - 3.0).abs() < 0.0001);
393 }
394
395 #[test]
396 fn sonnet_output_1m() {
397 let u = TokenUsage {
398 output_tokens: 1_000_000,
399 ..Default::default()
400 };
401 assert!((u.cost_for_model(None) - 15.0).abs() < 0.0001);
403 }
404
405 #[test]
406 fn sonnet_cache_write_1m() {
407 let u = TokenUsage {
408 cache_creation_tokens: 1_000_000,
409 ..Default::default()
410 };
411 assert!((u.cost_for_model(None) - 3.75).abs() < 0.0001);
413 }
414
415 #[test]
416 fn sonnet_cache_read_1m() {
417 let u = TokenUsage {
418 cache_read_tokens: 1_000_000,
419 ..Default::default()
420 };
421 assert!((u.cost_for_model(None) - 0.30).abs() < 0.0001);
423 }
424
425 #[test]
426 fn sonnet_all_token_types() {
427 let u = TokenUsage {
428 input_tokens: 1_000_000,
429 output_tokens: 1_000_000,
430 cache_creation_tokens: 1_000_000,
431 cache_read_tokens: 1_000_000,
432 };
433 assert!((u.cost_for_model(Some("claude-sonnet-4-6")) - 22.05).abs() < 0.0001);
435 }
436
437 #[test]
440 fn opus_input_1m() {
441 let u = TokenUsage {
442 input_tokens: 1_000_000,
443 ..Default::default()
444 };
445 assert!((u.cost_for_model(Some("claude-opus-4-6")) - 5.0).abs() < 0.0001);
447 }
448
449 #[test]
450 fn opus_output_1m() {
451 let u = TokenUsage {
452 output_tokens: 1_000_000,
453 ..Default::default()
454 };
455 assert!((u.cost_for_model(Some("claude-opus-4-6")) - 25.0).abs() < 0.0001);
457 }
458
459 #[test]
460 fn opus_cache_write_1m() {
461 let u = TokenUsage {
462 cache_creation_tokens: 1_000_000,
463 ..Default::default()
464 };
465 assert!((u.cost_for_model(Some("claude-opus-4-6")) - 6.25).abs() < 0.0001);
467 }
468
469 #[test]
470 fn opus_cache_read_1m() {
471 let u = TokenUsage {
472 cache_read_tokens: 1_000_000,
473 ..Default::default()
474 };
475 assert!((u.cost_for_model(Some("claude-opus-4-6")) - 0.50).abs() < 0.0001);
477 }
478
479 #[test]
480 fn opus_all_token_types() {
481 let u = TokenUsage {
482 input_tokens: 1_000_000,
483 output_tokens: 1_000_000,
484 cache_creation_tokens: 1_000_000,
485 cache_read_tokens: 1_000_000,
486 };
487 assert!((u.cost_for_model(Some("claude-opus-4-7")) - 36.75).abs() < 0.0001);
489 }
490
491 #[test]
494 fn haiku_input_1m() {
495 let u = TokenUsage {
496 input_tokens: 1_000_000,
497 ..Default::default()
498 };
499 assert!((u.cost_for_model(Some("claude-haiku-4-5-20251001")) - 1.0).abs() < 0.0001);
501 }
502
503 #[test]
504 fn haiku_output_1m() {
505 let u = TokenUsage {
506 output_tokens: 1_000_000,
507 ..Default::default()
508 };
509 assert!((u.cost_for_model(Some("claude-haiku-4-5-20251001")) - 5.0).abs() < 0.0001);
511 }
512
513 #[test]
514 fn haiku_cache_write_1m() {
515 let u = TokenUsage {
516 cache_creation_tokens: 1_000_000,
517 ..Default::default()
518 };
519 assert!((u.cost_for_model(Some("claude-haiku-4-5")) - 1.25).abs() < 0.0001);
521 }
522
523 #[test]
524 fn haiku_cache_read_1m() {
525 let u = TokenUsage {
526 cache_read_tokens: 1_000_000,
527 ..Default::default()
528 };
529 assert!((u.cost_for_model(Some("claude-haiku-4-5")) - 0.10).abs() < 0.0001);
531 }
532
533 #[test]
534 fn haiku_all_token_types() {
535 let u = TokenUsage {
536 input_tokens: 1_000_000,
537 output_tokens: 1_000_000,
538 cache_creation_tokens: 1_000_000,
539 cache_read_tokens: 1_000_000,
540 };
541 assert!((u.cost_for_model(Some("claude-haiku-4-5")) - 7.35).abs() < 0.0001);
543 }
544
545 #[test]
548 fn fable_all_token_types() {
549 let u = TokenUsage {
550 input_tokens: 1_000_000,
551 output_tokens: 1_000_000,
552 cache_creation_tokens: 1_000_000,
553 cache_read_tokens: 1_000_000,
554 };
555 assert!((u.cost_for_model(Some("claude-fable-5")) - 73.50).abs() < 0.0001);
557 }
558
559 #[test]
560 fn mythos_priced_as_fable_tier() {
561 let u = TokenUsage {
562 input_tokens: 1_000_000,
563 ..Default::default()
564 };
565 assert!((u.cost_for_model(Some("claude-mythos-5")) - 10.0).abs() < 0.0001);
567 assert!((u.cost_for_model(Some("claude-mythos-preview")) - 10.0).abs() < 0.0001);
568 }
569
570 #[test]
571 fn fable_is_not_free_or_sonnet() {
572 let u = TokenUsage {
573 output_tokens: 1_000_000,
574 ..Default::default()
575 };
576 let fable = u.cost_for_model(Some("claude-fable-5"));
577 assert!((fable - 50.0).abs() < 0.0001, "fable output is $50/MTok");
578 assert!(
579 fable > u.cost_for_model(Some("claude-sonnet-4-6")),
580 "fable must not fall through to the Sonnet or free tier"
581 );
582 }
583
584 #[test]
587 fn opus_fast_mode_is_premium() {
588 let u = TokenUsage {
589 input_tokens: 1_000_000,
590 output_tokens: 1_000_000,
591 ..Default::default()
592 };
593 assert!((u.cost_for_model(Some("claude-opus-4-6-fast")) - 180.0).abs() < 0.0001);
595 assert!((u.cost_for_model(Some("claude-opus-4-7-fast")) - 180.0).abs() < 0.0001);
596 assert!((u.cost_for_model(Some("claude-opus-4-8-fast")) - 60.0).abs() < 0.0001);
598 assert!((u.cost_for_model(Some("claude-opus-4-8")) - 30.0).abs() < 0.0001);
600 assert_eq!(ModelPricing::name(Some("claude-opus-4-6-fast")), "Opus");
602 }
603
604 #[test]
605 fn opus_fast_cache_rates_stack_on_fast_input() {
606 let p = ModelPricing::for_model(Some("claude-opus-4-6-fast"));
607 assert_eq!(p.cache_write_per_mtok, 37.50); assert_eq!(p.cache_read_per_mtok, 3.00); let p8 = ModelPricing::for_model(Some("claude-opus-4-8-fast"));
610 assert_eq!(p8.cache_write_per_mtok, 12.50);
611 assert_eq!(p8.cache_read_per_mtok, 1.00);
612 }
613
614 #[test]
617 fn gpt5_all_token_types() {
618 let u = TokenUsage {
619 input_tokens: 1_000_000,
620 output_tokens: 1_000_000,
621 cache_creation_tokens: 1_000_000,
622 cache_read_tokens: 1_000_000,
623 };
624 assert!((u.cost_for_model(Some("gpt-5")) - 12.625).abs() < 0.0001);
626 assert!((u.cost_for_model(Some("gpt-5-codex")) - 12.625).abs() < 0.0001);
627 assert!((u.cost_for_model(Some("gpt-5.5")) - 40.5).abs() < 0.0001);
628 }
629
630 #[test]
631 fn gpt4_all_token_types() {
632 let u = TokenUsage {
633 input_tokens: 1_000_000,
634 output_tokens: 1_000_000,
635 cache_creation_tokens: 1_000_000,
636 cache_read_tokens: 1_000_000,
637 };
638 assert!((u.cost_for_model(Some("gpt-4o")) - 16.25).abs() < 0.0001);
640 }
641
642 #[test]
643 fn gpt_models_are_not_priced_as_sonnet() {
644 let u = TokenUsage {
645 output_tokens: 1_000_000,
646 ..Default::default()
647 };
648 let gpt = u.cost_for_model(Some("gpt-5-codex"));
650 let sonnet = u.cost_for_model(Some("claude-sonnet-4-6"));
651 assert!(
652 (gpt - 10.0).abs() < 0.0001,
653 "gpt-5 output should be $10/MTok"
654 );
655 assert!(
656 gpt < sonnet,
657 "gpt-5 ({gpt}) must not be priced as sonnet ({sonnet})"
658 );
659 }
660
661 #[test]
662 fn gpt_name_labels() {
663 assert_eq!(ModelPricing::name(Some("gpt-5.5")), "GPT-5");
664 assert_eq!(ModelPricing::name(Some("gpt-5-codex")), "GPT-5");
665 assert_eq!(ModelPricing::name(Some("gpt-4o")), "GPT-4");
666 }
667
668 #[test]
671 fn cost_ordering_all_token_types() {
672 let u = TokenUsage {
673 input_tokens: 500_000,
674 output_tokens: 500_000,
675 cache_creation_tokens: 500_000,
676 cache_read_tokens: 500_000,
677 };
678 let fable = u.cost_for_model(Some("claude-fable-5"));
679 let opus = u.cost_for_model(Some("claude-opus-4-6"));
680 let sonnet = u.cost_for_model(Some("claude-sonnet-4-6"));
681 let haiku = u.cost_for_model(Some("claude-haiku-4-5"));
682 assert!(fable > opus, "fable ({fable}) should > opus ({opus})");
683 assert!(opus > sonnet, "opus ({opus}) should > sonnet ({sonnet})");
684 assert!(sonnet > haiku, "sonnet ({sonnet}) should > haiku ({haiku})");
685 }
686
687 #[test]
690 fn realistic_opus_session_cost() {
691 let u = TokenUsage {
692 input_tokens: 5_000,
693 output_tokens: 100_000,
694 cache_creation_tokens: 50_000,
695 cache_read_tokens: 500_000_000, };
697 let cost = u.cost_for_model(Some("claude-opus-4-6"));
698 assert!((cost - 252.8375).abs() < 0.001, "got {cost}");
704 }
705
706 #[test]
709 fn add_all_fields() {
710 let mut a = TokenUsage {
711 input_tokens: 100,
712 output_tokens: 200,
713 cache_creation_tokens: 300,
714 cache_read_tokens: 400,
715 };
716 let b = TokenUsage {
717 input_tokens: 10,
718 output_tokens: 20,
719 cache_creation_tokens: 30,
720 cache_read_tokens: 40,
721 };
722 a.add(&b);
723 assert_eq!(a.input_tokens, 110);
724 assert_eq!(a.output_tokens, 220);
725 assert_eq!(a.cache_creation_tokens, 330);
726 assert_eq!(a.cache_read_tokens, 440);
727 }
728
729 #[test]
730 fn add_preserves_cost_linearity() {
731 let a = TokenUsage {
732 output_tokens: 500_000,
733 ..Default::default()
734 };
735 let b = TokenUsage {
736 output_tokens: 500_000,
737 ..Default::default()
738 };
739 let mut combined = a.clone();
740 combined.add(&b);
741 let separate = a.cost_for_model(None) + b.cost_for_model(None);
742 assert!((combined.cost_for_model(None) - separate).abs() < 0.0001);
743 }
744
745 #[test]
748 fn total_tokens_sums_all() {
749 let u = TokenUsage {
750 input_tokens: 1,
751 output_tokens: 2,
752 cache_creation_tokens: 3,
753 cache_read_tokens: 4,
754 };
755 assert_eq!(u.total_tokens(), 10);
756 }
757
758 #[test]
761 fn model_pricing_name() {
762 assert_eq!(ModelPricing::name(Some("claude-fable-5")), "Fable");
763 assert_eq!(ModelPricing::name(Some("claude-mythos-5")), "Mythos");
764 assert_eq!(ModelPricing::name(Some("claude-opus-4-7")), "Opus");
765 assert_eq!(ModelPricing::name(Some("claude-opus-4-6")), "Opus");
766 assert_eq!(ModelPricing::name(Some("claude-haiku-4-5")), "Haiku");
767 assert_eq!(
768 ModelPricing::name(Some("claude-haiku-4-5-20251001")),
769 "Haiku"
770 );
771 assert_eq!(ModelPricing::name(Some("claude-sonnet-4-6")), "Sonnet");
772 assert_eq!(ModelPricing::name(None), "Sonnet");
773 assert_eq!(ModelPricing::name(Some("")), "Sonnet");
774 assert_eq!(ModelPricing::name(Some("<synthetic>")), "Other");
775 assert_eq!(ModelPricing::name(Some("unknown-model")), "Other");
776 }
777
778 #[test]
779 fn local_and_open_weight_models_are_not_sonnet() {
780 let u = TokenUsage {
781 input_tokens: 1_000_000,
782 output_tokens: 1_000_000,
783 ..Default::default()
784 };
785
786 for (model, family) in [
787 ("qwen3.6-35b-a3b-ud-mlx", "Qwen"),
788 ("ollama/qwen3.6:35b", "Qwen"),
789 ("ollama/gemma4:31b", "Gemma"),
790 ("llama3.3:70b", "Llama"),
791 ("deepseek-r1:70b", "DeepSeek"),
792 ("mistral-small:24b", "Mistral"),
793 ("mixtral:8x22b", "Mistral"),
794 ("phi4:14b", "Phi"),
795 ("glm-5.1", "GLM"),
796 ("granite-code:34b", "Granite"),
797 ("falcon3:10b", "Falcon"),
798 ("olmo-2:13b", "OLMo"),
799 ("bloom:176b", "BLOOM"),
800 ("starcoder2:15b", "StarCoder"),
801 ("gemini-3.1-pro", "Gemini"),
802 ("grok-4.1", "Grok"),
803 ("command-r-plus", "Command R"),
804 ("nemotron-3-super", "Nemotron"),
805 ("kimi-k2.5-thinking", "Kimi"),
806 ("ernie-4.5", "ERNIE"),
807 ("hunyuan-large", "Hunyuan"),
808 ("internlm2.5:20b", "InternLM"),
809 ("baichuan2:13b", "Baichuan"),
810 ("minimax-m2.5", "MiniMax"),
811 ("doubao-seed-2.0", "Seed"),
812 ("yi-34b", "Yi"),
813 ("nova-pro", "Nova"),
814 ("titan-text", "Titan"),
815 ("sonar-pro", "Sonar"),
816 ("solar-10.7b", "SOLAR"),
817 ("dbrx-instruct", "DBRX"),
818 ("jamba-large", "Jamba"),
819 ("llava-1.6", "LLaVA"),
820 ("gpt-oss-120b", "GPT-OSS"),
821 ("unknown-local-model", "Other"),
822 ] {
823 assert_eq!(ModelPricing::name(Some(model)), family, "{model}");
824 assert_eq!(
825 u.cost_for_model(Some(model)),
826 0.0,
827 "{model} should not be charged at Sonnet fallback rates"
828 );
829 }
830 }
831
832 #[test]
835 fn pricing_constants_fable() {
836 let p = ModelPricing::for_model(Some("claude-fable-5"));
837 assert_eq!(p.input_per_mtok, 10.0);
838 assert_eq!(p.output_per_mtok, 50.0);
839 assert_eq!(p.cache_write_per_mtok, 12.50);
840 assert_eq!(p.cache_read_per_mtok, 1.00);
841 }
842
843 #[test]
844 fn pricing_constants_opus() {
845 let p = ModelPricing::for_model(Some("claude-opus-4-6"));
846 assert_eq!(p.input_per_mtok, 5.0);
847 assert_eq!(p.output_per_mtok, 25.0);
848 assert_eq!(p.cache_write_per_mtok, 6.25);
849 assert_eq!(p.cache_read_per_mtok, 0.50);
850 }
851
852 #[test]
853 fn pricing_constants_sonnet() {
854 let p = ModelPricing::for_model(Some("claude-sonnet-4-6"));
855 assert_eq!(p.input_per_mtok, 3.0);
856 assert_eq!(p.output_per_mtok, 15.0);
857 assert_eq!(p.cache_write_per_mtok, 3.75);
858 assert_eq!(p.cache_read_per_mtok, 0.30);
859 }
860
861 #[test]
862 fn pricing_constants_haiku() {
863 let p = ModelPricing::for_model(Some("claude-haiku-4-5"));
864 assert_eq!(p.input_per_mtok, 1.0);
865 assert_eq!(p.output_per_mtok, 5.0);
866 assert_eq!(p.cache_write_per_mtok, 1.25);
867 assert_eq!(p.cache_read_per_mtok, 0.10);
868 }
869
870 #[test]
871 fn pricing_fallback_is_sonnet() {
872 let default = ModelPricing::for_model(None);
873 let sonnet = ModelPricing::for_model(Some("claude-sonnet-4-6"));
874 assert_eq!(default.input_per_mtok, sonnet.input_per_mtok);
875 assert_eq!(default.output_per_mtok, sonnet.output_per_mtok);
876 assert_eq!(default.cache_write_per_mtok, sonnet.cache_write_per_mtok);
877 assert_eq!(default.cache_read_per_mtok, sonnet.cache_read_per_mtok);
878 }
879
880 #[test]
883 fn opus_is_5x_sonnet_input() {
884 let p_opus = ModelPricing::for_model(Some("opus"));
885 let p_sonnet = ModelPricing::for_model(Some("sonnet"));
886 assert!((p_opus.input_per_mtok / p_sonnet.input_per_mtok - 5.0).abs() < 0.001);
887 }
888
889 #[test]
890 fn opus_is_5x_sonnet_output() {
891 let p_opus = ModelPricing::for_model(Some("opus"));
892 let p_sonnet = ModelPricing::for_model(Some("sonnet"));
893 assert!((p_opus.output_per_mtok / p_sonnet.output_per_mtok - 5.0).abs() < 0.001);
894 }
895
896 #[test]
897 fn opus_is_5x_sonnet_cache_read() {
898 let p_opus = ModelPricing::for_model(Some("opus"));
899 let p_sonnet = ModelPricing::for_model(Some("sonnet"));
900 assert!((p_opus.cache_read_per_mtok / p_sonnet.cache_read_per_mtok - 5.0).abs() < 0.001);
901 }
902
903 fn input_1m(model: &str) -> f64 {
906 TokenUsage {
907 input_tokens: 1_000_000,
908 ..Default::default()
909 }
910 .cost_for_model(Some(model))
911 }
912
913 fn output_1m(model: &str) -> f64 {
914 TokenUsage {
915 output_tokens: 1_000_000,
916 ..Default::default()
917 }
918 .cost_for_model(Some(model))
919 }
920
921 #[test]
922 fn legacy_opus_3_is_full_price() {
923 let p = ModelPricing::for_model(Some("claude-3-opus-20240229"));
925 assert_eq!(p.input_per_mtok, 15.0);
926 assert_eq!(p.output_per_mtok, 75.0);
927 assert_eq!(p.cache_write_per_mtok, 18.75);
928 assert_eq!(p.cache_read_per_mtok, 1.50);
929 assert_eq!(
930 ModelPricing::for_model(Some("claude-opus-4-1")).input_per_mtok,
931 15.0
932 );
933 }
934
935 #[test]
936 fn claude_3_haiku_is_cheapest_tier() {
937 let p = ModelPricing::for_model(Some("claude-3-haiku-20240307"));
938 assert_eq!(p.input_per_mtok, 0.25);
939 assert_eq!(p.output_per_mtok, 1.25);
940 assert_eq!(p.cache_write_per_mtok, 0.3125);
941 assert_eq!(p.cache_read_per_mtok, 0.025);
942 assert!((input_1m("claude-3-haiku-20240307") - 0.25).abs() < 0.0001);
943 }
944
945 #[test]
946 fn claude_3_5_haiku_stays_legacy_not_claude_3() {
947 let p = ModelPricing::for_model(Some("claude-3-5-haiku-20241022"));
949 assert_eq!(
950 p.input_per_mtok, 0.80,
951 "3.5 Haiku keeps the $0.80 legacy rate"
952 );
953 assert_eq!(p.output_per_mtok, 4.0);
954 }
955
956 #[test]
957 fn gpt4_turbo_pricing() {
958 for id in [
959 "gpt-4-turbo",
960 "gpt-4-turbo-2024-04-09",
961 "gpt-4-1106-preview",
962 ] {
963 assert!((input_1m(id) - 10.0).abs() < 0.0001, "{id} input");
964 assert!((output_1m(id) - 30.0).abs() < 0.0001, "{id} output");
965 }
966 }
967
968 #[test]
969 fn gpt4_32k_pricing() {
970 assert!((input_1m("gpt-4-32k") - 60.0).abs() < 0.0001);
971 assert!((output_1m("gpt-4-32k") - 120.0).abs() < 0.0001);
972 }
973
974 #[test]
975 fn gpt4_classic_8k_pricing() {
976 for id in ["gpt-4", "gpt-4-0613", "gpt-4-0314"] {
977 assert!((input_1m(id) - 30.0).abs() < 0.0001, "{id} input");
978 assert!((output_1m(id) - 60.0).abs() < 0.0001, "{id} output");
979 }
980 }
981
982 #[test]
983 fn gpt4_family_disambiguation_unaffected() {
984 assert!((input_1m("gpt-4o") - 2.50).abs() < 0.0001, "gpt-4o base");
986 assert!(
987 (input_1m("gpt-4o-mini") - 0.15).abs() < 0.0001,
988 "gpt-4o-mini"
989 );
990 assert!((input_1m("gpt-4.1") - 2.0).abs() < 0.0001, "gpt-4.1");
991 assert!(
992 (input_1m("gpt-4.1-mini") - 0.40).abs() < 0.0001,
993 "gpt-4.1-mini"
994 );
995 }
996
997 #[test]
998 fn gpt4_5_preview_is_premium_not_classic() {
999 assert!(
1002 (input_1m("gpt-4.5-preview") - 75.0).abs() < 0.0001,
1003 "gpt-4.5 input"
1004 );
1005 assert!(
1006 (output_1m("gpt-4.5-preview") - 150.0).abs() < 0.0001,
1007 "gpt-4.5 output"
1008 );
1009 assert!(
1010 !is_gpt4_classic("gpt-4.5-preview"),
1011 "4.5 excluded from classic"
1012 );
1013 }
1014}