1use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use crate::llm::{pricing_for, ModelPricing, TokenUsage};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CostData {
26 pub model: String,
28 pub provider: String,
30 pub total_usage: TokenUsage,
32 pub total_llm_latency_ms: u64,
34 pub cost_usd: Option<f64>,
36 pub pricing: Option<CostPricing>,
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
42pub struct CostPricing {
43 pub input_per_million: f64,
44 pub output_per_million: f64,
45 pub cache_hit_input_per_million: f64,
46}
47
48impl From<ModelPricing> for CostPricing {
49 fn from(p: ModelPricing) -> Self {
50 Self {
51 input_per_million: p.input_per_million,
52 output_per_million: p.output_per_million,
53 cache_hit_input_per_million: p.cache_hit_input_per_million,
54 }
55 }
56}
57
58pub struct CostTracker {
65 session_dir: PathBuf,
66 model: String,
67 provider: String,
68 pricing: Option<ModelPricing>,
69 accumulated_usage: TokenUsage,
71 accumulated_latency_ms: u64,
73 finished: bool,
75}
76
77impl CostTracker {
78 pub fn new(session_dir: PathBuf, model: &str, provider: &str) -> Self {
83 let pricing = pricing_for(model);
84 Self {
85 session_dir,
86 model: model.to_string(),
87 provider: provider.to_string(),
88 pricing,
89 accumulated_usage: TokenUsage::default(),
90 accumulated_latency_ms: 0,
91 finished: false,
92 }
93 }
94
95 pub fn record_usage(&mut self, usage: TokenUsage, latency_ms: u64) {
101 self.accumulated_usage = self.accumulated_usage.accumulate(usage);
102 self.accumulated_latency_ms = self.accumulated_latency_ms.saturating_add(latency_ms);
103 }
104
105 pub fn accumulated_usage(&self) -> TokenUsage {
107 self.accumulated_usage
108 }
109
110 pub fn accumulated_latency_ms(&self) -> u64 {
112 self.accumulated_latency_ms
113 }
114
115 pub fn cost_usd(&self) -> Option<f64> {
119 self.pricing.map(|p| p.cost_usd(self.accumulated_usage))
120 }
121
122 pub fn cost_data(&self) -> CostData {
124 CostData {
125 model: self.model.clone(),
126 provider: self.provider.clone(),
127 total_usage: self.accumulated_usage,
128 total_llm_latency_ms: self.accumulated_latency_ms,
129 cost_usd: self.cost_usd(),
130 pricing: self.pricing.map(CostPricing::from),
131 }
132 }
133
134 pub fn write_cost_json(&self) -> std::io::Result<PathBuf> {
138 let cost_path = self.session_dir.join("cost.json");
139 let data = self.cost_data();
140 let json = serde_json::to_string_pretty(&data)
141 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
142 std::fs::write(&cost_path, json)?;
143 Ok(cost_path)
144 }
145
146 pub fn update_meta_with_cost(&self) -> std::io::Result<()> {
152 let meta_path = self.session_dir.join(".meta.json");
153 let existing = match std::fs::read_to_string(&meta_path) {
154 Ok(s) => s,
155 Err(_) => return Ok(()), };
157
158 let mut meta: serde_json::Value = match serde_json::from_str(&existing) {
160 Ok(v) => v,
161 Err(_) => return Ok(()),
162 };
163
164 if let Some(obj) = meta.as_object_mut() {
165 obj.insert(
166 "cost_usd".to_string(),
167 serde_json::Value::Number(
168 serde_json::Number::from_f64(self.cost_usd().unwrap_or(0.0))
169 .unwrap_or(serde_json::Number::from_f64(0.0).unwrap()),
170 ),
171 );
172 obj.insert(
173 "total_tokens".to_string(),
174 serde_json::Value::Number(serde_json::Number::from(
175 self.accumulated_usage.total_tokens,
176 )),
177 );
178 obj.insert(
179 "prompt_tokens".to_string(),
180 serde_json::Value::Number(serde_json::Number::from(
181 self.accumulated_usage.prompt_tokens,
182 )),
183 );
184 obj.insert(
185 "completion_tokens".to_string(),
186 serde_json::Value::Number(serde_json::Number::from(
187 self.accumulated_usage.completion_tokens,
188 )),
189 );
190 obj.insert(
191 "cache_hit_tokens".to_string(),
192 serde_json::Value::Number(serde_json::Number::from(
193 self.accumulated_usage.cache_hit_tokens,
194 )),
195 );
196 obj.insert(
197 "cache_miss_tokens".to_string(),
198 serde_json::Value::Number(serde_json::Number::from(
199 self.accumulated_usage.cache_miss_tokens,
200 )),
201 );
202 obj.insert(
203 "total_llm_latency_ms".to_string(),
204 serde_json::Value::Number(serde_json::Number::from(self.accumulated_latency_ms)),
205 );
206 }
207
208 let updated = serde_json::to_string_pretty(&meta)
209 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
210 std::fs::write(&meta_path, updated)
211 }
212
213 pub fn finish(&mut self) -> std::io::Result<()> {
218 if self.finished {
219 return Ok(());
220 }
221 self.finished = true;
222
223 self.write_cost_json()?;
224 self.update_meta_with_cost()?;
225 Ok(())
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::llm::TokenUsage;
233
234 #[test]
235 fn test_cost_tracker_new() {
236 let dir = tempfile::tempdir().unwrap();
237 let tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
238 assert_eq!(tracker.model, "deepseek-chat");
239 assert_eq!(tracker.provider, "openai");
240 assert!(tracker.pricing.is_some());
241 assert_eq!(tracker.accumulated_usage.total_tokens, 0);
242 assert!(!tracker.finished);
243 }
244
245 #[test]
246 fn test_cost_tracker_record_usage() {
247 let dir = tempfile::tempdir().unwrap();
248 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
249
250 let usage1 = TokenUsage {
251 prompt_tokens: 100,
252 completion_tokens: 50,
253 total_tokens: 150,
254 cache_hit_tokens: 0,
255 cache_miss_tokens: 100,
256 };
257 tracker.record_usage(usage1, 500);
258
259 assert_eq!(tracker.accumulated_usage.prompt_tokens, 100);
260 assert_eq!(tracker.accumulated_usage.completion_tokens, 50);
261 assert_eq!(tracker.accumulated_usage.total_tokens, 150);
262 assert_eq!(tracker.accumulated_latency_ms, 500);
263
264 let usage2 = TokenUsage {
265 prompt_tokens: 200,
266 completion_tokens: 100,
267 total_tokens: 300,
268 cache_hit_tokens: 0,
269 cache_miss_tokens: 200,
270 };
271 tracker.record_usage(usage2, 300);
272
273 assert_eq!(tracker.accumulated_usage.prompt_tokens, 300);
274 assert_eq!(tracker.accumulated_usage.completion_tokens, 150);
275 assert_eq!(tracker.accumulated_usage.total_tokens, 450);
276 assert_eq!(tracker.accumulated_latency_ms, 800);
277 }
278
279 #[test]
280 fn test_cost_tracker_cost_usd() {
281 let dir = tempfile::tempdir().unwrap();
282 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
283
284 let usage = TokenUsage {
286 prompt_tokens: 1_000_000,
287 completion_tokens: 500_000,
288 total_tokens: 1_500_000,
289 cache_hit_tokens: 0,
290 cache_miss_tokens: 1_000_000,
291 };
292 tracker.record_usage(usage, 0);
293
294 let cost = tracker.cost_usd().unwrap();
295 assert!((cost - 0.82).abs() < 0.001);
299 }
300
301 #[test]
302 fn test_cost_tracker_unknown_model() {
303 let dir = tempfile::tempdir().unwrap();
304 let tracker = CostTracker::new(dir.path().to_path_buf(), "nonexistent-model-v42", "openai");
305 assert!(tracker.pricing.is_none());
306 assert!(tracker.cost_usd().is_none());
307 }
308
309 #[test]
310 fn test_cost_tracker_write_cost_json() {
311 let dir = tempfile::tempdir().unwrap();
312 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
313
314 let usage = TokenUsage {
315 prompt_tokens: 1000,
316 completion_tokens: 500,
317 total_tokens: 1500,
318 cache_hit_tokens: 200,
319 cache_miss_tokens: 800,
320 };
321 tracker.record_usage(usage, 1234);
322
323 let path = tracker.write_cost_json().unwrap();
324 assert!(path.exists());
325 assert_eq!(path.file_name().unwrap(), "cost.json");
326
327 let contents = std::fs::read_to_string(&path).unwrap();
329 let data: CostData = serde_json::from_str(&contents).unwrap();
330 assert_eq!(data.model, "deepseek-chat");
331 assert_eq!(data.total_usage.prompt_tokens, 1000);
332 assert_eq!(data.total_usage.completion_tokens, 500);
333 assert_eq!(data.total_usage.total_tokens, 1500);
334 assert_eq!(data.total_usage.cache_hit_tokens, 200);
335 assert_eq!(data.total_usage.cache_miss_tokens, 800);
336 assert_eq!(data.total_llm_latency_ms, 1234);
337 assert!(data.cost_usd.is_some());
338 assert!(data.pricing.is_some());
339 }
340
341 #[test]
342 fn test_cost_tracker_finish_writes_files() {
343 let dir = tempfile::tempdir().unwrap();
344
345 let initial_meta = serde_json::json!({
347 "session_id": "test-session",
348 "goal": "test goal",
349 "model": "deepseek-chat",
350 "provider": "openai",
351 "created_at": "2025-01-01T00:00:00Z",
352 "updated_at": "2025-01-01T00:00:00Z",
353 "message_count": 10,
354 "status": "active",
355 });
356 let meta_path = dir.path().join(".meta.json");
357 std::fs::write(
358 &meta_path,
359 serde_json::to_string_pretty(&initial_meta).unwrap(),
360 )
361 .unwrap();
362
363 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
364
365 let usage = TokenUsage {
366 prompt_tokens: 500,
367 completion_tokens: 300,
368 total_tokens: 800,
369 cache_hit_tokens: 100,
370 cache_miss_tokens: 400,
371 };
372 tracker.record_usage(usage, 999);
373
374 tracker.finish().unwrap();
375
376 let cost_path = dir.path().join("cost.json");
378 assert!(cost_path.exists());
379
380 let updated_meta: serde_json::Value =
382 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
383 assert!((updated_meta["cost_usd"].as_f64().unwrap() - 0.000_440_7).abs() < 0.000_001);
389 assert_eq!(updated_meta["total_tokens"], 800);
390 assert_eq!(updated_meta["prompt_tokens"], 500);
391 assert_eq!(updated_meta["completion_tokens"], 300);
392 assert_eq!(updated_meta["cache_hit_tokens"], 100);
393 assert_eq!(updated_meta["cache_miss_tokens"], 400);
394 assert_eq!(updated_meta["total_llm_latency_ms"], 999);
395
396 assert_eq!(updated_meta["session_id"], "test-session");
398 assert_eq!(updated_meta["goal"], "test goal");
399 assert_eq!(updated_meta["message_count"], 10);
400 }
401
402 #[test]
403 fn test_cost_tracker_finish_idempotent() {
404 let dir = tempfile::tempdir().unwrap();
405 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
406
407 let usage = TokenUsage {
408 prompt_tokens: 100,
409 completion_tokens: 50,
410 total_tokens: 150,
411 cache_hit_tokens: 0,
412 cache_miss_tokens: 100,
413 };
414 tracker.record_usage(usage, 100);
415
416 tracker.finish().unwrap();
418 tracker.finish().unwrap();
419
420 let cost_path = dir.path().join("cost.json");
422 assert!(cost_path.exists());
423 }
424
425 #[test]
426 fn test_cost_tracker_no_meta_file() {
427 let dir = tempfile::tempdir().unwrap();
428 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
429
430 let usage = TokenUsage {
431 prompt_tokens: 100,
432 completion_tokens: 50,
433 total_tokens: 150,
434 cache_hit_tokens: 0,
435 cache_miss_tokens: 100,
436 };
437 tracker.record_usage(usage, 100);
438
439 tracker.finish().unwrap();
441 assert!(dir.path().join("cost.json").exists());
442 }
443
444 #[test]
445 fn test_cost_data_serialization_roundtrip() {
446 let data = CostData {
447 model: "test-model".to_string(),
448 provider: "test-provider".to_string(),
449 total_usage: TokenUsage {
450 prompt_tokens: 100,
451 completion_tokens: 50,
452 total_tokens: 150,
453 cache_hit_tokens: 10,
454 cache_miss_tokens: 90,
455 },
456 total_llm_latency_ms: 5000,
457 cost_usd: Some(0.0123),
458 pricing: Some(CostPricing {
459 input_per_million: 2.5,
460 output_per_million: 10.0,
461 cache_hit_input_per_million: 0.25,
462 }),
463 };
464
465 let json = serde_json::to_string_pretty(&data).unwrap();
466 let deserialized: CostData = serde_json::from_str(&json).unwrap();
467
468 assert_eq!(deserialized.model, "test-model");
469 assert_eq!(deserialized.total_usage.prompt_tokens, 100);
470 assert_eq!(deserialized.total_usage.completion_tokens, 50);
471 assert_eq!(deserialized.total_usage.total_tokens, 150);
472 assert_eq!(deserialized.total_usage.cache_hit_tokens, 10);
473 assert_eq!(deserialized.total_usage.cache_miss_tokens, 90);
474 assert_eq!(deserialized.total_llm_latency_ms, 5000);
475 assert!((deserialized.cost_usd.unwrap() - 0.0123).abs() < 0.0001);
476 let p = deserialized.pricing.unwrap();
477 assert!((p.input_per_million - 2.5).abs() < 0.001);
478 assert!((p.output_per_million - 10.0).abs() < 0.001);
479 assert!((p.cache_hit_input_per_million - 0.25).abs() < 0.001);
480 }
481
482 #[test]
483 fn test_cost_tracker_cache_hit_discount() {
484 let dir = tempfile::tempdir().unwrap();
485 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
486
487 let usage = TokenUsage {
489 prompt_tokens: 1_000_000,
490 completion_tokens: 500_000,
491 total_tokens: 1_500_000,
492 cache_hit_tokens: 600_000,
493 cache_miss_tokens: 400_000,
494 };
495 tracker.record_usage(usage, 0);
496
497 let cost = tracker.cost_usd().unwrap();
498 assert!((cost - 0.6742).abs() < 0.001);
503 }
504}