1use crate::constants::FastHashMap;
6use serde_json::Value;
7
8pub fn accumulate_i64_fields(
33 target: &mut serde_json::Map<String, Value>,
34 source: &serde_json::Map<String, Value>,
35 fields: &[&str],
36) {
37 for field in fields {
38 if let Some(value) = source.get(*field).and_then(|v| v.as_i64()) {
39 let current = target.get(*field).and_then(|v| v.as_i64()).unwrap_or(0);
40 target.insert(field.to_string(), (current + value).into());
41 }
42 }
43}
44
45pub fn accumulate_nested_object(
71 target_obj: &mut serde_json::Map<String, Value>,
72 field_name: &str,
73 source_nested: &serde_json::Map<String, Value>,
74) {
75 let target_nested = target_obj
76 .entry(field_name.to_string())
77 .or_insert_with(|| serde_json::json!({}));
78
79 if let Some(target_nested_obj) = target_nested.as_object_mut() {
80 for (key, value) in source_nested {
81 if let Some(v) = value.as_i64() {
82 let current = target_nested_obj
83 .get(key)
84 .and_then(|v| v.as_i64())
85 .unwrap_or(0);
86 target_nested_obj.insert(key.clone(), (current + v).into());
87 }
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, Default)]
100struct AboveTierSlice {
101 input: i64,
102 output: i64,
103 reasoning: i64,
104 cache_read: i64,
105 cache_creation_5m: i64,
106 cache_creation_1h: i64,
107}
108
109impl AboveTierSlice {
110 fn accumulate_into(self, existing_obj: &mut serde_json::Map<String, Value>) {
111 let mut slice = serde_json::Map::with_capacity(6);
112 let mut push = |key: &str, value: i64| {
113 if value != 0 {
114 slice.insert(key.to_string(), value.into());
115 }
116 };
117 push("input_tokens", self.input);
118 push("output_tokens", self.output);
119 push("reasoning_tokens", self.reasoning);
120 push("cache_read_tokens", self.cache_read);
121 push("cache_creation_5m_tokens", self.cache_creation_5m);
122 push("cache_creation_1h_tokens", self.cache_creation_1h);
123 if !slice.is_empty() {
124 accumulate_nested_object(existing_obj, "above_tier", &slice);
125 }
126 }
127}
128
129pub fn claude_request_context(usage_obj: &serde_json::Map<String, Value>) -> i64 {
133 let field = |key: &str| usage_obj.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
134 let mut cache_creation = field("cache_creation_input_tokens");
135 if cache_creation == 0
136 && let Some(split) = usage_obj.get("cache_creation").and_then(|v| v.as_object())
137 {
138 cache_creation = split.values().filter_map(|v| v.as_i64()).sum();
139 }
140 field("input_tokens") + field("cache_read_input_tokens") + cache_creation
141}
142
143pub fn process_claude_usage(
155 conversation_usage: &mut FastHashMap<String, Value>,
156 model: &str,
157 usage: &Value,
158 above_tier: bool,
159) {
160 if model.contains("<synthetic>") {
162 return;
163 }
164
165 let usage_obj = match usage.as_object() {
166 Some(obj) => obj,
167 None => return,
168 };
169
170 let existing = conversation_usage
172 .entry(model.to_string())
173 .or_insert_with(|| {
174 serde_json::json!({
175 "input_tokens": 0,
176 "cache_creation_input_tokens": 0,
177 "cache_read_input_tokens": 0,
178 "cache_creation": {},
179 "output_tokens": 0,
180 "service_tier": ""
181 })
182 });
183
184 let Some(existing_obj) = existing.as_object_mut() else {
185 return;
186 };
187
188 accumulate_i64_fields(
190 existing_obj,
191 usage_obj,
192 &[
193 "input_tokens",
194 "cache_creation_input_tokens",
195 "cache_read_input_tokens",
196 "output_tokens",
197 ],
198 );
199
200 if let Some(service_tier) = usage_obj.get("service_tier").and_then(|v| v.as_str()) {
202 existing_obj.insert("service_tier".to_string(), service_tier.into());
203 }
204
205 if let Some(cache_creation) = usage_obj.get("cache_creation").and_then(|v| v.as_object()) {
207 accumulate_nested_object(existing_obj, "cache_creation", cache_creation);
208 }
209
210 if let Some(server_tool_use) = usage_obj.get("server_tool_use").and_then(|v| v.as_object()) {
214 accumulate_nested_object(existing_obj, "server_tool_use", server_tool_use);
215 }
216
217 if above_tier {
218 let field = |key: &str| usage_obj.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
219 let scalar_cc = field("cache_creation_input_tokens");
220 let cc_1h = usage_obj
224 .get("cache_creation")
225 .and_then(|v| v.as_object())
226 .and_then(|split| split.get("ephemeral_1h_input_tokens"))
227 .and_then(|v| v.as_i64())
228 .unwrap_or(0);
229 let ephemeral_5m = usage_obj
230 .get("cache_creation")
231 .and_then(|v| v.as_object())
232 .and_then(|split| split.get("ephemeral_5m_input_tokens"))
233 .and_then(|v| v.as_i64())
234 .unwrap_or(0);
235 let total_cc = scalar_cc.max(ephemeral_5m + cc_1h);
236 AboveTierSlice {
237 input: field("input_tokens"),
238 output: field("output_tokens"),
239 reasoning: 0,
240 cache_read: field("cache_read_input_tokens"),
241 cache_creation_5m: total_cc - cc_1h,
242 cache_creation_1h: cc_1h,
243 }
244 .accumulate_into(existing_obj);
245 }
246}
247
248pub const CODEX_TOKEN_FIELDS: [&str; 5] = [
250 "input_tokens",
251 "cached_input_tokens",
252 "output_tokens",
253 "reasoning_output_tokens",
254 "total_tokens",
255];
256
257#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
265pub struct CodexTokenTotals {
266 pub input_tokens: i64,
267 pub cached_input_tokens: i64,
268 pub output_tokens: i64,
269 pub reasoning_output_tokens: i64,
270 pub total_tokens: i64,
271}
272
273impl CodexTokenTotals {
274 pub fn from_total_object(total: &serde_json::Map<String, Value>) -> Self {
277 let field = |key: &str| total.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
278 Self {
279 input_tokens: field("input_tokens"),
280 cached_input_tokens: field("cached_input_tokens"),
281 output_tokens: field("output_tokens"),
282 reasoning_output_tokens: field("reasoning_output_tokens"),
283 total_tokens: field("total_tokens"),
284 }
285 }
286
287 fn field(&self, key: &str) -> i64 {
288 match key {
289 "input_tokens" => self.input_tokens,
290 "cached_input_tokens" => self.cached_input_tokens,
291 "output_tokens" => self.output_tokens,
292 "reasoning_output_tokens" => self.reasoning_output_tokens,
293 "total_tokens" => self.total_tokens,
294 _ => 0,
295 }
296 }
297
298 pub fn delta_fields(
306 total: &serde_json::Map<String, Value>,
307 prev: Option<&Self>,
308 ) -> serde_json::Map<String, Value> {
309 let current = Self::from_total_object(total);
310 let base = match prev {
311 Some(prev) if current.total_tokens >= prev.total_tokens => *prev,
312 _ => Self::default(),
313 };
314 let mut delta = serde_json::Map::new();
315 for key in CODEX_TOKEN_FIELDS {
316 if total.contains_key(key) {
317 delta.insert(
318 key.to_string(),
319 (current.field(key) - base.field(key)).max(0).into(),
320 );
321 }
322 }
323 delta
324 }
325}
326
327pub fn process_codex_usage(
340 conversation_usage: &mut FastHashMap<String, Value>,
341 model: &str,
342 delta: &serde_json::Map<String, Value>,
343 info: &Value,
344 above_tier: bool,
345) {
346 if model.contains("<synthetic>") {
348 return;
349 }
350
351 let info_obj = match info.as_object() {
352 Some(obj) => obj,
353 None => return,
354 };
355
356 let existing = conversation_usage
357 .entry(model.to_string())
358 .or_insert_with(|| {
359 serde_json::json!({
360 "total_token_usage": {},
361 "last_token_usage": {},
362 "model_context_window": null
363 })
364 });
365
366 let Some(existing_obj) = existing.as_object_mut() else {
367 return;
368 };
369
370 accumulate_nested_object(existing_obj, "total_token_usage", delta);
371
372 if above_tier {
373 let field = |key: &str| delta.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
374 let cached = field("cached_input_tokens");
375 let reasoning = field("reasoning_output_tokens");
376 AboveTierSlice {
377 input: (field("input_tokens") - cached).max(0),
378 output: (field("output_tokens") - reasoning).max(0),
379 reasoning,
380 cache_read: cached,
381 cache_creation_5m: 0,
382 cache_creation_1h: 0,
383 }
384 .accumulate_into(existing_obj);
385 }
386
387 if let Some(last_usage) = info_obj.get("last_token_usage") {
389 existing_obj.insert("last_token_usage".to_string(), last_usage.clone());
390 }
391
392 if let Some(context_window) = info_obj.get("model_context_window") {
394 existing_obj.insert("model_context_window".to_string(), context_window.clone());
395 }
396}
397
398pub fn process_gemini_usage(
411 conversation_usage: &mut FastHashMap<String, Value>,
412 model: &str,
413 tokens: &crate::models::GeminiTokens,
414 above_tier: bool,
415) {
416 let existing = conversation_usage
417 .entry(model.to_string())
418 .or_insert_with(|| {
419 serde_json::json!({
420 "input_tokens": 0,
421 "cache_read_input_tokens": 0,
422 "output_tokens": 0,
423 "thoughts_tokens": 0,
424 "tool_tokens": 0,
425 "total_tokens": 0,
426 })
427 });
428
429 let Some(existing_obj) = existing.as_object_mut() else {
430 return;
431 };
432
433 let input_non_cached = (tokens.input - tokens.cached).max(0);
447
448 let current_input = existing_obj
449 .get("input_tokens")
450 .and_then(|v| v.as_i64())
451 .unwrap_or(0);
452 existing_obj.insert(
453 "input_tokens".to_string(),
454 (current_input + input_non_cached).into(),
455 );
456
457 let current_cached = existing_obj
459 .get("cache_read_input_tokens")
460 .and_then(|v| v.as_i64())
461 .unwrap_or(0);
462 existing_obj.insert(
463 "cache_read_input_tokens".to_string(),
464 (current_cached + tokens.cached).into(),
465 );
466
467 let current_output = existing_obj
469 .get("output_tokens")
470 .and_then(|v| v.as_i64())
471 .unwrap_or(0);
472 existing_obj.insert(
473 "output_tokens".to_string(),
474 (current_output + tokens.output).into(),
475 );
476
477 let current_thoughts = existing_obj
479 .get("thoughts_tokens")
480 .and_then(|v| v.as_i64())
481 .unwrap_or(0);
482 existing_obj.insert(
483 "thoughts_tokens".to_string(),
484 (current_thoughts + tokens.thoughts).into(),
485 );
486
487 let current_tool = existing_obj
489 .get("tool_tokens")
490 .and_then(|v| v.as_i64())
491 .unwrap_or(0);
492 existing_obj.insert(
493 "tool_tokens".to_string(),
494 (current_tool + tokens.tool).into(),
495 );
496
497 let current_total = existing_obj
499 .get("total_tokens")
500 .and_then(|v| v.as_i64())
501 .unwrap_or(0);
502 existing_obj.insert(
503 "total_tokens".to_string(),
504 (current_total + tokens.total).into(),
505 );
506
507 if above_tier {
508 AboveTierSlice {
509 input: input_non_cached,
510 output: tokens.output,
511 reasoning: tokens.thoughts,
512 cache_read: tokens.cached,
513 cache_creation_5m: 0,
514 cache_creation_1h: 0,
515 }
516 .accumulate_into(existing_obj);
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use serde_json::json;
524
525 #[test]
526 fn test_accumulate_i64_fields() {
527 let mut target = serde_json::Map::new();
528 target.insert("count".to_string(), json!(10));
529 target.insert("total".to_string(), json!(100));
530
531 let mut source = serde_json::Map::new();
532 source.insert("count".to_string(), json!(5));
533 source.insert("total".to_string(), json!(50));
534 source.insert("new_field".to_string(), json!(25));
535
536 accumulate_i64_fields(&mut target, &source, &["count", "total", "new_field"]);
537
538 assert_eq!(target.get("count").unwrap().as_i64().unwrap(), 15);
539 assert_eq!(target.get("total").unwrap().as_i64().unwrap(), 150);
540 assert_eq!(target.get("new_field").unwrap().as_i64().unwrap(), 25);
541 }
542
543 #[test]
544 fn test_accumulate_i64_fields_missing_source() {
545 let mut target = serde_json::Map::new();
546 target.insert("count".to_string(), json!(10));
547
548 let source = serde_json::Map::new();
549
550 accumulate_i64_fields(&mut target, &source, &["count", "missing"]);
551
552 assert_eq!(target.get("count").unwrap().as_i64().unwrap(), 10);
553 assert!(!target.contains_key("missing"));
554 }
555
556 #[test]
557 fn test_accumulate_i64_fields_non_numeric() {
558 let mut target = serde_json::Map::new();
559 target.insert("count".to_string(), json!(10));
560
561 let mut source = serde_json::Map::new();
562 source.insert("count".to_string(), json!("not a number"));
563
564 accumulate_i64_fields(&mut target, &source, &["count"]);
565
566 assert_eq!(target.get("count").unwrap().as_i64().unwrap(), 10);
567 }
568
569 #[test]
570 fn test_accumulate_nested_object() {
571 let mut target = serde_json::Map::new();
572 target.insert(
573 "usage".to_string(),
574 json!({
575 "input": 100,
576 "output": 50
577 }),
578 );
579
580 let mut source_nested = serde_json::Map::new();
581 source_nested.insert("input".to_string(), json!(25));
582 source_nested.insert("output".to_string(), json!(15));
583 source_nested.insert("cached".to_string(), json!(10));
584
585 accumulate_nested_object(&mut target, "usage", &source_nested);
586
587 let usage = target.get("usage").unwrap().as_object().unwrap();
588 assert_eq!(usage.get("input").unwrap().as_i64().unwrap(), 125);
589 assert_eq!(usage.get("output").unwrap().as_i64().unwrap(), 65);
590 assert_eq!(usage.get("cached").unwrap().as_i64().unwrap(), 10);
591 }
592
593 #[test]
594 fn test_accumulate_nested_object_new_field() {
595 let mut target = serde_json::Map::new();
596
597 let mut source_nested = serde_json::Map::new();
598 source_nested.insert("input".to_string(), json!(100));
599 source_nested.insert("output".to_string(), json!(50));
600
601 accumulate_nested_object(&mut target, "usage", &source_nested);
602
603 let usage = target.get("usage").unwrap().as_object().unwrap();
604 assert_eq!(usage.get("input").unwrap().as_i64().unwrap(), 100);
605 assert_eq!(usage.get("output").unwrap().as_i64().unwrap(), 50);
606 }
607
608 #[test]
609 fn test_process_claude_usage_basic() {
610 let mut conversation_usage = FastHashMap::default();
611 let model = "claude-3-sonnet";
612 let usage = json!({
613 "input_tokens": 100,
614 "output_tokens": 50,
615 "cache_read_input_tokens": 200,
616 "cache_creation_input_tokens": 25
617 });
618
619 process_claude_usage(&mut conversation_usage, model, &usage, false);
620
621 let result = conversation_usage.get(model).unwrap();
622 assert_eq!(result["input_tokens"].as_i64().unwrap(), 100);
623 assert_eq!(result["output_tokens"].as_i64().unwrap(), 50);
624 assert_eq!(result["cache_read_input_tokens"].as_i64().unwrap(), 200);
625 assert_eq!(result["cache_creation_input_tokens"].as_i64().unwrap(), 25);
626 }
627
628 #[test]
629 fn test_process_claude_usage_accumulation() {
630 let mut conversation_usage = FastHashMap::default();
631 let model = "claude-3-sonnet";
632
633 let usage1 = json!({
634 "input_tokens": 100,
635 "output_tokens": 50
636 });
637 process_claude_usage(&mut conversation_usage, model, &usage1, false);
638
639 let usage2 = json!({
640 "input_tokens": 75,
641 "output_tokens": 25
642 });
643 process_claude_usage(&mut conversation_usage, model, &usage2, false);
644
645 let result = conversation_usage.get(model).unwrap();
646 assert_eq!(result["input_tokens"].as_i64().unwrap(), 175);
647 assert_eq!(result["output_tokens"].as_i64().unwrap(), 75);
648 }
649
650 #[test]
651 fn test_process_claude_usage_accumulates_server_tool_use() {
652 let mut conversation_usage = FastHashMap::default();
653 let model = "claude-opus-4-8";
654
655 process_claude_usage(
656 &mut conversation_usage,
657 model,
658 &json!({
659 "input_tokens": 10,
660 "server_tool_use": { "web_search_requests": 2, "web_fetch_requests": 1 }
661 }),
662 false,
663 );
664 process_claude_usage(
665 &mut conversation_usage,
666 model,
667 &json!({
668 "input_tokens": 5,
669 "server_tool_use": { "web_search_requests": 3, "web_fetch_requests": 0 }
670 }),
671 false,
672 );
673
674 let stu = conversation_usage.get(model).unwrap()["server_tool_use"]
675 .as_object()
676 .unwrap();
677 assert_eq!(stu["web_search_requests"].as_i64().unwrap(), 5);
678 assert_eq!(stu["web_fetch_requests"].as_i64().unwrap(), 1);
679 }
680
681 #[test]
682 fn test_process_claude_usage_skip_synthetic() {
683 let mut conversation_usage = FastHashMap::default();
684 let model = "claude-3-sonnet<synthetic>";
685 let usage = json!({
686 "input_tokens": 100,
687 "output_tokens": 50
688 });
689
690 process_claude_usage(&mut conversation_usage, model, &usage, false);
691
692 assert!(conversation_usage.is_empty());
693 }
694
695 #[test]
696 fn test_process_codex_usage_basic() {
697 let mut conversation_usage = FastHashMap::default();
698 let model = "gpt-4";
699 let info = json!({
700 "total_token_usage": {
701 "input_tokens": 100,
702 "output_tokens": 50,
703 "cached_input_tokens": 200
704 },
705 "model_context_window": 128000
706 });
707 let total = info["total_token_usage"].as_object().unwrap();
708 let delta = CodexTokenTotals::delta_fields(total, None);
709
710 process_codex_usage(&mut conversation_usage, model, &delta, &info, false);
711
712 let result = conversation_usage.get(model).unwrap();
713 let total_usage = result["total_token_usage"].as_object().unwrap();
714 assert_eq!(total_usage["input_tokens"].as_i64().unwrap(), 100);
715 assert_eq!(total_usage["output_tokens"].as_i64().unwrap(), 50);
716 assert_eq!(total_usage["cached_input_tokens"].as_i64().unwrap(), 200);
717 assert!(!total_usage.contains_key("total_tokens"));
718 assert_eq!(result["model_context_window"].as_i64().unwrap(), 128000);
719 }
720
721 #[test]
722 fn codex_delta_attributes_only_the_increment_to_the_current_model() {
723 let mut conversation_usage = FastHashMap::default();
726
727 let first = json!({
728 "total_token_usage": {
729 "input_tokens": 20_000,
730 "cached_input_tokens": 4_000,
731 "output_tokens": 3_289,
732 "reasoning_output_tokens": 1_000,
733 "total_tokens": 23_289
734 }
735 });
736 let first_total = first["total_token_usage"].as_object().unwrap();
737 let delta = CodexTokenTotals::delta_fields(first_total, None);
738 process_codex_usage(
739 &mut conversation_usage,
740 "gpt-5.6-luna",
741 &delta,
742 &first,
743 false,
744 );
745 let prev = CodexTokenTotals::from_total_object(first_total);
746
747 let second = json!({
748 "total_token_usage": {
749 "input_tokens": 90_000,
750 "cached_input_tokens": 60_000,
751 "output_tokens": 14_576,
752 "reasoning_output_tokens": 5_000,
753 "total_tokens": 104_576
754 }
755 });
756 let second_total = second["total_token_usage"].as_object().unwrap();
757 let delta = CodexTokenTotals::delta_fields(second_total, Some(&prev));
758 process_codex_usage(
759 &mut conversation_usage,
760 "gpt-5.6-sol",
761 &delta,
762 &second,
763 false,
764 );
765
766 let luna = conversation_usage["gpt-5.6-luna"]["total_token_usage"]
767 .as_object()
768 .unwrap();
769 let sol = conversation_usage["gpt-5.6-sol"]["total_token_usage"]
770 .as_object()
771 .unwrap();
772 assert_eq!(luna["total_tokens"].as_i64().unwrap(), 23_289);
773 assert_eq!(sol["total_tokens"].as_i64().unwrap(), 104_576 - 23_289);
774 assert_eq!(sol["input_tokens"].as_i64().unwrap(), 70_000);
775 assert_eq!(sol["cached_input_tokens"].as_i64().unwrap(), 56_000);
776 }
777
778 #[test]
779 fn codex_delta_treats_a_counter_reset_as_a_new_segment() {
780 let total = json!({
783 "input_tokens": 500,
784 "output_tokens": 100,
785 "total_tokens": 600
786 });
787 let prev = CodexTokenTotals {
788 input_tokens: 9_000,
789 cached_input_tokens: 0,
790 output_tokens: 1_000,
791 reasoning_output_tokens: 0,
792 total_tokens: 10_000,
793 };
794 let delta = CodexTokenTotals::delta_fields(total.as_object().unwrap(), Some(&prev));
795 assert_eq!(delta["input_tokens"].as_i64().unwrap(), 500);
796 assert_eq!(delta["output_tokens"].as_i64().unwrap(), 100);
797 assert_eq!(delta["total_tokens"].as_i64().unwrap(), 600);
798 }
799
800 #[test]
801 fn test_process_gemini_usage_basic() {
802 let mut conversation_usage = FastHashMap::default();
803 let model = "gemini-2.0-flash";
804 let tokens = crate::models::GeminiTokens {
807 input: 300,
808 output: 50,
809 cached: 200,
810 thoughts: 10,
811 tool: 5,
812 total: 360,
813 };
814
815 process_gemini_usage(&mut conversation_usage, model, &tokens, false);
816
817 let result = conversation_usage.get(model).unwrap();
818 assert_eq!(
819 result["input_tokens"].as_i64().unwrap(),
820 100,
821 "input must be stored as non-cached (input - cached) to match Claude semantics"
822 );
823 assert_eq!(result["output_tokens"].as_i64().unwrap(), 50);
824 assert_eq!(result["cache_read_input_tokens"].as_i64().unwrap(), 200);
825 assert_eq!(result["thoughts_tokens"].as_i64().unwrap(), 10);
826 assert_eq!(result["tool_tokens"].as_i64().unwrap(), 5);
827 assert_eq!(result["total_tokens"].as_i64().unwrap(), 360);
828 }
829
830 #[test]
831 fn test_process_gemini_usage_no_cache() {
832 let mut conversation_usage = FastHashMap::default();
834 let model = "gemini-2.0-flash";
835 let tokens = crate::models::GeminiTokens {
836 input: 13_906,
837 output: 185,
838 cached: 0,
839 thoughts: 306,
840 tool: 0,
841 total: 14_397,
842 };
843
844 process_gemini_usage(&mut conversation_usage, model, &tokens, false);
845
846 let result = conversation_usage.get(model).unwrap();
847 assert_eq!(result["input_tokens"].as_i64().unwrap(), 13_906);
848 assert_eq!(result["cache_read_input_tokens"].as_i64().unwrap(), 0);
849 }
850}