1use 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
32pub struct ModelPrice {
33 pub input_per_1k: f64,
35 pub output_per_1k: f64,
37}
38
39impl ModelPrice {
40 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 pub fn free() -> Self {
50 Self::new(0.0, 0.0)
51 }
52
53 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 pub fn blended_per_1k(&self) -> f64 {
63 0.75 * self.input_per_1k + 0.25 * self.output_per_1k
64 }
65}
66
67#[derive(Debug, Clone, Default)]
72pub struct PricingTable {
73 qualified: HashMap<(String, String), ModelPrice>,
74 model_only: HashMap<String, ModelPrice>,
75}
76
77impl PricingTable {
78 pub fn new() -> Self {
80 Self::default()
81 }
82
83 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 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 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 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 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 pub fn len(&self) -> usize {
160 self.qualified.len() + self.model_only.len()
161 }
162
163 pub fn is_empty(&self) -> bool {
165 self.qualified.is_empty() && self.model_only.is_empty()
166 }
167
168 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct CostRecord {
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub provider: Option<String>,
185 pub model: String,
187 pub prompt_tokens: usize,
189 pub completion_tokens: usize,
191 pub cost_usd: f64,
193}
194
195#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
197pub struct ModelSpend {
198 pub calls: usize,
200 pub prompt_tokens: usize,
202 pub completion_tokens: usize,
204 pub cost_usd: f64,
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct CostReport {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub scope: Option<String>,
214 pub calls: usize,
216 pub prompt_tokens: usize,
218 pub completion_tokens: usize,
220 pub total_cost_usd: f64,
222 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
237pub 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 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 pub fn with_builtin_prices() -> Self {
263 Self::new(Arc::new(PricingTable::builtin()))
264 }
265
266 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
269 self.scope = Some(scope.into());
270 self
271 }
272
273 pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
276 self.sink = Some(sink);
277 self
278 }
279
280 pub fn pricing(&self) -> &PricingTable {
282 &self.table
283 }
284
285 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 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 pub async fn total_cost_usd(&self) -> f64 {
364 self.inner.lock().await.total_cost_usd
365 }
366
367 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 pub async fn records(&self) -> Vec<CostRecord> {
382 self.inner.lock().await.records.clone()
383 }
384
385 pub async fn reset(&self) {
388 *self.inner.lock().await = Inner::default();
389 }
390}
391
392#[derive(Debug, thiserror::Error)]
394#[non_exhaustive]
395pub enum CostError {
396 #[error("cost catalog fetch failed: {0}")]
398 Fetch(String),
399 #[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 assert_eq!(p.cost_of(500, 250), 3.0);
413 assert_eq!(p.cost_of(0, 0), 0.0);
414 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 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 assert_eq!(
440 table.get(Some("proxy"), "gpt-x"),
441 Some(&ModelPrice::new(9.0, 9.0))
442 );
443 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 let c1 = tracker.record(Some("openai"), "gpt-x", 1000, 500).await;
469 assert_eq!(c1, 6.0);
470 tracker.record(Some("openai"), "gpt-x", 2000, 0).await;
472 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}