use crate::normalize::normalize_model_id;
use crate::usage::Usage;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pricing {
pub input_per_mtok: f64,
pub output_per_mtok: f64,
pub cache_read_per_mtok: f64,
pub cache_write_per_mtok: f64,
}
impl Pricing {
pub fn cost_for(&self, usage: &Usage) -> f64 {
(usage.input_tokens as f64 * self.input_per_mtok
+ usage.output_tokens as f64 * self.output_per_mtok
+ usage.cache_read_input_tokens as f64 * self.cache_read_per_mtok
+ usage.cache_creation_input_tokens as f64 * self.cache_write_per_mtok)
/ 1_000_000.0
}
}
pub const DEFAULT_PRICING_TABLE: &[(&str, Pricing)] = &[
(
"claude-sonnet-4-5",
Pricing {
input_per_mtok: 3.0,
output_per_mtok: 15.0,
cache_read_per_mtok: 0.3,
cache_write_per_mtok: 3.75,
},
),
(
"claude-opus-4-7",
Pricing {
input_per_mtok: 15.0,
output_per_mtok: 75.0,
cache_read_per_mtok: 1.5,
cache_write_per_mtok: 18.75,
},
),
(
"claude-haiku-4-5",
Pricing {
input_per_mtok: 1.0,
output_per_mtok: 5.0,
cache_read_per_mtok: 0.1,
cache_write_per_mtok: 1.25,
},
),
(
"claude-3-5-sonnet-20241022",
Pricing {
input_per_mtok: 3.0,
output_per_mtok: 15.0,
cache_read_per_mtok: 0.3,
cache_write_per_mtok: 3.75,
},
),
(
"claude-3-5-haiku-20241022",
Pricing {
input_per_mtok: 0.8,
output_per_mtok: 4.0,
cache_read_per_mtok: 0.08,
cache_write_per_mtok: 1.0,
},
),
];
pub fn default_pricing(model_id: &str) -> Option<Pricing> {
let key = normalize_model_id(model_id);
DEFAULT_PRICING_TABLE
.iter()
.find_map(|(name, p)| if *name == key { Some(*p) } else { None })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pricing_zero_usage_is_zero() {
let p = default_pricing("claude-sonnet-4-5").unwrap();
let cost = p.cost_for(&Usage::default());
assert_eq!(cost, 0.0);
}
#[test]
fn sonnet_million_input_is_three_dollars() {
let p = default_pricing("claude-sonnet-4-5").unwrap();
let usage = Usage {
input_tokens: 1_000_000,
..Usage::default()
};
assert!((p.cost_for(&usage) - 3.0).abs() < 1e-9);
}
#[test]
fn cache_read_is_one_tenth_input_for_sonnet() {
let p = default_pricing("claude-sonnet-4-5").unwrap();
let usage = Usage {
cache_read_input_tokens: 1_000_000,
..Usage::default()
};
assert!((p.cost_for(&usage) - 0.3).abs() < 1e-9);
}
#[test]
fn opus_pricing_is_5x_sonnet() {
let s = default_pricing("claude-sonnet-4-5").unwrap();
let o = default_pricing("claude-opus-4-7").unwrap();
assert_eq!(o.input_per_mtok / s.input_per_mtok, 5.0);
assert_eq!(o.output_per_mtok / s.output_per_mtok, 5.0);
}
#[test]
fn lookup_via_bedrock_versioned_id() {
assert!(default_pricing("anthropic.claude-sonnet-4-5-v1:0").is_some());
}
#[test]
fn lookup_via_inference_profile() {
assert!(default_pricing("us.anthropic.claude-haiku-4-5").is_some());
assert!(default_pricing("eu.anthropic.claude-opus-4-7-v1:0").is_some());
}
#[test]
fn unknown_model_returns_none() {
assert!(default_pricing("gpt-5").is_none());
}
#[test]
fn aggregate_cost_matches_byhand() {
let p = default_pricing("claude-sonnet-4-5").unwrap();
let usage = Usage {
input_tokens: 423,
output_tokens: 18,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 380,
};
assert!((p.cost_for(&usage) - 0.001653).abs() < 1e-9);
}
}