1use std::collections::BTreeMap;
35use std::fmt;
36use std::str::FromStr;
37
38use rust_decimal::Decimal;
39use serde::{Deserialize, Serialize};
40
41const TOKENS_PER_UNIT: u64 = 1_000_000;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50#[serde(try_from = "String", into = "String")]
51pub struct AsOf {
52 pub year: u16,
54 pub month: u8,
56 pub day: u8,
58}
59
60impl AsOf {
61 pub fn new(year: u16, month: u8, day: u8) -> Result<Self, AsOfParseError> {
70 let length = match month {
71 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
72 4 | 6 | 9 | 11 => 30,
73 2 if is_leap(year) => 29,
74 2 => 28,
75 _ => return Err(AsOfParseError::OutOfRange),
76 };
77 if day == 0 || day > length {
78 return Err(AsOfParseError::OutOfRange);
79 }
80 Ok(Self { year, month, day })
81 }
82
83 pub fn is_older_than(self, horizon: Self) -> bool {
90 self < horizon
91 }
92}
93
94impl fmt::Display for AsOf {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
97 }
98}
99
100const fn is_leap(year: u16) -> bool {
101 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
106pub enum AsOfParseError {
107 #[error("a pricing date must be YYYY-MM-DD")]
109 Malformed,
110 #[error("that is not a calendar date")]
112 OutOfRange,
113}
114
115impl FromStr for AsOf {
116 type Err = AsOfParseError;
117
118 fn from_str(s: &str) -> Result<Self, Self::Err> {
119 let mut parts = s.split('-');
120 let (Some(y), Some(m), Some(d), None) =
121 (parts.next(), parts.next(), parts.next(), parts.next())
122 else {
123 return Err(AsOfParseError::Malformed);
124 };
125 if (y.len(), m.len(), d.len()) != (4, 2, 2) {
126 return Err(AsOfParseError::Malformed);
127 }
128 let year = y.parse().map_err(|_| AsOfParseError::Malformed)?;
129 let part = |t: &str| t.parse::<u8>().map_err(|_| AsOfParseError::Malformed);
130 Self::new(year, part(m)?, part(d)?)
131 }
132}
133
134impl TryFrom<String> for AsOf {
135 type Error = AsOfParseError;
136
137 fn try_from(s: String) -> Result<Self, Self::Error> {
138 s.parse()
139 }
140}
141
142impl From<AsOf> for String {
143 fn from(date: AsOf) -> Self {
144 date.to_string()
145 }
146}
147
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
153pub struct TokenUsage {
154 pub input: u64,
156 pub output: u64,
158 pub cache_write: u64,
160 pub cache_read: u64,
162}
163
164impl TokenUsage {
165 pub const NONE: Self = Self {
167 input: 0,
168 output: 0,
169 cache_write: 0,
170 cache_read: 0,
171 };
172
173 pub fn prompt_tokens(&self) -> u64 {
175 self.input
176 .saturating_add(self.cache_write)
177 .saturating_add(self.cache_read)
178 }
179
180 pub fn total_tokens(&self) -> u64 {
182 self.prompt_tokens().saturating_add(self.output)
183 }
184
185 pub fn cache_hit_rate(&self) -> Option<Decimal> {
191 let prompt = self.prompt_tokens();
192 if prompt == 0 {
193 return None;
194 }
195 Some(Decimal::from(self.cache_read) * Decimal::ONE_HUNDRED / Decimal::from(prompt))
196 }
197
198 #[must_use]
200 pub fn plus(self, other: Self) -> Self {
201 Self {
202 input: self.input.saturating_add(other.input),
203 output: self.output.saturating_add(other.output),
204 cache_write: self.cache_write.saturating_add(other.cache_write),
205 cache_read: self.cache_read.saturating_add(other.cache_read),
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct CostModel {
236 pub input: Decimal,
238 pub output: Decimal,
240 pub cache_read: Decimal,
245 pub cache_write: Decimal,
251 pub cache_write_long: Option<Decimal>,
253 pub context_window: u32,
259 pub max_output: u32,
261 pub as_of: AsOf,
263}
264
265impl CostModel {
266 pub fn new(input: Decimal, output: Decimal, as_of: AsOf) -> Self {
272 Self {
273 input,
274 output,
275 cache_read: input,
276 cache_write: input,
277 cache_write_long: None,
278 context_window: 0,
279 max_output: 0,
280 as_of,
281 }
282 }
283
284 #[must_use]
286 pub fn with_cache_rates(mut self, read: Decimal, write: Decimal) -> Self {
287 self.cache_read = read;
288 self.cache_write = write;
289 self
290 }
291
292 #[must_use]
294 pub fn with_long_cache_write(mut self, write: Decimal) -> Self {
295 self.cache_write_long = Some(write);
296 self
297 }
298
299 #[must_use]
301 pub fn with_context_window(mut self, tokens: u32) -> Self {
302 self.context_window = tokens;
303 self
304 }
305
306 #[must_use]
308 pub fn with_max_output(mut self, tokens: u32) -> Self {
309 self.max_output = tokens;
310 self
311 }
312
313 pub fn price(&self, usage: &TokenUsage) -> Decimal {
319 let per = |tokens: u64, rate: Decimal| Decimal::from(tokens) * rate;
320 (per(usage.input, self.input)
321 + per(usage.output, self.output)
322 + per(usage.cache_read, self.cache_read)
323 + per(usage.cache_write, self.cache_write))
324 / Decimal::from(TOKENS_PER_UNIT)
325 }
326
327 pub fn admits(&self, prompt_tokens: u32) -> bool {
329 self.context_window == 0 || prompt_tokens <= self.context_window
330 }
331
332 pub fn is_stale(&self, horizon: AsOf) -> bool {
334 self.as_of.is_older_than(horizon)
335 }
336
337 pub fn prices_alike(&self, other: &Self) -> bool {
344 self.input == other.input
345 && self.output == other.output
346 && self.cache_read == other.cache_read
347 && self.cache_write == other.cache_write
348 }
349
350 pub fn costlier(&self, other: &Self) -> Self {
356 Self {
357 input: self.input.max(other.input),
358 output: self.output.max(other.output),
359 cache_read: self.cache_read.max(other.cache_read),
360 cache_write: self.cache_write.max(other.cache_write),
361 cache_write_long: match (self.cache_write_long, other.cache_write_long) {
362 (Some(a), Some(b)) => Some(a.max(b)),
363 (a, b) => a.or(b),
364 },
365 context_window: match (self.context_window, other.context_window) {
368 (0, w) | (w, 0) => w,
369 (a, b) => a.min(b),
370 },
371 max_output: match (self.max_output, other.max_output) {
372 (0, w) | (w, 0) => w,
373 (a, b) => a.min(b),
374 },
375 as_of: self.as_of.min(other.as_of),
376 }
377 }
378}
379
380#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(transparent)]
385pub struct Pricing {
386 models: BTreeMap<String, CostModel>,
387}
388
389impl Pricing {
390 pub fn new() -> Self {
392 Self::default()
393 }
394
395 pub fn from_json(json: &str) -> Result<Self, PricingError> {
402 serde_json::from_str(json).map_err(|e| PricingError::Malformed(e.to_string()))
403 }
404
405 pub fn to_json(&self) -> Result<String, PricingError> {
411 serde_json::to_string_pretty(self).map_err(|e| PricingError::Malformed(e.to_string()))
412 }
413
414 #[must_use]
416 pub fn with(mut self, model: impl Into<String>, cost: CostModel) -> Self {
417 self.models.insert(model.into(), cost);
418 self
419 }
420
421 pub fn get(&self, model: &str) -> Option<&CostModel> {
423 self.models.get(model)
424 }
425
426 pub fn models(&self) -> impl Iterator<Item = (&str, &CostModel)> {
428 self.models.iter().map(|(k, v)| (k.as_str(), v))
429 }
430
431 pub fn len(&self) -> usize {
433 self.models.len()
434 }
435
436 pub fn is_empty(&self) -> bool {
438 self.models.is_empty()
439 }
440
441 pub fn stale(&self, horizon: AsOf) -> Vec<&str> {
443 self.models
444 .iter()
445 .filter(|(_, cost)| cost.is_stale(horizon))
446 .map(|(id, _)| id.as_str())
447 .collect()
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
453pub enum PricingError {
454 #[error("the pricing catalogue is malformed: {0}")]
456 Malformed(String),
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn usd(s: &str) -> Decimal {
464 s.parse().unwrap()
465 }
466
467 fn opus() -> CostModel {
468 CostModel::new(usd("5.00"), usd("25.00"), AsOf::new(2026, 6, 24).unwrap())
469 .with_cache_rates(usd("0.50"), usd("6.25"))
470 .with_long_cache_write(usd("10.00"))
471 .with_context_window(1_000_000)
472 .with_max_output(128_000)
473 }
474
475 #[test]
478 fn a_cost_model_carries_input_output_and_the_context_ceiling() {
479 let m = opus();
480 assert_eq!(m.input, usd("5.00"));
481 assert_eq!(m.output, usd("25.00"));
482 assert_eq!(m.context_window, 1_000_000);
483 assert_eq!(m.max_output, 128_000);
484 }
485
486 #[test]
489 fn cached_reads_are_priced_separately_from_uncached_input() {
490 let m = opus();
491 assert_ne!(m.cache_read, m.input);
492 assert!(
493 m.cache_read < m.input,
494 "a read should be cheaper than a miss"
495 );
496 assert!(
497 m.cache_write > m.input,
498 "a write should cost more than not caching"
499 );
500
501 let million = 1_000_000;
502 assert_eq!(
503 m.price(&TokenUsage {
504 input: million,
505 ..TokenUsage::NONE
506 }),
507 usd("5.00")
508 );
509 assert_eq!(
510 m.price(&TokenUsage {
511 cache_read: million,
512 ..TokenUsage::NONE
513 }),
514 usd("0.50")
515 );
516 assert_eq!(
517 m.price(&TokenUsage {
518 cache_write: million,
519 ..TokenUsage::NONE
520 }),
521 usd("6.25")
522 );
523 }
524
525 #[test]
528 fn a_model_with_no_cache_tier_prices_reads_at_the_input_rate() {
529 let m = CostModel::new(usd("3.00"), usd("15.00"), AsOf::new(2026, 6, 24).unwrap());
530 assert_eq!(m.cache_read, m.input);
531 assert_eq!(m.cache_write, m.input);
532 }
533
534 #[test]
535 fn a_priced_call_sums_every_rate() {
536 let m = opus();
537 let usage = TokenUsage {
538 input: 100_000,
539 output: 20_000,
540 cache_write: 40_000,
541 cache_read: 800_000,
542 };
543 assert_eq!(m.price(&usage), usd("1.65"));
545 }
546
547 #[test]
548 fn money_is_exact() {
549 let m = CostModel::new(usd("0.1"), usd("0.2"), AsOf::new(2026, 1, 1).unwrap());
550 let a = m.price(&TokenUsage {
551 input: 1_000_000,
552 ..TokenUsage::NONE
553 });
554 let b = m.price(&TokenUsage {
555 output: 1_000_000,
556 ..TokenUsage::NONE
557 });
558 assert_eq!(a + b, usd("0.3"), "0.1 + 0.2 must be exactly 0.3");
559 }
560
561 #[test]
564 fn the_four_counts_are_disjoint_halves_of_one_total() {
565 let u = TokenUsage {
566 input: 10,
567 output: 5,
568 cache_write: 3,
569 cache_read: 90,
570 };
571 assert_eq!(u.prompt_tokens(), 103);
572 assert_eq!(u.total_tokens(), 108);
573 }
574
575 #[test]
576 fn the_cache_hit_rate_is_reported_over_the_whole_prompt() {
577 let u = TokenUsage {
578 input: 100,
579 cache_read: 900,
580 ..TokenUsage::NONE
581 };
582 assert_eq!(u.cache_hit_rate(), Some(usd("90")));
583 assert_eq!(TokenUsage::NONE.cache_hit_rate(), None, "nothing to divide");
584 }
585
586 #[test]
587 fn usage_accumulates_across_calls() {
588 let a = TokenUsage {
589 input: 1,
590 output: 2,
591 cache_write: 3,
592 cache_read: 4,
593 };
594 assert_eq!(a.plus(a).total_tokens(), 20);
595 }
596
597 #[test]
600 fn pricing_dates_order_chronologically() {
601 let older = AsOf::new(2025, 12, 31).unwrap();
602 let newer = AsOf::new(2026, 1, 1).unwrap();
603 assert!(older < newer);
604 assert!(older.is_older_than(newer));
605 assert!(!newer.is_older_than(older));
606 assert!(!newer.is_older_than(newer), "a horizon is not yet stale");
607 }
608
609 #[test]
613 fn staleness_is_measured_against_a_supplied_horizon() {
614 let m = opus();
615 assert!(!m.is_stale(AsOf::new(2026, 1, 1).unwrap()));
616 assert!(m.is_stale(AsOf::new(2027, 1, 1).unwrap()));
617 }
618
619 #[test]
620 fn a_pricing_date_round_trips_through_its_iso_form() {
621 let d = AsOf::new(2026, 6, 24).unwrap();
622 assert_eq!(d.to_string(), "2026-06-24");
623 assert_eq!("2026-06-24".parse::<AsOf>().unwrap(), d);
624 }
625
626 #[test]
627 fn a_date_that_cannot_exist_is_refused() {
628 for bad in ["2026-02-30", "2026-13-01", "2026-00-10", "2026-01-00"] {
629 assert_eq!(
630 bad.parse::<AsOf>(),
631 Err(AsOfParseError::OutOfRange),
632 "{bad} should not parse"
633 );
634 }
635 for bad in ["2026-6-24", "26-06-24", "2026/06/24", "2026-06", ""] {
636 assert_eq!(
637 bad.parse::<AsOf>(),
638 Err(AsOfParseError::Malformed),
639 "{bad} should not parse"
640 );
641 }
642 }
643
644 #[test]
645 fn february_29_exists_only_in_a_leap_year() {
646 assert!("2024-02-29".parse::<AsOf>().is_ok());
647 assert!("2000-02-29".parse::<AsOf>().is_ok(), "2000 is a leap year");
648 assert!("1900-02-29".parse::<AsOf>().is_err(), "1900 is not");
649 assert!("2026-02-29".parse::<AsOf>().is_err());
650 }
651
652 #[test]
655 fn a_catalogue_round_trips_through_json() {
656 let catalogue = Pricing::new().with("claude-opus-5", opus()).with(
657 "claude-haiku-4-5",
658 CostModel::new(usd("1.00"), usd("5.00"), AsOf::new(2026, 6, 24).unwrap()),
659 );
660
661 let json = catalogue.to_json().unwrap();
662 assert_eq!(Pricing::from_json(&json).unwrap(), catalogue);
663 assert_eq!(catalogue.len(), 2);
664 assert_eq!(catalogue.get("claude-opus-5").unwrap().input, usd("5.00"));
665 assert!(catalogue.get("nothing-by-that-name").is_none());
666 }
667
668 #[test]
669 fn a_malformed_catalogue_is_refused_rather_than_half_read() {
670 assert!(matches!(
671 Pricing::from_json("{\"a\": 3}"),
672 Err(PricingError::Malformed(_))
673 ));
674 assert!(Pricing::from_json("not json at all").is_err());
675 }
676
677 #[test]
678 fn the_catalogue_reports_which_models_have_gone_stale() {
679 let catalogue = Pricing::new().with("fresh", opus()).with(
680 "ancient",
681 CostModel::new(usd("1"), usd("1"), AsOf::new(2024, 1, 1).unwrap()),
682 );
683 assert_eq!(catalogue.stale(AsOf::new(2026, 1, 1).unwrap()), ["ancient"]);
684 assert!(catalogue.stale(AsOf::new(2020, 1, 1).unwrap()).is_empty());
685 }
686
687 #[test]
690 fn two_models_price_alike_only_when_every_rate_matches() {
691 let a = opus();
692 let mut b = opus();
693 assert!(a.prices_alike(&b));
694
695 b.output = usd("30.00");
696 assert!(!a.prices_alike(&b), "a different output rate is a mismatch");
697
698 let mut c = opus();
699 c.cache_read = usd("0.60");
700 assert!(!a.prices_alike(&c), "a different cache rate is a mismatch");
701
702 let mut d = opus();
704 d.as_of = AsOf::new(2026, 1, 1).unwrap();
705 assert!(a.prices_alike(&d));
706 }
707
708 #[test]
711 fn the_costlier_of_two_models_takes_the_worst_of_each() {
712 let cheap = CostModel::new(usd("1.00"), usd("5.00"), AsOf::new(2026, 6, 1).unwrap())
713 .with_context_window(200_000);
714 let dear = opus();
715
716 let worst = cheap.costlier(&dear);
717 assert_eq!(worst.input, usd("5.00"));
718 assert_eq!(worst.output, usd("25.00"));
719 assert_eq!(
720 worst.context_window, 200_000,
721 "the tighter window binds the chain"
722 );
723 assert_eq!(
724 worst.as_of,
725 AsOf::new(2026, 6, 1).unwrap(),
726 "the older date"
727 );
728 }
729
730 #[test]
731 fn a_prompt_is_admitted_only_within_the_window() {
732 let m = opus();
733 assert!(m.admits(999_999));
734 assert!(m.admits(1_000_000));
735 assert!(!m.admits(1_000_001));
736
737 let unbounded = CostModel::new(usd("1"), usd("1"), AsOf::new(2026, 1, 1).unwrap());
739 assert!(unbounded.admits(u32::MAX));
740 }
741}