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, Copy, Debug, Default, PartialEq)]
pub struct Extra {
pub enabled: bool,
pub limit_reached: bool,
pub used_pct: Option<f64>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Quota {
pub five_hour: Option<Window>,
pub seven_day: Option<Window>,
pub scoped: Vec<(String, Window)>,
pub extra: Option<Extra>,
}
impl Quota {
pub fn can_serve_past_windows(&self) -> bool {
self.extra.is_some_and(|x| x.enabled && !x.limit_reached)
}
}
#[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));
}
}
}
}
let extra = v.get("extra_usage").and_then(|x| {
let enabled = x.get("is_enabled").and_then(Value::as_bool)?;
Some(Extra {
enabled,
limit_reached: x
.get("spend_limit_reached")
.and_then(Value::as_bool)
.unwrap_or(false),
used_pct: x
.get("utilization")
.and_then(Value::as_f64)
.map(|p| p.clamp(0.0, 100.0)),
})
});
if five_hour.is_none() && seven_day.is_none() && scoped.is_empty() {
return None;
}
Some(Quota {
five_hour,
seven_day,
scoped,
extra,
})
}
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(_)));
}
}
pub fn latest_published() -> Option<String> {
let url = std::env::var("SWAPDEX_INDEX_URL")
.unwrap_or_else(|_| "https://index.crates.io/sw/ap/swapdex".to_string());
let (body, status) = run_curl_cfg(&format!(
"url = \"{url}\"\nmax-time = 8\nsilent\nwrite-out = \"\\n%{{http_code}}\"\n"
))
.ok()?;
if status != 200 {
return None;
}
body.lines()
.rev()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.find(|v| !v["yanked"].as_bool().unwrap_or(false))
.and_then(|v| v["vers"].as_str().map(str::to_string))
}
pub fn is_behind(running: &str, latest: &str) -> bool {
fn parts(v: &str) -> Vec<u64> {
v.split(['.', '-', '+'])
.map_while(|p| p.parse::<u64>().ok())
.collect()
}
let (a, b) = (parts(running), parts(latest));
if a.is_empty() || b.is_empty() {
return false;
}
for i in 0..a.len().max(b.len()) {
let (x, y) = (
a.get(i).copied().unwrap_or(0),
b.get(i).copied().unwrap_or(0),
);
if x != y {
return x < y;
}
}
false
}
#[cfg(test)]
mod version_tests {
use super::*;
#[test]
fn a_lower_version_is_behind_and_an_equal_one_is_not() {
assert!(is_behind("0.34.1", "0.35.0"));
assert!(is_behind("0.35.0", "0.35.1"));
assert!(!is_behind("0.35.0", "0.35.0"));
assert!(
!is_behind("0.36.0", "0.35.0"),
"ahead of the registry is fine"
);
}
#[test]
fn numbers_are_compared_as_numbers() {
assert!(is_behind("0.9.0", "0.35.0"));
assert!(!is_behind("0.35.0", "0.9.0"));
}
#[test]
fn an_unreadable_version_is_never_reported_as_behind() {
assert!(!is_behind("", "0.35.0"));
assert!(!is_behind("0.35.0", "unknown"));
}
}
#[cfg(test)]
mod extra_usage_tests {
use super::*;
const REAL: &str = r#"{
"five_hour": {"utilization": 100.0, "resets_at": 1785900000},
"seven_day": {"utilization": 55.0, "resets_at": 1786300000},
"extra_usage": {"is_enabled": true, "used_credits": 1121.0,
"monthly_limit": 50000, "utilization": 2.242,
"spend_limit_reached": false}
}"#;
#[test]
fn a_capped_window_with_credits_left_can_still_serve() {
let q = parse(REAL).expect("parsed");
let x = q.extra.expect("extra usage read");
assert!(x.enabled);
assert!(!x.limit_reached);
assert!(
q.can_serve_past_windows(),
"credits are available, so the account is not out"
);
}
#[test]
fn without_extra_usage_a_capped_window_really_is_the_end() {
let body = r#"{"five_hour": {"utilization": 100.0},
"extra_usage": {"is_enabled": false}}"#;
let q = parse(body).expect("parsed");
assert!(!q.can_serve_past_windows());
}
#[test]
fn a_reached_spend_limit_is_not_a_way_through() {
let body = r#"{"five_hour": {"utilization": 100.0},
"extra_usage": {"is_enabled": true, "spend_limit_reached": true}}"#;
let q = parse(body).expect("parsed");
assert!(!q.can_serve_past_windows());
}
#[test]
fn silence_is_not_permission() {
let q = parse(r#"{"five_hour": {"utilization": 100.0}}"#).expect("parsed");
assert!(q.extra.is_none());
assert!(!q.can_serve_past_windows());
}
}