@@ -33,7 +33,7 @@ impl GrokProvider {
3333 metadata : ProviderMetadata {
3434 id : ProviderId :: Grok ,
3535 display_name : "Grok" ,
36- session_label : "Monthly " ,
36+ session_label : "Credits " ,
3737 weekly_label : "On-demand" ,
3838 supports_opus : false ,
3939 supports_credits : false ,
@@ -434,6 +434,25 @@ fn text_field(value: &Value, key: &str) -> Option<String> {
434434struct GrokBillingSnapshot {
435435 used_percent : Option < f64 > ,
436436 resets_at : Option < DateTime < Utc > > ,
437+ window_minutes : Option < u32 > ,
438+ }
439+
440+ /// Classify Grok from the full billing-cycle duration, not time remaining.
441+ /// This preserves the upstream #2431/#2566 invariant that a monthly plan near
442+ /// its reset must not become a weekly plan.
443+ fn primary_label_for_cycle_minutes ( minutes : u32 ) -> Option < & ' static str > {
444+ const DAY_MINUTES : u32 = 24 * 60 ;
445+ if minutes <= 60 {
446+ return None ;
447+ }
448+ let days = ( minutes + DAY_MINUTES / 2 ) / DAY_MINUTES ;
449+ if ( 4 ..=12 ) . contains ( & days) {
450+ Some ( "Weekly" )
451+ } else if ( 20 ..=45 ) . contains ( & days) {
452+ Some ( "Monthly" )
453+ } else {
454+ None
455+ }
437456}
438457
439458fn result_from_billing (
@@ -443,17 +462,30 @@ fn result_from_billing(
443462 team_id : Option < String > ,
444463 login_method : Option < String > ,
445464) -> ProviderFetchResult {
446- // Upstream #2431 / #2566: do not infer windowMinutes from time-until-reset.
447- // A monthly quota near its reset would otherwise be misclassified as weekly.
465+ // Dynamic cadence is provider-owned and comes only from a complete billing
466+ // cycle. A reset timestamp alone is insufficient because monthly quotas can
467+ // have only a few days remaining.
468+ let primary_label = billing
469+ . window_minutes
470+ . and_then ( primary_label_for_cycle_minutes) ;
448471 let primary = match billing. used_percent {
449- Some ( used_percent) => RateWindow :: with_details ( used_percent, None , billing. resets_at , None ) ,
472+ Some ( used_percent) => RateWindow :: with_details (
473+ used_percent,
474+ billing. window_minutes ,
475+ billing. resets_at ,
476+ None ,
477+ ) ,
450478 None => {
451479 let mut window = RateWindow :: informational ( "Usage unavailable" ) ;
452480 window. resets_at = billing. resets_at ;
481+ window. window_minutes = billing. window_minutes ;
453482 window
454483 }
455484 } ;
456485 let mut usage = UsageSnapshot :: new ( primary) ;
486+ if let Some ( label) = primary_label {
487+ usage = usage. with_primary_label ( label) ;
488+ }
457489 usage. account_email = email;
458490 usage. account_organization = team_id;
459491 usage. login_method = login_method;
@@ -532,29 +564,50 @@ fn parse_grpc_web_response(data: &[u8]) -> Result<GrokBillingSnapshot, ProviderE
532564 } )
533565 . map ( |field| field. value as f64 ) ;
534566
567+ let now = Utc :: now ( ) ;
535568 let resets_at = scan
536569 . varints
537570 . iter ( )
538- . filter_map ( |field| {
539- // Varint timestamps are Unix seconds inside the range checked below.
540- #[ allow(
541- clippy:: cast_possible_wrap,
542- reason = "varint timestamps are bounded to the Unix-seconds range checked below"
543- ) ]
544- let seconds = field. value as i64 ;
545- ( 1_700_000_000 ..=2_100_000_000 )
546- . contains ( & field. value )
547- . then ( || Utc . timestamp_opt ( seconds, 0 ) . single ( ) )
548- . flatten ( )
549- } )
550- . filter ( |dt| * dt > Utc :: now ( ) )
571+ . filter_map ( varint_timestamp)
572+ . filter ( |dt| * dt > now)
551573 . min ( ) ;
552574 Ok ( GrokBillingSnapshot {
553575 used_percent,
554576 resets_at,
577+ window_minutes : current_period_window_minutes ( & scan, now) ,
555578 } )
556579}
557580
581+ fn varint_timestamp ( field : & VarintField ) -> Option < DateTime < Utc > > {
582+ // Varint timestamps are Unix seconds inside the range checked below.
583+ #[ allow(
584+ clippy:: cast_possible_wrap,
585+ reason = "varint timestamps are bounded to the Unix-seconds range checked below"
586+ ) ]
587+ let seconds = field. value as i64 ;
588+ ( 1_700_000_000 ..=2_100_000_000 )
589+ . contains ( & field. value )
590+ . then ( || Utc . timestamp_opt ( seconds, 0 ) . single ( ) )
591+ . flatten ( )
592+ }
593+
594+ fn current_period_window_minutes ( scan : & ProtoScan , now : DateTime < Utc > ) -> Option < u32 > {
595+ let timestamp_at = |path : & [ u64 ] | {
596+ scan. varints
597+ . iter ( )
598+ . find ( |field| field. path . as_slice ( ) == path)
599+ . and_then ( varint_timestamp)
600+ } ;
601+ let start = timestamp_at ( & [ 1 , 8 , 2 , 1 ] ) ?;
602+ let end = timestamp_at ( & [ 1 , 8 , 3 , 1 ] ) ?;
603+ if start > now || end <= now || end <= start {
604+ return None ;
605+ }
606+ u32:: try_from ( ( end - start) . num_minutes ( ) )
607+ . ok ( )
608+ . filter ( |minutes| * minutes > 0 )
609+ }
610+
558611fn grpc_web_data_frames ( data : & [ u8 ] ) -> Vec < Vec < u8 > > {
559612 let mut frames = Vec :: new ( ) ;
560613 let mut index = 0 ;
@@ -591,6 +644,7 @@ struct Fixed32Field {
591644}
592645
593646struct VarintField {
647+ path : Vec < u64 > ,
594648 value : u64 ,
595649}
596650
@@ -624,17 +678,20 @@ impl ProtoScan {
624678 wire : u64 ,
625679 ) -> Option < usize > {
626680 match wire {
627- 0 => self . scan_varint ( data, i) ,
681+ 0 => self . scan_varint ( data, i, path ) ,
628682 2 => self . scan_length_delimited ( data, i, path, depth) ,
629683 5 => self . scan_fixed32 ( data, i, path) ,
630684 1 => Some ( i. saturating_add ( 8 ) ) ,
631685 _ => None ,
632686 }
633687 }
634688
635- fn scan_varint ( & mut self , data : & [ u8 ] , i : usize ) -> Option < usize > {
689+ fn scan_varint ( & mut self , data : & [ u8 ] , i : usize , path : & [ u64 ] ) -> Option < usize > {
636690 let ( value, next) = read_varint ( data, i) ?;
637- self . varints . push ( VarintField { value } ) ;
691+ self . varints . push ( VarintField {
692+ path : path. to_vec ( ) ,
693+ value,
694+ } ) ;
638695 Some ( next)
639696 }
640697
@@ -794,22 +851,99 @@ mod tests {
794851 }
795852
796853 #[ test]
797- fn billing_snapshot_leaves_window_minutes_unset ( ) {
798- // A monthly quota with six days left must not be reported as weekly.
799- let resets = Utc :: now ( ) + chrono:: Duration :: days ( 6 ) ;
854+ fn billing_snapshot_uses_full_weekly_cycle_for_pace ( ) {
855+ let now = Utc :: now ( ) ;
856+ let resets = now + chrono:: Duration :: days ( 2 ) ;
800857 let result = result_from_billing (
801858 GrokBillingSnapshot {
802859 used_percent : Some ( 12.0 ) ,
803860 resets_at : Some ( resets) ,
861+ window_minutes : Some ( crate :: core:: WEEKLY_WINDOW_MINUTES ) ,
862+ } ,
863+ "web" ,
864+ None ,
865+ None ,
866+ Some ( "SuperGrok" . into ( ) ) ,
867+ ) ;
868+ assert_eq ! (
869+ result. usage. primary. window_minutes,
870+ Some ( crate :: core:: WEEKLY_WINDOW_MINUTES )
871+ ) ;
872+ assert_eq ! ( result. usage. primary_label. as_deref( ) , Some ( "Weekly" ) ) ;
873+ let pace = crate :: core:: UsagePace :: weekly (
874+ & result. usage . primary ,
875+ Some ( now) ,
876+ crate :: core:: WEEKLY_WINDOW_MINUTES ,
877+ ) ;
878+ assert ! ( pace. is_some( ) , "weekly window + reset must yield pace" ) ;
879+ }
880+
881+ #[ test]
882+ fn monthly_cycle_stays_monthly_with_six_days_remaining ( ) {
883+ let now = Utc :: now ( ) ;
884+ let resets = now + chrono:: Duration :: days ( 6 ) ;
885+ let monthly_minutes = 31 * 24 * 60 ;
886+ let result = result_from_billing (
887+ GrokBillingSnapshot {
888+ used_percent : Some ( 40.0 ) ,
889+ resets_at : Some ( resets) ,
890+ window_minutes : Some ( monthly_minutes) ,
891+ } ,
892+ "cli" ,
893+ None ,
894+ None ,
895+ Some ( "SuperGrok Heavy" . into ( ) ) ,
896+ ) ;
897+ assert_eq ! ( result. usage. primary_label. as_deref( ) , Some ( "Monthly" ) ) ;
898+ assert_eq ! ( result. usage. primary. window_minutes, Some ( monthly_minutes) ) ;
899+ }
900+
901+ #[ test]
902+ fn reset_distance_alone_does_not_invent_a_cadence ( ) {
903+ let resets = Utc :: now ( ) + chrono:: Duration :: days ( 6 ) ;
904+ let result = result_from_billing (
905+ GrokBillingSnapshot {
906+ used_percent : Some ( 80.0 ) ,
907+ resets_at : Some ( resets) ,
908+ window_minutes : None ,
804909 } ,
805910 "web" ,
806911 None ,
807912 None ,
808913 Some ( "SuperGrok" . into ( ) ) ,
809914 ) ;
810915 assert_eq ! ( result. usage. primary. window_minutes, None ) ;
811- assert_eq ! ( result. usage. primary. resets_at, Some ( resets) ) ;
812- assert_eq ! ( result. usage. login_method. as_deref( ) , Some ( "SuperGrok" ) ) ;
916+ assert_eq ! ( result. usage. primary_label, None ) ;
917+ }
918+
919+ #[ test]
920+ fn current_period_paths_define_the_full_cycle ( ) {
921+ let now = Utc . timestamp_opt ( 1_800_000_000 , 0 ) . single ( ) . unwrap ( ) ;
922+ let start = now - chrono:: Duration :: days ( 25 ) ;
923+ let end = now + chrono:: Duration :: days ( 6 ) ;
924+ let scan = ProtoScan {
925+ fixed32 : Vec :: new ( ) ,
926+ varints : vec ! [
927+ VarintField {
928+ path: vec![ 1 , 8 , 2 , 1 ] ,
929+ value: u64 :: try_from( start. timestamp( ) ) . unwrap( ) ,
930+ } ,
931+ VarintField {
932+ path: vec![ 1 , 8 , 3 , 1 ] ,
933+ value: u64 :: try_from( end. timestamp( ) ) . unwrap( ) ,
934+ } ,
935+ ] ,
936+ order : 0 ,
937+ } ;
938+
939+ assert_eq ! (
940+ current_period_window_minutes( & scan, now) ,
941+ Some ( 31 * 24 * 60 )
942+ ) ;
943+ assert_eq ! (
944+ primary_label_for_cycle_minutes( current_period_window_minutes( & scan, now) . unwrap( ) ) ,
945+ Some ( "Monthly" )
946+ ) ;
813947 }
814948
815949 #[ test]
@@ -819,6 +953,7 @@ mod tests {
819953 GrokBillingSnapshot {
820954 used_percent : None ,
821955 resets_at : Some ( resets) ,
956+ window_minutes : None ,
822957 } ,
823958 "cli" ,
824959 Some ( "user@example.com" . into ( ) ) ,
0 commit comments