1use forge_primitives::usage::{UsageSnapshot, UsageWindow};
29
30use forge_primitives::account::LoadedAccount;
31
32#[derive(Clone, Debug, Hash, Eq, PartialEq)]
34pub struct AccountKey(pub String);
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum UsageFetchStatus {
42 RateLimited,
46 Expired,
50 Unauthorized,
52 NetworkFailed,
54 Other,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum Unusable {
69 Saturated,
71 ProbeBlocked,
74 Bailed,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum LoadingState {
87 Loading,
89 Ready,
92 Bailed,
96}
97
98#[derive(Clone)]
99pub struct AccountState {
100 pub provider: forge_primitives::account::Provider,
103 pub models: Vec<String>,
106 pub model_aliases: std::collections::HashMap<String, Vec<String>>,
109 pub model_slugs: std::collections::HashMap<String, String>,
112 pub env: std::collections::HashMap<String, String>,
118 pub usage: Option<UsageSnapshot>,
123 pub limit_reading: Option<UsageSnapshot>,
127 pub last_error: Option<UsageFetchStatus>,
132 pub next_probe_at: Option<std::time::Instant>,
140 pub consecutive_failures: u32,
145 pub override_armed: bool,
157 pub loading: LoadingState,
162 pub retired: bool,
166}
167
168impl std::fmt::Debug for AccountState {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 let env: std::collections::BTreeMap<&str, &str> =
173 self.env.keys().map(|key| (key.as_str(), "[redacted]")).collect();
174 f.debug_struct("AccountState")
175 .field("provider", &self.provider)
176 .field("models", &self.models)
177 .field("model_aliases", &self.model_aliases)
178 .field("model_slugs", &self.model_slugs)
179 .field("env", &env)
180 .field("usage", &self.usage)
181 .field("limit_reading", &self.limit_reading)
182 .field("last_error", &self.last_error)
183 .field("next_probe_at", &self.next_probe_at)
184 .field("consecutive_failures", &self.consecutive_failures)
185 .field("override_armed", &self.override_armed)
186 .field("loading", &self.loading)
187 .field("retired", &self.retired)
188 .finish()
189 }
190}
191
192pub fn account_serves<S: std::hash::BuildHasher>(
197 models: &[String],
198 model_aliases: &std::collections::HashMap<String, Vec<String>, S>,
199 model: &str,
200) -> bool {
201 models.iter().any(|m| m == model)
202 || model_aliases.values().flatten().any(|alias| alias == model)
203}
204
205pub struct AccountStateMap {
206 pub ordered_keys: Vec<AccountKey>, pub by_key: std::collections::HashMap<AccountKey, AccountState>,
208}
209
210impl std::fmt::Debug for AccountStateMap {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 f.debug_struct("AccountStateMap")
215 .field("ordered_keys", &self.ordered_keys)
216 .field("by_key", &self.by_key)
217 .finish()
218 }
219}
220
221impl AccountStateMap {
222 #[cfg(any(test, feature = "testing"))]
226 pub fn empty_for_test() -> Self {
227 Self { ordered_keys: Vec::new(), by_key: std::collections::HashMap::new() }
228 }
229
230 pub fn new(accounts: &[LoadedAccount]) -> Self {
231 let mut ordered_keys = Vec::with_capacity(accounts.len());
232 let mut by_key = std::collections::HashMap::with_capacity(accounts.len());
233 for account in accounts {
234 let key = AccountKey(account.display_name.clone());
235 ordered_keys.push(key.clone());
236 by_key.insert(
237 key,
238 AccountState {
239 provider: account.provider,
240 models: account.models.clone(),
241 model_aliases: account.model_aliases.clone(),
242 model_slugs: account.model_slugs.clone(),
243 env: account.env.clone(),
244 usage: None,
245 limit_reading: None,
246 last_error: None,
247 next_probe_at: None,
248 consecutive_failures: 0,
249 override_armed: false,
250 loading: LoadingState::Loading,
251 retired: false,
252 },
253 );
254 }
255 Self { ordered_keys, by_key }
256 }
257
258 pub fn seed_from_cache(&mut self, cached: &std::collections::BTreeMap<String, UsageSnapshot>) {
266 for (name, snapshot) in cached {
267 let key = AccountKey(name.clone());
268 if let Some(state) = self.by_key.get_mut(&key) {
269 state.usage = Some(snapshot.clone());
270 }
271 }
272 }
273
274 pub fn snapshots_for_cache(&self) -> std::collections::BTreeMap<String, UsageSnapshot> {
278 let mut out = std::collections::BTreeMap::new();
279 for (key, state) in &self.by_key {
280 if let Some(snapshot) = state.usage.clone() {
281 out.insert(key.0.clone(), snapshot);
282 }
283 }
284 out
285 }
286
287 pub fn env(&self, key: &AccountKey) -> Option<&std::collections::HashMap<String, String>> {
292 self.by_key.get(key).map(|s| &s.env)
293 }
294
295 pub fn provider(&self, key: &AccountKey) -> Option<forge_primitives::account::Provider> {
298 self.by_key.get(key).map(|s| s.provider)
299 }
300
301 pub fn auth(&self, key: &AccountKey) -> Option<forge_primitives::account::AccountAuth> {
308 if self.provider(key)?.uses_base_url() {
309 return Some(forge_primitives::account::AccountAuth::BaseUrl);
310 }
311 Some(forge_primitives::account::AccountAuth::Token)
312 }
313
314 pub fn set_usage(&mut self, key: &AccountKey, snapshot: UsageSnapshot) {
323 if let Some(state) = self.by_key.get_mut(key) {
324 state.usage = Some(snapshot);
325 state.limit_reading = None;
326 state.last_error = None;
327 state.next_probe_at = None;
328 state.consecutive_failures = 0;
329 state.loading = LoadingState::Ready;
330 state.override_armed = true;
336 }
337 }
338
339 pub fn set_loading(&mut self, key: &AccountKey, loading: LoadingState) {
345 if let Some(state) = self.by_key.get_mut(key) {
346 state.loading = loading;
347 if loading == LoadingState::Bailed {
348 state.usage = None;
349 state.limit_reading = None;
350 }
351 }
352 }
353
354 pub fn set_limit_reading(&mut self, key: &AccountKey, snapshot: UsageSnapshot) {
358 if let Some(state) = self.by_key.get_mut(key) {
359 state.limit_reading = Some(snapshot);
360 }
361 }
362
363 pub fn latest_reading(&self, key: &AccountKey) -> Option<&UsageSnapshot> {
367 let state = self.by_key.get(key)?;
368 state.limit_reading.as_ref().or(state.usage.as_ref())
369 }
370
371 pub fn disarm_override(&mut self, key: &AccountKey) {
378 if let Some(state) = self.by_key.get_mut(key) {
379 state.override_armed = false;
380 }
381 }
382
383 pub fn set_last_error(
398 &mut self,
399 key: &AccountKey,
400 status: UsageFetchStatus,
401 retry_after: Option<std::time::Duration>,
402 ) {
403 if let Some(state) = self.by_key.get_mut(key) {
404 state.last_error = Some(status);
405 state.consecutive_failures = state.consecutive_failures.saturating_add(1);
406 if matches!(status, UsageFetchStatus::Unauthorized | UsageFetchStatus::Expired) {
419 let prev = state.loading;
420 state.loading = LoadingState::Bailed;
421 state.usage = None;
422 state.limit_reading = None;
423 if prev != LoadingState::Bailed {
429 tracing::warn!(
430 target: "forge_workspace::account",
431 event_name = "account_bailed",
432 account = %key.0,
433 prev_state = ?prev,
434 status = ?status,
435 "account transitioned to Bailed via probe failure; the pollers will retry it",
436 );
437 }
438 }
439 let delay = match retry_after {
449 Some(d) if d >= std::time::Duration::from_secs(1) => d.min(BACKOFF_CAP),
450 _ => backoff_delay(state.consecutive_failures),
451 };
452 state.next_probe_at = Some(std::time::Instant::now() + delay);
453 }
454 }
455
456 pub fn should_probe_now(&self, key: &AccountKey) -> bool {
460 let Some(state) = self.by_key.get(key) else { return true };
461 state.next_probe_at.is_none_or(|t| t <= std::time::Instant::now())
462 }
463
464 pub fn all_loaded(&self) -> bool {
471 self.by_key
472 .values()
473 .all(|s| s.retired || matches!(s.loading, LoadingState::Ready | LoadingState::Bailed))
474 }
475
476 pub fn is_saturated(&self, key: &AccountKey) -> bool {
482 self.by_key.get(key).and_then(|s| s.usage.as_ref()).is_some_and(is_rate_limited)
483 }
484
485 pub fn unusable_reason(&self, key: &AccountKey) -> Option<Unusable> {
495 if self.loading_state(key) == LoadingState::Bailed {
496 return Some(Unusable::Bailed);
497 }
498 let usage = self
499 .provider(key)
500 .and_then(crate::backend)
501 .and_then(|backend| self.usage(key).filter(|s| s.source == backend.source()));
502 unusable_reason(usage, self.usage_error(key))
503 }
504
505 pub fn loading_state(&self, key: &AccountKey) -> LoadingState {
510 self.by_key.get(key).map_or(LoadingState::Loading, |s| s.loading)
511 }
512
513 pub fn scheduler_should_probe(&self, key: &AccountKey) -> bool {
524 self.should_probe_now(key) || self.has_just_cleared_cap_window(key)
525 }
526
527 pub fn has_just_cleared_cap_window(&self, key: &AccountKey) -> bool {
543 let Some(state) = self.by_key.get(key) else {
544 return false;
545 };
546 if !state.override_armed {
547 return false;
548 }
549 let Some(usage) = state.usage.as_ref() else {
550 return false;
551 };
552 let now = std::time::SystemTime::now();
553 let windows = [
554 usage.five_hour.as_ref(),
555 usage.seven_day.as_ref(),
556 usage.seven_day_opus.as_ref(),
557 usage.seven_day_sonnet.as_ref(),
558 ];
559 windows
560 .into_iter()
561 .flatten()
562 .any(|w| w.utilization >= 100.0 && w.resets_at.is_some_and(|when| when <= now))
563 }
564
565 pub fn usage(&self, key: &AccountKey) -> Option<&UsageSnapshot> {
568 self.by_key.get(key).and_then(|s| s.usage.as_ref())
569 }
570
571 pub fn usage_error(&self, key: &AccountKey) -> Option<UsageFetchStatus> {
575 self.by_key.get(key).and_then(|s| s.last_error)
576 }
577
578 pub fn clear_last_error(&mut self, key: &AccountKey) {
582 if let Some(state) = self.by_key.get_mut(key) {
583 state.last_error = None;
584 }
585 }
586}
587
588fn unusable_reason(
598 usage: Option<&UsageSnapshot>,
599 last_error: Option<UsageFetchStatus>,
600) -> Option<Unusable> {
601 if usage.is_some_and(is_rate_limited) {
602 return Some(Unusable::Saturated);
603 }
604 if matches!(
605 last_error,
606 Some(
607 UsageFetchStatus::RateLimited
608 | UsageFetchStatus::Expired
609 | UsageFetchStatus::Unauthorized
610 )
611 ) {
612 return Some(Unusable::ProbeBlocked);
613 }
614 None
615}
616
617fn is_rate_limited(snapshot: &UsageSnapshot) -> bool {
632 let windows = [
633 snapshot.five_hour.as_ref(),
634 snapshot.seven_day.as_ref(),
635 snapshot.seven_day_opus.as_ref(),
636 snapshot.seven_day_sonnet.as_ref(),
637 ];
638 windows.into_iter().flatten().any(UsageWindow::is_currently_limited)
639}
640
641const BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(600);
646
647fn backoff_delay(consecutive_failures: u32) -> std::time::Duration {
663 use std::time::Duration;
664 let exp = consecutive_failures.min(20); let seconds = 30_u64.saturating_mul(1_u64 << exp.saturating_sub(1));
666 Duration::from_secs(seconds).min(BACKOFF_CAP)
667}
668
669#[cfg(test)]
670#[allow(clippy::expect_used, clippy::unwrap_used)]
671mod tests {
672 use super::*;
673 use forge_primitives::usage::{UsageSnapshot, UsageSourceKind, UsageWindow};
674 use std::time::SystemTime;
675
676 fn make_account(name: &str) -> LoadedAccount {
677 LoadedAccount {
678 display_name: name.to_owned(),
679 provider: forge_primitives::account::Provider::Anthropic,
680 base_url: None,
681 models: vec!["claude-sonnet-5".to_owned()],
682 model_aliases: std::collections::HashMap::new(),
683 model_slugs: std::collections::HashMap::new(),
684 env: std::collections::HashMap::new(),
685 }
686 }
687
688 #[test]
692 fn a_credential_never_reaches_the_account_state_debug_output() {
693 let mut account = make_account("Stargate");
694 account.env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), "gateway-supersecret".to_owned());
695 let map = AccountStateMap::new(&[account]);
696 let state = map.by_key.values().next().expect("the state is there");
697 let rendered = format!("{state:?}");
698 assert!(
699 !rendered.contains("gateway-supersecret"),
700 "the credential stamp leaked: {rendered}",
701 );
702 assert!(rendered.contains("Anthropic"), "the provider stays readable: {rendered}");
703 let rendered = format!("{map:?}");
704 assert!(
705 !rendered.contains("gateway-supersecret"),
706 "the credential stamp leaked through the map: {rendered}",
707 );
708 assert!(rendered.contains("Stargate"), "the account key stays readable: {rendered}");
709 }
710
711 fn snapshot(five_hour: Option<f64>, seven_day: Option<f64>) -> UsageSnapshot {
721 let future = SystemTime::now() + std::time::Duration::from_secs(3600);
722 UsageSnapshot {
723 source: UsageSourceKind::Oauth,
724 fetched_at: SystemTime::UNIX_EPOCH,
725 five_hour: five_hour.map(|util| UsageWindow {
726 utilization: util,
727 resets_at: Some(future),
728 reset_description: None,
729 status: None,
730 }),
731 seven_day: seven_day.map(|util| UsageWindow {
732 utilization: util,
733 resets_at: Some(future),
734 reset_description: None,
735 status: None,
736 }),
737 seven_day_opus: None,
738 seven_day_sonnet: None,
739 extra_usage: None,
740 spend: None,
741 balance: None,
742 }
743 }
744
745 #[test]
746 fn account_serves_matches_declared_names_and_their_aliases() {
747 let models = vec!["claude-opus-5[1m]".to_owned()];
748 let aliases = std::collections::HashMap::from([(
749 "claude-opus-5[1m]".to_owned(),
750 vec!["claude-opus-5".to_owned()],
751 )]);
752 assert!(
753 account_serves(&models, &aliases, "claude-opus-5[1m]"),
754 "a declared name is served",
755 );
756 assert!(account_serves(&models, &aliases, "claude-opus-5"), "a declared alias is served");
757 assert!(
758 !account_serves(&models, &aliases, "claude-sonnet-5"),
759 "a name that is neither declared nor an alias is not served",
760 );
761 assert!(
762 !account_serves(&models, &std::collections::HashMap::new(), "claude-opus-5"),
763 "with no aliases declared the account serves only its declared names",
764 );
765 }
766
767 #[test]
768 fn an_aliased_name_reaches_the_route_gate_through_the_pool() {
769 let mut granite = make_account("Granite");
770 granite.models = vec!["claude-opus-5[1m]".to_owned()];
771 granite
772 .model_aliases
773 .insert("claude-opus-5[1m]".to_owned(), vec!["claude-opus-5".to_owned()]);
774 let pool = crate::AccountPool::new(&[granite]);
775 let key = AccountKey("Granite".to_owned());
776 assert!(
777 pool.declares(&key, "claude-opus-5"),
778 "the route gate reads the alias the config declared",
779 );
780 assert!(
781 !pool.declares(&key, "claude-sonnet-5"),
782 "the route gate still refuses a model the account does not have",
783 );
784 }
785
786 #[test]
787 fn env_accessor_returns_account_env() {
788 let mut codex = make_account("Codex");
789 codex.env.insert("ANTHROPIC_BASE_URL".to_owned(), "http://localhost:18765".to_owned());
790 let map = AccountStateMap::new(&[codex, make_account("Plain")]);
791 assert_eq!(
792 map.env(&AccountKey("Codex".to_owned()))
793 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
794 .map(String::as_str),
795 Some("http://localhost:18765"),
796 );
797 assert!(
798 map.env(&AccountKey("Plain".to_owned()))
799 .is_some_and(std::collections::HashMap::is_empty),
800 "account with no env -> empty map",
801 );
802 assert!(map.env(&AccountKey("Unknown".to_owned())).is_none(), "unknown key -> None");
803 }
804
805 #[test]
811 fn auth_classifies_the_repair_route_per_account() {
812 let mut token = make_account("Token");
813 token.env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), "setup-token".to_owned());
814 let mut base_url = make_account("Base");
815 base_url.provider = forge_primitives::account::Provider::Openrouter;
816 base_url.env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), "setup-token".to_owned());
817 let map = AccountStateMap::new(&[token, base_url, make_account("Tokenless")]);
818 assert_eq!(
819 map.auth(&AccountKey("Token".to_owned())),
820 Some(forge_primitives::account::AccountAuth::Token),
821 );
822 assert_eq!(
823 map.auth(&AccountKey("Base".to_owned())),
824 Some(forge_primitives::account::AccountAuth::BaseUrl),
825 "a base-url provider classifies BaseUrl even beside a setup token",
826 );
827 assert_eq!(
828 map.auth(&AccountKey("Tokenless".to_owned())),
829 Some(forge_primitives::account::AccountAuth::Token),
830 "a token-less anthropic account repairs as a token account",
831 );
832 assert_eq!(map.auth(&AccountKey("Unknown".to_owned())), None, "unknown key -> None");
833 }
834
835 #[test]
836 fn unusable_reason_names_why_the_account_is_out() {
837 let mut map = AccountStateMap::new(&[
838 make_account("saturated"),
839 make_account("probe-blocked"),
840 make_account("bailed"),
841 make_account("both"),
842 ]);
843 map.set_usage(&AccountKey("saturated".to_owned()), snapshot(Some(100.0), None));
844 map.set_last_error(
848 &AccountKey("probe-blocked".to_owned()),
849 UsageFetchStatus::RateLimited,
850 None,
851 );
852 map.set_loading(&AccountKey("bailed".to_owned()), LoadingState::Bailed);
853 map.set_usage(&AccountKey("both".to_owned()), snapshot(Some(100.0), None));
856 map.set_last_error(&AccountKey("both".to_owned()), UsageFetchStatus::RateLimited, None);
857
858 assert_eq!(map.unusable_reason(&key("saturated")), Some(Unusable::Saturated));
859 assert_eq!(map.unusable_reason(&key("probe-blocked")), Some(Unusable::ProbeBlocked));
860 assert_eq!(map.unusable_reason(&key("bailed")), Some(Unusable::Bailed));
861 assert_eq!(map.unusable_reason(&key("both")), Some(Unusable::Saturated));
862 }
863
864 #[test]
870 fn a_source_mismatched_snapshot_cannot_saturate_a_spend_row() {
871 let mut router = make_account("Router");
872 router.provider = forge_primitives::account::Provider::Openrouter;
873 let mut map = AccountStateMap::new(&[router]);
874 map.set_usage(&key("Router"), snapshot(Some(100.0), Some(100.0)));
877 assert_eq!(
878 map.unusable_reason(&key("Router")),
879 None,
880 "stale cache alone must not tag the spend row at all",
881 );
882
883 map.set_last_error(&key("Router"), UsageFetchStatus::RateLimited, None);
886 assert_eq!(
887 map.unusable_reason(&key("Router")),
888 Some(Unusable::ProbeBlocked),
889 "the stale snapshot falls through to the probe class",
890 );
891 }
892
893 #[test]
894 fn backoff_delay_doubles_then_caps_at_10_minutes() {
895 use std::time::Duration;
896 assert_eq!(backoff_delay(1), Duration::from_secs(30));
897 assert_eq!(backoff_delay(2), Duration::from_secs(60));
898 assert_eq!(backoff_delay(3), Duration::from_secs(120));
899 assert_eq!(backoff_delay(4), Duration::from_secs(240));
900 assert_eq!(backoff_delay(5), Duration::from_secs(480));
901 assert_eq!(backoff_delay(6), Duration::from_secs(600), "capped at 10 min");
902 assert_eq!(backoff_delay(20), Duration::from_secs(600), "still capped");
903 }
904
905 #[test]
906 fn should_probe_now_true_for_cold_cache() {
907 let map = AccountStateMap::new(&[make_account("Gateway")]);
908 assert!(map.should_probe_now(&AccountKey("Gateway".to_owned())));
909 }
910
911 #[test]
912 fn set_last_error_schedules_next_probe_in_future() {
913 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
914 let key = AccountKey("Gateway".to_owned());
915 map.set_last_error(&key, UsageFetchStatus::RateLimited, None);
916 assert!(!map.should_probe_now(&key), "first failure puts account in backoff");
917 }
918
919 #[test]
920 fn retry_after_overrides_exponential_backoff() {
921 use std::time::Duration;
925 let mut map = AccountStateMap::new(&[make_account("Gateway1")]);
926 let key = AccountKey("Gateway1".to_owned());
927 let t0 = std::time::Instant::now();
928 map.set_last_error(&key, UsageFetchStatus::RateLimited, Some(Duration::from_secs(480)));
929 let next = map.by_key.get(&key).and_then(|s| s.next_probe_at).expect("scheduled");
930 let gap = next.saturating_duration_since(t0);
931 assert!(
932 gap >= Duration::from_secs(479) && gap <= Duration::from_secs(481),
933 "next_probe_at ≈ now + 480s; got {gap:?}",
934 );
935 }
936
937 #[test]
938 fn retry_after_is_clamped_to_the_backoff_ceiling() {
939 use std::time::Duration;
943 let mut map = AccountStateMap::new(&[make_account("Gateway1")]);
944 let key = AccountKey("Gateway1".to_owned());
945 let t0 = std::time::Instant::now();
946 map.set_last_error(&key, UsageFetchStatus::RateLimited, Some(Duration::from_secs(100_000)));
947 let next = map.by_key.get(&key).and_then(|s| s.next_probe_at).expect("scheduled");
948 let gap = next.saturating_duration_since(t0);
949 assert!(
950 gap >= Duration::from_secs(599) && gap <= Duration::from_secs(601),
951 "next_probe_at clamps to ≈ 600s; got {gap:?}",
952 );
953 }
954
955 #[test]
956 fn set_usage_clears_backoff_so_account_probes_again() {
957 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
958 let key = AccountKey("Gateway".to_owned());
959 map.set_last_error(&key, UsageFetchStatus::RateLimited, None);
960 assert!(!map.should_probe_now(&key));
961 map.set_usage(&key, snapshot(Some(10.0), Some(10.0)));
962 assert!(map.should_probe_now(&key), "successful probe clears the backoff gate");
963 }
964
965 #[test]
966 fn seven_day_util_takes_max_across_windows() {
967 let mut s = snapshot(Some(20.0), Some(30.0));
969 s.seven_day_opus = Some(UsageWindow {
970 utilization: 80.0,
971 resets_at: None,
972 reset_description: None,
973 status: None,
974 });
975 assert_eq!(s.seven_day_util(), Some(80.0), "the most-used 7d window is the binding one");
976 }
977
978 #[test]
982 fn seven_day_util_is_none_when_every_window_is_absent() {
983 let mut s = snapshot(Some(20.0), None);
984 s.seven_day_opus = None;
985 s.seven_day_sonnet = None;
986 assert_eq!(s.seven_day_util(), None, "no 7d window means no 7d reading");
987 assert_eq!(s.five_hour_util(), Some(20.0), "the window that is present still reports");
988 }
989
990 #[test]
991 fn is_rate_limited_fires_on_either_window() {
992 assert!(is_rate_limited(&snapshot(Some(100.0), Some(0.0))));
993 assert!(is_rate_limited(&snapshot(Some(0.0), Some(100.0))));
994 assert!(is_rate_limited(&snapshot(Some(100.0), Some(100.0))));
995 assert!(!is_rate_limited(&snapshot(Some(99.9), Some(99.9))));
996 }
997
998 fn snapshot_with_resets(
1003 five_hour: Option<(f64, Option<SystemTime>)>,
1004 seven_day: Option<(f64, Option<SystemTime>)>,
1005 ) -> UsageSnapshot {
1006 UsageSnapshot {
1007 source: UsageSourceKind::Oauth,
1008 fetched_at: SystemTime::UNIX_EPOCH,
1009 five_hour: five_hour.map(|(util, resets_at)| UsageWindow {
1010 utilization: util,
1011 resets_at,
1012 reset_description: None,
1013 status: None,
1014 }),
1015 seven_day: seven_day.map(|(util, resets_at)| UsageWindow {
1016 utilization: util,
1017 resets_at,
1018 reset_description: None,
1019 status: None,
1020 }),
1021 seven_day_opus: None,
1022 seven_day_sonnet: None,
1023 extra_usage: None,
1024 spend: None,
1025 balance: None,
1026 }
1027 }
1028
1029 #[test]
1030 fn is_rate_limited_false_when_cached_window_reset_already_passed() {
1031 let past = SystemTime::now() - std::time::Duration::from_secs(60);
1036 let snap = snapshot_with_resets(Some((100.0, Some(past))), Some((50.0, Some(past))));
1037 assert!(!is_rate_limited(&snap), "stale 100% reading must not strand the account");
1038 }
1039
1040 #[test]
1041 fn is_rate_limited_true_when_only_one_window_is_capped_and_still_in_window() {
1042 let future = SystemTime::now() + std::time::Duration::from_secs(60);
1046 let snap = snapshot_with_resets(Some((100.0, Some(future))), Some((30.0, Some(future))));
1047 assert!(is_rate_limited(&snap));
1048 }
1049
1050 fn opus_only_capped_snapshot(util: f64, resets_at: Option<SystemTime>) -> UsageSnapshot {
1056 UsageSnapshot {
1057 source: UsageSourceKind::Oauth,
1058 fetched_at: SystemTime::UNIX_EPOCH,
1059 five_hour: Some(UsageWindow {
1060 utilization: 30.0,
1061 resets_at: Some(SystemTime::now() + std::time::Duration::from_secs(3600)),
1062 reset_description: None,
1063 status: None,
1064 }),
1065 seven_day: Some(UsageWindow {
1066 utilization: 30.0,
1067 resets_at: Some(SystemTime::now() + std::time::Duration::from_secs(3600)),
1068 reset_description: None,
1069 status: None,
1070 }),
1071 seven_day_opus: Some(UsageWindow {
1072 utilization: util,
1073 resets_at,
1074 reset_description: None,
1075 status: None,
1076 }),
1077 seven_day_sonnet: None,
1078 extra_usage: None,
1079 spend: None,
1080 balance: None,
1081 }
1082 }
1083
1084 fn sonnet_only_capped_snapshot(util: f64, resets_at: Option<SystemTime>) -> UsageSnapshot {
1088 UsageSnapshot {
1089 source: UsageSourceKind::Oauth,
1090 fetched_at: SystemTime::UNIX_EPOCH,
1091 five_hour: Some(UsageWindow {
1092 utilization: 30.0,
1093 resets_at: Some(SystemTime::now() + std::time::Duration::from_secs(3600)),
1094 reset_description: None,
1095 status: None,
1096 }),
1097 seven_day: Some(UsageWindow {
1098 utilization: 30.0,
1099 resets_at: Some(SystemTime::now() + std::time::Duration::from_secs(3600)),
1100 reset_description: None,
1101 status: None,
1102 }),
1103 seven_day_opus: None,
1104 seven_day_sonnet: Some(UsageWindow {
1105 utilization: util,
1106 resets_at,
1107 reset_description: None,
1108 status: None,
1109 }),
1110 extra_usage: None,
1111 spend: None,
1112 balance: None,
1113 }
1114 }
1115
1116 #[test]
1117 fn is_rate_limited_fires_on_opus_only_capped_window() {
1118 let future = SystemTime::now() + std::time::Duration::from_secs(60);
1122 let snap = opus_only_capped_snapshot(100.0, Some(future));
1123 assert!(is_rate_limited(&snap), "opus 7d cap (alone) must be detected");
1124 }
1125
1126 #[test]
1127 fn is_rate_limited_fires_on_sonnet_only_capped_window() {
1128 let future = SystemTime::now() + std::time::Duration::from_secs(60);
1130 let snap = sonnet_only_capped_snapshot(100.0, Some(future));
1131 assert!(is_rate_limited(&snap), "sonnet 7d cap (alone) must be detected");
1132 }
1133
1134 #[test]
1135 fn is_rate_limited_false_when_opus_capped_but_reset_passed() {
1136 let past = SystemTime::now() - std::time::Duration::from_secs(60);
1141 let snap = opus_only_capped_snapshot(100.0, Some(past));
1142 assert!(!is_rate_limited(&snap), "stale opus window must self-clear like any other");
1143 }
1144
1145 #[test]
1146 fn is_rate_limited_false_when_window_at_cap_but_no_reset_scheduled() {
1147 let snap = snapshot_with_resets(Some((100.0, None)), Some((100.0, None)));
1151 assert!(!is_rate_limited(&snap), "None resets_at must not classify as limited");
1152 }
1153
1154 #[test]
1155 fn has_just_cleared_cap_window_true_when_cached_window_reset_passed() {
1156 let past = SystemTime::now() - std::time::Duration::from_secs(60);
1160 let stale = snapshot_with_resets(Some((100.0, Some(past))), Some((30.0, Some(past))));
1161 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1162 let k = AccountKey("Gateway".to_owned());
1163 map.set_usage(&k, stale);
1164 assert!(map.has_just_cleared_cap_window(&k));
1165 }
1166
1167 #[test]
1168 fn has_just_cleared_cap_window_false_when_window_still_in_cap() {
1169 let future = SystemTime::now() + std::time::Duration::from_secs(60);
1172 let live = snapshot_with_resets(Some((100.0, Some(future))), Some((30.0, Some(future))));
1173 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1174 let k = AccountKey("Gateway".to_owned());
1175 map.set_usage(&k, live);
1176 assert!(!map.has_just_cleared_cap_window(&k));
1177 }
1178
1179 #[test]
1180 fn has_just_cleared_cap_window_false_when_no_snapshot_cached() {
1181 let map = AccountStateMap::new(&[make_account("Gateway")]);
1185 assert!(!map.has_just_cleared_cap_window(&AccountKey("Gateway".to_owned())));
1186 }
1187
1188 #[test]
1189 fn has_just_cleared_cap_window_false_when_below_cap() {
1190 let past = SystemTime::now() - std::time::Duration::from_secs(60);
1194 let snap = snapshot_with_resets(Some((50.0, Some(past))), Some((30.0, Some(past))));
1195 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1196 let k = AccountKey("Gateway".to_owned());
1197 map.set_usage(&k, snap);
1198 assert!(!map.has_just_cleared_cap_window(&k));
1199 }
1200
1201 #[test]
1202 fn override_disarms_after_firing_and_does_not_refire_until_rearmed() {
1203 let past = SystemTime::now() - std::time::Duration::from_secs(60);
1209 let stale = snapshot_with_resets(Some((100.0, Some(past))), None);
1210 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1211 let k = AccountKey("Gateway".to_owned());
1212 map.set_usage(&k, stale);
1213 map.set_last_error(
1217 &k,
1218 UsageFetchStatus::RateLimited,
1219 Some(std::time::Duration::from_secs(3000)),
1220 );
1221 assert!(!map.should_probe_now(&k), "backoff active");
1222 assert!(map.has_just_cleared_cap_window(&k), "hook fires while armed");
1223
1224 map.disarm_override(&k);
1227 assert!(!map.has_just_cleared_cap_window(&k), "disarmed; no second override this cycle");
1228 assert!(!map.should_probe_now(&k), "backoff still active");
1229 assert!(
1232 !map.should_probe_now(&k) && !map.has_just_cleared_cap_window(&k),
1233 "scheduler must respect backoff once the override has been consumed",
1234 );
1235 }
1236
1237 #[test]
1238 fn fresh_set_usage_rearms_override_for_next_reset_boundary() {
1239 let past = SystemTime::now() - std::time::Duration::from_secs(60);
1243 let stale = snapshot_with_resets(Some((100.0, Some(past))), None);
1244 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1245 let k = AccountKey("Gateway".to_owned());
1246 map.set_usage(&k, stale.clone());
1247 map.disarm_override(&k);
1248 assert!(!map.has_just_cleared_cap_window(&k), "disarmed");
1249
1250 map.set_usage(&k, stale);
1254 assert!(map.has_just_cleared_cap_window(&k), "fresh set_usage re-arms the hook");
1255 }
1256
1257 #[test]
1258 fn override_not_armed_on_construction_so_hook_skips_cold_accounts() {
1259 let map = AccountStateMap::new(&[make_account("Gateway")]);
1264 let k = AccountKey("Gateway".to_owned());
1265 assert!(!map.has_just_cleared_cap_window(&k), "no arm = no override");
1266 }
1267
1268 #[test]
1276 fn scheduler_should_probe_fires_via_should_probe_now_alone() {
1277 let map = AccountStateMap::new(&[make_account("Gateway")]);
1282 let k = AccountKey("Gateway".to_owned());
1283 assert!(map.should_probe_now(&k));
1284 assert!(!map.has_just_cleared_cap_window(&k));
1285 assert!(map.scheduler_should_probe(&k), "OR true via should_probe_now");
1286 }
1287
1288 #[test]
1289 fn scheduler_should_probe_fires_via_hook_alone_during_active_backoff() {
1290 let past = SystemTime::now() - std::time::Duration::from_secs(60);
1298 let stale = snapshot_with_resets(Some((100.0, Some(past))), None);
1299 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1300 let k = AccountKey("Gateway".to_owned());
1301 map.set_usage(&k, stale);
1302 map.set_last_error(
1303 &k,
1304 UsageFetchStatus::RateLimited,
1305 Some(std::time::Duration::from_secs(3000)),
1306 );
1307
1308 assert!(!map.should_probe_now(&k), "backoff active");
1309 assert!(map.has_just_cleared_cap_window(&k), "armed + stale reset");
1310 assert!(map.scheduler_should_probe(&k), "OR fires when hook alone is true");
1311 }
1312
1313 #[test]
1314 fn scheduler_should_probe_skips_when_both_predicates_false() {
1315 let future = SystemTime::now() + std::time::Duration::from_secs(60);
1320 let live = snapshot_with_resets(Some((100.0, Some(future))), None);
1321 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1322 let k = AccountKey("Gateway".to_owned());
1323 map.set_usage(&k, live);
1324 map.set_last_error(
1325 &k,
1326 UsageFetchStatus::RateLimited,
1327 Some(std::time::Duration::from_secs(3000)),
1328 );
1329
1330 assert!(!map.should_probe_now(&k));
1331 assert!(!map.has_just_cleared_cap_window(&k));
1332 assert!(!map.scheduler_should_probe(&k), "OR false when both sides are false");
1333 }
1334
1335 fn key(name: &str) -> AccountKey {
1347 AccountKey(name.to_owned())
1348 }
1349
1350 #[test]
1351 fn loading_state_initial_is_loading() {
1352 let map = AccountStateMap::new(&[make_account("Gateway")]);
1356 assert_eq!(map.loading_state(&key("Gateway")), LoadingState::Loading);
1357 }
1358
1359 #[test]
1360 fn loading_state_returns_loading_for_unknown_key() {
1361 let map = AccountStateMap::new(&[make_account("Gateway")]);
1364 assert_eq!(map.loading_state(&key("NotRegistered")), LoadingState::Loading);
1365 }
1366
1367 #[test]
1368 fn set_usage_transitions_loading_to_ready() {
1369 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1370 let k = key("Gateway");
1371 assert_eq!(map.loading_state(&k), LoadingState::Loading);
1372 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1373 assert_eq!(map.loading_state(&k), LoadingState::Ready);
1374 }
1375
1376 #[test]
1377 fn set_last_error_unauthorized_transitions_to_bailed_and_clears_usage() {
1378 let mut map = AccountStateMap::new(&[make_account("Personal")]);
1383 let k = key("Personal");
1384 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1385 assert!(map.usage(&k).is_some(), "cache primed");
1386 assert_eq!(map.loading_state(&k), LoadingState::Ready);
1387
1388 map.set_last_error(&k, UsageFetchStatus::Unauthorized, None);
1389
1390 assert_eq!(map.loading_state(&k), LoadingState::Bailed);
1391 assert!(map.usage(&k).is_none(), "Bailed clears usage by construction");
1392 }
1393
1394 #[test]
1395 fn set_last_error_expired_transitions_to_bailed_and_clears_usage() {
1396 let mut map = AccountStateMap::new(&[make_account("Personal")]);
1399 let k = key("Personal");
1400 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1401
1402 map.set_last_error(&k, UsageFetchStatus::Expired, None);
1403
1404 assert_eq!(map.loading_state(&k), LoadingState::Bailed);
1405 assert!(map.usage(&k).is_none());
1406 }
1407
1408 #[test]
1414 fn a_limit_reading_is_kept_beside_the_successful_snapshot() {
1415 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1416 let k = key("Gateway");
1417 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1418 map.set_last_error(&k, UsageFetchStatus::RateLimited, None);
1419
1420 map.set_limit_reading(&k, snapshot(Some(100.0), Some(42.0)));
1421
1422 let reading = map.latest_reading(&k).expect("the 429's own windows");
1423 assert_eq!(reading.five_hour.as_ref().map(|w| w.utilization), Some(100.0));
1424 assert_eq!(
1425 map.usage(&k).and_then(|s| s.five_hour.as_ref().map(|w| w.utilization)),
1426 Some(30.0),
1427 "the walk's saturation input stays on the last successful probe",
1428 );
1429 assert!(!map.is_saturated(&k), "a 429 reading must not demote the account in the walk");
1430 }
1431
1432 #[test]
1436 fn a_success_or_an_auth_bail_clears_the_limit_reading() {
1437 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1438 let k = key("Gateway");
1439 map.set_limit_reading(&k, snapshot(Some(100.0), None));
1440
1441 map.set_usage(&k, snapshot(Some(20.0), Some(10.0)));
1442 assert_eq!(
1443 map.latest_reading(&k).and_then(|s| s.five_hour.as_ref().map(|w| w.utilization)),
1444 Some(20.0),
1445 "a successful probe is newer than the reading it replaces",
1446 );
1447
1448 map.set_limit_reading(&k, snapshot(Some(100.0), None));
1449 map.set_last_error(&k, UsageFetchStatus::Unauthorized, None);
1450 assert!(map.latest_reading(&k).is_none(), "auth bail drops the reading with the cache");
1451 }
1452
1453 #[test]
1454 fn set_last_error_rate_limited_leaves_loading_unchanged() {
1455 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1460 let k = key("Gateway");
1461 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1462 assert_eq!(map.loading_state(&k), LoadingState::Ready);
1463
1464 map.set_last_error(&k, UsageFetchStatus::RateLimited, None);
1465
1466 assert_eq!(map.loading_state(&k), LoadingState::Ready, "RateLimited preserves Ready");
1467 assert!(map.usage(&k).is_some(), "cache preserved under RateLimited");
1468 }
1469
1470 #[test]
1471 fn set_last_error_network_failed_leaves_loading_unchanged() {
1472 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1475 let k = key("Gateway");
1476 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1477
1478 map.set_last_error(&k, UsageFetchStatus::NetworkFailed, None);
1479
1480 assert_eq!(map.loading_state(&k), LoadingState::Ready);
1481 assert!(map.usage(&k).is_some());
1482 }
1483
1484 #[test]
1485 fn set_loading_bailed_clears_usage() {
1486 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1490 let k = key("Gateway");
1491 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1492
1493 map.set_loading(&k, LoadingState::Bailed);
1494
1495 assert_eq!(map.loading_state(&k), LoadingState::Bailed);
1496 assert!(map.usage(&k).is_none(), "explicit set_loading(Bailed) also clears usage");
1497 }
1498
1499 #[test]
1500 fn set_loading_to_loading_does_not_clear_usage() {
1501 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1506 let k = key("Gateway");
1507 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1508
1509 map.set_loading(&k, LoadingState::Loading);
1510
1511 assert_eq!(map.loading_state(&k), LoadingState::Loading);
1512 assert!(map.usage(&k).is_some(), "transition to Loading preserves any cached snapshot");
1513 }
1514
1515 #[test]
1516 fn bailed_account_recovers_to_ready_via_set_usage() {
1517 let mut map = AccountStateMap::new(&[make_account("Personal")]);
1521 let k = key("Personal");
1522 map.set_usage(&k, snapshot(Some(30.0), Some(40.0)));
1523 map.set_last_error(&k, UsageFetchStatus::Unauthorized, None);
1524 assert_eq!(map.loading_state(&k), LoadingState::Bailed);
1525
1526 map.set_usage(&k, snapshot(Some(50.0), Some(60.0)));
1527
1528 assert_eq!(map.loading_state(&k), LoadingState::Ready);
1529 assert!(map.usage(&k).is_some());
1530 }
1531
1532 #[test]
1533 fn all_loaded_true_for_empty_map() {
1534 let map = AccountStateMap::empty_for_test();
1535 assert!(map.all_loaded(), "empty map is vacuously all-loaded");
1536 }
1537
1538 #[test]
1539 fn all_loaded_false_when_any_loading() {
1540 let map = AccountStateMap::new(&[make_account("Gateway"), make_account("Personal")]);
1541 assert!(!map.all_loaded(), "fresh accounts start in Loading; gate must stay closed");
1542 }
1543
1544 #[test]
1545 fn all_loaded_true_when_mix_of_ready_and_bailed() {
1546 let mut map = AccountStateMap::new(&[make_account("Gateway"), make_account("Personal")]);
1547 map.set_usage(&key("Gateway"), snapshot(Some(10.0), Some(20.0)));
1548 map.set_last_error(&key("Personal"), UsageFetchStatus::Unauthorized, None);
1549 assert_eq!(map.loading_state(&key("Gateway")), LoadingState::Ready);
1550 assert_eq!(map.loading_state(&key("Personal")), LoadingState::Bailed);
1551 assert!(map.all_loaded(), "Ready + Bailed are both terminal states");
1552 }
1553
1554 #[test]
1558 fn a_bailed_account_probes_again_once_its_hold_down_expires() {
1559 let mut map = AccountStateMap::new(&[make_account("Gateway")]);
1560 let k = key("Gateway");
1561 map.set_last_error(
1562 &k,
1563 UsageFetchStatus::RateLimited,
1564 Some(std::time::Duration::from_secs(3600)),
1565 );
1566 map.set_loading(&k, LoadingState::Bailed);
1567 assert!(!map.scheduler_should_probe(&k), "an active hold-down gates the re-probe");
1568 map.by_key.get_mut(&k).unwrap().next_probe_at = Some(
1569 std::time::Instant::now()
1570 .checked_sub(std::time::Duration::from_secs(1))
1571 .expect("test clock is not at the Instant floor"),
1572 );
1573 assert!(
1574 map.scheduler_should_probe(&k),
1575 "an expired hold-down re-opens the probe even though the account is terminal",
1576 );
1577 }
1578}