1use crate::config::ProvidersConfig;
12use crate::constants::{FastHashMap, FastHashSet, capacity};
13use crate::models::TimeRange;
14use crate::models::{
15 CodeAnalysis, ExtensionType, PerProviderUsage, Provider, ProviderActiveDays, UsageResult,
16};
17use crate::pricing::TierThresholds;
18use crate::session::cursor::{
19 discover_cursor_store_dbs, load_conversation_model_snapshot, read_cursor_usage_store,
20};
21use crate::session::diagnostics::DatabaseUsageRead;
22use crate::session::hermes::read_hermes_usage_contributions;
23use crate::session::opencode::read_opencode_usage_contributions;
24use crate::session::sqlite::is_cacheable_sqlite_failure;
25use crate::session::{
26 ParseMode, parse_session_file_typed_as, read_cursor_usage, read_hermes_usage,
27 read_opencode_usage,
28};
29use crate::summary_cache::{
30 CompactSourceSummary, SourceFingerprint, SummaryCacheKey, SummaryKind, SummaryScanCache,
31};
32use crate::utils::directory::collect_provider_files_diagnostics;
33use crate::utils::{
34 COPILOT_SESSION_MAX_DEPTH, GROK_SESSION_MAX_DEPTH, HelperPaths, is_claude_session_file,
35 is_codex_session_file, is_copilot_session_file, is_gemini_session_file, is_grok_session_file,
36 merge_usage_values, resolve_paths,
37};
38use anyhow::Result;
39use rayon::prelude::*;
40use serde::Serialize;
41use serde_json::Value;
42use std::collections::HashSet;
43use std::path::Path;
44use std::sync::Arc;
45
46#[derive(Debug, Clone, Serialize)]
68pub struct UsageData {
69 pub models: UsageResult,
74 pub per_provider: PerProviderUsage,
81 pub provider_days: ProviderActiveDays,
84 pub stored_costs: StoredCosts,
86}
87
88pub use crate::scan::{ScanDiagnostics, ScanFailure};
91
92pub struct UsageCollection {
94 pub data: UsageData,
96 pub diagnostics: ScanDiagnostics,
98}
99
100#[derive(Debug, Default, Clone, Serialize)]
107pub struct StoredCosts {
108 pub opencode: FastHashMap<String, f64>,
110 pub cursor: FastHashMap<String, f64>,
112 pub hermes: FastHashMap<String, f64>,
114}
115
116fn extract_conversation_usage_from_analysis(analysis: CodeAnalysis) -> FastHashMap<String, Value> {
122 let mut conversation_usage = FastHashMap::with_capacity(capacity::MODELS_PER_SESSION);
123
124 let mut merge_into = |model: String, usage: Value| {
125 conversation_usage
126 .entry(model)
127 .and_modify(|existing_usage| merge_usage_values(existing_usage, &usage))
128 .or_insert(usage);
129 };
130
131 for record in analysis.records {
132 for (model, usage) in record.conversation_usage {
133 merge_into(model, usage);
134 }
135 for (model, usage) in record.advisor_usage {
139 merge_into(model, usage);
140 }
141 }
142
143 conversation_usage
144}
145
146pub fn aggregate_usage_from_home(time_range: TimeRange) -> Result<UsageData> {
173 aggregate_usage_from_home_with_providers(time_range, ProvidersConfig::default())
174}
175
176pub fn aggregate_usage_from_home_with_providers(
179 time_range: TimeRange,
180 providers: ProvidersConfig,
181) -> Result<UsageData> {
182 aggregate_usage_from_paths_with_providers(&resolve_paths()?, time_range, providers)
183}
184
185pub fn aggregate_usage_from_paths(paths: &HelperPaths, time_range: TimeRange) -> Result<UsageData> {
199 aggregate_usage_from_paths_with_providers(paths, time_range, ProvidersConfig::default())
200}
201
202pub fn aggregate_usage_from_paths_with_providers(
205 paths: &HelperPaths,
206 time_range: TimeRange,
207 providers: ProvidersConfig,
208) -> Result<UsageData> {
209 let mut result = FastHashMap::with_capacity(capacity::MODEL_COMBINATIONS);
210 let mut per_provider = PerProviderUsage::default();
211 let mut stored_costs = StoredCosts::default();
212
213 let mut claude_dates: HashSet<String> = HashSet::new();
214 let mut codex_dates: HashSet<String> = HashSet::new();
215 let mut copilot_dates: HashSet<String> = HashSet::new();
216 let mut gemini_dates: HashSet<String> = HashSet::new();
217 let mut grok_dates: HashSet<String> = HashSet::new();
218 let mut opencode_dates: HashSet<String> = HashSet::new();
219 let mut cursor_dates: HashSet<String> = HashSet::new();
220 let mut hermes_dates: HashSet<String> = HashSet::new();
221
222 if providers.claude && paths.claude_session_dir.exists() {
223 process_usage_directory(
226 &[paths.claude_session_dir.as_path()],
227 ExtensionType::ClaudeCode,
228 &mut result,
229 &mut per_provider.claude,
230 &mut claude_dates,
231 is_claude_session_file,
232 time_range,
233 None,
234 );
235 }
236
237 let codex_dirs = paths.codex_session_dirs();
238 if providers.codex && codex_dirs.iter().any(|dir| dir.exists()) {
239 process_usage_directory(
240 &codex_dirs,
241 ExtensionType::Codex,
242 &mut result,
243 &mut per_provider.codex,
244 &mut codex_dates,
245 is_codex_session_file,
246 time_range,
247 None,
248 );
249 }
250
251 if providers.copilot && paths.copilot_session_dir.exists() {
252 process_usage_directory(
258 &[paths.copilot_session_dir.as_path()],
259 ExtensionType::Copilot,
260 &mut result,
261 &mut per_provider.copilot,
262 &mut copilot_dates,
263 is_copilot_session_file,
264 time_range,
265 Some(COPILOT_SESSION_MAX_DEPTH),
266 );
267 }
268
269 if providers.gemini && paths.gemini_session_dir.exists() {
270 process_usage_directory(
271 &[paths.gemini_session_dir.as_path()],
272 ExtensionType::Gemini,
273 &mut result,
274 &mut per_provider.gemini,
275 &mut gemini_dates,
276 is_gemini_session_file,
277 time_range,
278 None,
279 );
280 }
281
282 if providers.grok && paths.grok_session_dir.exists() {
283 process_usage_directory(
284 &[paths.grok_session_dir.as_path()],
285 ExtensionType::Grok,
286 &mut result,
287 &mut per_provider.grok,
288 &mut grok_dates,
289 is_grok_session_file,
290 time_range,
291 Some(GROK_SESSION_MAX_DEPTH),
292 );
293 }
294
295 if providers.opencode
298 && paths.opencode_db.exists()
299 && let Err(err) = process_opencode_usage(
300 &paths.opencode_db,
301 &mut result,
302 &mut per_provider.opencode,
303 &mut stored_costs.opencode,
304 &mut opencode_dates,
305 time_range,
306 )
307 {
308 log::warn!(
309 "failed to read OpenCode DB {}: {err}",
310 paths.opencode_db.display()
311 );
312 }
313
314 if providers.cursor
318 && paths.cursor_chats_dir.exists()
319 && let Err(err) = process_cursor_usage(
320 &paths.cursor_chats_dir,
321 &paths.cursor_tracking_db,
322 &mut result,
323 &mut per_provider.cursor,
324 &mut stored_costs.cursor,
325 &mut cursor_dates,
326 time_range,
327 )
328 {
329 log::warn!("failed to read Cursor usage: {err}");
330 }
331
332 if providers.hermes
334 && paths.hermes_db.exists()
335 && let Err(err) = process_hermes_usage(
336 &paths.hermes_db,
337 &mut result,
338 &mut per_provider.hermes,
339 &mut stored_costs.hermes,
340 &mut hermes_dates,
341 time_range,
342 )
343 {
344 log::warn!(
345 "failed to read Hermes DB {}: {err}",
346 paths.hermes_db.display()
347 );
348 }
349
350 let mut all_dates: HashSet<&String> = HashSet::new();
351 all_dates.extend(claude_dates.iter());
352 all_dates.extend(codex_dates.iter());
353 all_dates.extend(copilot_dates.iter());
354 all_dates.extend(gemini_dates.iter());
355 all_dates.extend(grok_dates.iter());
356 all_dates.extend(opencode_dates.iter());
357 all_dates.extend(cursor_dates.iter());
358 all_dates.extend(hermes_dates.iter());
359
360 let provider_days = ProviderActiveDays {
361 claude: claude_dates.len(),
362 codex: codex_dates.len(),
363 copilot: copilot_dates.len(),
364 gemini: gemini_dates.len(),
365 grok: grok_dates.len(),
366 opencode: opencode_dates.len(),
367 cursor: cursor_dates.len(),
368 hermes: hermes_dates.len(),
369 total: all_dates.len(),
370 };
371
372 Ok(UsageData {
373 models: result,
374 per_provider,
375 provider_days,
376 stored_costs,
377 })
378}
379
380#[derive(Debug, Default, Clone)]
386pub struct UsageScanOptions {
387 pub tiers: Option<Arc<TierThresholds>>,
389}
390
391pub fn aggregate_usage_from_home_with_diagnostics(
393 time_range: TimeRange,
394 providers: ProvidersConfig,
395) -> Result<UsageCollection> {
396 aggregate_usage_from_home_with_diagnostics_opts(
397 time_range,
398 providers,
399 &UsageScanOptions::default(),
400 )
401}
402
403pub fn aggregate_usage_from_home_with_diagnostics_opts(
405 time_range: TimeRange,
406 providers: ProvidersConfig,
407 options: &UsageScanOptions,
408) -> Result<UsageCollection> {
409 let mut cache = SummaryScanCache::new();
410 aggregate_usage_from_paths_with_cache_opts(
411 &resolve_paths()?,
412 time_range,
413 providers,
414 &mut cache,
415 options,
416 )
417}
418
419pub fn aggregate_usage_from_paths_with_diagnostics(
421 paths: &HelperPaths,
422 time_range: TimeRange,
423 providers: ProvidersConfig,
424) -> Result<UsageCollection> {
425 let mut cache = SummaryScanCache::new();
426 aggregate_usage_from_paths_with_cache(paths, time_range, providers, &mut cache)
427}
428
429pub fn aggregate_usage_from_paths_with_cache(
435 paths: &HelperPaths,
436 time_range: TimeRange,
437 providers: ProvidersConfig,
438 cache: &mut SummaryScanCache,
439) -> Result<UsageCollection> {
440 aggregate_usage_from_paths_with_cache_opts(
441 paths,
442 time_range,
443 providers,
444 cache,
445 &UsageScanOptions::default(),
446 )
447}
448
449pub fn aggregate_usage_from_paths_with_cache_opts(
451 paths: &HelperPaths,
452 time_range: TimeRange,
453 providers: ProvidersConfig,
454 cache: &mut SummaryScanCache,
455 options: &UsageScanOptions,
456) -> Result<UsageCollection> {
457 aggregate_usage_from_paths_with_cache_inner(paths, time_range, providers, cache, options)
458}
459
460fn aggregate_usage_from_paths_with_cache_inner(
461 paths: &HelperPaths,
462 time_range: TimeRange,
463 providers: ProvidersConfig,
464 cache: &mut SummaryScanCache,
465 options: &UsageScanOptions,
466) -> Result<UsageCollection> {
467 let tiers = options.tiers.as_deref();
470 cache.ensure_tier_fingerprint(tiers.map_or(0, TierThresholds::fingerprint));
471 cache.begin_scan();
472 let mut accumulator = UsageAccumulator::default();
473 let mut diagnostics = ScanDiagnostics::default();
474 let mut seen = FastHashSet::default();
475
476 crate::scan::scan_all_cached_files(
477 paths,
478 providers,
479 time_range,
480 cache,
481 &mut seen,
482 &mut accumulator,
483 &mut diagnostics,
484 tiers,
485 )?;
486
487 if providers.opencode && paths.opencode_db.exists() {
488 scan_usage_database(
489 ExtensionType::OpenCode,
490 &paths.opencode_db,
491 SourceFingerprint::sqlite(&paths.opencode_db, &[]),
492 time_range,
493 cache,
494 &mut seen,
495 &mut accumulator,
496 &mut diagnostics,
497 || read_opencode_usage_contributions(&paths.opencode_db, time_range),
498 );
499 }
500 if providers.cursor && paths.cursor_chats_dir.exists() {
501 scan_cursor_usage_database(
502 &paths.cursor_chats_dir,
503 &paths.cursor_tracking_db,
504 time_range,
505 cache,
506 &mut seen,
507 &mut accumulator,
508 &mut diagnostics,
509 );
510 }
511 if providers.hermes && paths.hermes_db.exists() {
512 scan_usage_database(
513 ExtensionType::Hermes,
514 &paths.hermes_db,
515 SourceFingerprint::sqlite(&paths.hermes_db, &[]),
516 time_range,
517 cache,
518 &mut seen,
519 &mut accumulator,
520 &mut diagnostics,
521 || read_hermes_usage_contributions(&paths.hermes_db, time_range),
522 );
523 }
524
525 cache.retain_kinds(&seen, &[SummaryKind::File, SummaryKind::UsageDatabase]);
526 diagnostics.finalize();
527 Ok(UsageCollection {
528 data: accumulator.finish(),
529 diagnostics,
530 })
531}
532
533#[allow(clippy::too_many_arguments)]
534fn scan_usage_database<F>(
535 provider: ExtensionType,
536 source: &Path,
537 fingerprint: Result<SourceFingerprint>,
538 time_range: TimeRange,
539 cache: &mut SummaryScanCache,
540 seen: &mut FastHashSet<SummaryCacheKey>,
541 accumulator: &mut UsageAccumulator,
542 diagnostics: &mut ScanDiagnostics,
543 loader: F,
544) where
545 F: FnOnce() -> Result<DatabaseUsageRead>,
546{
547 diagnostics.candidates += 1;
548 let key = SummaryCacheKey::new(SummaryKind::UsageDatabase, provider, source, time_range);
549 seen.insert(key.clone());
550 let fingerprint = match fingerprint {
551 Ok(value) => value,
552 Err(error) => {
553 diagnostics.failures.push(ScanFailure {
554 provider,
555 source: source.to_path_buf(),
556 error: error.to_string(),
557 });
558 return;
559 }
560 };
561 if let Some(cached) = cache.get(&key, &fingerprint) {
562 crate::scan::fold_cached(provider, source, cached, accumulator, diagnostics);
563 return;
564 }
565
566 cache.record_parse();
567 match loader() {
568 Ok(read) => {
569 let complete_failure = read.expected_records > 0 && read.parsed_records == 0;
570 let failed = read.failed_records();
571 let mut summary = CompactSourceSummary::default();
572 for contribution in read.rows {
573 summary.add_usage_contribution(contribution);
574 }
575 let loaded = crate::scan::LoadedCompactSummary {
576 summary,
577 parsed: !complete_failure,
578 failure: if complete_failure {
579 Some(format!(
580 "none of {} usage records used a supported schema",
581 read.expected_records
582 ))
583 } else if failed > 0 {
584 Some(format!("{failed} usage records used an unsupported schema"))
585 } else {
586 None
587 },
588 };
589 crate::scan::fold_loaded(provider, source, &loaded, accumulator, diagnostics);
590 cache.insert(
591 key,
592 fingerprint,
593 loaded.summary,
594 loaded.parsed,
595 loaded.failure,
596 );
597 }
598 Err(error) => {
599 let failure = error.to_string();
600 diagnostics.failures.push(ScanFailure {
601 provider,
602 source: source.to_path_buf(),
603 error: failure.clone(),
604 });
605 if is_cacheable_sqlite_failure(&error) {
606 cache.insert(
607 key,
608 fingerprint,
609 CompactSourceSummary::default(),
610 false,
611 Some(failure),
612 );
613 }
614 }
615 }
616}
617
618fn scan_cursor_usage_database(
619 chats_dir: &Path,
620 tracking_db: &Path,
621 time_range: TimeRange,
622 cache: &mut SummaryScanCache,
623 seen: &mut FastHashSet<SummaryCacheKey>,
624 accumulator: &mut UsageAccumulator,
625 diagnostics: &mut ScanDiagnostics,
626) {
627 let provider = ExtensionType::Cursor;
628 let discovery = discover_cursor_store_dbs(chats_dir);
629 if !discovery.failures.is_empty() {
630 cache.preserve_provider_keys(seen, SummaryKind::UsageDatabase, provider);
631 }
632 for failure in discovery.failures {
633 diagnostics.candidates += 1;
634 diagnostics.failures.push(ScanFailure {
635 provider,
636 source: failure.path,
637 error: failure.error,
638 });
639 }
640
641 let (conv_models, tracking_fingerprint, tracking_ok) =
642 match load_conversation_model_snapshot(tracking_db) {
643 Ok(snapshot) => (snapshot.models, snapshot.fingerprint, true),
644 Err(error) => {
645 diagnostics.failures.push(ScanFailure {
646 provider,
647 source: tracking_db.to_path_buf(),
648 error: error.to_string(),
649 });
650 (FastHashMap::default(), None, false)
651 }
652 };
653
654 for store in discovery.stores {
655 diagnostics.candidates += 1;
656 let key = SummaryCacheKey::new(SummaryKind::UsageDatabase, provider, &store, time_range);
657 seen.insert(key.clone());
658 let fingerprint = if tracking_ok {
659 SourceFingerprint::sqlite_with_dependency(
660 &store,
661 tracking_db,
662 tracking_fingerprint.as_ref(),
663 )
664 } else {
665 SourceFingerprint::sqlite(&store, &[])
666 };
667 let fingerprint = match fingerprint {
668 Ok(fingerprint) => fingerprint,
669 Err(error) => {
670 diagnostics.failures.push(ScanFailure {
671 provider,
672 source: store,
673 error: error.to_string(),
674 });
675 continue;
676 }
677 };
678 if tracking_ok && let Some(cached) = cache.get(&key, &fingerprint) {
679 crate::scan::fold_cached(provider, &store, cached, accumulator, diagnostics);
680 continue;
681 }
682
683 cache.record_parse();
684 match read_cursor_usage_store(&store, &conv_models, time_range) {
685 Ok(read) => {
686 let complete_failure = read.expected_records > 0 && read.parsed_records == 0;
687 let failed = read.failed_records();
688 let mut summary = CompactSourceSummary::default();
689 for contribution in read.rows {
690 summary.add_usage_contribution(contribution);
691 }
692 let loaded = crate::scan::LoadedCompactSummary {
693 summary,
694 parsed: !complete_failure,
695 failure: if complete_failure {
696 Some(format!(
697 "none of {} Cursor usage payloads used a supported schema",
698 read.expected_records
699 ))
700 } else if failed > 0 {
701 Some(format!(
702 "{failed} Cursor usage payloads used an unsupported schema"
703 ))
704 } else {
705 None
706 },
707 };
708 crate::scan::fold_loaded(provider, &store, &loaded, accumulator, diagnostics);
709 if tracking_ok {
710 cache.insert(
711 key,
712 fingerprint,
713 loaded.summary,
714 loaded.parsed,
715 loaded.failure,
716 );
717 }
718 }
719 Err(error) => {
720 let failure = error.to_string();
721 diagnostics.failures.push(ScanFailure {
722 provider,
723 source: store.clone(),
724 error: failure.clone(),
725 });
726 if tracking_ok && is_cacheable_sqlite_failure(&error) {
727 cache.insert(
728 key,
729 fingerprint,
730 CompactSourceSummary::default(),
731 false,
732 Some(failure),
733 );
734 }
735 }
736 }
737 }
738}
739
740#[derive(Default)]
741struct UsageAccumulator {
742 models: UsageResult,
743 per_provider: PerProviderUsage,
744 stored_costs: StoredCosts,
745 claude_dates: HashSet<String>,
746 codex_dates: HashSet<String>,
747 copilot_dates: HashSet<String>,
748 gemini_dates: HashSet<String>,
749 grok_dates: HashSet<String>,
750 opencode_dates: HashSet<String>,
751 cursor_dates: HashSet<String>,
752 hermes_dates: HashSet<String>,
753}
754
755impl crate::scan::CompactSink for UsageAccumulator {
756 fn fold(&mut self, provider: ExtensionType, summary: &CompactSourceSummary) {
757 self.add(provider, summary);
758 }
759}
760
761impl UsageAccumulator {
762 fn add(&mut self, provider: ExtensionType, summary: &CompactSourceSummary) {
763 let provider_result = match provider {
764 ExtensionType::ClaudeCode => &mut self.per_provider.claude,
765 ExtensionType::Codex => &mut self.per_provider.codex,
766 ExtensionType::Copilot => &mut self.per_provider.copilot,
767 ExtensionType::Gemini => &mut self.per_provider.gemini,
768 ExtensionType::Grok => &mut self.per_provider.grok,
769 ExtensionType::OpenCode => &mut self.per_provider.opencode,
770 ExtensionType::Cursor => &mut self.per_provider.cursor,
771 ExtensionType::Hermes => &mut self.per_provider.hermes,
772 };
773 for (model, usage) in &summary.usage {
776 match provider_result.get_mut(model) {
777 Some(existing) => merge_usage_values(existing, usage),
778 None => {
779 provider_result.insert(model.clone(), usage.clone());
780 }
781 }
782 match self.models.get_mut(model) {
783 Some(existing) => merge_usage_values(existing, usage),
784 None => {
785 self.models.insert(model.clone(), usage.clone());
786 }
787 }
788 }
789 for (model, tokens) in &summary.database_usage {
790 let usage = tokens.into_value();
791 match provider_result.get_mut(model) {
792 Some(existing) => merge_usage_values(existing, &usage),
793 None => {
794 provider_result.insert(model.clone(), usage.clone());
795 }
796 }
797 match self.models.get_mut(model) {
798 Some(existing) => merge_usage_values(existing, &usage),
799 None => {
800 self.models.insert(model.clone(), usage);
802 }
803 }
804 }
805
806 let stored = match provider {
807 ExtensionType::OpenCode => Some(&mut self.stored_costs.opencode),
808 ExtensionType::Cursor => Some(&mut self.stored_costs.cursor),
809 ExtensionType::Hermes => Some(&mut self.stored_costs.hermes),
810 _ => None,
811 };
812 if let Some(stored) = stored {
813 for (model, cost) in &summary.stored_costs {
814 *stored.entry(model.clone()).or_insert(0.0) += cost;
815 }
816 }
817
818 let dates = match provider {
819 ExtensionType::ClaudeCode => &mut self.claude_dates,
820 ExtensionType::Codex => &mut self.codex_dates,
821 ExtensionType::Copilot => &mut self.copilot_dates,
822 ExtensionType::Gemini => &mut self.gemini_dates,
823 ExtensionType::Grok => &mut self.grok_dates,
824 ExtensionType::OpenCode => &mut self.opencode_dates,
825 ExtensionType::Cursor => &mut self.cursor_dates,
826 ExtensionType::Hermes => &mut self.hermes_dates,
827 };
828 dates.extend(summary.usage_dates.iter().cloned());
829 }
830
831 fn finish(self) -> UsageData {
832 let mut all_dates: HashSet<&String> = HashSet::new();
835 all_dates.extend(self.claude_dates.iter());
836 all_dates.extend(self.codex_dates.iter());
837 all_dates.extend(self.copilot_dates.iter());
838 all_dates.extend(self.gemini_dates.iter());
839 all_dates.extend(self.grok_dates.iter());
840 all_dates.extend(self.opencode_dates.iter());
841 all_dates.extend(self.cursor_dates.iter());
842 all_dates.extend(self.hermes_dates.iter());
843 let total_days = all_dates.len();
844 UsageData {
845 models: self.models,
846 per_provider: self.per_provider,
847 provider_days: ProviderActiveDays {
848 claude: self.claude_dates.len(),
849 codex: self.codex_dates.len(),
850 copilot: self.copilot_dates.len(),
851 gemini: self.gemini_dates.len(),
852 grok: self.grok_dates.len(),
853 opencode: self.opencode_dates.len(),
854 cursor: self.cursor_dates.len(),
855 hermes: self.hermes_dates.len(),
856 total: total_days,
857 },
858 stored_costs: self.stored_costs,
859 }
860 }
861}
862
863#[allow(clippy::too_many_arguments)] fn process_usage_directory<F>(
875 dirs: &[&Path],
876 provider: ExtensionType,
877 global_result: &mut UsageResult,
878 provider_result: &mut UsageResult,
879 unique_dates: &mut HashSet<String>,
880 filter_fn: F,
881 time_range: TimeRange,
882 max_depth: Option<usize>,
883) where
884 F: Copy + Fn(&Path) -> bool + Sync + Send,
885{
886 let files = collect_provider_files_diagnostics(dirs, filter_fn, time_range, max_depth).files;
889
890 let file_results: Vec<(String, FastHashMap<String, Value>)> = files
900 .into_par_iter()
901 .filter_map(|file_info| {
902 match parse_session_file_typed_as(&file_info.path, provider, ParseMode::UsageOnly) {
903 Ok(analysis) => {
904 let conversation_usage = extract_conversation_usage_from_analysis(analysis);
905 Some((file_info.modified_date, conversation_usage))
906 }
907 Err(e) => {
908 log::warn!("failed to analyze {}: {e}", file_info.path.display());
909 None
910 }
911 }
912 })
913 .collect();
914
915 for (date, conversation_usage) in file_results {
923 if usage_map_has_activity(&conversation_usage, 0.0) {
924 unique_dates.insert(date);
925 }
926
927 for (model, usage_value) in conversation_usage {
928 provider_result
929 .entry(model.clone())
930 .and_modify(|existing| merge_usage_values(existing, &usage_value))
931 .or_insert_with(|| usage_value.clone());
932
933 global_result
934 .entry(model)
935 .and_modify(|existing| merge_usage_values(existing, &usage_value))
936 .or_insert(usage_value);
937 }
938 }
939}
940
941fn process_opencode_usage(
954 db_path: &Path,
955 global_result: &mut UsageResult,
956 provider_result: &mut UsageResult,
957 stored_costs: &mut FastHashMap<String, f64>,
958 unique_dates: &mut HashSet<String>,
959 time_range: TimeRange,
960) -> Result<()> {
961 let sessions = read_opencode_usage(db_path, time_range)?;
962 fold_stored_cost_sessions(
963 sessions,
964 global_result,
965 provider_result,
966 stored_costs,
967 unique_dates,
968 );
969 Ok(())
970}
971
972fn process_cursor_usage(
979 chats_dir: &Path,
980 tracking_db: &Path,
981 global_result: &mut UsageResult,
982 provider_result: &mut UsageResult,
983 stored_costs: &mut FastHashMap<String, f64>,
984 unique_dates: &mut HashSet<String>,
985 time_range: TimeRange,
986) -> Result<()> {
987 let sessions = read_cursor_usage(chats_dir, tracking_db, time_range)?;
988 fold_stored_cost_sessions(
989 sessions,
990 global_result,
991 provider_result,
992 stored_costs,
993 unique_dates,
994 );
995 Ok(())
996}
997
998fn process_hermes_usage(
1008 db_path: &Path,
1009 global_result: &mut UsageResult,
1010 provider_result: &mut UsageResult,
1011 stored_costs: &mut FastHashMap<String, f64>,
1012 unique_dates: &mut HashSet<String>,
1013 time_range: TimeRange,
1014) -> Result<()> {
1015 let sessions = read_hermes_usage(db_path, time_range)?;
1016 fold_stored_cost_sessions(
1017 sessions,
1018 global_result,
1019 provider_result,
1020 stored_costs,
1021 unique_dates,
1022 );
1023 Ok(())
1024}
1025
1026fn fold_stored_cost_sessions(
1029 sessions: Vec<(String, CodeAnalysis, f64)>,
1030 global_result: &mut UsageResult,
1031 provider_result: &mut UsageResult,
1032 stored_costs: &mut FastHashMap<String, f64>,
1033 unique_dates: &mut HashSet<String>,
1034) {
1035 for (date, analysis, session_cost) in sessions {
1036 let conversation_usage = extract_conversation_usage_from_analysis(analysis);
1037 if usage_map_has_activity(&conversation_usage, session_cost) {
1038 unique_dates.insert(date);
1039 }
1040 for (model, usage_value) in conversation_usage {
1041 *stored_costs.entry(model.clone()).or_insert(0.0) += session_cost;
1042
1043 provider_result
1044 .entry(model.clone())
1045 .and_modify(|existing| merge_usage_values(existing, &usage_value))
1046 .or_insert_with(|| usage_value.clone());
1047
1048 global_result
1049 .entry(model)
1050 .and_modify(|existing| merge_usage_values(existing, &usage_value))
1051 .or_insert(usage_value);
1052 }
1053 }
1054}
1055
1056fn usage_map_has_activity(usage: &FastHashMap<String, Value>, stored_cost: f64) -> bool {
1057 stored_cost != 0.0
1058 || usage
1059 .values()
1060 .any(|value| crate::utils::extract_token_counts(value).has_activity())
1061}
1062
1063impl UsageData {
1064 pub fn provider_usage(&self, provider: Provider) -> Option<&UsageResult> {
1069 self.per_provider.get(provider)
1070 }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 use crate::utils::TokenCounts;
1077 use serde_json::json;
1078
1079 #[test]
1080 fn merge_preserves_tokens_across_mixed_shapes() {
1081 use crate::utils::extract_token_counts;
1082
1083 let codex = json!({
1085 "total_token_usage": {
1086 "input_tokens": 1000,
1087 "cached_input_tokens": 200,
1088 "output_tokens": 500,
1089 "total_tokens": 1500
1090 }
1091 });
1092 let flat = json!({
1094 "input_tokens": 100,
1095 "output_tokens": 20,
1096 "cache_read_input_tokens": 50,
1097 "cache_creation_input_tokens": 10
1098 });
1099
1100 let expect = |c: TokenCounts| {
1103 assert_eq!(c.input_tokens, 800 + 100);
1104 assert_eq!(c.output_tokens, 500 + 20);
1105 assert_eq!(c.cache_read, 200 + 50);
1106 assert_eq!(c.cache_creation, 10);
1107 assert_eq!(c.total, 1680);
1109 };
1110
1111 let mut existing = codex.clone();
1113 merge_usage_values(&mut existing, &flat);
1114 expect(extract_token_counts(&existing));
1115
1116 let mut existing = flat.clone();
1117 merge_usage_values(&mut existing, &codex);
1118 expect(extract_token_counts(&existing));
1119 }
1120}