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 crate::atomic::atomic_write(&cost_path, json.as_bytes())?;
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(0u64)),
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(
206 "reasoning_tokens".to_string(),
207 serde_json::Value::Number(serde_json::Number::from(
208 self.accumulated_usage.reasoning_tokens,
209 )),
210 );
211 obj.insert(
212 "total_llm_latency_ms".to_string(),
213 serde_json::Value::Number(serde_json::Number::from(self.accumulated_latency_ms)),
214 );
215 }
216
217 let updated = serde_json::to_string_pretty(&meta)
218 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
219 crate::atomic::atomic_write(&meta_path, updated.as_bytes())
220 }
221
222 pub fn finish(&mut self) -> std::io::Result<()> {
227 if self.finished {
228 return Ok(());
229 }
230 self.finished = true;
231
232 self.write_cost_json()?;
233 self.update_meta_with_cost()?;
234 Ok(())
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::llm::TokenUsage;
242
243 #[test]
244 fn test_cost_tracker_new() {
245 let dir = tempfile::tempdir().unwrap();
246 let tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
247 assert_eq!(tracker.model, "deepseek-chat");
248 assert_eq!(tracker.provider, "openai");
249 assert!(tracker.pricing.is_some());
250 assert_eq!(tracker.accumulated_usage.total_tokens, 0);
251 assert!(!tracker.finished);
252 }
253
254 #[test]
255 fn test_cost_tracker_record_usage() {
256 let dir = tempfile::tempdir().unwrap();
257 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
258
259 let usage1 = TokenUsage {
260 reasoning_tokens: 0,
261 prompt_tokens: 100,
262 completion_tokens: 50,
263 total_tokens: 150,
264 cache_hit_tokens: 0,
265 cache_miss_tokens: 100,
266 };
267 tracker.record_usage(usage1, 500);
268
269 assert_eq!(tracker.accumulated_usage.prompt_tokens, 100);
270 assert_eq!(tracker.accumulated_usage.completion_tokens, 50);
271 assert_eq!(tracker.accumulated_usage.total_tokens, 150);
272 assert_eq!(tracker.accumulated_latency_ms, 500);
273
274 let usage2 = TokenUsage {
275 reasoning_tokens: 0,
276 prompt_tokens: 200,
277 completion_tokens: 100,
278 total_tokens: 300,
279 cache_hit_tokens: 0,
280 cache_miss_tokens: 200,
281 };
282 tracker.record_usage(usage2, 300);
283
284 assert_eq!(tracker.accumulated_usage.prompt_tokens, 300);
285 assert_eq!(tracker.accumulated_usage.completion_tokens, 150);
286 assert_eq!(tracker.accumulated_usage.total_tokens, 450);
287 assert_eq!(tracker.accumulated_latency_ms, 800);
288 }
289
290 #[test]
291 fn test_cost_tracker_cost_usd() {
292 let _home = tempfile::tempdir().unwrap();
293 let _pin = crate::test_util::PinnedRecursiveHome::new(_home.path());
294 let dir = tempfile::tempdir().unwrap();
295 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
296
297 let usage = TokenUsage {
299 reasoning_tokens: 0,
300 prompt_tokens: 1_000_000,
301 completion_tokens: 500_000,
302 total_tokens: 1_500_000,
303 cache_hit_tokens: 0,
304 cache_miss_tokens: 1_000_000,
305 };
306 tracker.record_usage(usage, 0);
307
308 let cost = tracker.cost_usd().unwrap();
309 assert!((cost - 0.28).abs() < 0.001);
313 }
314
315 #[test]
316 fn test_cost_tracker_unknown_model() {
317 let dir = tempfile::tempdir().unwrap();
318 let tracker = CostTracker::new(dir.path().to_path_buf(), "nonexistent-model-v42", "openai");
319 assert!(tracker.pricing.is_none());
320 assert!(tracker.cost_usd().is_none());
321 }
322
323 #[test]
324 fn test_cost_tracker_write_cost_json() {
325 let dir = tempfile::tempdir().unwrap();
326 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
327
328 let usage = TokenUsage {
329 reasoning_tokens: 0,
330 prompt_tokens: 1000,
331 completion_tokens: 500,
332 total_tokens: 1500,
333 cache_hit_tokens: 200,
334 cache_miss_tokens: 800,
335 };
336 tracker.record_usage(usage, 1234);
337
338 let path = tracker.write_cost_json().unwrap();
339 assert!(path.exists());
340 assert_eq!(path.file_name().unwrap(), "cost.json");
341
342 let contents = std::fs::read_to_string(&path).unwrap();
344 let data: CostData = serde_json::from_str(&contents).unwrap();
345 assert_eq!(data.model, "deepseek-chat");
346 assert_eq!(data.total_usage.prompt_tokens, 1000);
347 assert_eq!(data.total_usage.completion_tokens, 500);
348 assert_eq!(data.total_usage.total_tokens, 1500);
349 assert_eq!(data.total_usage.cache_hit_tokens, 200);
350 assert_eq!(data.total_usage.cache_miss_tokens, 800);
351 assert_eq!(data.total_llm_latency_ms, 1234);
352 assert!(data.cost_usd.is_some());
353 assert!(data.pricing.is_some());
354 }
355
356 #[test]
357 fn test_cost_tracker_finish_writes_files() {
358 let _home = tempfile::tempdir().unwrap();
359 let _pin = crate::test_util::PinnedRecursiveHome::new(_home.path());
360 let dir = tempfile::tempdir().unwrap();
361
362 let initial_meta = serde_json::json!({
364 "session_id": "test-session",
365 "goal": "test goal",
366 "model": "deepseek-chat",
367 "provider": "openai",
368 "created_at": "2025-01-01T00:00:00Z",
369 "updated_at": "2025-01-01T00:00:00Z",
370 "message_count": 10,
371 "status": "active",
372 });
373 let meta_path = dir.path().join(".meta.json");
374 crate::atomic::atomic_write(
375 &meta_path,
376 serde_json::to_string_pretty(&initial_meta)
377 .unwrap()
378 .as_bytes(),
379 )
380 .unwrap();
381
382 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
383
384 let usage = TokenUsage {
385 reasoning_tokens: 0,
386 prompt_tokens: 500,
387 completion_tokens: 300,
388 total_tokens: 800,
389 cache_hit_tokens: 100,
390 cache_miss_tokens: 400,
391 };
392 tracker.record_usage(usage, 999);
393
394 tracker.finish().unwrap();
395
396 let cost_path = dir.path().join("cost.json");
398 assert!(cost_path.exists());
399
400 let updated_meta: serde_json::Value =
402 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
403 assert!((updated_meta["cost_usd"].as_f64().unwrap() - 0.000_140_308).abs() < 0.000_001);
409 assert_eq!(updated_meta["total_tokens"], 800);
410 assert_eq!(updated_meta["prompt_tokens"], 500);
411 assert_eq!(updated_meta["completion_tokens"], 300);
412 assert_eq!(updated_meta["cache_hit_tokens"], 100);
413 assert_eq!(updated_meta["cache_miss_tokens"], 400);
414 assert_eq!(updated_meta["total_llm_latency_ms"], 999);
415
416 assert_eq!(updated_meta["session_id"], "test-session");
418 assert_eq!(updated_meta["goal"], "test goal");
419 assert_eq!(updated_meta["message_count"], 10);
420 }
421
422 #[test]
423 fn test_cost_tracker_finish_idempotent() {
424 let dir = tempfile::tempdir().unwrap();
425 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
426
427 let usage = TokenUsage {
428 reasoning_tokens: 0,
429 prompt_tokens: 100,
430 completion_tokens: 50,
431 total_tokens: 150,
432 cache_hit_tokens: 0,
433 cache_miss_tokens: 100,
434 };
435 tracker.record_usage(usage, 100);
436
437 tracker.finish().unwrap();
439 tracker.finish().unwrap();
440
441 let cost_path = dir.path().join("cost.json");
443 assert!(cost_path.exists());
444 }
445
446 #[test]
447 fn test_cost_tracker_no_meta_file() {
448 let dir = tempfile::tempdir().unwrap();
449 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
450
451 let usage = TokenUsage {
452 reasoning_tokens: 0,
453 prompt_tokens: 100,
454 completion_tokens: 50,
455 total_tokens: 150,
456 cache_hit_tokens: 0,
457 cache_miss_tokens: 100,
458 };
459 tracker.record_usage(usage, 100);
460
461 tracker.finish().unwrap();
463 assert!(dir.path().join("cost.json").exists());
464 }
465
466 #[test]
467 fn test_cost_data_serialization_roundtrip() {
468 let data = CostData {
469 model: "test-model".to_string(),
470 provider: "test-provider".to_string(),
471 total_usage: TokenUsage {
472 reasoning_tokens: 0,
473 prompt_tokens: 100,
474 completion_tokens: 50,
475 total_tokens: 150,
476 cache_hit_tokens: 10,
477 cache_miss_tokens: 90,
478 },
479 total_llm_latency_ms: 5000,
480 cost_usd: Some(0.0123),
481 pricing: Some(CostPricing {
482 input_per_million: 2.5,
483 output_per_million: 10.0,
484 cache_hit_input_per_million: 0.25,
485 }),
486 };
487
488 let json = serde_json::to_string_pretty(&data).unwrap();
489 let deserialized: CostData = serde_json::from_str(&json).unwrap();
490
491 assert_eq!(deserialized.model, "test-model");
492 assert_eq!(deserialized.total_usage.prompt_tokens, 100);
493 assert_eq!(deserialized.total_usage.completion_tokens, 50);
494 assert_eq!(deserialized.total_usage.total_tokens, 150);
495 assert_eq!(deserialized.total_usage.cache_hit_tokens, 10);
496 assert_eq!(deserialized.total_usage.cache_miss_tokens, 90);
497 assert_eq!(deserialized.total_llm_latency_ms, 5000);
498 assert!((deserialized.cost_usd.unwrap() - 0.0123).abs() < 0.0001);
499 let p = deserialized.pricing.unwrap();
500 assert!((p.input_per_million - 2.5).abs() < 0.001);
501 assert!((p.output_per_million - 10.0).abs() < 0.001);
502 assert!((p.cache_hit_input_per_million - 0.25).abs() < 0.001);
503 }
504
505 #[test]
506 fn test_cost_tracker_cache_hit_discount() {
507 let _home = tempfile::tempdir().unwrap();
508 let _pin = crate::test_util::PinnedRecursiveHome::new(_home.path());
509 let dir = tempfile::tempdir().unwrap();
510 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
511
512 let usage = TokenUsage {
514 reasoning_tokens: 0,
515 prompt_tokens: 1_000_000,
516 completion_tokens: 500_000,
517 total_tokens: 1_500_000,
518 cache_hit_tokens: 600_000,
519 cache_miss_tokens: 400_000,
520 };
521 tracker.record_usage(usage, 0);
522
523 let cost = tracker.cost_usd().unwrap();
524 assert!((cost - 0.19768).abs() < 0.001);
529 }
530
531 #[test]
534 fn cost_includes_reasoning_in_output_bill() {
535 let _home = tempfile::tempdir().unwrap();
536 let _pin = crate::test_util::PinnedRecursiveHome::new(_home.path());
537 let dir = tempfile::tempdir().unwrap();
538 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
539
540 let usage = TokenUsage {
546 reasoning_tokens: 400,
547 prompt_tokens: 200_000,
548 completion_tokens: 100,
549 total_tokens: 200_500,
550 cache_hit_tokens: 0,
551 cache_miss_tokens: 200_000,
552 };
553 tracker.record_usage(usage, 0);
554
555 let cost = tracker.cost_usd().unwrap();
556 assert!((cost - 0.02814).abs() < 0.00001);
560 }
561
562 #[test]
566 fn update_meta_with_cost_no_panic() {
567 let dir = tempfile::tempdir().unwrap();
568 std::fs::write(dir.path().join(".meta.json"), r#"{"some": "field"}"#).unwrap();
570 let tracker = CostTracker::new(dir.path().to_path_buf(), "test-model", "openai");
571 let res = tracker.update_meta_with_cost();
573 assert!(res.is_ok());
574 }
575
576 #[test]
577 fn accessor_methods_reflect_recorded_usage() {
578 let dir = tempfile::tempdir().unwrap();
581 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "test-model", "openai");
582 let usage = TokenUsage {
583 prompt_tokens: 10,
584 completion_tokens: 5,
585 total_tokens: 15,
586 ..Default::default()
587 };
588 tracker.record_usage(usage, 250);
589 let acc = tracker.accumulated_usage();
590 assert_eq!(
591 acc.total_tokens, 15,
592 "accumulated_usage() must return recorded total"
593 );
594 assert_eq!(
595 tracker.accumulated_latency_ms(),
596 250,
597 "accumulated_latency_ms() must return recorded latency"
598 );
599 }
600
601 #[test]
602 fn cost_data_reflects_model_and_provider() {
603 let dir = tempfile::tempdir().unwrap();
605 let tracker = CostTracker::new(dir.path().to_path_buf(), "my-model", "my-provider");
606 let data = tracker.cost_data();
607 assert_eq!(
608 data.model, "my-model",
609 "cost_data().model must match constructor arg"
610 );
611 assert_eq!(
612 data.provider, "my-provider",
613 "cost_data().provider must match constructor arg"
614 );
615 assert_eq!(data.total_usage, TokenUsage::default());
616 }
617
618 #[test]
619 fn record_usage_multiple_calls_accumulates() {
620 let dir = tempfile::tempdir().unwrap();
622 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "test-model", "openai");
623 let u1 = TokenUsage {
624 prompt_tokens: 10,
625 completion_tokens: 5,
626 total_tokens: 15,
627 ..Default::default()
628 };
629 let u2 = TokenUsage {
630 prompt_tokens: 20,
631 completion_tokens: 10,
632 total_tokens: 30,
633 ..Default::default()
634 };
635 tracker.record_usage(u1, 100);
636 tracker.record_usage(u2, 200);
637 let acc = tracker.accumulated_usage();
638 assert_eq!(
639 acc.prompt_tokens, 30,
640 "prompt_tokens must accumulate across calls"
641 );
642 assert_eq!(
643 acc.total_tokens, 45,
644 "total_tokens must accumulate across calls"
645 );
646 assert_eq!(
647 tracker.accumulated_latency_ms(),
648 300,
649 "latency must accumulate"
650 );
651 }
652
653 #[test]
654 fn update_meta_with_cost_writes_all_token_fields() {
655 let dir = tempfile::tempdir().unwrap();
657 std::fs::write(dir.path().join(".meta.json"), r#"{"goal": "test"}"#).unwrap();
658 let mut tracker = CostTracker::new(dir.path().to_path_buf(), "deepseek-chat", "openai");
659 let usage = TokenUsage {
660 prompt_tokens: 50,
661 completion_tokens: 20,
662 total_tokens: 70,
663 cache_hit_tokens: 5,
664 cache_miss_tokens: 45,
665 reasoning_tokens: 10,
666 };
667 tracker.record_usage(usage, 500);
668 tracker.finish().unwrap();
669
670 let meta_raw = std::fs::read_to_string(dir.path().join(".meta.json")).unwrap();
671 let meta: serde_json::Value = serde_json::from_str(&meta_raw).unwrap();
672 assert_eq!(
673 meta["total_tokens"], 70,
674 "total_tokens must be written to meta"
675 );
676 assert_eq!(
677 meta["prompt_tokens"], 50,
678 "prompt_tokens must be written to meta"
679 );
680 assert_eq!(
681 meta["completion_tokens"], 20,
682 "completion_tokens must be written to meta"
683 );
684 assert_eq!(
685 meta["cache_hit_tokens"], 5,
686 "cache_hit_tokens must be written to meta"
687 );
688 assert_eq!(
689 meta["cache_miss_tokens"], 45,
690 "cache_miss_tokens must be written to meta"
691 );
692 assert_eq!(
693 meta["reasoning_tokens"], 10,
694 "reasoning_tokens must be written to meta"
695 );
696 assert_eq!(
697 meta["total_llm_latency_ms"], 500,
698 "latency must be written to meta"
699 );
700 }
701
702 #[test]
703 fn update_meta_with_cost_non_object_json_is_noop() {
704 let dir = tempfile::tempdir().unwrap();
708 std::fs::write(dir.path().join(".meta.json"), r#"[1,2,3]"#).unwrap();
710 let tracker = CostTracker::new(dir.path().to_path_buf(), "test-model", "openai");
711 let res = tracker.update_meta_with_cost();
712 assert!(
713 res.is_ok(),
714 "non-object meta JSON must be handled gracefully"
715 );
716 let after = std::fs::read_to_string(dir.path().join(".meta.json")).unwrap();
718 let v: serde_json::Value = serde_json::from_str(&after).unwrap();
719 assert!(v.is_array(), "non-object meta must not be overwritten");
720 }
721}