use crate::chat::response::ChatResponse;
use crate::cost::pricing::{Micros, PriceBook, Priced};
use crate::cost::usage::{Usage, UsageCoverage};
use crate::model::ModelId;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Total {
Exact(Micros),
AtLeast(Micros),
}
impl Total {
pub fn amount(self) -> Micros {
match self {
Total::Exact(amount) | Total::AtLeast(amount) => amount,
}
}
pub fn is_exact(self) -> bool {
matches!(self, Total::Exact(_))
}
}
impl std::fmt::Display for Total {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Total::Exact(amount) => write!(f, "{amount}"),
Total::AtLeast(amount) => write!(f, "at least {amount}"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Line {
pub model: ModelId,
pub usage: Usage,
pub cost: Option<Priced>,
}
#[derive(Debug, Clone, Default)]
pub struct Ledger {
lines: Vec<Line>,
}
impl Ledger {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, reply: &ChatResponse, book: Option<&PriceBook>) {
let cost = book.and_then(|book| book.price(&reply.model, &reply.usage));
self.lines.push(Line {
model: reply.model.clone(),
usage: reply.usage,
cost,
});
}
pub fn record_unpriced(&mut self, model: impl Into<ModelId>, usage: Usage) {
self.lines.push(Line {
model: model.into(),
usage,
cost: None,
});
}
pub fn absorb(&mut self, other: Ledger) {
self.lines.extend(other.lines);
}
pub fn lines(&self) -> &[Line] {
&self.lines
}
pub fn calls(&self) -> usize {
self.lines.len()
}
pub fn unpriced(&self) -> usize {
self.lines.iter().filter(|line| line.cost.is_none()).count()
}
pub fn editions(&self) -> Vec<&str> {
let mut seen: Vec<&str> = self
.lines
.iter()
.filter_map(|line| line.cost.as_ref().map(|c| c.book.as_str()))
.collect();
seen.sort_unstable();
seen.dedup();
seen
}
pub fn currencies(&self) -> Vec<&str> {
let mut seen: Vec<&str> = self
.lines
.iter()
.filter_map(|line| line.cost.as_ref().map(|c| c.currency.as_str()))
.collect();
seen.sort_unstable();
seen.dedup();
seen
}
pub fn currency(&self) -> Option<&str> {
match self.currencies().as_slice() {
[only] => Some(only),
_ => None,
}
}
pub fn total(&self) -> Option<Total> {
if self.currencies().len() > 1 {
return None;
}
let mut amount = Micros(0);
let mut whole = true;
for line in &self.lines {
match &line.cost {
Some(priced) => {
amount = amount + priced.amount;
if priced.coverage != UsageCoverage::Exact {
whole = false;
}
}
None => whole = false,
}
}
Some(if whole {
Total::Exact(amount)
} else {
Total::AtLeast(amount)
})
}
pub fn totals(&self) -> Vec<(String, Total)> {
let any_unpriced = self.unpriced() > 0;
let mut sums: BTreeMap<&str, (Micros, bool)> = BTreeMap::new();
for priced in self.lines.iter().filter_map(|line| line.cost.as_ref()) {
let entry = sums
.entry(priced.currency.as_str())
.or_insert((Micros(0), !any_unpriced));
entry.0 = entry.0 + priced.amount;
if priced.coverage != UsageCoverage::Exact {
entry.1 = false;
}
}
sums.into_iter()
.map(|(currency, (amount, whole))| {
(
currency.to_string(),
if whole {
Total::Exact(amount)
} else {
Total::AtLeast(amount)
},
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::message::{Message, Role, StopReason};
fn book() -> PriceBook {
PriceBook::parse(
r#"
id = "test-2026-08"
provider = "test"
effective_from = "2026-08-01"
source = "a fixture"
verified_at = "2026-08-30"
currency = "USD"
[[price]]
model = "m"
input = "10.00"
output = "30.00"
"#,
)
.unwrap_or_else(|e| panic!("the fixture book: {e}"))
}
fn reply(model: &str, usage: Usage) -> ChatResponse {
ChatResponse::new(
Message {
role: Role::Assistant,
content: vec![],
},
StopReason::EndTurn,
usage,
model.into(),
)
}
fn in_euros() -> PriceBook {
PriceBook::parse(
r#"
id = "test-eur-2026-08"
provider = "test"
effective_from = "2026-08-01"
source = "a fixture"
verified_at = "2026-08-30"
currency = "EUR"
[[price]]
model = "m"
input = "10.00"
output = "30.00"
"#,
)
.unwrap_or_else(|e| panic!("the euro book: {e}"))
}
fn sum(ledger: &Ledger) -> Total {
match ledger.total() {
Some(total) => total,
None => panic!("this run is priced in one currency"),
}
}
fn measured() -> Usage {
Usage::absent()
.with_input(1_000_000)
.with_cache_read(0)
.with_cache_write(0)
.with_output(1_000_000)
}
#[test]
fn a_run_of_measured_calls_totals_exactly() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
ledger.record(&reply("m", measured()), Some(&book()));
let total = sum(&ledger);
assert!(total.is_exact(), "{total}");
assert_eq!(ledger.calls(), 2);
assert_eq!(ledger.unpriced(), 0);
assert_eq!(total.amount(), Micros(80_000_000));
}
#[test]
fn one_unpriced_call_turns_the_whole_total_into_a_floor() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
ledger.record_unpriced("claude-sonnet-5", Usage::absent());
let total = sum(&ledger);
assert!(!total.is_exact(), "{total}");
assert_eq!(
total.amount(),
Micros(40_000_000),
"what is known is still known"
);
assert!(total.to_string().starts_with("at least"), "{total}");
}
#[test]
fn a_call_nobody_could_price_is_counted_rather_than_dropped() {
let mut ledger = Ledger::new();
ledger.record_unpriced("claude-sonnet-5", Usage::absent());
ledger.record_unpriced("claude-sonnet-5", Usage::absent());
assert_eq!(ledger.calls(), 2);
assert_eq!(ledger.unpriced(), 2);
assert_eq!(sum(&ledger).amount(), Micros(0));
assert!(
!sum(&ledger).is_exact(),
"nought known is not the same as nought spent"
);
}
#[test]
fn a_price_from_partial_usage_makes_the_total_a_floor_too() {
let mut ledger = Ledger::new();
ledger.record(
&reply("m", Usage::absent().with_output(1_000_000)),
Some(&book()),
);
assert_eq!(ledger.unpriced(), 0, "it was priced");
assert!(!sum(&ledger).is_exact(), "from half the numbers");
}
#[test]
fn a_model_with_no_row_in_the_book_is_unpriced_rather_than_free() {
let mut ledger = Ledger::new();
ledger.record(&reply("a-model-nobody-priced", measured()), Some(&book()));
assert_eq!(ledger.calls(), 1);
assert_eq!(ledger.unpriced(), 1);
assert!(!sum(&ledger).is_exact());
}
#[test]
fn the_edition_each_cost_came_from_is_kept() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
ledger.record(&reply("m", measured()), Some(&book()));
assert_eq!(ledger.editions(), vec!["test-2026-08"]);
assert_eq!(
ledger.lines()[0].cost.as_ref().map(|c| c.book.as_str()),
Some("test-2026-08")
);
}
#[test]
fn a_later_book_does_not_rewrite_what_an_earlier_call_cost() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
let then = sum(&ledger).amount();
let dearer = PriceBook::parse(
r#"
id = "test-2026-09"
provider = "test"
effective_from = "2026-09-01"
source = "a fixture"
verified_at = "2026-09-01"
currency = "USD"
[[price]]
model = "m"
input = "20.00"
output = "60.00"
"#,
)
.unwrap_or_else(|e| panic!("the second book: {e}"));
ledger.record(&reply("m", measured()), Some(&dearer));
assert_eq!(
ledger.lines()[0].cost.as_ref().map(|c| c.amount),
Some(then),
"the first call still costs what it cost"
);
assert_eq!(ledger.editions(), vec!["test-2026-08", "test-2026-09"]);
}
#[test]
fn a_run_in_one_currency_says_which_one() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
assert_eq!(ledger.currency(), Some("USD"));
assert_eq!(
ledger.totals(),
vec![("USD".to_string(), Total::Exact(Micros(40_000_000)))]
);
}
#[test]
fn a_run_priced_in_two_currencies_has_no_single_total() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
ledger.record(&reply("m", measured()), Some(&in_euros()));
assert_eq!(ledger.total(), None, "there is no such number");
assert_eq!(ledger.currency(), None);
assert_eq!(ledger.currencies(), vec!["EUR", "USD"]);
assert_eq!(ledger.calls(), 2, "both calls still happened");
}
#[test]
fn a_mixed_run_is_totalled_one_currency_at_a_time() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
ledger.record(&reply("m", measured()), Some(&in_euros()));
ledger.record(&reply("m", measured()), Some(&in_euros()));
assert_eq!(
ledger.totals(),
vec![
("EUR".to_string(), Total::Exact(Micros(80_000_000))),
("USD".to_string(), Total::Exact(Micros(40_000_000))),
]
);
}
#[test]
fn one_unpriced_call_makes_every_currency_a_floor() {
let mut ledger = Ledger::new();
ledger.record(&reply("m", measured()), Some(&book()));
ledger.record(&reply("m", measured()), Some(&in_euros()));
ledger.record_unpriced("claude-sonnet-5", Usage::absent());
assert_eq!(
ledger.totals(),
vec![
("EUR".to_string(), Total::AtLeast(Micros(40_000_000))),
("USD".to_string(), Total::AtLeast(Micros(40_000_000))),
]
);
}
#[test]
fn a_run_nobody_priced_totals_to_a_floor_rather_than_to_nothing() {
let mut ledger = Ledger::new();
ledger.record_unpriced("claude-sonnet-5", Usage::absent());
ledger.record_unpriced("claude-sonnet-5", Usage::absent());
assert_eq!(ledger.total(), Some(Total::AtLeast(Micros(0))));
assert_eq!(ledger.currency(), None);
assert!(ledger.totals().is_empty());
assert_eq!(ledger.calls(), 2);
}
#[test]
fn absorbing_a_ledger_in_another_currency_is_noticed() {
let mut one = Ledger::new();
one.record(&reply("m", measured()), Some(&book()));
assert!(one.total().is_some());
let mut other = Ledger::new();
other.record(&reply("m", measured()), Some(&in_euros()));
one.absorb(other);
assert_eq!(one.total(), None);
}
#[test]
fn two_ledgers_join_without_losing_what_either_knew() {
let mut one = Ledger::new();
one.record(&reply("m", measured()), Some(&book()));
let mut other = Ledger::new();
other.record_unpriced("claude-sonnet-5", Usage::absent());
one.absorb(other);
assert_eq!(one.calls(), 2);
assert_eq!(one.unpriced(), 1);
assert!(!sum(&one).is_exact());
}
}