use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::llm::{pricing_for, ModelPricing, TokenUsage};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostData {
pub model: String,
pub provider: String,
pub total_usage: TokenUsage,
pub total_llm_latency_ms: u64,
pub cost_usd: Option<f64>,
pub pricing: Option<CostPricing>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CostPricing {
pub input_per_million: f64,
pub output_per_million: f64,
pub cache_hit_input_per_million: f64,
}
impl From<ModelPricing> for CostPricing {
fn from(p: ModelPricing) -> Self {
Self {
input_per_million: p.input_per_million,
output_per_million: p.output_per_million,
cache_hit_input_per_million: p.cache_hit_input_per_million,
}
}
}
pub struct CostTracker {
session_dir: PathBuf,
model: String,
provider: String,
pricing: Option<ModelPricing>,
accumulated_usage: TokenUsage,
accumulated_latency_ms: u64,
finished: bool,
}
impl CostTracker {
pub fn new(session_dir: PathBuf, model: &str, provider: &str) -> Self {
let pricing = pricing_for(model);
Self {
session_dir,
model: model.to_string(),
provider: provider.to_string(),
pricing,
accumulated_usage: TokenUsage::default(),
accumulated_latency_ms: 0,
finished: false,
}
}
pub fn record_usage(&mut self, usage: TokenUsage, latency_ms: u64) {
self.accumulated_usage = self.accumulated_usage.accumulate(usage);
self.accumulated_latency_ms = self.accumulated_latency_ms.saturating_add(latency_ms);
}
pub fn accumulated_usage(&self) -> TokenUsage {
self.accumulated_usage
}
pub fn accumulated_latency_ms(&self) -> u64 {
self.accumulated_latency_ms
}
pub fn cost_usd(&self) -> Option<f64> {
self.pricing.map(|p| p.cost_usd(self.accumulated_usage))
}
pub fn cost_data(&self) -> CostData {
CostData {
model: self.model.clone(),
provider: self.provider.clone(),
total_usage: self.accumulated_usage,
total_llm_latency_ms: self.accumulated_latency_ms,
cost_usd: self.cost_usd(),
pricing: self.pricing.map(CostPricing::from),
}
}
pub fn write_cost_json(&self) -> std::io::Result<PathBuf> {
let cost_path = self.session_dir.join("cost.json");
let data = self.cost_data();
let json = serde_json::to_string_pretty(&data)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&cost_path, json)?;
Ok(cost_path)
}
pub fn update_meta_with_cost(&self) -> std::io::Result<()> {
let meta_path = self.session_dir.join(".meta.json");
let existing = match std::fs::read_to_string(&meta_path) {
Ok(s) => s,
Err(_) => return Ok(()), };
let mut meta: serde_json::Value = match serde_json::from_str(&existing) {
Ok(v) => v,
Err(_) => return Ok(()),
};
if let Some(obj) = meta.as_object_mut() {
obj.insert(
"cost_usd".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(self.cost_usd().unwrap_or(0.0))
.unwrap_or(serde_json::Number::from_f64(0.0).unwrap()),
),
);
obj.insert(
"total_tokens".to_string(),
serde_json::Value::Number(serde_json::Number::from(
self.accumulated_usage.total_tokens,
)),
);
obj.insert(
"prompt_tokens".to_string(),
serde_json::Value::Number(serde_json::Number::from(
self.accumulated_usage.prompt_tokens,
)),
);
obj.insert(
"completion_tokens".to_string(),
serde_json::Value::Number(serde_json::Number::from(
self.accumulated_usage.completion_tokens,
)),
);
obj.insert(
"cache_hit_tokens".to_string(),
serde_json::Value::Number(serde_json::Number::from(
self.accumulated_usage.cache_hit_tokens,
)),
);
obj.insert(
"cache_miss_tokens".to_string(),
serde_json::Value::Number(serde_json::Number::from(
self.accumulated_usage.cache_miss_tokens,
)),
);
obj.insert(
"total_llm_latency_ms".to_string(),
serde_json::Value::Number(serde_json::Number::from(self.accumulated_latency_ms)),
);
}
let updated = serde_json::to_string_pretty(&meta)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&meta_path, updated)
}
pub fn finish(&mut self) -> std::io::Result<()> {
if self.finished {
return Ok(());
}
self.finished = true;
self.write_cost_json()?;
self.update_meta_with_cost()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::TokenUsage;
#[test]
fn test_cost_tracker_new() {
let dir = tempfile::tempdir().unwrap();
let tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
assert_eq!(tracker.model, "deepseek-chat");
assert_eq!(tracker.provider, "openai");
assert!(tracker.pricing.is_some());
assert_eq!(tracker.accumulated_usage.total_tokens, 0);
assert!(!tracker.finished);
}
#[test]
fn test_cost_tracker_record_usage() {
let dir = tempfile::tempdir().unwrap();
let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
let usage1 = TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cache_hit_tokens: 0,
cache_miss_tokens: 100,
};
tracker.record_usage(usage1, 500);
assert_eq!(tracker.accumulated_usage.prompt_tokens, 100);
assert_eq!(tracker.accumulated_usage.completion_tokens, 50);
assert_eq!(tracker.accumulated_usage.total_tokens, 150);
assert_eq!(tracker.accumulated_latency_ms, 500);
let usage2 = TokenUsage {
prompt_tokens: 200,
completion_tokens: 100,
total_tokens: 300,
cache_hit_tokens: 0,
cache_miss_tokens: 200,
};
tracker.record_usage(usage2, 300);
assert_eq!(tracker.accumulated_usage.prompt_tokens, 300);
assert_eq!(tracker.accumulated_usage.completion_tokens, 150);
assert_eq!(tracker.accumulated_usage.total_tokens, 450);
assert_eq!(tracker.accumulated_latency_ms, 800);
}
#[test]
fn test_cost_tracker_cost_usd() {
let dir = tempfile::tempdir().unwrap();
let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
let usage = TokenUsage {
prompt_tokens: 1_000_000,
completion_tokens: 500_000,
total_tokens: 1_500_000,
cache_hit_tokens: 0,
cache_miss_tokens: 1_000_000,
};
tracker.record_usage(usage, 0);
let cost = tracker.cost_usd().unwrap();
assert!((cost - 0.82).abs() < 0.001);
}
#[test]
fn test_cost_tracker_unknown_model() {
let dir = tempfile::tempdir().unwrap();
let tracker = CostTracker::new(dir.path().to_path_buf(), "nonexistent-model-v42", "openai");
assert!(tracker.pricing.is_none());
assert!(tracker.cost_usd().is_none());
}
#[test]
fn test_cost_tracker_write_cost_json() {
let dir = tempfile::tempdir().unwrap();
let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
let usage = TokenUsage {
prompt_tokens: 1000,
completion_tokens: 500,
total_tokens: 1500,
cache_hit_tokens: 200,
cache_miss_tokens: 800,
};
tracker.record_usage(usage, 1234);
let path = tracker.write_cost_json().unwrap();
assert!(path.exists());
assert_eq!(path.file_name().unwrap(), "cost.json");
let contents = std::fs::read_to_string(&path).unwrap();
let data: CostData = serde_json::from_str(&contents).unwrap();
assert_eq!(data.model, "deepseek-chat");
assert_eq!(data.total_usage.prompt_tokens, 1000);
assert_eq!(data.total_usage.completion_tokens, 500);
assert_eq!(data.total_usage.total_tokens, 1500);
assert_eq!(data.total_usage.cache_hit_tokens, 200);
assert_eq!(data.total_usage.cache_miss_tokens, 800);
assert_eq!(data.total_llm_latency_ms, 1234);
assert!(data.cost_usd.is_some());
assert!(data.pricing.is_some());
}
#[test]
fn test_cost_tracker_finish_writes_files() {
let dir = tempfile::tempdir().unwrap();
let initial_meta = serde_json::json!({
"session_id": "test-session",
"goal": "test goal",
"model": "deepseek-chat",
"provider": "openai",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
"message_count": 10,
"status": "active",
});
let meta_path = dir.path().join(".meta.json");
std::fs::write(
&meta_path,
serde_json::to_string_pretty(&initial_meta).unwrap(),
)
.unwrap();
let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
let usage = TokenUsage {
prompt_tokens: 500,
completion_tokens: 300,
total_tokens: 800,
cache_hit_tokens: 100,
cache_miss_tokens: 400,
};
tracker.record_usage(usage, 999);
tracker.finish().unwrap();
let cost_path = dir.path().join("cost.json");
assert!(cost_path.exists());
let updated_meta: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
assert!((updated_meta["cost_usd"].as_f64().unwrap() - 0.000_440_7).abs() < 0.000_001);
assert_eq!(updated_meta["total_tokens"], 800);
assert_eq!(updated_meta["prompt_tokens"], 500);
assert_eq!(updated_meta["completion_tokens"], 300);
assert_eq!(updated_meta["cache_hit_tokens"], 100);
assert_eq!(updated_meta["cache_miss_tokens"], 400);
assert_eq!(updated_meta["total_llm_latency_ms"], 999);
assert_eq!(updated_meta["session_id"], "test-session");
assert_eq!(updated_meta["goal"], "test goal");
assert_eq!(updated_meta["message_count"], 10);
}
#[test]
fn test_cost_tracker_finish_idempotent() {
let dir = tempfile::tempdir().unwrap();
let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
let usage = TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cache_hit_tokens: 0,
cache_miss_tokens: 100,
};
tracker.record_usage(usage, 100);
tracker.finish().unwrap();
tracker.finish().unwrap();
let cost_path = dir.path().join("cost.json");
assert!(cost_path.exists());
}
#[test]
fn test_cost_tracker_no_meta_file() {
let dir = tempfile::tempdir().unwrap();
let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
let usage = TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cache_hit_tokens: 0,
cache_miss_tokens: 100,
};
tracker.record_usage(usage, 100);
tracker.finish().unwrap();
assert!(dir.path().join("cost.json").exists());
}
#[test]
fn test_cost_data_serialization_roundtrip() {
let data = CostData {
model: "test-model".to_string(),
provider: "test-provider".to_string(),
total_usage: TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cache_hit_tokens: 10,
cache_miss_tokens: 90,
},
total_llm_latency_ms: 5000,
cost_usd: Some(0.0123),
pricing: Some(CostPricing {
input_per_million: 2.5,
output_per_million: 10.0,
cache_hit_input_per_million: 0.25,
}),
};
let json = serde_json::to_string_pretty(&data).unwrap();
let deserialized: CostData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.model, "test-model");
assert_eq!(deserialized.total_usage.prompt_tokens, 100);
assert_eq!(deserialized.total_usage.completion_tokens, 50);
assert_eq!(deserialized.total_usage.total_tokens, 150);
assert_eq!(deserialized.total_usage.cache_hit_tokens, 10);
assert_eq!(deserialized.total_usage.cache_miss_tokens, 90);
assert_eq!(deserialized.total_llm_latency_ms, 5000);
assert!((deserialized.cost_usd.unwrap() - 0.0123).abs() < 0.0001);
let p = deserialized.pricing.unwrap();
assert!((p.input_per_million - 2.5).abs() < 0.001);
assert!((p.output_per_million - 10.0).abs() < 0.001);
assert!((p.cache_hit_input_per_million - 0.25).abs() < 0.001);
}
#[test]
fn test_cost_tracker_cache_hit_discount() {
let dir = tempfile::tempdir().unwrap();
let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
let usage = TokenUsage {
prompt_tokens: 1_000_000,
completion_tokens: 500_000,
total_tokens: 1_500_000,
cache_hit_tokens: 600_000,
cache_miss_tokens: 400_000,
};
tracker.record_usage(usage, 0);
let cost = tracker.cost_usd().unwrap();
assert!((cost - 0.6742).abs() < 0.001);
}
}