use std::collections::HashMap;
use std::sync::Arc;
use atomr_agents_observability::{RunEvent, RunEventKind, Telemetry};
use atomr_infer_core::tokens::TokenUsage;
use parking_lot::Mutex;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DecisionKey {
pub desk: String,
pub strategy: String,
pub decision_id: String,
}
impl DecisionKey {
pub fn new(desk: impl Into<String>, strategy: impl Into<String>, decision_id: impl Into<String>) -> Self {
Self {
desk: desk.into(),
strategy: strategy.into(),
decision_id: decision_id.into(),
}
}
pub fn canonical(&self) -> String {
format!("{}/{}/{}", self.desk, self.strategy, self.decision_id)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ModelPricing {
pub input_micro_usd_per_1k: u64,
pub output_micro_usd_per_1k: u64,
}
#[derive(Debug, Clone, Default)]
pub struct Pricing {
table: HashMap<String, ModelPricing>,
}
impl Pricing {
pub fn new() -> Self {
Self::default()
}
pub fn with_model(mut self, model_id: impl Into<String>, pricing: ModelPricing) -> Self {
self.table.insert(model_id.into(), pricing);
self
}
fn get(&self, model_id: &str) -> ModelPricing {
self.table.get(model_id).copied().unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CostEstimate {
pub micro_usd: u64,
pub tokens: u32,
}
#[derive(Debug, Clone)]
pub struct Spend {
pub micro_usd: u64,
pub tokens: u32,
pub decision_key: Option<DecisionKey>,
}
pub struct CostMeter {
pricing: Pricing,
}
impl CostMeter {
pub fn new(pricing: Pricing) -> Self {
Self { pricing }
}
pub fn estimate(&self, model_id: &str, est_input: u32, est_output: u32) -> CostEstimate {
let p = self.pricing.get(model_id);
let micro = (est_input as u64 * p.input_micro_usd_per_1k) / 1000
+ (est_output as u64 * p.output_micro_usd_per_1k) / 1000;
CostEstimate {
micro_usd: micro,
tokens: est_input + est_output,
}
}
pub fn record(&self, model_id: &str, usage: &TokenUsage, key: Option<DecisionKey>) -> Spend {
let p = self.pricing.get(model_id);
let micro = (usage.input_tokens as u64 * p.input_micro_usd_per_1k) / 1000
+ (usage.output_tokens as u64 * p.output_micro_usd_per_1k) / 1000;
Spend {
micro_usd: micro,
tokens: usage.input_tokens + usage.output_tokens,
decision_key: key,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetScope {
Desk(String),
Strategy(String),
Global,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cap {
Money(u64), Tokens(u32),
}
#[derive(Debug, Clone)]
pub struct Budget {
pub cap: Cap,
pub scope: BudgetScope,
}
#[derive(Debug, Clone, Error)]
#[error("budget exceeded for scope {scope:?}: {spent} > cap")]
pub struct BudgetExceeded {
pub scope: BudgetScope,
pub spent: u64,
}
#[derive(Default)]
struct LedgerInner {
by_key: HashMap<String, (u64, u32)>, by_desk: HashMap<String, (u64, u32)>,
by_strategy: HashMap<String, (u64, u32)>,
total_micro: u64,
total_tokens: u32,
}
#[derive(Clone, Default)]
pub struct SpendLedger {
inner: Arc<Mutex<LedgerInner>>,
telemetry: Option<Telemetry>,
run_id: Option<String>,
}
impl SpendLedger {
pub fn new() -> Self {
Self::default()
}
pub fn with_telemetry(mut self, telemetry: Telemetry, run_id: impl Into<String>) -> Self {
self.telemetry = Some(telemetry);
self.run_id = Some(run_id.into());
self
}
pub fn record(&self, spend: &Spend) {
{
let mut g = self.inner.lock();
g.total_micro += spend.micro_usd;
g.total_tokens += spend.tokens;
if let Some(k) = &spend.decision_key {
let e = g.by_key.entry(k.canonical()).or_default();
e.0 += spend.micro_usd;
e.1 += spend.tokens;
let d = g.by_desk.entry(k.desk.clone()).or_default();
d.0 += spend.micro_usd;
d.1 += spend.tokens;
let s = g.by_strategy.entry(k.strategy.clone()).or_default();
s.0 += spend.micro_usd;
s.1 += spend.tokens;
}
}
if let Some(t) = &self.telemetry {
let mut e = RunEvent::new(RunEventKind::BudgetSpent {
decision_key: spend.decision_key.as_ref().map(|k| k.canonical()),
micro_usd: spend.micro_usd,
});
e.run = self.run_id.clone();
t.emit(e);
}
}
pub fn spend_by(&self, key: &DecisionKey) -> u64 {
self.inner
.lock()
.by_key
.get(&key.canonical())
.map(|(m, _)| *m)
.unwrap_or(0)
}
pub fn total_micro_usd(&self) -> u64 {
self.inner.lock().total_micro
}
pub fn total_tokens(&self) -> u32 {
self.inner.lock().total_tokens
}
fn scope_spend(&self, scope: &BudgetScope) -> (u64, u32) {
let g = self.inner.lock();
match scope {
BudgetScope::Global => (g.total_micro, g.total_tokens),
BudgetScope::Desk(d) => g.by_desk.get(d).copied().unwrap_or_default(),
BudgetScope::Strategy(s) => g.by_strategy.get(s).copied().unwrap_or_default(),
}
}
pub fn check(&self, budget: &Budget) -> std::result::Result<(), BudgetExceeded> {
let (micro, tokens) = self.scope_spend(&budget.scope);
let over = match budget.cap {
Cap::Money(cap) => {
if micro > cap {
Some(micro)
} else {
None
}
}
Cap::Tokens(cap) => {
if tokens > cap {
Some(tokens as u64)
} else {
None
}
}
};
match over {
Some(spent) => Err(BudgetExceeded {
scope: budget.scope.clone(),
spent,
}),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use atomr_agents_observability::InMemoryTelemetrySink;
fn pricing() -> Pricing {
Pricing::new().with_model(
"claude",
ModelPricing {
input_micro_usd_per_1k: 3000, output_micro_usd_per_1k: 15000, },
)
}
#[test]
fn estimate_and_record() {
let m = CostMeter::new(pricing());
let est = m.estimate("claude", 1000, 1000);
assert_eq!(est.micro_usd, 3000 + 15000);
let usage = TokenUsage {
input_tokens: 2000,
output_tokens: 500,
..Default::default()
};
let spend = m.record("claude", &usage, Some(DecisionKey::new("credit", "carry", "d1")));
assert_eq!(spend.micro_usd, 2 * 3000 + (500 * 15000) / 1000);
}
#[test]
fn ledger_attributes_and_emits() {
let sink = Arc::new(InMemoryTelemetrySink::new());
let tel = Telemetry::new().with_sink(sink.clone());
let ledger = SpendLedger::new().with_telemetry(tel, "run-1");
let key = DecisionKey::new("credit", "carry", "d1");
ledger.record(&Spend {
micro_usd: 5000,
tokens: 100,
decision_key: Some(key.clone()),
});
ledger.record(&Spend {
micro_usd: 2000,
tokens: 50,
decision_key: Some(key.clone()),
});
assert_eq!(ledger.spend_by(&key), 7000);
assert_eq!(ledger.total_micro_usd(), 7000);
assert_eq!(sink.len(), 2);
}
#[test]
fn budget_cap_triggers_exceeded() {
let ledger = SpendLedger::new();
let key = DecisionKey::new("credit", "carry", "d1");
ledger.record(&Spend {
micro_usd: 12_000,
tokens: 100,
decision_key: Some(key),
});
let b = Budget {
cap: Cap::Money(10_000),
scope: BudgetScope::Desk("credit".into()),
};
assert!(ledger.check(&b).is_err());
let ok = Budget {
cap: Cap::Money(100_000),
scope: BudgetScope::Global,
};
assert!(ledger.check(&ok).is_ok());
}
}