1use std::collections::{BTreeMap, HashMap};
9use std::time::Duration;
10
11use parking_lot::Mutex;
12
13use crate::Provider;
14
15use crate::UsageSnapshot;
16use crate::account::{
17 AccountKey, AccountStateMap, LoadingState, Unusable, UsageFetchStatus, account_serves,
18};
19
20pub struct AccountPool {
22 accounts: Mutex<AccountStateMap>,
23}
24
25impl AccountPool {
26 pub fn new(specs: &[forge_primitives::account::LoadedAccount]) -> Self {
27 Self { accounts: Mutex::new(AccountStateMap::new(specs)) }
28 }
29
30 #[cfg(any(test, feature = "testing"))]
32 pub fn empty_for_test() -> Self {
33 Self { accounts: Mutex::new(AccountStateMap::empty_for_test()) }
34 }
35
36 pub fn all_loaded(&self) -> bool {
37 self.accounts.lock().all_loaded()
38 }
39
40 pub fn usage(&self, display_name: &str) -> Option<UsageSnapshot> {
41 self.accounts.lock().usage(&AccountKey(display_name.to_owned())).cloned()
42 }
43
44 pub fn latest_reading(&self, display_name: &str) -> Option<UsageSnapshot> {
48 self.accounts.lock().latest_reading(&AccountKey(display_name.to_owned())).cloned()
49 }
50
51 pub fn usage_error(&self, display_name: &str) -> Option<UsageFetchStatus> {
52 self.accounts.lock().usage_error(&AccountKey(display_name.to_owned()))
53 }
54
55 pub fn auth(&self, display_name: &str) -> Option<forge_primitives::account::AccountAuth> {
56 self.accounts.lock().auth(&AccountKey(display_name.to_owned()))
57 }
58
59 pub fn env(&self, key: &AccountKey) -> Option<HashMap<String, String>> {
60 self.accounts.lock().env(key).cloned()
61 }
62
63 pub fn provider(&self, key: &AccountKey) -> Option<forge_primitives::account::Provider> {
64 self.accounts.lock().provider(key)
65 }
66
67 pub fn loading_state(&self, key: &AccountKey) -> LoadingState {
68 self.accounts.lock().loading_state(key)
69 }
70
71 pub fn is_saturated(&self, key: &AccountKey) -> bool {
72 self.accounts.lock().is_saturated(key)
73 }
74
75 pub fn unusable_reason(&self, key: &AccountKey) -> Option<Unusable> {
76 self.accounts.lock().unusable_reason(key)
77 }
78
79 pub fn scheduler_should_probe(&self, key: &AccountKey) -> bool {
80 self.accounts.lock().scheduler_should_probe(key)
81 }
82
83 pub fn should_probe_now(&self, key: &AccountKey) -> bool {
84 self.accounts.lock().should_probe_now(key)
85 }
86
87 pub fn set_usage(&self, key: &AccountKey, snapshot: UsageSnapshot) {
88 self.accounts.lock().set_usage(key, snapshot);
89 }
90
91 pub fn set_loading(&self, key: &AccountKey, loading: LoadingState) {
92 self.accounts.lock().set_loading(key, loading);
93 }
94
95 pub fn set_limit_reading(&self, key: &AccountKey, snapshot: UsageSnapshot) {
96 self.accounts.lock().set_limit_reading(key, snapshot);
97 }
98
99 pub fn set_last_error(
100 &self,
101 key: &AccountKey,
102 status: UsageFetchStatus,
103 retry_after: Option<Duration>,
104 ) {
105 self.accounts.lock().set_last_error(key, status, retry_after);
106 }
107
108 pub fn clear_last_error(&self, key: &AccountKey) {
109 self.accounts.lock().clear_last_error(key);
110 }
111
112 pub fn disarm_override(&self, key: &AccountKey) {
113 self.accounts.lock().disarm_override(key);
114 }
115
116 pub fn seed_from_cache(&self, cached: &BTreeMap<String, UsageSnapshot>) {
117 self.accounts.lock().seed_from_cache(cached);
118 }
119
120 pub fn snapshots_for_cache(&self) -> BTreeMap<String, UsageSnapshot> {
121 self.accounts.lock().snapshots_for_cache()
122 }
123
124 pub fn account_names(&self) -> Vec<String> {
125 self.accounts.lock().ordered_keys.iter().map(|k| k.0.clone()).collect()
126 }
127
128 pub fn ordered_account_keys(&self) -> Vec<AccountKey> {
130 self.accounts.lock().ordered_keys.clone()
131 }
132
133 pub fn next_probe_after(&self, key: &AccountKey) -> Option<Duration> {
136 self.accounts
137 .lock()
138 .by_key
139 .get(key)
140 .and_then(|s| s.next_probe_at)
141 .and_then(|t| t.checked_duration_since(std::time::Instant::now()))
142 }
143
144 pub fn probe_entries(&self) -> Vec<(AccountKey, Provider, HashMap<String, String>)> {
147 let state = self.accounts.lock();
148 state
149 .ordered_keys
150 .iter()
151 .filter(|key| state.scheduler_should_probe(key))
152 .filter_map(|key| {
153 let provider = state.provider(key)?;
154 Some((key.clone(), provider, state.env(key).cloned().unwrap_or_default()))
155 })
156 .collect()
157 }
158
159 pub fn disarm_backoff_overrides(&self, keys: &[AccountKey]) {
162 let mut state = self.accounts.lock();
163 for key in keys {
164 if !state.should_probe_now(key) {
165 state.disarm_override(key);
166 }
167 }
168 }
169
170 pub fn select_account(
172 &self,
173 pin: &crate::selection::OrgPin,
174 org: &str,
175 model: &str,
176 ) -> Result<AccountKey, crate::selection::SelectionError> {
177 let state = self.accounts.lock();
178 crate::selection::select_account(&state, pin, org, model)
179 }
180
181 pub fn declares(&self, key: &AccountKey, model: &str) -> bool {
184 self.accounts
185 .lock()
186 .by_key
187 .get(key)
188 .is_some_and(|account| account_serves(&account.models, &account.model_aliases, model))
189 }
190
191 pub fn model_slug_for(&self, key: &AccountKey, canonical: &str) -> Option<String> {
195 self.accounts.lock().by_key.get(key)?.model_slugs.get(canonical).cloned()
196 }
197
198 #[cfg(any(test, feature = "testing"))]
201 pub fn set_model_slug(&self, key: &AccountKey, canonical: &str, slug: &str) {
202 if let Some(account) = self.accounts.lock().by_key.get_mut(key) {
203 account.model_slugs.insert(canonical.to_owned(), slug.to_owned());
204 }
205 }
206
207 pub fn provider_and_env(
209 &self,
210 key: &AccountKey,
211 ) -> Option<(Provider, HashMap<String, String>)> {
212 let state = self.accounts.lock();
213 let account = state.by_key.get(key)?;
214 Some((account.provider, account.env.clone()))
215 }
216
217 #[cfg(any(test, feature = "testing"))]
219 pub fn replace_state_for_test(&self, map: AccountStateMap) {
220 *self.accounts.lock() = map;
221 }
222
223 pub fn replace_accounts(&self, specs: &[forge_primitives::account::LoadedAccount]) {
230 let mut current = self.accounts.lock();
231 let mut next = AccountStateMap::new(specs);
232 for key in &next.ordered_keys {
233 let Some(previous) = current.by_key.get(key) else { continue };
234 let Some(state) = next.by_key.get_mut(key) else { continue };
235 state.usage.clone_from(&previous.usage);
236 state.last_error.clone_from(&previous.last_error);
237 state.next_probe_at = previous.next_probe_at;
238 state.consecutive_failures = previous.consecutive_failures;
239 state.override_armed = previous.override_armed;
240 state.loading = previous.loading;
241 }
242 for (key, previous) in ¤t.by_key {
247 if next.by_key.contains_key(key) {
248 continue;
249 }
250 let mut retired = previous.clone();
251 retired.retired = true;
252 next.by_key.insert(key.clone(), retired);
253 }
254 *current = next;
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use std::collections::HashMap;
261 use std::time::SystemTime;
262
263 use forge_primitives::account::{LoadedAccount, Provider};
264 use forge_primitives::usage::{UsageSnapshot, UsageSourceKind};
265
266 use super::AccountPool;
267 use crate::account::AccountKey;
268
269 fn account(name: &str) -> LoadedAccount {
270 LoadedAccount {
271 display_name: name.to_owned(),
272 provider: Provider::Anthropic,
273 base_url: None,
274 models: vec!["test-model".to_owned()],
275 model_slugs: HashMap::new(),
276 model_aliases: HashMap::new(),
277 env: HashMap::new(),
278 }
279 }
280
281 fn snapshot() -> UsageSnapshot {
282 UsageSnapshot {
283 source: UsageSourceKind::Oauth,
284 fetched_at: SystemTime::UNIX_EPOCH,
285 five_hour: None,
286 seven_day: None,
287 seven_day_opus: None,
288 seven_day_sonnet: None,
289 extra_usage: None,
290 spend: None,
291 balance: None,
292 }
293 }
294
295 #[test]
299 fn a_reload_carries_surviving_accounts_health_across() {
300 let pool = AccountPool::new(&[account("Kept"), account("Gone")]);
301 let kept = AccountKey("Kept".to_owned());
302 let gone = AccountKey("Gone".to_owned());
303 pool.set_usage(&kept, snapshot());
304 pool.set_loading(&kept, crate::LoadingState::Ready);
305
306 pool.replace_accounts(&[account("Kept"), account("Added")]);
307
308 assert!(pool.usage("Kept").is_some(), "the surviving account keeps its usage reading");
309 assert_eq!(
310 pool.loading_state(&kept),
311 crate::LoadingState::Ready,
312 "the surviving account stays ready, so a spawn is not refused after an edit",
313 );
314 assert_eq!(
315 pool.loading_state(&AccountKey("Added".to_owned())),
316 crate::LoadingState::Loading,
317 "an account the reload added waits for its first probe",
318 );
319 assert!(
320 pool.provider_and_env(&gone).is_some(),
321 "a bound session still resolves a removed account's provider and env",
322 );
323 }
324
325 #[test]
330 fn a_retired_account_is_never_selected() {
331 let pool = AccountPool::new(&[account("Kept"), account("Gone")]);
332 let gone = AccountKey("Gone".to_owned());
333 pool.set_loading(&gone, crate::LoadingState::Ready);
334 pool.replace_accounts(&[account("Kept")]);
335
336 let pin = crate::selection::OrgPin {
337 accounts: vec!["Gone".to_owned()],
338 fallback_accounts: Vec::new(),
339 };
340 let picked =
341 crate::selection::select_account(&pool.accounts.lock(), &pin, "Org", "test-model");
342
343 assert_eq!(
344 picked,
345 Err(crate::selection::SelectionError::NoEligibleAccount {
346 model: "test-model".to_owned(),
347 org: "Org".to_owned(),
348 }),
349 "a retired account is not offered to a new selection",
350 );
351 }
352
353 #[test]
358 fn a_retired_loading_account_does_not_hold_the_loaded_gate_closed() {
359 let pool = AccountPool::new(&[account("Kept"), account("Gone")]);
360 let kept = AccountKey("Kept".to_owned());
361 pool.set_loading(&kept, crate::LoadingState::Ready);
362 assert_eq!(
363 pool.loading_state(&AccountKey("Gone".to_owned())),
364 crate::LoadingState::Loading,
365 "premise: the removed account is retired while still loading",
366 );
367
368 pool.replace_accounts(&[account("Kept")]);
369
370 assert!(
371 pool.all_loaded(),
372 "a retired account can never load, so it must not keep the gate closed",
373 );
374 }
375}