1use super::quota_policy::{
5 self as quota, AccountCapacity, QuotaObservation, RejectReason, Selection, SelectionRequest,
6 Strategy,
7};
8use super::{CredentialRef, OAuthProviderId};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct AutoSelectionPolicy {
14 pub strategy: Strategy,
15 pub overrides: BTreeMap<OAuthProviderId, Strategy>,
16 pub urgent_horizon_ms: u64,
17 pub sticky: bool,
18}
19impl Default for AutoSelectionPolicy {
20 fn default() -> Self {
21 Self {
22 strategy: Strategy::SoonestReset,
23 overrides: BTreeMap::new(),
24 urgent_horizon_ms: quota::DEFAULT_URGENT_HORIZON_MS,
25 sticky: true,
26 }
27 }
28}
29impl AutoSelectionPolicy {
30 pub fn strategy_for(&self, provider: OAuthProviderId) -> Strategy {
31 self.overrides
32 .get(&provider)
33 .copied()
34 .unwrap_or(self.strategy)
35 }
36 pub fn from_config_map(map: &BTreeMap<String, String>) -> (Self, Vec<String>) {
39 let mut policy = Self::default();
40 let mut warnings = Vec::new();
41 for (key, value) in map {
42 let strategy = || match value.trim() {
43 "soonest_reset" => Some(Strategy::SoonestReset),
44 "lowest_utilization" => Some(Strategy::LowestUtilization),
45 "preference_order" => Some(Strategy::PreferenceOrder),
46 _ => None,
47 };
48 let valid = match key.as_str() {
49 "strategy" => strategy().map(|s| policy.strategy = s).is_some(),
50 "sticky" => value
51 .trim()
52 .parse::<bool>()
53 .map(|v| policy.sticky = v)
54 .is_ok(),
55 "urgent_horizon_hours" => value
56 .trim()
57 .parse::<u64>()
58 .ok()
59 .filter(|v| (1..=168).contains(v))
60 .map(|v| policy.urgent_horizon_ms = v * 3_600_000)
61 .is_some(),
62 _ => {
63 if let Some(provider) = key
64 .strip_prefix("strategy.")
65 .and_then(|p| p.parse::<OAuthProviderId>().ok())
66 {
67 policy
70 .overrides
71 .insert(provider, strategy().unwrap_or(Strategy::SoonestReset));
72 strategy().is_some()
73 } else {
74 false
75 }
76 }
77 };
78 if !valid {
79 warnings.push(format!("invalid auth.auto.{key}; using documented default"));
80 }
81 }
82 (policy, warnings)
83 }
84}
85
86#[derive(Clone, Debug, Serialize, Deserialize)]
87pub struct PlanRow {
88 pub credential: CredentialRef,
89 pub rank: Option<u32>,
90 pub tier: Option<u8>,
91 pub anchored: Option<bool>,
92 pub budget_reset_ms: Option<u64>,
93 pub utilization: Option<f64>,
94 pub verdict: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub cause: Option<quota::ObservationFailure>,
98}
99#[derive(Clone, Debug, Serialize, Deserialize)]
100pub struct SelectionPlan {
101 pub provider: String,
102 pub model: Option<String>,
103 pub strategy: Strategy,
104 pub urgent_horizon_ms: u64,
105 pub sticky: bool,
106 pub current: Option<CredentialRef>,
107 pub now_ms: u64,
108 pub rows: Vec<PlanRow>,
109 pub selection: Selection,
110}
111impl SelectionPlan {
112 pub fn from_candidates(req: &SelectionRequest<'_>, candidates: &[AccountCapacity]) -> Self {
113 let selection = quota::select(req, candidates);
114 let rejections = match &selection {
115 Selection::Selected { rejections, .. } | Selection::NoCapacity { rejections, .. } => {
116 rejections
117 }
118 };
119 let mut rows: Vec<_> = candidates
120 .iter()
121 .filter(|c| c.credential.provider == req.provider)
122 .map(|cap| {
123 let eligible = quota::evaluate(req, cap).ok();
124 let rejection = rejections.iter().find(|r| r.credential == cap.credential);
125 let reason = rejection.map(|r| &r.reason);
126 let selected = selection.selected() == Some(&cap.credential);
127 let rank = if selected {
128 Some(1)
129 } else if let Some(RejectReason::Outranked { rank }) = reason {
130 Some(*rank)
131 } else {
132 None
133 };
134 let budget = match &cap.observation {
137 QuotaObservation::Ok { windows, .. } => {
138 quota::budget_window(windows, req.model)
139 }
140 _ => None,
141 };
142 PlanRow {
143 credential: cap.credential.clone(),
144 rank,
145 tier: eligible.as_ref().map(|e| e.tier),
146 anchored: eligible.as_ref().and_then(|e| e.anchored),
147 budget_reset_ms: eligible
148 .as_ref()
149 .and_then(|e| e.budget_reset_ms)
150 .or_else(|| budget.and_then(|w| w.resets_at_ms)),
151 utilization: eligible
152 .as_ref()
153 .and_then(|e| e.utilization)
154 .or_else(|| budget.and_then(|w| w.used_percent)),
155 verdict: if selected {
156 "selected".into()
157 } else {
158 rejection
159 .map(quota::Rejection::describe)
160 .unwrap_or_else(|| "not considered".into())
161 },
162 cause: rejection.and_then(|r| r.cause.clone()),
163 }
164 })
165 .collect();
166 rows.sort_by(|a, b| {
167 a.rank
168 .unwrap_or(u32::MAX)
169 .cmp(&b.rank.unwrap_or(u32::MAX))
170 .then_with(|| a.credential.cmp(&b.credential))
171 });
172 Self {
173 provider: req.provider.to_string(),
174 model: req.model.map(str::to_string),
175 strategy: req.strategy,
176 urgent_horizon_ms: req.urgent_horizon_ms,
177 sticky: req.sticky,
178 current: req.current.cloned(),
179 now_ms: req.now_ms,
180 rows,
181 selection,
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 #[test]
190 fn config_loader_retains_auto_keys_and_surfaces_warnings() {
191 let config = crate::config::load_config_from_str("auth.auto.strategy = preference_order\nauth.auto.strategy.anthropic = lowest_utilization\nauth.auto.sticky = false\nauth.auto.urgent_horizon_hours = 48\n");
192 let (p, warnings) = AutoSelectionPolicy::from_config_map(&config.auth.auto);
193 assert!(warnings.is_empty());
194 assert_eq!(
195 p.strategy_for(OAuthProviderId::Anthropic),
196 Strategy::LowestUtilization
197 );
198 assert_eq!(
199 p.strategy_for(OAuthProviderId::OpenAiCodex),
200 Strategy::PreferenceOrder
201 );
202 assert_eq!(p.urgent_horizon_ms, 48 * 3_600_000);
203 assert!(!p.sticky);
204 let bad = crate::config::load_config_from_str(
205 "auth.auto.sticky = typo\nauth.auto.strategy = typo\n",
206 );
207 assert_eq!(
208 bad.warnings
209 .iter()
210 .filter(|w| w.contains("auth.auto"))
211 .count(),
212 2
213 );
214 }
215
216 #[test]
217 fn defaults_overrides_and_invalid_values() {
218 let p = AutoSelectionPolicy::default();
219 assert_eq!(
220 p.strategy_for(OAuthProviderId::Anthropic),
221 Strategy::SoonestReset
222 );
223 assert!(p.sticky);
224 let input = [
225 ("strategy", "lowest_utilization"),
226 ("strategy.anthropic", "preference_order"),
227 ("urgent_horizon_hours", "48"),
228 ("sticky", "false"),
229 ]
230 .into_iter()
231 .map(|(k, v)| (k.into(), v.into()))
232 .collect();
233 let (p, w) = AutoSelectionPolicy::from_config_map(&input);
234 assert!(w.is_empty());
235 assert_eq!(
236 p.strategy_for(OAuthProviderId::Anthropic),
237 Strategy::PreferenceOrder
238 );
239 assert_eq!(
240 p.strategy_for(OAuthProviderId::OpenAiCodex),
241 Strategy::LowestUtilization
242 );
243 assert_eq!(p.urgent_horizon_ms, 48 * 3_600_000);
244 assert!(!p.sticky);
245 for value in ["0", "169", "-1", "bad"] {
246 let (p, w) = AutoSelectionPolicy::from_config_map(&BTreeMap::from([(
247 "urgent_horizon_hours".into(),
248 value.into(),
249 )]));
250 assert_eq!(p.urgent_horizon_ms, quota::DEFAULT_URGENT_HORIZON_MS);
251 assert_eq!(w.len(), 1);
252 }
253 let (p, w) = AutoSelectionPolicy::from_config_map(&BTreeMap::from([
254 ("strategy.anthropic".into(), "garbage".into()),
255 ("strategy".into(), "lowest_utilization".into()),
256 ]));
257 assert_eq!(
258 p.strategy_for(OAuthProviderId::Anthropic),
259 Strategy::SoonestReset
260 );
261 assert_eq!(w.len(), 1);
262 }
263}