use std::collections::HashMap;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
}
impl Usage {
pub fn from_response(v: &Value) -> Option<Usage> {
let u = v
.get("usage")
.or_else(|| v.get("message").and_then(|m| m.get("usage")))?;
let input = field(u, &["input_tokens", "prompt_tokens"]);
let output = field(u, &["output_tokens", "completion_tokens"]);
let cache = field(u, &["cache_creation_input_tokens"]).unwrap_or(0)
+ field(u, &["cache_read_input_tokens"]).unwrap_or(0);
if input.is_none() && output.is_none() && cache == 0 {
return None;
}
Some(Usage {
input_tokens: input.unwrap_or(0) + cache,
output_tokens: output.unwrap_or(0),
})
}
}
fn field(obj: &Value, keys: &[&str]) -> Option<u64> {
keys.iter()
.find_map(|k| obj.get(*k).and_then(Value::as_u64))
}
#[derive(Debug, Clone, Copy)]
pub struct Price {
pub input_per_mtok_cents: u64,
pub output_per_mtok_cents: u64,
}
#[derive(Debug, Clone)]
pub struct PriceTable {
exact: HashMap<String, Price>,
default: Price,
}
impl Default for PriceTable {
fn default() -> Self {
Self::builtin()
}
}
impl PriceTable {
pub fn builtin() -> Self {
Self {
exact: HashMap::new(),
default: Price {
input_per_mtok_cents: 1000,
output_per_mtok_cents: 3000,
},
}
}
pub fn set(&mut self, model: &str, price: Price) {
self.exact.insert(model.to_ascii_lowercase(), price);
}
pub fn price_for(&self, model: &str) -> Price {
let m = model.to_ascii_lowercase();
if let Some(p) = self.exact.get(&m) {
return *p;
}
const FAMILIES: &[(&str, u64, u64)] = &[
("opus", 1500, 7500),
("sonnet", 300, 1500),
("fable", 300, 1500),
("haiku", 80, 400),
("gpt-4", 250, 1000),
("gpt-5", 250, 1000),
("gpt", 150, 600),
("gemini", 200, 800),
];
for (needle, i, o) in FAMILIES {
if m.contains(needle) {
return Price {
input_per_mtok_cents: *i,
output_per_mtok_cents: *o,
};
}
}
self.default
}
pub fn cost_cents(&self, model: &str, u: &Usage) -> u64 {
let p = self.price_for(model);
let micro = u128::from(u.input_tokens) * u128::from(p.input_per_mtok_cents)
+ u128::from(u.output_tokens) * u128::from(p.output_per_mtok_cents);
let cents = (micro + 500_000) / 1_000_000;
cents.min(u128::from(u64::MAX)) as u64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Charge {
Ok { cost_cents: u64, spent_cents: u64 },
Blocked { spent_cents: u64, budget_cents: u64 },
}
#[derive(Debug, Clone)]
pub struct SpendMeter {
budget_cents: Option<u64>,
spent_cents: u64,
blocked: bool,
prices: PriceTable,
}
impl SpendMeter {
pub fn new(budget_cents: Option<u64>, prices: PriceTable) -> Self {
Self {
budget_cents,
spent_cents: 0,
blocked: false,
prices,
}
}
pub fn spent_cents(&self) -> u64 {
self.spent_cents
}
pub fn budget_cents(&self) -> Option<u64> {
self.budget_cents
}
pub fn is_blocked(&self) -> bool {
self.blocked
}
pub fn allow_request(&self) -> bool {
!self.over_budget()
}
fn over_budget(&self) -> bool {
matches!(self.budget_cents, Some(b) if self.spent_cents >= b)
}
pub fn charge(&mut self, model: &str, usage: &Usage) -> Charge {
if self.over_budget() {
self.blocked = true;
return Charge::Blocked {
spent_cents: self.spent_cents,
budget_cents: self.budget_cents.unwrap_or(0),
};
}
let cost = self.prices.cost_cents(model, usage);
self.spent_cents = self.spent_cents.saturating_add(cost);
if self.over_budget() {
self.blocked = true;
}
Charge::Ok {
cost_cents: cost,
spent_cents: self.spent_cents,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_anthropic_and_openai_usage_shapes() {
let anthropic =
json!({"model":"claude-opus-4-8","usage":{"input_tokens":100,"output_tokens":50}});
assert_eq!(
Usage::from_response(&anthropic),
Some(Usage {
input_tokens: 100,
output_tokens: 50
})
);
let openai = json!({"model":"gpt-5","usage":{"prompt_tokens":200,"completion_tokens":80}});
assert_eq!(
Usage::from_response(&openai),
Some(Usage {
input_tokens: 200,
output_tokens: 80
})
);
assert_eq!(
Usage::from_response(&json!({"type":"content_block_delta"})),
None
);
assert_eq!(Usage::from_response(&json!({"usage":{}})), None);
}
#[test]
fn finds_nested_usage_and_folds_in_cache_tokens() {
let start = json!({
"type":"message_start",
"message":{"usage":{"input_tokens":25000,"output_tokens":1}}
});
assert_eq!(
Usage::from_response(&start),
Some(Usage {
input_tokens: 25000,
output_tokens: 1
})
);
let cached = json!({"usage":{
"input_tokens": 1000,
"cache_creation_input_tokens": 2000,
"cache_read_input_tokens": 500,
"output_tokens": 100
}});
assert_eq!(
Usage::from_response(&cached),
Some(Usage {
input_tokens: 3500,
output_tokens: 100
})
);
}
#[test]
fn cost_is_priced_by_family_and_rounded() {
let t = PriceTable::builtin();
let u = Usage {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
};
assert_eq!(t.cost_cents("claude-opus-4-8", &u), 9000);
assert_eq!(t.cost_cents("claude-haiku-4-5", &u), 80 + 400);
assert_eq!(t.cost_cents("mystery-model", &u), 1000 + 3000);
}
#[test]
fn exact_override_beats_family_match() {
let mut t = PriceTable::builtin();
t.set(
"claude-opus-4-8",
Price {
input_per_mtok_cents: 1,
output_per_mtok_cents: 1,
},
);
let u = Usage {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
};
assert_eq!(t.cost_cents("claude-opus-4-8", &u), 2, "override wins");
assert_eq!(t.cost_cents("claude-opus-5", &u), 9000);
}
#[test]
fn no_budget_meters_but_never_blocks() {
let mut m = SpendMeter::new(None, PriceTable::builtin());
let u = Usage {
input_tokens: 10_000_000,
output_tokens: 10_000_000,
};
for _ in 0..5 {
assert!(m.allow_request());
assert!(matches!(m.charge("opus", &u), Charge::Ok { .. }));
}
assert!(!m.is_blocked());
}
#[test]
fn the_kill_switch_halts_a_runaway_loop_at_the_budget() {
let mut m = SpendMeter::new(Some(500), PriceTable::builtin());
let per_call = Usage {
input_tokens: 0,
output_tokens: 100_000,
};
for expected in [150u64, 300, 450] {
assert!(m.allow_request());
assert_eq!(
m.charge("claude-sonnet-5", &per_call),
Charge::Ok {
cost_cents: 150,
spent_cents: expected
}
);
}
assert!(m.allow_request(), "still allowed just before crossing");
assert!(matches!(
m.charge("claude-sonnet-5", &per_call),
Charge::Ok {
spent_cents: 600,
..
}
));
assert!(m.is_blocked(), "crossing the budget trips the switch");
assert!(!m.allow_request());
assert_eq!(
m.charge("claude-sonnet-5", &per_call),
Charge::Blocked {
spent_cents: 600,
budget_cents: 500
}
);
assert_eq!(m.spent_cents(), 600, "blocked requests add nothing");
}
}