use std::fmt;
use std::future::Future;
use std::mem;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::{DateTime, TimeDelta, TimeZone, Utc};
use reqwest::header::{HeaderMap, RETRY_AFTER};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use strum::{Display, EnumString};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum WindowStatus {
Allowed,
AllowedWarning,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AccountWindow {
pub window: String,
pub utilization: f64,
pub resets_at: Option<DateTime<Utc>>,
pub status: WindowStatus,
pub model_scope: Option<String>,
pub observed_at: DateTime<Utc>,
}
impl AccountWindow {
pub fn applies_to(&self, model: &str) -> bool {
match &self.model_scope {
None => true,
Some(scope) => model
.to_ascii_lowercase()
.contains(&scope.to_ascii_lowercase()),
}
}
pub fn is_exhausted(&self, now: DateTime<Utc>) -> bool {
self.status == WindowStatus::Rejected && self.resets_at.is_none_or(|reset| reset > now)
}
pub fn effective_utilization(&self, now: DateTime<Utc>) -> f64 {
match self.resets_at {
Some(reset) if reset <= now => 0.0,
_ => self.utilization,
}
}
}
#[derive(Clone)]
pub struct AccountCredential {
env_var: &'static str,
value: String,
}
impl AccountCredential {
pub fn new(env_var: &'static str, value: String) -> Self {
Self { env_var, value }
}
pub fn env_var(&self) -> &'static str {
self.env_var
}
pub fn expose(&self) -> &str {
&self.value
}
}
impl fmt::Debug for AccountCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AccountCredential")
.field("env_var", &self.env_var)
.field("value", &"[REDACTED]")
.finish()
}
}
#[derive(Debug, Clone, Default)]
pub struct RateLimitRecorder(Arc<Mutex<Vec<AccountWindow>>>);
impl RateLimitRecorder {
pub fn record(&self, window: AccountWindow) {
let mut windows = self.0.lock().unwrap_or_else(|e| e.into_inner());
windows.retain(|w| !(w.window == window.window && w.model_scope == window.model_scope));
windows.push(window);
}
pub fn take(&self) -> Vec<AccountWindow> {
let mut windows = self.0.lock().unwrap_or_else(|e| e.into_inner());
mem::take(&mut *windows)
}
}
#[derive(Clone)]
pub struct AccountSession {
credential: AccountCredential,
recorder: RateLimitRecorder,
}
impl AccountSession {
pub fn new(credential: AccountCredential, recorder: RateLimitRecorder) -> Self {
Self {
credential,
recorder,
}
}
pub fn credential(&self) -> &AccountCredential {
&self.credential
}
pub fn recorder(&self) -> &RateLimitRecorder {
&self.recorder
}
}
impl fmt::Debug for AccountSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AccountSession")
.field("credential", &self.credential)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum CredentialCheck {
Valid {
windows: Vec<AccountWindow>,
},
Limited {
windows: Vec<AccountWindow>,
},
}
impl CredentialCheck {
pub fn windows(&self) -> &[AccountWindow] {
match self {
Self::Valid { windows } | Self::Limited { windows } => windows,
}
}
}
#[derive(Debug, Error)]
pub enum AccountError {
#[error("invalid credential: {0}")]
InvalidCredential(String),
#[error("credential rejected by provider (HTTP {status})")]
Unauthorized {
status: u16,
},
#[error("credential check failed: {0}")]
CheckFailed(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct AccountFormField {
pub name: &'static str,
pub label: &'static str,
pub secret: bool,
pub help: &'static str,
}
pub type AccountCheckFuture<'a> =
Pin<Box<dyn Future<Output = Result<CredentialCheck, AccountError>> + Send + 'a>>;
pub trait AccountKind: Send + Sync {
fn id(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn form_fields(&self) -> Vec<AccountFormField>;
fn validate_credential(&self, raw: &str) -> Result<(), AccountError>;
fn credential(&self, secret: &str) -> AccountCredential;
fn check_credential<'a>(&'a self, secret: &'a str) -> AccountCheckFuture<'a>;
}
const CLAUDE_TOKEN_PREFIX: &str = "sk-ant-oat01-";
const CLAUDE_TOKEN_MIN_LEN: usize = 40;
const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const CHECK_MODEL: &str = "claude-haiku-4-5-20251001";
const MAX_ERROR_BODY: usize = 200;
#[derive(Debug, Clone)]
pub struct ClaudeSubscriptionKind {
api_base: String,
client: Client,
}
impl ClaudeSubscriptionKind {
pub const ID: &'static str = "claude_subscription";
pub const TOKEN_ENV: &'static str = "CLAUDE_CODE_OAUTH_TOKEN";
pub fn new() -> Self {
Self::with_api_base("https://api.anthropic.com")
}
pub fn with_api_base(api_base: &str) -> Self {
Self {
api_base: api_base.trim_end_matches('/').to_string(),
client: Client::new(),
}
}
}
impl Default for ClaudeSubscriptionKind {
fn default() -> Self {
Self::new()
}
}
impl AccountKind for ClaudeSubscriptionKind {
fn id(&self) -> &'static str {
Self::ID
}
fn display_name(&self) -> &'static str {
"Claude subscription (Pro/Max)"
}
fn form_fields(&self) -> Vec<AccountFormField> {
vec![AccountFormField {
name: "token",
label: "OAuth token",
secret: true,
help: "Run `claude setup-token` and paste the sk-ant-oat01-... token",
}]
}
fn validate_credential(&self, raw: &str) -> Result<(), AccountError> {
let token = raw.trim();
let well_formed = token.starts_with(CLAUDE_TOKEN_PREFIX)
&& token.len() >= CLAUDE_TOKEN_MIN_LEN
&& token
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if well_formed {
Ok(())
} else {
Err(AccountError::InvalidCredential(
"expected a `claude setup-token` token (sk-ant-oat01-...)".to_string(),
))
}
}
fn credential(&self, secret: &str) -> AccountCredential {
AccountCredential::new(Self::TOKEN_ENV, secret.trim().to_string())
}
fn check_credential<'a>(&'a self, secret: &'a str) -> AccountCheckFuture<'a> {
Box::pin(async move {
let body = json!({
"model": CHECK_MODEL,
"max_tokens": 1,
"system": "You are Claude Code, Anthropic's official CLI for Claude.",
"messages": [{"role": "user", "content": "ping"}],
});
let response = self
.client
.post(format!("{}/v1/messages", self.api_base))
.timeout(CHECK_TIMEOUT)
.bearer_auth(secret.trim())
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", "oauth-2025-04-20")
.json(&body)
.send()
.await
.map_err(|e| AccountError::CheckFailed(e.without_url().to_string()))?;
let status = response.status();
let now = Utc::now();
if status.is_success() {
return Ok(CredentialCheck::Valid {
windows: windows_from_headers(response.headers(), now, false),
});
}
if status == StatusCode::TOO_MANY_REQUESTS {
return Ok(CredentialCheck::Limited {
windows: windows_from_headers(response.headers(), now, true),
});
}
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
return Err(AccountError::Unauthorized {
status: status.as_u16(),
});
}
let text = match response.text().await {
Ok(text) => text,
Err(e) => format!("<unreadable body: {e}>"),
};
let truncated: String = text.chars().take(MAX_ERROR_BODY).collect();
Err(AccountError::CheckFailed(format!(
"HTTP {}: {truncated}",
status.as_u16()
)))
})
}
}
fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::trim)
}
fn unix_seconds(value: &str) -> Option<DateTime<Utc>> {
let secs = value.parse::<i64>().ok()?;
Utc.timestamp_opt(secs, 0).single()
}
fn windows_from_headers(
headers: &HeaderMap,
now: DateTime<Utc>,
limited: bool,
) -> Vec<AccountWindow> {
let mut windows = Vec::new();
for (suffix, name) in [("5h", "five_hour"), ("7d", "seven_day")] {
let prefix = format!("anthropic-ratelimit-unified-{suffix}");
let utilization = header_str(headers, &format!("{prefix}-utilization"))
.and_then(|v| v.parse::<f64>().ok());
let status = header_str(headers, &format!("{prefix}-status"))
.and_then(|v| v.parse::<WindowStatus>().ok());
if utilization.is_none() && status.is_none() {
continue;
}
let status = status.unwrap_or(WindowStatus::Allowed);
let utilization = utilization
.map(|u| if u > 1.0 { u / 100.0 } else { u })
.unwrap_or(if status == WindowStatus::Rejected {
1.0
} else {
0.0
})
.clamp(0.0, 1.0);
windows.push(AccountWindow {
window: name.to_string(),
utilization,
resets_at: header_str(headers, &format!("{prefix}-reset")).and_then(unix_seconds),
status,
model_scope: None,
observed_at: now,
});
}
if limited && windows.is_empty() {
let resets_at = header_str(headers, "anthropic-ratelimit-unified-reset")
.and_then(unix_seconds)
.or_else(|| {
header_str(headers, RETRY_AFTER.as_str())
.and_then(|v| v.parse::<i64>().ok())
.map(|secs| now + TimeDelta::seconds(secs))
});
windows.push(AccountWindow {
window: "five_hour".to_string(),
utilization: 1.0,
resets_at,
status: WindowStatus::Rejected,
model_scope: None,
observed_at: now,
});
}
windows
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account_strategy::{AccountCandidate, LeastUtilized, select_account};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const GOOD_TOKEN: &str = "sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123456789_-AB";
#[test]
fn validate_credential_accepts_well_formed_token() {
let kind = ClaudeSubscriptionKind::new();
assert!(kind.validate_credential(GOOD_TOKEN).is_ok());
assert!(
kind.validate_credential(&format!(" {GOOD_TOKEN}\n"))
.is_ok()
);
}
#[test]
fn validate_credential_rejects_wrong_prefix() {
let kind = ClaudeSubscriptionKind::new();
let token = GOOD_TOKEN.replace("oat01", "api03");
assert!(matches!(
kind.validate_credential(&token),
Err(AccountError::InvalidCredential(_))
));
}
#[test]
fn validate_credential_rejects_short_token() {
let kind = ClaudeSubscriptionKind::new();
assert!(kind.validate_credential("sk-ant-oat01-invalid").is_err());
assert!(kind.validate_credential("").is_err());
}
#[test]
fn validate_credential_rejects_bad_chars() {
let kind = ClaudeSubscriptionKind::new();
let token = format!("{GOOD_TOKEN}$é");
assert!(kind.validate_credential(&token).is_err());
let token = format!("{GOOD_TOKEN} abc");
assert!(kind.validate_credential(&token).is_err());
}
#[test]
fn credential_and_session_debug_are_redacted() {
let credential = AccountCredential::new("CLAUDE_CODE_OAUTH_TOKEN", GOOD_TOKEN.to_string());
let debug = format!("{credential:?}");
assert!(!debug.contains(GOOD_TOKEN));
assert!(debug.contains("[REDACTED]"));
let session = AccountSession::new(credential, RateLimitRecorder::default());
assert!(!format!("{session:?}").contains(GOOD_TOKEN));
}
#[test]
fn credential_trims_the_secret() {
let kind = ClaudeSubscriptionKind::new();
let credential = kind.credential(&format!("{GOOD_TOKEN}\n"));
assert_eq!(credential.expose(), GOOD_TOKEN);
}
#[test]
fn form_fields_have_one_secret_token() {
let fields = ClaudeSubscriptionKind::new().form_fields();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].name, "token");
assert!(fields[0].secret);
assert!(fields[0].help.contains("claude setup-token"));
}
async fn stub_server(response: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
let text = String::from_utf8_lossy(&buf);
if let Some(end) = text.find("\r\n\r\n") {
let content_length = text[..end]
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
})
.unwrap_or(0);
if buf.len() >= end + 4 + content_length {
break;
}
}
}
socket.write_all(response.as_bytes()).await.unwrap();
socket.shutdown().await.unwrap();
});
format!("http://{addr}")
}
fn http_response(status: &str, headers: &[(&str, String)], body: &str) -> String {
let mut out = format!("HTTP/1.1 {status}\r\n");
for (name, value) in headers {
out.push_str(&format!("{name}: {value}\r\n"));
}
out.push_str(&format!(
"content-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
));
out
}
#[tokio::test]
async fn check_credential_valid_reads_unified_windows() {
let reset = Utc::now().timestamp() + 3600;
let response = http_response(
"200 OK",
&[
(
"anthropic-ratelimit-unified-5h-utilization",
"0.42".to_string(),
),
("anthropic-ratelimit-unified-5h-reset", reset.to_string()),
(
"anthropic-ratelimit-unified-5h-status",
"allowed".to_string(),
),
(
"anthropic-ratelimit-unified-7d-utilization",
"0.8".to_string(),
),
(
"anthropic-ratelimit-unified-7d-status",
"allowed_warning".to_string(),
),
],
"{}",
);
let base = stub_server(response).await;
let kind = ClaudeSubscriptionKind::with_api_base(&base);
let check = kind.check_credential(GOOD_TOKEN).await.unwrap();
let CredentialCheck::Valid { windows } = check else {
panic!("expected Valid, got {check:?}");
};
assert_eq!(windows.len(), 2);
assert_eq!(windows[0].window, "five_hour");
assert!((windows[0].utilization - 0.42).abs() < 1e-9);
assert_eq!(windows[0].resets_at.unwrap().timestamp(), reset);
assert_eq!(windows[1].window, "seven_day");
assert_eq!(windows[1].status, WindowStatus::AllowedWarning);
}
#[tokio::test]
async fn check_credential_429_is_limited_with_rejected_window() {
let reset = Utc::now().timestamp() + 1800;
let response = http_response(
"429 Too Many Requests",
&[("anthropic-ratelimit-unified-reset", reset.to_string())],
"{\"error\":\"rate_limited\"}",
);
let base = stub_server(response).await;
let kind = ClaudeSubscriptionKind::with_api_base(&base);
let check = kind.check_credential(GOOD_TOKEN).await.unwrap();
let CredentialCheck::Limited { windows } = check else {
panic!("expected Limited, got {check:?}");
};
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].window, "five_hour");
assert_eq!(windows[0].status, WindowStatus::Rejected);
assert_eq!(windows[0].utilization, 1.0);
assert_eq!(windows[0].resets_at.unwrap().timestamp(), reset);
}
#[tokio::test]
async fn check_credential_401_is_unauthorized() {
let response = http_response("401 Unauthorized", &[], "{}");
let base = stub_server(response).await;
let kind = ClaudeSubscriptionKind::with_api_base(&base);
let err = kind.check_credential(GOOD_TOKEN).await.unwrap_err();
assert!(matches!(err, AccountError::Unauthorized { status: 401 }));
assert!(!err.to_string().contains(GOOD_TOKEN));
}
#[tokio::test]
async fn check_credential_500_is_check_failed() {
let response = http_response("500 Internal Server Error", &[], "boom");
let base = stub_server(response).await;
let kind = ClaudeSubscriptionKind::with_api_base(&base);
let err = kind.check_credential(GOOD_TOKEN).await.unwrap_err();
let AccountError::CheckFailed(message) = err else {
panic!("expected CheckFailed");
};
assert!(message.contains("HTTP 500"));
assert!(message.contains("boom"));
}
#[tokio::test]
async fn check_credential_unreachable_is_check_failed() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let kind = ClaudeSubscriptionKind::with_api_base(&format!("http://{addr}"));
let err = kind.check_credential(GOOD_TOKEN).await.unwrap_err();
assert!(matches!(err, AccountError::CheckFailed(_)));
}
struct BudgetKind;
impl AccountKind for BudgetKind {
fn id(&self) -> &'static str {
"budget"
}
fn display_name(&self) -> &'static str {
"Budget"
}
fn form_fields(&self) -> Vec<AccountFormField> {
vec![AccountFormField {
name: "api_key",
label: "API key",
secret: true,
help: "Paste the API key",
}]
}
fn validate_credential(&self, raw: &str) -> Result<(), AccountError> {
if raw.is_empty() {
Err(AccountError::InvalidCredential("empty".to_string()))
} else {
Ok(())
}
}
fn credential(&self, secret: &str) -> AccountCredential {
AccountCredential::new("BUDGET_API_KEY", secret.to_string())
}
fn check_credential<'a>(&'a self, _secret: &'a str) -> AccountCheckFuture<'a> {
Box::pin(async {
Ok(CredentialCheck::Valid {
windows: vec![budget_window(0.5, WindowStatus::Allowed)],
})
})
}
}
fn budget_window(utilization: f64, status: WindowStatus) -> AccountWindow {
AccountWindow {
window: "monthly_budget_usd".to_string(),
utilization,
resets_at: Some(Utc::now() + TimeDelta::days(10)),
status,
model_scope: None,
observed_at: Utc::now(),
}
}
#[tokio::test]
async fn budget_kind_goes_through_select_account() {
let kind = BudgetKind;
assert!(kind.validate_credential("").is_err());
let check = kind.check_credential("key").await.unwrap();
let spent = AccountCandidate {
id: "a".to_string(),
name: "spent".to_string(),
priority: 1,
max_concurrency: None,
running_steps: 0,
windows: vec![budget_window(1.0, WindowStatus::Rejected)],
};
let fresh = AccountCandidate {
id: "b".to_string(),
name: "fresh".to_string(),
priority: 2,
max_concurrency: None,
running_steps: 0,
windows: check.windows().to_vec(),
};
let candidates = [spent, fresh];
let selected =
select_account(&LeastUtilized, &candidates, "any-model", Utc::now()).unwrap();
assert_eq!(selected.name, "fresh");
assert_eq!(kind.credential("key").env_var(), "BUDGET_API_KEY");
}
}