Skip to main content

claudex/
types.rs

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    /// Per-million-token pricing for a model id.
20    ///
21    /// Missing/empty model ids preserve the historical Claude Sonnet fallback
22    /// for old Claude transcripts that did not record a model. Non-empty model
23    /// ids are only priced when we recognize a priced family. Unknown, local,
24    /// and open-weight models are treated as free unless the provider supplied
25    /// an embedded cost (see `ModelSessionStats::embedded_cost`). This avoids
26    /// fabricating Sonnet charges for Ollama/MLX/vLLM/OpenRouter-style models.
27    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            // Claude Fable 5 / Mythos 5 — the frontier tier above Opus
33            // (GA June 9, 2026): $10/$50, standard 1.25x/0.1x cache multipliers.
34            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            // Fast mode (research preview) carries premium rates over standard
42            // Opus: $30/$150 on Opus 4.6/4.7, $10/$50 on Opus 4.8. The cache
43            // multipliers (1.25x write / 0.1x read) stack on the fast input rate.
44            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            // Claude 3 Haiku — the cheapest legacy Haiku tier ($0.25/$1.25).
82            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            // Claude 3.5 Haiku (revised Dec 2024) and any other legacy Haiku.
90            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            // GPT-4.5 preview ("Orion") — premium research-preview pricing.
129            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            // GPT-4 Turbo — predates prompt caching, so cache rates = input.
136            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            // Original GPT-4 (8k) — far pricier than GPT-4o / GPT-4.1.
141            openai_pricing(30.0, 30.0, 60.0)
142        } else if is_gpt4(&m) {
143            // GPT-4o (base) and anything else in the gpt-4 family.
144            openai_pricing(2.50, 1.25, 10.0)
145        } else {
146            free_pricing()
147        }
148    }
149
150    /// Short display label for a model's family.
151    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    // Fast-mode Opus ids (`claude-opus-4-6-fast`, `claude-opus-4-8-fast`).
259    // Fast mode only exists on Opus 4.6+, so anchor on the modern-Opus match
260    // rather than `fast` alone to avoid false positives.
261    is_claude_opus_latest(m) && m.contains("fast")
262}
263
264fn is_claude_fable_tier(m: &str) -> bool {
265    // Claude Fable 5 (`claude-fable-5`) and the limited-availability Claude
266    // Mythos 5 / Mythos Preview share the same published $10/$50 rate card.
267    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    // Claude 3 Haiku ids look like `claude-3-haiku-20240307`. Exclude the 3.5
286    // Haiku ids (`claude-3-5-haiku-*`), which keep the legacy $0.80/$4 rate.
287    m.contains("3-haiku") && !m.contains("3-5-haiku")
288}
289
290fn is_gpt4_classic(m: &str) -> bool {
291    // Original GPT-4 family (gpt-4, gpt-4-0613, gpt-4-8k) — but NOT the far
292    // cheaper gpt-4o, the gpt-4.1 family, or the premium gpt-4.5 preview, each
293    // of which carries its own tier.
294    (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    /// Model-aware cost in USD.
343    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    // --- Zero / default ---
373
374    #[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    // --- Sonnet pricing (default fallback) ---
384
385    #[test]
386    fn sonnet_input_1m() {
387        let u = TokenUsage {
388            input_tokens: 1_000_000,
389            ..Default::default()
390        };
391        // $3/MTok
392        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        // $15/MTok
402        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        // $3.75/MTok
412        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        // $0.30/MTok
422        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        // $3 + $15 + $3.75 + $0.30 = $22.05
434        assert!((u.cost_for_model(Some("claude-sonnet-4-6")) - 22.05).abs() < 0.0001);
435    }
436
437    // --- Opus pricing ---
438
439    #[test]
440    fn opus_input_1m() {
441        let u = TokenUsage {
442            input_tokens: 1_000_000,
443            ..Default::default()
444        };
445        // $5/MTok
446        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        // $25/MTok
456        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        // $6.25/MTok
466        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        // $0.50/MTok
476        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        // $5 + $25 + $6.25 + $0.50 = $36.75
488        assert!((u.cost_for_model(Some("claude-opus-4-7")) - 36.75).abs() < 0.0001);
489    }
490
491    // --- Haiku pricing ---
492
493    #[test]
494    fn haiku_input_1m() {
495        let u = TokenUsage {
496            input_tokens: 1_000_000,
497            ..Default::default()
498        };
499        // $1/MTok
500        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        // $5/MTok
510        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        // $1.25/MTok
520        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        // $0.10/MTok
530        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        // $1.00 + $5.00 + $1.25 + $0.10 = $7.35
542        assert!((u.cost_for_model(Some("claude-haiku-4-5")) - 7.35).abs() < 0.0001);
543    }
544
545    // --- Fable pricing (frontier tier) ---
546
547    #[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        // $10 + $50 + $12.50 + $1.00 = $73.50
556        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        // Claude Mythos 5 / Mythos Preview share Fable's $10/MTok input rate.
566        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    // --- Opus fast mode (premium rates) ---
585
586    #[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        // Opus 4.6 / 4.7 fast: $30 + $150 = $180
594        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        // Opus 4.8 fast: $10 + $50 = $60
597        assert!((u.cost_for_model(Some("claude-opus-4-8-fast")) - 60.0).abs() < 0.0001);
598        // Standard Opus ids must stay on the $5/$25 card.
599        assert!((u.cost_for_model(Some("claude-opus-4-8")) - 30.0).abs() < 0.0001);
600        // Fast ids still display as the Opus family.
601        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); // 1.25x of $30
608        assert_eq!(p.cache_read_per_mtok, 3.00); // 0.1x of $30
609        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    // --- OpenAI GPT pricing (Codex) ---
615
616    #[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        // $1.25 + $10.00 + $1.25 + $0.125 = $12.625
625        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        // $2.50 + $10.00 + $2.50 + $1.25 = $16.25
639        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        // Sonnet output is $15/MTok; GPT-5 is $10/MTok — they must differ.
649        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    // --- Cross-model ordering ---
669
670    #[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    // --- Realistic scenario: cache-heavy Opus session ---
688
689    #[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, // 500M cache reads
696        };
697        let cost = u.cost_for_model(Some("claude-opus-4-6"));
698        // input:  5K * 5 / 1M = $0.025
699        // output: 100K * 25 / 1M = $2.50
700        // cache_w: 50K * 6.25 / 1M = $0.3125
701        // cache_r: 500M * 0.50 / 1M = $250.00
702        // total: $252.8375
703        assert!((cost - 252.8375).abs() < 0.001, "got {cost}");
704    }
705
706    // --- add() ---
707
708    #[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    // --- total_tokens() ---
746
747    #[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    // --- Model name detection ---
759
760    #[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    // --- Pricing constants verification ---
833
834    #[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    // --- Opus:Sonnet ratio verification ---
881
882    #[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    // --- Historical / legacy tiers ---
904
905    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        // Claude 3 / Opus 4 / 4.1 predate the 4.5+ price cut.
924        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        // `claude-3-5-haiku-*` must NOT match the Claude 3 Haiku tier.
948        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        // The new classic-GPT-4 tier must not steal gpt-4o / gpt-4.1 ids.
985        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        // `gpt-4.5-preview` must NOT fall into the classic GPT-4 8k tier ($30/$60);
1000        // it has its own premium rate ($75/$150).
1001        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}