Skip to main content

cost_meter/
lib.rs

1//! # cost-meter
2//!
3//! Aggregate LLM API cost across providers, models, and time windows.
4//!
5//! Provider-agnostic: you compute the per-call cost with whatever pricing
6//! crate you like (`claude-cost`, `openai-cost`, `gemini-cost`,
7//! `bedrock-cost`, or BYO) and feed `(provider, model, tokens, cost)`
8//! into the meter. The meter keeps running totals broken down by
9//! provider and model, and exposes them as a sorted snapshot.
10//!
11//! ## Example
12//!
13//! ```
14//! use cost_meter::{Meter, Call};
15//!
16//! let mut meter = Meter::new();
17//! meter.record(Call {
18//!     provider: "anthropic",
19//!     model: "claude-sonnet-4-5",
20//!     input_tokens: 1_000,
21//!     output_tokens: 500,
22//!     cost_usd: 0.0105,
23//! });
24//! meter.record(Call {
25//!     provider: "openai",
26//!     model: "gpt-5",
27//!     input_tokens: 2_000,
28//!     output_tokens: 800,
29//!     cost_usd: 0.0105,
30//! });
31//!
32//! let snap = meter.snapshot();
33//! assert_eq!(snap.total_calls, 2);
34//! assert!((snap.total_cost_usd - 0.021).abs() < 1e-9);
35//!
36//! // Per-provider breakdown is sorted by cost desc.
37//! let by_provider = meter.by_provider();
38//! assert_eq!(by_provider.len(), 2);
39//! ```
40
41#![deny(missing_docs)]
42
43#[cfg(feature = "serde")]
44use serde::{Deserialize, Serialize};
45
46use std::collections::BTreeMap;
47
48/// A single LLM call to record against the meter.
49#[derive(Debug, Clone, Copy)]
50pub struct Call<'a> {
51    /// Provider name. Free-form; typical values: `anthropic`, `openai`,
52    /// `google`, `bedrock`.
53    pub provider: &'a str,
54    /// Model id as you call it (need not be normalized).
55    pub model: &'a str,
56    /// Fresh input tokens billed on this call.
57    pub input_tokens: u64,
58    /// Output tokens billed on this call.
59    pub output_tokens: u64,
60    /// Pre-computed USD cost for this call.
61    pub cost_usd: f64,
62}
63
64/// Aggregated counters for a single (provider, model) pair.
65#[derive(Debug, Clone, Default, PartialEq)]
66#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
67pub struct Bucket {
68    /// Number of calls counted.
69    pub calls: u64,
70    /// Total input tokens.
71    pub input_tokens: u64,
72    /// Total output tokens.
73    pub output_tokens: u64,
74    /// Total USD cost.
75    pub cost_usd: f64,
76}
77
78impl Bucket {
79    fn add_call(&mut self, c: &Call<'_>) {
80        self.calls += 1;
81        self.input_tokens += c.input_tokens;
82        self.output_tokens += c.output_tokens;
83        self.cost_usd += c.cost_usd;
84    }
85}
86
87/// Top-level snapshot of running totals.
88#[derive(Debug, Clone, Default, PartialEq)]
89#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
90pub struct Snapshot {
91    /// Total calls recorded.
92    pub total_calls: u64,
93    /// Total input tokens.
94    pub total_input_tokens: u64,
95    /// Total output tokens.
96    pub total_output_tokens: u64,
97    /// Total USD cost.
98    pub total_cost_usd: f64,
99}
100
101/// Cost aggregator. Cheap to construct; cheap to record.
102#[derive(Debug, Default)]
103pub struct Meter {
104    by_pm: BTreeMap<(String, String), Bucket>,
105    snap: Snapshot,
106}
107
108impl Meter {
109    /// Create an empty meter.
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Record one call.
115    pub fn record(&mut self, c: Call<'_>) {
116        let key = (c.provider.to_string(), c.model.to_string());
117        let bucket = self.by_pm.entry(key).or_default();
118        bucket.add_call(&c);
119        self.snap.total_calls += 1;
120        self.snap.total_input_tokens += c.input_tokens;
121        self.snap.total_output_tokens += c.output_tokens;
122        self.snap.total_cost_usd += c.cost_usd;
123    }
124
125    /// Current snapshot of grand totals.
126    pub fn snapshot(&self) -> Snapshot {
127        self.snap.clone()
128    }
129
130    /// Buckets grouped by provider, sorted by cost desc.
131    pub fn by_provider(&self) -> Vec<(String, Bucket)> {
132        let mut acc: BTreeMap<String, Bucket> = BTreeMap::new();
133        for ((provider, _), b) in self.by_pm.iter() {
134            let target = acc.entry(provider.clone()).or_default();
135            target.calls += b.calls;
136            target.input_tokens += b.input_tokens;
137            target.output_tokens += b.output_tokens;
138            target.cost_usd += b.cost_usd;
139        }
140        let mut v: Vec<(String, Bucket)> = acc.into_iter().collect();
141        v.sort_by(|a, b| {
142            b.1.cost_usd
143                .partial_cmp(&a.1.cost_usd)
144                .unwrap_or(std::cmp::Ordering::Equal)
145        });
146        v
147    }
148
149    /// Buckets grouped by (provider, model), sorted by cost desc.
150    pub fn by_model(&self) -> Vec<((String, String), Bucket)> {
151        let mut v: Vec<((String, String), Bucket)> =
152            self.by_pm.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
153        v.sort_by(|a, b| {
154            b.1.cost_usd
155                .partial_cmp(&a.1.cost_usd)
156                .unwrap_or(std::cmp::Ordering::Equal)
157        });
158        v
159    }
160
161    /// Reset the meter to empty.
162    pub fn reset(&mut self) {
163        self.by_pm.clear();
164        self.snap = Snapshot::default();
165    }
166}