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
29pub 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 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
258pub 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 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
1215pub 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; 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 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
1286pub 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}