Skip to main content

vct_core/utils/
usage_processor.rs

1//! Per-provider usage accumulation: merges the token fields of one session
2//! record into a running per-model map, normalising provider-specific shapes
3//! along the way.
4
5use crate::constants::FastHashMap;
6use serde_json::Value;
7
8/// Adds the named `i64` fields from `source` into `target`, in place.
9///
10/// For each name in `fields`, the matching integer in `source` is added to
11/// the matching integer in `target` (treating a missing target as `0`).
12/// Fields absent from `source`, or present but non-integer, are skipped — so
13/// `target` only ever gains the keys that actually carried a number.
14///
15/// # Examples
16///
17/// ```
18/// use serde_json::{json, Map};
19/// use vct_core::utils::accumulate_i64_fields;
20///
21/// let mut target = Map::new();
22/// target.insert("input".into(), json!(10));
23///
24/// let mut source = Map::new();
25/// source.insert("input".into(), json!(5));
26/// source.insert("output".into(), json!(7));
27///
28/// accumulate_i64_fields(&mut target, &source, &["input", "output"]);
29/// assert_eq!(target["input"], json!(15));
30/// assert_eq!(target["output"], json!(7));
31/// ```
32pub fn accumulate_i64_fields(
33    target: &mut serde_json::Map<String, Value>,
34    source: &serde_json::Map<String, Value>,
35    fields: &[&str],
36) {
37    for field in fields {
38        if let Some(value) = source.get(*field).and_then(|v| v.as_i64()) {
39            let current = target.get(*field).and_then(|v| v.as_i64()).unwrap_or(0);
40            target.insert(field.to_string(), (current + value).into());
41        }
42    }
43}
44
45/// Adds every `i64` field of `source_nested` into the nested object stored
46/// at `target_obj[field_name]`.
47///
48/// The nested target object is created (as `{}`) if it does not yet exist.
49/// Unlike [`accumulate_i64_fields`], the set of keys is taken from
50/// `source_nested` rather than a fixed list, so any integer key present in
51/// the source is merged. Non-integer source values are ignored.
52///
53/// # Examples
54///
55/// ```
56/// use serde_json::{json, Map};
57/// use vct_core::utils::accumulate_nested_object;
58///
59/// let mut target = Map::new();
60/// target.insert("usage".into(), json!({ "input": 100 }));
61///
62/// let mut nested = Map::new();
63/// nested.insert("input".into(), json!(25));
64/// nested.insert("cached".into(), json!(10));
65///
66/// accumulate_nested_object(&mut target, "usage", &nested);
67/// assert_eq!(target["usage"]["input"], json!(125));
68/// assert_eq!(target["usage"]["cached"], json!(10));
69/// ```
70pub fn accumulate_nested_object(
71    target_obj: &mut serde_json::Map<String, Value>,
72    field_name: &str,
73    source_nested: &serde_json::Map<String, Value>,
74) {
75    let target_nested = target_obj
76        .entry(field_name.to_string())
77        .or_insert_with(|| serde_json::json!({}));
78
79    if let Some(target_nested_obj) = target_nested.as_object_mut() {
80        for (key, value) in source_nested {
81            if let Some(v) = value.as_i64() {
82                let current = target_nested_obj
83                    .get(key)
84                    .and_then(|v| v.as_i64())
85                    .unwrap_or(0);
86                target_nested_obj.insert(key.clone(), (current + v).into());
87            }
88        }
89    }
90}
91
92/// Normalized above-threshold slice of one request's tokens.
93///
94/// Accumulated into the per-model `above_tier` object that
95/// `extract_token_counts` reads back into `TokenCounts::above_*`, so
96/// `calculate_cost` can bill this slice at the model's context-tier rate.
97/// The buckets use the extractor's disjoint semantics (input excludes cache
98/// reads, output excludes reasoning).
99#[derive(Debug, Clone, Copy, Default)]
100struct AboveTierSlice {
101    input: i64,
102    output: i64,
103    reasoning: i64,
104    cache_read: i64,
105    cache_creation_5m: i64,
106    cache_creation_1h: i64,
107}
108
109impl AboveTierSlice {
110    fn accumulate_into(self, existing_obj: &mut serde_json::Map<String, Value>) {
111        let mut slice = serde_json::Map::with_capacity(6);
112        let mut push = |key: &str, value: i64| {
113            if value != 0 {
114                slice.insert(key.to_string(), value.into());
115            }
116        };
117        push("input_tokens", self.input);
118        push("output_tokens", self.output);
119        push("reasoning_tokens", self.reasoning);
120        push("cache_read_tokens", self.cache_read);
121        push("cache_creation_5m_tokens", self.cache_creation_5m);
122        push("cache_creation_1h_tokens", self.cache_creation_1h);
123        if !slice.is_empty() {
124            accumulate_nested_object(existing_obj, "above_tier", &slice);
125        }
126    }
127}
128
129/// The full prompt context of one Claude request: non-cached input plus
130/// cache reads plus cache writes — what the provider compares against the
131/// "above Nk tokens" threshold.
132pub fn claude_request_context(usage_obj: &serde_json::Map<String, Value>) -> i64 {
133    let field = |key: &str| usage_obj.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
134    let mut cache_creation = field("cache_creation_input_tokens");
135    if cache_creation == 0
136        && let Some(split) = usage_obj.get("cache_creation").and_then(|v| v.as_object())
137    {
138        cache_creation = split.values().filter_map(|v| v.as_i64()).sum();
139    }
140    field("input_tokens") + field("cache_read_input_tokens") + cache_creation
141}
142
143/// Merges one Claude usage record into `conversation_usage`, keyed by `model`.
144///
145/// Token fields accumulate across calls (the per-model entry is created on
146/// first sight). `service_tier` is overwritten with the latest value rather
147/// than accumulated, and the `cache_creation` TTL split is merged via
148/// [`accumulate_nested_object`]. Records for synthetic models (whose name
149/// contains `<synthetic>`) and non-object `usage` payloads are ignored.
150///
151/// `above_tier` marks this record (one request) as classified above the
152/// model's context-tier threshold: its buckets are additionally accumulated
153/// into the `above_tier` slice that prices at the tier rate.
154pub fn process_claude_usage(
155    conversation_usage: &mut FastHashMap<String, Value>,
156    model: &str,
157    usage: &Value,
158    above_tier: bool,
159) {
160    // Skip synthetic models
161    if model.contains("<synthetic>") {
162        return;
163    }
164
165    let usage_obj = match usage.as_object() {
166        Some(obj) => obj,
167        None => return,
168    };
169
170    // Get or create usage entry
171    let existing = conversation_usage
172        .entry(model.to_string())
173        .or_insert_with(|| {
174            serde_json::json!({
175                "input_tokens": 0,
176                "cache_creation_input_tokens": 0,
177                "cache_read_input_tokens": 0,
178                "cache_creation": {},
179                "output_tokens": 0,
180                "service_tier": ""
181            })
182        });
183
184    let Some(existing_obj) = existing.as_object_mut() else {
185        return;
186    };
187
188    // Accumulate numeric token fields
189    accumulate_i64_fields(
190        existing_obj,
191        usage_obj,
192        &[
193            "input_tokens",
194            "cache_creation_input_tokens",
195            "cache_read_input_tokens",
196            "output_tokens",
197        ],
198    );
199
200    // Handle service_tier
201    if let Some(service_tier) = usage_obj.get("service_tier").and_then(|v| v.as_str()) {
202        existing_obj.insert("service_tier".to_string(), service_tier.into());
203    }
204
205    // Handle cache_creation nested object
206    if let Some(cache_creation) = usage_obj.get("cache_creation").and_then(|v| v.as_object()) {
207        accumulate_nested_object(existing_obj, "cache_creation", cache_creation);
208    }
209
210    // Handle server_tool_use nested object (web_search_requests /
211    // web_fetch_requests). Accumulated so per-query web-search billing sees
212    // the session total.
213    if let Some(server_tool_use) = usage_obj.get("server_tool_use").and_then(|v| v.as_object()) {
214        accumulate_nested_object(existing_obj, "server_tool_use", server_tool_use);
215    }
216
217    if above_tier {
218        let field = |key: &str| usage_obj.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
219        let scalar_cc = field("cache_creation_input_tokens");
220        // Mirror `extract_token_counts`: the scalar total is authoritative, the
221        // 1h portion comes from the split, and the remainder bills at 5m so no
222        // cache-creation token is dropped from the above-tier slice either.
223        let cc_1h = usage_obj
224            .get("cache_creation")
225            .and_then(|v| v.as_object())
226            .and_then(|split| split.get("ephemeral_1h_input_tokens"))
227            .and_then(|v| v.as_i64())
228            .unwrap_or(0);
229        let ephemeral_5m = usage_obj
230            .get("cache_creation")
231            .and_then(|v| v.as_object())
232            .and_then(|split| split.get("ephemeral_5m_input_tokens"))
233            .and_then(|v| v.as_i64())
234            .unwrap_or(0);
235        let total_cc = scalar_cc.max(ephemeral_5m + cc_1h);
236        AboveTierSlice {
237            input: field("input_tokens"),
238            output: field("output_tokens"),
239            reasoning: 0,
240            cache_read: field("cache_read_input_tokens"),
241            cache_creation_5m: total_cc - cc_1h,
242            cache_creation_1h: cc_1h,
243        }
244        .accumulate_into(existing_obj);
245    }
246}
247
248/// The five integer fields of a Codex `total_token_usage` object.
249pub const CODEX_TOKEN_FIELDS: [&str; 5] = [
250    "input_tokens",
251    "cached_input_tokens",
252    "output_tokens",
253    "reasoning_output_tokens",
254    "total_tokens",
255];
256
257/// Snapshot of Codex's cumulative `total_token_usage` counters.
258///
259/// Codex publishes a whole-session running total on every `token_count`
260/// event. Attribution therefore works on the *delta* between consecutive
261/// snapshots, so a mid-session model switch bills each model only for the
262/// tokens produced while it was current instead of double-counting the
263/// pre-switch prefix under both models.
264#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
265pub struct CodexTokenTotals {
266    pub input_tokens: i64,
267    pub cached_input_tokens: i64,
268    pub output_tokens: i64,
269    pub reasoning_output_tokens: i64,
270    pub total_tokens: i64,
271}
272
273impl CodexTokenTotals {
274    /// Reads the cumulative counters out of a `total_token_usage` object,
275    /// treating missing or non-integer fields as `0`.
276    pub fn from_total_object(total: &serde_json::Map<String, Value>) -> Self {
277        let field = |key: &str| total.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
278        Self {
279            input_tokens: field("input_tokens"),
280            cached_input_tokens: field("cached_input_tokens"),
281            output_tokens: field("output_tokens"),
282            reasoning_output_tokens: field("reasoning_output_tokens"),
283            total_tokens: field("total_tokens"),
284        }
285    }
286
287    fn field(&self, key: &str) -> i64 {
288        match key {
289            "input_tokens" => self.input_tokens,
290            "cached_input_tokens" => self.cached_input_tokens,
291            "output_tokens" => self.output_tokens,
292            "reasoning_output_tokens" => self.reasoning_output_tokens,
293            "total_tokens" => self.total_tokens,
294            _ => 0,
295        }
296    }
297
298    /// Builds the per-event delta map against `prev`, keeping only the keys
299    /// actually present in `total` so absent fields stay absent downstream.
300    ///
301    /// A drop in `total_tokens` means the counter restarted (never observed
302    /// in real logs, but cheap to guard): the event is treated as the start
303    /// of a new segment rather than clamping every field to zero, which
304    /// would silently drop the new segment's early tokens.
305    pub fn delta_fields(
306        total: &serde_json::Map<String, Value>,
307        prev: Option<&Self>,
308    ) -> serde_json::Map<String, Value> {
309        let current = Self::from_total_object(total);
310        let base = match prev {
311            Some(prev) if current.total_tokens >= prev.total_tokens => *prev,
312            _ => Self::default(),
313        };
314        let mut delta = serde_json::Map::new();
315        for key in CODEX_TOKEN_FIELDS {
316            if total.contains_key(key) {
317                delta.insert(
318                    key.to_string(),
319                    (current.field(key) - base.field(key)).max(0).into(),
320                );
321            }
322        }
323        delta
324    }
325}
326
327/// Merges one Codex usage delta into `conversation_usage`, keyed by `model`.
328///
329/// `delta` carries the per-event increments derived from consecutive
330/// cumulative snapshots (see [`CodexTokenTotals`]) and is *accumulated* into
331/// the model's `total_token_usage`, so each model ends up with exactly the
332/// tokens produced while it was the active model. `last_token_usage` and
333/// `model_context_window` are replaced with the latest values. Synthetic
334/// models and non-object `info` payloads are ignored.
335///
336/// `above_tier` marks this turn as classified above the model's context-tier
337/// threshold: the delta is additionally accumulated (in the extractor's
338/// disjoint form) into the `above_tier` slice that prices at the tier rate.
339pub fn process_codex_usage(
340    conversation_usage: &mut FastHashMap<String, Value>,
341    model: &str,
342    delta: &serde_json::Map<String, Value>,
343    info: &Value,
344    above_tier: bool,
345) {
346    // Skip synthetic models
347    if model.contains("<synthetic>") {
348        return;
349    }
350
351    let info_obj = match info.as_object() {
352        Some(obj) => obj,
353        None => return,
354    };
355
356    let existing = conversation_usage
357        .entry(model.to_string())
358        .or_insert_with(|| {
359            serde_json::json!({
360                "total_token_usage": {},
361                "last_token_usage": {},
362                "model_context_window": null
363            })
364        });
365
366    let Some(existing_obj) = existing.as_object_mut() else {
367        return;
368    };
369
370    accumulate_nested_object(existing_obj, "total_token_usage", delta);
371
372    if above_tier {
373        let field = |key: &str| delta.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
374        let cached = field("cached_input_tokens");
375        let reasoning = field("reasoning_output_tokens");
376        AboveTierSlice {
377            input: (field("input_tokens") - cached).max(0),
378            output: (field("output_tokens") - reasoning).max(0),
379            reasoning,
380            cache_read: cached,
381            cache_creation_5m: 0,
382            cache_creation_1h: 0,
383        }
384        .accumulate_into(existing_obj);
385    }
386
387    // Process last_token_usage
388    if let Some(last_usage) = info_obj.get("last_token_usage") {
389        existing_obj.insert("last_token_usage".to_string(), last_usage.clone());
390    }
391
392    // Handle model_context_window
393    if let Some(context_window) = info_obj.get("model_context_window") {
394        existing_obj.insert("model_context_window".to_string(), context_window.clone());
395    }
396}
397
398/// Merges one Gemini usage record into `conversation_usage`, keyed by `model`.
399///
400/// All token buckets accumulate across calls. Gemini reports `tokens.input`
401/// as the *full* prompt count (cached subset included), so this function
402/// stores `input - cached` under `input_tokens` and the cached portion under
403/// `cache_read_input_tokens`, mirroring the Claude convention where input and
404/// cache reads are disjoint. The subtraction is clamped at `0` to stay
405/// defensive against a misreport where `cached > input`.
406///
407/// `above_tier` marks this message (one request) as classified above the
408/// model's context-tier threshold: its buckets are additionally accumulated
409/// into the `above_tier` slice that prices at the tier rate.
410pub fn process_gemini_usage(
411    conversation_usage: &mut FastHashMap<String, Value>,
412    model: &str,
413    tokens: &crate::models::GeminiTokens,
414    above_tier: bool,
415) {
416    let existing = conversation_usage
417        .entry(model.to_string())
418        .or_insert_with(|| {
419            serde_json::json!({
420                "input_tokens": 0,
421                "cache_read_input_tokens": 0,
422                "output_tokens": 0,
423                "thoughts_tokens": 0,
424                "tool_tokens": 0,
425                "total_tokens": 0,
426            })
427        });
428
429    let Some(existing_obj) = existing.as_object_mut() else {
430        return;
431    };
432
433    // Gemini's `tokens.input` is the full promptTokenCount (mirrors
434    // Google's API), which already includes the cached subset reported
435    // as `tokens.cached`. LiteLLM prices the two independently — if we
436    // accumulated `tokens.input` verbatim, every cached token would be
437    // billed at both `input_cost_per_token` and
438    // `cache_read_input_token_cost`, inflating Gemini cost reports.
439    //
440    // Subtract cached from input before accumulating so downstream
441    // bookkeeping matches the Claude convention (input ⊥ cache_read).
442    // We verify this against the Gemini CLI event stream: every
443    // observed record satisfies `total == input + output + thoughts`
444    // with `cached` *not* added — i.e. cached is already folded into
445    // `input`, not stored alongside it.
446    let input_non_cached = (tokens.input - tokens.cached).max(0);
447
448    let current_input = existing_obj
449        .get("input_tokens")
450        .and_then(|v| v.as_i64())
451        .unwrap_or(0);
452    existing_obj.insert(
453        "input_tokens".to_string(),
454        (current_input + input_non_cached).into(),
455    );
456
457    // Add cached tokens as cache_read_input_tokens
458    let current_cached = existing_obj
459        .get("cache_read_input_tokens")
460        .and_then(|v| v.as_i64())
461        .unwrap_or(0);
462    existing_obj.insert(
463        "cache_read_input_tokens".to_string(),
464        (current_cached + tokens.cached).into(),
465    );
466
467    // Add output tokens
468    let current_output = existing_obj
469        .get("output_tokens")
470        .and_then(|v| v.as_i64())
471        .unwrap_or(0);
472    existing_obj.insert(
473        "output_tokens".to_string(),
474        (current_output + tokens.output).into(),
475    );
476
477    // Add thoughts tokens (Gemini-specific)
478    let current_thoughts = existing_obj
479        .get("thoughts_tokens")
480        .and_then(|v| v.as_i64())
481        .unwrap_or(0);
482    existing_obj.insert(
483        "thoughts_tokens".to_string(),
484        (current_thoughts + tokens.thoughts).into(),
485    );
486
487    // Add tool tokens (Gemini-specific)
488    let current_tool = existing_obj
489        .get("tool_tokens")
490        .and_then(|v| v.as_i64())
491        .unwrap_or(0);
492    existing_obj.insert(
493        "tool_tokens".to_string(),
494        (current_tool + tokens.tool).into(),
495    );
496
497    // Add total tokens
498    let current_total = existing_obj
499        .get("total_tokens")
500        .and_then(|v| v.as_i64())
501        .unwrap_or(0);
502    existing_obj.insert(
503        "total_tokens".to_string(),
504        (current_total + tokens.total).into(),
505    );
506
507    if above_tier {
508        AboveTierSlice {
509            input: input_non_cached,
510            output: tokens.output,
511            reasoning: tokens.thoughts,
512            cache_read: tokens.cached,
513            cache_creation_5m: 0,
514            cache_creation_1h: 0,
515        }
516        .accumulate_into(existing_obj);
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use serde_json::json;
524
525    #[test]
526    fn test_accumulate_i64_fields() {
527        let mut target = serde_json::Map::new();
528        target.insert("count".to_string(), json!(10));
529        target.insert("total".to_string(), json!(100));
530
531        let mut source = serde_json::Map::new();
532        source.insert("count".to_string(), json!(5));
533        source.insert("total".to_string(), json!(50));
534        source.insert("new_field".to_string(), json!(25));
535
536        accumulate_i64_fields(&mut target, &source, &["count", "total", "new_field"]);
537
538        assert_eq!(target.get("count").unwrap().as_i64().unwrap(), 15);
539        assert_eq!(target.get("total").unwrap().as_i64().unwrap(), 150);
540        assert_eq!(target.get("new_field").unwrap().as_i64().unwrap(), 25);
541    }
542
543    #[test]
544    fn test_accumulate_i64_fields_missing_source() {
545        let mut target = serde_json::Map::new();
546        target.insert("count".to_string(), json!(10));
547
548        let source = serde_json::Map::new();
549
550        accumulate_i64_fields(&mut target, &source, &["count", "missing"]);
551
552        assert_eq!(target.get("count").unwrap().as_i64().unwrap(), 10);
553        assert!(!target.contains_key("missing"));
554    }
555
556    #[test]
557    fn test_accumulate_i64_fields_non_numeric() {
558        let mut target = serde_json::Map::new();
559        target.insert("count".to_string(), json!(10));
560
561        let mut source = serde_json::Map::new();
562        source.insert("count".to_string(), json!("not a number"));
563
564        accumulate_i64_fields(&mut target, &source, &["count"]);
565
566        assert_eq!(target.get("count").unwrap().as_i64().unwrap(), 10);
567    }
568
569    #[test]
570    fn test_accumulate_nested_object() {
571        let mut target = serde_json::Map::new();
572        target.insert(
573            "usage".to_string(),
574            json!({
575                "input": 100,
576                "output": 50
577            }),
578        );
579
580        let mut source_nested = serde_json::Map::new();
581        source_nested.insert("input".to_string(), json!(25));
582        source_nested.insert("output".to_string(), json!(15));
583        source_nested.insert("cached".to_string(), json!(10));
584
585        accumulate_nested_object(&mut target, "usage", &source_nested);
586
587        let usage = target.get("usage").unwrap().as_object().unwrap();
588        assert_eq!(usage.get("input").unwrap().as_i64().unwrap(), 125);
589        assert_eq!(usage.get("output").unwrap().as_i64().unwrap(), 65);
590        assert_eq!(usage.get("cached").unwrap().as_i64().unwrap(), 10);
591    }
592
593    #[test]
594    fn test_accumulate_nested_object_new_field() {
595        let mut target = serde_json::Map::new();
596
597        let mut source_nested = serde_json::Map::new();
598        source_nested.insert("input".to_string(), json!(100));
599        source_nested.insert("output".to_string(), json!(50));
600
601        accumulate_nested_object(&mut target, "usage", &source_nested);
602
603        let usage = target.get("usage").unwrap().as_object().unwrap();
604        assert_eq!(usage.get("input").unwrap().as_i64().unwrap(), 100);
605        assert_eq!(usage.get("output").unwrap().as_i64().unwrap(), 50);
606    }
607
608    #[test]
609    fn test_process_claude_usage_basic() {
610        let mut conversation_usage = FastHashMap::default();
611        let model = "claude-3-sonnet";
612        let usage = json!({
613            "input_tokens": 100,
614            "output_tokens": 50,
615            "cache_read_input_tokens": 200,
616            "cache_creation_input_tokens": 25
617        });
618
619        process_claude_usage(&mut conversation_usage, model, &usage, false);
620
621        let result = conversation_usage.get(model).unwrap();
622        assert_eq!(result["input_tokens"].as_i64().unwrap(), 100);
623        assert_eq!(result["output_tokens"].as_i64().unwrap(), 50);
624        assert_eq!(result["cache_read_input_tokens"].as_i64().unwrap(), 200);
625        assert_eq!(result["cache_creation_input_tokens"].as_i64().unwrap(), 25);
626    }
627
628    #[test]
629    fn test_process_claude_usage_accumulation() {
630        let mut conversation_usage = FastHashMap::default();
631        let model = "claude-3-sonnet";
632
633        let usage1 = json!({
634            "input_tokens": 100,
635            "output_tokens": 50
636        });
637        process_claude_usage(&mut conversation_usage, model, &usage1, false);
638
639        let usage2 = json!({
640            "input_tokens": 75,
641            "output_tokens": 25
642        });
643        process_claude_usage(&mut conversation_usage, model, &usage2, false);
644
645        let result = conversation_usage.get(model).unwrap();
646        assert_eq!(result["input_tokens"].as_i64().unwrap(), 175);
647        assert_eq!(result["output_tokens"].as_i64().unwrap(), 75);
648    }
649
650    #[test]
651    fn test_process_claude_usage_accumulates_server_tool_use() {
652        let mut conversation_usage = FastHashMap::default();
653        let model = "claude-opus-4-8";
654
655        process_claude_usage(
656            &mut conversation_usage,
657            model,
658            &json!({
659                "input_tokens": 10,
660                "server_tool_use": { "web_search_requests": 2, "web_fetch_requests": 1 }
661            }),
662            false,
663        );
664        process_claude_usage(
665            &mut conversation_usage,
666            model,
667            &json!({
668                "input_tokens": 5,
669                "server_tool_use": { "web_search_requests": 3, "web_fetch_requests": 0 }
670            }),
671            false,
672        );
673
674        let stu = conversation_usage.get(model).unwrap()["server_tool_use"]
675            .as_object()
676            .unwrap();
677        assert_eq!(stu["web_search_requests"].as_i64().unwrap(), 5);
678        assert_eq!(stu["web_fetch_requests"].as_i64().unwrap(), 1);
679    }
680
681    #[test]
682    fn test_process_claude_usage_skip_synthetic() {
683        let mut conversation_usage = FastHashMap::default();
684        let model = "claude-3-sonnet<synthetic>";
685        let usage = json!({
686            "input_tokens": 100,
687            "output_tokens": 50
688        });
689
690        process_claude_usage(&mut conversation_usage, model, &usage, false);
691
692        assert!(conversation_usage.is_empty());
693    }
694
695    #[test]
696    fn test_process_codex_usage_basic() {
697        let mut conversation_usage = FastHashMap::default();
698        let model = "gpt-4";
699        let info = json!({
700            "total_token_usage": {
701                "input_tokens": 100,
702                "output_tokens": 50,
703                "cached_input_tokens": 200
704            },
705            "model_context_window": 128000
706        });
707        let total = info["total_token_usage"].as_object().unwrap();
708        let delta = CodexTokenTotals::delta_fields(total, None);
709
710        process_codex_usage(&mut conversation_usage, model, &delta, &info, false);
711
712        let result = conversation_usage.get(model).unwrap();
713        let total_usage = result["total_token_usage"].as_object().unwrap();
714        assert_eq!(total_usage["input_tokens"].as_i64().unwrap(), 100);
715        assert_eq!(total_usage["output_tokens"].as_i64().unwrap(), 50);
716        assert_eq!(total_usage["cached_input_tokens"].as_i64().unwrap(), 200);
717        assert!(!total_usage.contains_key("total_tokens"));
718        assert_eq!(result["model_context_window"].as_i64().unwrap(), 128000);
719    }
720
721    #[test]
722    fn codex_delta_attributes_only_the_increment_to_the_current_model() {
723        // Mirrors a real mid-session model switch: the cumulative counter at
724        // the switch point must not be billed again under the second model.
725        let mut conversation_usage = FastHashMap::default();
726
727        let first = json!({
728            "total_token_usage": {
729                "input_tokens": 20_000,
730                "cached_input_tokens": 4_000,
731                "output_tokens": 3_289,
732                "reasoning_output_tokens": 1_000,
733                "total_tokens": 23_289
734            }
735        });
736        let first_total = first["total_token_usage"].as_object().unwrap();
737        let delta = CodexTokenTotals::delta_fields(first_total, None);
738        process_codex_usage(
739            &mut conversation_usage,
740            "gpt-5.6-luna",
741            &delta,
742            &first,
743            false,
744        );
745        let prev = CodexTokenTotals::from_total_object(first_total);
746
747        let second = json!({
748            "total_token_usage": {
749                "input_tokens": 90_000,
750                "cached_input_tokens": 60_000,
751                "output_tokens": 14_576,
752                "reasoning_output_tokens": 5_000,
753                "total_tokens": 104_576
754            }
755        });
756        let second_total = second["total_token_usage"].as_object().unwrap();
757        let delta = CodexTokenTotals::delta_fields(second_total, Some(&prev));
758        process_codex_usage(
759            &mut conversation_usage,
760            "gpt-5.6-sol",
761            &delta,
762            &second,
763            false,
764        );
765
766        let luna = conversation_usage["gpt-5.6-luna"]["total_token_usage"]
767            .as_object()
768            .unwrap();
769        let sol = conversation_usage["gpt-5.6-sol"]["total_token_usage"]
770            .as_object()
771            .unwrap();
772        assert_eq!(luna["total_tokens"].as_i64().unwrap(), 23_289);
773        assert_eq!(sol["total_tokens"].as_i64().unwrap(), 104_576 - 23_289);
774        assert_eq!(sol["input_tokens"].as_i64().unwrap(), 70_000);
775        assert_eq!(sol["cached_input_tokens"].as_i64().unwrap(), 56_000);
776    }
777
778    #[test]
779    fn codex_delta_treats_a_counter_reset_as_a_new_segment() {
780        // total_tokens dropping below the previous snapshot means the session
781        // counter restarted; the event's own values are the new segment.
782        let total = json!({
783            "input_tokens": 500,
784            "output_tokens": 100,
785            "total_tokens": 600
786        });
787        let prev = CodexTokenTotals {
788            input_tokens: 9_000,
789            cached_input_tokens: 0,
790            output_tokens: 1_000,
791            reasoning_output_tokens: 0,
792            total_tokens: 10_000,
793        };
794        let delta = CodexTokenTotals::delta_fields(total.as_object().unwrap(), Some(&prev));
795        assert_eq!(delta["input_tokens"].as_i64().unwrap(), 500);
796        assert_eq!(delta["output_tokens"].as_i64().unwrap(), 100);
797        assert_eq!(delta["total_tokens"].as_i64().unwrap(), 600);
798    }
799
800    #[test]
801    fn test_process_gemini_usage_basic() {
802        let mut conversation_usage = FastHashMap::default();
803        let model = "gemini-2.0-flash";
804        // Use a realistic shape: `input` (300) includes `cached` (200),
805        // so non-cached input is 100.
806        let tokens = crate::models::GeminiTokens {
807            input: 300,
808            output: 50,
809            cached: 200,
810            thoughts: 10,
811            tool: 5,
812            total: 360,
813        };
814
815        process_gemini_usage(&mut conversation_usage, model, &tokens, false);
816
817        let result = conversation_usage.get(model).unwrap();
818        assert_eq!(
819            result["input_tokens"].as_i64().unwrap(),
820            100,
821            "input must be stored as non-cached (input - cached) to match Claude semantics"
822        );
823        assert_eq!(result["output_tokens"].as_i64().unwrap(), 50);
824        assert_eq!(result["cache_read_input_tokens"].as_i64().unwrap(), 200);
825        assert_eq!(result["thoughts_tokens"].as_i64().unwrap(), 10);
826        assert_eq!(result["tool_tokens"].as_i64().unwrap(), 5);
827        assert_eq!(result["total_tokens"].as_i64().unwrap(), 360);
828    }
829
830    #[test]
831    fn test_process_gemini_usage_no_cache() {
832        // Sanity check: a record with `cached: 0` must not alter input.
833        let mut conversation_usage = FastHashMap::default();
834        let model = "gemini-2.0-flash";
835        let tokens = crate::models::GeminiTokens {
836            input: 13_906,
837            output: 185,
838            cached: 0,
839            thoughts: 306,
840            tool: 0,
841            total: 14_397,
842        };
843
844        process_gemini_usage(&mut conversation_usage, model, &tokens, false);
845
846        let result = conversation_usage.get(model).unwrap();
847        assert_eq!(result["input_tokens"].as_i64().unwrap(), 13_906);
848        assert_eq!(result["cache_read_input_tokens"].as_i64().unwrap(), 0);
849    }
850}