1use crate::account::{AccountKey, AccountStateMap, LoadingState, account_serves};
13
14#[derive(Debug, Clone, Default)]
16pub struct OrgPin {
17 pub accounts: Vec<String>,
18 pub fallback_accounts: Vec<String>,
19}
20
21#[derive(Debug, thiserror::Error, PartialEq, Eq)]
24pub enum SelectionError {
25 #[error("no account in org '{org}' serves model '{model}'")]
26 NoEligibleAccount { model: String, org: String },
27}
28
29fn declares(state: &AccountStateMap, key: &AccountKey, model: &str) -> bool {
31 state
32 .by_key
33 .get(key)
34 .is_some_and(|account| account_serves(&account.models, &account.model_aliases, model))
35}
36
37pub fn select_account(
42 state: &AccountStateMap,
43 pin: &OrgPin,
44 org: &str,
45 model: &str,
46) -> Result<AccountKey, SelectionError> {
47 let mut ready: Option<AccountKey> = None;
48 let mut saturated: Option<AccountKey> = None;
49 let mut degraded: Option<AccountKey> = None;
50
51 for name in pin.accounts.iter().chain(pin.fallback_accounts.iter()) {
52 let key = AccountKey(name.clone());
53 let Some(account) = state.by_key.get(&key) else {
54 continue;
55 };
56 if account.retired {
57 continue;
58 }
59 if !declares(state, &key, model) {
60 continue;
61 }
62 match account.loading {
63 LoadingState::Loading => continue,
64 LoadingState::Ready if state.is_saturated(&key) => {
65 if saturated.is_none() {
66 saturated = Some(key);
67 }
68 }
69 LoadingState::Ready => {
70 if ready.is_none() {
71 ready = Some(key);
72 }
73 }
74 LoadingState::Bailed => {
75 if degraded.is_none() {
76 degraded = Some(key);
77 }
78 }
79 }
80 if ready.is_some() {
81 break;
84 }
85 }
86
87 ready.or(saturated).or(degraded).ok_or_else(|| SelectionError::NoEligibleAccount {
88 model: model.to_owned(),
89 org: org.to_owned(),
90 })
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::Provider;
97 use crate::account::UsageFetchStatus;
98 use std::collections::HashMap;
99 use std::time::Duration;
100
101 fn window(resets_at: Option<std::time::SystemTime>) -> forge_primitives::usage::UsageWindow {
102 forge_primitives::usage::UsageWindow {
103 utilization: 100.0,
104 resets_at,
105 reset_description: None,
106 status: None,
107 }
108 }
109
110 type Spec<'a> = (&'a str, Provider, LoadingState, Option<UsageFetchStatus>, Vec<&'a str>);
113
114 fn pool_with(accounts: &[Spec<'_>]) -> AccountStateMap {
115 let specs: Vec<forge_primitives::account::LoadedAccount> = accounts
116 .iter()
117 .map(|(name, provider, _, _, models)| forge_primitives::account::LoadedAccount {
118 display_name: (*name).to_owned(),
119 provider: *provider,
120 base_url: None,
121 models: models.iter().map(|m| (*m).to_owned()).collect(),
122 model_aliases: std::collections::HashMap::new(),
123 model_slugs: std::collections::HashMap::new(),
124 env: HashMap::new(),
125 })
126 .collect();
127 let mut state = AccountStateMap::new(&specs);
128 for (name, _, loading, error, _) in accounts {
129 let key = AccountKey((*name).to_owned());
130 state.set_loading(&key, *loading);
131 if let Some(error) = error {
132 state.set_last_error(&key, *error, None);
133 }
134 }
135 state
136 }
137
138 fn alias(state: &mut AccountStateMap, name: &str, model: &str, alias: &str) {
141 state
142 .by_key
143 .get_mut(&AccountKey(name.to_owned()))
144 .expect("the fixture account is in the state")
145 .model_aliases
146 .insert(model.to_owned(), vec![alias.to_owned()]);
147 }
148
149 fn pin(accounts: &[&str], fallbacks: &[&str]) -> OrgPin {
150 OrgPin {
151 accounts: accounts.iter().map(|s| (*s).to_owned()).collect(),
152 fallback_accounts: fallbacks.iter().map(|s| (*s).to_owned()).collect(),
153 }
154 }
155
156 #[test]
157 fn only_an_account_declaring_the_model_is_selected() {
158 let state = pool_with(&[
159 ("Openrouter", Provider::Openrouter, LoadingState::Ready, None, vec!["glm-5.3-flash"]),
160 ("Anthropic", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5"]),
161 ]);
162 let selected = select_account(
163 &state,
164 &pin(&["Openrouter", "Anthropic"], &[]),
165 "Default",
166 "claude-opus-5",
167 )
168 .expect("the anthropic account declares the model");
169 assert_eq!(
170 selected,
171 AccountKey("Anthropic".to_owned()),
172 "the walk skips accounts that do not declare the model",
173 );
174 }
175
176 #[test]
177 fn an_account_is_selected_for_a_model_it_declares_only_as_an_alias() {
178 let mut state = pool_with(&[
179 ("Granite", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5[1m]"]),
180 ("Other", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5"]),
181 ]);
182 alias(&mut state, "Granite", "claude-opus-5[1m]", "claude-opus-5");
183 let selected =
184 select_account(&state, &pin(&["Granite", "Other"], &[]), "Default", "claude-opus-5")
185 .expect("Granite serves the model through its alias");
186 assert_eq!(
187 selected,
188 AccountKey("Granite".to_owned()),
189 "the walk reaches the aliasing account, not only the one that declares the name",
190 );
191 }
192
193 #[test]
194 fn the_first_account_declaring_the_model_wins() {
195 let state = pool_with(&[
196 ("Zai", Provider::Zai, LoadingState::Ready, None, vec!["glm-5.3-flash"]),
197 ("Openrouter", Provider::Openrouter, LoadingState::Ready, None, vec!["glm-5.3-flash"]),
198 ]);
199 let selected =
200 select_account(&state, &pin(&["Zai", "Openrouter"], &[]), "Default", "glm-5.3-flash")
201 .expect("both accounts declare the model");
202 assert_eq!(
203 selected,
204 AccountKey("Zai".to_owned()),
205 "first in walk order, not a guessed best",
206 );
207 }
208
209 #[test]
210 fn a_non_declaring_account_is_skipped() {
211 let state = pool_with(&[
212 ("Anthropic", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5"]),
213 ("Zai", Provider::Zai, LoadingState::Ready, None, vec!["glm-5.3-flash"]),
214 ]);
215 let selected =
216 select_account(&state, &pin(&["Anthropic", "Zai"], &[]), "Default", "glm-5.3-flash")
217 .expect("a declaring account is in the walk");
218 assert_eq!(selected, AccountKey("Zai".to_owned()));
219 }
220
221 #[test]
222 fn no_declaring_account_fails_naming_the_model_and_org() {
223 let state = pool_with(&[(
224 "Anthropic",
225 Provider::Anthropic,
226 LoadingState::Ready,
227 None,
228 vec!["claude-opus-5"],
229 )]);
230 let error = select_account(&state, &pin(&["Anthropic"], &[]), "Default", "glm-5.3-flash")
231 .expect_err("no account declares the model");
232 assert_eq!(
233 error,
234 SelectionError::NoEligibleAccount {
235 model: "glm-5.3-flash".to_owned(),
236 org: "Default".to_owned(),
237 },
238 );
239 }
240
241 #[test]
242 fn saturated_accounts_are_demoted_but_not_dropped() {
243 let mut state = pool_with(&[
244 ("Stargate", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5"]),
245 ("Gateway", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5"]),
246 ]);
247 let key = AccountKey("Stargate".to_owned());
249 state.set_usage(
250 &key,
251 crate::UsageSnapshot {
252 source: crate::UsageSourceKind::Oauth,
253 fetched_at: std::time::SystemTime::UNIX_EPOCH,
254 five_hour: Some(window(Some(
255 std::time::SystemTime::now() + Duration::from_secs(3600),
256 ))),
257 seven_day: None,
258 seven_day_opus: None,
259 seven_day_sonnet: None,
260 extra_usage: None,
261 spend: None,
262 balance: None,
263 },
264 );
265 let selected =
266 select_account(&state, &pin(&["Stargate", "Gateway"], &[]), "Default", "claude-opus-5")
267 .expect("a ready account exists");
268 assert_eq!(selected, AccountKey("Gateway".to_owned()), "ready beats saturated");
269 }
270
271 #[test]
272 fn fallback_accounts_are_walked_after_the_primaries() {
273 let state = pool_with(&[
276 ("Primary", Provider::Anthropic, LoadingState::Bailed, None, vec!["claude-opus-5"]),
277 ("Fallback", Provider::Anthropic, LoadingState::Bailed, None, vec!["claude-opus-5"]),
278 ]);
279 let selected =
280 select_account(&state, &pin(&["Primary"], &["Fallback"]), "Default", "claude-opus-5")
281 .expect("the degraded tier is the last resort");
282 assert_eq!(selected, AccountKey("Primary".to_owned()), "within a tier, walk order decides");
283
284 let state = pool_with(&[
288 ("Primary", Provider::Anthropic, LoadingState::Bailed, None, vec!["claude-opus-5"]),
289 ("Fallback", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5"]),
290 ]);
291 let selected =
292 select_account(&state, &pin(&["Primary"], &["Fallback"]), "Default", "claude-opus-5")
293 .expect("the ready fallback is selectable");
294 assert_eq!(
295 selected,
296 AccountKey("Fallback".to_owned()),
297 "a ready fallback outranks a degraded primary",
298 );
299 }
300
301 #[test]
302 fn loading_accounts_are_skipped_entirely() {
303 let state = pool_with(&[
304 ("Stargate", Provider::Anthropic, LoadingState::Loading, None, vec!["claude-opus-5"]),
305 ("Gateway", Provider::Anthropic, LoadingState::Ready, None, vec!["claude-opus-5"]),
306 ]);
307 let selected =
308 select_account(&state, &pin(&["Stargate", "Gateway"], &[]), "Default", "claude-opus-5")
309 .expect("a terminal account exists");
310 assert_eq!(
311 selected,
312 AccountKey("Gateway".to_owned()),
313 "a non-terminal account is not selectable",
314 );
315 }
316
317 #[test]
318 fn an_unknown_account_name_in_the_pin_is_skipped() {
319 let state = pool_with(&[(
320 "Stargate",
321 Provider::Anthropic,
322 LoadingState::Ready,
323 None,
324 vec!["claude-opus-5"],
325 )]);
326 let selected =
327 select_account(&state, &pin(&["Ghost", "Stargate"], &[]), "Default", "claude-opus-5")
328 .expect("the known account resolves");
329 assert_eq!(selected, AccountKey("Stargate".to_owned()));
330 }
331}