Skip to main content

vct_core/utils/
token_extractor.rs

1use serde_json::Value;
2
3/// Normalized token counts extracted from provider-specific usage data.
4///
5/// `cache_creation` is the total across TTL variants. For Claude Code, the
6/// `cache_creation_5m` / `cache_creation_1h` split reflects Anthropic's two
7/// cache TTL tiers (5 minutes default, 1 hour extended — the latter is ~60%
8/// more expensive per write). Providers that don't split TTL (Codex, Gemini,
9/// older Claude Code records) get all cache_creation tokens into the 5m bucket.
10///
11/// Invariant: `cache_creation == cache_creation_5m + cache_creation_1h` when
12/// the split data is available.
13///
14/// `reasoning_tokens` carries the model's "thinking" budget emitted as part
15/// of the assistant turn but billed separately from user-visible output.
16/// Populated by Gemini (`thoughts_tokens`), Codex
17/// (`reasoning_output_tokens`), and Copilot (`reasoning_output_tokens`
18/// after `session::copilot::parse_copilot_events` normalises it). Claude
19/// has no equivalent and leaves this at 0.
20#[derive(Debug, Default)]
21pub struct TokenCounts {
22    /// Non-cached prompt tokens (cached reads are excluded; see `cache_read`).
23    pub input_tokens: i64,
24    /// User-visible completion tokens, excluding reasoning.
25    pub output_tokens: i64,
26    /// Model "thinking" tokens, billed separately from `output_tokens`.
27    pub reasoning_tokens: i64,
28    /// Prompt tokens served from the provider's prompt cache.
29    pub cache_read: i64,
30    /// Total cache-write tokens across all TTL tiers.
31    pub cache_creation: i64,
32    /// Cache-write tokens at the default 5-minute TTL.
33    pub cache_creation_5m: i64,
34    /// Cache-write tokens at the extended 1-hour TTL.
35    pub cache_creation_1h: i64,
36    /// Server-side web-search requests (Claude `server_tool_use`). Billed
37    /// per query (not per token) at the model's web-search rate, so it is
38    /// tracked here but excluded from `total`.
39    pub web_search_requests: i64,
40    /// Sum of the billed buckets used for cost and display.
41    pub total: i64,
42    /// Slice of `input_tokens` from requests whose own prompt context
43    /// exceeded the model's context-tier threshold (see the `above_tier`
44    /// object written by the usage parsers). Always a subset of the field it
45    /// mirrors — never additional tokens — so displays keep using the totals
46    /// above while `calculate_cost` bills this slice at the tier rate.
47    pub above_input: i64,
48    /// Above-threshold slice of `output_tokens`.
49    pub above_output: i64,
50    /// Above-threshold slice of `reasoning_tokens`.
51    pub above_reasoning: i64,
52    /// Above-threshold slice of `cache_read`.
53    pub above_cache_read: i64,
54    /// Above-threshold slice of `cache_creation_5m`.
55    pub above_cache_creation_5m: i64,
56    /// Above-threshold slice of `cache_creation_1h`.
57    pub above_cache_creation_1h: i64,
58}
59
60impl TokenCounts {
61    /// Whether any billed bucket carries a nonzero count.
62    ///
63    /// The single source of truth for "did this usage do anything" — used to
64    /// decide whether a date counts as active. New buckets added above must be
65    /// reflected here so no activity check silently misses them.
66    pub fn has_activity(&self) -> bool {
67        self.total != 0
68            || self.input_tokens != 0
69            || self.output_tokens != 0
70            || self.reasoning_tokens != 0
71            || self.cache_read != 0
72            || self.cache_creation != 0
73            || self.cache_creation_5m != 0
74            || self.cache_creation_1h != 0
75            || self.web_search_requests != 0
76    }
77}
78
79/// Extracts token counts from usage data in any provider format
80///
81/// Supports two shapes:
82/// - Flat providers: direct fields like `input_tokens`, `output_tokens`
83/// - Codex: Nested `total_token_usage` object with different field names
84///
85/// Reasoning tokens (Gemini `thoughts_tokens`, Codex
86/// `reasoning_output_tokens`, Copilot `reasoning_output_tokens`) are no
87/// longer folded into `output_tokens`. Keeping them separate is what lets
88/// `calculate_cost` bill them at `output_cost_per_reasoning_token` for
89/// providers that publish a distinct reasoning rate (e.g. Gemini 2.5
90/// Flash, Perplexity Sonar Deep Research, dashscope/qwen-turbo).
91///
92/// Returns a normalized [`TokenCounts`]; a non-object `usage` yields the
93/// all-zero default.
94///
95/// # Examples
96///
97/// ```
98/// use serde_json::json;
99/// use vct_core::utils::extract_token_counts;
100///
101/// // Flat provider shape with no TTL split: every
102/// // cache_creation token lands in the 5-minute bucket.
103/// let counts = extract_token_counts(&json!({
104///     "input_tokens": 100,
105///     "output_tokens": 50,
106///     "cache_read_input_tokens": 200,
107///     "cache_creation_input_tokens": 10_000,
108/// }));
109/// assert_eq!(counts.input_tokens, 100);
110/// assert_eq!(counts.cache_creation_5m, 10_000);
111/// assert_eq!(counts.cache_creation_1h, 0);
112/// ```
113pub fn extract_token_counts(usage: &Value) -> TokenCounts {
114    let mut counts = TokenCounts::default();
115
116    if let Some(usage_obj) = usage.as_object() {
117        // Flat provider usage format
118        if let Some(input) = usage_obj.get("input_tokens").and_then(|v| v.as_i64()) {
119            counts.input_tokens = input;
120        }
121        if let Some(output) = usage_obj.get("output_tokens").and_then(|v| v.as_i64()) {
122            counts.output_tokens = output;
123        }
124        if let Some(cache_read) = usage_obj
125            .get("cache_read_input_tokens")
126            .and_then(|v| v.as_i64())
127        {
128            counts.cache_read = cache_read;
129        }
130        if let Some(cache_creation) = usage_obj
131            .get("cache_creation_input_tokens")
132            .and_then(|v| v.as_i64())
133        {
134            counts.cache_creation = cache_creation;
135        }
136
137        // Claude `server_tool_use.web_search_requests`: server-side web search
138        // count, billed per query separately from tokens. Read here in the
139        // flat section so it is captured before the Codex `total_token_usage`
140        // early-return below (Codex never carries this field, so it stays 0).
141        if let Some(server_tool_use) = usage_obj.get("server_tool_use").and_then(|v| v.as_object())
142        {
143            counts.web_search_requests = server_tool_use
144                .get("web_search_requests")
145                .and_then(|v| v.as_i64())
146                .unwrap_or(0);
147        }
148
149        // Per-request tier classification (usage scans only): the parsers
150        // accumulate the above-threshold slice of every bucket into a nested
151        // `above_tier` object. Read it here — before the Codex early return —
152        // so both the flat and the nested shapes carry it into pricing.
153        if let Some(above) = usage_obj.get("above_tier").and_then(|v| v.as_object()) {
154            let field = |key: &str| above.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
155            counts.above_input = field("input_tokens");
156            counts.above_output = field("output_tokens");
157            counts.above_reasoning = field("reasoning_tokens");
158            counts.above_cache_read = field("cache_read_tokens");
159            counts.above_cache_creation_5m = field("cache_creation_5m_tokens");
160            counts.above_cache_creation_1h = field("cache_creation_1h_tokens");
161        }
162
163        // Gemini writes reasoning budget as `thoughts_tokens`; the flat
164        // providers (Copilot / OpenCode / Hermes) use `reasoning_output_tokens`.
165        // A single record only carries one of them, but a cross-provider merge
166        // of the same model (e.g. Gemini CLI and Hermes both using `gemini-*`)
167        // keeps both keys, so sum them — an overwrite would drop the other
168        // provider's thinking-time tokens from the merged Output/Total.
169        let thoughts = usage_obj
170            .get("thoughts_tokens")
171            .and_then(|v| v.as_i64())
172            .unwrap_or(0);
173        let reasoning_output = usage_obj
174            .get("reasoning_output_tokens")
175            .and_then(|v| v.as_i64())
176            .unwrap_or(0);
177        counts.reasoning_tokens = thoughts + reasoning_output;
178
179        // Claude Code records cache_creation split by TTL:
180        //   "cache_creation": { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens }
181        // The scalar `cache_creation_input_tokens` is the authoritative total.
182        // Bill the 1h portion from the split and everything else at the default
183        // (5m) TTL, so the two priced buckets always sum to the scalar total.
184        // This matters because merging multi-iteration turns can leave the
185        // scalar larger than the published 5m+1h split (some iterations report
186        // only the scalar); billing 5m+1h verbatim would drop the remainder.
187        if let Some(cc_split) = usage_obj.get("cache_creation").and_then(|v| v.as_object()) {
188            let ephemeral_5m = cc_split
189                .get("ephemeral_5m_input_tokens")
190                .and_then(|v| v.as_i64())
191                .unwrap_or(0);
192            counts.cache_creation_1h = cc_split
193                .get("ephemeral_1h_input_tokens")
194                .and_then(|v| v.as_i64())
195                .unwrap_or(0);
196            // The total is the larger of the scalar and the split sum (the
197            // scalar may be missing, or the split may exceed a stale scalar).
198            counts.cache_creation = counts
199                .cache_creation
200                .max(ephemeral_5m + counts.cache_creation_1h);
201            counts.cache_creation_5m = counts.cache_creation - counts.cache_creation_1h;
202        } else {
203            // No TTL split → treat every cache_creation token as default (5 min) TTL.
204            counts.cache_creation_5m = counts.cache_creation;
205        }
206
207        // Codex usage format (has total_token_usage nested object)
208        if let Some(total_usage) = usage_obj
209            .get("total_token_usage")
210            .and_then(|v| v.as_object())
211        {
212            // Codex follows OpenAI's convention: `input_tokens` is the
213            // full prompt size and `cached_input_tokens` is the subset
214            // that hit the prompt cache. LiteLLM, in contrast, prices
215            // non-cached input (`input_cost_per_token`) and cached reads
216            // (`cache_read_input_token_cost`) *separately*. Forwarding
217            // Codex's raw `input_tokens` to `calculate_cost` alongside
218            // the same `cached_input_tokens` would charge every cached
219            // token twice — once at the full input rate, then again at
220            // the cache read rate — inflating Codex cost reports by
221            // ~130% on heavy-cache sessions.
222            //
223            // Subtract cached from raw input so each token is billed
224            // exactly once, at the right rate.
225            let raw_input = total_usage
226                .get("input_tokens")
227                .and_then(|v| v.as_i64())
228                .unwrap_or(0);
229            let cached = total_usage
230                .get("cached_input_tokens")
231                .and_then(|v| v.as_i64())
232                .unwrap_or(0);
233            counts.input_tokens = (raw_input - cached).max(0);
234            counts.cache_read = cached;
235
236            if let Some(output) = total_usage.get("output_tokens").and_then(|v| v.as_i64()) {
237                // Codex already accumulates across turns, so replace instead
238                // of adding — the value is the running total for the session.
239                counts.output_tokens = output;
240            }
241            if let Some(reasoning) = total_usage
242                .get("reasoning_output_tokens")
243                .and_then(|v| v.as_i64())
244            {
245                counts.reasoning_tokens = reasoning;
246            }
247            // OpenAI convention: `output_tokens` (completion) already INCLUDES
248            // `reasoning_output_tokens`. Split them into disjoint buckets so
249            // each token is billed exactly once — visible output at the output
250            // rate, reasoning at its dedicated rate (or the output fallback).
251            // Without this subtraction reasoning is billed twice: once inside
252            // output and again as the separate reasoning bucket. Verified
253            // across 21,113 real Codex token_count events: `total_tokens ==
254            // input + output` holds for every one and `reasoning > output`
255            // never occurs, so reasoning is always a subset of output.
256            counts.output_tokens = (counts.output_tokens - counts.reasoning_tokens).max(0);
257            if let Some(total) = total_usage.get("total_tokens").and_then(|v| v.as_i64()) {
258                // `total_tokens == input (incl. cached) + output`, and output
259                // already contains reasoning, so the published total is the
260                // correct each-token-once figure. Use it verbatim.
261                counts.total = total;
262                return counts;
263            }
264        }
265
266        // Flat providers may publish tool-only tokens that have no separate
267        // LiteLLM billing bucket. Keep them in the displayed/activity total
268        // without assigning them an input or output price. Prefer a larger
269        // provider-published total when present so these sessions do not look
270        // inactive merely because every priced bucket is zero.
271        let tool_tokens = usage_obj
272            .get("tool_tokens")
273            .and_then(|value| value.as_i64())
274            .unwrap_or(0);
275        let derived_total = counts.input_tokens
276            + counts.output_tokens
277            + counts.reasoning_tokens
278            + counts.cache_read
279            + counts.cache_creation
280            + tool_tokens;
281        counts.total = usage_obj
282            .get("total_tokens")
283            .and_then(|value| value.as_i64())
284            .map_or(derived_total, |published| published.max(derived_total));
285    }
286
287    counts
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use serde_json::json;
294
295    #[test]
296    fn claude_format_without_ttl_split_defaults_to_5m() {
297        // Old-style Claude record or provider that predates the ephemeral split.
298        let usage = json!({
299            "input_tokens": 100,
300            "output_tokens": 50,
301            "cache_read_input_tokens": 200,
302            "cache_creation_input_tokens": 10_000
303        });
304        let c = extract_token_counts(&usage);
305        assert_eq!(c.cache_creation, 10_000);
306        assert_eq!(c.cache_creation_5m, 10_000);
307        assert_eq!(c.cache_creation_1h, 0);
308    }
309
310    #[test]
311    fn claude_code_ttl_split_is_preserved() {
312        let usage = json!({
313            "input_tokens": 6,
314            "output_tokens": 866,
315            "cache_read_input_tokens": 16_651,
316            "cache_creation_input_tokens": 10_338,
317            "cache_creation": {
318                "ephemeral_5m_input_tokens": 0,
319                "ephemeral_1h_input_tokens": 10_338
320            }
321        });
322        let c = extract_token_counts(&usage);
323        assert_eq!(c.cache_creation, 10_338);
324        assert_eq!(c.cache_creation_5m, 0);
325        assert_eq!(c.cache_creation_1h, 10_338);
326    }
327
328    #[test]
329    fn merged_thoughts_and_reasoning_output_are_summed() {
330        // A single record carries only one reasoning key, but a cross-provider
331        // merge (Gemini `thoughts_tokens` + a flat `reasoning_output_tokens` for
332        // the same model) keeps both — they must add, not overwrite.
333        let gemini_only = json!({ "input_tokens": 10, "thoughts_tokens": 30 });
334        assert_eq!(extract_token_counts(&gemini_only).reasoning_tokens, 30);
335
336        let flat_only = json!({ "input_tokens": 10, "reasoning_output_tokens": 7 });
337        assert_eq!(extract_token_counts(&flat_only).reasoning_tokens, 7);
338
339        let merged = json!({
340            "input_tokens": 100,
341            "output_tokens": 50,
342            "thoughts_tokens": 30,
343            "reasoning_output_tokens": 7,
344        });
345        assert_eq!(extract_token_counts(&merged).reasoning_tokens, 37);
346    }
347
348    #[test]
349    fn ttl_split_mixed_5m_and_1h() {
350        let usage = json!({
351            "cache_creation_input_tokens": 1_500,
352            "cache_creation": {
353                "ephemeral_5m_input_tokens": 500,
354                "ephemeral_1h_input_tokens": 1_000
355            }
356        });
357        let c = extract_token_counts(&usage);
358        assert_eq!(c.cache_creation, 1_500);
359        assert_eq!(c.cache_creation_5m, 500);
360        assert_eq!(c.cache_creation_1h, 1_000);
361    }
362
363    #[test]
364    fn ttl_split_smaller_than_scalar_bills_the_remainder_at_5m() {
365        // Merged multi-iteration turn: the scalar total exceeds the published
366        // 5m+1h split. The remainder must bill at the 5m TTL, not be dropped —
367        // and the two priced buckets must sum back to the scalar total.
368        let usage = json!({
369            "cache_creation_input_tokens": 397_602_459_i64,
370            "cache_creation": {
371                "ephemeral_5m_input_tokens": 183_962_961_i64,
372                "ephemeral_1h_input_tokens": 213_022_705_i64
373            }
374        });
375        let c = extract_token_counts(&usage);
376        assert_eq!(c.cache_creation, 397_602_459);
377        assert_eq!(c.cache_creation_1h, 213_022_705);
378        // 5m carries its own tokens plus the 616,793-token remainder.
379        assert_eq!(c.cache_creation_5m, 397_602_459 - 213_022_705);
380        assert_eq!(c.cache_creation_5m + c.cache_creation_1h, c.cache_creation);
381    }
382
383    #[test]
384    fn ttl_split_derives_total_when_scalar_missing() {
385        // Edge case: only the split object is present, not the scalar total.
386        let usage = json!({
387            "cache_creation": {
388                "ephemeral_5m_input_tokens": 200,
389                "ephemeral_1h_input_tokens": 300
390            }
391        });
392        let c = extract_token_counts(&usage);
393        assert_eq!(c.cache_creation, 500);
394        assert_eq!(c.cache_creation_5m, 200);
395        assert_eq!(c.cache_creation_1h, 300);
396    }
397
398    #[test]
399    fn codex_format_no_cache_creation() {
400        // Codex JSONL uses total_token_usage; no cache_creation concept at all.
401        let usage = json!({
402            "total_token_usage": {
403                "input_tokens": 1000,
404                "output_tokens": 500,
405                "cached_input_tokens": 200,
406                "total_tokens": 1500
407            }
408        });
409        let c = extract_token_counts(&usage);
410        assert_eq!(c.cache_creation, 0);
411        assert_eq!(c.cache_creation_5m, 0);
412        assert_eq!(c.cache_creation_1h, 0);
413        assert_eq!(c.cache_read, 200);
414        // Codex `input_tokens` includes `cached_input_tokens`; the extractor
415        // must subtract cached so the two buckets don't overlap.
416        assert_eq!(c.input_tokens, 800);
417    }
418
419    #[test]
420    fn codex_input_tokens_excludes_cached_bucket() {
421        // Taken from a real Codex session: input=576_145 includes
422        // cached=408_832. The extractor must split the two so
423        // `calculate_cost` doesn't bill every cached token twice.
424        let usage = json!({
425            "total_token_usage": {
426                "input_tokens": 576_145,
427                "cached_input_tokens": 408_832,
428                "output_tokens": 13_156,
429                "reasoning_output_tokens": 8_591,
430                "total_tokens": 589_301
431            }
432        });
433        let c = extract_token_counts(&usage);
434        assert_eq!(c.input_tokens, 576_145 - 408_832);
435        assert_eq!(c.cache_read, 408_832);
436        // `output_tokens` (completion) already includes reasoning; the
437        // extractor splits them so each token is billed once.
438        assert_eq!(c.output_tokens, 13_156 - 8_591);
439        assert_eq!(c.reasoning_tokens, 8_591);
440        // `total_tokens == input + output` already, so it is used verbatim.
441        assert_eq!(c.total, 589_301);
442    }
443
444    #[test]
445    fn codex_cached_exceeding_input_clamps_to_zero() {
446        // Defensive: never emit a negative `input_tokens` even if the
447        // provider ever misreports cached > input. Better to undercount
448        // than to panic via integer overflow downstream.
449        let usage = json!({
450            "total_token_usage": {
451                "input_tokens": 100,
452                "cached_input_tokens": 500,
453                "output_tokens": 50,
454                "total_tokens": 150
455            }
456        });
457        let c = extract_token_counts(&usage);
458        assert_eq!(c.input_tokens, 0);
459        assert_eq!(c.cache_read, 500);
460    }
461
462    #[test]
463    fn gemini_thoughts_tokens_populate_reasoning_bucket() {
464        // Drives Gemini 2.5 flash pricing — thoughts_tokens must reach the
465        // reasoning bucket instead of being silently dropped or bundled
466        // into output.
467        let usage = json!({
468            "input_tokens": 13_906,
469            "output_tokens": 185,
470            "cache_read_input_tokens": 0,
471            "thoughts_tokens": 306,
472            "tool_tokens": 0,
473            "total_tokens": 14_397
474        });
475        let c = extract_token_counts(&usage);
476        assert_eq!(c.input_tokens, 13_906);
477        assert_eq!(c.output_tokens, 185);
478        assert_eq!(c.reasoning_tokens, 306);
479        assert_eq!(c.total, 14_397);
480    }
481
482    #[test]
483    fn gemini_tool_only_usage_remains_active() {
484        let usage = json!({
485            "input_tokens": 0,
486            "output_tokens": 0,
487            "thoughts_tokens": 0,
488            "tool_tokens": 5,
489            "total_tokens": 5
490        });
491        let counts = extract_token_counts(&usage);
492        assert_eq!(counts.total, 5);
493        assert_eq!(counts.input_tokens, 0);
494        assert_eq!(counts.output_tokens, 0);
495    }
496
497    #[test]
498    fn codex_reasoning_is_split_out_of_output() {
499        // Mimics a real Codex `event_msg` record mid-session. Per OpenAI's
500        // convention `output_tokens` already includes `reasoning_output_tokens`,
501        // so the extractor subtracts reasoning out of output to keep the two
502        // buckets disjoint (each token billed exactly once).
503        let usage = json!({
504            "total_token_usage": {
505                "input_tokens": 5_645,
506                "cached_input_tokens": 5_504,
507                "output_tokens": 810,
508                "reasoning_output_tokens": 640,
509                "total_tokens": 6_455
510            }
511        });
512        let c = extract_token_counts(&usage);
513        assert_eq!(c.input_tokens, 5_645 - 5_504);
514        assert_eq!(c.cache_read, 5_504);
515        assert_eq!(
516            c.output_tokens,
517            810 - 640,
518            "reasoning must be subtracted out of output, not double-counted"
519        );
520        assert_eq!(c.reasoning_tokens, 640);
521        // `total_tokens == input + output` (reasoning lives inside output),
522        // so the published total is used verbatim.
523        assert_eq!(c.total, 6_455);
524    }
525
526    #[test]
527    fn codex_real_world_reasoning_subset_of_output() {
528        // The exact `info.total_token_usage` shape from a real session: output
529        // 508 already contains the 255 reasoning tokens; total 73_861 already
530        // equals input + output. Regression guard against re-introducing the
531        // double-count.
532        let usage = json!({
533            "total_token_usage": {
534                "input_tokens": 73_353,
535                "cached_input_tokens": 31_744,
536                "output_tokens": 508,
537                "reasoning_output_tokens": 255,
538                "total_tokens": 73_861
539            }
540        });
541        let c = extract_token_counts(&usage);
542        assert_eq!(c.input_tokens, 73_353 - 31_744);
543        assert_eq!(c.cache_read, 31_744);
544        assert_eq!(c.output_tokens, 508 - 255);
545        assert_eq!(c.reasoning_tokens, 255);
546        assert_eq!(c.total, 73_861);
547    }
548
549    #[test]
550    fn claude_server_tool_use_web_search_is_extracted() {
551        // `server_tool_use.web_search_requests` feeds the per-query web-search
552        // billing path; a missing object leaves the count at 0.
553        let with_search = json!({
554            "input_tokens": 100,
555            "output_tokens": 50,
556            "server_tool_use": {
557                "web_search_requests": 3,
558                "web_fetch_requests": 1
559            }
560        });
561        assert_eq!(extract_token_counts(&with_search).web_search_requests, 3);
562
563        let without = json!({ "input_tokens": 100, "output_tokens": 50 });
564        assert_eq!(extract_token_counts(&without).web_search_requests, 0);
565    }
566
567    #[test]
568    fn above_tier_slices_are_extracted_for_both_shapes() {
569        // Flat shape (Claude / Gemini): above_tier is a sibling of the token
570        // fields and mirrors them as subsets.
571        let flat = json!({
572            "input_tokens": 300_000,
573            "output_tokens": 1_000,
574            "cache_read_input_tokens": 50_000,
575            "above_tier": {
576                "input_tokens": 280_000,
577                "output_tokens": 600,
578                "cache_read_tokens": 50_000
579            }
580        });
581        let c = extract_token_counts(&flat);
582        assert_eq!(c.input_tokens, 300_000);
583        assert_eq!(c.above_input, 280_000);
584        assert_eq!(c.above_output, 600);
585        assert_eq!(c.above_cache_read, 50_000);
586        assert_eq!(c.above_cache_creation_5m, 0);
587
588        // Codex shape: above_tier sits beside total_token_usage and must
589        // survive the early return that consumes the published total.
590        let codex = json!({
591            "total_token_usage": {
592                "input_tokens": 400_000,
593                "cached_input_tokens": 100_000,
594                "output_tokens": 2_000,
595                "reasoning_output_tokens": 500,
596                "total_tokens": 402_000
597            },
598            "above_tier": {
599                "input_tokens": 250_000,
600                "cache_read_tokens": 80_000,
601                "output_tokens": 900,
602                "reasoning_tokens": 300
603            }
604        });
605        let c = extract_token_counts(&codex);
606        assert_eq!(c.total, 402_000);
607        assert_eq!(c.above_input, 250_000);
608        assert_eq!(c.above_cache_read, 80_000);
609        assert_eq!(c.above_output, 900);
610        assert_eq!(c.above_reasoning, 300);
611    }
612
613    #[test]
614    fn copilot_reasoning_output_tokens_populate_reasoning_bucket() {
615        // After `session::copilot::parse_copilot_events` normalisation,
616        // Copilot sessions use the same flat `reasoning_output_tokens` key
617        // as Codex's nested one.
618        let usage = json!({
619            "input_tokens": 2_000,
620            "output_tokens": 300,
621            "cache_read_input_tokens": 100,
622            "cache_creation_input_tokens": 0,
623            "reasoning_output_tokens": 150
624        });
625        let c = extract_token_counts(&usage);
626        assert_eq!(c.output_tokens, 300);
627        assert_eq!(c.reasoning_tokens, 150);
628        assert_eq!(c.total, 2_000 + 300 + 150 + 100);
629    }
630}