Skip to main content

codex_helper_core/
usage.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4fn i64_is_zero(value: &i64) -> bool {
5    *value == 0
6}
7
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum CacheInputAccounting {
11    #[default]
12    DirectReadSeparate,
13    DirectReadIncludedInInput,
14}
15
16impl CacheInputAccounting {
17    pub fn for_service(service: &str) -> Self {
18        match service.trim().to_ascii_lowercase().as_str() {
19            "codex" | "gemini" => Self::DirectReadIncludedInInput,
20            _ => Self::DirectReadSeparate,
21        }
22    }
23
24    fn includes_direct_read_in_input(self) -> bool {
25        matches!(self, Self::DirectReadIncludedInInput)
26    }
27}
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub struct CacheUsageBreakdown {
31    pub input_tokens: i64,
32    pub cached_input_tokens: i64,
33    pub direct_cache_read_input_tokens: i64,
34    pub cache_read_input_tokens: i64,
35    pub cache_creation_input_tokens: i64,
36    pub effective_input_tokens: i64,
37    pub denominator_tokens: i64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
41pub struct UsageMetrics {
42    #[serde(default)]
43    pub input_tokens: i64,
44    #[serde(default)]
45    pub output_tokens: i64,
46    #[serde(default)]
47    pub reasoning_tokens: i64,
48    #[serde(default, skip_serializing_if = "i64_is_zero")]
49    pub reasoning_output_tokens: i64,
50    #[serde(default)]
51    pub total_tokens: i64,
52    #[serde(default, skip_serializing_if = "i64_is_zero")]
53    pub cached_input_tokens: i64,
54    #[serde(
55        default,
56        alias = "cache_read_tokens",
57        skip_serializing_if = "i64_is_zero"
58    )]
59    pub cache_read_input_tokens: i64,
60    #[serde(
61        default,
62        alias = "cache_creation_tokens",
63        skip_serializing_if = "i64_is_zero"
64    )]
65    pub cache_creation_input_tokens: i64,
66    #[serde(default, skip_serializing_if = "i64_is_zero")]
67    pub cache_creation_5m_input_tokens: i64,
68    #[serde(default, skip_serializing_if = "i64_is_zero")]
69    pub cache_creation_1h_input_tokens: i64,
70}
71
72impl UsageMetrics {
73    pub fn add_assign(&mut self, other: &UsageMetrics) {
74        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
75        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
76        self.reasoning_tokens = self.reasoning_tokens.saturating_add(other.reasoning_tokens);
77        self.reasoning_output_tokens = self
78            .reasoning_output_tokens
79            .saturating_add(other.reasoning_output_tokens_total());
80        self.total_tokens = self.total_tokens.saturating_add(other.total_tokens);
81        self.cached_input_tokens = self
82            .cached_input_tokens
83            .saturating_add(other.cached_input_tokens);
84        self.cache_read_input_tokens = self
85            .cache_read_input_tokens
86            .saturating_add(other.cache_read_input_tokens);
87        self.cache_creation_input_tokens = self
88            .cache_creation_input_tokens
89            .saturating_add(other.cache_creation_input_tokens);
90        self.cache_creation_5m_input_tokens = self
91            .cache_creation_5m_input_tokens
92            .saturating_add(other.cache_creation_5m_input_tokens);
93        self.cache_creation_1h_input_tokens = self
94            .cache_creation_1h_input_tokens
95            .saturating_add(other.cache_creation_1h_input_tokens);
96    }
97
98    pub fn reasoning_output_tokens_total(&self) -> i64 {
99        self.reasoning_output_tokens.max(self.reasoning_tokens)
100    }
101
102    pub fn cache_creation_tokens_total(&self) -> i64 {
103        let by_ttl = self
104            .cache_creation_5m_input_tokens
105            .saturating_add(self.cache_creation_1h_input_tokens);
106        self.cache_creation_input_tokens.max(by_ttl)
107    }
108
109    pub fn has_cache_tokens(&self) -> bool {
110        self.cached_input_tokens > 0
111            || self.cache_read_input_tokens > 0
112            || self.cache_creation_tokens_total() > 0
113    }
114
115    pub fn cache_read_tokens_total(&self) -> i64 {
116        self.cached_input_tokens
117            .max(0)
118            .saturating_add(self.cache_read_input_tokens.max(0))
119    }
120
121    pub fn cache_usage_breakdown(&self, accounting: CacheInputAccounting) -> CacheUsageBreakdown {
122        let input = self.input_tokens.max(0);
123        let cached = self.cached_input_tokens.max(0);
124        let direct_read = self.cache_read_input_tokens.max(0);
125        let read = cached.saturating_add(direct_read);
126        let create = self.cache_creation_tokens_total().max(0);
127        let included_read = if accounting.includes_direct_read_in_input() {
128            read
129        } else {
130            cached
131        };
132        let effective_input = input.saturating_sub(included_read);
133        let denom = effective_input.saturating_add(create).saturating_add(read);
134
135        CacheUsageBreakdown {
136            input_tokens: input,
137            cached_input_tokens: cached,
138            direct_cache_read_input_tokens: direct_read,
139            cache_read_input_tokens: read,
140            cache_creation_input_tokens: create,
141            effective_input_tokens: effective_input,
142            denominator_tokens: denom,
143        }
144    }
145
146    pub fn cache_hit_rate_with_accounting(&self, accounting: CacheInputAccounting) -> Option<f64> {
147        let breakdown = self.cache_usage_breakdown(accounting);
148        if breakdown.denominator_tokens <= 0 {
149            return None;
150        }
151        Some(breakdown.cache_read_input_tokens as f64 / breakdown.denominator_tokens as f64)
152    }
153
154    pub fn cache_hit_rate_for_service(&self, service: &str) -> Option<f64> {
155        self.cache_hit_rate_with_accounting(CacheInputAccounting::for_service(service))
156    }
157
158    pub fn cache_hit_rate(&self) -> Option<f64> {
159        self.cache_hit_rate_with_accounting(CacheInputAccounting::default())
160    }
161
162    pub fn effective_input_tokens_with_accounting(&self, accounting: CacheInputAccounting) -> i64 {
163        self.cache_usage_breakdown(accounting)
164            .effective_input_tokens
165    }
166
167    pub fn cache_denominator_tokens_with_accounting(
168        &self,
169        accounting: CacheInputAccounting,
170    ) -> Option<i64> {
171        let denom = self.cache_usage_breakdown(accounting).denominator_tokens;
172        if denom <= 0 {
173            return None;
174        }
175        Some(denom)
176    }
177
178    fn derived_total_tokens(&self) -> i64 {
179        self.input_tokens
180            .saturating_add(self.output_tokens)
181            .saturating_add(self.cache_read_input_tokens)
182            .saturating_add(self.cache_creation_tokens_total())
183    }
184}
185
186fn to_i64(v: &Value) -> i64 {
187    match v {
188        Value::Number(n) => n.as_i64().unwrap_or(0),
189        Value::String(s) => s.parse::<f64>().ok().map(|f| f as i64).unwrap_or(0),
190        _ => 0,
191    }
192}
193
194fn extract_usage_obj(payload: &Value) -> Option<&Value> {
195    if let Some(u) = payload.get("usage") {
196        return Some(u);
197    }
198    if let Some(msg) = payload.get("message")
199        && let Some(u) = msg.get("usage")
200    {
201        return Some(u);
202    }
203    if let Some(resp) = payload.get("response")
204        && let Some(u) = resp.get("usage")
205    {
206        return Some(u);
207    }
208    None
209}
210
211fn usage_from_value(usage_obj: &Value) -> Option<UsageMetrics> {
212    let mut m = UsageMetrics::default();
213    let mut recognized = false;
214    let mut total_provided = false;
215
216    if let Some(v) = usage_obj.get("input_tokens") {
217        m.input_tokens = to_i64(v);
218        recognized = true;
219    }
220    if let Some(v) = usage_obj.get("output_tokens") {
221        m.output_tokens = to_i64(v);
222        recognized = true;
223    }
224    if let Some(v) = usage_obj.get("total_tokens") {
225        m.total_tokens = to_i64(v);
226        recognized = true;
227        total_provided = true;
228    }
229
230    // OpenAI Chat Completions compatibility (`prompt_tokens` / `completion_tokens`).
231    if let Some(v) = usage_obj.get("prompt_tokens") {
232        m.input_tokens = to_i64(v);
233        recognized = true;
234    }
235    if let Some(v) = usage_obj.get("completion_tokens") {
236        m.output_tokens = to_i64(v);
237        recognized = true;
238    }
239
240    // Some providers may expose reasoning tokens directly.
241    if let Some(v) = usage_obj.get("reasoning_tokens") {
242        let value = to_i64(v);
243        m.reasoning_tokens = value;
244        m.reasoning_output_tokens = value;
245        recognized = true;
246    }
247    if let Some(v) = usage_obj.get("reasoning_output_tokens") {
248        let value = to_i64(v);
249        m.reasoning_output_tokens = value;
250        m.reasoning_tokens = m.reasoning_tokens.max(value);
251        recognized = true;
252    }
253
254    if let Some(details) = usage_obj
255        .get("output_tokens_details")
256        .and_then(|v| v.as_object())
257        && let Some(v) = details.get("reasoning_tokens")
258    {
259        let value = to_i64(v);
260        m.reasoning_tokens = value;
261        m.reasoning_output_tokens = value;
262        recognized = true;
263    }
264    if let Some(details) = usage_obj
265        .get("completion_tokens_details")
266        .and_then(|v| v.as_object())
267        && let Some(v) = details.get("reasoning_tokens")
268    {
269        let value = to_i64(v);
270        m.reasoning_tokens = value;
271        m.reasoning_output_tokens = value;
272        recognized = true;
273    }
274
275    if let Some(details) = usage_obj
276        .get("input_tokens_details")
277        .or_else(|| usage_obj.get("input_token_details"))
278        .and_then(|v| v.as_object())
279        && let Some(v) = details.get("cached_tokens")
280    {
281        m.cached_input_tokens = to_i64(v);
282        recognized = true;
283    } else if let Some(details) = usage_obj
284        .get("prompt_tokens_details")
285        .or_else(|| usage_obj.get("prompt_token_details"))
286        .and_then(|v| v.as_object())
287        && let Some(v) = details.get("cached_tokens")
288    {
289        m.cached_input_tokens = to_i64(v);
290        recognized = true;
291    } else if let Some(v) = usage_obj.get("cached_input_tokens") {
292        m.cached_input_tokens = to_i64(v);
293        recognized = true;
294    } else if let Some(v) = usage_obj.get("cached_tokens") {
295        m.cached_input_tokens = to_i64(v);
296        recognized = true;
297    } else if let Some(v) = usage_obj
298        .get("cache_read_input_tokens")
299        .or_else(|| usage_obj.get("cache_read_tokens"))
300    {
301        m.cache_read_input_tokens = to_i64(v);
302        recognized = true;
303    }
304    if let Some(v) = usage_obj
305        .get("cache_creation_input_tokens")
306        .or_else(|| usage_obj.get("cache_creation_tokens"))
307    {
308        m.cache_creation_input_tokens = to_i64(v);
309        recognized = true;
310    }
311    if let Some(v) = usage_obj.get("cache_creation_5m_input_tokens") {
312        m.cache_creation_5m_input_tokens = to_i64(v);
313        recognized = true;
314    } else if let Some(v) = usage_obj
315        .get("cache_creation")
316        .and_then(|v| v.get("ephemeral_5m_input_tokens"))
317    {
318        m.cache_creation_5m_input_tokens = to_i64(v);
319        recognized = true;
320    } else if let Some(v) = usage_obj.get("claude_cache_creation_5_m_tokens") {
321        m.cache_creation_5m_input_tokens = to_i64(v);
322        recognized = true;
323    }
324    if let Some(v) = usage_obj.get("cache_creation_1h_input_tokens") {
325        m.cache_creation_1h_input_tokens = to_i64(v);
326        recognized = true;
327    } else if let Some(v) = usage_obj
328        .get("cache_creation")
329        .and_then(|v| v.get("ephemeral_1h_input_tokens"))
330    {
331        m.cache_creation_1h_input_tokens = to_i64(v);
332        recognized = true;
333    } else if let Some(v) = usage_obj.get("claude_cache_creation_1_h_tokens") {
334        m.cache_creation_1h_input_tokens = to_i64(v);
335        recognized = true;
336    }
337    if m.cache_creation_input_tokens == 0 {
338        m.cache_creation_input_tokens = m
339            .cache_creation_5m_input_tokens
340            .saturating_add(m.cache_creation_1h_input_tokens);
341    }
342
343    // If total isn't provided, derive it from input/output when possible.
344    if !total_provided {
345        m.total_tokens = m.derived_total_tokens();
346    }
347
348    if !recognized {
349        return None;
350    }
351    Some(m)
352}
353
354fn merge_usage_update(last: &mut Option<UsageMetrics>, update: UsageMetrics) {
355    let Some(existing) = last.as_mut() else {
356        *last = Some(update);
357        return;
358    };
359
360    if update.input_tokens != 0 {
361        existing.input_tokens = update.input_tokens;
362    }
363    if update.output_tokens != 0 {
364        existing.output_tokens = update.output_tokens;
365    }
366    if update.reasoning_tokens != 0 {
367        existing.reasoning_tokens = update.reasoning_tokens;
368    }
369    if update.reasoning_output_tokens != 0 {
370        existing.reasoning_output_tokens = update.reasoning_output_tokens;
371    }
372    if update.cached_input_tokens != 0 {
373        existing.cached_input_tokens = update.cached_input_tokens;
374    }
375    if update.cache_read_input_tokens != 0 {
376        existing.cache_read_input_tokens = update.cache_read_input_tokens;
377    }
378    if update.cache_creation_input_tokens != 0 {
379        existing.cache_creation_input_tokens = update.cache_creation_input_tokens;
380    }
381    if update.cache_creation_5m_input_tokens != 0 {
382        existing.cache_creation_5m_input_tokens = update.cache_creation_5m_input_tokens;
383    }
384    if update.cache_creation_1h_input_tokens != 0 {
385        existing.cache_creation_1h_input_tokens = update.cache_creation_1h_input_tokens;
386    }
387
388    existing.cache_creation_input_tokens = existing.cache_creation_input_tokens.max(
389        existing
390            .cache_creation_5m_input_tokens
391            .saturating_add(existing.cache_creation_1h_input_tokens),
392    );
393    existing.total_tokens = update
394        .total_tokens
395        .max(existing.total_tokens)
396        .max(existing.derived_total_tokens());
397}
398
399pub fn extract_usage_from_bytes(data: &[u8]) -> Option<UsageMetrics> {
400    let text = std::str::from_utf8(data).ok()?.trim();
401    if text.is_empty() {
402        return None;
403    }
404    let json: Value = match serde_json::from_str(text) {
405        Ok(json) => json,
406        Err(_) => return extract_usage_from_sse_bytes(data),
407    };
408    let usage_obj = extract_usage_obj(&json)?;
409    usage_from_value(usage_obj)
410}
411
412#[allow(dead_code)]
413pub fn extract_usage_from_sse_bytes(data: &[u8]) -> Option<UsageMetrics> {
414    let text = std::str::from_utf8(data).ok()?;
415    let mut last: Option<UsageMetrics> = None;
416
417    for chunk in text.split("\n\n") {
418        let lines: Vec<&str> = chunk
419            .lines()
420            .map(|l| l.trim())
421            .filter(|l| !l.is_empty())
422            .collect();
423        for line in lines {
424            if let Some(rest) = line.strip_prefix("data:") {
425                let payload_str = rest.trim();
426                if payload_str.is_empty() {
427                    continue;
428                }
429                if let Ok(json) = serde_json::from_str::<Value>(payload_str)
430                    && let Some(usage_obj) = extract_usage_obj(&json)
431                    && let Some(u) = usage_from_value(usage_obj)
432                {
433                    merge_usage_update(&mut last, u);
434                }
435            }
436        }
437    }
438
439    last
440}
441
442/// Incrementally scan SSE bytes for `data: {json}` lines that contain usage information.
443///
444/// This is designed for streaming scenarios where the response arrives in many chunks:
445/// it avoids repeatedly re-parsing the entire buffer (which can become O(n^2)).
446///
447/// - `scan_pos` is an in/out cursor into `data` (byte index).
448/// - `last` stores the latest usage parsed so far (updated in-place).
449pub fn scan_usage_from_sse_bytes_incremental(
450    data: &[u8],
451    scan_pos: &mut usize,
452    last: &mut Option<UsageMetrics>,
453) {
454    let mut i = (*scan_pos).min(data.len());
455
456    while i < data.len() {
457        let Some(rel_end) = data[i..].iter().position(|b| *b == b'\n') else {
458            break;
459        };
460        let end = i + rel_end;
461        let mut line = &data[i..end];
462        i = end.saturating_add(1);
463
464        if line.ends_with(b"\r") {
465            line = &line[..line.len().saturating_sub(1)];
466        }
467        if line.is_empty() {
468            continue;
469        }
470
471        const DATA_PREFIX: &[u8] = b"data:";
472        if !line.starts_with(DATA_PREFIX) {
473            continue;
474        }
475        let mut payload = &line[DATA_PREFIX.len()..];
476        while !payload.is_empty() && payload[0].is_ascii_whitespace() {
477            payload = &payload[1..];
478        }
479        if payload.is_empty() || payload == b"[DONE]" {
480            continue;
481        }
482
483        if let Ok(json) = serde_json::from_slice::<Value>(payload)
484            && let Some(usage_obj) = extract_usage_obj(&json)
485            && let Some(u) = usage_from_value(usage_obj)
486        {
487            merge_usage_update(last, u);
488        }
489    }
490
491    *scan_pos = i;
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    use pretty_assertions::assert_eq;
499
500    #[test]
501    fn incremental_sse_scan_matches_full_parse() {
502        let sse = concat!(
503            "event: response.output_text.delta\n",
504            "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n",
505            "\n",
506            "event: response.completed\n",
507            "data: {\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n",
508            "\n"
509        );
510
511        let full = extract_usage_from_sse_bytes(sse.as_bytes());
512        let mut pos = 0usize;
513        let mut last = None;
514        scan_usage_from_sse_bytes_incremental(sse.as_bytes(), &mut pos, &mut last);
515        assert_eq!(last, full);
516    }
517
518    #[test]
519    fn incremental_sse_scan_handles_split_lines() {
520        let part1 = b"data: {\"response\":{\"usage\":{\"input_tokens\":1";
521        let part2 = b",\"output_tokens\":2,\"total_tokens\":3}}}\n\n";
522        let mut buf = Vec::new();
523        let mut pos = 0usize;
524        let mut last = None;
525
526        buf.extend_from_slice(part1);
527        scan_usage_from_sse_bytes_incremental(&buf, &mut pos, &mut last);
528        assert_eq!(last, None);
529
530        buf.extend_from_slice(part2);
531        scan_usage_from_sse_bytes_incremental(&buf, &mut pos, &mut last);
532        assert_eq!(
533            last,
534            Some(UsageMetrics {
535                input_tokens: 1,
536                output_tokens: 2,
537                total_tokens: 3,
538                ..UsageMetrics::default()
539            })
540        );
541    }
542
543    #[test]
544    fn parses_anthropic_message_start_usage() {
545        let json = r#"{
546          "type":"message_start",
547          "message":{
548            "usage":{
549              "input_tokens":100,
550              "cache_read_input_tokens":30,
551              "cache_creation":{
552                "ephemeral_5m_input_tokens":20,
553                "ephemeral_1h_input_tokens":40
554              }
555            }
556          }
557        }"#;
558
559        assert_eq!(
560            extract_usage_from_bytes(json.as_bytes()),
561            Some(UsageMetrics {
562                input_tokens: 100,
563                cache_read_input_tokens: 30,
564                cache_creation_input_tokens: 60,
565                cache_creation_5m_input_tokens: 20,
566                cache_creation_1h_input_tokens: 40,
567                total_tokens: 190,
568                ..UsageMetrics::default()
569            })
570        );
571    }
572
573    #[test]
574    fn sse_scan_merges_message_start_and_delta_usage() {
575        let sse = concat!(
576            "event: message_start\n",
577            "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":100,\"cache_read_input_tokens\":30,\"cache_creation\":{\"ephemeral_5m_input_tokens\":20}}}}\n",
578            "\n",
579            "event: message_delta\n",
580            "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":7}}\n",
581            "\n"
582        );
583        let expected = Some(UsageMetrics {
584            input_tokens: 100,
585            output_tokens: 7,
586            cache_read_input_tokens: 30,
587            cache_creation_input_tokens: 20,
588            cache_creation_5m_input_tokens: 20,
589            total_tokens: 157,
590            ..UsageMetrics::default()
591        });
592
593        assert_eq!(extract_usage_from_sse_bytes(sse.as_bytes()), expected);
594
595        let mut pos = 0usize;
596        let mut last = None;
597        scan_usage_from_sse_bytes_incremental(sse.as_bytes(), &mut pos, &mut last);
598        assert_eq!(last, expected);
599    }
600
601    #[test]
602    fn extract_usage_from_bytes_falls_back_to_sse_body() {
603        let sse = concat!(
604            "event: response.completed\n",
605            "data: {\"type\":\"response.completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":4}}\n",
606            "\n"
607        );
608
609        assert_eq!(
610            extract_usage_from_bytes(sse.as_bytes()),
611            Some(UsageMetrics {
612                input_tokens: 3,
613                output_tokens: 4,
614                total_tokens: 7,
615                ..UsageMetrics::default()
616            })
617        );
618    }
619
620    #[test]
621    fn parses_chat_completions_usage_fields() {
622        let json = r#"{
623          "id":"chatcmpl_x",
624          "object":"chat.completion",
625          "usage":{
626            "prompt_tokens":9,
627            "completion_tokens":12,
628            "total_tokens":21,
629            "completion_tokens_details":{"reasoning_tokens":5}
630          }
631        }"#;
632        assert_eq!(
633            extract_usage_from_bytes(json.as_bytes()),
634            Some(UsageMetrics {
635                input_tokens: 9,
636                output_tokens: 12,
637                reasoning_tokens: 5,
638                reasoning_output_tokens: 5,
639                total_tokens: 21,
640                ..UsageMetrics::default()
641            })
642        );
643    }
644
645    #[test]
646    fn parses_responses_usage_cache_and_reasoning_details() {
647        let json = r#"{
648          "response":{
649            "usage":{
650              "input_tokens":100,
651              "output_tokens":20,
652              "input_tokens_details":{"cached_tokens":40},
653              "output_tokens_details":{"reasoning_tokens":7}
654            }
655          }
656        }"#;
657        assert_eq!(
658            extract_usage_from_bytes(json.as_bytes()),
659            Some(UsageMetrics {
660                input_tokens: 100,
661                output_tokens: 20,
662                reasoning_tokens: 7,
663                reasoning_output_tokens: 7,
664                cached_input_tokens: 40,
665                total_tokens: 120,
666                ..UsageMetrics::default()
667            })
668        );
669    }
670
671    #[test]
672    fn parses_openai_cached_tokens_before_direct_cache_read_tokens() {
673        let json = r#"{
674          "usage":{
675            "input_tokens":100,
676            "output_tokens":20,
677            "input_tokens_details":{"cached_tokens":40},
678            "cache_read_input_tokens":30
679          }
680        }"#;
681        assert_eq!(
682            extract_usage_from_bytes(json.as_bytes()),
683            Some(UsageMetrics {
684                input_tokens: 100,
685                output_tokens: 20,
686                cached_input_tokens: 40,
687                total_tokens: 120,
688                ..UsageMetrics::default()
689            })
690        );
691    }
692
693    #[test]
694    fn parses_anthropic_cache_usage_fields() {
695        let json = r#"{
696          "usage":{
697            "input_tokens":10,
698            "output_tokens":5,
699            "cache_read_input_tokens":30,
700            "cache_creation_5m_input_tokens":20,
701            "cache_creation_1h_input_tokens":40
702          }
703        }"#;
704        assert_eq!(
705            extract_usage_from_bytes(json.as_bytes()),
706            Some(UsageMetrics {
707                input_tokens: 10,
708                output_tokens: 5,
709                total_tokens: 105,
710                cache_read_input_tokens: 30,
711                cache_creation_input_tokens: 60,
712                cache_creation_5m_input_tokens: 20,
713                cache_creation_1h_input_tokens: 40,
714                ..UsageMetrics::default()
715            })
716        );
717    }
718
719    #[test]
720    fn parses_cache_creation_aliases_used_by_relay_bridges() {
721        let json = r#"{
722          "usage":{
723            "input_tokens":10,
724            "output_tokens":5,
725            "cached_tokens":4,
726            "cache_creation":{
727              "ephemeral_5m_input_tokens":20,
728              "ephemeral_1h_input_tokens":40
729            }
730          }
731        }"#;
732        assert_eq!(
733            extract_usage_from_bytes(json.as_bytes()),
734            Some(UsageMetrics {
735                input_tokens: 10,
736                output_tokens: 5,
737                cached_input_tokens: 4,
738                total_tokens: 75,
739                cache_creation_input_tokens: 60,
740                cache_creation_5m_input_tokens: 20,
741                cache_creation_1h_input_tokens: 40,
742                ..UsageMetrics::default()
743            })
744        );
745    }
746
747    #[test]
748    fn parses_claude_cache_creation_aliases() {
749        let json = r#"{
750          "usage":{
751            "input_tokens":10,
752            "output_tokens":5,
753            "claude_cache_creation_5_m_tokens":20,
754            "claude_cache_creation_1_h_tokens":40
755          }
756        }"#;
757        assert_eq!(
758            extract_usage_from_bytes(json.as_bytes()),
759            Some(UsageMetrics {
760                input_tokens: 10,
761                output_tokens: 5,
762                total_tokens: 75,
763                cache_creation_input_tokens: 60,
764                cache_creation_5m_input_tokens: 20,
765                cache_creation_1h_input_tokens: 40,
766                ..UsageMetrics::default()
767            })
768        );
769    }
770
771    #[test]
772    fn computes_cache_hit_rate_from_read_and_creation_tokens() {
773        let usage = UsageMetrics {
774            input_tokens: 100,
775            cache_read_input_tokens: 30,
776            cache_creation_input_tokens: 20,
777            ..UsageMetrics::default()
778        };
779
780        let rate = usage.cache_hit_rate().expect("cache hit rate");
781
782        assert_eq!(rate, 0.2);
783    }
784
785    #[test]
786    fn computes_cache_hit_rate_when_direct_cache_read_is_included_in_input() {
787        let usage = UsageMetrics {
788            input_tokens: 100,
789            cache_read_input_tokens: 30,
790            cache_creation_input_tokens: 20,
791            ..UsageMetrics::default()
792        };
793
794        let rate = usage
795            .cache_hit_rate_with_accounting(CacheInputAccounting::DirectReadIncludedInInput)
796            .expect("cache hit rate");
797
798        assert_eq!(rate, 0.25);
799    }
800
801    #[test]
802    fn computes_cache_hit_rate_from_cached_input_tokens() {
803        let usage = UsageMetrics {
804            input_tokens: 100,
805            cached_input_tokens: 40,
806            ..UsageMetrics::default()
807        };
808
809        let rate = usage.cache_hit_rate().expect("cache hit rate");
810
811        assert_eq!(rate, 0.4);
812    }
813
814    #[test]
815    fn computes_cache_hit_rate_from_mixed_usage_cache_fields() {
816        let usage = UsageMetrics {
817            input_tokens: 1_500,
818            cached_input_tokens: 50,
819            cache_read_input_tokens: 250,
820            ..UsageMetrics::default()
821        };
822
823        let rate = usage.cache_hit_rate().expect("cache hit rate");
824
825        assert!((rate - (300.0 / 1_750.0)).abs() < f64::EPSILON);
826    }
827
828    #[test]
829    fn computes_service_specific_cache_hit_rate_from_mixed_usage_cache_fields() {
830        let usage = UsageMetrics {
831            input_tokens: 1_500,
832            cached_input_tokens: 50,
833            cache_read_input_tokens: 250,
834            ..UsageMetrics::default()
835        };
836
837        let rate = usage
838            .cache_hit_rate_for_service("codex")
839            .expect("cache hit rate");
840
841        assert!((rate - (300.0 / 1_500.0)).abs() < f64::EPSILON);
842    }
843
844    #[test]
845    fn unknown_usage_schema_returns_none() {
846        let json = r#"{"usage":{"foo":123}}"#;
847        assert_eq!(extract_usage_from_bytes(json.as_bytes()), None);
848    }
849}