Skip to main content

imp_core/
usage.rs

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
7/// Session custom entry type used for canonical usage accounting.
8pub const USAGE_CUSTOM_TYPE: &str = "usage-record";
9
10/// Current canonical usage record schema version.
11pub const USAGE_RECORD_VERSION: u32 = 1;
12
13/// Where a usage report came from when reading session history.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum UsageRecordSource {
17    Canonical,
18    LegacyAssistantMessage,
19}
20
21/// Stable request identity used for dedupe across copied/forked session history.
22///
23/// `request_id` is generated once per upstream model request and copied forward
24/// with the canonical record. Global summaries should dedupe on this key so the
25/// same request preserved in multiple session files is only counted once.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct UsageDedupeKey {
28    pub request_id: String,
29}
30
31/// Raw token accounting captured at request time.
32#[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/// Stored dollar cost at request time.
85#[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/// Canonical usage record stored inside `SessionEntry::Custom`.
143///
144/// This schema is intentionally small and versioned. It captures stable request
145/// identity, attribution for reporting, raw tokens, and stored cost so later
146/// reporting doesn't need to recompute historical values from possibly changed
147/// model pricing tables.
148#[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    /// Stable dedupe identity for global usage rollups.
190    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/// Usage row returned by read helpers, including provenance and attribution.
235#[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/// Aggregate totals across usage records.
269#[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
286/// Build a canonical usage record for a persisted assistant turn.
287///
288/// Returns `None` when the assistant message has no usage or when an equivalent
289/// canonical record already exists for the same assistant turn.
290pub 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
306/// Build a canonical usage record for a persisted assistant turn from model metadata.
307pub 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
341/// Read usage rows from a single session entry slice.
342///
343/// Canonical custom records are preferred. Legacy assistant-message usage is
344/// only surfaced when no canonical usage record exists for the same
345/// `assistant_message_id`, which preserves backward compatibility while
346/// avoiding local double counting once canonical persistence lands.
347pub 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
399/// Read usage rows from a session manager, attaching the session path when known.
400pub 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
457/// Return a deduped record set while preserving a stable canonical ordering.
458///
459/// Records are sorted so canonical rows win over legacy fallbacks, then the
460/// earliest observation of a request is kept. This gives downstream reporting a
461/// deterministic per-request representative row for grouping by model, day, or
462/// session.
463pub 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
474/// Sum usage rows without dedupe.
475pub 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
483/// Sum usage rows while deduping copied/forked history by stable request id.
484pub 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
509/// Build a canonical session custom entry for persistence.
510pub 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}