use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
const TOKENS_PER_UNIT: u64 = 1_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct AsOf {
pub year: u16,
pub month: u8,
pub day: u8,
}
impl AsOf {
pub fn new(year: u16, month: u8, day: u8) -> Result<Self, AsOfParseError> {
let length = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => return Err(AsOfParseError::OutOfRange),
};
if day == 0 || day > length {
return Err(AsOfParseError::OutOfRange);
}
Ok(Self { year, month, day })
}
pub fn is_older_than(self, horizon: Self) -> bool {
self < horizon
}
}
impl fmt::Display for AsOf {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
const fn is_leap(year: u16) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AsOfParseError {
#[error("a pricing date must be YYYY-MM-DD")]
Malformed,
#[error("that is not a calendar date")]
OutOfRange,
}
impl FromStr for AsOf {
type Err = AsOfParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split('-');
let (Some(y), Some(m), Some(d), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(AsOfParseError::Malformed);
};
if (y.len(), m.len(), d.len()) != (4, 2, 2) {
return Err(AsOfParseError::Malformed);
}
let year = y.parse().map_err(|_| AsOfParseError::Malformed)?;
let part = |t: &str| t.parse::<u8>().map_err(|_| AsOfParseError::Malformed);
Self::new(year, part(m)?, part(d)?)
}
}
impl TryFrom<String> for AsOf {
type Error = AsOfParseError;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<AsOf> for String {
fn from(date: AsOf) -> Self {
date.to_string()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input: u64,
pub output: u64,
pub cache_write: u64,
pub cache_read: u64,
}
impl TokenUsage {
pub const NONE: Self = Self {
input: 0,
output: 0,
cache_write: 0,
cache_read: 0,
};
pub fn prompt_tokens(&self) -> u64 {
self.input
.saturating_add(self.cache_write)
.saturating_add(self.cache_read)
}
pub fn total_tokens(&self) -> u64 {
self.prompt_tokens().saturating_add(self.output)
}
pub fn cache_hit_rate(&self) -> Option<Decimal> {
let prompt = self.prompt_tokens();
if prompt == 0 {
return None;
}
Some(Decimal::from(self.cache_read) * Decimal::ONE_HUNDRED / Decimal::from(prompt))
}
#[must_use]
pub fn plus(self, other: Self) -> Self {
Self {
input: self.input.saturating_add(other.input),
output: self.output.saturating_add(other.output),
cache_write: self.cache_write.saturating_add(other.cache_write),
cache_read: self.cache_read.saturating_add(other.cache_read),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CostModel {
pub input: Decimal,
pub output: Decimal,
pub cache_read: Decimal,
pub cache_write: Decimal,
pub cache_write_long: Option<Decimal>,
pub context_window: u32,
pub max_output: u32,
pub as_of: AsOf,
}
impl CostModel {
pub fn new(input: Decimal, output: Decimal, as_of: AsOf) -> Self {
Self {
input,
output,
cache_read: input,
cache_write: input,
cache_write_long: None,
context_window: 0,
max_output: 0,
as_of,
}
}
#[must_use]
pub fn with_cache_rates(mut self, read: Decimal, write: Decimal) -> Self {
self.cache_read = read;
self.cache_write = write;
self
}
#[must_use]
pub fn with_long_cache_write(mut self, write: Decimal) -> Self {
self.cache_write_long = Some(write);
self
}
#[must_use]
pub fn with_context_window(mut self, tokens: u32) -> Self {
self.context_window = tokens;
self
}
#[must_use]
pub fn with_max_output(mut self, tokens: u32) -> Self {
self.max_output = tokens;
self
}
pub fn price(&self, usage: &TokenUsage) -> Decimal {
let per = |tokens: u64, rate: Decimal| Decimal::from(tokens) * rate;
(per(usage.input, self.input)
+ per(usage.output, self.output)
+ per(usage.cache_read, self.cache_read)
+ per(usage.cache_write, self.cache_write))
/ Decimal::from(TOKENS_PER_UNIT)
}
pub fn admits(&self, prompt_tokens: u32) -> bool {
self.context_window == 0 || prompt_tokens <= self.context_window
}
pub fn is_stale(&self, horizon: AsOf) -> bool {
self.as_of.is_older_than(horizon)
}
pub fn prices_alike(&self, other: &Self) -> bool {
self.input == other.input
&& self.output == other.output
&& self.cache_read == other.cache_read
&& self.cache_write == other.cache_write
}
pub fn costlier(&self, other: &Self) -> Self {
Self {
input: self.input.max(other.input),
output: self.output.max(other.output),
cache_read: self.cache_read.max(other.cache_read),
cache_write: self.cache_write.max(other.cache_write),
cache_write_long: match (self.cache_write_long, other.cache_write_long) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
},
context_window: match (self.context_window, other.context_window) {
(0, w) | (w, 0) => w,
(a, b) => a.min(b),
},
max_output: match (self.max_output, other.max_output) {
(0, w) | (w, 0) => w,
(a, b) => a.min(b),
},
as_of: self.as_of.min(other.as_of),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Pricing {
models: BTreeMap<String, CostModel>,
}
impl Pricing {
pub fn new() -> Self {
Self::default()
}
pub fn from_json(json: &str) -> Result<Self, PricingError> {
serde_json::from_str(json).map_err(|e| PricingError::Malformed(e.to_string()))
}
pub fn to_json(&self) -> Result<String, PricingError> {
serde_json::to_string_pretty(self).map_err(|e| PricingError::Malformed(e.to_string()))
}
#[must_use]
pub fn with(mut self, model: impl Into<String>, cost: CostModel) -> Self {
self.models.insert(model.into(), cost);
self
}
pub fn get(&self, model: &str) -> Option<&CostModel> {
self.models.get(model)
}
pub fn models(&self) -> impl Iterator<Item = (&str, &CostModel)> {
self.models.iter().map(|(k, v)| (k.as_str(), v))
}
pub fn len(&self) -> usize {
self.models.len()
}
pub fn is_empty(&self) -> bool {
self.models.is_empty()
}
pub fn stale(&self, horizon: AsOf) -> Vec<&str> {
self.models
.iter()
.filter(|(_, cost)| cost.is_stale(horizon))
.map(|(id, _)| id.as_str())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PricingError {
#[error("the pricing catalogue is malformed: {0}")]
Malformed(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn usd(s: &str) -> Decimal {
s.parse().unwrap()
}
fn opus() -> CostModel {
CostModel::new(usd("5.00"), usd("25.00"), AsOf::new(2026, 6, 24).unwrap())
.with_cache_rates(usd("0.50"), usd("6.25"))
.with_long_cache_write(usd("10.00"))
.with_context_window(1_000_000)
.with_max_output(128_000)
}
#[test]
fn a_cost_model_carries_input_output_and_the_context_ceiling() {
let m = opus();
assert_eq!(m.input, usd("5.00"));
assert_eq!(m.output, usd("25.00"));
assert_eq!(m.context_window, 1_000_000);
assert_eq!(m.max_output, 128_000);
}
#[test]
fn cached_reads_are_priced_separately_from_uncached_input() {
let m = opus();
assert_ne!(m.cache_read, m.input);
assert!(
m.cache_read < m.input,
"a read should be cheaper than a miss"
);
assert!(
m.cache_write > m.input,
"a write should cost more than not caching"
);
let million = 1_000_000;
assert_eq!(
m.price(&TokenUsage {
input: million,
..TokenUsage::NONE
}),
usd("5.00")
);
assert_eq!(
m.price(&TokenUsage {
cache_read: million,
..TokenUsage::NONE
}),
usd("0.50")
);
assert_eq!(
m.price(&TokenUsage {
cache_write: million,
..TokenUsage::NONE
}),
usd("6.25")
);
}
#[test]
fn a_model_with_no_cache_tier_prices_reads_at_the_input_rate() {
let m = CostModel::new(usd("3.00"), usd("15.00"), AsOf::new(2026, 6, 24).unwrap());
assert_eq!(m.cache_read, m.input);
assert_eq!(m.cache_write, m.input);
}
#[test]
fn a_priced_call_sums_every_rate() {
let m = opus();
let usage = TokenUsage {
input: 100_000,
output: 20_000,
cache_write: 40_000,
cache_read: 800_000,
};
assert_eq!(m.price(&usage), usd("1.65"));
}
#[test]
fn money_is_exact() {
let m = CostModel::new(usd("0.1"), usd("0.2"), AsOf::new(2026, 1, 1).unwrap());
let a = m.price(&TokenUsage {
input: 1_000_000,
..TokenUsage::NONE
});
let b = m.price(&TokenUsage {
output: 1_000_000,
..TokenUsage::NONE
});
assert_eq!(a + b, usd("0.3"), "0.1 + 0.2 must be exactly 0.3");
}
#[test]
fn the_four_counts_are_disjoint_halves_of_one_total() {
let u = TokenUsage {
input: 10,
output: 5,
cache_write: 3,
cache_read: 90,
};
assert_eq!(u.prompt_tokens(), 103);
assert_eq!(u.total_tokens(), 108);
}
#[test]
fn the_cache_hit_rate_is_reported_over_the_whole_prompt() {
let u = TokenUsage {
input: 100,
cache_read: 900,
..TokenUsage::NONE
};
assert_eq!(u.cache_hit_rate(), Some(usd("90")));
assert_eq!(TokenUsage::NONE.cache_hit_rate(), None, "nothing to divide");
}
#[test]
fn usage_accumulates_across_calls() {
let a = TokenUsage {
input: 1,
output: 2,
cache_write: 3,
cache_read: 4,
};
assert_eq!(a.plus(a).total_tokens(), 20);
}
#[test]
fn pricing_dates_order_chronologically() {
let older = AsOf::new(2025, 12, 31).unwrap();
let newer = AsOf::new(2026, 1, 1).unwrap();
assert!(older < newer);
assert!(older.is_older_than(newer));
assert!(!newer.is_older_than(older));
assert!(!newer.is_older_than(newer), "a horizon is not yet stale");
}
#[test]
fn staleness_is_measured_against_a_supplied_horizon() {
let m = opus();
assert!(!m.is_stale(AsOf::new(2026, 1, 1).unwrap()));
assert!(m.is_stale(AsOf::new(2027, 1, 1).unwrap()));
}
#[test]
fn a_pricing_date_round_trips_through_its_iso_form() {
let d = AsOf::new(2026, 6, 24).unwrap();
assert_eq!(d.to_string(), "2026-06-24");
assert_eq!("2026-06-24".parse::<AsOf>().unwrap(), d);
}
#[test]
fn a_date_that_cannot_exist_is_refused() {
for bad in ["2026-02-30", "2026-13-01", "2026-00-10", "2026-01-00"] {
assert_eq!(
bad.parse::<AsOf>(),
Err(AsOfParseError::OutOfRange),
"{bad} should not parse"
);
}
for bad in ["2026-6-24", "26-06-24", "2026/06/24", "2026-06", ""] {
assert_eq!(
bad.parse::<AsOf>(),
Err(AsOfParseError::Malformed),
"{bad} should not parse"
);
}
}
#[test]
fn february_29_exists_only_in_a_leap_year() {
assert!("2024-02-29".parse::<AsOf>().is_ok());
assert!("2000-02-29".parse::<AsOf>().is_ok(), "2000 is a leap year");
assert!("1900-02-29".parse::<AsOf>().is_err(), "1900 is not");
assert!("2026-02-29".parse::<AsOf>().is_err());
}
#[test]
fn a_catalogue_round_trips_through_json() {
let catalogue = Pricing::new().with("claude-opus-5", opus()).with(
"claude-haiku-4-5",
CostModel::new(usd("1.00"), usd("5.00"), AsOf::new(2026, 6, 24).unwrap()),
);
let json = catalogue.to_json().unwrap();
assert_eq!(Pricing::from_json(&json).unwrap(), catalogue);
assert_eq!(catalogue.len(), 2);
assert_eq!(catalogue.get("claude-opus-5").unwrap().input, usd("5.00"));
assert!(catalogue.get("nothing-by-that-name").is_none());
}
#[test]
fn a_malformed_catalogue_is_refused_rather_than_half_read() {
assert!(matches!(
Pricing::from_json("{\"a\": 3}"),
Err(PricingError::Malformed(_))
));
assert!(Pricing::from_json("not json at all").is_err());
}
#[test]
fn the_catalogue_reports_which_models_have_gone_stale() {
let catalogue = Pricing::new().with("fresh", opus()).with(
"ancient",
CostModel::new(usd("1"), usd("1"), AsOf::new(2024, 1, 1).unwrap()),
);
assert_eq!(catalogue.stale(AsOf::new(2026, 1, 1).unwrap()), ["ancient"]);
assert!(catalogue.stale(AsOf::new(2020, 1, 1).unwrap()).is_empty());
}
#[test]
fn two_models_price_alike_only_when_every_rate_matches() {
let a = opus();
let mut b = opus();
assert!(a.prices_alike(&b));
b.output = usd("30.00");
assert!(!a.prices_alike(&b), "a different output rate is a mismatch");
let mut c = opus();
c.cache_read = usd("0.60");
assert!(!a.prices_alike(&c), "a different cache rate is a mismatch");
let mut d = opus();
d.as_of = AsOf::new(2026, 1, 1).unwrap();
assert!(a.prices_alike(&d));
}
#[test]
fn the_costlier_of_two_models_takes_the_worst_of_each() {
let cheap = CostModel::new(usd("1.00"), usd("5.00"), AsOf::new(2026, 6, 1).unwrap())
.with_context_window(200_000);
let dear = opus();
let worst = cheap.costlier(&dear);
assert_eq!(worst.input, usd("5.00"));
assert_eq!(worst.output, usd("25.00"));
assert_eq!(
worst.context_window, 200_000,
"the tighter window binds the chain"
);
assert_eq!(
worst.as_of,
AsOf::new(2026, 6, 1).unwrap(),
"the older date"
);
}
#[test]
fn a_prompt_is_admitted_only_within_the_window() {
let m = opus();
assert!(m.admits(999_999));
assert!(m.admits(1_000_000));
assert!(!m.admits(1_000_001));
let unbounded = CostModel::new(usd("1"), usd("1"), AsOf::new(2026, 1, 1).unwrap());
assert!(unbounded.admits(u32::MAX));
}
}