Skip to main content

agent_core/core/auth/
quota_policy.rs

1//! G5 — pure capacity-selection policy for multi-account OAuth providers.
2//!
3//! This module has **no I/O, no clock and no network**. Every decision is a
4//! deterministic function of `now_ms` and the caller-supplied per-account
5//! capacity views ([`AccountCapacity`]), so it is trivially testable with a
6//! synthetic clock and can be shared by the broker (`AccountSelector::Auto`),
7//! the Codex stream failover and the quota keeper.
8//!
9//! ## Invariants
10//!
11//! * **Fail closed.** Stale, unknown, malformed, unsupported or auth-errored
12//!   quota is *not* capacity. An account is only advertised as ready when a
13//!   fresh observation shows headroom on every applicable limit.
14//! * **All applicable limits count.** Every window that applies to the
15//!   requested model (account-wide windows plus model-scoped windows naming
16//!   that model) must have headroom. A window with no usable evidence makes
17//!   the account ineligible.
18//! * **Exclusions are absolute.** Cooling-down, exhausted, excluded and
19//!   auth-errored accounts are never selected.
20//! * **Explicit never falls back.** When a specific account is requested only
21//!   that account is considered; it is selected or the request fails.
22//! * **Deterministic preference.** Ties are broken by the operator's
23//!   preference order, then by storage key, so equal inputs always yield the
24//!   same seat.
25//! * **One bounded failover.** [`failover`] permits a single switch to a
26//!   different eligible seat, only on a recognized pre-output quota failure,
27//!   never after any output or tool activity and never on an ordinary 429.
28//! * **Perishable capacity first.** [`Strategy::SoonestReset`] ranks eligible
29//!   seats by tier — urgent anchored, then unanchored (first use starts the
30//!   clock), then far anchored, then no reset evidence — soonest budget reset
31//!   first, so capacity about to expire is burned before it is lost.
32//!   Stickiness keeps the currently pinned seat unless a candidate sits in a
33//!   strictly higher tier (or, within tier 1, resets strictly sooner); an
34//!   ineligible current seat is never kept and an excluded one never re-picked.
35
36use serde::{Deserialize, Serialize};
37
38use super::account::CredentialRef;
39use super::provider::OAuthProviderId;
40
41/// Serde shim: [`CredentialRef`] round-trips as its storage key
42/// (`"openai-codex@astra2"`), which is already validated text.
43pub mod cred_serde {
44    use super::CredentialRef;
45    use serde::{Deserialize, Deserializer, Serialize, Serializer};
46
47    pub fn serialize<S: Serializer>(c: &CredentialRef, s: S) -> Result<S::Ok, S::Error> {
48        c.storage_key().serialize(s)
49    }
50
51    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<CredentialRef, D::Error> {
52        let raw = String::deserialize(d)?;
53        CredentialRef::parse_storage_key(&raw)
54            .ok_or_else(|| serde::de::Error::custom(format!("invalid credential key '{raw}'")))
55    }
56}
57
58/// Lower bound (inclusive) for classifying a window as "weekly" from its
59/// provider-reported duration. Six days leaves room for providers that report
60/// approximate durations; never infer weekliness from a name like `primary`.
61pub const WEEKLY_WINDOW_MIN_MS: u64 = 6 * 24 * 60 * 60 * 1000;
62/// Upper bound (inclusive) for the weekly classification.
63pub const WEEKLY_WINDOW_MAX_MS: u64 = 8 * 24 * 60 * 60 * 1000;
64/// Observation timestamps further in the future than this are malformed
65/// (a small allowance for clock skew between the provider and this host).
66pub const MAX_FUTURE_OBSERVATION_SKEW_MS: u64 = 5 * 60 * 1000;
67/// Default utilization percentage at/above which a window is exhausted.
68pub const DEFAULT_EXHAUSTED_AT_PERCENT: f64 = 100.0;
69/// Default [`SelectionRequest::urgent_horizon_ms`]: an anchored budget window
70/// resetting within this horizon is "urgent" — its unused capacity is about
71/// to be lost, so it ranks first under [`Strategy::SoonestReset`].
72pub const DEFAULT_URGENT_HORIZON_MS: u64 = 24 * 60 * 60 * 1000;
73/// Tolerance for [`is_unanchored`]: a window whose reported reset is within
74/// this distance of `observed_at + duration` is sliding with the observation
75/// rather than fixed on the provider's calendar. Absorbs poll latency and
76/// clock skew between the provider and this host.
77pub const UNANCHORED_TOLERANCE_MS: u64 = 5 * 60 * 1000;
78/// Maximum `used_percent` (inclusive) for a window to count as "≈0 % used"
79/// in [`is_unanchored`]. A window with real consumption has, by definition,
80/// already been anchored by that first use.
81pub const UNANCHORED_MAX_USED_PERCENT: f64 = 0.5;
82
83// ── Inputs ───────────────────────────────────────────────────────────────────
84
85/// One provider-reported rate-limit window, normalized by the usage adapter.
86///
87/// `models == None` means the window applies to every model on the account;
88/// `Some(list)` scopes it to exactly those model ids.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct WindowLimit {
91    /// Provider-side identifier (e.g. `primary`, `seven_day_sonnet`). Display only.
92    pub id: String,
93    /// Window length as reported by the provider, if known.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub duration_ms: Option<u64>,
96    /// Utilization in percent (`0..=100`), if reported.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub used_percent: Option<f64>,
99    /// Provider's explicit "limit reached" flag, if reported. Adapters must
100    /// set `Some(false)` ONLY when the provider asserts it (it counts as
101    /// headroom evidence when no percentage is available); an absent flag
102    /// must be `None`.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub limit_reached: Option<bool>,
105    /// Provider-reported reset instant (epoch ms), if reported. Authoritative.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub resets_at_ms: Option<u64>,
108    /// Model scope. `None` = account-wide.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub models: Option<Vec<String>>,
111}
112
113/// Fail-closed verdict for one window.
114#[derive(Debug, Clone, PartialEq)]
115pub enum WindowVerdict {
116    /// Evidence of remaining capacity.
117    Headroom { used_percent: Option<f64> },
118    /// Evidence the window is used up (or reports contradictory/past data).
119    Exhausted { resets_at_ms: Option<u64> },
120    /// No usable evidence (missing or malformed fields).
121    Unknown,
122}
123
124/// Whether a provider availability/window entry (`sonnet`, `gpt-6-astra`)
125/// names the requested model id (`claude-sonnet-4-6`). Exact
126/// (case-insensitive) match, or the entry equals one `-`/`_`/`/`/`:`/`.`
127/// separated token of the request — the same rule the usage adapter uses.
128pub fn model_matches(entry: &str, requested: &str) -> bool {
129    let entry = entry.trim().to_ascii_lowercase();
130    let req = requested.trim().to_ascii_lowercase();
131    if entry.is_empty() || req.is_empty() {
132        return false;
133    }
134    if entry == req {
135        return true;
136    }
137    req.split(['-', '_', '/', ':', '.']).any(|tok| tok == entry)
138}
139
140impl WindowLimit {
141    /// Whether this window constrains `model` (`None` = any model).
142    pub fn applies_to(&self, model: Option<&str>) -> bool {
143        match (&self.models, model) {
144            (None, _) => true,
145            // A model-scoped window cannot be matched without a model; treat
146            // it as applicable so an unknown model never bypasses a limit.
147            (Some(_), None) => true,
148            (Some(list), Some(m)) => list.iter().any(|x| model_matches(x, m)),
149        }
150    }
151
152    /// Weekly classification from the reported duration only.
153    pub fn is_weekly(&self) -> bool {
154        matches!(self.duration_ms, Some(d) if (WEEKLY_WINDOW_MIN_MS..=WEEKLY_WINDOW_MAX_MS).contains(&d))
155    }
156
157    /// Capacity verdict. Exhaustion threshold is inclusive. A provider
158    /// `limit_reached: true` flag always wins; malformed percentages are
159    /// unknown (never capacity). An invalid threshold (non-finite or outside
160    /// `0..=100`) fails closed: the verdict is `Unknown`.
161    pub fn verdict(&self, exhausted_at_percent: f64) -> WindowVerdict {
162        if !valid_threshold(exhausted_at_percent) {
163            return WindowVerdict::Unknown;
164        }
165        if self.limit_reached == Some(true) {
166            return WindowVerdict::Exhausted {
167                resets_at_ms: self.resets_at_ms,
168            };
169        }
170        let used = match self.used_percent {
171            // An overrun (> 100, bounded) is a spent window, not unknown.
172            Some(p) if p > 100.0 && p <= super::usage::OVERRUN_PERCENT_MAX => Some(100.0),
173            Some(p) if !p.is_finite() || !(0.0..=100.0).contains(&p) => {
174                return WindowVerdict::Unknown
175            }
176            other => other,
177        };
178        match used {
179            Some(p) if p >= exhausted_at_percent => WindowVerdict::Exhausted {
180                resets_at_ms: self.resets_at_ms,
181            },
182            Some(p) => WindowVerdict::Headroom {
183                used_percent: Some(p),
184            },
185            None if self.limit_reached == Some(false) => {
186                WindowVerdict::Headroom { used_percent: None }
187            }
188            None => WindowVerdict::Unknown,
189        }
190    }
191}
192
193/// Whether an exhaustion threshold is usable (finite, `0..=100`).
194pub fn valid_threshold(percent: f64) -> bool {
195    percent.is_finite() && (0.0..=100.0).contains(&percent)
196}
197
198/// Tri-state model availability. `Unknown` is preserved as unknown — it is
199/// never coerced to "available".
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum ModelState {
203    Available,
204    Exhausted,
205    Unknown,
206}
207
208/// Provider-reported model availability for an account.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct ModelAvailability {
211    pub model: String,
212    pub state: ModelState,
213}
214
215/// What the usage adapter learned about one account.
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217#[serde(tag = "kind", rename_all = "snake_case")]
218pub enum QuotaObservation {
219    /// A well-formed reading.
220    Ok {
221        windows: Vec<WindowLimit>,
222        /// `None` = the provider exposes no per-model availability.
223        #[serde(default, skip_serializing_if = "Option::is_none")]
224        models: Option<Vec<ModelAvailability>>,
225    },
226    /// Credential rejected (401/403) — never capacity, needs re-login.
227    AuthError,
228    /// Provider has no usage endpoint / adapter — unknown, never capacity.
229    Unsupported,
230    /// Response could not be parsed — never capacity.
231    Malformed,
232    /// No reading available (transport failure, never fetched, …).
233    Unknown,
234}
235
236/// Per-account capacity view supplied by the caller.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct AccountCapacity {
239    #[serde(with = "cred_serde")]
240    pub credential: CredentialRef,
241    /// Snapshot time (epoch ms). `None` = never observed → not capacity.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub observed_at_ms: Option<u64>,
244    pub observation: QuotaObservation,
245    /// Client-side cooldown (e.g. after a provider limit response). Epoch ms.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub cooldown_until_ms: Option<u64>,
248    /// Why there is no reading, when the read itself failed (a usage 429,
249    /// a refresh timeout, …). Never capacity; carried into the rejection
250    /// so the failure can be named instead of a bare "quota unknown".
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub failure: Option<ObservationFailure>,
253}
254
255impl AccountCapacity {
256    /// Convenience constructor for an account with no observation yet.
257    pub fn unobserved(credential: CredentialRef) -> Self {
258        Self {
259            credential,
260            observed_at_ms: None,
261            observation: QuotaObservation::Unknown,
262            cooldown_until_ms: None,
263            failure: None,
264        }
265    }
266
267    /// An account whose observation failed with `failure`.
268    pub fn failed(credential: CredentialRef, failure: ObservationFailure) -> Self {
269        Self {
270            observation: failure.observation(),
271            failure: Some(failure),
272            ..Self::unobserved(credential)
273        }
274    }
275}
276
277// ── Typed failure kinds ──────────────────────────────────────────────────────
278
279/// Why one account could not serve a request, as a stable snake_case kind:
280/// a failed observation (usage read, token refresh, credential store) or
281/// the policy verdict on a good one. Secret-free; safe for logs and users.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum AccountFailureKind {
285    /// A provider limit was reported for the account (client cooldown).
286    CoolingDown,
287    /// A usage window is at its limit.
288    Exhausted,
289    /// The provider reports the requested model as used up.
290    ModelUnavailable,
291    /// The usage endpoint itself answered 429. Not proof of exhaustion.
292    UsageRateLimited,
293    UsageTimeout,
294    /// The usage endpoint answered another non-2xx status (or a redirect).
295    UsageUpstream,
296    UsageTransport,
297    /// The usage response could not be parsed (or was too large).
298    UsageMalformed,
299    /// The usage endpoint rejected the access token (401/403).
300    UsageUnauthorized,
301    /// The token refresh failed for a network/provider reason (5xx, 429,
302    /// timeout).
303    RefreshTransient,
304    /// Another refresh of the account was still running.
305    RefreshLockBusy,
306    /// A local refresh failure (lock file I/O, the refresh task, a forced-
307    /// refresh cooldown).
308    RefreshOther,
309    /// The token endpoint rejected the login (e.g. `invalid_grant`).
310    RefreshRejected,
311    /// The user must act outside synaps (corrupt store, billing).
312    RefreshActionRequired,
313    /// The slot disappeared between listing and reading.
314    CredentialMissing,
315    /// The credential store could not be read.
316    CredentialStore,
317    /// The credential was rejected; no finer cause recorded.
318    AuthError,
319    /// No usage adapter for the provider.
320    Unsupported,
321    /// No reading and no recorded cause.
322    QuotaUnknown,
323    StaleSnapshot,
324    WindowUnknown,
325    ModelUnknown,
326    NoLimitEvidence,
327    /// Excluded by the caller (e.g. a duplicate of another slot's seat).
328    Excluded,
329    NotPresent,
330    InvalidLabel,
331    /// A kind this build does not know (from a newer daemon). Claims no
332    /// remedy: not transient, not a login, not an action, not limited.
333    #[serde(other)]
334    Unknown,
335}
336
337impl AccountFailureKind {
338    pub fn as_str(self) -> &'static str {
339        match self {
340            Self::CoolingDown => "cooling_down",
341            Self::Exhausted => "exhausted",
342            Self::ModelUnavailable => "model_unavailable",
343            Self::UsageRateLimited => "usage_rate_limited",
344            Self::UsageTimeout => "usage_timeout",
345            Self::UsageUpstream => "usage_upstream",
346            Self::UsageTransport => "usage_transport",
347            Self::UsageMalformed => "usage_malformed",
348            Self::UsageUnauthorized => "usage_unauthorized",
349            Self::RefreshTransient => "refresh_transient",
350            Self::RefreshLockBusy => "refresh_lock_busy",
351            Self::RefreshOther => "refresh_other",
352            Self::RefreshRejected => "refresh_rejected",
353            Self::RefreshActionRequired => "refresh_action_required",
354            Self::CredentialMissing => "credential_missing",
355            Self::CredentialStore => "credential_store",
356            Self::AuthError => "auth_error",
357            Self::Unsupported => "unsupported",
358            Self::QuotaUnknown => "quota_unknown",
359            Self::StaleSnapshot => "stale_snapshot",
360            Self::WindowUnknown => "window_unknown",
361            Self::ModelUnknown => "model_unknown",
362            Self::NoLimitEvidence => "no_limit_evidence",
363            Self::Excluded => "excluded",
364            Self::NotPresent => "not_present",
365            Self::InvalidLabel => "invalid_label",
366            Self::Unknown => "unknown",
367        }
368    }
369
370    /// Only a new login for the account can fix it.
371    pub fn needs_login(self) -> bool {
372        matches!(
373            self,
374            Self::UsageUnauthorized
375                | Self::RefreshRejected
376                | Self::CredentialMissing
377                | Self::AuthError
378        )
379    }
380
381    /// Capacity is unproven, not absent: the same request can succeed later
382    /// without the user doing anything.
383    pub fn is_transient(self) -> bool {
384        matches!(
385            self,
386            Self::UsageRateLimited
387                | Self::UsageTimeout
388                | Self::UsageUpstream
389                | Self::UsageTransport
390                | Self::RefreshTransient
391                | Self::RefreshLockBusy
392                | Self::RefreshOther
393        )
394    }
395
396    /// The account is at a provider limit (reported or measured).
397    pub fn is_limited(self) -> bool {
398        matches!(
399            self,
400            Self::CoolingDown | Self::Exhausted | Self::ModelUnavailable
401        )
402    }
403
404    /// Only an action outside synaps fixes it (a corrupt or missing store,
405    /// a billing requirement); neither a retry nor a login does.
406    pub fn needs_action(self) -> bool {
407        matches!(self, Self::RefreshActionRequired | Self::CredentialStore)
408    }
409}
410
411impl std::fmt::Display for AccountFailureKind {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        f.write_str(self.as_str())
414    }
415}
416
417/// A failed observation: its kind plus the HTTP status and `Retry-After`
418/// the provider sent, when there were any. Never token material or body
419/// bytes.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub struct ObservationFailure {
422    pub kind: AccountFailureKind,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub http_status: Option<u16>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub retry_after_s: Option<u64>,
427}
428
429impl ObservationFailure {
430    pub fn new(kind: AccountFailureKind) -> Self {
431        Self {
432            kind,
433            http_status: None,
434            retry_after_s: None,
435        }
436    }
437
438    pub fn with_status(mut self, status: u16) -> Self {
439        self.http_status = Some(status);
440        self
441    }
442
443    pub fn with_retry_after(mut self, secs: Option<u64>) -> Self {
444        self.retry_after_s = secs;
445        self
446    }
447
448    /// The (never-capacity) observation this failure stands for.
449    pub fn observation(&self) -> QuotaObservation {
450        match self.kind {
451            k if k.needs_login() => QuotaObservation::AuthError,
452            AccountFailureKind::UsageMalformed => QuotaObservation::Malformed,
453            AccountFailureKind::Unsupported => QuotaObservation::Unsupported,
454            _ => QuotaObservation::Unknown,
455        }
456    }
457}
458
459impl std::fmt::Display for ObservationFailure {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        write!(f, "{}", self.kind)?;
462        if let Some(status) = self.http_status {
463            write!(f, ", HTTP {status}")?;
464        }
465        if let Some(secs) = self.retry_after_s {
466            write!(f, ", retry after {secs}s")?;
467        }
468        Ok(())
469    }
470}
471
472/// Ordering strategy among eligible accounts. All are deterministic.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
474#[serde(rename_all = "snake_case")]
475pub enum Strategy {
476    /// First eligible account in the operator's preference list; unlisted
477    /// accounts follow in storage-key order.
478    PreferenceOrder,
479    /// Lowest maximum utilization across applicable windows; ties broken by
480    /// preference order, then storage key. Accounts whose utilization is
481    /// unknown (but proven not limit-reached) sort after known ones.
482    LowestUtilization,
483    /// Perishable capacity first. Eligible seats are ranked by
484    /// [`Eligible::tier`] (1 = urgent anchored, 2 = unanchored, 3 = anchored
485    /// but not urgent, 4 = no reset evidence); tiers 1 and 3 order by soonest
486    /// budget reset, tier 2 by preference then storage key, tier 4 by lowest
487    /// utilization. Remaining ties: lowest utilization → preference → storage
488    /// key. The only strategy to which [`SelectionRequest::sticky`] applies.
489    SoonestReset,
490}
491
492/// A selection request. All times are epoch milliseconds.
493#[derive(Debug, Clone)]
494pub struct SelectionRequest<'a> {
495    pub provider: OAuthProviderId,
496    /// Model the request is for; `None` = any (model-scoped windows still apply).
497    pub model: Option<&'a str>,
498    pub now_ms: u64,
499    /// Observations older than this are stale (not capacity).
500    pub max_snapshot_age_ms: u64,
501    /// Operator preference (labels, `default` for the bare slot).
502    pub preference: &'a [String],
503    /// Explicit account label; when set no other account is considered.
504    pub explicit_account: Option<&'a str>,
505    /// Accounts that must not be selected (e.g. a seat that just failed).
506    pub exclude: &'a [CredentialRef],
507    pub strategy: Strategy,
508    /// Utilization percent at/above which a window counts as exhausted.
509    pub exhausted_at_percent: f64,
510    /// The seat currently pinned for this provider, if any. Only consulted
511    /// when [`sticky`](Self::sticky) is set under [`Strategy::SoonestReset`];
512    /// it never bypasses eligibility or exclusion.
513    pub current: Option<&'a CredentialRef>,
514    /// An anchored budget window resetting within this many ms of `now_ms`
515    /// is urgent (tier 1). Default [`DEFAULT_URGENT_HORIZON_MS`].
516    pub urgent_horizon_ms: u64,
517    /// Keep [`current`](Self::current) when it is still eligible unless a
518    /// candidate sits in a strictly higher tier (or both are tier 1 and the
519    /// candidate resets strictly sooner). Avoids seat thrash and prompt-cache
520    /// loss. Applies to [`Strategy::SoonestReset`] only; `false` in
521    /// [`new`](Self::new) so other strategies are unaffected.
522    pub sticky: bool,
523    /// Last successful readings for accounts whose current usage read
524    /// failed transiently (usage-endpoint 429, 5xx, timeout, transport).
525    /// Such an account (observation [`QuotaObservation::Unknown`]) is
526    /// evaluated on its reading here instead: strictly younger than
527    /// [`grace_max_age_ms`](Self::grace_max_age_ms), never past an exhausted
528    /// window, an exclusion or a cooldown. Graced seats rank after every
529    /// fresh one, but a graced sticky seat stays (a read failure alone is no
530    /// reason to switch: the sticky rule judges the graced reading exactly
531    /// as it would a fresh one). Empty in [`new`](Self::new).
532    pub grace: &'a [AccountCapacity],
533    /// Oldest graced reading that still counts. Default
534    /// [`USAGE_READ_GRACE`](super::usage_reads::USAGE_READ_GRACE).
535    pub grace_max_age_ms: u64,
536}
537
538impl<'a> SelectionRequest<'a> {
539    /// Minimal request with defaults: any model, no preference/exclusions,
540    /// lowest-utilization strategy, 100 % exhaustion threshold, no current
541    /// seat, default urgent horizon, not sticky.
542    pub fn new(provider: OAuthProviderId, now_ms: u64, max_snapshot_age_ms: u64) -> Self {
543        Self {
544            provider,
545            model: None,
546            now_ms,
547            max_snapshot_age_ms,
548            preference: &[],
549            explicit_account: None,
550            exclude: &[],
551            strategy: Strategy::LowestUtilization,
552            exhausted_at_percent: DEFAULT_EXHAUSTED_AT_PERCENT,
553            current: None,
554            urgent_horizon_ms: DEFAULT_URGENT_HORIZON_MS,
555            sticky: false,
556            grace: &[],
557            grace_max_age_ms: super::usage_reads::USAGE_READ_GRACE.as_millis() as u64,
558        }
559    }
560
561    /// Like [`new`](Self::new) but with [`Strategy::SoonestReset`], sticky
562    /// selection and the default urgent horizon. Set
563    /// [`current`](Self::current) for stickiness to have any effect.
564    pub fn soonest_reset(provider: OAuthProviderId, now_ms: u64, max_snapshot_age_ms: u64) -> Self {
565        Self {
566            strategy: Strategy::SoonestReset,
567            sticky: true,
568            ..Self::new(provider, now_ms, max_snapshot_age_ms)
569        }
570    }
571}
572
573// ── Outputs ──────────────────────────────────────────────────────────────────
574
575/// Why an account was not selected. Secret-free, display-ready.
576#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
577#[serde(tag = "reason", rename_all = "snake_case")]
578pub enum RejectReason {
579    /// Snapshot missing or older than the allowed age.
580    Stale {
581        #[serde(default, skip_serializing_if = "Option::is_none")]
582        age_ms: Option<u64>,
583    },
584    /// Observation timestamp is implausibly in the future or fields invalid.
585    Malformed,
586    Unknown,
587    Unsupported,
588    AuthError,
589    CoolingDown {
590        until_ms: u64,
591    },
592    Exhausted {
593        window: String,
594        #[serde(default, skip_serializing_if = "Option::is_none")]
595        resets_at_ms: Option<u64>,
596    },
597    /// An applicable window carries no usable evidence.
598    WindowUnknown {
599        window: String,
600    },
601    /// The reading has no applicable window at all — no limit evidence.
602    NoLimitEvidence,
603    ModelUnavailable {
604        model: String,
605    },
606    /// The provider lists the model with unknown availability — not capacity.
607    ModelUnknown {
608        model: String,
609    },
610    /// Present in `exclude`.
611    Excluded,
612    /// The explicitly requested account is not among the candidates.
613    NotPresent,
614    /// The explicitly requested label fails the label grammar.
615    InvalidLabel {
616        label: String,
617    },
618    /// Eligible, but another eligible account ranked higher. `rank` is the
619    /// 1-based position in the final ranking (the winner is rank 1), so
620    /// callers can print the full ordered table.
621    Outranked {
622        #[serde(default)]
623        rank: u32,
624    },
625}
626
627impl std::fmt::Display for RejectReason {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        match self {
630            Self::Stale { age_ms: Some(a) } => write!(f, "stale snapshot ({}s old)", a / 1000),
631            Self::Stale { age_ms: None } => write!(f, "no snapshot"),
632            Self::Malformed => write!(f, "malformed snapshot"),
633            Self::Unknown => write!(f, "quota unknown"),
634            Self::Unsupported => write!(f, "usage unsupported"),
635            Self::AuthError => write!(f, "auth error"),
636            Self::CoolingDown { until_ms } => write!(f, "cooling down until {until_ms}"),
637            Self::Exhausted {
638                window,
639                resets_at_ms: Some(r),
640            } => write!(f, "window '{window}' exhausted, resets at {r}"),
641            Self::Exhausted { window, .. } => write!(f, "window '{window}' exhausted"),
642            Self::WindowUnknown { window } => write!(f, "window '{window}' has no evidence"),
643            Self::NoLimitEvidence => write!(f, "no applicable limit evidence"),
644            Self::ModelUnavailable { model } => write!(f, "model '{model}' unavailable"),
645            Self::ModelUnknown { model } => write!(f, "model '{model}' availability unknown"),
646            Self::Excluded => write!(f, "excluded"),
647            Self::NotPresent => write!(f, "account not present"),
648            Self::InvalidLabel { label } => write!(f, "invalid account label '{label}'"),
649            Self::Outranked { rank } => write!(f, "eligible; outranked (rank {rank})"),
650        }
651    }
652}
653
654/// One rejected candidate.
655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
656pub struct Rejection {
657    #[serde(with = "cred_serde")]
658    pub credential: CredentialRef,
659    pub reason: RejectReason,
660    /// The failed observation behind the verdict, when it is known: for an
661    /// observation verdict (`unknown`, `auth_error`, `malformed`,
662    /// `unsupported`) and for a cooldown, which does not erase what the
663    /// account's last read or refresh said.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub cause: Option<ObservationFailure>,
666    /// Diagnostics only: when this account can be used again as far as its
667    /// cooldown and its exhausted applicable windows know, i.e. the LATEST
668    /// of them (every exhausted window must reset). `None` when it has
669    /// neither, or an exhausted window has no future reset.
670    #[serde(default, skip_serializing_if = "Option::is_none")]
671    pub usable_after_ms: Option<u64>,
672}
673
674impl Rejection {
675    /// `reason`, plus the cause in parentheses when there is one.
676    pub fn describe(&self) -> String {
677        match &self.cause {
678            Some(cause) => format!("{} ({cause})", self.reason),
679            None => self.reason.to_string(),
680        }
681    }
682}
683
684/// One account's part in a failed selection.
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
686pub struct AccountFailure {
687    /// Account label (`default` for the bare slot). Never an identity.
688    pub account: String,
689    pub kind: AccountFailureKind,
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub http_status: Option<u16>,
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub retry_after_s: Option<u64>,
694    /// Cooldown end or exhausted-window reset, epoch ms, when known.
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub until_ms: Option<u64>,
697    /// The account is cooling down (a reported provider limit), whatever
698    /// `kind` says about its last observation.
699    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
700    pub cooling_down: bool,
701}
702
703impl AccountFailure {
704    /// The account's failure for a rejection; `None` for an account that
705    /// was eligible but outranked (not a failure). A cooling account keeps
706    /// the kind of its last failed read or refresh, with `cooling_down` set
707    /// beside it; `until_ms` is the rejection's `usable_after_ms`.
708    pub fn from_rejection(rejection: &Rejection) -> Option<Self> {
709        use AccountFailureKind as K;
710        let mut out = Self {
711            account: rejection.credential.account.label_str().to_string(),
712            kind: K::QuotaUnknown,
713            http_status: None,
714            retry_after_s: None,
715            until_ms: rejection.usable_after_ms,
716            cooling_down: matches!(rejection.reason, RejectReason::CoolingDown { .. }),
717        };
718        if let RejectReason::Outranked { .. } = rejection.reason {
719            return None;
720        }
721        if let Some(cause) = &rejection.cause {
722            out.kind = cause.kind;
723            out.http_status = cause.http_status;
724            out.retry_after_s = cause.retry_after_s;
725            return Some(out);
726        }
727        out.kind = match &rejection.reason {
728            RejectReason::Outranked { .. } => return None,
729            RejectReason::CoolingDown { .. } => K::CoolingDown,
730            RejectReason::Exhausted { .. } => K::Exhausted,
731            RejectReason::Stale { .. } => K::StaleSnapshot,
732            RejectReason::Malformed => K::UsageMalformed,
733            RejectReason::Unknown => K::QuotaUnknown,
734            RejectReason::Unsupported => K::Unsupported,
735            RejectReason::AuthError => K::AuthError,
736            RejectReason::WindowUnknown { .. } => K::WindowUnknown,
737            RejectReason::NoLimitEvidence => K::NoLimitEvidence,
738            RejectReason::ModelUnavailable { .. } => K::ModelUnavailable,
739            RejectReason::ModelUnknown { .. } => K::ModelUnknown,
740            RejectReason::Excluded => K::Excluded,
741            RejectReason::NotPresent => K::NotPresent,
742            RejectReason::InvalidLabel { .. } => K::InvalidLabel,
743        };
744        Some(out)
745    }
746
747    /// Cooling down or at a provider limit: the account counts as limited
748    /// for the top-level kind, whatever its last read said.
749    pub fn is_limited(&self) -> bool {
750        self.cooling_down || self.kind.is_limited()
751    }
752
753    /// Counts toward the headline: not limited, and not an excluded slot
754    /// (a duplicate of a seat that is already counted).
755    pub fn is_live(&self) -> bool {
756        !self.is_limited() && self.kind != AccountFailureKind::Excluded
757    }
758
759    /// `label:kind[:status][:cooling]`, the log form; `:cooling` marks a
760    /// cooling account whose kind is its last failed read or refresh.
761    pub fn log_form(&self) -> String {
762        let mut out = format!("{}:{}", self.account, self.kind);
763        if let Some(status) = self.http_status {
764            out.push_str(&format!(":{status}"));
765        }
766        if self.cooling_down && self.kind != AccountFailureKind::CoolingDown {
767            out.push_str(":cooling");
768        }
769        out
770    }
771}
772
773/// The one kind that explains a failed automatic selection.
774#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
775#[serde(rename_all = "snake_case")]
776pub enum SelectionFailureKind {
777    /// No account is logged in for the provider.
778    NoCredentials,
779    /// At least one account's usage read or token refresh failed for a
780    /// transient reason: capacity is unproven, not absent.
781    UsageReadFailed,
782    /// Every account that is not cooling down needs a new login.
783    CredentialRejected,
784    /// Every account is cooling down or at a usage limit.
785    AllCoolingDown,
786    /// The readings succeeded but proved no capacity.
787    QuotaUnknown,
788    /// The chosen slot kept changing while its capacity was being proven.
789    SeatChurn,
790    /// A remote broker answered "no account available" (its own log has
791    /// the reason).
792    RemoteNoCapacity,
793    /// Every account that is not limited needs a login or an action outside
794    /// synaps (a corrupt or missing store, a billing requirement), and at
795    /// least one needs the action.
796    ActionRequired,
797    /// The credential store could not be listed, so no account was read.
798    CredentialStore,
799    /// A kind this build does not know (from a newer daemon).
800    #[serde(other)]
801    Unknown,
802}
803
804impl SelectionFailureKind {
805    pub fn as_str(self) -> &'static str {
806        match self {
807            Self::NoCredentials => "no_credentials",
808            Self::UsageReadFailed => "usage_read_failed",
809            Self::CredentialRejected => "credential_rejected",
810            Self::AllCoolingDown => "all_cooling_down",
811            Self::QuotaUnknown => "quota_unknown",
812            Self::SeatChurn => "seat_churn",
813            Self::RemoteNoCapacity => "remote_no_capacity",
814            Self::ActionRequired => "action_required",
815            Self::CredentialStore => "credential_store",
816            Self::Unknown => "unknown",
817        }
818    }
819}
820
821impl std::fmt::Display for SelectionFailureKind {
822    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
823        f.write_str(self.as_str())
824    }
825}
826
827/// A failed automatic selection: the top-level kind, every account's part
828/// in it, and the earliest known reset among limited accounts.
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
830pub struct SelectionFailure {
831    pub kind: SelectionFailureKind,
832    #[serde(default)]
833    pub accounts: Vec<AccountFailure>,
834    /// Earliest future cooldown end or exhausted-window reset, epoch ms.
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub earliest_reset_ms: Option<u64>,
837    /// For `usage_read_failed`: when the held usage reads go to the network
838    /// again (epoch ms), i.e. the earliest time a new selection reads usage.
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub usage_read_retry_ms: Option<u64>,
841}
842
843impl SelectionFailure {
844    /// A failure with no per-account detail.
845    pub fn new(kind: SelectionFailureKind) -> Self {
846        Self {
847            kind,
848            accounts: Vec::new(),
849            earliest_reset_ms: None,
850            usage_read_retry_ms: None,
851        }
852    }
853
854    /// Classify a `NoCapacity` result. The headline is what the user can act
855    /// on now: it is classified over accounts that aren't cooling down. A
856    /// cooling account's read failure is shown per account. An account
857    /// counts as limited when it is cooling down or at a provider limit,
858    /// whatever its last read said; only the other accounts' kinds pick the
859    /// headline; an `excluded` account (a duplicate of a counted seat) never
860    /// does. First match wins: no accounts → `no_credentials`; a
861    /// transient failure on an account that is not limited →
862    /// `usage_read_failed`; every account limited → `all_cooling_down`;
863    /// every account not limited needs a login → `credential_rejected`; each
864    /// needs a login or an action outside synaps (at least one an action) →
865    /// `action_required`; else `quota_unknown`. The earliest reset is the
866    /// earliest time some limited account is usable again: the minimum of
867    /// their `until`, skipping accounts that also need a login or an action
868    /// (their cooldown's end alone won't make them usable).
869    pub fn from_rejections(rejections: &[Rejection], now_ms: u64) -> Self {
870        use SelectionFailureKind as K;
871        let accounts: Vec<_> = rejections
872            .iter()
873            .filter_map(AccountFailure::from_rejection)
874            .collect();
875        // An excluded slot duplicates a seat that is already counted (it is
876        // excluded before its cooldown is checked), so it never picks the
877        // headline.
878        let live: Vec<_> = accounts.iter().filter(|a| a.is_live()).collect();
879        let kind = if accounts.is_empty() {
880            K::NoCredentials
881        } else if live.iter().any(|a| a.kind.is_transient()) {
882            K::UsageReadFailed
883        } else if live.is_empty() && accounts.iter().any(AccountFailure::is_limited) {
884            K::AllCoolingDown
885        } else if live.is_empty() {
886            K::QuotaUnknown
887        } else if live.iter().all(|a| a.kind.needs_login()) {
888            K::CredentialRejected
889        } else if live
890            .iter()
891            .all(|a| a.kind.needs_login() || a.kind.needs_action())
892        {
893            K::ActionRequired
894        } else {
895            K::QuotaUnknown
896        };
897        let earliest_reset_ms = accounts
898            .iter()
899            .filter(|a| a.is_limited() && !a.kind.needs_login() && !a.kind.needs_action())
900            .filter_map(|a| a.until_ms)
901            .filter(|t| *t > now_ms)
902            .min();
903        Self {
904            kind,
905            accounts,
906            earliest_reset_ms,
907            usage_read_retry_ms: None,
908        }
909    }
910
911    /// The shortest `Retry-After` among the live transient failures, when
912    /// every one of them carries one: sending again sooner only repeats
913    /// them. `None` when there is no live transient failure or one has no
914    /// `Retry-After`.
915    pub fn transient_retry_after_s(&self) -> Option<u64> {
916        let waits: Option<Vec<u64>> = self
917            .accounts
918            .iter()
919            .filter(|a| a.is_live() && a.kind.is_transient())
920            .map(|a| a.retry_after_s)
921            .collect();
922        waits?.into_iter().min()
923    }
924
925    /// Labels whose failure only a new login fixes, in order.
926    pub fn login_labels(&self) -> Vec<&str> {
927        self.accounts
928            .iter()
929            .filter(|a| a.kind.needs_login())
930            .map(|a| a.account.as_str())
931            .collect()
932    }
933
934    /// Labels whose failure needs an action outside synaps, in order.
935    pub fn action_labels(&self) -> Vec<&str> {
936        self.accounts
937            .iter()
938            .filter(|a| a.kind.needs_action())
939            .map(|a| a.account.as_str())
940            .collect()
941    }
942
943    /// `label:kind[:status][:cooling],…` — one log field, secret-free.
944    pub fn accounts_field(&self) -> String {
945        self.accounts
946            .iter()
947            .map(AccountFailure::log_form)
948            .collect::<Vec<_>>()
949            .join(",")
950    }
951}
952
953/// Result of [`select`].
954#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
955#[serde(tag = "outcome", rename_all = "snake_case")]
956pub enum Selection {
957    Selected {
958        #[serde(with = "cred_serde")]
959        credential: CredentialRef,
960        /// Maximum utilization across the applicable windows, if known.
961        #[serde(default, skip_serializing_if = "Option::is_none")]
962        utilization: Option<f64>,
963        /// The winner's [`Eligible::tier`] (`1..=4`; `0` only when decoded
964        /// from a record written before tiers existed). Computed under every
965        /// strategy so the pick can always be explained.
966        #[serde(default)]
967        tier: u8,
968        /// The winner's [`Eligible::budget_reset_ms`], if it has one.
969        #[serde(default, skip_serializing_if = "Option::is_none")]
970        budget_reset_ms: Option<u64>,
971        /// Candidates that lost — informational.
972        rejections: Vec<Rejection>,
973    },
974    NoCapacity {
975        rejections: Vec<Rejection>,
976        /// Earliest *future* provider-reported reset among exhausted
977        /// candidates, if any. Past resets are never reported here: a passed
978        /// reset is not evidence of a new window.
979        #[serde(default, skip_serializing_if = "Option::is_none")]
980        earliest_reset_ms: Option<u64>,
981    },
982}
983
984impl Selection {
985    pub fn selected(&self) -> Option<&CredentialRef> {
986        match self {
987            Self::Selected { credential, .. } => Some(credential),
988            Self::NoCapacity { .. } => None,
989        }
990    }
991}
992
993// ── Eligibility ──────────────────────────────────────────────────────────────
994
995/// Eligible-account summary produced by [`evaluate`].
996#[derive(Debug, Clone, PartialEq)]
997pub struct Eligible {
998    /// Max utilization over applicable windows (`None` = proven-not-limited only).
999    pub utilization: Option<f64>,
1000    /// Reset instant of the budget window (see [`budget_window`]), if the
1001    /// provider reports one that lies in the future. A reset at or before
1002    /// `now_ms` is not evidence of a new window and is reported as `None`.
1003    pub budget_reset_ms: Option<u64>,
1004    /// Whether the budget window's reset is a fixed instant (`Some(true)`)
1005    /// or slides with the observation because the window has not been
1006    /// started by a first use yet (`Some(false)`, see [`is_unanchored`]).
1007    /// `None` when there is no budget reset to classify.
1008    pub anchored: Option<bool>,
1009    /// Priority tier under [`Strategy::SoonestReset`], computed for every
1010    /// eligible seat regardless of strategy so any ranking can be explained:
1011    /// 1 = anchored and resetting within the urgent horizon (capacity about
1012    /// to be lost), 2 = unanchored (using it starts the clock), 3 = anchored
1013    /// beyond the horizon (earliest deadline first), 4 = no reset evidence
1014    /// (headroom proven, deadline unknown). Unknown anchoring with a known
1015    /// reset is treated as anchored.
1016    pub tier: u8,
1017    /// Eligible on a last good reading because its current usage read
1018    /// failed transiently (see [`SelectionRequest::grace`]).
1019    pub graced: bool,
1020}
1021
1022/// Tier for an eligible seat; see [`Eligible::tier`].
1023fn tier_for(
1024    anchored: Option<bool>,
1025    budget_reset_ms: Option<u64>,
1026    now_ms: u64,
1027    urgent_horizon_ms: u64,
1028) -> u8 {
1029    match (anchored, budget_reset_ms) {
1030        (_, None) => 4,
1031        (Some(false), Some(_)) => 2,
1032        (_, Some(reset)) if reset.saturating_sub(now_ms) <= urgent_horizon_ms => 1,
1033        (_, Some(_)) => 3,
1034    }
1035}
1036
1037/// Evaluate a single account against the request. Pure; ignores
1038/// `explicit_account` and provider matching (the caller filters those).
1039pub fn evaluate(
1040    req: &SelectionRequest<'_>,
1041    cap: &AccountCapacity,
1042) -> Result<Eligible, RejectReason> {
1043    if req
1044        .exclude
1045        .iter()
1046        .any(|c| c.storage_key() == cap.credential.storage_key())
1047    {
1048        return Err(RejectReason::Excluded);
1049    }
1050    if let Some(until) = cap.cooldown_until_ms {
1051        if until > req.now_ms {
1052            return Err(RejectReason::CoolingDown { until_ms: until });
1053        }
1054    }
1055    let (windows, models) = match &cap.observation {
1056        QuotaObservation::Ok { windows, models } => (windows, models),
1057        QuotaObservation::AuthError => return Err(RejectReason::AuthError),
1058        QuotaObservation::Unsupported => return Err(RejectReason::Unsupported),
1059        QuotaObservation::Malformed => return Err(RejectReason::Malformed),
1060        QuotaObservation::Unknown => return evaluate_graced(req, cap),
1061    };
1062    let observed = cap
1063        .observed_at_ms
1064        .ok_or(RejectReason::Stale { age_ms: None })?;
1065    if observed > req.now_ms.saturating_add(MAX_FUTURE_OBSERVATION_SKEW_MS) {
1066        return Err(RejectReason::Malformed);
1067    }
1068    let age = req.now_ms.saturating_sub(observed);
1069    if age > req.max_snapshot_age_ms {
1070        return Err(RejectReason::Stale { age_ms: Some(age) });
1071    }
1072    if let (Some(model), Some(list)) = (req.model, models) {
1073        for m in list.iter().filter(|m| model_matches(&m.model, model)) {
1074            match m.state {
1075                ModelState::Available => {}
1076                ModelState::Exhausted => {
1077                    return Err(RejectReason::ModelUnavailable {
1078                        model: model.to_string(),
1079                    })
1080                }
1081                ModelState::Unknown => {
1082                    return Err(RejectReason::ModelUnknown {
1083                        model: model.to_string(),
1084                    })
1085                }
1086            }
1087        }
1088    }
1089    let mut applicable = 0usize;
1090    let mut utilization: Option<f64> = None;
1091    for w in windows.iter().filter(|w| w.applies_to(req.model)) {
1092        applicable += 1;
1093        match w.verdict(req.exhausted_at_percent) {
1094            WindowVerdict::Headroom { used_percent } => {
1095                if let Some(p) = used_percent {
1096                    utilization = Some(utilization.map_or(p, |u| u.max(p)));
1097                }
1098            }
1099            WindowVerdict::Exhausted { resets_at_ms } => {
1100                return Err(RejectReason::Exhausted {
1101                    window: w.id.clone(),
1102                    resets_at_ms,
1103                });
1104            }
1105            WindowVerdict::Unknown => {
1106                return Err(RejectReason::WindowUnknown {
1107                    window: w.id.clone(),
1108                });
1109            }
1110        }
1111    }
1112    if applicable == 0 {
1113        return Err(RejectReason::NoLimitEvidence);
1114    }
1115    // Perishable-capacity evidence. Only a future reset counts: a reset at or
1116    // before `now_ms` on a still-fresh reading is not evidence of a new
1117    // window (the same rule `NoCapacity::earliest_reset_ms` applies).
1118    let budget = budget_window(windows, req.model);
1119    let budget_reset_ms = budget
1120        .and_then(|w| w.resets_at_ms)
1121        .filter(|t| *t > req.now_ms);
1122    let anchored = match (budget, budget_reset_ms) {
1123        (Some(w), Some(_)) => is_unanchored(w, observed).map(|u| !u),
1124        _ => None,
1125    };
1126    let tier = tier_for(anchored, budget_reset_ms, req.now_ms, req.urgent_horizon_ms);
1127    Ok(Eligible {
1128        utilization,
1129        budget_reset_ms,
1130        anchored,
1131        tier,
1132        graced: false,
1133    })
1134}
1135
1136/// An account whose current read gave no reading: eligible only on its
1137/// last good reading in [`SelectionRequest::grace`], judged by the same
1138/// rules (windows, models) against the grace age, with the account's own
1139/// cooldown (already checked by the caller, kept for the re-check). Any
1140/// rejection of that reading reports the read failure, not the old reading.
1141fn evaluate_graced(
1142    req: &SelectionRequest<'_>,
1143    cap: &AccountCapacity,
1144) -> Result<Eligible, RejectReason> {
1145    let key = cap.credential.storage_key();
1146    let Some(good) = req
1147        .grace
1148        .iter()
1149        .find(|g| g.credential.storage_key() == key)
1150        .filter(|g| matches!(g.observation, QuotaObservation::Ok { .. }))
1151    else {
1152        return Err(RejectReason::Unknown);
1153    };
1154    // Strictly younger than the grace age (a reading exactly that old no
1155    // longer counts); `evaluate` below re-checks with its inclusive bound.
1156    let young = good
1157        .observed_at_ms
1158        .is_some_and(|t| req.now_ms.saturating_sub(t) < req.grace_max_age_ms);
1159    if !young {
1160        return Err(RejectReason::Unknown);
1161    }
1162    let graced_req = SelectionRequest {
1163        max_snapshot_age_ms: req.grace_max_age_ms,
1164        grace: &[],
1165        ..req.clone()
1166    };
1167    let reading = AccountCapacity {
1168        cooldown_until_ms: cap.cooldown_until_ms,
1169        ..good.clone()
1170    };
1171    evaluate(&graced_req, &reading)
1172        .map(|e| Eligible { graced: true, ..e })
1173        .map_err(|_| RejectReason::Unknown)
1174}
1175
1176/// Lowest-utilization ordering: known before unknown, then ascending.
1177fn utilization_cmp(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
1178    a.is_none().cmp(&b.is_none()).then_with(|| {
1179        a.unwrap_or(0.0)
1180            .partial_cmp(&b.unwrap_or(0.0))
1181            .unwrap_or(std::cmp::Ordering::Equal)
1182    })
1183}
1184
1185fn preference_rank(req: &SelectionRequest<'_>, cred: &CredentialRef) -> usize {
1186    let label = cred.account.label_str();
1187    req.preference
1188        .iter()
1189        .position(|p| p == label)
1190        .unwrap_or(usize::MAX)
1191}
1192
1193/// The recorded observation failure behind a verdict: an observation
1194/// verdict, or a cooldown (checked first, so it would otherwise hide what
1195/// the account's last read or refresh said). The other policy verdicts
1196/// (exhausted, …) come from a good reading and need no cause.
1197fn observation_cause(reason: &RejectReason, cap: &AccountCapacity) -> Option<ObservationFailure> {
1198    match reason {
1199        RejectReason::Unknown
1200        | RejectReason::AuthError
1201        | RejectReason::Malformed
1202        | RejectReason::Unsupported
1203        | RejectReason::CoolingDown { .. } => cap.failure.clone(),
1204        _ => None,
1205    }
1206}
1207
1208/// Diagnostics only (never eligibility): when `cap` can be used again as
1209/// far as its cooldown and its reading know: the latest of its future
1210/// cooldown end and the resets of every applicable exhausted window, since
1211/// all of them must be over. `None` when it has neither, or an exhausted
1212/// window has no future reset (the time is unknown).
1213fn usable_after_ms(req: &SelectionRequest<'_>, cap: &AccountCapacity) -> Option<u64> {
1214    let mut latest = cap.cooldown_until_ms.filter(|t| *t > req.now_ms);
1215    if let QuotaObservation::Ok { windows, .. } = &cap.observation {
1216        for w in windows.iter().filter(|w| w.applies_to(req.model)) {
1217            if let WindowVerdict::Exhausted { resets_at_ms } = w.verdict(req.exhausted_at_percent) {
1218                let reset = resets_at_ms.filter(|t| *t > req.now_ms)?;
1219                latest = Some(latest.map_or(reset, |l| l.max(reset)));
1220            }
1221        }
1222    }
1223    latest
1224}
1225
1226/// Pick the best eligible account for `req` among `candidates`.
1227///
1228/// Candidates for other providers are ignored. See module docs for the rules.
1229pub fn select(req: &SelectionRequest<'_>, candidates: &[AccountCapacity]) -> Selection {
1230    let mut rejections: Vec<Rejection> = Vec::new();
1231    let mut eligible: Vec<(&AccountCapacity, Eligible)> = Vec::new();
1232    let mut explicit_seen = false;
1233
1234    for cap in candidates
1235        .iter()
1236        .filter(|c| c.credential.provider == req.provider)
1237    {
1238        if let Some(label) = req.explicit_account {
1239            if cap.credential.account.label_str() != label {
1240                continue;
1241            }
1242            explicit_seen = true;
1243        }
1244        match evaluate(req, cap) {
1245            Ok(e) => eligible.push((cap, e)),
1246            Err(reason) => rejections.push(Rejection {
1247                credential: cap.credential.clone(),
1248                cause: observation_cause(&reason, cap),
1249                usable_after_ms: usable_after_ms(req, cap),
1250                reason,
1251            }),
1252        }
1253    }
1254
1255    if let (Some(label), false) = (req.explicit_account, explicit_seen) {
1256        // An unparseable label cannot be represented as a credential; report
1257        // it distinctly (against the provider's default slot) so callers never
1258        // mistake it for a missing valid account.
1259        let (credential, reason) = match super::account::Account::parse(label) {
1260            Ok(account) => (
1261                CredentialRef::new(req.provider, account),
1262                RejectReason::NotPresent,
1263            ),
1264            Err(_) => (
1265                CredentialRef::default_for(req.provider),
1266                RejectReason::InvalidLabel {
1267                    label: label.to_string(),
1268                },
1269            ),
1270        };
1271        return Selection::NoCapacity {
1272            rejections: vec![Rejection {
1273                credential,
1274                reason,
1275                cause: None,
1276                usable_after_ms: None,
1277            }],
1278            earliest_reset_ms: None,
1279        };
1280    }
1281
1282    // Deterministic ordering. Fresh readings first: a graced seat (last good
1283    // reading, current read failed) never outranks a fresh one when choosing
1284    // anew; stickiness below may still keep a graced current seat.
1285    eligible.sort_by(|(a, ea), (b, eb)| {
1286        let ka = a.credential.storage_key();
1287        let kb = b.credential.storage_key();
1288        let pa = preference_rank(req, &a.credential);
1289        let pb = preference_rank(req, &b.credential);
1290        ea.graced.cmp(&eb.graced).then_with(|| match req.strategy {
1291            Strategy::PreferenceOrder => pa.cmp(&pb).then_with(|| ka.cmp(&kb)),
1292            Strategy::LowestUtilization => utilization_cmp(ea.utilization, eb.utilization)
1293                .then_with(|| pa.cmp(&pb))
1294                .then_with(|| ka.cmp(&kb)),
1295            Strategy::SoonestReset => ea
1296                .tier
1297                .cmp(&eb.tier)
1298                .then_with(|| match ea.tier {
1299                    // Soonest deadline first; both resets are `Some` here.
1300                    1 | 3 => ea.budget_reset_ms.cmp(&eb.budget_reset_ms),
1301                    // Unanchored seats have no deadline yet: operator order.
1302                    2 => pa.cmp(&pb).then_with(|| ka.cmp(&kb)),
1303                    // No reset evidence: fall back to headroom.
1304                    _ => utilization_cmp(ea.utilization, eb.utilization),
1305                })
1306                .then_with(|| utilization_cmp(ea.utilization, eb.utilization))
1307                .then_with(|| pa.cmp(&pb))
1308                .then_with(|| ka.cmp(&kb)),
1309        })
1310    });
1311
1312    // Stickiness (SoonestReset only): keep the pinned seat unless a candidate
1313    // is in a strictly higher tier, or both are urgent and the candidate's
1314    // reset is strictly sooner. An ineligible/excluded current seat is not in
1315    // `eligible`, so it can never be kept. A graced current seat is judged on
1316    // its graced reading exactly as a fresh one: ranking graced after fresh
1317    // never moves off it by itself, so a read failure alone never switches.
1318    if req.sticky && req.strategy == Strategy::SoonestReset {
1319        if let Some(current) = req.current {
1320            let key = current.storage_key();
1321            let at = eligible
1322                .iter()
1323                .position(|(c, _)| c.credential.storage_key() == key);
1324            if let Some(i) = at.filter(|i| *i > 0) {
1325                let best = &eligible[0].1;
1326                let cur = &eligible[i].1;
1327                let switch = best.tier < cur.tier
1328                    || (best.tier == 1
1329                        && cur.tier == 1
1330                        && best.budget_reset_ms < cur.budget_reset_ms);
1331                if !switch {
1332                    eligible[..=i].rotate_right(1);
1333                }
1334            }
1335        }
1336    }
1337
1338    match eligible.first() {
1339        Some((cap, e)) => {
1340            let winner = cap.credential.clone();
1341            for (i, (other, _)) in eligible.iter().enumerate().skip(1) {
1342                rejections.push(Rejection {
1343                    credential: other.credential.clone(),
1344                    reason: RejectReason::Outranked {
1345                        rank: (i + 1) as u32,
1346                    },
1347                    cause: None,
1348                    usable_after_ms: None,
1349                });
1350            }
1351            Selection::Selected {
1352                credential: winner,
1353                utilization: e.utilization,
1354                tier: e.tier,
1355                budget_reset_ms: e.budget_reset_ms,
1356                rejections,
1357            }
1358        }
1359        None => {
1360            let earliest_reset_ms = rejections
1361                .iter()
1362                .filter_map(|r| match &r.reason {
1363                    RejectReason::Exhausted {
1364                        resets_at_ms: Some(t),
1365                        ..
1366                    } if *t > req.now_ms => Some(*t),
1367                    _ => None,
1368                })
1369                .min();
1370            Selection::NoCapacity {
1371                rejections,
1372                earliest_reset_ms,
1373            }
1374        }
1375    }
1376}
1377
1378// ── Bounded failover ─────────────────────────────────────────────────────────
1379
1380/// Classification of the failure that triggered a failover request.
1381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1382#[serde(rename_all = "snake_case")]
1383pub enum FailureKind {
1384    /// Provider explicitly reported the account's quota as exhausted before
1385    /// any output was produced (e.g. Codex `usage_limit_reached`).
1386    QuotaExhaustedPreOutput,
1387    /// Ordinary rate limiting (429 / overloaded). Not proof of exhaustion.
1388    RateLimited,
1389    /// Anything else (network, 5xx, parse, cancel).
1390    Other,
1391}
1392
1393/// Evidence about the failed attempt.
1394#[derive(Debug, Clone, PartialEq, Eq)]
1395pub struct FailureEvidence {
1396    pub kind: FailureKind,
1397    /// Any model output (text/thinking/tool_use) reached the caller.
1398    pub output_started: bool,
1399    /// Any tool executed, or tool results were fed back, for this turn.
1400    pub tool_activity: bool,
1401    /// Failovers already performed for this request.
1402    pub failovers_so_far: u32,
1403}
1404
1405/// Maximum number of account switches for one request.
1406pub const MAX_FAILOVERS: u32 = 1;
1407
1408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1409#[serde(tag = "decision", rename_all = "snake_case")]
1410pub enum FailoverDecision {
1411    Failover {
1412        #[serde(with = "cred_serde")]
1413        credential: CredentialRef,
1414    },
1415    NoFailover {
1416        reason: String,
1417    },
1418}
1419
1420/// Decide whether the request may be retried on another seat.
1421///
1422/// Never replays after output or tool activity, never on an ordinary 429,
1423/// never for an explicitly requested account, and at most [`MAX_FAILOVERS`]
1424/// times. The failed seat is excluded from the fresh selection.
1425pub fn failover(
1426    req: &SelectionRequest<'_>,
1427    failed: &CredentialRef,
1428    evidence: &FailureEvidence,
1429    candidates: &[AccountCapacity],
1430) -> FailoverDecision {
1431    let no = |reason: &str| FailoverDecision::NoFailover {
1432        reason: reason.to_string(),
1433    };
1434    if evidence.output_started {
1435        return no("output already started; never replay on another account");
1436    }
1437    if evidence.tool_activity {
1438        return no("tool activity occurred; never replay on another account");
1439    }
1440    if evidence.failovers_so_far >= MAX_FAILOVERS {
1441        return no("failover budget exhausted");
1442    }
1443    if evidence.kind != FailureKind::QuotaExhaustedPreOutput {
1444        return no("not a recognized pre-output quota failure");
1445    }
1446    if req.explicit_account.is_some() {
1447        return no("explicit account requested; no silent fallback");
1448    }
1449    let mut exclude: Vec<CredentialRef> = req.exclude.to_vec();
1450    exclude.push(failed.clone());
1451    let retry = SelectionRequest {
1452        exclude: &exclude,
1453        ..req.clone()
1454    };
1455    match select(&retry, candidates) {
1456        Selection::Selected { credential, .. } => FailoverDecision::Failover { credential },
1457        Selection::NoCapacity { .. } => no("no other eligible account"),
1458    }
1459}
1460
1461// ── Helpers shared with the keeper ───────────────────────────────────────────
1462
1463/// Weekly pick over an arbitrary set of windows: account-wide first, then `id`.
1464fn pick_weekly<'w>(windows: impl Iterator<Item = &'w WindowLimit>) -> Option<&'w WindowLimit> {
1465    let mut weekly: Vec<&WindowLimit> = windows.filter(|w| w.is_weekly()).collect();
1466    weekly.sort_by(|a, b| {
1467        a.models
1468            .is_some()
1469            .cmp(&b.models.is_some())
1470            .then_with(|| a.id.cmp(&b.id))
1471    });
1472    weekly.into_iter().next()
1473}
1474
1475/// The account-wide weekly window of a reading, if the provider reports one.
1476///
1477/// Prefers account-wide (`models == None`) weekly windows; falls back to a
1478/// model-scoped weekly window only when no account-wide one exists. Order is
1479/// deterministic (first by scope, then by `id`).
1480pub fn weekly_window(windows: &[WindowLimit]) -> Option<&WindowLimit> {
1481    pick_weekly(windows.iter())
1482}
1483
1484/// The *budget window* of a reading for `model`: the window whose reset
1485/// decides when unused capacity is lost.
1486///
1487/// Among the windows that [`apply to`](WindowLimit::applies_to) `model`,
1488/// prefers the weekly window with the same preference as [`weekly_window`]
1489/// (account-wide before model-scoped, then `id`); otherwise the window with
1490/// the longest reported `duration_ms` (ties: account-wide first, then `id`).
1491/// `None` when no applicable window reports a duration — a window of unknown
1492/// length cannot be a deadline.
1493pub fn budget_window<'w>(
1494    windows: &'w [WindowLimit],
1495    model: Option<&str>,
1496) -> Option<&'w WindowLimit> {
1497    let applicable = || windows.iter().filter(|w| w.applies_to(model));
1498    if let Some(weekly) = pick_weekly(applicable()) {
1499        return Some(weekly);
1500    }
1501    applicable()
1502        .filter(|w| w.duration_ms.is_some())
1503        .min_by(|a, b| {
1504            b.duration_ms
1505                .cmp(&a.duration_ms)
1506                .then_with(|| a.models.is_some().cmp(&b.models.is_some()))
1507                .then_with(|| a.id.cmp(&b.id))
1508        })
1509}
1510
1511/// Whether `window`'s reset slides with the observation instead of being a
1512/// fixed instant — the signature of a seat whose window has not been started
1513/// by a first use (Codex anchors a window at first use after a reset; an
1514/// idle seat therefore keeps reporting `reset ≈ observed_at + duration`).
1515///
1516/// * `Some(true)`: `used_percent` is reported and at most
1517///   [`UNANCHORED_MAX_USED_PERCENT`], **and** both `duration_ms` and
1518///   `resets_at_ms` are present, **and**
1519///   `|resets_at_ms − (observed_at_ms + duration_ms)| ≤ UNANCHORED_TOLERANCE_MS`.
1520/// * `Some(false)`: duration and reset are present but the window has been
1521///   used, its utilization is unreported, or its reset does not track the
1522///   observation (e.g. an Anthropic 7-day window at 0 % still reports the
1523///   provider's fixed calendar reset).
1524/// * `None`: duration or reset missing — anchoring cannot be judged.
1525pub fn is_unanchored(window: &WindowLimit, observed_at_ms: u64) -> Option<bool> {
1526    let (Some(duration), Some(reset)) = (window.duration_ms, window.resets_at_ms) else {
1527        return None;
1528    };
1529    let near_zero = matches!(
1530        window.used_percent,
1531        Some(p) if (0.0..=UNANCHORED_MAX_USED_PERCENT).contains(&p)
1532    );
1533    if !near_zero {
1534        return Some(false);
1535    }
1536    let expected = observed_at_ms.saturating_add(duration);
1537    Some(expected.abs_diff(reset) <= UNANCHORED_TOLERANCE_MS)
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542    use super::*;
1543    use crate::auth::account::Account;
1544
1545    const HOUR: u64 = 3_600_000;
1546    const DAY: u64 = 24 * HOUR;
1547    const NOW: u64 = 1_800_000_000_000;
1548
1549    fn cred(label: &str) -> CredentialRef {
1550        CredentialRef::new(OAuthProviderId::OpenAiCodex, Account::parse(label).unwrap())
1551    }
1552
1553    fn win(id: &str, dur: u64, used: f64, resets: u64) -> WindowLimit {
1554        WindowLimit {
1555            id: id.into(),
1556            duration_ms: Some(dur),
1557            used_percent: Some(used),
1558            limit_reached: Some(used >= 100.0),
1559            resets_at_ms: Some(resets),
1560            models: None,
1561        }
1562    }
1563
1564    fn ok(label: &str, windows: Vec<WindowLimit>) -> AccountCapacity {
1565        AccountCapacity {
1566            credential: cred(label),
1567            observed_at_ms: Some(NOW - 1000),
1568            observation: QuotaObservation::Ok {
1569                windows,
1570                models: None,
1571            },
1572            cooldown_until_ms: None,
1573            failure: None,
1574        }
1575    }
1576
1577    fn req() -> SelectionRequest<'static> {
1578        SelectionRequest::new(OAuthProviderId::OpenAiCodex, NOW, 30 * 60 * 1000)
1579    }
1580
1581    #[test]
1582    fn window_verdicts_fail_closed() {
1583        let mut w = win("primary", 7 * DAY, 20.0, NOW + DAY);
1584        assert_eq!(
1585            w.verdict(100.0),
1586            WindowVerdict::Headroom {
1587                used_percent: Some(20.0)
1588            }
1589        );
1590        w.used_percent = Some(100.0);
1591        w.limit_reached = Some(false);
1592        assert!(matches!(w.verdict(100.0), WindowVerdict::Exhausted { .. }));
1593        w.used_percent = Some(50.0);
1594        w.limit_reached = Some(true);
1595        assert!(matches!(w.verdict(100.0), WindowVerdict::Exhausted { .. }));
1596        w.used_percent = None;
1597        w.limit_reached = None;
1598        assert_eq!(w.verdict(100.0), WindowVerdict::Unknown);
1599        w.limit_reached = Some(false);
1600        assert_eq!(
1601            w.verdict(100.0),
1602            WindowVerdict::Headroom { used_percent: None }
1603        );
1604        w.used_percent = Some(f64::NAN);
1605        assert_eq!(w.verdict(100.0), WindowVerdict::Unknown);
1606        // An overrun window is exhausted, not unknown.
1607        w.used_percent = Some(140.0);
1608        assert!(matches!(w.verdict(100.0), WindowVerdict::Exhausted { .. }));
1609        w.used_percent = Some(1000.5);
1610        assert_eq!(w.verdict(100.0), WindowVerdict::Unknown);
1611        // A provider `limit_reached: true` wins over any malformed percent.
1612        let saved = w.limit_reached;
1613        w.limit_reached = Some(true);
1614        for p in [1500.0, f64::NAN, -3.0] {
1615            w.used_percent = Some(p);
1616            assert!(
1617                matches!(w.verdict(100.0), WindowVerdict::Exhausted { .. }),
1618                "{p}"
1619            );
1620        }
1621        w.limit_reached = saved;
1622        w.used_percent = Some(-1.0);
1623        assert_eq!(w.verdict(100.0), WindowVerdict::Unknown);
1624        // invalid thresholds fail closed
1625        w.used_percent = Some(1.0);
1626        assert_eq!(w.verdict(f64::NAN), WindowVerdict::Unknown);
1627        assert_eq!(w.verdict(101.0), WindowVerdict::Unknown);
1628        assert_eq!(w.verdict(-0.5), WindowVerdict::Unknown);
1629        assert_eq!(w.verdict(f64::INFINITY), WindowVerdict::Unknown);
1630        let r = SelectionRequest {
1631            exhausted_at_percent: f64::NAN,
1632            ..SelectionRequest::new(OAuthProviderId::OpenAiCodex, NOW, HOUR)
1633        };
1634        assert!(select(
1635            &r,
1636            &[ok("a", vec![win("primary", 7 * DAY, 1.0, NOW + DAY)])]
1637        )
1638        .selected()
1639        .is_none());
1640    }
1641
1642    #[test]
1643    fn weekly_is_from_duration_not_name() {
1644        let primary_5h = win("primary", 5 * HOUR, 0.0, NOW + HOUR);
1645        let primary_week = win("primary", 7 * DAY, 0.0, NOW + DAY);
1646        let no_dur = WindowLimit {
1647            duration_ms: None,
1648            ..win("seven_day", 0, 0.0, NOW)
1649        };
1650        assert!(!primary_5h.is_weekly());
1651        assert!(primary_week.is_weekly());
1652        assert!(!no_dur.is_weekly());
1653        assert_eq!(
1654            weekly_window(&[primary_5h.clone(), primary_week.clone()]).map(|w| w.duration_ms),
1655            Some(Some(7 * DAY))
1656        );
1657        assert!(weekly_window(&[primary_5h, no_dur]).is_none());
1658        // account-wide weekly preferred over model-scoped weekly
1659        let scoped = WindowLimit {
1660            id: "aaa_scoped".into(),
1661            models: Some(vec!["m".into()]),
1662            ..primary_week.clone()
1663        };
1664        assert_eq!(
1665            weekly_window(&[scoped, primary_week]).map(|w| w.id.as_str()),
1666            Some("primary")
1667        );
1668    }
1669
1670    #[test]
1671    fn selects_lowest_utilization_deterministically() {
1672        let a = ok("a", vec![win("primary", 7 * DAY, 60.0, NOW + DAY)]);
1673        let b = ok("b", vec![win("primary", 7 * DAY, 10.0, NOW + DAY)]);
1674        let c = ok("c", vec![win("primary", 7 * DAY, 10.0, NOW + DAY)]);
1675        let sel = select(&req(), &[a.clone(), c.clone(), b.clone()]);
1676        assert_eq!(sel.selected().unwrap().storage_key(), "openai-codex@b");
1677        // identical inputs in another order → same answer
1678        let sel2 = select(&req(), &[c, b, a]);
1679        assert_eq!(sel2.selected().unwrap().storage_key(), "openai-codex@b");
1680    }
1681
1682    #[test]
1683    fn preference_breaks_ties_and_orders() {
1684        let b = ok("b", vec![win("primary", 7 * DAY, 10.0, NOW + DAY)]);
1685        let c = ok("c", vec![win("primary", 7 * DAY, 10.0, NOW + DAY)]);
1686        let pref = vec!["c".to_string(), "b".to_string()];
1687        let r = SelectionRequest {
1688            preference: &pref,
1689            ..req()
1690        };
1691        assert_eq!(
1692            select(&r, &[b.clone(), c.clone()])
1693                .selected()
1694                .unwrap()
1695                .storage_key(),
1696            "openai-codex@c"
1697        );
1698        let a = ok("a", vec![win("primary", 7 * DAY, 90.0, NOW + DAY)]);
1699        let pref2 = vec!["a".to_string()];
1700        let r2 = SelectionRequest {
1701            preference: &pref2,
1702            strategy: Strategy::PreferenceOrder,
1703            ..req()
1704        };
1705        assert_eq!(
1706            select(&r2, &[b, c, a]).selected().unwrap().storage_key(),
1707            "openai-codex@a"
1708        );
1709    }
1710
1711    /// Grace: a failed read (`Unknown`) counts on its last good reading only
1712    /// under the grace age and never past an exhausted window or a cooldown;
1713    /// graced seats rank after fresh ones, but a graced sticky seat stays.
1714    #[test]
1715    fn graced_readings_are_bounded_rank_after_fresh_and_keep_a_sticky_seat() {
1716        let grace_ms = super::super::usage_reads::USAGE_READ_GRACE.as_millis() as u64;
1717        let failed = |label: &str| AccountCapacity::unobserved(cred(label));
1718        let good = |label: &str, age: u64, used: f64, reset: u64| AccountCapacity {
1719            observed_at_ms: Some(NOW - age),
1720            ..ok(label, vec![win("primary", 7 * DAY, used, reset)])
1721        };
1722        let fresh_req = || SelectionRequest::soonest_reset(OAuthProviderId::OpenAiCodex, NOW, 60_000);
1723        let winner = |sel: Selection| match sel {
1724            Selection::Selected { credential, .. } => credential.account.label_str().to_string(),
1725            Selection::NoCapacity { rejections, .. } => panic!("no capacity: {rejections:?}"),
1726        };
1727        let one = |grace: &[AccountCapacity], cap: &AccountCapacity| {
1728            evaluate(&SelectionRequest { grace, ..fresh_req() }, cap)
1729        };
1730
1731        let young = [good("a", grace_ms - 60_000, 20.0, NOW + DAY)];
1732        assert!(one(&young, &failed("a")).unwrap().graced);
1733        let old = [good("a", grace_ms + 60_000, 20.0, NOW + DAY)];
1734        assert_eq!(one(&old, &failed("a")), Err(RejectReason::Unknown), "past the grace age");
1735        // Strictly under the grace age: a reading exactly that old is out.
1736        let edge = [good("a", grace_ms, 20.0, NOW + DAY)];
1737        assert_eq!(one(&edge, &failed("a")), Err(RejectReason::Unknown), "at the grace age");
1738        let inside = [good("a", grace_ms - 1, 20.0, NOW + DAY)];
1739        assert!(one(&inside, &failed("a")).unwrap().graced, "just under it");
1740        let exhausted = [good("a", 60_000, 100.0, NOW + HOUR)];
1741        assert_eq!(one(&exhausted, &failed("a")), Err(RejectReason::Unknown), "exhausted window");
1742        let cooling = AccountCapacity {
1743            cooldown_until_ms: Some(NOW + HOUR),
1744            ..failed("a")
1745        };
1746        assert!(matches!(one(&young, &cooling), Err(RejectReason::CoolingDown { .. })));
1747        let auth = AccountCapacity {
1748            observation: QuotaObservation::AuthError,
1749            ..failed("a")
1750        };
1751        assert_eq!(one(&young, &auth), Err(RejectReason::AuthError), "only a failed read is graced");
1752
1753        // a: graced, urgent; b: fresh, far. Choosing anew: fresh b first.
1754        let grace = [good("a", 60_000, 20.0, NOW + HOUR)];
1755        let b = good("b", 1000, 20.0, NOW + 3 * DAY);
1756        let anew = SelectionRequest { grace: &grace, ..fresh_req() };
1757        assert_eq!(winner(select(&anew, &[failed("a"), b.clone()])), "b");
1758        assert_eq!(winner(select(&anew, &[failed("a")])), "a", "alone, the graced seat serves");
1759        // Sticky on graced a: stays (a read failure alone is no reason to switch).
1760        let current = cred("a");
1761        let sticky = SelectionRequest {
1762            current: Some(&current),
1763            ..anew.clone()
1764        };
1765        assert_eq!(winner(select(&sticky, &[failed("a"), b])), "a");
1766
1767        // A graced sticky seat is judged exactly as a fresh one would be: it
1768        // stays wherever a fresh reading would stay and yields only where a
1769        // fresh reading yields too (a strictly higher tier, or a strictly
1770        // sooner urgent reset), never because its read failed.
1771        let mut switched = 0;
1772        for a_reset in [NOW + HOUR, NOW + 2 * DAY, NOW + 10 * HOUR] {
1773            for b_reset in [NOW + HOUR / 2, NOW + 3 * DAY, NOW + 12 * HOUR, NOW + 5 * DAY] {
1774                let a_good = good("a", 1000, 20.0, a_reset);
1775                let b = good("b", 1000, 20.0, b_reset);
1776                let graced = [a_good.clone()];
1777                let on_grace = SelectionRequest {
1778                    grace: &graced,
1779                    current: Some(&current),
1780                    ..fresh_req()
1781                };
1782                let on_fresh = SelectionRequest {
1783                    current: Some(&current),
1784                    ..fresh_req()
1785                };
1786                let g = winner(select(&on_grace, &[failed("a"), b.clone()]));
1787                let f = winner(select(&on_fresh, &[a_good, b]));
1788                assert_eq!(g, f, "a resets at {a_reset}, b at {b_reset}");
1789                switched += usize::from(g == "b");
1790            }
1791        }
1792        assert!(switched > 0 && switched < 12, "the cases cover staying and switching: {switched}");
1793    }
1794
1795    #[test]
1796    fn stale_unknown_malformed_auth_are_not_capacity() {
1797        let mut stale = ok("s", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1798        stale.observed_at_ms = Some(NOW - 2 * HOUR);
1799        let mut never = ok("n", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1800        never.observed_at_ms = None;
1801        let mut future = ok("f", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1802        future.observed_at_ms = Some(NOW + HOUR);
1803        let mut auth = ok("x", vec![]);
1804        auth.observation = QuotaObservation::AuthError;
1805        let mut unk = ok("u", vec![]);
1806        unk.observation = QuotaObservation::Unknown;
1807        let mut mal = ok("m", vec![]);
1808        mal.observation = QuotaObservation::Malformed;
1809        let mut uns = ok("v", vec![]);
1810        uns.observation = QuotaObservation::Unsupported;
1811        let none = ok("e", vec![]);
1812        let sel = select(&req(), &[stale, never, future, auth, unk, mal, uns, none]);
1813        let Selection::NoCapacity {
1814            rejections,
1815            earliest_reset_ms,
1816        } = sel
1817        else {
1818            panic!("expected no capacity");
1819        };
1820        assert_eq!(earliest_reset_ms, None);
1821        let reasons: Vec<(String, RejectReason)> = rejections
1822            .into_iter()
1823            .map(|r| (r.credential.account.label_str().to_string(), r.reason))
1824            .collect();
1825        assert!(reasons.contains(&(
1826            "s".into(),
1827            RejectReason::Stale {
1828                age_ms: Some(2 * HOUR)
1829            }
1830        )));
1831        assert!(reasons.contains(&("n".into(), RejectReason::Stale { age_ms: None })));
1832        assert!(reasons.contains(&("f".into(), RejectReason::Malformed)));
1833        assert!(reasons.contains(&("x".into(), RejectReason::AuthError)));
1834        assert!(reasons.contains(&("u".into(), RejectReason::Unknown)));
1835        assert!(reasons.contains(&("m".into(), RejectReason::Malformed)));
1836        assert!(reasons.contains(&("v".into(), RejectReason::Unsupported)));
1837        assert!(reasons.contains(&("e".into(), RejectReason::NoLimitEvidence)));
1838    }
1839
1840    #[test]
1841    fn exhausted_cooldown_and_unknown_window_excluded() {
1842        let exhausted = ok("x", vec![win("primary", 7 * DAY, 100.0, NOW + 2 * DAY)]);
1843        let mut cooling = ok("c", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1844        cooling.cooldown_until_ms = Some(NOW + HOUR);
1845        let mut cooled = ok("d", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1846        cooled.cooldown_until_ms = Some(NOW - 1);
1847        let unknown_window = ok(
1848            "w",
1849            vec![WindowLimit {
1850                used_percent: None,
1851                limit_reached: None,
1852                ..win("primary", 7 * DAY, 0.0, NOW + DAY)
1853            }],
1854        );
1855        let past_exhausted = ok("p", vec![win("primary", 7 * DAY, 100.0, NOW - DAY)]);
1856        let sel = select(
1857            &req(),
1858            &[exhausted, cooling, unknown_window, past_exhausted],
1859        );
1860        let Selection::NoCapacity {
1861            earliest_reset_ms, ..
1862        } = &sel
1863        else {
1864            panic!("no capacity expected");
1865        };
1866        // past reset never reported as the earliest reset
1867        assert_eq!(*earliest_reset_ms, Some(NOW + 2 * DAY));
1868        let sel = select(&req(), &[cooled]);
1869        assert_eq!(sel.selected().unwrap().storage_key(), "openai-codex@d");
1870    }
1871
1872    #[test]
1873    fn all_applicable_limits_including_model_scoped_count() {
1874        let model_win = WindowLimit {
1875            id: "weekly_sonnet".into(),
1876            models: Some(vec!["sonnet".into()]),
1877            ..win("weekly_sonnet", 7 * DAY, 100.0, NOW + DAY)
1878        };
1879        let five_h = win("five_hour", 5 * HOUR, 99.0, NOW + HOUR);
1880        let acct = ok(
1881            "a",
1882            vec![
1883                win("seven_day", 7 * DAY, 10.0, NOW + DAY),
1884                five_h,
1885                model_win,
1886            ],
1887        );
1888        // sonnet request hits the exhausted model window
1889        let r = SelectionRequest {
1890            model: Some("sonnet"),
1891            ..req()
1892        };
1893        assert!(select(&r, std::slice::from_ref(&acct)).selected().is_none());
1894        // opus request ignores the sonnet window and takes max utilization (99)
1895        let r = SelectionRequest {
1896            model: Some("opus"),
1897            ..req()
1898        };
1899        match select(&r, std::slice::from_ref(&acct)) {
1900            Selection::Selected { utilization, .. } => assert_eq!(utilization, Some(99.0)),
1901            other => panic!("unexpected {other:?}"),
1902        }
1903        // no model given: scoped windows still apply (never bypass a limit)
1904        assert!(select(&req(), std::slice::from_ref(&acct))
1905            .selected()
1906            .is_none());
1907        // model listed as unavailable
1908        let mut unavailable = acct;
1909        if let QuotaObservation::Ok { models, .. } = &mut unavailable.observation {
1910            *models = Some(vec![ModelAvailability {
1911                model: "opus".into(),
1912                state: ModelState::Exhausted,
1913            }]);
1914        }
1915        let r = SelectionRequest {
1916            model: Some("opus"),
1917            ..req()
1918        };
1919        let sel = select(&r, &[unavailable.clone()]);
1920        match sel {
1921            Selection::NoCapacity { rejections, .. } => assert_eq!(
1922                rejections[0].reason,
1923                RejectReason::ModelUnavailable {
1924                    model: "opus".into()
1925                }
1926            ),
1927            other => panic!("unexpected {other:?}"),
1928        }
1929        // unknown availability is preserved as unknown → not capacity
1930        let mut unknown = unavailable;
1931        if let QuotaObservation::Ok { models, .. } = &mut unknown.observation {
1932            *models = Some(vec![ModelAvailability {
1933                model: "opus".into(),
1934                state: ModelState::Unknown,
1935            }]);
1936        }
1937        match select(&r, &[unknown]) {
1938            Selection::NoCapacity { rejections, .. } => assert_eq!(
1939                rejections[0].reason,
1940                RejectReason::ModelUnknown {
1941                    model: "opus".into()
1942                }
1943            ),
1944            other => panic!("unexpected {other:?}"),
1945        }
1946    }
1947
1948    #[test]
1949    fn explicit_account_never_falls_back() {
1950        let a = ok("a", vec![win("primary", 7 * DAY, 100.0, NOW + DAY)]);
1951        let b = ok("b", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1952        let r = SelectionRequest {
1953            explicit_account: Some("a"),
1954            ..req()
1955        };
1956        let sel = select(&r, &[a.clone(), b.clone()]);
1957        assert!(sel.selected().is_none());
1958        let r = SelectionRequest {
1959            explicit_account: Some("zzz"),
1960            ..req()
1961        };
1962        match select(&r, &[a.clone(), b.clone()]) {
1963            Selection::NoCapacity { rejections, .. } => {
1964                assert_eq!(rejections.len(), 1);
1965                assert_eq!(rejections[0].reason, RejectReason::NotPresent);
1966                assert_eq!(rejections[0].credential.storage_key(), "openai-codex@zzz");
1967            }
1968            other => panic!("unexpected {other:?}"),
1969        }
1970        let r = SelectionRequest {
1971            explicit_account: Some("Bad Label"),
1972            ..req()
1973        };
1974        match select(&r, &[a, b]) {
1975            Selection::NoCapacity { rejections, .. } => assert_eq!(
1976                rejections[0].reason,
1977                RejectReason::InvalidLabel {
1978                    label: "Bad Label".into()
1979                }
1980            ),
1981            other => panic!("unexpected {other:?}"),
1982        }
1983    }
1984
1985    #[test]
1986    fn other_providers_and_exclusions_are_ignored() {
1987        let mut other = ok("a", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1988        other.credential =
1989            CredentialRef::new(OAuthProviderId::Anthropic, Account::parse("a").unwrap());
1990        let b = ok("b", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
1991        let excl = vec![cred("b")];
1992        let r = SelectionRequest {
1993            exclude: &excl,
1994            ..req()
1995        };
1996        let sel = select(&r, &[other, b]);
1997        match sel {
1998            Selection::NoCapacity { rejections, .. } => {
1999                assert_eq!(rejections.len(), 1);
2000                assert_eq!(rejections[0].reason, RejectReason::Excluded);
2001            }
2002            other => panic!("unexpected {other:?}"),
2003        }
2004    }
2005
2006    #[test]
2007    fn failover_is_bounded_and_never_after_output() {
2008        let a = ok("a", vec![win("primary", 7 * DAY, 100.0, NOW + DAY)]);
2009        let b = ok("b", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)]);
2010        let c = ok("c", vec![win("primary", 7 * DAY, 100.0, NOW + DAY)]);
2011        let cands = [a, b, c];
2012        let ev = FailureEvidence {
2013            kind: FailureKind::QuotaExhaustedPreOutput,
2014            output_started: false,
2015            tool_activity: false,
2016            failovers_so_far: 0,
2017        };
2018        match failover(&req(), &cred("a"), &ev, &cands) {
2019            FailoverDecision::Failover { credential } => {
2020                assert_eq!(credential.storage_key(), "openai-codex@b")
2021            }
2022            other => panic!("unexpected {other:?}"),
2023        }
2024        // second failover denied
2025        let ev2 = FailureEvidence {
2026            failovers_so_far: 1,
2027            ..ev.clone()
2028        };
2029        assert!(matches!(
2030            failover(&req(), &cred("b"), &ev2, &cands),
2031            FailoverDecision::NoFailover { .. }
2032        ));
2033        // output started → never
2034        let ev3 = FailureEvidence {
2035            output_started: true,
2036            ..ev.clone()
2037        };
2038        assert!(matches!(
2039            failover(&req(), &cred("a"), &ev3, &cands),
2040            FailoverDecision::NoFailover { .. }
2041        ));
2042        // tool activity → never
2043        let ev4 = FailureEvidence {
2044            tool_activity: true,
2045            ..ev.clone()
2046        };
2047        assert!(matches!(
2048            failover(&req(), &cred("a"), &ev4, &cands),
2049            FailoverDecision::NoFailover { .. }
2050        ));
2051        // ordinary 429 → never
2052        let ev5 = FailureEvidence {
2053            kind: FailureKind::RateLimited,
2054            ..ev.clone()
2055        };
2056        assert!(matches!(
2057            failover(&req(), &cred("a"), &ev5, &cands),
2058            FailoverDecision::NoFailover { .. }
2059        ));
2060        // explicit account → never
2061        let r = SelectionRequest {
2062            explicit_account: Some("a"),
2063            ..req()
2064        };
2065        assert!(matches!(
2066            failover(&r, &cred("a"), &ev, &cands),
2067            FailoverDecision::NoFailover { .. }
2068        ));
2069        // no other eligible seat → NoFailover, and the failed seat is excluded
2070        // even if its snapshot still claims headroom.
2071        let only = [ok("a", vec![win("primary", 7 * DAY, 0.0, NOW + DAY)])];
2072        assert!(matches!(
2073            failover(&req(), &cred("a"), &ev, &only),
2074            FailoverDecision::NoFailover { .. }
2075        ));
2076    }
2077
2078    #[test]
2079    fn unknown_utilization_sorts_after_known() {
2080        let proven = ok(
2081            "p",
2082            vec![WindowLimit {
2083                used_percent: None,
2084                limit_reached: Some(false),
2085                ..win("primary", 7 * DAY, 0.0, NOW + DAY)
2086            }],
2087        );
2088        let known = ok("k", vec![win("primary", 7 * DAY, 95.0, NOW + DAY)]);
2089        assert_eq!(
2090            select(&req(), &[proven.clone(), known])
2091                .selected()
2092                .unwrap()
2093                .storage_key(),
2094            "openai-codex@k"
2095        );
2096        assert_eq!(
2097            select(&req(), &[proven]).selected().unwrap().storage_key(),
2098            "openai-codex@p"
2099        );
2100    }
2101
2102    #[test]
2103    fn model_matching_uses_family_tokens() {
2104        assert!(model_matches("sonnet", "claude-sonnet-4-6"));
2105        assert!(model_matches("Sonnet", "CLAUDE-SONNET-4-6"));
2106        assert!(model_matches("gpt-6-astra", "gpt-6-astra"));
2107        assert!(!model_matches("opus", "claude-sonnet-4-6"));
2108        assert!(!model_matches("", "x") && !model_matches("x", ""));
2109        let w = WindowLimit {
2110            models: Some(vec!["sonnet".into()]),
2111            ..win("seven_day_sonnet", 7 * DAY, 100.0, NOW + DAY)
2112        };
2113        assert!(w.applies_to(Some("claude-sonnet-4-6")));
2114        assert!(!w.applies_to(Some("claude-opus-4-6")));
2115        assert!(w.applies_to(None));
2116        let acct = ok("a", vec![win("seven_day", 7 * DAY, 1.0, NOW + DAY), w]);
2117        let r = SelectionRequest {
2118            model: Some("claude-sonnet-4-6"),
2119            ..req()
2120        };
2121        assert!(select(&r, std::slice::from_ref(&acct)).selected().is_none());
2122        let r = SelectionRequest {
2123            model: Some("claude-opus-4-6"),
2124            ..req()
2125        };
2126        assert!(select(&r, std::slice::from_ref(&acct)).selected().is_some());
2127    }
2128
2129    #[test]
2130    fn serde_round_trip_is_secret_free() {
2131        let a = ok("a", vec![win("primary", 7 * DAY, 100.0, NOW + DAY)]);
2132        let sel = select(&req(), &[a]);
2133        let json = serde_json::to_string(&sel).unwrap();
2134        assert!(json.contains("no_capacity"));
2135        let back: Selection = serde_json::from_str(&json).unwrap();
2136        assert_eq!(back, sel);
2137    }
2138
2139    // ── SoonestReset ─────────────────────────────────────────────────────────
2140
2141    /// Request under [`Strategy::SoonestReset`] with the constructor defaults
2142    /// (sticky, default horizon, no current seat).
2143    fn sr_req() -> SelectionRequest<'static> {
2144        SelectionRequest::soonest_reset(OAuthProviderId::OpenAiCodex, NOW, 30 * 60 * 1000)
2145    }
2146
2147    /// Observation instant used by [`ok`].
2148    const OBSERVED: u64 = NOW - 1000;
2149
2150    /// Anchored weekly window: reset is a fixed instant unrelated to the
2151    /// observation (Anthropic-like, or a Codex seat already in use).
2152    fn anchored(used: f64, resets: u64) -> WindowLimit {
2153        win("primary", 7 * DAY, used, resets)
2154    }
2155
2156    /// Unanchored weekly window: idle Codex-like seat reporting 0 % and a
2157    /// reset exactly one duration after the observation.
2158    fn unanchored() -> WindowLimit {
2159        win("primary", 7 * DAY, 0.0, OBSERVED + 7 * DAY)
2160    }
2161
2162    /// Headroom proven but no reset instant reported.
2163    fn no_reset(used: f64) -> WindowLimit {
2164        WindowLimit {
2165            resets_at_ms: None,
2166            ..win("primary", 7 * DAY, used, 0)
2167        }
2168    }
2169
2170    fn label_of(sel: &Selection) -> &str {
2171        sel.selected().expect("a selection").account.label_str()
2172    }
2173
2174    fn ranks(sel: &Selection) -> Vec<(String, u32)> {
2175        let (Selection::Selected { rejections, .. } | Selection::NoCapacity { rejections, .. }) =
2176            sel;
2177        rejections
2178            .iter()
2179            .filter_map(|r| match r.reason {
2180                RejectReason::Outranked { rank } => {
2181                    Some((r.credential.account.label_str().to_string(), rank))
2182                }
2183                _ => None,
2184            })
2185            .collect()
2186    }
2187
2188    #[test]
2189    fn soonest_reset_beats_lower_utilization_where_lowest_utilization_does_not() {
2190        // `a` is busier but its capacity expires sooner.
2191        let a = ok("a", vec![anchored(60.0, NOW + 2 * DAY)]);
2192        let b = ok("b", vec![anchored(10.0, NOW + 5 * DAY)]);
2193        let cands = [b.clone(), a.clone()];
2194        match select(&sr_req(), &cands) {
2195            Selection::Selected {
2196                credential,
2197                tier,
2198                budget_reset_ms,
2199                utilization,
2200                ..
2201            } => {
2202                assert_eq!(credential.storage_key(), "openai-codex@a");
2203                assert_eq!(tier, 3);
2204                assert_eq!(budget_reset_ms, Some(NOW + 2 * DAY));
2205                assert_eq!(utilization, Some(60.0));
2206            }
2207            other => panic!("unexpected {other:?}"),
2208        }
2209        // Same input, previous strategy: spreads load to the emptier seat.
2210        assert_eq!(label_of(&select(&req(), &cands)), "b");
2211        // `new` keeps its defaults byte-for-byte: not sticky, no current.
2212        let r = req();
2213        assert_eq!(r.strategy, Strategy::LowestUtilization);
2214        assert!(!r.sticky && r.current.is_none());
2215        assert_eq!(r.urgent_horizon_ms, DEFAULT_URGENT_HORIZON_MS);
2216        let s = sr_req();
2217        assert_eq!(s.strategy, Strategy::SoonestReset);
2218        assert!(s.sticky && s.current.is_none());
2219        assert_eq!(s.urgent_horizon_ms, DEFAULT_URGENT_HORIZON_MS);
2220    }
2221
2222    #[test]
2223    fn tiers_order_urgent_then_unanchored_then_far_then_no_reset() {
2224        let urgent = ok("u", vec![anchored(50.0, NOW + 6 * HOUR)]);
2225        let sliding = ok("s", vec![unanchored()]);
2226        let far = ok("f", vec![anchored(0.0, NOW + 5 * DAY)]);
2227        let none = ok("n", vec![no_reset(0.0)]);
2228        let r = sr_req();
2229        for (cap, tier, anch) in [
2230            (&urgent, 1u8, Some(true)),
2231            (&sliding, 2, Some(false)),
2232            (&far, 3, Some(true)),
2233            (&none, 4, None),
2234        ] {
2235            let e = evaluate(&r, cap).unwrap();
2236            assert_eq!(e.tier, tier, "{}", cap.credential.storage_key());
2237            assert_eq!(e.anchored, anch, "{}", cap.credential.storage_key());
2238        }
2239        // Tier is computed regardless of strategy (for `auth plan`).
2240        assert_eq!(evaluate(&req(), &sliding).unwrap().tier, 2);
2241        // Lower utilization/preference never beats a higher tier.
2242        let cands = [none.clone(), far.clone(), sliding.clone(), urgent.clone()];
2243        let sel = select(&r, &cands);
2244        assert_eq!(label_of(&sel), "u");
2245        assert_eq!(
2246            ranks(&sel),
2247            vec![
2248                ("s".to_string(), 2),
2249                ("f".to_string(), 3),
2250                ("n".to_string(), 4)
2251            ]
2252        );
2253        assert_eq!(
2254            label_of(&select(&r, &[none.clone(), far.clone(), sliding])),
2255            "s"
2256        );
2257        assert_eq!(label_of(&select(&r, &[none.clone(), far])), "f");
2258        assert_eq!(label_of(&select(&r, &[none])), "n");
2259        // Urgency is horizon-relative: with a 1 h horizon the 6 h seat is tier 3
2260        // and loses to the unanchored seat.
2261        let short = SelectionRequest {
2262            urgent_horizon_ms: HOUR,
2263            ..sr_req()
2264        };
2265        assert_eq!(evaluate(&short, &urgent).unwrap().tier, 3);
2266        assert_eq!(
2267            label_of(&select(&short, &[urgent, ok("s", vec![unanchored()])])),
2268            "s"
2269        );
2270        // Exactly at the horizon is still urgent (inclusive).
2271        let edge = ok("e", vec![anchored(0.0, NOW + DEFAULT_URGENT_HORIZON_MS)]);
2272        assert_eq!(evaluate(&sr_req(), &edge).unwrap().tier, 1);
2273    }
2274
2275    #[test]
2276    fn within_tier_orders_by_reset_preference_or_utilization() {
2277        // Tier 1: soonest reset wins even with higher utilization.
2278        let a = ok("a", vec![anchored(90.0, NOW + 2 * HOUR)]);
2279        let b = ok("b", vec![anchored(1.0, NOW + 3 * HOUR)]);
2280        assert_eq!(label_of(&select(&sr_req(), &[b, a])), "a");
2281        // Tier 2: preference, then storage key; utilization is irrelevant.
2282        let s1 = ok("s1", vec![unanchored()]);
2283        let s2 = ok("s2", vec![unanchored()]);
2284        assert_eq!(
2285            label_of(&select(&sr_req(), &[s2.clone(), s1.clone()])),
2286            "s1"
2287        );
2288        let pref = vec!["s2".to_string()];
2289        let r = SelectionRequest {
2290            preference: &pref,
2291            ..sr_req()
2292        };
2293        assert_eq!(label_of(&select(&r, &[s1, s2])), "s2");
2294        // Tier 4: lowest utilization, unknown last.
2295        let n1 = ok("n1", vec![no_reset(40.0)]);
2296        let n2 = ok("n2", vec![no_reset(5.0)]);
2297        let n3 = ok(
2298            "n3",
2299            vec![WindowLimit {
2300                used_percent: None,
2301                limit_reached: Some(false),
2302                ..no_reset(0.0)
2303            }],
2304        );
2305        let sel = select(&sr_req(), &[n3, n1, n2]);
2306        assert_eq!(label_of(&sel), "n2");
2307        assert_eq!(
2308            ranks(&sel),
2309            vec![("n1".to_string(), 2), ("n3".to_string(), 3)]
2310        );
2311        // Equal resets in tier 3: lowest utilization breaks the tie.
2312        let t1 = ok("t1", vec![anchored(30.0, NOW + 3 * DAY)]);
2313        let t2 = ok("t2", vec![anchored(20.0, NOW + 3 * DAY)]);
2314        assert_eq!(label_of(&select(&sr_req(), &[t1, t2])), "t2");
2315    }
2316
2317    #[test]
2318    fn exhausted_soonest_seat_is_skipped_to_next_soonest() {
2319        let x = ok("x", vec![anchored(100.0, NOW + HOUR)]);
2320        let a = ok("a", vec![anchored(20.0, NOW + 2 * DAY)]);
2321        let b = ok("b", vec![anchored(20.0, NOW + 3 * DAY)]);
2322        let sel = select(&sr_req(), &[b, x.clone(), a]);
2323        assert_eq!(label_of(&sel), "a");
2324        let Selection::Selected { rejections, .. } = &sel else {
2325            panic!("expected selection");
2326        };
2327        assert!(rejections
2328            .iter()
2329            .any(|r| r.credential.account.label_str() == "x"
2330                && r.reason
2331                    == RejectReason::Exhausted {
2332                        window: "primary".into(),
2333                        resets_at_ms: Some(NOW + HOUR),
2334                    }));
2335        // Everything exhausted: earliest *future* reset is reported.
2336        let y = ok("y", vec![anchored(100.0, NOW + 2 * DAY)]);
2337        let past = ok("p", vec![anchored(100.0, NOW - HOUR)]);
2338        match select(&sr_req(), &[y, past, x]) {
2339            Selection::NoCapacity {
2340                earliest_reset_ms, ..
2341            } => assert_eq!(earliest_reset_ms, Some(NOW + HOUR)),
2342            other => panic!("unexpected {other:?}"),
2343        }
2344    }
2345
2346    #[test]
2347    fn five_hour_throttle_rejects_then_reenters_after_reset() {
2348        let throttled = ok(
2349            "t",
2350            vec![
2351                win("five_hour", 5 * HOUR, 100.0, NOW + HOUR),
2352                win("seven_day", 7 * DAY, 30.0, NOW + 2 * DAY),
2353            ],
2354        );
2355        let other = ok("o", vec![win("seven_day", 7 * DAY, 10.0, NOW + 4 * DAY)]);
2356        let sel = select(&sr_req(), &[throttled, other.clone()]);
2357        assert_eq!(label_of(&sel), "o");
2358        let Selection::Selected { rejections, .. } = &sel else {
2359            panic!("expected selection");
2360        };
2361        assert_eq!(
2362            rejections[0].reason,
2363            RejectReason::Exhausted {
2364                window: "five_hour".into(),
2365                resets_at_ms: Some(NOW + HOUR),
2366            }
2367        );
2368        // Clock past the 5 h reset, fresh observations: the 5 h window is
2369        // empty again and the seat wins on its sooner 7 d deadline.
2370        let now2 = NOW + HOUR + 1;
2371        let mut throttled2 = ok(
2372            "t",
2373            vec![
2374                win("five_hour", 5 * HOUR, 0.0, now2 + 5 * HOUR),
2375                win("seven_day", 7 * DAY, 30.0, NOW + 2 * DAY),
2376            ],
2377        );
2378        throttled2.observed_at_ms = Some(now2 - 1000);
2379        let mut other2 = other;
2380        other2.observed_at_ms = Some(now2 - 1000);
2381        let r = SelectionRequest {
2382            now_ms: now2,
2383            ..sr_req()
2384        };
2385        match select(&r, &[other2, throttled2]) {
2386            Selection::Selected {
2387                credential,
2388                tier,
2389                budget_reset_ms,
2390                ..
2391            } => {
2392                assert_eq!(credential.storage_key(), "openai-codex@t");
2393                assert_eq!(tier, 3);
2394                assert_eq!(budget_reset_ms, Some(NOW + 2 * DAY));
2395            }
2396            other => panic!("unexpected {other:?}"),
2397        }
2398    }
2399
2400    #[test]
2401    fn is_unanchored_classifies_sliding_resets_only() {
2402        let obs = NOW - 90_000;
2403        // Codex-like idle seat: reset == observed + duration.
2404        let codex_idle = win("primary", 7 * DAY, 0.0, obs + 7 * DAY);
2405        assert_eq!(is_unanchored(&codex_idle, obs), Some(true));
2406        // One minute of skew either way is within tolerance.
2407        let skewed = win("primary", 7 * DAY, 0.0, obs + 7 * DAY + 60_000);
2408        assert_eq!(is_unanchored(&skewed, obs), Some(true));
2409        let skewed = win("primary", 7 * DAY, 0.0, obs + 7 * DAY - 60_000);
2410        assert_eq!(is_unanchored(&skewed, obs), Some(true));
2411        // Just beyond tolerance → anchored.
2412        let beyond = win(
2413            "primary",
2414            7 * DAY,
2415            0.0,
2416            obs + 7 * DAY + UNANCHORED_TOLERANCE_MS + 1,
2417        );
2418        assert_eq!(is_unanchored(&beyond, obs), Some(false));
2419        // Anthropic-like: 0 % but the calendar reset is 5 days out.
2420        let anthropic = win("seven_day", 7 * DAY, 0.0, obs + 5 * DAY);
2421        assert_eq!(is_unanchored(&anthropic, obs), Some(false));
2422        // Real consumption means the window has already been anchored.
2423        let used = win("primary", 7 * DAY, 40.0, obs + 7 * DAY);
2424        assert_eq!(is_unanchored(&used, obs), Some(false));
2425        // Threshold is inclusive.
2426        let half = win(
2427            "primary",
2428            7 * DAY,
2429            UNANCHORED_MAX_USED_PERCENT,
2430            obs + 7 * DAY,
2431        );
2432        assert_eq!(is_unanchored(&half, obs), Some(true));
2433        // Unreported utilization is never evidence of idleness.
2434        let unreported = WindowLimit {
2435            used_percent: None,
2436            limit_reached: Some(false),
2437            ..codex_idle.clone()
2438        };
2439        assert_eq!(is_unanchored(&unreported, obs), Some(false));
2440        // Missing duration or reset → unknown.
2441        let no_dur = WindowLimit {
2442            duration_ms: None,
2443            ..codex_idle.clone()
2444        };
2445        assert_eq!(is_unanchored(&no_dur, obs), None);
2446        let no_reset = WindowLimit {
2447            resets_at_ms: None,
2448            ..codex_idle
2449        };
2450        assert_eq!(is_unanchored(&no_reset, obs), None);
2451    }
2452
2453    #[test]
2454    fn budget_window_prefers_weekly_then_longest() {
2455        let five_h = win("five_hour", 5 * HOUR, 0.0, NOW + HOUR);
2456        let one_h = win("one_hour", HOUR, 0.0, NOW + HOUR);
2457        let thirty_d = win("thirty_day", 30 * DAY, 0.0, NOW + 20 * DAY);
2458        let weekly_wide = win("weekly", 7 * DAY, 0.0, NOW + 3 * DAY);
2459        let weekly_scoped = WindowLimit {
2460            id: "aaa_weekly_scoped".into(),
2461            models: Some(vec!["m".into()]),
2462            ..weekly_wide.clone()
2463        };
2464        // Account-wide weekly beats model-scoped weekly beats longest other.
2465        let all = [
2466            thirty_d.clone(),
2467            weekly_scoped.clone(),
2468            five_h.clone(),
2469            weekly_wide.clone(),
2470        ];
2471        assert_eq!(
2472            budget_window(&all, Some("m")).map(|w| w.id.as_str()),
2473            Some("weekly")
2474        );
2475        assert_eq!(
2476            budget_window(&all, None).map(|w| w.id.as_str()),
2477            Some("weekly")
2478        );
2479        let no_wide = [thirty_d.clone(), weekly_scoped.clone(), five_h.clone()];
2480        assert_eq!(
2481            budget_window(&no_wide, Some("m")).map(|w| w.id.as_str()),
2482            Some("aaa_weekly_scoped")
2483        );
2484        // Scoped weekly does not apply to another model → longest applicable.
2485        assert_eq!(
2486            budget_window(&no_wide, Some("other")).map(|w| w.id.as_str()),
2487            Some("thirty_day")
2488        );
2489        // No weekly at all → longest duration.
2490        assert_eq!(
2491            budget_window(&[five_h.clone(), one_h.clone()], None).map(|w| w.id.as_str()),
2492            Some("five_hour")
2493        );
2494        // Equal durations: account-wide first, then id.
2495        let b5 = WindowLimit {
2496            id: "b5".into(),
2497            ..five_h.clone()
2498        };
2499        let a5_scoped = WindowLimit {
2500            id: "a5".into(),
2501            models: Some(vec!["m".into()]),
2502            ..five_h.clone()
2503        };
2504        assert_eq!(
2505            budget_window(&[b5.clone(), a5_scoped.clone()], Some("m")).map(|w| w.id.as_str()),
2506            Some("b5")
2507        );
2508        let a5 = WindowLimit {
2509            id: "a5".into(),
2510            ..five_h.clone()
2511        };
2512        assert_eq!(
2513            budget_window(&[b5, a5], None).map(|w| w.id.as_str()),
2514            Some("a5")
2515        );
2516        // No durations → no budget window.
2517        let no_dur = WindowLimit {
2518            duration_ms: None,
2519            ..five_h
2520        };
2521        assert!(budget_window(&[no_dur.clone(), no_dur], None).is_none());
2522        assert!(budget_window(&[], None).is_none());
2523        // `weekly_window` is unchanged by the shared picker.
2524        assert_eq!(weekly_window(&all).map(|w| w.id.as_str()), Some("weekly"));
2525    }
2526
2527    #[test]
2528    fn past_reset_with_headroom_is_not_reset_evidence() {
2529        // Fresh reading, headroom, but the reported reset already passed: no
2530        // deadline can be inferred → tier 4, not tier 1 with a zero horizon.
2531        let stale_reset = ok("p", vec![anchored(20.0, NOW - 1)]);
2532        let e = evaluate(&sr_req(), &stale_reset).unwrap();
2533        assert_eq!(e.budget_reset_ms, None);
2534        assert_eq!(e.anchored, None);
2535        assert_eq!(e.tier, 4);
2536        let far = ok("f", vec![anchored(80.0, NOW + 6 * DAY)]);
2537        assert_eq!(label_of(&select(&sr_req(), &[stale_reset, far])), "f");
2538    }
2539
2540    #[test]
2541    fn sticky_keeps_current_within_tier_3() {
2542        let cur = cred("cur");
2543        let current = ok("cur", vec![anchored(30.0, NOW + 5 * DAY)]);
2544        let sooner = ok("soon", vec![anchored(10.0, NOW + 2 * DAY)]);
2545        let r = SelectionRequest {
2546            current: Some(&cur),
2547            ..sr_req()
2548        };
2549        let sel = select(&r, &[sooner.clone(), current.clone()]);
2550        assert_eq!(label_of(&sel), "cur");
2551        assert_eq!(ranks(&sel), vec![("soon".to_string(), 2)]);
2552        // Without a current seat the sooner deadline wins.
2553        assert_eq!(
2554            label_of(&select(&sr_req(), &[sooner.clone(), current.clone()])),
2555            "soon"
2556        );
2557        // sticky = false ignores `current`.
2558        let r = SelectionRequest { sticky: false, ..r };
2559        assert_eq!(label_of(&select(&r, &[sooner, current])), "soon");
2560    }
2561
2562    #[test]
2563    fn sticky_yields_to_strictly_higher_tier() {
2564        let cur = cred("cur");
2565        let current = ok("cur", vec![anchored(10.0, NOW + 5 * DAY)]);
2566        let urgent = ok("urg", vec![anchored(90.0, NOW + 6 * HOUR)]);
2567        let r = SelectionRequest {
2568            current: Some(&cur),
2569            ..sr_req()
2570        };
2571        assert_eq!(label_of(&select(&r, &[current.clone(), urgent])), "urg");
2572        // Tier 2 also outranks a tier-3 current seat.
2573        let sliding = ok("sld", vec![unanchored()]);
2574        assert_eq!(label_of(&select(&r, &[current, sliding])), "sld");
2575        // But a tier-2 current seat is kept over another tier-2 seat that
2576        // would otherwise sort first, and over any tier-3 seat.
2577        let cur_sliding = ok("cur", vec![unanchored()]);
2578        let a_sliding = ok("a", vec![unanchored()]);
2579        let far = ok("far", vec![anchored(0.0, NOW + 2 * DAY)]);
2580        assert_eq!(label_of(&select(&r, &[a_sliding, far, cur_sliding])), "cur");
2581    }
2582
2583    #[test]
2584    fn sticky_tier1_yields_only_to_strictly_sooner_reset() {
2585        let cur = cred("cur");
2586        let current = ok("cur", vec![anchored(50.0, NOW + 10 * HOUR)]);
2587        let r = SelectionRequest {
2588            current: Some(&cur),
2589            ..sr_req()
2590        };
2591        let sooner = ok("soon", vec![anchored(80.0, NOW + 6 * HOUR)]);
2592        assert_eq!(label_of(&select(&r, &[current.clone(), sooner])), "soon");
2593        // Equal reset with lower utilization would sort first, but it is not
2594        // strictly sooner → current kept.
2595        let equal = ok("eq", vec![anchored(1.0, NOW + 10 * HOUR)]);
2596        let sel = select(&r, &[equal.clone(), current.clone()]);
2597        assert_eq!(label_of(&sel), "cur");
2598        assert_eq!(ranks(&sel), vec![("eq".to_string(), 2)]);
2599        // Later reset in tier 1 → current kept.
2600        let later = ok("late", vec![anchored(0.0, NOW + 12 * HOUR)]);
2601        assert_eq!(label_of(&select(&r, &[later, current])), "cur");
2602    }
2603
2604    #[test]
2605    fn sticky_never_keeps_ineligible_current_and_only_applies_to_soonest_reset() {
2606        let cur = cred("cur");
2607        let other = ok("o", vec![anchored(50.0, NOW + 5 * DAY)]);
2608        let r = SelectionRequest {
2609            current: Some(&cur),
2610            ..sr_req()
2611        };
2612        // Exhausted current → switch.
2613        let exhausted = ok("cur", vec![anchored(100.0, NOW + DAY)]);
2614        assert_eq!(label_of(&select(&r, &[exhausted, other.clone()])), "o");
2615        // Cooling-down current → switch.
2616        let mut cooling = ok("cur", vec![anchored(0.0, NOW + DAY)]);
2617        cooling.cooldown_until_ms = Some(NOW + HOUR);
2618        assert_eq!(label_of(&select(&r, &[cooling, other.clone()])), "o");
2619        // Stale current → switch.
2620        let mut stale = ok("cur", vec![anchored(0.0, NOW + DAY)]);
2621        stale.observed_at_ms = Some(NOW - 2 * HOUR);
2622        assert_eq!(label_of(&select(&r, &[stale, other.clone()])), "o");
2623        // Excluded current → switch (exclusion wins over stickiness).
2624        let excl = vec![cur.clone()];
2625        let r_excl = SelectionRequest {
2626            exclude: &excl,
2627            ..r.clone()
2628        };
2629        let healthy = ok("cur", vec![anchored(0.0, NOW + DAY)]);
2630        assert_eq!(
2631            label_of(&select(&r_excl, &[healthy.clone(), other.clone()])),
2632            "o"
2633        );
2634        // Current not among candidates at all → normal ranking, no panic.
2635        assert_eq!(label_of(&select(&r, std::slice::from_ref(&other))), "o");
2636        // Stickiness never applies to the other strategies, even if asked.
2637        let busy_current = ok("cur", vec![anchored(90.0, NOW + 5 * DAY)]);
2638        let r_lu = SelectionRequest {
2639            strategy: Strategy::LowestUtilization,
2640            sticky: true,
2641            current: Some(&cur),
2642            ..req()
2643        };
2644        assert_eq!(
2645            label_of(&select(&r_lu, &[busy_current.clone(), other.clone()])),
2646            "o"
2647        );
2648        let pref = vec!["o".to_string()];
2649        let r_po = SelectionRequest {
2650            strategy: Strategy::PreferenceOrder,
2651            sticky: true,
2652            current: Some(&cur),
2653            preference: &pref,
2654            ..req()
2655        };
2656        assert_eq!(label_of(&select(&r_po, &[busy_current, other])), "o");
2657    }
2658
2659    #[test]
2660    fn failover_under_soonest_reset_never_repicks_sticky_failed_seat() {
2661        let cur = cred("a");
2662        // `a` still claims headroom and would be kept by stickiness.
2663        let a = ok("a", vec![anchored(0.0, NOW + 2 * DAY)]);
2664        let b = ok("b", vec![anchored(50.0, NOW + 5 * DAY)]);
2665        let r = SelectionRequest {
2666            current: Some(&cur),
2667            ..sr_req()
2668        };
2669        assert_eq!(label_of(&select(&r, &[a.clone(), b.clone()])), "a");
2670        let ev = FailureEvidence {
2671            kind: FailureKind::QuotaExhaustedPreOutput,
2672            output_started: false,
2673            tool_activity: false,
2674            failovers_so_far: 0,
2675        };
2676        match failover(&r, &cur, &ev, &[a.clone(), b]) {
2677            FailoverDecision::Failover { credential } => {
2678                assert_eq!(credential.storage_key(), "openai-codex@b")
2679            }
2680            other => panic!("unexpected {other:?}"),
2681        }
2682        assert!(matches!(
2683            failover(&r, &cur, &ev, &[a]),
2684            FailoverDecision::NoFailover { .. }
2685        ));
2686    }
2687
2688    #[test]
2689    fn explicit_account_under_soonest_reset_never_falls_back() {
2690        let a = ok("a", vec![anchored(100.0, NOW + HOUR)]);
2691        let b = ok("b", vec![anchored(0.0, NOW + 6 * HOUR)]);
2692        let cur = cred("b");
2693        // Explicit exhausted seat: fail, even though `b` is urgent and pinned.
2694        let r = SelectionRequest {
2695            explicit_account: Some("a"),
2696            current: Some(&cur),
2697            ..sr_req()
2698        };
2699        match select(&r, &[a.clone(), b.clone()]) {
2700            Selection::NoCapacity { rejections, .. } => {
2701                assert_eq!(rejections.len(), 1);
2702                assert_eq!(rejections[0].credential.storage_key(), "openai-codex@a");
2703            }
2704            other => panic!("unexpected {other:?}"),
2705        }
2706        // Explicit healthy seat: other seats are never even evaluated.
2707        let r = SelectionRequest {
2708            explicit_account: Some("b"),
2709            ..sr_req()
2710        };
2711        match select(&r, &[a, b]) {
2712            Selection::Selected {
2713                credential,
2714                rejections,
2715                tier,
2716                ..
2717            } => {
2718                assert_eq!(credential.storage_key(), "openai-codex@b");
2719                assert!(rejections.is_empty());
2720                assert_eq!(tier, 1);
2721            }
2722            other => panic!("unexpected {other:?}"),
2723        }
2724    }
2725
2726    /// All permutations of `items` (Heap's algorithm), for order-independence checks.
2727    fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
2728        fn go<T: Clone>(k: usize, a: &mut Vec<T>, out: &mut Vec<Vec<T>>) {
2729            if k <= 1 {
2730                out.push(a.clone());
2731                return;
2732            }
2733            go(k - 1, a, out);
2734            for i in 0..k - 1 {
2735                if k.is_multiple_of(2) {
2736                    a.swap(i, k - 1);
2737                } else {
2738                    a.swap(0, k - 1);
2739                }
2740                go(k - 1, a, out);
2741            }
2742        }
2743        let mut a = items.to_vec();
2744        let mut out = Vec::new();
2745        go(a.len(), &mut a, &mut out);
2746        out
2747    }
2748
2749    #[test]
2750    fn soonest_reset_is_deterministic_under_candidate_shuffle() {
2751        let cur = cred("t3b");
2752        let seats = vec![
2753            ok("t1", vec![anchored(70.0, NOW + 3 * HOUR)]),
2754            ok("t2", vec![unanchored()]),
2755            ok("t3a", vec![anchored(5.0, NOW + 2 * DAY)]),
2756            ok("t3b", vec![anchored(40.0, NOW + 4 * DAY)]),
2757            ok("x", vec![anchored(100.0, NOW + DAY)]),
2758        ];
2759        let r = SelectionRequest {
2760            current: Some(&cur),
2761            ..sr_req()
2762        };
2763        let perms = permutations(&seats);
2764        assert_eq!(perms.len(), 120);
2765        let baseline = select(&r, &seats);
2766        assert_eq!(label_of(&baseline), "t1");
2767        let normalize = |sel: &Selection| {
2768            let Selection::Selected {
2769                credential,
2770                tier,
2771                budget_reset_ms,
2772                utilization,
2773                rejections,
2774            } = sel
2775            else {
2776                panic!("expected selection");
2777            };
2778            let mut rej: Vec<(String, RejectReason)> = rejections
2779                .iter()
2780                .map(|r| (r.credential.storage_key(), r.reason.clone()))
2781                .collect();
2782            rej.sort_by(|a, b| a.0.cmp(&b.0));
2783            (
2784                credential.storage_key(),
2785                *tier,
2786                *budget_reset_ms,
2787                *utilization,
2788                rej,
2789            )
2790        };
2791        let expected = normalize(&baseline);
2792        assert_eq!(
2793            ranks(&baseline),
2794            vec![
2795                ("t2".to_string(), 2),
2796                ("t3a".to_string(), 3),
2797                ("t3b".to_string(), 4)
2798            ]
2799        );
2800        for p in perms {
2801            assert_eq!(normalize(&select(&r, &p)), expected);
2802        }
2803    }
2804
2805    #[test]
2806    fn outranked_ranks_are_two_to_n_in_ranking_order() {
2807        let seats = [
2808            ok("c", vec![anchored(0.0, NOW + 4 * DAY)]),
2809            ok("a", vec![anchored(0.0, NOW + 2 * DAY)]),
2810            ok("d", vec![anchored(0.0, NOW + 5 * DAY)]),
2811            ok("b", vec![anchored(0.0, NOW + 3 * DAY)]),
2812        ];
2813        let sel = select(&sr_req(), &seats);
2814        assert_eq!(label_of(&sel), "a");
2815        assert_eq!(
2816            ranks(&sel),
2817            vec![
2818                ("b".to_string(), 2),
2819                ("c".to_string(), 3),
2820                ("d".to_string(), 4)
2821            ]
2822        );
2823        // Ranks are produced under the existing strategies too.
2824        let lu = [
2825            ok("hi", vec![anchored(80.0, NOW + DAY)]),
2826            ok("lo", vec![anchored(10.0, NOW + DAY)]),
2827            ok("mid", vec![anchored(50.0, NOW + DAY)]),
2828        ];
2829        let sel = select(&req(), &lu);
2830        assert_eq!(label_of(&sel), "lo");
2831        assert_eq!(
2832            ranks(&sel),
2833            vec![("mid".to_string(), 2), ("hi".to_string(), 3)]
2834        );
2835        assert_eq!(
2836            RejectReason::Outranked { rank: 3 }.to_string(),
2837            "eligible; outranked (rank 3)"
2838        );
2839    }
2840
2841    #[test]
2842    fn soonest_reset_serde_is_backward_compatible() {
2843        assert_eq!(
2844            serde_json::to_string(&Strategy::SoonestReset).unwrap(),
2845            "\"soonest_reset\""
2846        );
2847        assert_eq!(
2848            serde_json::from_str::<Strategy>("\"soonest_reset\"").unwrap(),
2849            Strategy::SoonestReset
2850        );
2851        let a = ok("a", vec![anchored(10.0, NOW + 2 * HOUR)]);
2852        let b = ok("b", vec![anchored(10.0, NOW + 3 * HOUR)]);
2853        let sel = select(&sr_req(), &[a, b]);
2854        let json = serde_json::to_string(&sel).unwrap();
2855        assert!(json.contains("\"tier\":1"));
2856        assert!(json.contains("\"budget_reset_ms\""));
2857        assert!(json.contains("\"rank\":2"));
2858        assert_eq!(serde_json::from_str::<Selection>(&json).unwrap(), sel);
2859        // Records written before tiers/ranks existed still decode.
2860        let old = r#"{"outcome":"selected","credential":"openai-codex@a","utilization":10.0,"rejections":[{"credential":"openai-codex@b","reason":{"reason":"outranked"}}]}"#;
2861        match serde_json::from_str::<Selection>(old).unwrap() {
2862            Selection::Selected {
2863                tier,
2864                budget_reset_ms,
2865                rejections,
2866                ..
2867            } => {
2868                assert_eq!(tier, 0);
2869                assert_eq!(budget_reset_ms, None);
2870                assert_eq!(rejections[0].reason, RejectReason::Outranked { rank: 0 });
2871            }
2872            other => panic!("unexpected {other:?}"),
2873        }
2874    }
2875
2876    // ── Typed selection failures ─────────────────────────────────────────
2877
2878    fn failed(label: &str, failure: ObservationFailure) -> AccountCapacity {
2879        AccountCapacity::failed(cred(label), failure)
2880    }
2881
2882    fn failure_of(candidates: &[AccountCapacity]) -> SelectionFailure {
2883        match select(&req(), candidates) {
2884            Selection::NoCapacity { rejections, .. } => {
2885                SelectionFailure::from_rejections(&rejections, NOW)
2886            }
2887            other => panic!("expected no capacity, got {other:?}"),
2888        }
2889    }
2890
2891    #[test]
2892    fn observation_failure_maps_to_a_never_capacity_observation() {
2893        use AccountFailureKind as K;
2894        for (kind, obs) in [
2895            (K::UsageRateLimited, QuotaObservation::Unknown),
2896            (K::RefreshTransient, QuotaObservation::Unknown),
2897            (K::UsageUnauthorized, QuotaObservation::AuthError),
2898            (K::RefreshRejected, QuotaObservation::AuthError),
2899            (K::CredentialMissing, QuotaObservation::AuthError),
2900            (K::UsageMalformed, QuotaObservation::Malformed),
2901            (K::Unsupported, QuotaObservation::Unsupported),
2902            (K::RefreshActionRequired, QuotaObservation::Unknown),
2903        ] {
2904            assert_eq!(ObservationFailure::new(kind).observation(), obs, "{kind}");
2905        }
2906        let f = ObservationFailure::new(K::UsageRateLimited)
2907            .with_status(429)
2908            .with_retry_after(Some(120));
2909        assert_eq!(
2910            f.to_string(),
2911            "usage_rate_limited, HTTP 429, retry after 120s"
2912        );
2913        assert_eq!(
2914            serde_json::to_value(&f).unwrap(),
2915            serde_json::json!({"kind": "usage_rate_limited", "http_status": 429, "retry_after_s": 120})
2916        );
2917        // Records without a cause (older writers) still decode.
2918        let old: Rejection = serde_json::from_str(
2919            r#"{"credential":"openai-codex@a","reason":{"reason":"unknown"}}"#,
2920        )
2921        .unwrap();
2922        assert_eq!(old.cause, None);
2923        assert_eq!(old.describe(), "quota unknown");
2924    }
2925
2926    #[test]
2927    fn rejection_keeps_the_observation_cause_beside_a_cooldown() {
2928        use AccountFailureKind as K;
2929        let limited = ObservationFailure::new(K::UsageRateLimited).with_status(429);
2930        let mut cooling = failed("b", limited.clone());
2931        cooling.cooldown_until_ms = Some(NOW + HOUR);
2932        match select(&req(), &[failed("a", limited.clone()), cooling]) {
2933            Selection::NoCapacity { rejections, .. } => {
2934                assert_eq!(rejections[0].reason, RejectReason::Unknown);
2935                assert_eq!(rejections[0].cause.as_ref(), Some(&limited));
2936                assert_eq!(
2937                    rejections[0].describe(),
2938                    "quota unknown (usage_rate_limited, HTTP 429)"
2939                );
2940                // The verdict stays the policy's (eligibility unchanged),
2941                // but the read's failure is kept beside it (r2 blocker 1).
2942                assert!(matches!(
2943                    rejections[1].reason,
2944                    RejectReason::CoolingDown { .. }
2945                ));
2946                assert_eq!(rejections[1].cause.as_ref(), Some(&limited));
2947            }
2948            other => panic!("unexpected {other:?}"),
2949        }
2950    }
2951
2952    /// r2 blocker 1: a cooling account's failure keeps the observation kind
2953    /// (login advice depends on it) with the cooldown beside it, and still
2954    /// counts as limited for the top-level kind.
2955    #[test]
2956    fn a_cooling_accounts_failure_keeps_its_read_kind_and_counts_as_limited() {
2957        use AccountFailureKind as K;
2958        use SelectionFailureKind as S;
2959        let mut refused = failed(
2960            "a",
2961            ObservationFailure::new(K::UsageUnauthorized).with_status(401),
2962        );
2963        refused.cooldown_until_ms = Some(NOW + 2 * HOUR);
2964        let mut throttled = failed(
2965            "b",
2966            ObservationFailure::new(K::UsageRateLimited)
2967                .with_status(429)
2968                .with_retry_after(Some(90)),
2969        );
2970        throttled.cooldown_until_ms = Some(NOW + HOUR);
2971        let mut plain = ok("c", vec![win("primary", 5 * HOUR, 10.0, NOW + DAY)]);
2972        plain.cooldown_until_ms = Some(NOW + 3 * HOUR);
2973
2974        let f = failure_of(&[refused, throttled, plain]);
2975        assert_eq!(f.kind, S::AllCoolingDown, "{f:?}");
2976        let a = &f.accounts[0];
2977        assert_eq!(
2978            (a.kind, a.http_status, a.cooling_down, a.until_ms),
2979            (K::UsageUnauthorized, Some(401), true, Some(NOW + 2 * HOUR))
2980        );
2981        let b = &f.accounts[1];
2982        assert_eq!(
2983            (b.kind, b.http_status, b.retry_after_s, b.cooling_down),
2984            (K::UsageRateLimited, Some(429), Some(90), true)
2985        );
2986        let c = &f.accounts[2];
2987        assert_eq!((c.kind, c.cooling_down), (K::CoolingDown, true));
2988        assert_eq!(f.login_labels(), vec!["a"]);
2989        assert_eq!(f.earliest_reset_ms, Some(NOW + HOUR));
2990        assert_eq!(
2991            f.accounts_field(),
2992            "a:usage_unauthorized:401:cooling,b:usage_rate_limited:429:cooling,c:cooling_down"
2993        );
2994        // Serialized with the flag; an account that is not cooling omits it.
2995        let v = serde_json::to_value(&f).unwrap();
2996        assert_eq!(v["accounts"][0]["cooling_down"], true);
2997        let g = failure_of(&[failed("d", ObservationFailure::new(K::UsageTimeout))]);
2998        assert!(serde_json::to_value(&g).unwrap()["accounts"][0]
2999            .get("cooling_down")
3000            .is_none());
3001    }
3002
3003    /// r3, the B1 headline rule: the headline is what the user can act on
3004    /// now, classified over the accounts that aren't cooling down; a
3005    /// cooling account's read failure is shown per account (kind, status,
3006    /// Retry-After, the cooling flag and `:cooling` in the WARN field).
3007    /// A: every account cools, one also had a usage 429 → all_cooling_down.
3008    /// B: a cooling 429 plus a live refused login → credential_rejected.
3009    #[test]
3010    fn the_headline_skips_a_cooling_accounts_read_failure_which_stays_per_account() {
3011        use AccountFailureKind as K;
3012        use SelectionFailureKind as S;
3013        let throttled = || {
3014            let mut c = failed(
3015                "claude5",
3016                ObservationFailure::new(K::UsageRateLimited)
3017                    .with_status(429)
3018                    .with_retry_after(Some(90)),
3019            );
3020            c.cooldown_until_ms = Some(NOW + HOUR);
3021            c
3022        };
3023        let c5_kept = |f: &SelectionFailure| {
3024            let c5 = &f.accounts[0];
3025            assert_eq!(
3026                (
3027                    c5.account.as_str(),
3028                    c5.kind,
3029                    c5.http_status,
3030                    c5.retry_after_s,
3031                    c5.cooling_down,
3032                    c5.until_ms
3033                ),
3034                (
3035                    "claude5",
3036                    K::UsageRateLimited,
3037                    Some(429),
3038                    Some(90),
3039                    true,
3040                    Some(NOW + HOUR)
3041                ),
3042                "{f:?}"
3043            );
3044        };
3045
3046        let mut plain = ok("claude6", vec![win("primary", 5 * HOUR, 10.0, NOW + DAY)]);
3047        plain.cooldown_until_ms = Some(NOW + 2 * HOUR);
3048        let a = failure_of(&[throttled(), plain]);
3049        assert_eq!(a.kind, S::AllCoolingDown, "{a:?}");
3050        c5_kept(&a);
3051        assert_eq!(
3052            a.accounts_field(),
3053            "claude5:usage_rate_limited:429:cooling,claude6:cooling_down"
3054        );
3055        assert_eq!(a.earliest_reset_ms, Some(NOW + HOUR));
3056        assert!(a.login_labels().is_empty(), "{a:?}");
3057
3058        let refused = failed(
3059            "claude2",
3060            ObservationFailure::new(K::UsageUnauthorized).with_status(401),
3061        );
3062        let b = failure_of(&[throttled(), refused]);
3063        assert_eq!(b.kind, S::CredentialRejected, "{b:?}");
3064        c5_kept(&b);
3065        assert_eq!(
3066            b.accounts_field(),
3067            "claude5:usage_rate_limited:429:cooling,claude2:usage_unauthorized:401"
3068        );
3069        assert_eq!(b.earliest_reset_ms, Some(NOW + HOUR));
3070        assert_eq!(b.login_labels(), vec!["claude2"]);
3071    }
3072
3073    /// r2 item 7: an account is usable again only when every exhausted
3074    /// window has reset and its cooldown has ended, so its `until` is the
3075    /// latest of those; the earliest reset is the minimum over accounts.
3076    #[test]
3077    fn an_accounts_until_is_its_latest_exhausted_reset_and_cooldown() {
3078        // (a) The second exhausted window resets later than the first.
3079        let later = ok(
3080            "a",
3081            vec![
3082                win("five_hour", 5 * HOUR, 100.0, NOW + HOUR),
3083                win("seven_day", 7 * DAY, 100.0, NOW + 3 * DAY),
3084            ],
3085        );
3086        // (b) The second exhausted window resets earlier than the first.
3087        let earlier = ok(
3088            "b",
3089            vec![
3090                win("seven_day", 7 * DAY, 100.0, NOW + 2 * DAY),
3091                win("five_hour", 5 * HOUR, 100.0, NOW + 2 * HOUR),
3092            ],
3093        );
3094        // Cooling beyond its only exhausted window's reset.
3095        let mut cooled = ok("c", vec![win("five_hour", 5 * HOUR, 100.0, NOW + HOUR)]);
3096        cooled.cooldown_until_ms = Some(NOW + 4 * HOUR);
3097        // An exhausted window without a reset makes the account's time unknown.
3098        let mut unknown = ok("d", vec![win("seven_day", 7 * DAY, 100.0, NOW + DAY)]);
3099        if let QuotaObservation::Ok { windows, .. } = &mut unknown.observation {
3100            windows.push(WindowLimit {
3101                resets_at_ms: None,
3102                ..win("five_hour", 5 * HOUR, 100.0, 0)
3103            });
3104        }
3105
3106        let until = |f: &SelectionFailure, label: &str| {
3107            f.accounts
3108                .iter()
3109                .find(|a| a.account == label)
3110                .unwrap()
3111                .until_ms
3112        };
3113        let f = failure_of(&[later.clone(), earlier.clone(), cooled.clone(), unknown]);
3114        assert_eq!(until(&f, "a"), Some(NOW + 3 * DAY));
3115        assert_eq!(until(&f, "b"), Some(NOW + 2 * DAY));
3116        assert_eq!(until(&f, "c"), Some(NOW + 4 * HOUR));
3117        assert_eq!(until(&f, "d"), None);
3118        assert_eq!(f.earliest_reset_ms, Some(NOW + 4 * HOUR));
3119        assert_eq!(
3120            failure_of(&[later, earlier]).earliest_reset_ms,
3121            Some(NOW + 2 * DAY)
3122        );
3123        // Eligibility is unchanged: the first exhausted window still names
3124        // the verdict.
3125        match select(&req(), &[cooled]) {
3126            Selection::NoCapacity { rejections, .. } => {
3127                assert!(matches!(
3128                    rejections[0].reason,
3129                    RejectReason::CoolingDown { .. }
3130                ));
3131            }
3132            other => panic!("unexpected {other:?}"),
3133        }
3134    }
3135
3136    /// "Send again after" names a time some account is usable: a cooling
3137    /// account that also needs a login (or an action) is not usable when
3138    /// its cooldown ends, so it never sets the earliest reset; it still
3139    /// shows its own time.
3140    #[test]
3141    fn the_earliest_reset_skips_accounts_that_need_a_login_or_an_action() {
3142        use AccountFailureKind as K;
3143        let mut refused = failed(
3144            "a",
3145            ObservationFailure::new(K::UsageUnauthorized).with_status(401),
3146        );
3147        refused.cooldown_until_ms = Some(NOW + HOUR);
3148        let mut broken = failed("b", ObservationFailure::new(K::CredentialStore));
3149        broken.cooldown_until_ms = Some(NOW + 2 * HOUR);
3150        let mut plain = ok("c", vec![win("primary", 5 * HOUR, 10.0, NOW + DAY)]);
3151        plain.cooldown_until_ms = Some(NOW + 3 * HOUR);
3152
3153        let f = failure_of(&[refused.clone(), broken.clone(), plain]);
3154        assert_eq!(f.kind, SelectionFailureKind::AllCoolingDown, "{f:?}");
3155        assert_eq!(f.earliest_reset_ms, Some(NOW + 3 * HOUR));
3156        assert_eq!(f.accounts[0].until_ms, Some(NOW + HOUR));
3157        assert_eq!(f.accounts[1].until_ms, Some(NOW + 2 * HOUR));
3158        assert_eq!(failure_of(&[refused, broken]).earliest_reset_ms, None);
3159    }
3160
3161    /// r2 blocker 2: a refresh that needs action outside synaps and an
3162    /// unreadable store are their own kind, never `quota_unknown`.
3163    #[test]
3164    fn action_required_and_store_failures_get_their_own_kind() {
3165        use AccountFailureKind as K;
3166        use SelectionFailureKind as S;
3167        let action = ObservationFailure::new(K::RefreshActionRequired);
3168        let store = ObservationFailure::new(K::CredentialStore);
3169        let refused = ObservationFailure::new(K::UsageUnauthorized).with_status(401);
3170        let mut cooling = ok("c", vec![win("primary", 5 * HOUR, 10.0, NOW + DAY)]);
3171        cooling.cooldown_until_ms = Some(NOW + HOUR);
3172
3173        for f in [
3174            failure_of(&[failed("a", action.clone())]),
3175            failure_of(&[failed("a", store.clone())]),
3176            failure_of(&[
3177                failed("a", action.clone()),
3178                failed("b", store.clone()),
3179                cooling.clone(),
3180            ]),
3181            failure_of(&[failed("a", action.clone()), failed("b", refused.clone())]),
3182        ] {
3183            assert_eq!(f.kind, S::ActionRequired, "{f:?}");
3184        }
3185        assert!(K::RefreshActionRequired.needs_action());
3186        assert!(K::CredentialStore.needs_action());
3187        assert!(
3188            !K::RefreshActionRequired.needs_login() && !K::RefreshActionRequired.is_transient()
3189        );
3190        let f = failure_of(&[failed("a", action.clone()), failed("b", store.clone())]);
3191        assert_eq!(f.action_labels(), vec!["a", "b"]);
3192        assert!(f.login_labels().is_empty());
3193        // A transient read elsewhere still means "capacity unproven".
3194        let f = failure_of(&[
3195            failed("a", action),
3196            failed("b", ObservationFailure::new(K::UsageTimeout)),
3197        ]);
3198        assert_eq!(f.kind, S::UsageReadFailed);
3199        assert_eq!(f.action_labels(), vec!["a"]);
3200        assert_eq!(S::ActionRequired.as_str(), "action_required");
3201        assert_eq!(S::CredentialStore.as_str(), "credential_store");
3202    }
3203
3204    #[test]
3205    fn selection_failure_kind_priority_and_login_labels() {
3206        use AccountFailureKind as K;
3207        use SelectionFailureKind as S;
3208        let transient = ObservationFailure::new(K::UsageUpstream).with_status(503);
3209        let rejected = ObservationFailure::new(K::RefreshRejected);
3210        let exhausted = ok("x", vec![win("primary", 5 * HOUR, 100.0, NOW + 2 * HOUR)]);
3211        let mut cooling = ok("c", vec![win("primary", 5 * HOUR, 10.0, NOW + DAY)]);
3212        cooling.cooldown_until_ms = Some(NOW + HOUR);
3213
3214        assert_eq!(failure_of(&[]).kind, S::NoCredentials);
3215
3216        // Transient beats a rejected login: capacity unproven, not absent.
3217        let f = failure_of(&[
3218            failed("a", rejected.clone()),
3219            failed("b", transient.clone()),
3220        ]);
3221        assert_eq!(f.kind, S::UsageReadFailed);
3222        assert_eq!(f.login_labels(), vec!["a"]);
3223        assert_eq!(
3224            f.accounts_field(),
3225            "a:refresh_rejected,b:usage_upstream:503"
3226        );
3227
3228        // Every account not cooling down needs a login.
3229        let f = failure_of(&[failed("a", rejected.clone()), cooling.clone()]);
3230        assert_eq!(f.kind, S::CredentialRejected);
3231        assert_eq!(f.login_labels(), vec!["a"]);
3232        assert_eq!(f.earliest_reset_ms, Some(NOW + HOUR));
3233
3234        // Every account limited; the earliest reset over cooldowns and
3235        // exhausted windows.
3236        let f = failure_of(&[exhausted.clone(), cooling.clone()]);
3237        assert_eq!(f.kind, S::AllCoolingDown);
3238        assert_eq!(f.accounts_field(), "x:exhausted,c:cooling_down");
3239        assert_eq!(f.earliest_reset_ms, Some(NOW + HOUR));
3240        assert!(f.login_labels().is_empty());
3241
3242        // A login problem among accounts that proved nothing is not
3243        // "every account needs a login".
3244        let no_evidence = ok("n", vec![]);
3245        let f = failure_of(&[failed("a", rejected), no_evidence]);
3246        assert_eq!(f.kind, S::QuotaUnknown);
3247        assert_eq!(f.login_labels(), vec!["a"]);
3248
3249        // Refresh lock contention and local refresh failures are transient.
3250        for kind in [
3251            K::RefreshLockBusy,
3252            K::RefreshOther,
3253            K::UsageTimeout,
3254            K::UsageTransport,
3255        ] {
3256            let f = failure_of(&[failed("a", ObservationFailure::new(kind))]);
3257            assert_eq!(f.kind, S::UsageReadFailed, "{kind}");
3258        }
3259        // Not transient and not a login problem: their own kind (r2
3260        // blocker 2), never quota_unknown.
3261        for kind in [K::RefreshActionRequired, K::CredentialStore] {
3262            let f = failure_of(&[failed("a", ObservationFailure::new(kind))]);
3263            assert_eq!(f.kind, S::ActionRequired, "{kind}");
3264            assert!(f.login_labels().is_empty(), "{kind}");
3265        }
3266        let f = failure_of(&[failed("a", ObservationFailure::new(K::UsageMalformed))]);
3267        assert_eq!(f.kind, S::QuotaUnknown);
3268        assert!(f.login_labels().is_empty());
3269        // A past reset is never reported.
3270        let stale_exhausted = ok("y", vec![win("primary", 5 * HOUR, 100.0, NOW - 1)]);
3271        assert_eq!(failure_of(&[stale_exhausted]).earliest_reset_ms, None);
3272    }
3273    /// A duplicate-seat slot is excluded before its cooldown is checked, so
3274    /// it must not count as a live account: one exhausted seat plus its
3275    /// excluded duplicate is `all_cooling_down` with the seat's reset, not
3276    /// `quota_unknown` ("send again to retry").
3277    #[test]
3278    fn an_excluded_duplicate_seat_does_not_pick_the_headline() {
3279        use AccountFailureKind as K;
3280        let reset = NOW + 2 * HOUR;
3281        let spent = ok("claude1", vec![win("primary", 5 * HOUR, 100.0, reset)]);
3282        let dup = ok("claude1-dup", vec![win("primary", 5 * HOUR, 100.0, reset)]);
3283        let exclude = [cred("claude1-dup")];
3284        let request = SelectionRequest {
3285            exclude: &exclude,
3286            ..req()
3287        };
3288        let f = match select(&request, &[spent, dup]) {
3289            Selection::NoCapacity { rejections, .. } => {
3290                SelectionFailure::from_rejections(&rejections, NOW)
3291            }
3292            other => panic!("expected no capacity, got {other:?}"),
3293        };
3294        assert_eq!(f.kind, SelectionFailureKind::AllCoolingDown, "{f:?}");
3295        assert_eq!(f.earliest_reset_ms, Some(reset));
3296        assert_eq!(f.accounts[1].kind, K::Excluded);
3297
3298        // Same with a cooling seat and a transient read failure on the dup.
3299        let mut cooling = ok("a", vec![win("primary", 5 * HOUR, 10.0, NOW + DAY)]);
3300        cooling.cooldown_until_ms = Some(NOW + HOUR);
3301        let dup = failed("b", ObservationFailure::new(K::UsageRateLimited));
3302        let exclude = [cred("b")];
3303        let request = SelectionRequest {
3304            exclude: &exclude,
3305            ..req()
3306        };
3307        let f = match select(&request, &[cooling, dup]) {
3308            Selection::NoCapacity { rejections, .. } => {
3309                SelectionFailure::from_rejections(&rejections, NOW)
3310            }
3311            other => panic!("expected no capacity, got {other:?}"),
3312        };
3313        assert_eq!(f.kind, SelectionFailureKind::AllCoolingDown, "{f:?}");
3314        assert_eq!(f.earliest_reset_ms, Some(NOW + HOUR));
3315
3316        // Every account excluded and none limited: nothing proves a limit
3317        // or a login problem, so the headline stays `quota_unknown`.
3318        let only = ok("x", vec![win("primary", 5 * HOUR, 10.0, NOW + DAY)]);
3319        let exclude = [cred("x")];
3320        let request = SelectionRequest {
3321            exclude: &exclude,
3322            ..req()
3323        };
3324        let f = match select(&request, &[only]) {
3325            Selection::NoCapacity { rejections, .. } => {
3326                SelectionFailure::from_rejections(&rejections, NOW)
3327            }
3328            other => panic!("expected no capacity, got {other:?}"),
3329        };
3330        assert_eq!(f.kind, SelectionFailureKind::QuotaUnknown, "{f:?}");
3331    }
3332}