1use imp_llm::{model::ModelMeta, AssistantMessage, Cost, Message, Model, Usage};
2use serde::{Deserialize, Serialize};
3
4use crate::error::{Error, Result};
5use crate::session::{SessionEntry, SessionManager};
6
7pub const USAGE_CUSTOM_TYPE: &str = "usage-record";
9
10pub const USAGE_RECORD_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum UsageRecordSource {
17 Canonical,
18 LegacyAssistantMessage,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct UsageDedupeKey {
28 pub request_id: String,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub struct UsageTokens {
34 pub input: u32,
35 pub output: u32,
36 pub cache_read: u32,
37 pub cache_write: u32,
38}
39
40impl From<Usage> for UsageTokens {
41 fn from(value: Usage) -> Self {
42 Self {
43 input: value.input_tokens,
44 output: value.output_tokens,
45 cache_read: value.cache_read_tokens,
46 cache_write: value.cache_write_tokens,
47 }
48 }
49}
50
51impl From<&Usage> for UsageTokens {
52 fn from(value: &Usage) -> Self {
53 Self {
54 input: value.input_tokens,
55 output: value.output_tokens,
56 cache_read: value.cache_read_tokens,
57 cache_write: value.cache_write_tokens,
58 }
59 }
60}
61
62impl From<UsageTokens> for Usage {
63 fn from(value: UsageTokens) -> Self {
64 Self {
65 input_tokens: value.input,
66 output_tokens: value.output,
67 cache_read_tokens: value.cache_read,
68 cache_write_tokens: value.cache_write,
69 }
70 }
71}
72
73impl From<&UsageTokens> for Usage {
74 fn from(value: &UsageTokens) -> Self {
75 Self {
76 input_tokens: value.input,
77 output_tokens: value.output,
78 cache_read_tokens: value.cache_read,
79 cache_write_tokens: value.cache_write,
80 }
81 }
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
86pub struct UsageCostBreakdown {
87 pub input: f64,
88 pub output: f64,
89 pub cache_read: f64,
90 pub cache_write: f64,
91 pub total: f64,
92}
93
94impl From<Cost> for UsageCostBreakdown {
95 fn from(value: Cost) -> Self {
96 Self {
97 input: value.input,
98 output: value.output,
99 cache_read: value.cache_read,
100 cache_write: value.cache_write,
101 total: value.total,
102 }
103 }
104}
105
106impl From<&Cost> for UsageCostBreakdown {
107 fn from(value: &Cost) -> Self {
108 Self {
109 input: value.input,
110 output: value.output,
111 cache_read: value.cache_read,
112 cache_write: value.cache_write,
113 total: value.total,
114 }
115 }
116}
117
118impl From<UsageCostBreakdown> for Cost {
119 fn from(value: UsageCostBreakdown) -> Self {
120 Self {
121 input: value.input,
122 output: value.output,
123 cache_read: value.cache_read,
124 cache_write: value.cache_write,
125 total: value.total,
126 }
127 }
128}
129
130impl From<&UsageCostBreakdown> for Cost {
131 fn from(value: &UsageCostBreakdown) -> Self {
132 Self {
133 input: value.input,
134 output: value.output,
135 cache_read: value.cache_read,
136 cache_write: value.cache_write,
137 total: value.total,
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct UsageRecordV1 {
150 pub version: u32,
151 pub request_id: String,
152 pub recorded_at: u64,
153 pub provider: String,
154 pub model: String,
155 pub session_id: Option<String>,
156 pub session_path: Option<String>,
157 pub assistant_message_id: Option<String>,
158 pub turn_index: Option<u32>,
159 pub usage: UsageTokens,
160 pub cost: UsageCostBreakdown,
161 pub source: UsageRecordSource,
162}
163
164impl UsageRecordV1 {
165 pub fn new(
166 request_id: impl Into<String>,
167 recorded_at: u64,
168 provider: impl Into<String>,
169 model: impl Into<String>,
170 usage: impl Into<UsageTokens>,
171 cost: impl Into<UsageCostBreakdown>,
172 ) -> Self {
173 Self {
174 version: USAGE_RECORD_VERSION,
175 request_id: request_id.into(),
176 recorded_at,
177 provider: provider.into(),
178 model: model.into(),
179 session_id: None,
180 session_path: None,
181 assistant_message_id: None,
182 turn_index: None,
183 usage: usage.into(),
184 cost: cost.into(),
185 source: UsageRecordSource::Canonical,
186 }
187 }
188
189 pub fn dedupe_key(&self) -> UsageDedupeKey {
191 UsageDedupeKey {
192 request_id: self.request_id.clone(),
193 }
194 }
195
196 pub fn usage_value(&self) -> Usage {
197 Usage::from(&self.usage)
198 }
199
200 pub fn cost_value(&self) -> Cost {
201 Cost::from(&self.cost)
202 }
203
204 pub fn with_session_context(
205 mut self,
206 session_id: Option<String>,
207 session_path: Option<String>,
208 assistant_message_id: Option<String>,
209 turn_index: Option<u32>,
210 ) -> Self {
211 self.session_id = session_id;
212 self.session_path = session_path;
213 self.assistant_message_id = assistant_message_id;
214 self.turn_index = turn_index;
215 self
216 }
217
218 pub fn into_custom_data(self) -> Result<serde_json::Value> {
219 serde_json::to_value(self).map_err(Into::into)
220 }
221
222 pub fn from_custom_data(value: serde_json::Value) -> Result<Self> {
223 let record: Self = serde_json::from_value(value)?;
224 if record.version != USAGE_RECORD_VERSION {
225 return Err(Error::Session(format!(
226 "unsupported usage record version: {}",
227 record.version
228 )));
229 }
230 Ok(record)
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct SessionUsageRecord {
237 pub entry_id: String,
238 pub parent_id: Option<String>,
239 pub request_id: String,
240 pub recorded_at: u64,
241 pub provider: Option<String>,
242 pub model: Option<String>,
243 pub session_id: Option<String>,
244 pub session_path: Option<String>,
245 pub assistant_message_id: Option<String>,
246 pub turn_index: Option<u32>,
247 pub usage: UsageTokens,
248 pub cost: Option<UsageCostBreakdown>,
249 pub source: UsageRecordSource,
250}
251
252impl SessionUsageRecord {
253 pub fn dedupe_key(&self) -> UsageDedupeKey {
254 UsageDedupeKey {
255 request_id: self.request_id.clone(),
256 }
257 }
258
259 pub fn usage_value(&self) -> Usage {
260 Usage::from(&self.usage)
261 }
262
263 pub fn cost_value(&self) -> Option<Cost> {
264 self.cost.as_ref().map(Cost::from)
265 }
266}
267
268#[derive(Debug, Clone, Default, PartialEq)]
270pub struct UsageTotals {
271 pub usage: Usage,
272 pub cost: Cost,
273 pub records: usize,
274}
275
276impl UsageTotals {
277 pub fn add_record(&mut self, record: &SessionUsageRecord) {
278 self.usage.add(&record.usage_value());
279 if let Some(cost) = record.cost_value() {
280 self.cost.add(&cost);
281 }
282 self.records += 1;
283 }
284}
285
286pub fn canonical_usage_record_for_assistant_turn(
291 session: &SessionManager,
292 model: &Model,
293 assistant_message_id: &str,
294 turn_index: u32,
295 message: &AssistantMessage,
296) -> Option<UsageRecordV1> {
297 canonical_usage_record_for_assistant_turn_with_model_meta(
298 session,
299 &model.meta,
300 assistant_message_id,
301 turn_index,
302 message,
303 )
304}
305
306pub fn canonical_usage_record_for_assistant_turn_with_model_meta(
308 session: &SessionManager,
309 model_meta: &ModelMeta,
310 assistant_message_id: &str,
311 turn_index: u32,
312 message: &AssistantMessage,
313) -> Option<UsageRecordV1> {
314 let usage = message.usage.as_ref()?;
315 let request_id = canonical_request_id(assistant_message_id);
316
317 if session.has_canonical_usage_request_id(&request_id)
318 || session.has_canonical_usage_for_assistant_message(assistant_message_id)
319 {
320 return None;
321 }
322
323 Some(
324 UsageRecordV1::new(
325 request_id,
326 message.timestamp,
327 model_meta.provider.clone(),
328 model_meta.id.clone(),
329 usage,
330 usage.cost(&model_meta.pricing),
331 )
332 .with_session_context(
333 session.session_id(),
334 session.path().map(|p| p.display().to_string()),
335 Some(assistant_message_id.to_string()),
336 Some(turn_index),
337 ),
338 )
339}
340
341pub fn usage_records_from_entries(entries: &[SessionEntry]) -> Vec<SessionUsageRecord> {
348 let session_id = infer_session_id_from_entries(entries);
349 let session_path = infer_session_path_from_entries(entries);
350
351 let mut records = Vec::new();
352 let mut canonical_assistant_ids = std::collections::HashSet::new();
353
354 for entry in entries {
355 if let Some(record) =
356 canonical_usage_record_from_entry(entry, session_id.clone(), session_path.clone())
357 {
358 if let Some(assistant_message_id) = record.assistant_message_id.clone() {
359 canonical_assistant_ids.insert(assistant_message_id);
360 }
361 records.push(record);
362 }
363 }
364
365 let mut turn_index = 0u32;
366 for entry in entries {
367 if let SessionEntry::Message {
368 id,
369 parent_id,
370 message: Message::Assistant(message),
371 } = entry
372 {
373 if let Some(usage) = &message.usage {
374 if !canonical_assistant_ids.contains(id) {
375 records.push(SessionUsageRecord {
376 entry_id: id.clone(),
377 parent_id: parent_id.clone(),
378 request_id: legacy_request_id(id),
379 recorded_at: message.timestamp,
380 provider: None,
381 model: None,
382 session_id: session_id.clone(),
383 session_path: session_path.clone(),
384 assistant_message_id: Some(id.clone()),
385 turn_index: Some(turn_index),
386 usage: UsageTokens::from(usage),
387 cost: None,
388 source: UsageRecordSource::LegacyAssistantMessage,
389 });
390 }
391 turn_index += 1;
392 }
393 }
394 }
395
396 records
397}
398
399pub fn usage_records_from_session(session: &SessionManager) -> Vec<SessionUsageRecord> {
401 let session_id = session
402 .session_id()
403 .or_else(|| infer_session_id_from_entries(session.entries()));
404 let session_path = session
405 .path()
406 .map(|p| p.display().to_string())
407 .or_else(|| infer_session_path_from_entries(session.entries()));
408
409 let mut records = Vec::new();
410 let mut canonical_assistant_ids = std::collections::HashSet::new();
411
412 for entry in session.entries() {
413 if let Some(record) =
414 canonical_usage_record_from_entry(entry, session_id.clone(), session_path.clone())
415 {
416 if let Some(assistant_message_id) = record.assistant_message_id.clone() {
417 canonical_assistant_ids.insert(assistant_message_id);
418 }
419 records.push(record);
420 }
421 }
422
423 let mut turn_index = 0u32;
424 for entry in session.entries() {
425 if let SessionEntry::Message {
426 id,
427 parent_id,
428 message: Message::Assistant(message),
429 } = entry
430 {
431 if let Some(usage) = &message.usage {
432 if !canonical_assistant_ids.contains(id) {
433 records.push(SessionUsageRecord {
434 entry_id: id.clone(),
435 parent_id: parent_id.clone(),
436 request_id: legacy_request_id(id),
437 recorded_at: message.timestamp,
438 provider: None,
439 model: None,
440 session_id: session_id.clone(),
441 session_path: session_path.clone(),
442 assistant_message_id: Some(id.clone()),
443 turn_index: Some(turn_index),
444 usage: UsageTokens::from(usage),
445 cost: None,
446 source: UsageRecordSource::LegacyAssistantMessage,
447 });
448 }
449 turn_index += 1;
450 }
451 }
452 }
453
454 records
455}
456
457pub fn dedupe_usage_records(records: &[SessionUsageRecord]) -> Vec<SessionUsageRecord> {
464 let mut sorted = records.to_vec();
465 sorted.sort_by(usage_record_preference);
466
467 let mut seen = std::collections::HashSet::new();
468 sorted
469 .into_iter()
470 .filter(|record| seen.insert(record.dedupe_key()))
471 .collect()
472}
473
474pub fn aggregate_usage(records: &[SessionUsageRecord]) -> UsageTotals {
476 let mut totals = UsageTotals::default();
477 for record in records {
478 totals.add_record(record);
479 }
480 totals
481}
482
483pub fn aggregate_usage_deduped(records: &[SessionUsageRecord]) -> UsageTotals {
485 let deduped = dedupe_usage_records(records);
486 aggregate_usage(&deduped)
487}
488
489fn usage_record_preference(a: &SessionUsageRecord, b: &SessionUsageRecord) -> std::cmp::Ordering {
490 use std::cmp::Ordering;
491
492 usage_source_rank(a.source)
493 .cmp(&usage_source_rank(b.source))
494 .then_with(|| a.recorded_at.cmp(&b.recorded_at))
495 .then_with(|| a.session_id.cmp(&b.session_id))
496 .then_with(|| a.session_path.cmp(&b.session_path))
497 .then_with(|| a.assistant_message_id.cmp(&b.assistant_message_id))
498 .then_with(|| a.entry_id.cmp(&b.entry_id))
499 .then(Ordering::Equal)
500}
501
502fn usage_source_rank(source: UsageRecordSource) -> u8 {
503 match source {
504 UsageRecordSource::Canonical => 0,
505 UsageRecordSource::LegacyAssistantMessage => 1,
506 }
507}
508
509pub fn usage_record_entry(
511 entry_id: impl Into<String>,
512 record: UsageRecordV1,
513) -> Result<SessionEntry> {
514 Ok(SessionEntry::Custom {
515 id: entry_id.into(),
516 parent_id: None,
517 custom_type: USAGE_CUSTOM_TYPE.to_string(),
518 data: record.into_custom_data()?,
519 })
520}
521
522fn canonical_usage_record_from_entry(
523 entry: &SessionEntry,
524 fallback_session_id: Option<String>,
525 fallback_session_path: Option<String>,
526) -> Option<SessionUsageRecord> {
527 let SessionEntry::Custom {
528 id,
529 parent_id,
530 custom_type,
531 data,
532 } = entry
533 else {
534 return None;
535 };
536
537 if custom_type != USAGE_CUSTOM_TYPE {
538 return None;
539 }
540
541 let record = UsageRecordV1::from_custom_data(data.clone()).ok()?;
542 Some(SessionUsageRecord {
543 entry_id: id.clone(),
544 parent_id: parent_id.clone(),
545 request_id: record.request_id,
546 recorded_at: record.recorded_at,
547 provider: Some(record.provider),
548 model: Some(record.model),
549 session_id: record.session_id.or(fallback_session_id),
550 session_path: record.session_path.or(fallback_session_path),
551 assistant_message_id: record.assistant_message_id,
552 turn_index: record.turn_index,
553 usage: record.usage,
554 cost: Some(record.cost),
555 source: record.source,
556 })
557}
558
559fn infer_session_id_from_entries(entries: &[SessionEntry]) -> Option<String> {
560 entries.iter().find_map(|entry| {
561 let SessionEntry::Custom {
562 custom_type, data, ..
563 } = entry
564 else {
565 return None;
566 };
567
568 if custom_type != USAGE_CUSTOM_TYPE {
569 return None;
570 }
571
572 UsageRecordV1::from_custom_data(data.clone())
573 .ok()
574 .and_then(|record| record.session_id)
575 })
576}
577
578fn infer_session_path_from_entries(entries: &[SessionEntry]) -> Option<String> {
579 entries.iter().find_map(|entry| {
580 let SessionEntry::Custom {
581 custom_type, data, ..
582 } = entry
583 else {
584 return None;
585 };
586
587 if custom_type != USAGE_CUSTOM_TYPE {
588 return None;
589 }
590
591 UsageRecordV1::from_custom_data(data.clone())
592 .ok()
593 .and_then(|record| record.session_path)
594 })
595}
596
597fn canonical_request_id(assistant_message_id: &str) -> String {
598 format!("assistant:{assistant_message_id}")
599}
600
601fn legacy_request_id(assistant_message_id: &str) -> String {
602 format!("legacy-assistant:{assistant_message_id}")
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::session::SessionEntry;
609 use imp_llm::{AssistantMessage, ContentBlock, StopReason};
610
611 fn assistant_message(timestamp: u64, usage: Option<Usage>) -> Message {
612 Message::Assistant(AssistantMessage {
613 content: vec![ContentBlock::Text {
614 text: "done".into(),
615 }],
616 usage,
617 stop_reason: StopReason::EndTurn,
618 timestamp,
619 })
620 }
621
622 fn legacy_assistant_entry(id: &str, timestamp: u64, usage: Usage) -> SessionEntry {
623 SessionEntry::Message {
624 id: id.to_string(),
625 parent_id: None,
626 message: assistant_message(timestamp, Some(usage)),
627 }
628 }
629
630 fn canonical_entry(
631 entry_id: &str,
632 request_id: &str,
633 assistant_message_id: Option<&str>,
634 session_id: Option<&str>,
635 usage: Usage,
636 cost: Cost,
637 ) -> SessionEntry {
638 usage_record_entry(
639 entry_id,
640 UsageRecordV1::new(
641 request_id,
642 123,
643 "anthropic",
644 "claude-3-7-sonnet",
645 usage,
646 cost,
647 )
648 .with_session_context(
649 session_id.map(str::to_string),
650 Some("/tmp/session.jsonl".into()),
651 assistant_message_id.map(str::to_string),
652 Some(2),
653 ),
654 )
655 .unwrap()
656 }
657
658 #[test]
659 fn canonical_usage_record_round_trips_through_custom_entry() {
660 let entry = canonical_entry(
661 "entry-1",
662 "req-1",
663 Some("assistant-1"),
664 Some("session-1"),
665 Usage {
666 input_tokens: 100,
667 output_tokens: 20,
668 cache_read_tokens: 5,
669 cache_write_tokens: 2,
670 },
671 Cost {
672 input: 1.0,
673 output: 2.0,
674 cache_read: 0.1,
675 cache_write: 0.2,
676 total: 3.3,
677 },
678 );
679
680 let records = usage_records_from_entries(&[entry]);
681 assert_eq!(records.len(), 1);
682 let record = &records[0];
683 assert_eq!(record.request_id, "req-1");
684 assert_eq!(record.provider.as_deref(), Some("anthropic"));
685 assert_eq!(record.model.as_deref(), Some("claude-3-7-sonnet"));
686 assert_eq!(record.assistant_message_id.as_deref(), Some("assistant-1"));
687 assert_eq!(record.turn_index, Some(2));
688 assert_eq!(record.source, UsageRecordSource::Canonical);
689 assert_eq!(record.usage.input, 100);
690 assert_eq!(record.cost.as_ref().unwrap().total, 3.3);
691 }
692
693 #[test]
694 fn usage_reader_falls_back_to_legacy_assistant_usage() {
695 let entries = vec![legacy_assistant_entry(
696 "assistant-legacy",
697 456,
698 Usage {
699 input_tokens: 50,
700 output_tokens: 10,
701 cache_read_tokens: 3,
702 cache_write_tokens: 0,
703 },
704 )];
705
706 let records = usage_records_from_entries(&entries);
707 assert_eq!(records.len(), 1);
708 let record = &records[0];
709 assert_eq!(record.request_id, "legacy-assistant:assistant-legacy");
710 assert_eq!(record.recorded_at, 456);
711 assert_eq!(record.source, UsageRecordSource::LegacyAssistantMessage);
712 assert_eq!(record.provider, None);
713 assert_eq!(record.model, None);
714 assert_eq!(record.cost, None);
715 assert_eq!(record.turn_index, Some(0));
716 }
717
718 #[test]
719 fn canonical_record_suppresses_legacy_fallback_for_same_assistant_message() {
720 let usage = Usage {
721 input_tokens: 80,
722 output_tokens: 12,
723 cache_read_tokens: 4,
724 cache_write_tokens: 1,
725 };
726 let entries = vec![
727 legacy_assistant_entry("assistant-1", 100, usage.clone()),
728 canonical_entry(
729 "usage-1",
730 "req-1",
731 Some("assistant-1"),
732 Some("session-1"),
733 usage,
734 Cost {
735 input: 0.8,
736 output: 0.12,
737 cache_read: 0.04,
738 cache_write: 0.01,
739 total: 0.97,
740 },
741 ),
742 ];
743
744 let records = usage_records_from_entries(&entries);
745 assert_eq!(records.len(), 1);
746 assert_eq!(records[0].source, UsageRecordSource::Canonical);
747 assert_eq!(records[0].request_id, "req-1");
748 }
749
750 #[test]
751 fn aggregate_usage_dedupes_forked_history_by_request_id() {
752 let usage = Usage {
753 input_tokens: 100,
754 output_tokens: 25,
755 cache_read_tokens: 10,
756 cache_write_tokens: 5,
757 };
758 let cost = Cost {
759 input: 1.0,
760 output: 2.0,
761 cache_read: 0.3,
762 cache_write: 0.4,
763 total: 3.7,
764 };
765 let original = usage_records_from_entries(&[canonical_entry(
766 "usage-original",
767 "req-shared",
768 Some("assistant-1"),
769 Some("session-a"),
770 usage.clone(),
771 cost.clone(),
772 )]);
773 let forked = usage_records_from_entries(&[canonical_entry(
774 "usage-fork",
775 "req-shared",
776 Some("assistant-1"),
777 Some("session-b"),
778 usage,
779 cost,
780 )]);
781
782 let mut all = Vec::new();
783 all.extend(original);
784 all.extend(forked);
785
786 let raw = aggregate_usage(&all);
787 assert_eq!(raw.records, 2);
788 assert_eq!(raw.usage.input_tokens, 200);
789
790 let deduped = aggregate_usage_deduped(&all);
791 assert_eq!(deduped.records, 1);
792 assert_eq!(deduped.usage.input_tokens, 100);
793 assert_eq!(deduped.usage.output_tokens, 25);
794 assert!((deduped.cost.total - 3.7).abs() < f64::EPSILON);
795 }
796
797 #[test]
798 fn dedupe_usage_records_keeps_earliest_duplicate_row() {
799 let usage = Usage {
800 input_tokens: 100,
801 output_tokens: 25,
802 cache_read_tokens: 10,
803 cache_write_tokens: 5,
804 };
805 let cost = Cost {
806 input: 1.0,
807 output: 2.0,
808 cache_read: 0.3,
809 cache_write: 0.4,
810 total: 3.7,
811 };
812
813 let records = vec![
814 SessionUsageRecord {
815 entry_id: "late".into(),
816 parent_id: None,
817 request_id: "req-shared".into(),
818 recorded_at: 200,
819 provider: Some("anthropic".into()),
820 model: Some("claude-3-7-sonnet".into()),
821 session_id: Some("session-b".into()),
822 session_path: Some("/tmp/b.jsonl".into()),
823 assistant_message_id: Some("assistant-1".into()),
824 turn_index: Some(0),
825 usage: UsageTokens::from(usage.clone()),
826 cost: Some(UsageCostBreakdown::from(cost.clone())),
827 source: UsageRecordSource::Canonical,
828 },
829 SessionUsageRecord {
830 entry_id: "early".into(),
831 parent_id: None,
832 request_id: "req-shared".into(),
833 recorded_at: 100,
834 provider: Some("anthropic".into()),
835 model: Some("claude-3-7-sonnet".into()),
836 session_id: Some("session-a".into()),
837 session_path: Some("/tmp/a.jsonl".into()),
838 assistant_message_id: Some("assistant-1".into()),
839 turn_index: Some(0),
840 usage: UsageTokens::from(usage),
841 cost: Some(UsageCostBreakdown::from(cost)),
842 source: UsageRecordSource::Canonical,
843 },
844 ];
845
846 let deduped = dedupe_usage_records(&records);
847 assert_eq!(deduped.len(), 1);
848 assert_eq!(deduped[0].entry_id, "early");
849 assert_eq!(deduped[0].session_id.as_deref(), Some("session-a"));
850 }
851
852 #[test]
853 fn aggregate_usage_keeps_distinct_legacy_records() {
854 let records = usage_records_from_entries(&[
855 legacy_assistant_entry(
856 "assistant-1",
857 100,
858 Usage {
859 input_tokens: 10,
860 output_tokens: 2,
861 cache_read_tokens: 0,
862 cache_write_tokens: 0,
863 },
864 ),
865 legacy_assistant_entry(
866 "assistant-2",
867 200,
868 Usage {
869 input_tokens: 20,
870 output_tokens: 4,
871 cache_read_tokens: 0,
872 cache_write_tokens: 0,
873 },
874 ),
875 ]);
876
877 let totals = aggregate_usage_deduped(&records);
878 assert_eq!(totals.records, 2);
879 assert_eq!(totals.usage.input_tokens, 30);
880 assert_eq!(totals.usage.output_tokens, 6);
881 }
882}