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