Skip to main content

recursive/
cost.rs

1//! Cost tracking for agent runs.
2//!
3//! `CostTracker` observes token usage flowing through the agent and accumulates
4//! cost data. It writes a `cost.json` file into the session
5//! directory alongside the JSONL transcript, and can update the session meta
6//! file with cost summary fields.
7//!
8//! # Usage
9//!
10//! ```ignore
11//! let tracker = CostTracker::new(&workspace, "gpt-4o", "openai");
12//! // after runtime.run(...):
13//! tracker.record_usage(outcome.total_usage, outcome.llm_latency_ms);
14//! tracker.finish()?;
15//! ```
16
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use crate::llm::{pricing_for, ModelPricing, TokenUsage};
22
23/// Accumulated cost data for a single agent run.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CostData {
26    /// Model name used for the run.
27    pub model: String,
28    /// Provider identifier.
29    pub provider: String,
30    /// Total token usage across all LLM calls.
31    pub total_usage: TokenUsage,
32    /// Total LLM latency in milliseconds.
33    pub total_llm_latency_ms: u64,
34    /// Computed cost in USD (None if pricing is unknown for the model).
35    pub cost_usd: Option<f64>,
36    /// Pricing rates used for the computation (None if unknown).
37    pub pricing: Option<CostPricing>,
38}
39
40/// Serializable pricing rates, mirroring `ModelPricing` but serializable.
41#[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
58/// Tracks token usage and cost across an agent run, persisting to the session
59/// directory.
60///
61/// `CostTracker` is designed to be used alongside `SessionWriter`. It writes a
62/// `cost.json` file into the same session directory and optionally updates the
63/// `.meta.json` file with cost summary fields.
64pub struct CostTracker {
65    session_dir: PathBuf,
66    model: String,
67    provider: String,
68    pricing: Option<ModelPricing>,
69    /// Accumulated token usage across all LLM calls observed so far.
70    accumulated_usage: TokenUsage,
71    /// Accumulated LLM latency in milliseconds.
72    accumulated_latency_ms: u64,
73    /// Whether the tracker has been finished (written final cost.json).
74    finished: bool,
75}
76
77impl CostTracker {
78    /// Create a new `CostTracker` for the given session directory.
79    ///
80    /// `session_dir` should be the same directory used by `SessionWriter`.
81    /// Pricing is looked up from the bundled `providers.toml` via `pricing_for()`.
82    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    /// Record token usage and latency from a single LLM call.
96    ///
97    /// Call this from the integration layer after each `runtime.run()`,
98    /// passing `outcome.total_usage` and `outcome.llm_latency_ms` from
99    /// the [`RuntimeOutcome`](crate::runtime::RuntimeOutcome).
100    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    /// Return the accumulated token usage so far.
106    pub fn accumulated_usage(&self) -> TokenUsage {
107        self.accumulated_usage
108    }
109
110    /// Return the accumulated LLM latency in milliseconds.
111    pub fn accumulated_latency_ms(&self) -> u64 {
112        self.accumulated_latency_ms
113    }
114
115    /// Compute the cost in USD for the accumulated usage.
116    ///
117    /// Returns `None` if pricing is unknown for the model.
118    pub fn cost_usd(&self) -> Option<f64> {
119        self.pricing.map(|p| p.cost_usd(self.accumulated_usage))
120    }
121
122    /// Build the current `CostData` snapshot.
123    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    /// Write the cost data to `cost.json` in the session directory.
135    ///
136    /// Returns the path to the written file.
137    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    /// Update the `.meta.json` file in the session directory with cost summary
147    /// fields.
148    ///
149    /// Reads the existing meta file, adds cost fields, and writes it back.
150    /// If the meta file doesn't exist or can't be read, this is a no-op.
151    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(()), // meta file doesn't exist yet, skip
156        };
157
158        // Parse existing meta as a generic Value to preserve unknown fields
159        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    /// Finalise the tracker: write `cost.json` and update `.meta.json`.
214    ///
215    /// After calling this, the tracker is marked as finished and subsequent
216    /// calls are no-ops.
217    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        // deepseek-chat pricing: $0.27/M input, $1.10/M output
285        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        // input: 1M * 0.27/1M = $0.27
296        // output: 500k * 1.10/1M = $0.55
297        // total: $0.82
298        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        // Read back and verify
328        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        // Create a minimal .meta.json to test update_meta_with_cost
346        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        // Verify cost.json exists
377        let cost_path = dir.path().join("cost.json");
378        assert!(cost_path.exists());
379
380        // Verify .meta.json was updated with cost fields
381        let updated_meta: serde_json::Value =
382            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
383        // deepseek-chat: input $0.27/M, output $1.10/M, cache hit $0.027/M
384        // cache hit: 100 * 0.027/1M = 0.0000027
385        // cache miss: 400 * 0.27/1M = 0.000108
386        // output: 300 * 1.10/1M = 0.00033
387        // total: 0.0004407
388        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        // Verify original fields are preserved
397        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        // Call finish twice — second call should be a no-op
417        tracker.finish().unwrap();
418        tracker.finish().unwrap();
419
420        // Only one cost.json should exist
421        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        // Should not error even without .meta.json
440        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        // deepseek-chat: $0.27/M input, $1.10/M output, $0.027/M cache hit
488        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        // cache hit: 600k * 0.027/1M = $0.0162
499        // cache miss: 400k * 0.27/1M = $0.108
500        // output: 500k * 1.10/1M = $0.55
501        // total: $0.6742
502        assert!((cost - 0.6742).abs() < 0.001);
503    }
504}