use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::language_models::TokenUsage;
use crate::observability::{MetricsSink, ObsEvent};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ModelPrice {
pub input_per_1k: f64,
pub output_per_1k: f64,
}
impl ModelPrice {
pub fn new(input_per_1k: f64, output_per_1k: f64) -> Self {
Self {
input_per_1k,
output_per_1k,
}
}
pub fn free() -> Self {
Self::new(0.0, 0.0)
}
pub fn cost_of(&self, prompt_tokens: usize, completion_tokens: usize) -> f64 {
(prompt_tokens as f64 / 1000.0) * self.input_per_1k
+ (completion_tokens as f64 / 1000.0) * self.output_per_1k
}
pub fn blended_per_1k(&self) -> f64 {
0.75 * self.input_per_1k + 0.25 * self.output_per_1k
}
}
#[derive(Debug, Clone, Default)]
pub struct PricingTable {
qualified: HashMap<(String, String), ModelPrice>,
model_only: HashMap<String, ModelPrice>,
}
impl PricingTable {
pub fn new() -> Self {
Self::default()
}
pub fn builtin() -> Self {
let mut t = Self::new();
for (provider, model, input, output) in [
("openai", "gpt-4o", 2.5, 10.0),
("openai", "gpt-4o-mini", 0.15, 0.60),
("openai", "gpt-4.1", 2.0, 8.0),
("openai", "gpt-4.1-mini", 0.40, 1.60),
("openai", "o4-mini", 1.10, 4.40),
("anthropic", "claude-3-5-sonnet-latest", 3.0, 15.0),
("anthropic", "claude-3-5-haiku-latest", 0.80, 4.0),
("google", "gemini-1.5-pro", 1.25, 5.0),
("google", "gemini-1.5-flash", 0.075, 0.30),
("groq", "llama-3.3-70b-versatile", 0.59, 0.79),
("groq", "llama-3.1-8b-instant", 0.05, 0.08),
("deepseek", "deepseek-chat", 0.27, 1.10),
] {
t.insert(Some(provider), model, ModelPrice::new(input, output));
}
t
}
pub fn with(
mut self,
provider: impl Into<String>,
model: impl Into<String>,
price: ModelPrice,
) -> Self {
self.insert(Some(provider), model, price);
self
}
pub fn with_model_only(mut self, model: impl Into<String>, price: ModelPrice) -> Self {
self.insert(Option::<&str>::None, model, price);
self
}
pub fn insert(
&mut self,
provider: Option<impl Into<String>>,
model: impl Into<String>,
price: ModelPrice,
) {
let model = model.into();
match provider {
Some(provider) => {
self.qualified.insert((provider.into(), model), price);
}
None => {
self.model_only.insert(model, price);
}
}
}
pub fn get(&self, provider: Option<&str>, model: &str) -> Option<&ModelPrice> {
if let Some(provider) = provider {
if let Some(price) = self
.qualified
.get(&(provider.to_string(), model.to_string()))
{
return Some(price);
}
}
self.model_only.get(model)
}
pub fn len(&self) -> usize {
self.qualified.len() + self.model_only.len()
}
pub fn is_empty(&self) -> bool {
self.qualified.is_empty() && self.model_only.is_empty()
}
pub fn from_registry(registry: &crate::model_registry::ModelRegistry) -> Self {
let mut table = Self::new();
for info in registry.models() {
table.insert(Some(info.provider.clone()), info.id.clone(), info.price);
}
table
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CostRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
pub model: String,
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelSpend {
pub calls: usize,
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub cost_usd: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CostReport {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub calls: usize,
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub total_cost_usd: f64,
pub by_model: HashMap<String, ModelSpend>,
}
#[derive(Default)]
struct Inner {
calls: usize,
prompt_tokens: usize,
completion_tokens: usize,
total_cost_usd: f64,
by_model: HashMap<String, ModelSpend>,
records: Vec<CostRecord>,
}
pub struct CostTracker {
table: Arc<PricingTable>,
scope: Option<String>,
sink: Option<Arc<dyn MetricsSink>>,
inner: Mutex<Inner>,
}
impl CostTracker {
pub fn new(table: impl Into<Arc<PricingTable>>) -> Self {
Self {
table: table.into(),
scope: None,
sink: None,
inner: Mutex::new(Inner::default()),
}
}
pub fn with_builtin_prices() -> Self {
Self::new(Arc::new(PricingTable::builtin()))
}
pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}
pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
self.sink = Some(sink);
self
}
pub fn pricing(&self) -> &PricingTable {
&self.table
}
pub async fn record(
&self,
provider: Option<&str>,
model: &str,
prompt_tokens: usize,
completion_tokens: usize,
) -> f64 {
let cost = self
.table
.get(provider, model)
.map(|p| p.cost_of(prompt_tokens, completion_tokens))
.unwrap_or(0.0);
let record = CostRecord {
provider: provider.map(str::to_string),
model: model.to_string(),
prompt_tokens,
completion_tokens,
cost_usd: cost,
};
let key = match provider {
Some(provider) => format!("{provider}/{model}"),
None => model.to_string(),
};
{
let mut inner = self.inner.lock().await;
inner.calls += 1;
inner.prompt_tokens += prompt_tokens;
inner.completion_tokens += completion_tokens;
inner.total_cost_usd += cost;
let entry = inner.by_model.entry(key).or_default();
entry.calls += 1;
entry.prompt_tokens += prompt_tokens;
entry.completion_tokens += completion_tokens;
entry.cost_usd += cost;
inner.records.push(record.clone());
}
if let Some(sink) = &self.sink {
let evt = ObsEvent::Cost(crate::observability::CostEvent {
scope: self.scope.clone(),
provider: provider.map(str::to_string),
model: model.to_string(),
prompt_tokens,
completion_tokens,
cost_usd: cost,
});
if let Err(e) = sink.export(&evt).await {
log::warn!(target: "lc_core::cost", "cost event export failed: {e}");
}
}
cost
}
pub async fn record_usage(
&self,
provider: Option<&str>,
model: &str,
usage: &TokenUsage,
) -> f64 {
self.record(
provider,
model,
usage.prompt_tokens,
usage.completion_tokens,
)
.await
}
pub async fn total_cost_usd(&self) -> f64 {
self.inner.lock().await.total_cost_usd
}
pub async fn report(&self) -> CostReport {
let inner = self.inner.lock().await;
CostReport {
scope: self.scope.clone(),
calls: inner.calls,
prompt_tokens: inner.prompt_tokens,
completion_tokens: inner.completion_tokens,
total_cost_usd: inner.total_cost_usd,
by_model: inner.by_model.clone(),
}
}
pub async fn records(&self) -> Vec<CostRecord> {
self.inner.lock().await.records.clone()
}
pub async fn reset(&self) {
*self.inner.lock().await = Inner::default();
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CostError {
#[error("cost catalog fetch failed: {0}")]
Fetch(String),
#[error("cost catalog payload invalid: {0}")]
Payload(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn price_calculation_is_exact() {
let p = ModelPrice::new(2.0, 8.0);
assert_eq!(p.cost_of(500, 250), 3.0);
assert_eq!(p.cost_of(0, 0), 0.0);
assert_eq!(p.cost_of(1000, 1000), 10.0);
}
#[test]
fn free_prices_remain_zero() {
assert_eq!(ModelPrice::free().cost_of(10_000, 10_000), 0.0);
}
#[test]
fn blended_mix_weights_input_three_quarters() {
assert_eq!(ModelPrice::new(4.0, 8.0).blended_per_1k(), 5.0);
}
#[test]
fn table_qualified_entry_shadows_model_only() {
let table = PricingTable::new()
.with("openai", "gpt-x", ModelPrice::new(1.0, 2.0))
.with_model_only("gpt-x", ModelPrice::new(9.0, 9.0));
assert_eq!(
table.get(Some("openai"), "gpt-x"),
Some(&ModelPrice::new(1.0, 2.0))
);
assert_eq!(
table.get(Some("proxy"), "gpt-x"),
Some(&ModelPrice::new(9.0, 9.0))
);
assert_eq!(table.get(None, "gpt-x"), Some(&ModelPrice::new(9.0, 9.0)));
assert_eq!(table.get(Some("openai"), "missing"), None);
assert_eq!(table.len(), 2);
}
#[test]
fn builtin_table_covers_seeded_models() {
let table = PricingTable::builtin();
assert!(table.len() >= 10);
assert_eq!(
table.get(Some("openai"), "gpt-4o-mini"),
Some(&ModelPrice::new(0.15, 0.60))
);
}
#[tokio::test]
async fn tracker_aggregates_per_model_and_total() {
let tracker = CostTracker::new(Arc::new(
PricingTable::new()
.with("openai", "gpt-x", ModelPrice::new(2.0, 8.0))
.with("anthropic", "c-x", ModelPrice::new(3.0, 15.0)),
));
let c1 = tracker.record(Some("openai"), "gpt-x", 1000, 500).await;
assert_eq!(c1, 6.0);
tracker.record(Some("openai"), "gpt-x", 2000, 0).await;
tracker.record(Some("anthropic"), "c-x", 1000, 1000).await;
assert_eq!(tracker.total_cost_usd().await, 28.0);
let report = tracker.report().await;
assert_eq!(report.calls, 3);
assert_eq!(report.prompt_tokens, 4000);
assert_eq!(report.completion_tokens, 1500);
assert_eq!(report.by_model["openai/gpt-x"].calls, 2);
assert_eq!(report.by_model["openai/gpt-x"].cost_usd, 10.0);
assert_eq!(report.by_model["anthropic/c-x"].cost_usd, 18.0);
assert_eq!(tracker.records().await.len(), 3);
}
#[tokio::test]
async fn unknown_model_prices_zero_but_still_counts() {
let tracker = CostTracker::with_builtin_prices();
let cost = tracker.record(Some("local"), "oss-model", 1000, 1000).await;
assert_eq!(cost, 0.0);
let report = tracker.report().await;
assert_eq!(report.calls, 1);
assert_eq!(report.total_cost_usd, 0.0);
assert_eq!(report.by_model["local/oss-model"].prompt_tokens, 1000);
}
#[tokio::test]
async fn reset_clears_accumulation() {
let tracker = CostTracker::with_builtin_prices();
tracker
.record(Some("openai"), "gpt-4o-mini", 1000, 1000)
.await;
assert_eq!(tracker.total_cost_usd().await, 0.75);
tracker.reset().await;
assert_eq!(tracker.total_cost_usd().await, 0.0);
assert_eq!(tracker.report().await.calls, 0);
}
#[tokio::test]
async fn report_serializes_scope_and_totals() {
let tracker = CostTracker::with_builtin_prices().with_scope("run-7");
tracker
.record(Some("openai"), "gpt-4o-mini", 1000, 1000)
.await;
let json = serde_json::to_value(tracker.report().await).unwrap();
assert_eq!(json["scope"], "run-7");
assert_eq!(json["total_cost_usd"], 0.75);
assert_eq!(json["by_model"]["openai/gpt-4o-mini"]["calls"], 1);
}
}