Skip to main content

forge_gateway/
account.rs

1//! Account state, health and probe scheduling.
2//!
3//! The per-account state map every selection reads: it holds each
4//! account's declared models, its `LoadingState`, its latest usage
5//! snapshot and its last probe outcome. The walk that turns that into
6//! a picked account lives in `selection.rs`; this module owns the
7//! state and the classification the walk and the accounts read both
8//! use.
9//!
10//! **Two-tier classification:**
11//!
12//! 1. **Usable** - usage is unknown, OR usage shows under 100% on
13//!    both windows, OR last probe failed transiently (network /
14//!    other HTTP). A session can try this account; the spawned
15//!    `claude` either succeeds or surfaces its own rate-limit error
16//!    we can react to.
17//! 2. **Unusable** - usage shows 100% on at least one window, OR
18//!    `/api/oauth/usage` returned 429, OR credentials are expired /
19//!    unauthorized. Known to be either at the cap or unable to
20//!    authenticate.
21//!
22//! Saturation and probe failures are classification only - they feed
23//! the walk's preference order and the accounts read.
24//!
25//! Rate-limited accounts are visible in a view's usage bars so the
26//! user can wait for the window to reset.
27
28use forge_primitives::usage::{UsageSnapshot, UsageWindow};
29
30use forge_primitives::account::LoadedAccount;
31
32/// Newtype wrapping the account's `display_name`.
33#[derive(Clone, Debug, Hash, Eq, PartialEq)]
34pub struct AccountKey(pub String);
35
36/// Classification of the latest usage-poll attempt outcome for an
37/// account. Feeds `unusable_reason`'s probe-blocked branch and the
38/// backoff schedule.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum UsageFetchStatus {
42    /// Anthropic returned HTTP 429 - too many concurrent polls
43    /// against the OAuth `/api/oauth/usage` endpoint (typical when
44    /// multiple forge instances poll from the same machine).
45    RateLimited,
46    /// The account's credential is dead or absent (expired, or no
47    /// token at all). Repair is minting the setup token on the account
48    /// block plus a restart.
49    Expired,
50    /// API returned 401/403 - token rejected (may be revoked).
51    Unauthorized,
52    /// Network failure reaching the API (DNS, TLS, timeout, …).
53    NetworkFailed,
54    /// Decode / unknown-HTTP-status fallthrough, kept distinct so an
55    /// unmapped cause names itself instead of wearing a known bucket.
56    /// Like `NetworkFailed` it does not block the account.
57    Other,
58}
59
60/// Why an account is held out of the usable tier.
61/// Which reason can apply is keyed on the account's billing kind: only
62/// a window-billed account can saturate, so on a spend row an unusable
63/// account can only be probe-blocked. A snapshot whose source is not
64/// the account backend's is ignored here, so a cache surviving a
65/// `forge.toml` provider edit cannot saturate the row it landed on.
66#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum Unusable {
69    /// A plan window is at or over its cap and has not reset yet.
70    Saturated,
71    /// The last probe failed before it could read usage: throttled,
72    /// rejected, or expired credentials.
73    ProbeBlocked,
74    /// The boot-time loading task ended in `Bailed` on an auth
75    /// failure.
76    Bailed,
77}
78
79/// Boot-time loading state for an account. The boot wave and the cron
80/// spawn path wait for every account in the map to resolve to `Ready`
81/// or `Bailed`; the selection walk skips `Loading` and keeps `Bailed`
82/// as its last resort. A bailed account's `usage` is `None` by
83/// construction (the loader clears it on the transition).
84#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum LoadingState {
87    /// First-pass probe in progress.
88    Loading,
89    /// Probe returned 200; account is usable and may be assigned to
90    /// sessions.
91    Ready,
92    /// The probe ended in an auth failure the account cannot recover
93    /// from in place. The user fixes the credential and lets the 60 s
94    /// usage poller flip the account back to `Ready`.
95    Bailed,
96}
97
98#[derive(Clone)]
99pub struct AccountState {
100    /// Declared backend from `[[accounts]] provider`. Decides which
101    /// endpoint the usage probe hits and which credential it carries.
102    pub provider: forge_primitives::account::Provider,
103    /// The canonical model names the account serves. Selection and
104    /// the route's model gate match through [`account_serves`].
105    pub models: Vec<String>,
106    /// Canonical name -> the other names a request may arrive under for
107    /// that model. Keys are always members of [`Self::models`].
108    pub model_aliases: std::collections::HashMap<String, Vec<String>>,
109    /// Canonical name -> upstream slug, only where the spellings
110    /// differ. The forward splices the slug into the request body.
111    pub model_slugs: std::collections::HashMap<String, String>,
112    /// Per-account environment: provider-behaviour extras plus the
113    /// derived credential and base-url variable stamps. Stamped onto
114    /// the account's `claude` subprocess at spawn, and read by the
115    /// usage probe for the `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`
116    /// a base-url provider authenticates with.
117    pub env: std::collections::HashMap<String, String>,
118    /// Latest usage snapshot fetched by the workspace's background
119    /// poller. `None` until the first successful fetch. Drives the
120    /// selection walk's order and the cooldown's reset; a view reads
121    /// [`Self::limit_reading`] with it, in that order.
122    pub usage: Option<UsageSnapshot>,
123    /// The windows a rate-limited probe carried, kept for the view beside
124    /// `usage` rather than in it: they never heal the account, and
125    /// selection and cooldowns must keep reading the successful probe.
126    pub limit_reading: Option<UsageSnapshot>,
127    /// Latest poll-attempt outcome when the fetch failed. Cleared
128    /// (set to `None`) on the next successful fetch. `unusable_reason`
129    /// reads this to classify a probe-blocked account, and the write
130    /// schedules the next probe's backoff (`next_probe_at`).
131    pub last_error: Option<UsageFetchStatus>,
132    /// Earliest wall-clock time we're allowed to probe this account
133    /// again. The poller skips accounts whose `next_probe_at` is in
134    /// the future. Used for per-account exponential backoff when
135    /// Anthropic's `/api/oauth/usage` endpoint keeps returning 429
136    /// (without backoff every poll cycle re-trips the per-IP rate
137    /// limit and we never accumulate usage data). `None` means
138    /// "probe on the next tick" (default).
139    pub next_probe_at: Option<std::time::Instant>,
140    /// Consecutive probe failures since the last success. Drives
141    /// the backoff schedule: each consecutive failure doubles the
142    /// next-probe delay (capped at a sensible ceiling). Reset to
143    /// 0 on success.
144    pub consecutive_failures: u32,
145    /// One-shot arming flag for the `has_just_cleared_cap_window`
146    /// scheduler hook. Without it, a stale-reset snapshot paired with
147    /// sustained probe failure would re-trigger the override every
148    /// poll cycle, defeating the exponential backoff schedule -
149    /// forge would hammer Anthropic's `/api/oauth/usage` endpoint once
150    /// per minute through a multi-hour outage instead of backing off.
151    /// `set_usage` arms it (a fresh snapshot earns one override
152    /// attempt once its `resets_at` passes); `disarm_override` clears
153    /// it after the scheduler fires an override. False until the
154    /// first successful probe lands a snapshot. Distinct from
155    /// `LoadingState` - rate-limit concern, not auth-recovery.
156    pub override_armed: bool,
157    /// Where this account sits in the boot-time loading state
158    /// machine. The boot wave and the cron spawn path gate on this
159    /// across every account; the selection walk only considers
160    /// accounts whose state is `Ready` (or a saturated `Ready`).
161    pub loading: LoadingState,
162    /// A config reload removed this account from the file. It is not in the
163    /// selection walk any more, but a session already bound to it keeps
164    /// working until the session ends.
165    pub retired: bool,
166}
167
168/// Hand-written because the env map carries the account's credential
169/// stamps, which the derived output would print.
170impl 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
192/// `true` when an account declaring `models` serves `model`: it
193/// declares the name, or declares it as an alias of a model it does.
194/// The one home for the rule: the selection walk, the route's binding
195/// gate, and the config load gate all match a request's model here.
196pub 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>, // forge.toml definition order
207    pub by_key: std::collections::HashMap<AccountKey, AccountState>,
208}
209
210/// Hand-written so a field added later cannot print through it until it
211/// is deliberately listed; the states it holds redact themselves.
212impl 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    /// Empty map for the `testing` feature's `Workspace::testing_stub`.
223    /// Production code paths reach this map only through the gateway's
224    /// selection walk, which a test fixture should never exercise.
225    #[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    /// Seed the in-memory map from a previously-persisted cache.
259    /// Each known account that appears in `cached` gets its `usage`
260    /// populated; unknown cache entries (account removed from
261    /// forge.toml) are ignored. Does NOT clear `last_error` or
262    /// `next_probe_at` - the cache is purely seed data; the live
263    /// poller still drives backoff. Used by `Workspace::new` so
264    /// usage reads have data on cold boot.
265    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    /// Snapshot the per-account `usage` for writing to the on-disk
275    /// cache. Accounts with no live snapshot are omitted so the cache
276    /// file doesn't grow placeholders.
277    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    /// The account's env map: global `[env]` extended by its own
288    /// `[accounts.env]`. Consumed by the spawn path (stamped onto the
289    /// child) and the usage poller / loader (base url, bearer, setup
290    /// token). `None` for unknown keys.
291    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    /// Declared [`forge_primitives::account::Provider`] for `key`, the input the probe plan and the
296    /// preflight repair copy both branch on. `None` for unknown keys.
297    pub fn provider(&self, key: &AccountKey) -> Option<forge_primitives::account::Provider> {
298        self.by_key.get(key).map(|s| s.provider)
299    }
300
301    /// How the account proves who it is, which is the only thing that
302    /// changes what an auth-repair hint tells the user to do. Base-url
303    /// wins: such an account authenticates with the flat token beside
304    /// its base_url. Every other account's credential is its setup
305    /// token, so both its token-mode and its token-less shapes repair
306    /// the same way. `None` for unknown keys.
307    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    /// Replace the cached usage snapshot for `key` and clear any
315    /// stale `last_error`. Resets the consecutive-failure counter
316    /// and clears the next-probe gate so this account returns to
317    /// the default cadence. Also transitions `loading` to `Ready` -
318    /// a successful probe is the terminal-success state for the
319    /// boot-time loading task. Silent no-op when `key` isn't
320    /// registered (defensive - invariant says every poller key was
321    /// inserted in `new()`).
322    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            // Arm the scheduler-hook override: this fresh snapshot
331            // earns ONE probe attempt once its `resets_at` passes.
332            // Without re-arming, the hook stays disarmed forever after
333            // its first fire and a stale cached window never gets a
334            // fresh probe even on a healthy account.
335            state.override_armed = true;
336        }
337    }
338
339    /// Drive a `LoadingState` transition for `key`, used by the
340    /// boot-time loading task. Setting `Bailed` clears the cached
341    /// `usage` so no stale bar is drawn (replaces the PR #238
342    /// 3-strike counter; bailed accounts have no live snapshot by
343    /// construction).
344    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    /// Keep the windows a failed probe still carried. It never touches
355    /// `loading` or `last_error`: a rate-limited probe does not heal the
356    /// account, and nothing selection or a cooldown reads may move.
357    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    /// The newest reading the account gave, for a view's bars: a
364    /// rate-limited response's windows when one landed after the last
365    /// successful probe, else that probe's snapshot. Absent while both are.
366    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    /// Disarm the scheduler hook for `key` after firing an override
372    /// probe attempt. One-shot semantics: the hook fires once per
373    /// arming (i.e. once per fresh snapshot). Subsequent stale-reset
374    /// state must wait for the next successful probe (which re-arms
375    /// via `set_usage`) or until the existing backoff timer
376    /// (`next_probe_at`) elapses.
377    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    /// Record the latest poll-attempt failure for `key` and schedule
384    /// the next probe.
385    ///
386    /// `retry_after` (when `Some`) is the server-provided hold-down
387    /// duration - typically Anthropic's `Retry-After` header on 429.
388    /// We honour it verbatim because the server knows when its
389    /// per-account bucket will reset; guessing with our own backoff
390    /// either over- or under-shoots and keeps the limit hot. When
391    /// `None` (network failures, unknown HTTP status, server didn't
392    /// send Retry-After), fall back to a local exponential schedule.
393    ///
394    /// The cached `usage` snapshot is preserved so the panel keeps
395    /// showing the last known-good bars (a fresh 429 doesn't blank
396    /// them out).
397    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            // Auth-recovery cache-clear: an Unauthorized or Expired
407            // probe response means the account's credential is dead.
408            // Transition `loading` to `Bailed` and drop the
409            // cached `usage` so no stale bar is drawn and the account
410            // reads bailed. The account recovers via the
411            // 60 s usage poller, which reads the env the config now
412            // holds. Other statuses leave
413            // `loading` alone (a transient `RateLimited` or
414            // `NetworkFailed` is not auth-related; the cache stays
415            // and the account remains Ready for the walk).
416            // Replaces the PR #238 `consecutive_unauthorized` 3-strike
417            // counter.
418            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                // Surface the transition so an operator triaging
424                // "account suddenly stopped working" can see WHEN the
425                // bail happened + which probe-class triggered it.
426                // Without this log a Ready -> Bailed flip is silent
427                // until a poller runs again.
428                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            // Anthropic returns `Retry-After: 0` on the /api/oauth/usage
440            // 429 path, which is "no specific hint" rather than "you can
441            // retry now". Trusting it literally schedules next_probe_at
442            // at the same instant and the next round re-trips the same
443            // rate limit - that's the burst-probe behaviour the warm
444            // loop kept hitting. Treat any sub-second hint as missing
445            // and fall through to the exponential default
446            // (starts at 30 s), so the leaky-bucket actually gets
447            // a chance to refill.
448            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    /// `true` when the poller may probe `key` now. `false` when the
457    /// account is in an active backoff window from a recent failure.
458    /// Cold-cache accounts (no last_error) always return `true`.
459    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    /// `true` when every account in the map has reached a terminal
465    /// `LoadingState` (`Ready` or `Bailed`). The boot wave and the cron
466    /// spawn path gate on this. Empty maps return `true` (vacuous; no
467    /// accounts means no work to wait on - relevant in the testing-stub
468    /// path). A retired account counts as settled: the edit removed it, so
469    /// nothing will ever load it.
470    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    /// True when `key`'s loaded usage snapshot shows it at-or-beyond
477    /// the plan cap - the same saturation signal `unusable_reason`
478    /// classifies on. A Ready-but-saturated account logs in
479    /// fine but trips the rate limit on its next request, so the
480    /// selection walk prefers other accounts when one is available.
481    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    /// Why `key` is out of the usable tier, `None` when pickable: what
486    /// the walk reads to demote an account. `Bailed`
487    /// outranks the probe classes: it is the loading task's terminal
488    /// verdict, and a bailed account has no cached windows to saturate
489    /// with. A snapshot whose source is not the account backend's is
490    /// treated as absent, the same refusal `budget()` applies - the
491    /// redb cache survives a provider edit and is re-seeded at boot,
492    /// and without this a stale windowed snapshot would classify a
493    /// spend row as saturated.
494    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    /// Snapshot the current `LoadingState` for `key`. Returns
506    /// `LoadingState::Loading` by default for unknown keys
507    /// (defensive - a caller may hold a key that hasn't been
508    /// registered in the map).
509    pub fn loading_state(&self, key: &AccountKey) -> LoadingState {
510        self.by_key.get(key).map_or(LoadingState::Loading, |s| s.loading)
511    }
512
513    /// Combined scheduler signal: probe `key` on the current cycle if
514    /// the normal backoff gate (`should_probe_now`) is open OR the
515    /// one-shot reset-clear override hook (`has_just_cleared_cap_window`)
516    /// is firing. Both sides of the OR carry through to the workspace
517    /// poller, which calls this method to decide which accounts to
518    /// probe each tick. Centralising the OR here keeps a future
519    /// refactor from accidentally dropping one half of the signal -
520    /// the unit test pins the truth table that the scheduler relies
521    /// on, regardless of whether the call site spells out both
522    /// predicates inline.
523    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    /// `true` when at least one cached window shows the account just
528    /// transitioned out of its cap (utilization at-or-above 100%,
529    /// `resets_at` now in the past) AND the override hook is armed.
530    /// The scheduler ORs this with `should_probe_now` so a fresh probe
531    /// lands on the next poll cycle after the reset moment, instead of
532    /// waiting through the remainder of an active backoff window.
533    /// Without the hook, an account 429'd with a multi-hour
534    /// `Retry-After` keeps painting the stale "100%" bar for hours
535    /// past the actual reset because the next probe is gated until the
536    /// backoff timer elapses.
537    ///
538    /// The `override_armed` gate enforces one-shot semantics: each
539    /// successful probe arms the hook exactly once, and the scheduler
540    /// disarms it after firing. A persistently failing probe series
541    /// won't keep tripping the override every cycle.
542    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    /// Look up the cached usage snapshot for `key`. `None` when the
566    /// poller hasn't yet succeeded for this account.
567    pub fn usage(&self, key: &AccountKey) -> Option<&UsageSnapshot> {
568        self.by_key.get(key).and_then(|s| s.usage.as_ref())
569    }
570
571    /// Look up the latest poll-attempt failure for `key`. `None`
572    /// when the most recent attempt succeeded (or no attempt has
573    /// been made yet).
574    pub fn usage_error(&self, key: &AccountKey) -> Option<UsageFetchStatus> {
575        self.by_key.get(key).and_then(|s| s.last_error)
576    }
577
578    /// Drop the recorded failure for `key`, so a bail that never
579    /// recorded its own cause falls back to the unrecorded default
580    /// rather than wearing a stale class.
581    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
588/// The classification behind [`AccountStateMap::unusable_reason`] for a
589/// key already resolved to its inputs. `None` means usable: usage
590/// unknown, or under 100% on every window, or the last probe failed
591/// transiently (network / unknown HTTP - we just don't know yet).
592/// `Some` means at the cap, or unable to authenticate.
593///
594/// Saturation outranks a failed probe: the windows came from a
595/// successful read, and on an at-cap account the failed probe usually
596/// IS the cap throttling the endpoint.
597fn 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
617/// True when ANY window in the snapshot is currently at-or-beyond
618/// the plan cap AND its scheduled reset has not yet passed. Such an
619/// account will immediately trip the API rate limit on the next
620/// request, so it's excluded from the "available" tier and only used
621/// as a fallback when every pinned account is rate-limited.
622///
623/// Each window's predicate (`UsageWindow::is_currently_limited`) gates
624/// on `resets_at` so a stale cached snapshot from before the window
625/// reset doesn't keep the account permanently classified as
626/// rate-limited. The pair "utilization >= 100% AND resets_at > now"
627/// is what makes the classification self-clearing across the reset
628/// boundary - earlier shapes that checked utilization alone needed
629/// a separate cache-invalidation pathway, which has churned through
630/// several abandoned designs.
631fn 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
641/// Ceiling shared by the exponential schedule and the server
642/// `Retry-After` clamp. The hold-down this caps is the sole re-probe
643/// path for a settled account, so a bogus header must not hold one
644/// down arbitrarily long.
645const BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(600);
646
647/// Per-account exponential backoff schedule for usage-probe
648/// failures. Doubles each consecutive failure, capped at 10
649/// minutes. The 60 s poll loop ticks ~once a minute; without
650/// backoff, a transient per-IP /usage 429 would persist across
651/// every cycle for as long as Anthropic kept rate-limiting,
652/// preventing any account from accumulating fresh usage data.
653///
654/// | consecutive | delay        |
655/// |-------------|--------------|
656/// | 1           | 30 s         |
657/// | 2           | 1 min        |
658/// | 3           | 2 min        |
659/// | 4           | 4 min        |
660/// | 5           | 8 min        |
661/// | 6+          | 10 min (cap) |
662fn backoff_delay(consecutive_failures: u32) -> std::time::Duration {
663    use std::time::Duration;
664    let exp = consecutive_failures.min(20); // shift saturates at 20; well under u64::MAX
665    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    /// `AccountStateMap::new` copies `LoadedAccount.env` verbatim - the
689    /// live credential included - so neither the state nor the map may
690    /// print a value from it.
691    #[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    /// Default snapshot fixture used by the account-selection tests. The
712    /// `resets_at` value matters: under the resets_at-driven predicate,
713    /// a window at 100% utilization with `resets_at = None` is NOT
714    /// classified as rate-limited (None means "no future reset to clear
715    /// this", which would strand the account forever). Real probes
716    /// always emit a future `resets_at` alongside the percentage, so
717    /// the fixture mirrors that by defaulting to a reset 1 hour out.
718    /// The few tests that need an EXPIRED reset (the staleness case)
719    /// build their snapshot inline rather than through this helper.
720    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    /// The repair-route table every auth hint branches on. Base-url
806    /// wins: an account whose provider owns a base url re-keys its env
807    /// token, whatever else its env carries. A token-less Anthropic
808    /// account classifies Token - the repair it needs is the same
809    /// setup-token copy.
810    #[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        // RateLimited leaves loading alone, so this isolates the probe
845        // class; an Unauthorized probe would flip loading to Bailed and
846        // read as Bailed here instead.
847        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        // Saturation outranks a failed probe: the windows came from a
854        // successful read.
855        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    /// The redb cache survives a `forge.toml` provider edit, so a
865    /// spend-billed account can boot holding a windowed snapshot under
866    /// another backend's source. That snapshot must not classify
867    /// Saturated: spend rows have no window to hit, and the tag would
868    /// assert a spent allowance the account does not have.
869    #[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        // The snapshot helper stamps Oauth; the Openrouter backend's
875        // source is OpenRouterKey, so the cached windows are refused.
876        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        // With the probe also failing, the row wears the probe class -
884        // never the saturated one.
885        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        // The server-provided retry_after must win over the local
922        // exponential schedule - 30 s for a first failure would
923        // under-shoot the actual reset and re-trip the limit.
924        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        // The hold-down is the sole re-probe path for a settled
940        // account, so a bogus (or merely very large) header must not
941        // hold one down past the schedule's own 10-minute ceiling.
942        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        // seven_day = 30, seven_day_opus = 80 → binding = max = 80
968        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    /// An empty fold produced 0.0 with nothing that looked like a
979    /// default, so a 200 carrying only the session window reported the
980    /// account as 0% used across seven days.
981    #[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    /// Build a snapshot with explicit `resets_at` values per window. The
999    /// public `snapshot()` helper hides this for the common test case
1000    /// (resets in the future); this variant lets the
1001    /// staleness/transition tests drive the resets_at clock.
1002    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        // Stale cached snapshot: probe reported 100% an hour ago but
1032        // the reset moment has come and gone. Predicate must return
1033        // false so the rotation re-considers the account WITHOUT
1034        // waiting for a fresh probe to overwrite the cache.
1035        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        // 5h at 100% + still in window; 7d below cap. Either window
1043        // can keep the account out of the pool while it's in its
1044        // hold-down period.
1045        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    /// Build a snapshot exercising the opus-specific 7-day window.
1051    /// Max-plan accounts hit the opus cap independently of the
1052    /// shared 7-day envelope, and `is_rate_limited` must walk every
1053    /// window in the four-tuple - a future short-circuit on
1054    /// `seven_day` would silently break Max-plan classification.
1055    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    /// Sister of `opus_only_capped_snapshot` for the sonnet 7-day
1085    /// window. Same rationale: a sonnet-only cap must bubble through
1086    /// `is_rate_limited` even when no other window is at the limit.
1087    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        // Max-plan reality: shared 5h + 7d windows healthy, opus-7d
1119        // at cap. The selection walk must classify as rate-limited or
1120        // the account keeps getting selected and trips API 429s.
1121        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        // Symmetry with opus: sonnet-7d at cap must also bubble up.
1129        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        // Stale opus-window snapshot - resets_at has come and gone.
1137        // Must classify as NOT rate-limited so the selection walk
1138        // reconsiders the account; the same self-clearing applies
1139        // per-window.
1140        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        // None means "no scheduled reset" - we cannot prove the limit
1148        // is still in effect. Treating it as limited would forever
1149        // strand the account on a single bad cached reading.
1150        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        // Cache snapshot has util 100% with resets_at in the past:
1157        // the scheduler hook must fire so a fresh probe overwrites
1158        // the stale bar.
1159        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        // Cap window still active: hook must not fire (the existing
1170        // `should_probe_now` gate covers normal cadence).
1171        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        // Cold cache - nothing to compare against. Hook returns false;
1182        // the normal cold-cache `should_probe_now == true` already
1183        // schedules the first probe.
1184        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        // Below-cap snapshot with a past resets_at: NOT a freshly
1191        // cleared cap, just a stale-but-irrelevant timestamp. The
1192        // hook only cares about the at-cap transition.
1193        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        // Setup mirrors the production sustained-failure shape: a
1204        // stale cached snapshot (util=100, resets_at in the past)
1205        // plus an active backoff timer from a recent 429. Without
1206        // the disarm gate, every poll cycle would re-fire the hook
1207        // and hammer Anthropic through the entire backoff window.
1208        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        // Push next_probe_at into the future so should_probe_now is
1214        // false: this is the "in backoff" state the scheduler hook is
1215        // meant to override.
1216        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        // Scheduler picks up the account via the hook + fires its
1225        // override probe; disarm runs.
1226        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        // Combined scheduler signal: neither predicate fires - the
1230        // account waits for the backoff timer to elapse.
1231        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        // After the override fires + disarms once, a NEW successful
1240        // probe (set_usage) must re-arm the hook so future stale-reset
1241        // boundaries can still trigger their one-shot override.
1242        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        // A fresh probe lands - even if the snapshot is STILL
1251        // stale-reset, the hook re-arms because set_usage represents
1252        // a brand-new probe attempt's success.
1253        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        // Without an arming step, a brand-new AccountStateMap entry
1260        // must NOT fire the hook even if some hypothetical stale
1261        // snapshot were planted manually. Cold accounts are picked up
1262        // via cold-cache `should_probe_now == true`, not the override.
1263        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    // -------------------------------------------------------------
1269    // Scheduler OR truth table - pins the combined signal the
1270    // workspace poller relies on. A future refactor that swaps the
1271    // OR for AND or drops one half loses the behavior with CI green
1272    // unless this table covers it.
1273    // -------------------------------------------------------------
1274
1275    #[test]
1276    fn scheduler_should_probe_fires_via_should_probe_now_alone() {
1277        // Cold-cache account: no failures yet, no snapshot. The normal
1278        // `should_probe_now` returns true (next_probe_at is None);
1279        // the hook returns false (no snapshot to override against).
1280        // OR result: true.
1281        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        // The signature override scenario from the planner: a stale
1291        // cached snapshot (util=100, resets_at in the past) paired
1292        // with an active backoff timer from a recent 429. The hook
1293        // must be the deciding factor:
1294        // - should_probe_now == false (backoff active)
1295        // - has_just_cleared_cap_window == true (armed + stale reset)
1296        // - scheduler_should_probe == true (OR fires)
1297        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        // Backoff active AND the snapshot has a future resets_at (no
1316        // stale state). Both sides of the OR return false; the
1317        // account is skipped this cycle and waits for the backoff
1318        // timer to elapse.
1319        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    // ---------------------------------------------------------------
1336    // #246: LoadingState gates boot + feeds the selection walk.
1337    //
1338    // Replaces the PR #238 `consecutive_unauthorized` 3-strike counter:
1339    // a single 401 now transitions to `LoadingState::Bailed` (clearing
1340    // the cached usage). The 60 s usage poller absorbs transient
1341    // failures by re-probing. The user-visible effect is the same -
1342    // a bailed account reads as bailed instead of showing a stale bar -
1343    // just the storage shape moved.
1344    // ---------------------------------------------------------------
1345
1346    fn key(name: &str) -> AccountKey {
1347        AccountKey(name.to_owned())
1348    }
1349
1350    #[test]
1351    fn loading_state_initial_is_loading() {
1352        // Brand-new account state has no probe result yet; it stays
1353        // Loading until the loading task lands a terminal verdict (Ready
1354        // or Bailed).
1355        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        // Defensive: a caller may hold a key that hasn't been registered
1362        // yet; treat unknown keys as Loading rather than panicking.
1363        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        // Subsumes the PR #238 three-strike test. Single Unauthorized
1379        // (not three) now flips loading to Bailed and clears the
1380        // cached usage, so no stale bar is drawn and the account reads
1381        // bailed. The 60 s usage poller re-probes the account.
1382        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        // Expired is in the same auth-recovery family as Unauthorized;
1397        // both drive the same Bailed transition.
1398        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    /// **A rate-limited response's windows are a reading, not an absence:**
1409    /// a view states them while the account has no successful probe behind
1410    /// it. The reading is kept beside the successful snapshot, so the
1411    /// walk's saturation input and the cooldown's reset stay on the last
1412    /// probe verdict.
1413    #[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    /// A successful probe supersedes the reading, and an auth bail drops
1433    /// it with the cache: a view states the newest thing the account said,
1434    /// and a dead credential has said nothing since.
1435    #[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        // RateLimited is a probe-side throttle, not an auth failure;
1456        // loading state must NOT transition to Bailed. The cached
1457        // usage is also preserved so a view keeps showing the last
1458        // known-good bars while the probe backs off.
1459        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        // Network errors are transient and unknown - the loading state
1473        // stays at whatever it was, and the cache is preserved.
1474        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        // Driven by the loading task to clear stale snapshots when
1487        // transitioning to Bailed; mirrors the side-effect that
1488        // set_last_error performs for the auth-error case.
1489        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        // The transition itself shouldn't wipe a cache that might've
1502        // been re-primed since the bail. (In practice the cache is
1503        // already None on a Bailed account, but the contract should
1504        // be explicit.)
1505        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        // Recovery flow: account got Bailed by a probe failure; the
1518        // 60 s usage poller's re-probe lands a fresh snapshot;
1519        // set_usage transitions back to Ready, re-priming the cache.
1520        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    /// Terminality does not gate re-probing: a Bailed account re-enters
1555    /// the poller's probe set the moment its hold-down expires, which
1556    /// is the single-pass loader's whole healing path.
1557    #[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}