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        crate::atomic::atomic_write(&cost_path, json.as_bytes())?;
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(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            // Goal 273: surface reasoning tokens in .meta.json so
203            // users running R1 / o1 can see the cost driver
204            // separately from visible output.
205            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    /// Finalise the tracker: write `cost.json` and update `.meta.json`.
223    ///
224    /// After calling this, the tracker is marked as finished and subsequent
225    /// calls are no-ops.
226    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        // deepseek-chat pricing: $0.27/M input, $1.10/M output
298        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        // input: 1M * 0.14/1M = $0.14
310        // output: 500k * 0.28/1M = $0.14
311        // total: $0.28
312        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        // Read back and verify
343        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        // Create a minimal .meta.json to test update_meta_with_cost
363        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        // Verify cost.json exists
397        let cost_path = dir.path().join("cost.json");
398        assert!(cost_path.exists());
399
400        // Verify .meta.json was updated with cost fields
401        let updated_meta: serde_json::Value =
402            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
403        // deepseek-chat: input $0.14/M, output $0.28/M, cache hit $0.0028/M
404        // cache hit: 100 * 0.0028/1M = 0.00000028
405        // cache miss: 400 * 0.14/1M = 0.000056
406        // output: 300 * 0.28/1M = 0.000084
407        // total: 0.000140308
408        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        // Verify original fields are preserved
417        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        // Call finish twice — second call should be a no-op
438        tracker.finish().unwrap();
439        tracker.finish().unwrap();
440
441        // Only one cost.json should exist
442        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        // Should not error even without .meta.json
462        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        // deepseek-chat: $0.14/M input, $0.28/M output, $0.0028/M cache hit
513        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        // cache hit: 600k * 0.0028/1M = $0.00168
525        // cache miss: 400k * 0.14/1M = $0.056
526        // output: 500k * 0.28/1M = $0.14
527        // total: $0.19768
528        assert!((cost - 0.19768).abs() < 0.001);
529    }
530
531    /// Goal 273: reasoning tokens add to the output bill at the model's
532    /// output rate.
533    #[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        // deepseek-chat: $0.14/M input, $0.28/M output
541        // completion_tokens: 100, reasoning_tokens: 400
542        // input: 200k * 0.14/1M = $0.028
543        // output: (100 + 400) * 0.28/1M = 500 * 0.28/1M = $0.00014
544        // total: $0.02814
545        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        // input: 200k * 0.14/1M = 0.028
557        // output: (100 + 400) * 0.28 / 1M = 500 * 0.28 / 1M = 0.00014
558        // total: 0.028 + 0.00014 = 0.02814
559        assert!((cost - 0.02814).abs() < 0.00001);
560    }
561
562    /// Goal 311: `update_meta_with_cost` must not panic even when the
563    /// cost is unknown (e.g. unknown model — `cost_usd()` returns
564    /// `None`) or when no usage has been recorded.
565    #[test]
566    fn update_meta_with_cost_no_panic() {
567        let dir = tempfile::tempdir().unwrap();
568        // Write a valid meta.json
569        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        // Don't crash even with no usage recorded
572        let res = tracker.update_meta_with_cost();
573        assert!(res.is_ok());
574    }
575
576    #[test]
577    fn accessor_methods_reflect_recorded_usage() {
578        // kills `replace accumulated_usage -> TokenUsage with Default::default()`
579        // and `replace accumulated_latency_ms -> u64 with 0` mutations
580        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        // kills mutations that swap model/provider fields
604        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        // kills `= usage` → does not accumulate mutation in record_usage
621        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        // kills any field-insertion removal mutation in update_meta_with_cost
656        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        // kills `if let Some(obj) = meta.as_object_mut()` guard-removal mutation:
705        // when .meta.json is a non-object JSON value, the function must return Ok()
706        // without panicking or writing anything.
707        let dir = tempfile::tempdir().unwrap();
708        // Write a JSON array as meta (not an object)
709        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        // The file should remain unchanged (still the array)
717        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}