Skip to main content

agent_core/
pricing.rs

1//! Centralised pricing logic for Anthropic models.
2//!
3//! All cost calculations live here so that the engine, TUI, and any future
4//! consumer share a single source of truth. Update this file — and only this
5//! file — whenever Anthropic changes its pricing.
6//!
7//! Prices are in USD per million tokens.
8//! Source: <https://platform.claude.com/docs/en/about-claude/pricing>
9//! ("Model pricing", "Batch processing", "Long context pricing" and
10//! "Feature-specific pricing → Prompt caching").
11//!
12//! | Model                         | Input  | Output | Cache read |
13//! |-------------------------------|--------|--------|------------|
14//! | Fable 5.1 / Mythos 5.1        | $10.00 | $50.00 | 0.025×     |
15//! | Fable 5 / Mythos 5            | $10.00 | $50.00 | 0.10×      |
16//! | Opus 5.5                      | $4.00  | $20.00 | 0.05×      |
17//! | Opus 4.5 – 5                  | $5.00  | $25.00 | 0.10×      |
18//! | Opus 4 / 4.1 (and Opus 3)     | $15.00 | $75.00 | 0.10×      |
19//! | Sonnet 5.5                    | $2.00  | $10.00 | 0.05×      |
20//! | Sonnet 5                      | $2.00  | $10.00 | 0.10×      |
21//! | Sonnet (other)                | $3.00  | $15.00 | 0.10×      |
22//! | Haiku 5.5, prompt ≤ 100k      | $0.10  | $0.50  | 0.10×      |
23//! | Haiku 5.5, prompt > 100k      | $0.50  | $2.50  | 0.10×      |
24//! | Haiku 3.5                     | $0.80  | $4.00  | 0.10×      |
25//! | Haiku 3 (historical)          | $0.25  | $1.25  | fallback* |
26//! | Haiku (other)                 | $1.00  | $5.00  | 0.10×      |
27//!
28//! Historical Opus 3 / Haiku 3 input and output rates:
29//! <https://www.anthropic.com/news/claude-3-family> (verified 2026-10-10).
30//! *Legacy cache rates use the standard multiplier fallback; historical cache
31//! support and prices were not verified from that launch source.
32//!
33//! **Sonnet 5.** The official model overview explicitly lists $2 input / $10
34//! output per million tokens (verified 2026-10-10):
35//! <https://platform.claude.com/docs/en/models/sonnet-5/overview>.
36//! These are standard global direct-API estimates, not complete invoices:
37//! fast-mode/residency multipliers and server-tool charges are not modeled.
38//! Unknown-model fallbacks and routed/cloud/subscription model IDs are estimates,
39//! not verified provider invoice rates or net charges after credits/discounts.
40//!
41//! **Haiku 5.5** is priced by prompt length ("Long context pricing"): a prompt
42//! of over 100,000 tokens pays the higher row. The prompt is everything the
43//! request sent — uncached input + cache reads + cache writes — so
44//! [`calculate_cost_split`] picks the tier per request from those counts.
45//!
46//! Cache pricing (relative to input price):
47//! - Cache reads:       0.10× input price, except where the docs list a
48//!   per-model multiplier (see [`cache_read_multiplier`])
49//! - Cache write (5m):  1.25× input price  (5-minute TTL, the default)
50//! - Cache write (1h):  2.00× input price  (1-hour TTL, opt-in via `cache_ttl`)
51
52/// Haiku 5.5 bills a prompt of more than this many tokens at the higher tier
53/// (<https://platform.claude.com/docs/en/about-claude/pricing>, "Long context
54/// pricing").
55const HAIKU_5_5_TIER_TOKENS: u64 = 100_000;
56
57/// Match a complete version, accepting dotted and hyphenated minor versions.
58/// Only documented ID suffix shapes are stripped: snapshot dates, Vertex dates,
59/// and Bedrock revisions. Similar-looking version prefixes are not aliases.
60fn 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/// Returns `(input_price_per_mtok, output_price_per_mtok)` for the given model
100/// string and the request's prompt size (uncached input + cache read + cache
101/// write tokens; only Haiku 5.5 is tiered on it). Matching is substring-based
102/// so it works with full model IDs like `claude-opus-4-5-20251101` as well as
103/// short names like `claude-opus`.
104///
105/// Falls back to Sonnet pricing for unknown models.
106#[inline]
107fn model_prices(model: &str, prompt_tokens: u64) -> (f64, f64) {
108    match model {
109        // InferX publishes DeepSeek V4 Flash at $0/M input and output.
110        // Keep this exact ID before generic DeepSeek entries are ever added.
111        "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        // Sonnet 5: explicit base rates in the model overview (module docs).
118        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), // default: Sonnet pricing
136    }
137}
138
139/// Cache-read price as a multiple of the input price
140/// (<https://platform.claude.com/docs/en/about-claude/pricing>, "Prompt
141/// caching"). The docs list 0.025×
142/// for Fable 5.1 / Mythos 5.1 and 0.05× for Opus 5.5 / Sonnet 5.5; every
143/// other model uses the standard 0.1× (also the conservative default for
144/// models this table does not know).
145pub 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
153/// Calculate the USD cost of a single model turn (no cache-write TTL split).
154///
155/// Thin wrapper over [`calculate_cost_split`]: the aggregate `cache_creation`
156/// count is billed at the 5m write rate (1.25×). When the user opted into 1h
157/// caching but the TTL split didn't arrive, this under-bills (fail-cheap, not
158/// fail-expensive — cost display is informational, not invoiced).
159///
160/// # Arguments
161/// * `model`           – Model identifier string (e.g. `"claude-sonnet-4-5"`).
162/// * `input_tokens`    – Uncached input tokens billed at full input rate.
163/// * `output_tokens`   – Output / generated tokens (includes adaptive thinking).
164/// * `cache_read`      – Tokens served from the prompt cache (0.10× input rate).
165/// * `cache_creation`  – Tokens written to the prompt cache (1.25× input rate).
166///
167/// # Returns
168/// Cost in USD for this turn.
169pub 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
186/// Calculate the USD cost of a single model turn with the cache-write TTL
187/// split made first-class.
188///
189/// Cache pricing relative to input price:
190/// reads [`cache_read_multiplier`] (0.10× by default) | 5m write 1.25× |
191/// 1h write 2.0×. Haiku 5.5's tier is chosen from this request's prompt
192/// (`input_tokens + cache_read + cache_write_5m + cache_write_1h`).
193pub 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/// Split-aware cost for callers holding an aggregate plus an *optional* TTL
214/// split (the shape of `SessionEvent::Usage`). When either split bucket is
215/// present, preserve its known count and assign any aggregate remainder to
216/// the conservative 1h rate. This is an estimate, not an inferred TTL. A split
217/// sum exceeding the aggregate is malformed metadata: retain the larger split
218/// sum rather than dropping known writes or lowering the request's price tier.
219/// Arithmetic saturates. When both are `None`, TTL is explicitly unknown and
220/// the existing aggregate-at-5m estimate is retained (potentially underbilling).
221#[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        // (input, aggregate, reported 5m, reported 1h, billed 5m, billed 1h)
262        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        // No TTL evidence: intentionally retain the historical 5m estimate.
295        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        // 1M input + 1M output, no cache → $10 + $50 = $60
392        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        // 1M input + 1M output, no cache → $5 + $25 = $30
399        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        // 1M input + 1M output → $3 + $15 = $18
406        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        // 1M input + 1M output → $1 + $5 = $6
413        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        // 1M cache-read tokens for Sonnet: 0.1 × $3 = $0.30
420        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        // 1M cache-write tokens for Sonnet: 1.25 × $3 = $3.75
427        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        // 1M 1h cache-write tokens for Sonnet: 2.0 × $3 = $6.00 (spec §5)
434        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        // Sonnet: 1M @ 1.25× ($3.75) + 1M @ 2.0× ($6.00) = $9.75
441        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        // calculate_cost(m,i,o,r,w) == calculate_cost_split(m,i,o,r,w,0)
448        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        // Fable 5.1: $10 × 0.025 = $0.25/MTok; Opus 5.5: $4 × 0.05 = $0.20;
517        // Sonnet 5.5: $2 × 0.05 = $0.10; Opus 4.7: $5 × 0.1 = $0.50.
518        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        // $2 + $10 = $12 for 1M in + 1M out; reads 0.1 × $2 = $0.20/MTok,
535        // 5m writes $2.50, 1h writes $4 — the page's Sonnet 5 cache cells.
536        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        // Sonnet 5.5 and 4.x are unaffected.
552        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        // An unlisted later minor keeps the family default.
557        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        // 100_000-token prompt (exactly at the boundary) + 1_000 out:
565        // 0.1 × $0.10 + 0.001 × $0.50 = $0.0105.
566        let cost = calculate_cost(m, 100_000, 1_000, 0, 0);
567        assert!((cost - 0.0105).abs() < 1e-12, "${cost}");
568        // Mixed prompt still ≤ 100k: 40k in + 50k read + 10k 5m write.
569        // 0.04×0.10 + 0.05×0.01 + 0.01×0.125 = 0.004 + 0.0005 + 0.00125.
570        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        // 100_001 uncached in: 0.100001 × $0.50.
578        let cost = calculate_cost(m, 100_001, 0, 0, 0);
579        assert!((cost - 0.100_001 * 0.5).abs() < 1e-12, "${cost}");
580        // Prompt crosses 100k only through cache reads + 1h writes; the
581        // whole request (output too) moves to the high tier.
582        // 1k in ($0.0005) + 90k read (0.09 × $0.05) + 10k 1h write
583        // (0.01 × $1) + 1M out ($2.50).
584        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}