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}