Skip to main content

lc_core/
cost.rs

1//! Unified cost tracking (B3, 0.22.4).
2//!
3//! Per provider/model pricing, cumulative USD spend aggregation per run or
4//! session, and hard budget enforcement. A single [`CostTracker`] is shared
5//! (`Arc`) across every LLM call of a run — or across multiple runs of one
6//! session — and [`CostTracker::record`] prices each call through its
7//! [`PricingTable`], aggregates the totals, and optionally emits a
8//! [`crate::observability::ObsEvent::Cost`] record.
9//!
10//! Design notes:
11//! - Prices are quoted **USD per 1,000 tokens** (matching the legacy
12//!   [`crate::token_counter::ModelPricing`]); zero is a valid price (local /
13//!   OSS models).
14//! - Calls to models absent from the table are still counted (calls/tokens)
15//!   but priced at 0.0 — missing pricing data degrades to usage-only tracking,
16//!   never a hard error, so attaching a tracker cannot break the agent loop.
17//! - The tracker only *measures*. The hard stop lives next to the existing
18//!   budget gates (`lc-agents::executor::BudgetConfig::max_cost_usd`), keeping
19//!   enforcement policy out of the measurement primitive.
20
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use serde::{Deserialize, Serialize};
25use tokio::sync::Mutex;
26
27use crate::language_models::TokenUsage;
28use crate::observability::{MetricsSink, ObsEvent};
29
30/// Price of one model, in USD per 1,000 tokens.
31#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
32pub struct ModelPrice {
33    /// USD per 1,000 **input** (prompt) tokens.
34    pub input_per_1k: f64,
35    /// USD per 1,000 **output** (completion) tokens.
36    pub output_per_1k: f64,
37}
38
39impl ModelPrice {
40    /// Creates a price entry.
41    pub fn new(input_per_1k: f64, output_per_1k: f64) -> Self {
42        Self {
43            input_per_1k,
44            output_per_1k,
45        }
46    }
47
48    /// Free entry (local / open-source / self-hosted model).
49    pub fn free() -> Self {
50        Self::new(0.0, 0.0)
51    }
52
53    /// Prices one call. Pure function — the core of the whole tracker.
54    pub fn cost_of(&self, prompt_tokens: usize, completion_tokens: usize) -> f64 {
55        (prompt_tokens as f64 / 1000.0) * self.input_per_1k
56            + (completion_tokens as f64 / 1000.0) * self.output_per_1k
57    }
58
59    /// Single comparable cost figure for routing weights, assuming a
60    /// representative 3:1 prompt:completion traffic mix (75% input, 25%
61    /// output). Callers that know their real mix should price calls directly.
62    pub fn blended_per_1k(&self) -> f64 {
63        0.75 * self.input_per_1k + 0.25 * self.output_per_1k
64    }
65}
66
67/// Provider/model pricing table.
68///
69/// Lookup keys on `(provider, model)`; entries whose provider is `None` act as
70/// model-only fallbacks (matched when no provider-qualified entry exists).
71#[derive(Debug, Clone, Default)]
72pub struct PricingTable {
73    qualified: HashMap<(String, String), ModelPrice>,
74    model_only: HashMap<String, ModelPrice>,
75}
76
77impl PricingTable {
78    /// Empty table (every call prices at zero).
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Built-in snapshot of common models (USD/1K, as of 2026-09).
84    ///
85    /// This is a convenience seed, not a maintained source of truth: provider
86    /// prices change frequently. Fetch a current [`crate::model_registry::ModelRegistry`]
87    /// remotely and convert it with [`PricingTable::from_registry`] for
88    /// production accounting.
89    pub fn builtin() -> Self {
90        let mut t = Self::new();
91        for (provider, model, input, output) in [
92            ("openai", "gpt-4o", 2.5, 10.0),
93            ("openai", "gpt-4o-mini", 0.15, 0.60),
94            ("openai", "gpt-4.1", 2.0, 8.0),
95            ("openai", "gpt-4.1-mini", 0.40, 1.60),
96            ("openai", "o4-mini", 1.10, 4.40),
97            ("anthropic", "claude-3-5-sonnet-latest", 3.0, 15.0),
98            ("anthropic", "claude-3-5-haiku-latest", 0.80, 4.0),
99            ("google", "gemini-1.5-pro", 1.25, 5.0),
100            ("google", "gemini-1.5-flash", 0.075, 0.30),
101            ("groq", "llama-3.3-70b-versatile", 0.59, 0.79),
102            ("groq", "llama-3.1-8b-instant", 0.05, 0.08),
103            ("deepseek", "deepseek-chat", 0.27, 1.10),
104        ] {
105            t.insert(Some(provider), model, ModelPrice::new(input, output));
106        }
107        t
108    }
109
110    /// Adds/replaces a qualified entry; returns the table for chaining.
111    pub fn with(
112        mut self,
113        provider: impl Into<String>,
114        model: impl Into<String>,
115        price: ModelPrice,
116    ) -> Self {
117        self.insert(Some(provider), model, price);
118        self
119    }
120
121    /// Adds/replaces a model-only fallback entry.
122    pub fn with_model_only(mut self, model: impl Into<String>, price: ModelPrice) -> Self {
123        self.insert(Option::<&str>::None, model, price);
124        self
125    }
126
127    /// Inserts an entry. `provider = None` registers a model-only fallback.
128    pub fn insert(
129        &mut self,
130        provider: Option<impl Into<String>>,
131        model: impl Into<String>,
132        price: ModelPrice,
133    ) {
134        let model = model.into();
135        match provider {
136            Some(provider) => {
137                self.qualified.insert((provider.into(), model), price);
138            }
139            None => {
140                self.model_only.insert(model, price);
141            }
142        }
143    }
144
145    /// Lookup: provider-qualified first, then the model-only fallback.
146    pub fn get(&self, provider: Option<&str>, model: &str) -> Option<&ModelPrice> {
147        if let Some(provider) = provider {
148            if let Some(price) = self
149                .qualified
150                .get(&(provider.to_string(), model.to_string()))
151            {
152                return Some(price);
153            }
154        }
155        self.model_only.get(model)
156    }
157
158    /// Number of registered entries.
159    pub fn len(&self) -> usize {
160        self.qualified.len() + self.model_only.len()
161    }
162
163    /// Whether no entry is registered.
164    pub fn is_empty(&self) -> bool {
165        self.qualified.is_empty() && self.model_only.is_empty()
166    }
167
168    /// Builds a table from a model registry (qualified entries only).
169    pub fn from_registry(registry: &crate::model_registry::ModelRegistry) -> Self {
170        let mut table = Self::new();
171        for info in registry.models() {
172            table.insert(Some(info.provider.clone()), info.id.clone(), info.price);
173        }
174        table
175    }
176}
177
178/// One priced LLM call.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct CostRecord {
181    /// Provider slug (`"openai"`, `"anthropic"`, ...); `None` when the caller
182    /// did not declare one.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub provider: Option<String>,
185    /// Model id as reported by the model.
186    pub model: String,
187    /// Prompt tokens of the call.
188    pub prompt_tokens: usize,
189    /// Completion tokens of the call.
190    pub completion_tokens: usize,
191    /// Priced USD cost (0.0 when the table has no entry).
192    pub cost_usd: f64,
193}
194
195/// Aggregated spend of one `(provider, model)` key.
196#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
197pub struct ModelSpend {
198    /// Number of calls.
199    pub calls: usize,
200    /// Cumulative prompt tokens.
201    pub prompt_tokens: usize,
202    /// Cumulative completion tokens.
203    pub completion_tokens: usize,
204    /// Cumulative USD spend.
205    pub cost_usd: f64,
206}
207
208/// Point-in-time aggregate report of a [`CostTracker`].
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct CostReport {
211    /// Optional run/session label the tracker was scoped with.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub scope: Option<String>,
214    /// Total calls recorded.
215    pub calls: usize,
216    /// Total prompt tokens.
217    pub prompt_tokens: usize,
218    /// Total completion tokens.
219    pub completion_tokens: usize,
220    /// Total USD spend across all models.
221    pub total_cost_usd: f64,
222    /// Per-model breakdown keyed `"<provider>/<model>"` (or `"<model>"` when no
223    /// provider was declared).
224    pub by_model: HashMap<String, ModelSpend>,
225}
226
227#[derive(Default)]
228struct Inner {
229    calls: usize,
230    prompt_tokens: usize,
231    completion_tokens: usize,
232    total_cost_usd: f64,
233    by_model: HashMap<String, ModelSpend>,
234    records: Vec<CostRecord>,
235}
236
237/// Thread-safe cumulative cost tracker.
238///
239/// Construct once per run (or share one per session), attach it to a
240/// [`crate::token_counter::TokenTrackingLLM`] with
241/// `with_cost_tracker`, optionally share the same `Arc` with an
242/// `AgentExecutor::with_cost_tracker` for hard budget enforcement.
243pub struct CostTracker {
244    table: Arc<PricingTable>,
245    scope: Option<String>,
246    sink: Option<Arc<dyn MetricsSink>>,
247    inner: Mutex<Inner>,
248}
249
250impl CostTracker {
251    /// Tracker over the given pricing table.
252    pub fn new(table: impl Into<Arc<PricingTable>>) -> Self {
253        Self {
254            table: table.into(),
255            scope: None,
256            sink: None,
257            inner: Mutex::new(Inner::default()),
258        }
259    }
260
261    /// Tracker over the built-in price snapshot.
262    pub fn with_builtin_prices() -> Self {
263        Self::new(Arc::new(PricingTable::builtin()))
264    }
265
266    /// Labels this tracker with a run/session id (carried in reports and
267    /// emitted `cost` events).
268    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
269        self.scope = Some(scope.into());
270        self
271    }
272
273    /// Attaches an observability sink: every `record` emits one
274    /// [`ObsEvent::Cost`]. Sink failures are warned, never propagated.
275    pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
276        self.sink = Some(sink);
277        self
278    }
279
280    /// The pricing table backing this tracker.
281    pub fn pricing(&self) -> &PricingTable {
282        &self.table
283    }
284
285    /// Records one LLM call and returns its USD cost.
286    ///
287    /// Models absent from the table price at 0.0 but still count toward token /
288    /// call aggregates. The observability event (when attached) is exported
289    /// *after* aggregation; export failure only logs a warning.
290    pub async fn record(
291        &self,
292        provider: Option<&str>,
293        model: &str,
294        prompt_tokens: usize,
295        completion_tokens: usize,
296    ) -> f64 {
297        let cost = self
298            .table
299            .get(provider, model)
300            .map(|p| p.cost_of(prompt_tokens, completion_tokens))
301            .unwrap_or(0.0);
302
303        let record = CostRecord {
304            provider: provider.map(str::to_string),
305            model: model.to_string(),
306            prompt_tokens,
307            completion_tokens,
308            cost_usd: cost,
309        };
310        let key = match provider {
311            Some(provider) => format!("{provider}/{model}"),
312            None => model.to_string(),
313        };
314
315        {
316            let mut inner = self.inner.lock().await;
317            inner.calls += 1;
318            inner.prompt_tokens += prompt_tokens;
319            inner.completion_tokens += completion_tokens;
320            inner.total_cost_usd += cost;
321            let entry = inner.by_model.entry(key).or_default();
322            entry.calls += 1;
323            entry.prompt_tokens += prompt_tokens;
324            entry.completion_tokens += completion_tokens;
325            entry.cost_usd += cost;
326            inner.records.push(record.clone());
327        }
328
329        if let Some(sink) = &self.sink {
330            let evt = ObsEvent::Cost(crate::observability::CostEvent {
331                scope: self.scope.clone(),
332                provider: provider.map(str::to_string),
333                model: model.to_string(),
334                prompt_tokens,
335                completion_tokens,
336                cost_usd: cost,
337            });
338            if let Err(e) = sink.export(&evt).await {
339                log::warn!(target: "lc_core::cost", "cost event export failed: {e}");
340            }
341        }
342
343        cost
344    }
345
346    /// Records one call from a provider-annotated [`TokenUsage`].
347    pub async fn record_usage(
348        &self,
349        provider: Option<&str>,
350        model: &str,
351        usage: &TokenUsage,
352    ) -> f64 {
353        self.record(
354            provider,
355            model,
356            usage.prompt_tokens,
357            usage.completion_tokens,
358        )
359        .await
360    }
361
362    /// Cumulative USD spend (the value the budget gate reads).
363    pub async fn total_cost_usd(&self) -> f64 {
364        self.inner.lock().await.total_cost_usd
365    }
366
367    /// Snapshot report of everything recorded so far.
368    pub async fn report(&self) -> CostReport {
369        let inner = self.inner.lock().await;
370        CostReport {
371            scope: self.scope.clone(),
372            calls: inner.calls,
373            prompt_tokens: inner.prompt_tokens,
374            completion_tokens: inner.completion_tokens,
375            total_cost_usd: inner.total_cost_usd,
376            by_model: inner.by_model.clone(),
377        }
378    }
379
380    /// Every individual call recorded so far (oldest first).
381    pub async fn records(&self) -> Vec<CostRecord> {
382        self.inner.lock().await.records.clone()
383    }
384
385    /// Resets all accumulation (e.g. start a new run while keeping the same
386    /// session-scoped tracker).
387    pub async fn reset(&self) {
388        *self.inner.lock().await = Inner::default();
389    }
390}
391
392/// Errors from loading a price/model catalog (local JSON or remote fetch).
393#[derive(Debug, thiserror::Error)]
394#[non_exhaustive]
395pub enum CostError {
396    /// Transport/HTTP failure while fetching a remote catalog.
397    #[error("cost catalog fetch failed: {0}")]
398    Fetch(String),
399    /// JSON payload did not match the catalog schema.
400    #[error("cost catalog payload invalid: {0}")]
401    Payload(String),
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn price_calculation_is_exact() {
410        let p = ModelPrice::new(2.0, 8.0);
411        // 500 in * 2/1k + 250 out * 8/1k = 1.0 + 2.0 = 3.0
412        assert_eq!(p.cost_of(500, 250), 3.0);
413        assert_eq!(p.cost_of(0, 0), 0.0);
414        // 1k/1k at full price
415        assert_eq!(p.cost_of(1000, 1000), 10.0);
416    }
417
418    #[test]
419    fn free_prices_remain_zero() {
420        assert_eq!(ModelPrice::free().cost_of(10_000, 10_000), 0.0);
421    }
422
423    #[test]
424    fn blended_mix_weights_input_three_quarters() {
425        // 0.75*4 + 0.25*8 = 3 + 2 = 5
426        assert_eq!(ModelPrice::new(4.0, 8.0).blended_per_1k(), 5.0);
427    }
428
429    #[test]
430    fn table_qualified_entry_shadows_model_only() {
431        let table = PricingTable::new()
432            .with("openai", "gpt-x", ModelPrice::new(1.0, 2.0))
433            .with_model_only("gpt-x", ModelPrice::new(9.0, 9.0));
434        assert_eq!(
435            table.get(Some("openai"), "gpt-x"),
436            Some(&ModelPrice::new(1.0, 2.0))
437        );
438        // A different provider falls back to the model-only entry.
439        assert_eq!(
440            table.get(Some("proxy"), "gpt-x"),
441            Some(&ModelPrice::new(9.0, 9.0))
442        );
443        // No provider at all also uses model-only.
444        assert_eq!(table.get(None, "gpt-x"), Some(&ModelPrice::new(9.0, 9.0)));
445        assert_eq!(table.get(Some("openai"), "missing"), None);
446        assert_eq!(table.len(), 2);
447    }
448
449    #[test]
450    fn builtin_table_covers_seeded_models() {
451        let table = PricingTable::builtin();
452        assert!(table.len() >= 10);
453        assert_eq!(
454            table.get(Some("openai"), "gpt-4o-mini"),
455            Some(&ModelPrice::new(0.15, 0.60))
456        );
457    }
458
459    #[tokio::test]
460    async fn tracker_aggregates_per_model_and_total() {
461        let tracker = CostTracker::new(Arc::new(
462            PricingTable::new()
463                .with("openai", "gpt-x", ModelPrice::new(2.0, 8.0))
464                .with("anthropic", "c-x", ModelPrice::new(3.0, 15.0)),
465        ));
466
467        // call 1: 1000/500 on gpt-x -> 2 + 4 = 6
468        let c1 = tracker.record(Some("openai"), "gpt-x", 1000, 500).await;
469        assert_eq!(c1, 6.0);
470        // call 2: 2000/0 on gpt-x -> 4
471        tracker.record(Some("openai"), "gpt-x", 2000, 0).await;
472        // call 3: 1000/1000 on c-x -> 3 + 15 = 18
473        tracker.record(Some("anthropic"), "c-x", 1000, 1000).await;
474
475        assert_eq!(tracker.total_cost_usd().await, 28.0);
476        let report = tracker.report().await;
477        assert_eq!(report.calls, 3);
478        assert_eq!(report.prompt_tokens, 4000);
479        assert_eq!(report.completion_tokens, 1500);
480        assert_eq!(report.by_model["openai/gpt-x"].calls, 2);
481        assert_eq!(report.by_model["openai/gpt-x"].cost_usd, 10.0);
482        assert_eq!(report.by_model["anthropic/c-x"].cost_usd, 18.0);
483        assert_eq!(tracker.records().await.len(), 3);
484    }
485
486    #[tokio::test]
487    async fn unknown_model_prices_zero_but_still_counts() {
488        let tracker = CostTracker::with_builtin_prices();
489        let cost = tracker.record(Some("local"), "oss-model", 1000, 1000).await;
490        assert_eq!(cost, 0.0);
491        let report = tracker.report().await;
492        assert_eq!(report.calls, 1);
493        assert_eq!(report.total_cost_usd, 0.0);
494        assert_eq!(report.by_model["local/oss-model"].prompt_tokens, 1000);
495    }
496
497    #[tokio::test]
498    async fn reset_clears_accumulation() {
499        let tracker = CostTracker::with_builtin_prices();
500        tracker
501            .record(Some("openai"), "gpt-4o-mini", 1000, 1000)
502            .await;
503        assert_eq!(tracker.total_cost_usd().await, 0.75);
504        tracker.reset().await;
505        assert_eq!(tracker.total_cost_usd().await, 0.0);
506        assert_eq!(tracker.report().await.calls, 0);
507    }
508
509    #[tokio::test]
510    async fn report_serializes_scope_and_totals() {
511        let tracker = CostTracker::with_builtin_prices().with_scope("run-7");
512        tracker
513            .record(Some("openai"), "gpt-4o-mini", 1000, 1000)
514            .await;
515        let json = serde_json::to_value(tracker.report().await).unwrap();
516        assert_eq!(json["scope"], "run-7");
517        assert_eq!(json["total_cost_usd"], 0.75);
518        assert_eq!(json["by_model"]["openai/gpt-4o-mini"]["calls"], 1);
519    }
520}