@@ -35,6 +35,21 @@ struct OpencodeDbSessionState {
3535 cached_read : i64 ,
3636}
3737
38+ #[ derive( Clone , Debug , PartialEq , Eq , Hash ) ]
39+ struct UsageModelKey {
40+ model : String ,
41+ provider : Option < String > ,
42+ }
43+
44+ impl UsageModelKey {
45+ fn unknown ( ) -> Self {
46+ Self {
47+ model : "unknown" . to_string ( ) ,
48+ provider : None ,
49+ }
50+ }
51+ }
52+
3853const MAX_ACTIVITY_GAP_MS : i64 = 2 * 60 * 1000 ;
3954
4055pub ( crate ) async fn local_usage_snapshot_core (
@@ -78,7 +93,7 @@ fn scan_local_usage(
7893 . iter ( )
7994 . map ( |key| ( key. clone ( ) , DailyTotals :: default ( ) ) )
8095 . collect ( ) ;
81- let mut model_totals: HashMap < String , i64 > = HashMap :: new ( ) ;
96+ let mut model_totals: HashMap < UsageModelKey , i64 > = HashMap :: new ( ) ;
8297
8398 if !sessions_roots. is_empty ( ) {
8499 for root in sessions_roots {
@@ -117,7 +132,7 @@ fn build_snapshot(
117132 updated_at : i64 ,
118133 day_keys : Vec < String > ,
119134 daily : HashMap < String , DailyTotals > ,
120- model_totals : HashMap < String , i64 > ,
135+ model_totals : HashMap < UsageModelKey , i64 > ,
121136) -> LocalUsageSnapshot {
122137 let mut days: Vec < LocalUsageDay > = Vec :: with_capacity ( day_keys. len ( ) ) ;
123138 let mut total_tokens = 0 ;
@@ -163,9 +178,10 @@ fn build_snapshot(
163178
164179 let mut top_models: Vec < LocalUsageModel > = model_totals
165180 . into_iter ( )
166- . filter ( |( model, tokens) | model != "unknown" && * tokens > 0 )
167- . map ( |( model, tokens) | LocalUsageModel {
168- model,
181+ . filter ( |( key, tokens) | key. model != "unknown" && * tokens > 0 )
182+ . map ( |( key, tokens) | LocalUsageModel {
183+ model : key. model ,
184+ provider : key. provider ,
169185 tokens,
170186 share_percent : if total_tokens > 0 {
171187 ( ( tokens as f64 ) / ( total_tokens as f64 ) * 1000.0 ) . round ( ) / 10.0
@@ -218,7 +234,7 @@ fn scan_opencode_db_usage(
218234 . iter ( )
219235 . map ( |key| ( key. clone ( ) , DailyTotals :: default ( ) ) )
220236 . collect ( ) ;
221- let mut model_totals: HashMap < String , i64 > = HashMap :: new ( ) ;
237+ let mut model_totals: HashMap < UsageModelKey , i64 > = HashMap :: new ( ) ;
222238 let mut cache_state_by_session: HashMap < String , OpencodeDbSessionState > = HashMap :: new ( ) ;
223239 let cutoff_ms = earliest_day_start_ms ( days) . unwrap_or ( 0 ) ;
224240
@@ -325,7 +341,7 @@ fn scan_opencode_db_usage(
325341
326342 if total_for_model > 0 {
327343 let model = extract_model_from_opencode_message ( & value)
328- . unwrap_or_else ( || " unknown" . to_string ( ) ) ;
344+ . unwrap_or_else ( UsageModelKey :: unknown) ;
329345 * model_totals. entry ( model) . or_insert ( 0 ) += total_for_model;
330346 }
331347 }
@@ -399,8 +415,8 @@ fn extract_opencode_message_cwd(value: &Value) -> Option<String> {
399415 . map ( |cwd| cwd. to_string ( ) )
400416}
401417
402- fn extract_model_from_opencode_message ( value : & Value ) -> Option < String > {
403- value
418+ fn extract_model_from_opencode_message ( value : & Value ) -> Option < UsageModelKey > {
419+ let model = value
404420 . get ( "modelID" )
405421 . and_then ( |v| v. as_str ( ) )
406422 . or_else ( || {
@@ -409,13 +425,24 @@ fn extract_model_from_opencode_message(value: &Value) -> Option<String> {
409425 . and_then ( |model| model. get ( "modelID" ) )
410426 . and_then ( |v| v. as_str ( ) )
411427 } )
412- . map ( |model| model. to_string ( ) )
428+ . and_then ( normalize_non_empty_string) ?;
429+ let provider = value
430+ . get ( "providerID" )
431+ . and_then ( |v| v. as_str ( ) )
432+ . or_else ( || {
433+ value
434+ . get ( "model" )
435+ . and_then ( |model| model. get ( "providerID" ) )
436+ . and_then ( |v| v. as_str ( ) )
437+ } )
438+ . and_then ( normalize_non_empty_string) ;
439+ Some ( UsageModelKey { model, provider } )
413440}
414441
415442fn scan_file (
416443 path : & Path ,
417444 daily : & mut HashMap < String , DailyTotals > ,
418- model_totals : & mut HashMap < String , i64 > ,
445+ model_totals : & mut HashMap < UsageModelKey , i64 > ,
419446 workspace_path : Option < & Path > ,
420447) -> Result < ( ) , String > {
421448 let file = match File :: open ( path) {
@@ -426,7 +453,7 @@ fn scan_file(
426453 } ;
427454 let reader = BufReader :: new ( file) ;
428455 let mut previous_totals: Option < UsageTotals > = None ;
429- let mut current_model: Option < String > = None ;
456+ let mut current_model: Option < UsageModelKey > = None ;
430457 let mut last_activity_ms: Option < i64 > = None ;
431458 let mut seen_runs: HashSet < i64 > = HashSet :: new ( ) ;
432459 let mut match_known = workspace_path. is_none ( ) ;
@@ -602,7 +629,7 @@ fn scan_file(
602629 let model = current_model
603630 . clone ( )
604631 . or_else ( || extract_model_from_token_count ( & value) )
605- . unwrap_or_else ( || " unknown" . to_string ( ) ) ;
632+ . unwrap_or_else ( UsageModelKey :: unknown) ;
606633 * model_totals. entry ( model) . or_insert ( 0 ) += delta. input + delta. output ;
607634 }
608635 }
@@ -645,18 +672,38 @@ fn scan_file(
645672 Ok ( ( ) )
646673}
647674
648- fn extract_model_from_turn_context ( value : & Value ) -> Option < String > {
675+ fn extract_model_from_turn_context ( value : & Value ) -> Option < UsageModelKey > {
649676 let payload = value. get ( "payload" ) . and_then ( |value| value. as_object ( ) ) ?;
650- if let Some ( model) = payload. get ( "model" ) . and_then ( |value| value. as_str ( ) ) {
651- return Some ( model. to_string ( ) ) ;
677+ if let Some ( model) = payload
678+ . get ( "model" )
679+ . and_then ( |value| value. as_str ( ) )
680+ . and_then ( normalize_non_empty_string)
681+ {
682+ let provider = payload
683+ . get ( "provider" )
684+ . or_else ( || payload. get ( "provider_id" ) )
685+ . or_else ( || payload. get ( "providerID" ) )
686+ . and_then ( |value| value. as_str ( ) )
687+ . and_then ( normalize_non_empty_string) ;
688+ return Some ( UsageModelKey { model, provider } ) ;
652689 }
653690 let info = payload. get ( "info" ) . and_then ( |value| value. as_object ( ) ) ?;
654- info. get ( "model" )
691+ let model = info
692+ . get ( "model" )
693+ . or_else ( || info. get ( "model_id" ) )
694+ . or_else ( || info. get ( "modelID" ) )
695+ . and_then ( |value| value. as_str ( ) )
696+ . and_then ( normalize_non_empty_string) ?;
697+ let provider = info
698+ . get ( "provider" )
699+ . or_else ( || info. get ( "provider_id" ) )
700+ . or_else ( || info. get ( "providerID" ) )
655701 . and_then ( |value| value. as_str ( ) )
656- . map ( |value| value. to_string ( ) )
702+ . and_then ( normalize_non_empty_string) ;
703+ Some ( UsageModelKey { model, provider } )
657704}
658705
659- fn extract_model_from_token_count ( value : & Value ) -> Option < String > {
706+ fn extract_model_from_token_count ( value : & Value ) -> Option < UsageModelKey > {
660707 let payload = value. get ( "payload" ) . and_then ( |value| value. as_object ( ) ) ?;
661708 let info = payload. get ( "info" ) . and_then ( |value| value. as_object ( ) ) ;
662709 let model = info
@@ -666,8 +713,21 @@ fn extract_model_from_token_count(value: &Value) -> Option<String> {
666713 . and_then ( |value| value. as_str ( ) )
667714 } )
668715 . or_else ( || payload. get ( "model" ) . and_then ( |value| value. as_str ( ) ) )
669- . or_else ( || value. get ( "model" ) . and_then ( |value| value. as_str ( ) ) ) ;
670- model. map ( |value| value. to_string ( ) )
716+ . or_else ( || value. get ( "model" ) . and_then ( |value| value. as_str ( ) ) )
717+ . and_then ( normalize_non_empty_string) ?;
718+
719+ let provider = info
720+ . and_then ( |info| {
721+ info. get ( "provider" )
722+ . or_else ( || info. get ( "provider_id" ) )
723+ . or_else ( || info. get ( "providerID" ) )
724+ . and_then ( |value| value. as_str ( ) )
725+ } )
726+ . or_else ( || payload. get ( "provider" ) . and_then ( |value| value. as_str ( ) ) )
727+ . or_else ( || value. get ( "provider" ) . and_then ( |value| value. as_str ( ) ) )
728+ . and_then ( normalize_non_empty_string) ;
729+
730+ Some ( UsageModelKey { model, provider } )
671731}
672732
673733fn find_usage_map < ' a > (
@@ -689,6 +749,15 @@ fn read_i64(map: &serde_json::Map<String, Value>, keys: &[&str]) -> i64 {
689749 . unwrap_or ( 0 )
690750}
691751
752+ fn normalize_non_empty_string ( value : & str ) -> Option < String > {
753+ let trimmed = value. trim ( ) ;
754+ if trimmed. is_empty ( ) {
755+ None
756+ } else {
757+ Some ( trimmed. to_string ( ) )
758+ }
759+ }
760+
692761fn read_timestamp_ms ( value : & Value ) -> Option < i64 > {
693762 let raw = value. get ( "timestamp" ) ?;
694763 if let Some ( text) = raw. as_str ( ) {
0 commit comments