1use std::fmt;
25use std::future::Future;
26use std::mem;
27use std::pin::Pin;
28use std::sync::{Arc, Mutex};
29use std::time::Duration;
30
31use chrono::{DateTime, TimeDelta, TimeZone, Utc};
32use reqwest::header::{HeaderMap, RETRY_AFTER};
33use reqwest::{Client, StatusCode};
34use serde::{Deserialize, Serialize};
35use serde_json::json;
36use strum::{Display, EnumString};
37use thiserror::Error;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
49#[serde(rename_all = "snake_case")]
50#[strum(serialize_all = "snake_case")]
51pub enum WindowStatus {
52 Allowed,
54 AllowedWarning,
56 Rejected,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct AccountWindow {
82 pub window: String,
84 pub utilization: f64,
86 pub resets_at: Option<DateTime<Utc>>,
88 pub status: WindowStatus,
90 pub model_scope: Option<String>,
92 pub observed_at: DateTime<Utc>,
94}
95
96impl AccountWindow {
97 pub fn applies_to(&self, model: &str) -> bool {
103 match &self.model_scope {
104 None => true,
105 Some(scope) => model
106 .to_ascii_lowercase()
107 .contains(&scope.to_ascii_lowercase()),
108 }
109 }
110
111 pub fn is_exhausted(&self, now: DateTime<Utc>) -> bool {
119 self.status == WindowStatus::Rejected && self.resets_at.is_none_or(|reset| reset > now)
120 }
121
122 pub fn effective_utilization(&self, now: DateTime<Utc>) -> f64 {
142 match self.resets_at {
143 Some(reset) if reset <= now => 0.0,
144 _ => self.utilization,
145 }
146 }
147}
148
149#[derive(Clone)]
163pub struct AccountCredential {
164 env_var: &'static str,
165 value: String,
166}
167
168impl AccountCredential {
169 pub fn new(env_var: &'static str, value: String) -> Self {
175 Self { env_var, value }
176 }
177
178 pub fn env_var(&self) -> &'static str {
184 self.env_var
185 }
186
187 pub fn expose(&self) -> &str {
198 &self.value
199 }
200}
201
202impl fmt::Debug for AccountCredential {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 f.debug_struct("AccountCredential")
205 .field("env_var", &self.env_var)
206 .field("value", &"[REDACTED]")
207 .finish()
208 }
209}
210
211#[derive(Debug, Clone, Default)]
238pub struct RateLimitRecorder(Arc<Mutex<Vec<AccountWindow>>>);
239
240impl RateLimitRecorder {
241 pub fn record(&self, window: AccountWindow) {
248 let mut windows = self.0.lock().unwrap_or_else(|e| e.into_inner());
249 windows.retain(|w| !(w.window == window.window && w.model_scope == window.model_scope));
250 windows.push(window);
251 }
252
253 pub fn take(&self) -> Vec<AccountWindow> {
259 let mut windows = self.0.lock().unwrap_or_else(|e| e.into_inner());
260 mem::take(&mut *windows)
261 }
262}
263
264#[derive(Clone)]
279pub struct AccountSession {
280 credential: AccountCredential,
281 recorder: RateLimitRecorder,
282}
283
284impl AccountSession {
285 pub fn new(credential: AccountCredential, recorder: RateLimitRecorder) -> Self {
291 Self {
292 credential,
293 recorder,
294 }
295 }
296
297 pub fn credential(&self) -> &AccountCredential {
303 &self.credential
304 }
305
306 pub fn recorder(&self) -> &RateLimitRecorder {
318 &self.recorder
319 }
320}
321
322impl fmt::Debug for AccountSession {
323 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324 f.debug_struct("AccountSession")
325 .field("credential", &self.credential)
326 .finish_non_exhaustive()
327 }
328}
329
330#[derive(Debug, Clone, PartialEq)]
341pub enum CredentialCheck {
342 Valid {
344 windows: Vec<AccountWindow>,
346 },
347 Limited {
349 windows: Vec<AccountWindow>,
351 },
352}
353
354impl CredentialCheck {
355 pub fn windows(&self) -> &[AccountWindow] {
361 match self {
362 Self::Valid { windows } | Self::Limited { windows } => windows,
363 }
364 }
365}
366
367#[derive(Debug, Error)]
378pub enum AccountError {
379 #[error("invalid credential: {0}")]
381 InvalidCredential(String),
382 #[error("credential rejected by provider (HTTP {status})")]
384 Unauthorized {
385 status: u16,
387 },
388 #[error("credential check failed: {0}")]
390 CheckFailed(String),
391}
392
393#[derive(Debug, Clone, Serialize)]
405pub struct AccountFormField {
406 pub name: &'static str,
408 pub label: &'static str,
410 pub secret: bool,
412 pub help: &'static str,
414}
415
416pub type AccountCheckFuture<'a> =
418 Pin<Box<dyn Future<Output = Result<CredentialCheck, AccountError>> + Send + 'a>>;
419
420pub trait AccountKind: Send + Sync {
433 fn id(&self) -> &'static str;
435
436 fn display_name(&self) -> &'static str;
438
439 fn form_fields(&self) -> Vec<AccountFormField>;
441
442 fn validate_credential(&self, raw: &str) -> Result<(), AccountError>;
448
449 fn credential(&self, secret: &str) -> AccountCredential;
451
452 fn check_credential<'a>(&'a self, secret: &'a str) -> AccountCheckFuture<'a>;
459}
460
461const CLAUDE_TOKEN_PREFIX: &str = "sk-ant-oat01-";
462const CLAUDE_TOKEN_MIN_LEN: usize = 40;
463const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
464const CHECK_MODEL: &str = "claude-haiku-4-5-20251001";
465const MAX_ERROR_BODY: usize = 200;
466
467#[derive(Debug, Clone)]
478pub struct ClaudeSubscriptionKind {
479 api_base: String,
480 client: Client,
481}
482
483impl ClaudeSubscriptionKind {
484 pub const ID: &'static str = "claude_subscription";
486
487 pub const TOKEN_ENV: &'static str = "CLAUDE_CODE_OAUTH_TOKEN";
489
490 pub fn new() -> Self {
496 Self::with_api_base("https://api.anthropic.com")
497 }
498
499 pub fn with_api_base(api_base: &str) -> Self {
510 Self {
511 api_base: api_base.trim_end_matches('/').to_string(),
512 client: Client::new(),
513 }
514 }
515}
516
517impl Default for ClaudeSubscriptionKind {
518 fn default() -> Self {
519 Self::new()
520 }
521}
522
523impl AccountKind for ClaudeSubscriptionKind {
524 fn id(&self) -> &'static str {
525 Self::ID
526 }
527
528 fn display_name(&self) -> &'static str {
529 "Claude subscription (Pro/Max)"
530 }
531
532 fn form_fields(&self) -> Vec<AccountFormField> {
533 vec![AccountFormField {
534 name: "token",
535 label: "OAuth token",
536 secret: true,
537 help: "Run `claude setup-token` and paste the sk-ant-oat01-... token",
538 }]
539 }
540
541 fn validate_credential(&self, raw: &str) -> Result<(), AccountError> {
542 let token = raw.trim();
543 let well_formed = token.starts_with(CLAUDE_TOKEN_PREFIX)
544 && token.len() >= CLAUDE_TOKEN_MIN_LEN
545 && token
546 .chars()
547 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
548 if well_formed {
549 Ok(())
550 } else {
551 Err(AccountError::InvalidCredential(
552 "expected a `claude setup-token` token (sk-ant-oat01-...)".to_string(),
553 ))
554 }
555 }
556
557 fn credential(&self, secret: &str) -> AccountCredential {
558 AccountCredential::new(Self::TOKEN_ENV, secret.trim().to_string())
559 }
560
561 fn check_credential<'a>(&'a self, secret: &'a str) -> AccountCheckFuture<'a> {
562 Box::pin(async move {
563 let body = json!({
564 "model": CHECK_MODEL,
565 "max_tokens": 1,
566 "system": "You are Claude Code, Anthropic's official CLI for Claude.",
567 "messages": [{"role": "user", "content": "ping"}],
568 });
569 let response = self
570 .client
571 .post(format!("{}/v1/messages", self.api_base))
572 .timeout(CHECK_TIMEOUT)
573 .bearer_auth(secret.trim())
574 .header("anthropic-version", "2023-06-01")
575 .header("anthropic-beta", "oauth-2025-04-20")
576 .json(&body)
577 .send()
578 .await
579 .map_err(|e| AccountError::CheckFailed(e.without_url().to_string()))?;
580
581 let status = response.status();
582 let now = Utc::now();
583 if status.is_success() {
584 return Ok(CredentialCheck::Valid {
585 windows: windows_from_headers(response.headers(), now, false),
586 });
587 }
588 if status == StatusCode::TOO_MANY_REQUESTS {
589 return Ok(CredentialCheck::Limited {
590 windows: windows_from_headers(response.headers(), now, true),
591 });
592 }
593 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
594 return Err(AccountError::Unauthorized {
595 status: status.as_u16(),
596 });
597 }
598 let text = match response.text().await {
599 Ok(text) => text,
600 Err(e) => format!("<unreadable body: {e}>"),
601 };
602 let truncated: String = text.chars().take(MAX_ERROR_BODY).collect();
603 Err(AccountError::CheckFailed(format!(
604 "HTTP {}: {truncated}",
605 status.as_u16()
606 )))
607 })
608 }
609}
610
611fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
612 headers
613 .get(name)
614 .and_then(|v| v.to_str().ok())
615 .map(str::trim)
616}
617
618fn unix_seconds(value: &str) -> Option<DateTime<Utc>> {
619 let secs = value.parse::<i64>().ok()?;
620 Utc.timestamp_opt(secs, 0).single()
621}
622
623fn windows_from_headers(
628 headers: &HeaderMap,
629 now: DateTime<Utc>,
630 limited: bool,
631) -> Vec<AccountWindow> {
632 let mut windows = Vec::new();
633 for (suffix, name) in [("5h", "five_hour"), ("7d", "seven_day")] {
634 let prefix = format!("anthropic-ratelimit-unified-{suffix}");
635 let utilization = header_str(headers, &format!("{prefix}-utilization"))
636 .and_then(|v| v.parse::<f64>().ok());
637 let status = header_str(headers, &format!("{prefix}-status"))
638 .and_then(|v| v.parse::<WindowStatus>().ok());
639 if utilization.is_none() && status.is_none() {
640 continue;
641 }
642 let status = status.unwrap_or(WindowStatus::Allowed);
643 let utilization = utilization
644 .map(|u| if u > 1.0 { u / 100.0 } else { u })
645 .unwrap_or(if status == WindowStatus::Rejected {
646 1.0
647 } else {
648 0.0
649 })
650 .clamp(0.0, 1.0);
651 windows.push(AccountWindow {
652 window: name.to_string(),
653 utilization,
654 resets_at: header_str(headers, &format!("{prefix}-reset")).and_then(unix_seconds),
655 status,
656 model_scope: None,
657 observed_at: now,
658 });
659 }
660
661 if limited && windows.is_empty() {
662 let resets_at = header_str(headers, "anthropic-ratelimit-unified-reset")
663 .and_then(unix_seconds)
664 .or_else(|| {
665 header_str(headers, RETRY_AFTER.as_str())
666 .and_then(|v| v.parse::<i64>().ok())
667 .map(|secs| now + TimeDelta::seconds(secs))
668 });
669 windows.push(AccountWindow {
670 window: "five_hour".to_string(),
671 utilization: 1.0,
672 resets_at,
673 status: WindowStatus::Rejected,
674 model_scope: None,
675 observed_at: now,
676 });
677 }
678 windows
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use crate::account_strategy::{AccountCandidate, LeastUtilized, select_account};
685 use tokio::io::{AsyncReadExt, AsyncWriteExt};
686 use tokio::net::TcpListener;
687
688 const GOOD_TOKEN: &str = "sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123456789_-AB";
689
690 #[test]
691 fn validate_credential_accepts_well_formed_token() {
692 let kind = ClaudeSubscriptionKind::new();
693 assert!(kind.validate_credential(GOOD_TOKEN).is_ok());
694 assert!(
695 kind.validate_credential(&format!(" {GOOD_TOKEN}\n"))
696 .is_ok()
697 );
698 }
699
700 #[test]
701 fn validate_credential_rejects_wrong_prefix() {
702 let kind = ClaudeSubscriptionKind::new();
703 let token = GOOD_TOKEN.replace("oat01", "api03");
704 assert!(matches!(
705 kind.validate_credential(&token),
706 Err(AccountError::InvalidCredential(_))
707 ));
708 }
709
710 #[test]
711 fn validate_credential_rejects_short_token() {
712 let kind = ClaudeSubscriptionKind::new();
713 assert!(kind.validate_credential("sk-ant-oat01-invalid").is_err());
714 assert!(kind.validate_credential("").is_err());
715 }
716
717 #[test]
718 fn validate_credential_rejects_bad_chars() {
719 let kind = ClaudeSubscriptionKind::new();
720 let token = format!("{GOOD_TOKEN}$é");
721 assert!(kind.validate_credential(&token).is_err());
722 let token = format!("{GOOD_TOKEN} abc");
723 assert!(kind.validate_credential(&token).is_err());
724 }
725
726 #[test]
727 fn credential_and_session_debug_are_redacted() {
728 let credential = AccountCredential::new("CLAUDE_CODE_OAUTH_TOKEN", GOOD_TOKEN.to_string());
729 let debug = format!("{credential:?}");
730 assert!(!debug.contains(GOOD_TOKEN));
731 assert!(debug.contains("[REDACTED]"));
732 let session = AccountSession::new(credential, RateLimitRecorder::default());
733 assert!(!format!("{session:?}").contains(GOOD_TOKEN));
734 }
735
736 #[test]
737 fn credential_trims_the_secret() {
738 let kind = ClaudeSubscriptionKind::new();
739 let credential = kind.credential(&format!("{GOOD_TOKEN}\n"));
740 assert_eq!(credential.expose(), GOOD_TOKEN);
741 }
742
743 #[test]
744 fn form_fields_have_one_secret_token() {
745 let fields = ClaudeSubscriptionKind::new().form_fields();
746 assert_eq!(fields.len(), 1);
747 assert_eq!(fields[0].name, "token");
748 assert!(fields[0].secret);
749 assert!(fields[0].help.contains("claude setup-token"));
750 }
751
752 async fn stub_server(response: String) -> String {
754 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
755 let addr = listener.local_addr().unwrap();
756 tokio::spawn(async move {
757 let (mut socket, _) = listener.accept().await.unwrap();
758 let mut buf = Vec::new();
759 let mut chunk = [0u8; 4096];
760 loop {
761 let n = socket.read(&mut chunk).await.unwrap();
762 if n == 0 {
763 break;
764 }
765 buf.extend_from_slice(&chunk[..n]);
766 let text = String::from_utf8_lossy(&buf);
767 if let Some(end) = text.find("\r\n\r\n") {
768 let content_length = text[..end]
769 .lines()
770 .find_map(|l| {
771 l.to_ascii_lowercase()
772 .strip_prefix("content-length:")
773 .map(|v| v.trim().parse::<usize>().unwrap_or(0))
774 })
775 .unwrap_or(0);
776 if buf.len() >= end + 4 + content_length {
777 break;
778 }
779 }
780 }
781 socket.write_all(response.as_bytes()).await.unwrap();
782 socket.shutdown().await.unwrap();
783 });
784 format!("http://{addr}")
785 }
786
787 fn http_response(status: &str, headers: &[(&str, String)], body: &str) -> String {
788 let mut out = format!("HTTP/1.1 {status}\r\n");
789 for (name, value) in headers {
790 out.push_str(&format!("{name}: {value}\r\n"));
791 }
792 out.push_str(&format!(
793 "content-length: {}\r\nconnection: close\r\n\r\n{body}",
794 body.len()
795 ));
796 out
797 }
798
799 #[tokio::test]
800 async fn check_credential_valid_reads_unified_windows() {
801 let reset = Utc::now().timestamp() + 3600;
802 let response = http_response(
803 "200 OK",
804 &[
805 (
806 "anthropic-ratelimit-unified-5h-utilization",
807 "0.42".to_string(),
808 ),
809 ("anthropic-ratelimit-unified-5h-reset", reset.to_string()),
810 (
811 "anthropic-ratelimit-unified-5h-status",
812 "allowed".to_string(),
813 ),
814 (
815 "anthropic-ratelimit-unified-7d-utilization",
816 "0.8".to_string(),
817 ),
818 (
819 "anthropic-ratelimit-unified-7d-status",
820 "allowed_warning".to_string(),
821 ),
822 ],
823 "{}",
824 );
825 let base = stub_server(response).await;
826 let kind = ClaudeSubscriptionKind::with_api_base(&base);
827 let check = kind.check_credential(GOOD_TOKEN).await.unwrap();
828 let CredentialCheck::Valid { windows } = check else {
829 panic!("expected Valid, got {check:?}");
830 };
831 assert_eq!(windows.len(), 2);
832 assert_eq!(windows[0].window, "five_hour");
833 assert!((windows[0].utilization - 0.42).abs() < 1e-9);
834 assert_eq!(windows[0].resets_at.unwrap().timestamp(), reset);
835 assert_eq!(windows[1].window, "seven_day");
836 assert_eq!(windows[1].status, WindowStatus::AllowedWarning);
837 }
838
839 #[tokio::test]
840 async fn check_credential_429_is_limited_with_rejected_window() {
841 let reset = Utc::now().timestamp() + 1800;
842 let response = http_response(
843 "429 Too Many Requests",
844 &[("anthropic-ratelimit-unified-reset", reset.to_string())],
845 "{\"error\":\"rate_limited\"}",
846 );
847 let base = stub_server(response).await;
848 let kind = ClaudeSubscriptionKind::with_api_base(&base);
849 let check = kind.check_credential(GOOD_TOKEN).await.unwrap();
850 let CredentialCheck::Limited { windows } = check else {
851 panic!("expected Limited, got {check:?}");
852 };
853 assert_eq!(windows.len(), 1);
854 assert_eq!(windows[0].window, "five_hour");
855 assert_eq!(windows[0].status, WindowStatus::Rejected);
856 assert_eq!(windows[0].utilization, 1.0);
857 assert_eq!(windows[0].resets_at.unwrap().timestamp(), reset);
858 }
859
860 #[tokio::test]
861 async fn check_credential_401_is_unauthorized() {
862 let response = http_response("401 Unauthorized", &[], "{}");
863 let base = stub_server(response).await;
864 let kind = ClaudeSubscriptionKind::with_api_base(&base);
865 let err = kind.check_credential(GOOD_TOKEN).await.unwrap_err();
866 assert!(matches!(err, AccountError::Unauthorized { status: 401 }));
867 assert!(!err.to_string().contains(GOOD_TOKEN));
868 }
869
870 #[tokio::test]
871 async fn check_credential_500_is_check_failed() {
872 let response = http_response("500 Internal Server Error", &[], "boom");
873 let base = stub_server(response).await;
874 let kind = ClaudeSubscriptionKind::with_api_base(&base);
875 let err = kind.check_credential(GOOD_TOKEN).await.unwrap_err();
876 let AccountError::CheckFailed(message) = err else {
877 panic!("expected CheckFailed");
878 };
879 assert!(message.contains("HTTP 500"));
880 assert!(message.contains("boom"));
881 }
882
883 #[tokio::test]
884 async fn check_credential_unreachable_is_check_failed() {
885 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
886 let addr = listener.local_addr().unwrap();
887 drop(listener);
888 let kind = ClaudeSubscriptionKind::with_api_base(&format!("http://{addr}"));
889 let err = kind.check_credential(GOOD_TOKEN).await.unwrap_err();
890 assert!(matches!(err, AccountError::CheckFailed(_)));
891 }
892
893 struct BudgetKind;
896
897 impl AccountKind for BudgetKind {
898 fn id(&self) -> &'static str {
899 "budget"
900 }
901
902 fn display_name(&self) -> &'static str {
903 "Budget"
904 }
905
906 fn form_fields(&self) -> Vec<AccountFormField> {
907 vec![AccountFormField {
908 name: "api_key",
909 label: "API key",
910 secret: true,
911 help: "Paste the API key",
912 }]
913 }
914
915 fn validate_credential(&self, raw: &str) -> Result<(), AccountError> {
916 if raw.is_empty() {
917 Err(AccountError::InvalidCredential("empty".to_string()))
918 } else {
919 Ok(())
920 }
921 }
922
923 fn credential(&self, secret: &str) -> AccountCredential {
924 AccountCredential::new("BUDGET_API_KEY", secret.to_string())
925 }
926
927 fn check_credential<'a>(&'a self, _secret: &'a str) -> AccountCheckFuture<'a> {
928 Box::pin(async {
929 Ok(CredentialCheck::Valid {
930 windows: vec![budget_window(0.5, WindowStatus::Allowed)],
931 })
932 })
933 }
934 }
935
936 fn budget_window(utilization: f64, status: WindowStatus) -> AccountWindow {
937 AccountWindow {
938 window: "monthly_budget_usd".to_string(),
939 utilization,
940 resets_at: Some(Utc::now() + TimeDelta::days(10)),
941 status,
942 model_scope: None,
943 observed_at: Utc::now(),
944 }
945 }
946
947 #[tokio::test]
948 async fn budget_kind_goes_through_select_account() {
949 let kind = BudgetKind;
950 assert!(kind.validate_credential("").is_err());
951 let check = kind.check_credential("key").await.unwrap();
952 let spent = AccountCandidate {
953 id: "a".to_string(),
954 name: "spent".to_string(),
955 priority: 1,
956 max_concurrency: None,
957 running_steps: 0,
958 windows: vec![budget_window(1.0, WindowStatus::Rejected)],
959 };
960 let fresh = AccountCandidate {
961 id: "b".to_string(),
962 name: "fresh".to_string(),
963 priority: 2,
964 max_concurrency: None,
965 running_steps: 0,
966 windows: check.windows().to_vec(),
967 };
968 let candidates = [spent, fresh];
969 let selected =
970 select_account(&LeastUtilized, &candidates, "any-model", Utc::now()).unwrap();
971 assert_eq!(selected.name, "fresh");
972 assert_eq!(kind.credential("key").env_var(), "BUDGET_API_KEY");
973 }
974}