Skip to main content

forge_gateway/
selection.rs

1//! Account selection: which account serves a session the gateway has
2//! no binding for.
3//!
4//! The walk is the org's `accounts` list then `fallback_accounts`,
5//! filtered by declared models: the first account whose `models` list
6//! includes the requested model wins. Within the surviving set the
7//! first account in walk order wins, with saturation demoting an
8//! account to a later tier: ready beats saturated, and a Bailed
9//! account is the last resort (its 429 hit the usage probe, not
10//! inference).
11
12use crate::account::{AccountKey, AccountStateMap, LoadingState, account_serves};
13
14/// An org's walk order: the primary pin, then fallbacks.
15#[derive(Debug, Clone, Default)]
16pub struct OrgPin {
17    pub accounts: Vec<String>,
18    pub fallback_accounts: Vec<String>,
19}
20
21/// Why no account was selected. Loud by design: a silent fallback
22/// would route a session to an account its model cannot use.
23#[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
29/// `true` when the account serves `model`, by declaration or alias.
30fn 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
37/// Pick the account for `model` out of `org`'s walk order: the first
38/// account whose declared models include the model. Ready accounts
39/// beat saturated ones, and Bailed accounts are the last resort. A
40/// retired account - one a reload removed - is never offered.
41pub 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            // Walk order decides: the first ready account in the org's
82            // own order is the pick, so no later account is consulted.
83            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    /// One fixture account: name, provider, loading state, last probe
111    /// error, and the models it declares.
112    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    /// Declare `alias` as an alternative name for `model` on one
139    /// fixture account.
140    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        // Push Stargate over the cap: a full window with a future reset.
248        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        // Same tier on both tiers of the pin: walk order decides, and
274        // the primaries come first.
275        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        // A ready fallback beats a degraded primary: state outranks
285        // tier, which is what sends a session to a live fallback
286        // instead of a dead primary.
287        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}