use serde_json::Value;
pub const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
pub const OAUTH_BETA: &str = "oauth-2025-04-20";
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Window {
pub used_pct: f64,
pub resets_at: Option<i64>,
}
impl Window {
pub fn remaining_pct(&self) -> f64 {
(100.0 - self.used_pct).clamp(0.0, 100.0)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Quota {
pub five_hour: Option<Window>,
pub seven_day: Option<Window>,
pub scoped: Vec<(String, Window)>,
}
#[derive(Debug)]
pub enum Fetch {
Ok(Quota),
Unauthorized,
Unexpected(u32, String),
Offline(String),
Throttled,
}
pub fn token_from_credentials(bytes: &[u8]) -> Option<String> {
let v: Value = serde_json::from_slice(bytes).ok()?;
v["claudeAiOauth"]["accessToken"]
.as_str()
.map(str::to_string)
}
pub fn credentials_expired(bytes: &[u8], now_ms: i64) -> bool {
serde_json::from_slice::<Value>(bytes)
.ok()
.and_then(|v| v["claudeAiOauth"]["expiresAt"].as_i64())
.is_some_and(|exp| exp <= now_ms)
}
fn window_from(v: &Value) -> Option<Window> {
Some(Window {
used_pct: pct_used(v)?,
resets_at: reset_secs(v),
})
}
fn pct_used(v: &Value) -> Option<f64> {
for k in [
"utilization",
"used_percentage",
"used_pct",
"utilization_percentage",
"percent_used",
"percent",
] {
if let Some(f) = v.get(k).and_then(Value::as_f64) {
return Some(f.clamp(0.0, 100.0));
}
}
for k in ["used_fraction", "fraction_used"] {
if let Some(f) = v.get(k).and_then(Value::as_f64) {
return Some((f * 100.0).clamp(0.0, 100.0));
}
}
None
}
fn normalize_epoch(n: i64) -> i64 {
if n > 100_000_000_000 {
n / 1000
} else {
n
}
}
fn reset_secs(v: &Value) -> Option<i64> {
for k in ["resets_at", "reset_at", "resets", "reset"] {
match v.get(k) {
Some(Value::Number(n)) => {
return n
.as_i64()
.or_else(|| n.as_f64().map(|f| f as i64))
.map(normalize_epoch)
}
Some(Value::String(s)) => {
if let Some(t) = crate::session_link::rfc3339_to_secs(s) {
return Some(t);
}
if let Ok(n) = s.parse::<i64>() {
return Some(normalize_epoch(n));
}
}
_ => {}
}
}
None
}
pub fn parse(body: &str) -> Option<Quota> {
let v: Value = serde_json::from_str(body).ok()?;
let five_hour = v.get("five_hour").and_then(window_from);
let seven_day = v.get("seven_day").and_then(window_from);
let mut scoped = Vec::new();
for (k, label) in [
("seven_day_opus", "opus 7d"),
("seven_day_sonnet", "sonnet 7d"),
("seven_day_oi", "opus 7d"),
] {
if let Some(w) = v.get(k).and_then(window_from) {
if !scoped.iter().any(|(n, _): &(String, Window)| n == label) {
scoped.push((label.to_string(), w));
}
}
}
if let Some(limits) = v.get("limits").and_then(Value::as_array) {
for l in limits {
let name = l
.get("scope")
.and_then(|s| s.get("model"))
.and_then(|m| m.get("display_name"))
.and_then(Value::as_str)
.or_else(|| l.get("name").and_then(Value::as_str));
if let (Some(name), Some(w)) = (name, window_from(l)) {
if !scoped.iter().any(|(n, _)| n == name) {
scoped.push((name.to_string(), w));
}
}
}
}
if five_hour.is_none() && seven_day.is_none() && scoped.is_empty() {
return None;
}
Some(Quota {
five_hour,
seven_day,
scoped,
})
}
pub fn classify(code: u32, body: String) -> Fetch {
match code {
401 | 403 => Fetch::Unauthorized,
429 => Fetch::Throttled,
200..=299 => match parse(&body) {
Some(q) => Fetch::Ok(q),
None => Fetch::Unexpected(code, body),
},
0 => Fetch::Offline("no response from api.anthropic.com".into()),
c => Fetch::Unexpected(c, body),
}
}
pub fn token_usable(token: &str) -> bool {
!token.is_empty() && !token.contains(['"', '\n', '\r', '\\'])
}
pub fn fetch_with_retry(token: &str) -> Fetch {
for wait_ms in [400u64, 900, 1800] {
match fetch(token) {
Fetch::Throttled => std::thread::sleep(std::time::Duration::from_millis(wait_ms)),
other => return other,
}
}
fetch(token)
}
pub fn pace_between_accounts() {
std::thread::sleep(std::time::Duration::from_millis(PACE_MS));
}
const PACE_MS: u64 = 120;
pub fn fetch_many(tokens: Vec<(usize, String)>) -> Vec<(usize, Fetch)> {
let mut handles = Vec::with_capacity(tokens.len());
for (n, (idx, token)) in tokens.into_iter().enumerate() {
handles.push(std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(PACE_MS * n as u64));
(idx, fetch_with_retry(&token))
}));
}
handles.into_iter().filter_map(|h| h.join().ok()).collect()
}
pub fn fetch(token: &str) -> Fetch {
if !token_usable(token) {
return Fetch::Offline("no usable access token for this account".into());
}
let cfg = format!(
"url = \"{USAGE_URL}\"\n\
header = \"Authorization: Bearer {token}\"\n\
header = \"anthropic-beta: {OAUTH_BETA}\"\n\
header = \"Accept: application/json\"\n\
header = \"User-Agent: swapdex\"\n\
silent\n\
show-error\n\
connect-timeout = 6\n\
max-time = 15\n\
write-out = \"\\n%{{http_code}}\"\n"
);
match run_curl(&cfg) {
Ok((body, code)) => classify(code, body),
Err(e) => Fetch::Offline(e),
}
}
fn curl_bin() -> String {
if std::env::var_os("SWAPDEX_ROOT").is_some() {
if let Some(t) = std::env::var_os("SWAPDEX_CURL") {
return t.to_string_lossy().into_owned();
}
}
if std::path::Path::new("/usr/bin/curl").exists() {
"/usr/bin/curl".into()
} else {
"curl".into()
}
}
pub fn run_curl_cfg(cfg: &str) -> std::result::Result<(String, u32), String> {
run_curl(cfg)
}
fn run_curl(cfg: &str) -> std::result::Result<(String, u32), String> {
use std::io::Write;
let mut child = std::process::Command::new(curl_bin())
.arg("-q")
.arg("--config")
.arg("-")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("run curl: {e} (curl is required only for `swapdex quota`)"))?;
child
.stdin
.take()
.ok_or_else(|| "curl stdin unavailable".to_string())?
.write_all(cfg.as_bytes())
.map_err(|e| e.to_string())?;
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if !out.status.success() || out.stdout.is_empty() {
let err = String::from_utf8_lossy(&out.stderr);
let msg = err.trim();
return Err(if msg.is_empty() {
"no response from api.anthropic.com".to_string()
} else {
msg.to_string()
});
}
let text = String::from_utf8_lossy(&out.stdout).into_owned();
let (body, code) = match text.rfind('\n') {
Some(i) => (
text[..i].to_string(),
text[i + 1..].trim().parse::<u32>().unwrap_or(0),
),
None => (String::new(), text.trim().parse::<u32>().unwrap_or(0)),
};
Ok((body, code))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_extracted_from_credentials() {
let cred = br#"{"claudeAiOauth":{"accessToken":"sk-ant-oat01-XYZ","refreshToken":"r"}}"#;
assert_eq!(
token_from_credentials(cred).as_deref(),
Some("sk-ant-oat01-XYZ")
);
assert_eq!(token_from_credentials(b"{}"), None);
assert_eq!(token_from_credentials(b"not json"), None);
}
#[test]
fn reset_in_milliseconds_is_normalized_to_seconds() {
let body = r#"{"five_hour":{"utilization":0.5,"resets_at":1900000000000}}"#;
let w = parse(body).unwrap().five_hour.unwrap();
assert_eq!(w.resets_at, Some(1_900_000_000), "ms divided to seconds");
let body = r#"{"five_hour":{"utilization":0.5,"resets_at":1900000000}}"#;
assert_eq!(
parse(body).unwrap().five_hour.unwrap().resets_at,
Some(1_900_000_000)
);
}
#[test]
fn parses_percentage_windows_with_reset() {
let body = r#"{"five_hour":{"utilization":61.0,"resets_at":1700000000},
"seven_day":{"utilization":22.0,"resets_at":1700500000}}"#;
let q = parse(body).unwrap();
let f = q.five_hour.unwrap();
assert!((f.used_pct - 61.0).abs() < 1e-6);
assert!((f.remaining_pct() - 39.0).abs() < 1e-6);
assert_eq!(f.resets_at, Some(1_700_000_000));
assert!((q.seven_day.unwrap().remaining_pct() - 78.0).abs() < 1e-6);
}
#[test]
fn parses_percentage_fields_and_rfc3339_reset() {
let body = r#"{"five_hour":{"used_percentage":90,"resets_at":"2026-07-10T12:00:00Z"}}"#;
let q = parse(body).unwrap();
let f = q.five_hour.unwrap();
assert!((f.used_pct - 90.0).abs() < 1e-6);
assert_eq!(
f.resets_at,
crate::session_link::rfc3339_to_secs("2026-07-10T12:00:00Z")
);
}
#[test]
fn parses_scoped_weekly_limits_array() {
let body = r#"{"seven_day":{"utilization":50.0},
"limits":[{"scope":{"model":{"display_name":"Opus"}},"utilization":80.0}]}"#;
let q = parse(body).unwrap();
assert_eq!(q.scoped.len(), 1);
assert_eq!(q.scoped[0].0, "Opus");
assert!((q.scoped[0].1.used_pct - 80.0).abs() < 1e-6);
}
#[test]
fn unrecognized_shape_is_none() {
assert!(parse(r#"{"something":"else"}"#).is_none());
assert!(parse("not json").is_none());
}
#[test]
fn classify_maps_status_codes() {
assert!(matches!(classify(401, String::new()), Fetch::Unauthorized));
assert!(matches!(classify(403, String::new()), Fetch::Unauthorized));
assert!(matches!(
classify(200, r#"{"five_hour":{"utilization":0.1}}"#.into()),
Fetch::Ok(_)
));
assert!(matches!(
classify(200, "{}".into()),
Fetch::Unexpected(200, _)
));
assert!(matches!(
classify(500, "oops".into()),
Fetch::Unexpected(500, _)
));
assert!(matches!(classify(0, String::new()), Fetch::Offline(_)));
}
#[test]
fn utilization_is_a_percentage_not_a_fraction() {
let body = r#"{"five_hour":{"utilization":4.0,"resets_at":"2026-07-31T04:19:59+00:00"},
"seven_day":{"utilization":59.0,"resets_at":"2026-08-03T06:59:59+00:00"},
"limits":[{"kind":"session","percent":4},{"kind":"weekly_all","percent":59}]}"#;
let q = parse(body).expect("parsed");
assert_eq!(q.five_hour.unwrap().used_pct, 4.0, "4% is four percent");
assert_eq!(q.seven_day.unwrap().used_pct, 59.0);
assert_eq!(q.five_hour.unwrap().remaining_pct(), 96.0);
let spent = parse(r#"{"five_hour":{"utilization":100.0}}"#).expect("parsed");
assert_eq!(spent.five_hour.unwrap().used_pct, 100.0);
let fresh = parse(r#"{"five_hour":{"utilization":0.0}}"#).expect("parsed");
assert_eq!(fresh.five_hour.unwrap().used_pct, 0.0);
}
#[test]
fn a_lapsed_snapshot_is_recognised_before_it_is_sent() {
let now = 1_800_000_000_000i64;
let blob =
|exp: i64| format!(r#"{{"claudeAiOauth":{{"accessToken":"A","expiresAt":{exp}}}}}"#);
assert!(credentials_expired(blob(now - 1).as_bytes(), now));
assert!(!credentials_expired(blob(now + 3_600_000).as_bytes(), now));
assert!(!credentials_expired(
br#"{"claudeAiOauth":{"accessToken":"A"}}"#,
now
));
assert!(!credentials_expired(b"not json", now));
}
#[test]
fn a_429_is_endpoint_throttling_not_an_account_verdict() {
assert!(matches!(classify(429, String::new()), Fetch::Throttled));
assert!(matches!(classify(401, String::new()), Fetch::Unauthorized));
assert!(matches!(classify(403, String::new()), Fetch::Unauthorized));
}
#[test]
fn fetch_rejects_a_token_with_shell_metacharacters() {
assert!(matches!(fetch("bad\"token"), Fetch::Offline(_)));
assert!(matches!(fetch(""), Fetch::Offline(_)));
}
}