Skip to main content

alex_auth/
lib.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::{Arc, RwLock as StdRwLock};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use alex_core::Provider;
8use anyhow::{anyhow, bail, Context, Result};
9use base64::Engine;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use tokio::sync::{Mutex, RwLock};
13
14pub mod login;
15pub mod sessions;
16
17pub const ANTHROPIC_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
18pub const ANTHROPIC_TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
19pub const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
20pub const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
21pub const XAI_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828";
22pub const XAI_TOKEN_URL: &str = "https://auth.x.ai/oauth2/token";
23pub const GEMINI_CLIENT_ID: &str =
24    "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
25pub const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
26const ANTHROPIC_PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile";
27const XAI_USERINFO_URL: &str = "https://auth.x.ai/oauth2/userinfo";
28
29// The gemini-cli OAuth client is a public "installed app" credential embedded
30// in Google's open-source CLI (not a confidential secret). Assembled from
31// fragments so repo secret-scanners don't false-positive on the literal.
32pub fn gemini_client_secret() -> String {
33    ["GOCSPX", "4uHgMPm", "1o7Sk", "geV6Cu5clXFsxl"].join("-")
34}
35
36const REFRESH_MARGIN_MS: i64 = 120_000;
37
38pub fn now_ms() -> i64 {
39    SystemTime::now()
40        .duration_since(UNIX_EPOCH)
41        .unwrap()
42        .as_millis() as i64
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Account {
47    pub id: String,
48    pub provider: Provider,
49    pub kind: String,
50    #[serde(default = "default_account_name")]
51    pub name: String,
52    #[serde(default)]
53    pub description: Option<String>,
54    #[serde(default)]
55    pub paused: bool,
56    #[serde(default)]
57    pub label: Option<String>,
58    #[serde(default)]
59    pub access_token: Option<String>,
60    #[serde(default)]
61    pub refresh_token: Option<String>,
62    #[serde(default)]
63    pub id_token: Option<String>,
64    #[serde(default)]
65    pub api_key: Option<String>,
66    #[serde(default)]
67    pub expires_at_ms: Option<i64>,
68    #[serde(default)]
69    pub last_refresh_ms: Option<i64>,
70    #[serde(default)]
71    pub account_meta: Value,
72    #[serde(default)]
73    pub cooldown_until_ms: Option<i64>,
74    #[serde(default = "default_status")]
75    pub status: String,
76    #[serde(skip)]
77    pub path: Option<PathBuf>,
78}
79
80fn default_account_name() -> String {
81    "default".to_string()
82}
83
84fn default_status() -> String {
85    "active".to_string()
86}
87
88impl Account {
89    pub fn chatgpt_account_id(&self) -> Option<String> {
90        self.account_meta
91            .get("account_id")
92            .and_then(|v| v.as_str())
93            .map(String::from)
94    }
95
96    /// A display-only identity hint. This never returns token material.
97    pub fn email(&self) -> Option<String> {
98        fn payload_email(token: &str) -> Option<String> {
99            let payload = crate::login::jwt_payload(token)?;
100            payload
101                .get("email")
102                .and_then(Value::as_str)
103                .and_then(normalize_email)
104                .or_else(|| {
105                    payload
106                        .get("https://api.openai.com/profile")
107                        .and_then(|profile| profile.get("email"))
108                        .and_then(Value::as_str)
109                        .and_then(normalize_email)
110                })
111        }
112
113        self.account_meta
114            .get("email")
115            .and_then(Value::as_str)
116            .and_then(normalize_email)
117            .or_else(|| self.description.as_deref().and_then(normalize_email))
118            .or_else(|| {
119                (self.provider != Provider::Xai)
120                    .then(|| self.id_token.as_deref().and_then(payload_email))
121                    .flatten()
122            })
123            .or_else(|| {
124                (self.provider != Provider::Xai)
125                    .then(|| self.access_token.as_deref().and_then(payload_email))
126                    .flatten()
127            })
128            .or_else(|| {
129                self.label.as_deref().and_then(|label| {
130                    label
131                        .split(['(', ')', ' ', 'ยท'])
132                        .find_map(normalize_email)
133                })
134            })
135    }
136
137    fn needs_refresh(&self) -> bool {
138        self.kind == "oauth"
139            && match self.expires_at_ms {
140                Some(exp) => exp < now_ms() + REFRESH_MARGIN_MS,
141                None => true,
142            }
143    }
144}
145
146pub(crate) fn normalize_email(value: &str) -> Option<String> {
147    let value = value.trim();
148    (value.contains('@') && !value.chars().any(char::is_whitespace))
149        .then(|| value.to_ascii_lowercase())
150}
151
152pub(crate) fn persist_account_email(account: &mut Account, email: &str) {
153    let Some(email) = normalize_email(email) else { return };
154    if !account.account_meta.is_object() {
155        account.account_meta = json!({});
156    }
157    account.account_meta["email"] = json!(email);
158    if account.description.is_none()
159        || account
160            .description
161            .as_deref()
162            .and_then(normalize_email)
163            .is_some()
164    {
165        account.description = Some(email);
166    }
167}
168
169pub(crate) async fn fetch_provider_email(
170    http: &reqwest::Client,
171    provider: Provider,
172    access_token: &str,
173) -> Option<String> {
174    let mut request = match provider {
175        Provider::Anthropic => http.get(ANTHROPIC_PROFILE_URL),
176        Provider::Xai => http.get(XAI_USERINFO_URL),
177        _ => return None,
178    }
179    .bearer_auth(access_token)
180    .timeout(Duration::from_secs(5));
181    if provider == Provider::Anthropic {
182        request = request
183            .header("content-type", "application/json")
184            .header("cache-control", "no-cache");
185    }
186    let response = request.send().await.ok()?;
187    if !response.status().is_success() {
188        return None;
189    }
190    let profile: Value = response.json().await.ok()?;
191    let email = match provider {
192        Provider::Anthropic => profile["account"]["email"].as_str(),
193        Provider::Xai if profile["email_verified"].as_bool() == Some(false) => None,
194        Provider::Xai => profile["email"].as_str(),
195        _ => None,
196    }?;
197    normalize_email(email)
198}
199
200fn oauth_rank(account: &Account, prefer_oauth: bool) -> u8 {
201    if (account.kind == "oauth") == prefer_oauth {
202        0
203    } else {
204        1
205    }
206}
207
208fn policy_rank(policy: &AccountPolicy, name: &str) -> usize {
209    policy.order.iter().position(|n| n == name).unwrap_or(usize::MAX / 2)
210}
211
212fn utilization_pct(account: &Account) -> Option<u8> {
213    account.account_meta
214        .get("rate_limit_pct")
215        .or_else(|| account.account_meta.get("utilization_pct"))
216        .and_then(|v| v.as_u64())
217        .map(|v| v.min(100) as u8)
218}
219
220fn codex_limit_snapshot(account: &Account) -> Option<&Value> {
221    account.account_meta.get("codex_limits")
222}
223
224fn codex_window_values(account: &Account) -> impl Iterator<Item = &Value> {
225    codex_limit_snapshot(account)
226        .and_then(|snapshot| snapshot.get("windows"))
227        .and_then(Value::as_array)
228        .into_iter()
229        .flatten()
230}
231
232pub fn codex_reserve_pct(account: &Account, policy: &AccountPolicy) -> u8 {
233    policy
234        .account_reserve_pct
235        .get(&account.name)
236        .or_else(|| policy.account_reserve_pct.get(&account.id))
237        .copied()
238        .or(policy.reserve_pct)
239        .unwrap_or(10)
240        .min(100)
241}
242
243pub fn codex_reserve_blocked(account: &Account, reserve_pct: u8, now_s: i64) -> bool {
244    if reserve_pct == 0 {
245        return false;
246    }
247    codex_window_values(account).any(|window| {
248        let future_window = window
249            .get("resets_at_s")
250            .and_then(Value::as_i64)
251            .map(|reset| reset > now_s)
252            .unwrap_or(true);
253        let used_pct = window.get("used_pct").and_then(Value::as_f64);
254        future_window && used_pct.map(|used| used >= 100.0 - reserve_pct as f64).unwrap_or(false)
255    })
256}
257
258/// The binding quota window is the active window closest to exhaustion.
259/// Ties use the earlier reset. This avoids always picking the short window
260/// when a weekly/monthly window is the actual constraint.
261pub fn codex_reset_selection(account: &Account, now_s: i64) -> Option<Value> {
262    let mut selected: Option<(f64, i64, Option<String>)> = None;
263    for window in codex_window_values(account) {
264        let Some(used_pct) = window.get("used_pct").and_then(Value::as_f64) else {
265            continue;
266        };
267        let Some(resets_at_s) = window.get("resets_at_s").and_then(Value::as_i64) else {
268            continue;
269        };
270        if resets_at_s <= now_s {
271            continue;
272        }
273        let label = window.get("window").and_then(Value::as_str).map(String::from);
274        let replace = selected
275            .as_ref()
276            .map(|(current_used, current_reset, _)| {
277                used_pct > *current_used
278                    || (used_pct == *current_used && resets_at_s < *current_reset)
279            })
280            .unwrap_or(true);
281        if replace {
282            selected = Some((used_pct, resets_at_s, label));
283        }
284    }
285    selected.map(|(used_pct, resets_at_s, window)| {
286        json!({"window": window, "used_pct": used_pct, "resets_at_s": resets_at_s})
287    })
288}
289
290fn codex_binding_reset(account: &Account, now_s: i64) -> Option<i64> {
291    codex_reset_selection(account, now_s)?["resets_at_s"].as_i64()
292}
293
294fn account_proxy_eligible(account: &Account, policy: &AccountPolicy) -> bool {
295    !policy.disabled.iter().any(|name| name == &account.name || name == &account.id)
296}
297
298fn sort_by_policy(accounts: &mut Vec<Account>, policy: &AccountPolicy, prefer_oauth: bool, rr: usize) {
299    let now_s = now_ms() / 1000;
300    match policy.mode {
301        AccountPolicyMode::RoundRobin => {
302            let _ = rr;
303            accounts.sort_by_key(|a| (
304                oauth_rank(a, prefer_oauth),
305                codex_reserve_blocked(a, codex_reserve_pct(a, policy), now_s),
306                policy_rank(policy, &a.name),
307                a.name.clone(),
308                a.id.clone(),
309            ));
310        }
311        AccountPolicyMode::Threshold => {
312            let threshold = policy.threshold_pct.unwrap_or(80);
313            accounts.sort_by_key(|a| {
314                let over = utilization_pct(a).map(|p| p >= threshold).unwrap_or(false);
315                (oauth_rank(a, prefer_oauth), over, policy_rank(policy, &a.name), a.name.clone(), a.id.clone())
316            });
317        }
318        AccountPolicyMode::Priority => {
319            accounts.sort_by_key(|a| (
320                oauth_rank(a, prefer_oauth),
321                codex_reserve_blocked(a, codex_reserve_pct(a, policy), now_s),
322                policy_rank(policy, &a.name),
323                a.name.clone(),
324                a.id.clone(),
325            ));
326        }
327        AccountPolicyMode::ResetFirst => {
328            accounts.sort_by_key(|a| (
329                oauth_rank(a, prefer_oauth),
330                codex_reserve_blocked(a, codex_reserve_pct(a, policy), now_s),
331                codex_binding_reset(a, now_s).unwrap_or(i64::MAX),
332                policy_rank(policy, &a.name),
333                a.name.clone(),
334                a.id.clone(),
335            ));
336        }
337    }
338}
339
340pub fn rfc3339_to_ms(s: &str) -> Option<i64> {
341    chrono::DateTime::parse_from_rfc3339(s)
342        .ok()
343        .map(|dt| dt.timestamp_millis())
344}
345
346pub fn jwt_exp_ms(token: &str) -> Option<i64> {
347    let payload = token.split('.').nth(1)?;
348    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
349        .decode(payload)
350        .ok()?;
351    let v: Value = serde_json::from_slice(&bytes).ok()?;
352    v.get("exp")?.as_i64().map(|s| s * 1000)
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
356#[serde(rename_all = "snake_case")]
357pub enum AccountPolicyMode {
358    Priority,
359    RoundRobin,
360    Threshold,
361    ResetFirst,
362}
363
364impl Default for AccountPolicyMode {
365    fn default() -> Self { Self::Priority }
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct AccountPolicy {
370    #[serde(default)]
371    pub order: Vec<String>,
372    #[serde(default)]
373    pub mode: AccountPolicyMode,
374    #[serde(default)]
375    pub threshold_pct: Option<u8>,
376    #[serde(default)]
377    pub reserve_pct: Option<u8>,
378    #[serde(default)]
379    pub account_reserve_pct: HashMap<String, u8>,
380    #[serde(default = "default_allow_mid_thread_failover")]
381    pub allow_mid_thread_failover: bool,
382    #[serde(default)]
383    pub disabled: Vec<String>,
384}
385
386fn default_allow_mid_thread_failover() -> bool { true }
387
388impl Default for AccountPolicy {
389    fn default() -> Self {
390        Self {
391            order: Vec::new(),
392            mode: AccountPolicyMode::default(),
393            threshold_pct: None,
394            reserve_pct: None,
395            account_reserve_pct: HashMap::new(),
396            allow_mid_thread_failover: true,
397            disabled: Vec::new(),
398        }
399    }
400}
401
402fn default_policy_for(provider: Provider) -> AccountPolicy {
403    if provider == Provider::Openai {
404        AccountPolicy {
405            mode: AccountPolicyMode::ResetFirst,
406            reserve_pct: Some(10),
407            ..AccountPolicy::default()
408        }
409    } else {
410        AccountPolicy::default()
411    }
412}
413
414const ROUTING_POLICIES_FILE: &str = ".routing-policies";
415
416pub struct Vault {
417    dir: PathBuf,
418    accounts: RwLock<HashMap<String, Account>>,
419    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
420    rr_counter: AtomicUsize,
421    policies: StdRwLock<Vec<(Provider, AccountPolicy)>>,
422    http: reqwest::Client,
423}
424
425impl Vault {
426    pub fn open(dir: PathBuf) -> Result<Self> {
427        std::fs::create_dir_all(&dir)?;
428        let mut accounts = HashMap::new();
429        for entry in std::fs::read_dir(&dir)? {
430            let path = entry?.path();
431            if path.extension().map(|e| e == "json").unwrap_or(false) {
432                match std::fs::read_to_string(&path)
433                    .map_err(anyhow::Error::from)
434                    .and_then(|s| serde_json::from_str::<Account>(&s).map_err(Into::into))
435                {
436                    Ok(mut acct) => {
437                        if acct.name.is_empty() {
438                            acct.name = "default".into();
439                        }
440                        acct.path = Some(path.clone());
441                        accounts.insert(acct.id.clone(), acct);
442                    }
443                    Err(e) => tracing::warn!("skipping unreadable account file {path:?}: {e}"),
444                }
445            }
446        }
447        let policies = read_routing_policies(&dir).unwrap_or_else(|error| {
448            tracing::warn!(%error, "could not load persisted routing policies");
449            Vec::new()
450        });
451        Ok(Self {
452            dir,
453            accounts: RwLock::new(accounts),
454            locks: Mutex::new(HashMap::new()),
455            rr_counter: AtomicUsize::new(0),
456            policies: StdRwLock::new(policies),
457            http: reqwest::Client::new(),
458        })
459    }
460
461    pub async fn list(&self) -> Vec<Account> {
462        let mut v: Vec<Account> = self.accounts.read().await.values().cloned().collect();
463        v.sort_by(|a, b| (a.provider.as_str(), a.name.as_str(), a.kind.as_str()).cmp(&(b.provider.as_str(), b.name.as_str(), b.kind.as_str())));
464        v
465    }
466
467    pub fn set_policies_blocking(&self, policies: Vec<(Provider, AccountPolicy)>) {
468        *self.policies.write().expect("policy lock poisoned") = policies;
469    }
470
471    pub async fn set_policies(&self, policies: Vec<(Provider, AccountPolicy)>) {
472        self.set_policies_blocking(policies);
473    }
474
475    pub fn policy(&self, provider: Provider) -> AccountPolicy {
476        self.policies
477            .read()
478            .expect("policy lock poisoned")
479            .iter()
480            .find(|(candidate, _)| *candidate == provider)
481            .map(|(_, policy)| policy.clone())
482            .unwrap_or_else(|| default_policy_for(provider))
483    }
484
485    pub async fn set_policy_persisted(
486        &self,
487        provider: Provider,
488        policy: AccountPolicy,
489    ) -> Result<()> {
490        let policies = {
491            let mut guard = self.policies.write().expect("policy lock poisoned");
492            if let Some((_, current)) = guard
493                .iter_mut()
494                .find(|(candidate, _)| *candidate == provider)
495            {
496                *current = policy;
497            } else {
498                guard.push((provider, policy));
499            }
500            guard.clone()
501        };
502        write_routing_policies(&self.dir, &policies)
503    }
504
505    pub async fn record_codex_limits(&self, id: &str, mut snapshot: Value) -> Result<()> {
506        let now = now_ms();
507        let mut account = self
508            .accounts
509            .read()
510            .await
511            .get(id)
512            .cloned()
513            .ok_or_else(|| anyhow!("unknown account {id}"))?;
514        if account.provider != Provider::Openai {
515            bail!("account {id} is not an OpenAI account");
516        }
517        if account
518            .account_meta
519            .get("codex_limits")
520            .and_then(|value| value.get("observed_at_ms"))
521            .and_then(Value::as_i64)
522            .map(|observed| now - observed < 30_000)
523            .unwrap_or(false)
524        {
525            return Ok(());
526        }
527        if let Some(object) = snapshot.as_object_mut() {
528            object.insert("observed_at_ms".into(), json!(now));
529        }
530        if !account.account_meta.is_object() {
531            account.account_meta = json!({});
532        }
533        account
534            .account_meta
535            .as_object_mut()
536            .expect("account_meta initialized as object")
537            .insert("codex_limits".into(), snapshot);
538        self.upsert(account).await
539    }
540
541    pub async fn pause(&self, provider: Provider, name: &str, paused: bool) -> Result<()> {
542        let account = self.accounts.read().await.values().find(|a| a.provider == provider && a.name == name).cloned().ok_or_else(|| anyhow!("unknown {} account '{name}'", provider.as_str()))?;
543        let mut account = account;
544        account.paused = paused;
545        self.upsert(account).await
546    }
547
548    pub async fn set_paused(&self, id: &str, paused: bool) -> Result<()> {
549        let mut account = self
550            .accounts
551            .read()
552            .await
553            .get(id)
554            .cloned()
555            .ok_or_else(|| anyhow!("unknown account {id}"))?;
556        account.paused = paused;
557        self.upsert(account).await
558    }
559
560    pub async fn has_account_name(&self, provider: Provider, name: &str) -> bool {
561        self.accounts.read().await.values().any(|a| a.provider == provider && a.name == name)
562    }
563
564    pub async fn upsert(&self, account: Account) -> Result<()> {
565        write_account_file(&self.dir, &account)?;
566        self.accounts
567            .write()
568            .await
569            .insert(account.id.clone(), account);
570        Ok(())
571    }
572
573    pub async fn remove(&self, id: &str) -> Result<bool> {
574        let existed = self.accounts.write().await.remove(id).is_some();
575        let path = self.dir.join(format!("{id}.json"));
576        if path.exists() {
577            std::fs::remove_file(&path)?;
578        }
579        Ok(existed)
580    }
581
582    pub async fn account_for(&self, provider: Provider, prefer_oauth: bool) -> Result<Account> {
583        let excluded = HashSet::new();
584        self.account_for_excluding(provider, prefer_oauth, &excluded)
585            .await
586    }
587
588    pub async fn account_for_excluding(
589        &self,
590        provider: Provider,
591        prefer_oauth: bool,
592        excluded: &HashSet<String>,
593    ) -> Result<Account> {
594        self.account_for_excluding_preferred(provider, prefer_oauth, excluded, None)
595            .await
596    }
597
598    /// Select an account using the provider policy, while keeping an existing
599    /// conversation on `preferred_id` when that account is still usable.
600    ///
601    /// A preferred account never bypasses pause/eligibility/cooldown or the
602    /// caller's exclusion set. This lets the proxy preserve prompt-cache
603    /// affinity without defeating routing controls or retry failover.
604    pub async fn account_for_excluding_preferred(
605        &self,
606        provider: Provider,
607        prefer_oauth: bool,
608        excluded: &HashSet<String>,
609        preferred_id: Option<&str>,
610    ) -> Result<Account> {
611        self.account_for_excluding_preferred_mode(
612            provider,
613            prefer_oauth,
614            excluded,
615            preferred_id,
616            false,
617        )
618        .await
619    }
620
621    pub async fn account_for_excluding_preferred_mode(
622        &self,
623        provider: Provider,
624        prefer_oauth: bool,
625        excluded: &HashSet<String>,
626        preferred_id: Option<&str>,
627        preferred_ignores_cooldown: bool,
628    ) -> Result<Account> {
629        let now = now_ms();
630        let candidates: Vec<Account> = {
631            let map = self.accounts.read().await;
632            let mut v: Vec<Account> = map
633                .values()
634                .filter(|a| {
635                    a.provider == provider
636                        && a.status == "active"
637                        && !a.paused
638                        && !excluded.contains(&a.id)
639                })
640                .cloned()
641                .collect();
642            let policy = self.policy(provider);
643            v.retain(|account| account_proxy_eligible(account, &policy));
644            sort_by_policy(&mut v, &policy, prefer_oauth, self.rr_counter.load(Ordering::Relaxed));
645            v
646        };
647        if candidates.is_empty() {
648            bail!(
649                "no active {} account; run `alexandria auth import`",
650                provider.as_str()
651            );
652        }
653        let ready: Vec<&Account> = candidates
654            .iter()
655            .filter(|a| a.cooldown_until_ms.map(|c| c <= now).unwrap_or(true))
656            .collect();
657        let preferred = preferred_id.and_then(|preferred_id| {
658            candidates
659                .iter()
660                .find(|account| account.id == preferred_id)
661                .filter(|account| {
662                    preferred_ignores_cooldown
663                        || account.cooldown_until_ms.map(|c| c <= now).unwrap_or(true)
664                })
665                .cloned()
666        });
667        let account = if let Some(preferred) = preferred {
668            preferred
669        } else if ready.is_empty() {
670            let account = candidates
671                .iter()
672                .min_by_key(|a| a.cooldown_until_ms.unwrap_or(i64::MAX))
673                .unwrap()
674                .clone();
675            tracing::warn!(
676                "all {} accounts cooling down; using {} (soonest expiry) in degraded mode",
677                provider.as_str(),
678                account.id
679            );
680            account
681        } else {
682            let policy = self.policy(provider);
683            let policy_mode = policy.mode.clone();
684            if policy_mode == AccountPolicyMode::RoundRobin {
685                let unblocked: Vec<&Account> = ready
686                    .iter()
687                    .copied()
688                    .filter(|account| {
689                        !codex_reserve_blocked(
690                            account,
691                            codex_reserve_pct(account, &policy),
692                            now / 1000,
693                        )
694                    })
695                    .collect();
696                let pool: Vec<&Account> = if unblocked.is_empty() {
697                    ready.clone()
698                } else {
699                    unblocked
700                };
701                let top_rank = oauth_rank(pool[0], prefer_oauth);
702                let group: Vec<&Account> = pool
703                    .iter()
704                    .copied()
705                    .filter(|account| oauth_rank(account, prefer_oauth) == top_rank)
706                    .collect();
707                let idx = self.rr_counter.fetch_add(1, Ordering::Relaxed) % group.len();
708                group[idx].clone()
709            } else {
710                ready[0].clone()
711            }
712        };
713        if account.needs_refresh() {
714            return self.refresh(&account.id, false).await;
715        }
716        Ok(account)
717    }
718
719    pub async fn mark_cooldown(&self, id: &str, until_ms: i64) -> Result<()> {
720        let mut account = self
721            .accounts
722            .read()
723            .await
724            .get(id)
725            .cloned()
726            .ok_or_else(|| anyhow!("unknown account {id}"))?;
727        account.cooldown_until_ms = Some(until_ms);
728        self.upsert(account).await
729    }
730
731    pub async fn refresh(&self, id: &str, force: bool) -> Result<Account> {
732        let lock = {
733            let mut locks = self.locks.lock().await;
734            locks
735                .entry(id.to_string())
736                .or_insert_with(|| Arc::new(Mutex::new(())))
737                .clone()
738        };
739        let _guard = lock.lock().await;
740
741        let account = self
742            .accounts
743            .read()
744            .await
745            .get(id)
746            .cloned()
747            .ok_or_else(|| anyhow!("unknown account {id}"))?;
748        if !force && !account.needs_refresh() {
749            return Ok(account);
750        }
751        tracing::info!("refreshing oauth token for {id}");
752        let result = match (account.provider, account.refresh_token.clone()) {
753            (Provider::Anthropic, Some(rt)) => self.refresh_anthropic(&rt).await,
754            (Provider::Openai, Some(rt)) => self.refresh_openai(&rt).await,
755            (Provider::Xai, Some(rt)) => {
756                let client_id = account
757                    .account_meta
758                    .get("oidc_client_id")
759                    .and_then(|v| v.as_str())
760                    .unwrap_or(XAI_CLIENT_ID)
761                    .to_string();
762                self.refresh_xai(&rt, &client_id).await
763            }
764            (Provider::Gemini, Some(rt)) => self.refresh_gemini(&rt).await,
765            (Provider::Amp, _) => Err(anyhow!(
766                "amp accounts use a long-lived API key; re-run `alex auth import amp` or `alex auth amp-key`"
767            )),
768            (_, None) => Err(anyhow!("account {id} has no refresh token")),
769        };
770        let refreshed = match result {
771            Ok(r) => r,
772            Err(e) => {
773                tracing::warn!("refresh failed for {id}: {e}; re-importing from native creds");
774                if let Some(fresh) = self.reimport_native(&account).await {
775                    return Ok(fresh);
776                }
777                return Err(e);
778            }
779        };
780
781        let mut updated = account.clone();
782        if let Some(t) = refreshed.access_token {
783            updated.access_token = Some(t);
784        }
785        if let Some(t) = refreshed.refresh_token {
786            updated.refresh_token = Some(t);
787        }
788        if let Some(t) = refreshed.id_token {
789            updated.id_token = Some(t);
790        }
791        updated.expires_at_ms = refreshed
792            .expires_in
793            .map(|s| now_ms() + s * 1000)
794            .or_else(|| updated.access_token.as_deref().and_then(jwt_exp_ms));
795        updated.last_refresh_ms = Some(now_ms());
796        if let Some(email) = updated.email() {
797            persist_account_email(&mut updated, &email);
798        } else {
799            if let Some(access_token) = updated.access_token.clone() {
800                if let Some(email) = fetch_provider_email(&self.http, updated.provider, &access_token).await {
801                    persist_account_email(&mut updated, &email);
802                }
803            }
804        }
805        self.upsert(updated.clone()).await?;
806        Ok(updated)
807    }
808
809    async fn reimport_native(&self, stale: &Account) -> Option<Account> {
810        match stale.provider {
811            Provider::Anthropic => {
812                let _ = import_claude(self).await;
813            }
814            Provider::Openai => {
815                let _ = import_codex(self).await;
816            }
817            Provider::Gemini => {
818                let _ = import_gemini(self).await;
819            }
820            Provider::Xai => {
821                let _ = import_grok(self).await;
822            }
823            Provider::Amp => {
824                let _ = import_amp(self).await;
825            }
826        };
827        let fresh = self.accounts.read().await.get(&stale.id).cloned()?;
828        let changed = fresh.access_token != stale.access_token;
829        let valid = match fresh.expires_at_ms {
830            Some(exp) => exp > now_ms() + REFRESH_MARGIN_MS,
831            None => false,
832        };
833        if changed && valid {
834            tracing::info!("recovered {} from native credential source", stale.id);
835            Some(fresh)
836        } else {
837            None
838        }
839    }
840
841    async fn refresh_anthropic(&self, refresh_token: &str) -> Result<RefreshedTokens> {
842        let resp = self
843            .http
844            .post(ANTHROPIC_TOKEN_URL)
845            .json(&json!({
846                "grant_type": "refresh_token",
847                "refresh_token": refresh_token,
848                "client_id": ANTHROPIC_CLIENT_ID,
849            }))
850            .send()
851            .await?;
852        parse_token_response(resp).await
853    }
854
855    async fn refresh_openai(&self, refresh_token: &str) -> Result<RefreshedTokens> {
856        let resp = self
857            .http
858            .post(OPENAI_TOKEN_URL)
859            .json(&json!({
860                "client_id": OPENAI_CLIENT_ID,
861                "grant_type": "refresh_token",
862                "refresh_token": refresh_token,
863                "scope": "openid profile email",
864            }))
865            .send()
866            .await?;
867        parse_token_response(resp).await
868    }
869
870    async fn refresh_xai(&self, refresh_token: &str, client_id: &str) -> Result<RefreshedTokens> {
871        let resp = self
872            .http
873            .post(XAI_TOKEN_URL)
874            .form(&[
875                ("grant_type", "refresh_token"),
876                ("refresh_token", refresh_token),
877                ("client_id", client_id),
878            ])
879            .send()
880            .await?;
881        parse_token_response(resp).await
882    }
883
884    async fn refresh_gemini(&self, refresh_token: &str) -> Result<RefreshedTokens> {
885        let resp = self
886            .http
887            .post(GOOGLE_TOKEN_URL)
888            .form(&[
889                ("grant_type", "refresh_token"),
890                ("refresh_token", refresh_token),
891                ("client_id", GEMINI_CLIENT_ID),
892                ("client_secret", &gemini_client_secret()),
893            ])
894            .send()
895            .await?;
896        parse_token_response(resp).await
897    }
898
899    pub async fn set_account_meta(&self, id: &str, key: &str, value: Value) -> Result<()> {
900        let mut account = self
901            .accounts
902            .read()
903            .await
904            .get(id)
905            .cloned()
906            .ok_or_else(|| anyhow!("unknown account {id}"))?;
907        if !account.account_meta.is_object() {
908            account.account_meta = json!({});
909        }
910        account.account_meta[key] = value;
911        self.upsert(account).await
912    }
913}
914
915#[derive(Debug, Deserialize)]
916struct RefreshedTokens {
917    access_token: Option<String>,
918    refresh_token: Option<String>,
919    id_token: Option<String>,
920    expires_in: Option<i64>,
921}
922
923async fn parse_token_response(resp: reqwest::Response) -> Result<RefreshedTokens> {
924    let status = resp.status();
925    let text = resp.text().await?;
926    if !status.is_success() {
927        bail!("token refresh failed ({status}): {text}");
928    }
929    serde_json::from_str(&text).context("bad token response")
930}
931
932pub fn named_account_id(provider: Provider, kind: &str, name: &str) -> String {
933    if name == "default" {
934        format!("{}-{kind}", provider.as_str())
935    } else {
936        format!("{}-{kind}-{name}", provider.as_str())
937    }
938}
939
940fn account_path(dir: &Path, account: &Account) -> PathBuf {
941    account.path.clone().unwrap_or_else(|| dir.join(format!("{}.json", account.id)))
942}
943
944fn write_account_file(dir: &Path, account: &Account) -> Result<()> {
945    let path = account_path(dir, account);
946    let data = serde_json::to_string_pretty(account)?;
947    std::fs::write(&path, data)?;
948    #[cfg(unix)]
949    {
950        use std::os::unix::fs::PermissionsExt;
951        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
952    }
953    Ok(())
954}
955
956fn read_routing_policies(dir: &Path) -> Result<Vec<(Provider, AccountPolicy)>> {
957    let path = dir.join(ROUTING_POLICIES_FILE);
958    if !path.exists() {
959        return Ok(Vec::new());
960    }
961    let data = std::fs::read_to_string(&path)
962        .with_context(|| format!("reading {}", path.display()))?;
963    serde_json::from_str(&data).with_context(|| format!("parsing {}", path.display()))
964}
965
966fn write_routing_policies(
967    dir: &Path,
968    policies: &[(Provider, AccountPolicy)],
969) -> Result<()> {
970    let path = dir.join(ROUTING_POLICIES_FILE);
971    let temp = dir.join(format!("{ROUTING_POLICIES_FILE}.tmp"));
972    let data = serde_json::to_vec_pretty(policies)?;
973    std::fs::write(&temp, data)?;
974    #[cfg(unix)]
975    {
976        use std::os::unix::fs::PermissionsExt;
977        std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
978    }
979    std::fs::rename(&temp, &path)?;
980    Ok(())
981}
982
983pub struct ImportOutcome {
984    pub source: String,
985    pub imported: Vec<String>,
986    pub note: Option<String>,
987}
988
989pub async fn import_all(vault: &Vault, source: &str) -> Result<Vec<ImportOutcome>> {
990    let mut outcomes = Vec::new();
991    if source == "all" || source == "claude" {
992        outcomes.push(import_claude(vault).await);
993    }
994    if source == "all" || source == "codex" {
995        outcomes.push(import_codex(vault).await);
996    }
997    if source == "all" || source == "gemini" {
998        outcomes.push(import_gemini(vault).await);
999    }
1000    if source == "all" || source == "grok" || source == "xai" {
1001        outcomes.push(import_grok(vault).await);
1002    }
1003    if source == "all" || source == "amp" || source == "ampcode" {
1004        outcomes.push(import_amp(vault).await);
1005    }
1006    if outcomes.is_empty() {
1007        bail!("unknown source '{source}' (expected claude|codex|gemini|grok|xai|amp|all)");
1008    }
1009    Ok(outcomes)
1010}
1011
1012fn home() -> PathBuf {
1013    dirs::home_dir().expect("no home dir")
1014}
1015
1016async fn import_claude(vault: &Vault) -> ImportOutcome {
1017    let mut outcome = ImportOutcome {
1018        source: "claude".into(),
1019        imported: vec![],
1020        note: None,
1021    };
1022    let path = home().join(".claude/.credentials.json");
1023    let raw = if path.exists() {
1024        std::fs::read_to_string(&path).ok()
1025    } else {
1026        claude_keychain()
1027    };
1028    let Some(raw) = raw else {
1029        outcome.note = Some("no ~/.claude/.credentials.json and no Keychain entry".into());
1030        return outcome;
1031    };
1032    let Ok(v) = serde_json::from_str::<Value>(&raw) else {
1033        outcome.note = Some("could not parse claude credentials".into());
1034        return outcome;
1035    };
1036    let oauth = &v["claudeAiOauth"];
1037    let Some(access) = oauth["accessToken"].as_str() else {
1038        outcome.note = Some("no claudeAiOauth.accessToken found".into());
1039        return outcome;
1040    };
1041    let fallback_email = oauth["email"]
1042        .as_str()
1043        .or_else(|| v["email"].as_str())
1044        .and_then(normalize_email)
1045        .or_else(|| {
1046            crate::login::jwt_payload(access)
1047                .and_then(|payload| payload["email"].as_str().and_then(normalize_email))
1048        });
1049    let email = fetch_provider_email(&reqwest::Client::new(), Provider::Anthropic, access)
1050        .await
1051        .or(fallback_email);
1052    let mut account = Account {
1053        id: named_account_id(Provider::Anthropic, "oauth", "default"),
1054        provider: Provider::Anthropic,
1055        kind: "oauth".into(),
1056        name: "default".into(),
1057        description: email.clone(),
1058        paused: false,
1059        label: oauth["subscriptionType"]
1060            .as_str()
1061            .map(|s| format!("claude-code ({s})")),
1062        access_token: Some(access.to_string()),
1063        refresh_token: oauth["refreshToken"].as_str().map(String::from),
1064        id_token: None,
1065        api_key: None,
1066        expires_at_ms: oauth["expiresAt"].as_i64(),
1067        last_refresh_ms: Some(now_ms()),
1068        account_meta: json!({"scopes": oauth["scopes"].clone()}),
1069        cooldown_until_ms: None,
1070        status: "active".into(),
1071        path: None,
1072    };
1073    if let Some(email) = email {
1074        persist_account_email(&mut account, &email);
1075    }
1076    match vault.upsert(account).await {
1077        Ok(()) => outcome.imported.push("anthropic-oauth".into()),
1078        Err(e) => outcome.note = Some(format!("failed to save: {e}")),
1079    }
1080    outcome
1081}
1082
1083fn claude_keychain() -> Option<String> {
1084    let out = std::process::Command::new("security")
1085        .args([
1086            "find-generic-password",
1087            "-s",
1088            "Claude Code-credentials",
1089            "-w",
1090        ])
1091        .output()
1092        .ok()?;
1093    if !out.status.success() {
1094        return None;
1095    }
1096    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
1097}
1098
1099async fn import_codex(vault: &Vault) -> ImportOutcome {
1100    let mut outcome = ImportOutcome {
1101        source: "codex".into(),
1102        imported: vec![],
1103        note: None,
1104    };
1105    let path = home().join(".codex/auth.json");
1106    let Ok(raw) = std::fs::read_to_string(&path) else {
1107        outcome.note = Some("no ~/.codex/auth.json".into());
1108        return outcome;
1109    };
1110    let Ok(v) = serde_json::from_str::<Value>(&raw) else {
1111        outcome.note = Some("could not parse codex auth.json".into());
1112        return outcome;
1113    };
1114    if let Some(access) = v["tokens"]["access_token"].as_str() {
1115        let account = Account {
1116            id: named_account_id(Provider::Openai, "oauth", "default"),
1117            provider: Provider::Openai,
1118            kind: "oauth".into(),
1119            name: "default".into(),
1120            description: None,
1121            paused: false,
1122            label: Some("codex (chatgpt)".into()),
1123            access_token: Some(access.to_string()),
1124            refresh_token: v["tokens"]["refresh_token"].as_str().map(String::from),
1125            id_token: v["tokens"]["id_token"].as_str().map(String::from),
1126            api_key: None,
1127            expires_at_ms: jwt_exp_ms(access),
1128            last_refresh_ms: Some(now_ms()),
1129            account_meta: json!({"account_id": v["tokens"]["account_id"].clone()}),
1130            cooldown_until_ms: None,
1131            status: "active".into(),
1132            path: None,
1133        };
1134        match vault.upsert(account).await {
1135            Ok(()) => outcome.imported.push("openai-oauth".into()),
1136            Err(e) => outcome.note = Some(format!("failed to save: {e}")),
1137        }
1138    }
1139    if let Some(key) = v["OPENAI_API_KEY"].as_str() {
1140        let account = Account {
1141            id: named_account_id(Provider::Openai, "api_key", "default"),
1142            provider: Provider::Openai,
1143            kind: "api_key".into(),
1144            name: "default".into(),
1145            description: None,
1146            paused: false,
1147            label: Some("codex (api key)".into()),
1148            access_token: None,
1149            refresh_token: None,
1150            id_token: None,
1151            api_key: Some(key.to_string()),
1152            expires_at_ms: None,
1153            last_refresh_ms: None,
1154            account_meta: Value::Null,
1155            cooldown_until_ms: None,
1156            status: "active".into(),
1157            path: None,
1158        };
1159        match vault.upsert(account).await {
1160            Ok(()) => outcome.imported.push("openai-api-key".into()),
1161            Err(e) => outcome.note = Some(format!("failed to save: {e}")),
1162        }
1163    }
1164    if outcome.imported.is_empty() && outcome.note.is_none() {
1165        outcome.note = Some("auth.json had neither tokens nor OPENAI_API_KEY".into());
1166    }
1167    outcome
1168}
1169
1170async fn import_gemini(vault: &Vault) -> ImportOutcome {
1171    let mut outcome = ImportOutcome {
1172        source: "gemini".into(),
1173        imported: vec![],
1174        note: None,
1175    };
1176    let path = home().join(".gemini/oauth_creds.json");
1177    let Ok(raw) = std::fs::read_to_string(&path) else {
1178        outcome.note = Some("no ~/.gemini/oauth_creds.json".into());
1179        return outcome;
1180    };
1181    let Ok(v) = serde_json::from_str::<Value>(&raw) else {
1182        outcome.note = Some("could not parse gemini oauth_creds.json".into());
1183        return outcome;
1184    };
1185    let Some(access) = v["access_token"].as_str() else {
1186        outcome.note = Some("no access_token in gemini creds".into());
1187        return outcome;
1188    };
1189    let account = Account {
1190        id: named_account_id(Provider::Gemini, "oauth", "default"),
1191        provider: Provider::Gemini,
1192        kind: "oauth".into(),
1193        name: "default".into(),
1194        description: None,
1195        paused: false,
1196        label: Some("gemini-cli".into()),
1197        access_token: Some(access.to_string()),
1198        refresh_token: v["refresh_token"].as_str().map(String::from),
1199        id_token: v["id_token"].as_str().map(String::from),
1200        api_key: None,
1201        expires_at_ms: v["expiry_date"].as_i64(),
1202        last_refresh_ms: Some(now_ms()),
1203        account_meta: Value::Null,
1204        cooldown_until_ms: None,
1205        status: "active".into(),
1206        path: None,
1207    };
1208    match vault.upsert(account).await {
1209        Ok(()) => outcome.imported.push("gemini-oauth".into()),
1210        Err(e) => outcome.note = Some(format!("failed to save: {e}")),
1211    }
1212    outcome
1213}
1214
1215/// Import Amp API key from `~/.local/share/amp/secrets.json` (CLI login material).
1216pub async fn import_amp(vault: &Vault) -> ImportOutcome {
1217    let mut outcome = ImportOutcome {
1218        source: "amp".into(),
1219        imported: vec![],
1220        note: None,
1221    };
1222    let path = home().join(".local/share/amp/secrets.json");
1223    if !path.exists() {
1224        outcome.note = Some(
1225            "no ~/.local/share/amp/secrets.json โ€” run `amp login` or `alex auth amp-key <KEY>`"
1226                .into(),
1227        );
1228        return outcome;
1229    }
1230    let Ok(raw) = std::fs::read_to_string(&path) else {
1231        outcome.note = Some("could not read amp secrets.json".into());
1232        return outcome;
1233    };
1234    let Ok(v) = serde_json::from_str::<Value>(&raw) else {
1235        outcome.note = Some("could not parse amp secrets.json".into());
1236        return outcome;
1237    };
1238    let Some(obj) = v.as_object() else {
1239        outcome.note = Some("amp secrets.json is not an object".into());
1240        return outcome;
1241    };
1242    let mut key: Option<(String, String)> = None; // (url, key)
1243    for (k, val) in obj {
1244        if let Some(url) = k.strip_prefix("apiKey@") {
1245            if let Some(s) = val.as_str() {
1246                if !s.is_empty() {
1247                    key = Some((url.to_string(), s.to_string()));
1248                    // Prefer ampcode.com if multiple
1249                    if url.contains("ampcode.com") {
1250                        break;
1251                    }
1252                }
1253            }
1254        }
1255    }
1256    let Some((amp_url, api_key)) = key else {
1257        outcome.note = Some("no apiKey@โ€ฆ entry in amp secrets.json".into());
1258        return outcome;
1259    };
1260    let account = Account {
1261        id: "amp-api-key".into(),
1262        provider: Provider::Amp,
1263        kind: "api_key".into(),
1264        name: default_account_name(),
1265        description: None,
1266        paused: false,
1267        label: Some(format!("amp ({amp_url})")),
1268        access_token: None,
1269        refresh_token: None,
1270        id_token: None,
1271        api_key: Some(api_key),
1272        expires_at_ms: None,
1273        last_refresh_ms: Some(now_ms()),
1274        account_meta: json!({ "amp_url": amp_url }),
1275        cooldown_until_ms: None,
1276        status: "active".into(),
1277        path: None,
1278    };
1279    match vault.upsert(account).await {
1280        Ok(()) => outcome.imported.push("amp-api-key".into()),
1281        Err(e) => outcome.note = Some(format!("failed to save: {e}")),
1282    }
1283    outcome
1284}
1285
1286/// Save an Amp API key provided by the user (settings token / AMP_API_KEY).
1287pub async fn save_amp_api_key(vault: &Vault, api_key: &str) -> Result<String> {
1288    let key = api_key.trim();
1289    if key.is_empty() {
1290        bail!("empty amp api key");
1291    }
1292    let account = Account {
1293        id: "amp-api-key".into(),
1294        provider: Provider::Amp,
1295        kind: "api_key".into(),
1296        name: default_account_name(),
1297        description: None,
1298        paused: false,
1299        label: Some("amp (api key)".into()),
1300        access_token: None,
1301        refresh_token: None,
1302        id_token: None,
1303        api_key: Some(key.to_string()),
1304        expires_at_ms: None,
1305        last_refresh_ms: Some(now_ms()),
1306        account_meta: json!({ "amp_url": "https://ampcode.com/" }),
1307        cooldown_until_ms: None,
1308        status: "active".into(),
1309        path: None,
1310    };
1311    vault.upsert(account).await?;
1312    Ok("amp-api-key".into())
1313}
1314
1315async fn import_grok(vault: &Vault) -> ImportOutcome {
1316    let mut outcome = ImportOutcome {
1317        source: "grok".into(),
1318        imported: vec![],
1319        note: None,
1320    };
1321    let path = home().join(".grok/auth.json");
1322    let Ok(raw) = std::fs::read_to_string(&path) else {
1323        outcome.note = Some("no ~/.grok/auth.json".into());
1324        return outcome;
1325    };
1326    let accounts = grok_accounts_from_json(&raw);
1327    if accounts.is_empty() {
1328        outcome.note = Some("no usable entries in grok auth.json".into());
1329        return outcome;
1330    }
1331    for account in accounts {
1332        let id = account.id.clone();
1333        match vault.upsert(account).await {
1334            Ok(()) => outcome.imported.push(id),
1335            Err(e) => outcome.note = Some(format!("failed to save: {e}")),
1336        }
1337    }
1338    outcome
1339}
1340
1341pub fn grok_accounts_from_json(raw: &str) -> Vec<Account> {
1342    let Ok(v) = serde_json::from_str::<Value>(raw) else {
1343        return vec![];
1344    };
1345    let Some(map) = v.as_object() else {
1346        return vec![];
1347    };
1348    let mut accounts = Vec::new();
1349    for entry in map.values() {
1350        let Some(key) = entry["key"].as_str() else {
1351            continue;
1352        };
1353        let idx = accounts.len();
1354        let id = if idx == 0 {
1355            "xai-oauth".to_string()
1356        } else {
1357            format!("xai-oauth-{}", idx + 1)
1358        };
1359        let email = entry["email"].as_str().and_then(normalize_email);
1360        let mut account = Account {
1361            id,
1362            provider: Provider::Xai,
1363            kind: "oauth".into(),
1364            name: if idx == 0 { "default".into() } else { format!("{}", idx + 1) },
1365            description: email.clone(),
1366            paused: false,
1367            label: Some(email.as_ref().map(|email| format!("grok ({email})")).unwrap_or_else(|| "grok (oauth)".into())),
1368            access_token: Some(key.to_string()),
1369            refresh_token: entry["refresh_token"].as_str().map(String::from),
1370            id_token: None,
1371            api_key: None,
1372            expires_at_ms: entry["expires_at"].as_str().and_then(rfc3339_to_ms),
1373            last_refresh_ms: Some(now_ms()),
1374            account_meta: json!({
1375                "oidc_issuer": entry["oidc_issuer"].clone(),
1376                "oidc_client_id": entry["oidc_client_id"].clone(),
1377                "user_id": entry["user_id"].clone(),
1378            }),
1379            cooldown_until_ms: None,
1380            status: "active".into(),
1381            path: None,
1382        };
1383        if let Some(email) = email {
1384            persist_account_email(&mut account, &email);
1385        }
1386        accounts.push(account);
1387    }
1388    accounts
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393    use super::*;
1394
1395    fn temp_dir(name: &str) -> PathBuf {
1396        let nanos = SystemTime::now()
1397            .duration_since(UNIX_EPOCH)
1398            .unwrap()
1399            .as_nanos();
1400        std::env::temp_dir().join(format!(
1401            "alexandria-auth-{name}-{nanos}-{}",
1402            std::process::id()
1403        ))
1404    }
1405
1406    fn api_key_account(id: &str, provider: Provider) -> Account {
1407        Account {
1408            id: id.into(),
1409            provider,
1410            kind: "api_key".into(),
1411            name: id.rsplit('-').next().unwrap_or("default").into(),
1412            description: None,
1413            paused: false,
1414            label: None,
1415            access_token: None,
1416            refresh_token: None,
1417            id_token: None,
1418            api_key: Some(format!("sk-{id}")),
1419            expires_at_ms: None,
1420            last_refresh_ms: None,
1421            account_meta: Value::Null,
1422            cooldown_until_ms: None,
1423            status: "active".into(),
1424            path: None,
1425        }
1426    }
1427
1428    #[test]
1429    fn rfc3339_parse() {
1430        assert_eq!(rfc3339_to_ms("1970-01-01T00:00:01Z"), Some(1000));
1431        assert_eq!(
1432            rfc3339_to_ms("2001-09-09T01:46:40Z"),
1433            Some(1_000_000_000_000)
1434        );
1435        assert_eq!(
1436            rfc3339_to_ms("2001-09-09T03:46:40+02:00"),
1437            Some(1_000_000_000_000)
1438        );
1439        assert_eq!(rfc3339_to_ms("not a timestamp"), None);
1440    }
1441
1442    #[test]
1443    fn account_email_prefers_metadata_and_falls_back_to_jwt_profile() {
1444        let mut account = api_key_account("openai-oauth-default", Provider::Openai);
1445        account.kind = "oauth".into();
1446        account.api_key = None;
1447        account.account_meta = json!({"email": "Primary@Example.com"});
1448        assert_eq!(account.email().as_deref(), Some("primary@example.com"));
1449
1450        account.account_meta = json!({});
1451        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
1452            json!({"https://api.openai.com/profile": {"email": "Workspace@Example.com"}})
1453                .to_string(),
1454        );
1455        account.id_token = Some(format!("header.{payload}.signature"));
1456        assert_eq!(account.email().as_deref(), Some("workspace@example.com"));
1457    }
1458
1459    #[test]
1460    fn grok_json_parse() {
1461        let dir = temp_dir("grok");
1462        std::fs::create_dir_all(&dir).unwrap();
1463        let path = dir.join("auth.json");
1464        let raw = json!({
1465            "https://auth.x.ai::b1a00492-0000-0000-0000-000000000000": {
1466                "key": "bearer-token-abc",
1467                "auth_mode": "oauth",
1468                "create_time": "2026-01-01T00:00:00Z",
1469                "user_id": "user-1",
1470                "email": "user@x.com",
1471                "refresh_token": "refresh-abc",
1472                "expires_at": "2026-07-07T00:00:00Z",
1473                "oidc_issuer": "https://auth.x.ai",
1474                "oidc_client_id": "client-1"
1475            },
1476            "https://auth.x.ai::c2b11503-0000-0000-0000-000000000000": {
1477                "key": "bearer-token-def",
1478                "email": "second@x.com",
1479                "refresh_token": "refresh-def",
1480                "expires_at": "2026-08-01T00:00:00Z"
1481            }
1482        })
1483        .to_string();
1484        std::fs::write(&path, &raw).unwrap();
1485        let read_back = std::fs::read_to_string(&path).unwrap();
1486        let accounts = grok_accounts_from_json(&read_back);
1487        assert_eq!(accounts.len(), 2);
1488        assert_eq!(accounts[0].id, "xai-oauth");
1489        assert_eq!(accounts[1].id, "xai-oauth-2");
1490        assert_eq!(accounts[0].provider, Provider::Xai);
1491        assert_eq!(accounts[0].kind, "oauth");
1492        assert_eq!(accounts[0].label.as_deref(), Some("grok (user@x.com)"));
1493        assert_eq!(accounts[0].description.as_deref(), Some("user@x.com"));
1494        assert_eq!(accounts[0].account_meta["email"], "user@x.com");
1495        assert_eq!(accounts[0].email().as_deref(), Some("user@x.com"));
1496        assert_eq!(accounts[0].access_token.as_deref(), Some("bearer-token-abc"));
1497        assert_eq!(accounts[0].refresh_token.as_deref(), Some("refresh-abc"));
1498        assert_eq!(
1499            accounts[0].expires_at_ms,
1500            rfc3339_to_ms("2026-07-07T00:00:00Z")
1501        );
1502        assert!(accounts[0].expires_at_ms.is_some());
1503        assert_eq!(
1504            accounts[0].account_meta["oidc_issuer"].as_str(),
1505            Some("https://auth.x.ai")
1506        );
1507        assert_eq!(
1508            accounts[0].account_meta["oidc_client_id"].as_str(),
1509            Some("client-1")
1510        );
1511        assert_eq!(accounts[0].account_meta["user_id"].as_str(), Some("user-1"));
1512        assert!(grok_accounts_from_json("not json").is_empty());
1513        assert!(grok_accounts_from_json("[1,2]").is_empty());
1514        std::fs::remove_dir_all(&dir).ok();
1515    }
1516
1517    #[tokio::test]
1518    async fn round_robin_and_cooldown() {
1519        let dir = temp_dir("pool");
1520        let vault = Vault::open(dir.clone()).unwrap();
1521        vault
1522            .upsert(api_key_account("openai-key-a", Provider::Openai))
1523            .await
1524            .unwrap();
1525        vault
1526            .upsert(api_key_account("openai-key-b", Provider::Openai))
1527            .await
1528            .unwrap();
1529        vault.set_policies(vec![(Provider::Openai, AccountPolicy { mode: AccountPolicyMode::RoundRobin, ..AccountPolicy::default() })]).await;
1530
1531        let mut picks = Vec::new();
1532        for _ in 0..4 {
1533            picks.push(vault.account_for(Provider::Openai, false).await.unwrap().id);
1534        }
1535        assert!(picks.contains(&"openai-key-a".to_string()));
1536        assert!(picks.contains(&"openai-key-b".to_string()));
1537        for pair in picks.windows(2) {
1538            assert_ne!(pair[0], pair[1]);
1539        }
1540
1541        vault
1542            .mark_cooldown("openai-key-a", now_ms() + 60_000)
1543            .await
1544            .unwrap();
1545        for _ in 0..4 {
1546            let picked = vault.account_for(Provider::Openai, false).await.unwrap();
1547            assert_eq!(picked.id, "openai-key-b");
1548        }
1549
1550        vault
1551            .mark_cooldown("openai-key-b", now_ms() + 120_000)
1552            .await
1553            .unwrap();
1554        let degraded = vault.account_for(Provider::Openai, false).await.unwrap();
1555        assert_eq!(degraded.id, "openai-key-a");
1556
1557        std::fs::remove_dir_all(&dir).ok();
1558    }
1559
1560    #[tokio::test]
1561    async fn exclusions_visit_each_active_account_once_even_in_degraded_mode() {
1562        let dir = temp_dir("excluded-pool");
1563        let vault = Vault::open(dir.clone()).unwrap();
1564        for id in ["openai-key-a", "openai-key-b", "openai-key-c"] {
1565            vault
1566                .upsert(api_key_account(id, Provider::Openai))
1567                .await
1568                .unwrap();
1569        }
1570
1571        let mut paused = api_key_account("openai-key-paused", Provider::Openai);
1572        paused.paused = true;
1573        vault.upsert(paused).await.unwrap();
1574        let mut inactive = api_key_account("openai-key-inactive", Provider::Openai);
1575        inactive.status = "disabled".into();
1576        vault.upsert(inactive).await.unwrap();
1577
1578        let now = now_ms();
1579        vault
1580            .mark_cooldown("openai-key-a", now + 30_000)
1581            .await
1582            .unwrap();
1583        vault
1584            .mark_cooldown("openai-key-b", now + 20_000)
1585            .await
1586            .unwrap();
1587        vault
1588            .mark_cooldown("openai-key-c", now + 10_000)
1589            .await
1590            .unwrap();
1591
1592        let mut excluded = HashSet::new();
1593        let mut selected = Vec::new();
1594        for _ in 0..3 {
1595            let account = vault
1596                .account_for_excluding(Provider::Openai, false, &excluded)
1597                .await
1598                .unwrap();
1599            assert!(excluded.insert(account.id.clone()));
1600            selected.push(account.id);
1601        }
1602
1603        assert_eq!(
1604            selected,
1605            vec!["openai-key-c", "openai-key-b", "openai-key-a"]
1606        );
1607        assert!(vault
1608            .account_for_excluding(Provider::Openai, false, &excluded)
1609            .await
1610            .is_err());
1611        assert!(!excluded.contains("openai-key-paused"));
1612        assert!(!excluded.contains("openai-key-inactive"));
1613
1614        std::fs::remove_dir_all(&dir).ok();
1615    }
1616
1617    #[tokio::test]
1618    async fn preferred_account_never_bypasses_pause_policy_cooldown_or_exclusion() {
1619        let dir = temp_dir("preferred-constraints");
1620        let vault = Vault::open(dir.clone()).unwrap();
1621        let mut first = api_key_account("openai-key-a", Provider::Openai);
1622        first.name = "a".into();
1623        let mut second = api_key_account("openai-key-b", Provider::Openai);
1624        second.name = "b".into();
1625        vault.upsert(first).await.unwrap();
1626        vault.upsert(second.clone()).await.unwrap();
1627        vault
1628            .set_policies(vec![(
1629                Provider::Openai,
1630                AccountPolicy {
1631                    order: vec!["a".into(), "b".into()],
1632                    mode: AccountPolicyMode::Priority,
1633                    ..AccountPolicy::default()
1634                },
1635            )])
1636            .await;
1637
1638        let none = HashSet::new();
1639        assert_eq!(
1640            vault
1641                .account_for_excluding_preferred(
1642                    Provider::Openai,
1643                    false,
1644                    &none,
1645                    Some("openai-key-b"),
1646                )
1647                .await
1648                .unwrap()
1649                .id,
1650            "openai-key-b"
1651        );
1652
1653        second.paused = true;
1654        vault.upsert(second.clone()).await.unwrap();
1655        assert_eq!(
1656            vault
1657                .account_for_excluding_preferred(
1658                    Provider::Openai,
1659                    false,
1660                    &none,
1661                    Some("openai-key-b"),
1662                )
1663                .await
1664                .unwrap()
1665                .id,
1666            "openai-key-a"
1667        );
1668
1669        second.paused = false;
1670        second.cooldown_until_ms = Some(now_ms() + 60_000);
1671        vault.upsert(second.clone()).await.unwrap();
1672        assert_eq!(
1673            vault
1674                .account_for_excluding_preferred(
1675                    Provider::Openai,
1676                    false,
1677                    &none,
1678                    Some("openai-key-b"),
1679                )
1680                .await
1681                .unwrap()
1682                .id,
1683            "openai-key-a"
1684        );
1685        assert_eq!(
1686            vault
1687                .account_for_excluding_preferred_mode(
1688                    Provider::Openai,
1689                    false,
1690                    &none,
1691                    Some("openai-key-b"),
1692                    true,
1693                )
1694                .await
1695                .unwrap()
1696                .id,
1697            "openai-key-b"
1698        );
1699
1700        second.cooldown_until_ms = None;
1701        vault.upsert(second).await.unwrap();
1702        vault
1703            .set_policies(vec![(
1704                Provider::Openai,
1705                AccountPolicy {
1706                    disabled: vec!["b".into()],
1707                    ..AccountPolicy::default()
1708                },
1709            )])
1710            .await;
1711        assert_eq!(
1712            vault
1713                .account_for_excluding_preferred(
1714                    Provider::Openai,
1715                    false,
1716                    &none,
1717                    Some("openai-key-b"),
1718                )
1719                .await
1720                .unwrap()
1721                .id,
1722            "openai-key-a"
1723        );
1724
1725        let excluded = HashSet::from(["openai-key-b".to_string()]);
1726        assert_eq!(
1727            vault
1728                .account_for_excluding_preferred(
1729                    Provider::Openai,
1730                    false,
1731                    &excluded,
1732                    Some("openai-key-b"),
1733                )
1734                .await
1735                .unwrap()
1736                .id,
1737            "openai-key-a"
1738        );
1739        std::fs::remove_dir_all(dir).ok();
1740    }
1741
1742    #[tokio::test]
1743    async fn vault_loads_multi_and_legacy_names() {
1744        let dir = temp_dir("multi");
1745        std::fs::create_dir_all(&dir).unwrap();
1746        let mut legacy = api_key_account("openai-oauth", Provider::Openai);
1747        legacy.kind = "oauth".into();
1748        legacy.name = "default".into();
1749        std::fs::write(dir.join("openai-oauth.json"), serde_json::to_string(&legacy).unwrap()).unwrap();
1750        let mut work = api_key_account("openai-oauth-work", Provider::Openai);
1751        work.kind = "oauth".into();
1752        work.name = "work".into();
1753        std::fs::write(dir.join("openai-oauth-work.json"), serde_json::to_string(&work).unwrap()).unwrap();
1754        let vault = Vault::open(dir.clone()).unwrap();
1755        let accounts = vault.list().await;
1756        assert_eq!(accounts.len(), 2);
1757        assert!(accounts.iter().any(|a| a.name == "default" && a.path.as_ref().unwrap().ends_with("openai-oauth.json")));
1758        assert!(accounts.iter().any(|a| a.name == "work" && a.path.as_ref().unwrap().ends_with("openai-oauth-work.json")));
1759        std::fs::remove_dir_all(&dir).ok();
1760    }
1761
1762    #[tokio::test]
1763    async fn policy_selection_priority_round_robin_threshold() {
1764        let dir = temp_dir("policy");
1765        let vault = Vault::open(dir.clone()).unwrap();
1766        let mut work = api_key_account("openai-api_key-work", Provider::Openai);
1767        work.name = "work".into();
1768        let mut personal = api_key_account("openai-api_key-personal", Provider::Openai);
1769        personal.name = "personal".into();
1770        vault.upsert(work).await.unwrap();
1771        vault.upsert(personal).await.unwrap();
1772        vault.set_policies(vec![(Provider::Openai, AccountPolicy { order: vec!["work".into(), "personal".into()], mode: AccountPolicyMode::Priority, ..AccountPolicy::default() })]).await;
1773        assert_eq!(vault.account_for(Provider::Openai, false).await.unwrap().name, "work");
1774        vault.pause(Provider::Openai, "work", true).await.unwrap();
1775        assert_eq!(vault.account_for(Provider::Openai, false).await.unwrap().name, "personal");
1776        vault.pause(Provider::Openai, "work", false).await.unwrap();
1777        vault.mark_cooldown("openai-api_key-work", now_ms() + 60_000).await.unwrap();
1778        assert_eq!(vault.account_for(Provider::Openai, false).await.unwrap().name, "personal");
1779        vault.mark_cooldown("openai-api_key-work", now_ms() - 1).await.unwrap();
1780        vault.set_policies(vec![(Provider::Openai, AccountPolicy { order: vec!["work".into(), "personal".into()], mode: AccountPolicyMode::RoundRobin, ..AccountPolicy::default() })]).await;
1781        let a = vault.account_for(Provider::Openai, false).await.unwrap().name;
1782        let b = vault.account_for(Provider::Openai, false).await.unwrap().name;
1783        assert_ne!(a, b);
1784        let mut over = vault.list().await.into_iter().find(|a| a.name == "work").unwrap();
1785        over.account_meta = json!({"rate_limit_pct": 90});
1786        vault.upsert(over).await.unwrap();
1787        vault.set_policies(vec![(Provider::Openai, AccountPolicy { order: vec!["work".into(), "personal".into()], mode: AccountPolicyMode::Threshold, threshold_pct: Some(80), ..AccountPolicy::default() })]).await;
1788        assert_eq!(vault.account_for(Provider::Openai, false).await.unwrap().name, "personal");
1789        std::fs::remove_dir_all(&dir).ok();
1790    }
1791
1792    fn codex_limits(used_pct: f64, resets_at_s: i64) -> Value {
1793        json!({
1794            "codex_limits": {
1795                "observed_at_ms": now_ms(),
1796                "windows": [{
1797                    "window": "5h",
1798                    "used_pct": used_pct,
1799                    "resets_at_s": resets_at_s,
1800                }],
1801            }
1802        })
1803    }
1804
1805    #[test]
1806    fn binding_reset_uses_most_consumed_active_window() {
1807        let now_s = now_ms() / 1000;
1808        let mut account = api_key_account("openai-api_key-binding", Provider::Openai);
1809        account.account_meta = json!({
1810            "codex_limits": {"windows": [
1811                {"window": "5h", "used_pct": 25.0, "resets_at_s": now_s + 300},
1812                {"window": "7d", "used_pct": 70.0, "resets_at_s": now_s + 500_000}
1813            ]}
1814        });
1815        let selected = codex_reset_selection(&account, now_s).unwrap();
1816        assert_eq!(selected["window"], "7d");
1817        assert_eq!(selected["used_pct"], 70.0);
1818
1819        account.account_meta["codex_limits"]["windows"][0]["used_pct"] = json!(70.0);
1820        let tie = codex_reset_selection(&account, now_s).unwrap();
1821        assert_eq!(tie["window"], "5h");
1822    }
1823
1824    #[test]
1825    fn legacy_policy_defaults_new_fields_without_losing_global_reserve() {
1826        let policy: AccountPolicy = serde_json::from_value(json!({
1827            "mode": "reset_first",
1828            "reserve_pct": 17,
1829            "order": ["personal"]
1830        }))
1831        .unwrap();
1832        assert_eq!(policy.reserve_pct, Some(17));
1833        assert!(policy.account_reserve_pct.is_empty());
1834        assert!(policy.allow_mid_thread_failover);
1835    }
1836
1837    #[tokio::test]
1838    async fn reset_first_reserve_and_eligibility() {
1839        let dir = temp_dir("reset-first");
1840        let vault = Vault::open(dir.clone()).unwrap();
1841        let now_s = now_ms() / 1000;
1842        let mut soon = api_key_account("openai-api_key-soon", Provider::Openai);
1843        soon.name = "soon".into();
1844        soon.account_meta = codex_limits(20.0, now_s + 600);
1845        let mut later = api_key_account("openai-api_key-later", Provider::Openai);
1846        later.name = "later".into();
1847        later.account_meta = codex_limits(20.0, now_s + 3600);
1848        vault.upsert(soon.clone()).await.unwrap();
1849        vault.upsert(later.clone()).await.unwrap();
1850
1851        assert_eq!(
1852            vault.account_for(Provider::Openai, false).await.unwrap().name,
1853            "soon"
1854        );
1855
1856        soon.account_meta = codex_limits(95.0, now_s + 600);
1857        vault.upsert(soon.clone()).await.unwrap();
1858        assert_eq!(
1859            vault.account_for(Provider::Openai, false).await.unwrap().name,
1860            "later"
1861        );
1862
1863        vault
1864            .set_policies(vec![(
1865                Provider::Openai,
1866                AccountPolicy {
1867                    mode: AccountPolicyMode::ResetFirst,
1868                    reserve_pct: Some(10),
1869                    account_reserve_pct: HashMap::from([
1870                        ("soon".into(), 0),
1871                        ("later".into(), 10),
1872                    ]),
1873                    ..AccountPolicy::default()
1874                },
1875            )])
1876            .await;
1877        assert_eq!(
1878            vault.account_for(Provider::Openai, false).await.unwrap().name,
1879            "soon"
1880        );
1881
1882        later.account_meta = codex_limits(95.0, now_s + 3600);
1883        vault.upsert(later).await.unwrap();
1884        assert_eq!(
1885            vault.account_for(Provider::Openai, false).await.unwrap().name,
1886            "soon"
1887        );
1888
1889        vault
1890            .set_policy_persisted(
1891                Provider::Openai,
1892                AccountPolicy {
1893                    mode: AccountPolicyMode::ResetFirst,
1894                    reserve_pct: Some(10),
1895                    disabled: vec!["soon".into()],
1896                    ..AccountPolicy::default()
1897                },
1898            )
1899            .await
1900            .unwrap();
1901        assert_eq!(
1902            vault.account_for(Provider::Openai, false).await.unwrap().name,
1903            "later"
1904        );
1905        std::fs::remove_dir_all(&dir).ok();
1906    }
1907
1908    #[tokio::test]
1909    async fn routing_policy_and_codex_limits_survive_reopen() {
1910        let dir = temp_dir("routing-persist");
1911        {
1912            let vault = Vault::open(dir.clone()).unwrap();
1913            let mut account = api_key_account("openai-api_key-work", Provider::Openai);
1914            account.name = "work".into();
1915            vault.upsert(account).await.unwrap();
1916            vault
1917                .set_policy_persisted(
1918                    Provider::Openai,
1919                    AccountPolicy {
1920                        order: vec!["work".into()],
1921                        mode: AccountPolicyMode::Priority,
1922                        reserve_pct: Some(15),
1923                        account_reserve_pct: HashMap::from([("work".into(), 23)]),
1924                        allow_mid_thread_failover: false,
1925                        ..AccountPolicy::default()
1926                    },
1927                )
1928                .await
1929                .unwrap();
1930            vault
1931                .record_codex_limits(
1932                    "openai-api_key-work",
1933                    json!({
1934                        "plan": "plus",
1935                        "windows": [{"window": "5h", "used_pct": 42.0, "resets_at_s": now_ms() / 1000 + 600}],
1936                    }),
1937                )
1938                .await
1939                .unwrap();
1940        }
1941        let reopened = Vault::open(dir.clone()).unwrap();
1942        let policy = reopened.policy(Provider::Openai);
1943        assert_eq!(policy.mode, AccountPolicyMode::Priority);
1944        assert_eq!(policy.reserve_pct, Some(15));
1945        assert_eq!(policy.account_reserve_pct["work"], 23);
1946        assert!(!policy.allow_mid_thread_failover);
1947        assert_eq!(policy.order, vec!["work"]);
1948        let account = reopened
1949            .list()
1950            .await
1951            .into_iter()
1952            .find(|account| account.name == "work")
1953            .unwrap();
1954        assert_eq!(
1955            account.account_meta["codex_limits"]["windows"][0]["used_pct"],
1956            42.0
1957        );
1958        assert!(account.account_meta["codex_limits"]["observed_at_ms"].is_i64());
1959        std::fs::remove_dir_all(&dir).ok();
1960    }
1961}