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}