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 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 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_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
442pub 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}