use crate::ai::oauth::{self, Service};
use crate::ai::types::AiError;
use serde_json::Value;
use std::io::Read;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const URL: &str = "https://api.anthropic.com/api/oauth/usage";
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Window {
pub used_percent: f32,
pub resets_at: Option<u64>,
}
impl Window {
pub fn resets_in(&self, now: u64) -> Option<Duration> {
self.resets_at.filter(|at| *at > now).map(|at| Duration::from_secs(at - now))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SubscriptionUsage {
pub five_hour: Option<Window>,
pub weekly: Option<Window>,
}
pub fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs())
}
pub fn parse_iso8601(text: &str) -> Option<u64> {
let (date, rest) = text.split_once('T')?;
let mut parts = date.split('-').map(|part| part.parse::<i64>().ok());
let (year, month, day) = (parts.next()??, parts.next()??, parts.next()??);
let clock: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == ':').collect();
let mut hms = clock.split(':').map(|part| part.parse::<i64>().ok());
let (hour, minute, second) = (hms.next()??, hms.next()??, hms.next().flatten().unwrap_or(0));
let zone = &rest[clock.len()..];
let zone = zone.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
let offset = match zone.as_bytes().first() {
Some(b'+' | b'-') => {
let sign = if zone.starts_with('-') { -1 } else { 1 };
let mut pieces = zone[1..].split(':').map(|part| part.parse::<i64>().ok());
sign * (pieces.next().flatten().unwrap_or(0) * 3600 + pieces.next().flatten().unwrap_or(0) * 60)
}
_ => 0,
};
let year = if month <= 2 { year - 1 } else { year };
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
let days = era * 146_097 + day_of_era - 719_468;
u64::try_from(days * 86_400 + hour * 3600 + minute * 60 + second - offset).ok()
}
fn window(value: &Value) -> Option<Window> {
let used = value["utilization"].as_f64()? as f32;
Some(Window {
used_percent: used.clamp(0.0, 100.0),
resets_at: value["resets_at"].as_str().and_then(parse_iso8601),
})
}
pub fn parse(body: &str) -> Result<SubscriptionUsage, AiError> {
let value: Value = serde_json::from_str(body).map_err(|error| AiError::Decode(error.to_string()))?;
let usage = SubscriptionUsage { five_hour: window(&value["five_hour"]), weekly: window(&value["seven_day"]) };
if usage == SubscriptionUsage::default() {
return Err(AiError::Decode("la respuesta no trae los límites de uso".into()));
}
Ok(usage)
}
pub fn fetch_claude() -> Result<SubscriptionUsage, AiError> {
let session = oauth::fresh(Service::Claude)?;
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(10)))
.http_status_as_error(false)
.build()
.into();
let mut request = agent.get(URL);
for (name, value) in crate::ai::anthropic::subscription_headers(&session.access) {
request = request.header(name, value);
}
let response = request.call().map_err(|error| AiError::Transport(error.to_string()))?;
let code = response.status().as_u16();
let mut text = String::new();
response
.into_body()
.into_reader()
.take(200_000)
.read_to_string(&mut text)
.map_err(|error| AiError::Transport(error.to_string()))?;
if code >= 400 {
return Err(AiError::Status { code, body: text.chars().take(300).collect() });
}
parse(&text)
}
fn window_json(window: Option<Window>) -> Value {
window.map_or(Value::Null, |w| serde_json::json!({ "used": w.used_percent, "resets_at": w.resets_at }))
}
fn window_from(value: &Value) -> Option<Window> {
Some(Window { used_percent: value["used"].as_f64()? as f32, resets_at: value["resets_at"].as_u64() })
}
impl SubscriptionUsage {
pub fn to_cache(&self, fetched_at: u64) -> String {
serde_json::json!({
"fetched_at": fetched_at,
"five_hour": window_json(self.five_hour),
"weekly": window_json(self.weekly),
})
.to_string()
}
pub fn from_cache(text: &str) -> Option<(Self, u64)> {
let value: Value = serde_json::from_str(text).ok()?;
let usage = Self { five_hour: window_from(&value["five_hour"]), weekly: window_from(&value["weekly"]) };
Some((usage, value["fetched_at"].as_u64()?))
}
}
pub fn format_duration(duration: Duration) -> String {
let minutes = duration.as_secs() / 60;
match (minutes / 1440, minutes / 60 % 24, minutes % 60) {
(0, 0, minutes) => format!("{minutes} min"),
(0, hours, minutes) => format!("{hours} h {minutes} min"),
(days, hours, _) => format!("{days} d {hours} h"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fechas_iso_con_y_sin_zona() {
assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_iso8601("2025-11-04T04:59:59.943648+00:00"), Some(1_762_232_399));
assert_eq!(parse_iso8601("2025-11-04T00:59:59-04:00"), Some(1_762_232_399));
assert_eq!(parse_iso8601("2024-02-29T12:00:00Z"), Some(1_709_208_000));
assert_eq!(parse_iso8601("no es fecha"), None);
}
#[test]
fn lee_las_dos_ventanas_y_calcula_lo_que_falta() {
let body = r#"{"five_hour":{"utilization":22.0,"resets_at":"2025-11-04T04:59:59+00:00"},
"seven_day":{"utilization":49.5,"resets_at":null},"seven_day_opus":null}"#;
let usage = parse(body).unwrap();
let five = usage.five_hour.unwrap();
assert_eq!(five.used_percent, 22.0);
assert_eq!(five.resets_in(1_762_232_399 - 7500), Some(Duration::from_secs(7500)));
assert_eq!(five.resets_in(1_762_232_400), None);
assert_eq!(usage.weekly, Some(Window { used_percent: 49.5, resets_at: None }));
assert!(parse(r#"{"error":"x"}"#).is_err());
}
#[test]
fn la_copia_en_disco_ida_y_vuelta() {
let usage = SubscriptionUsage {
five_hour: Some(Window { used_percent: 41.0, resets_at: Some(100) }),
weekly: Some(Window { used_percent: 36.0, resets_at: None }),
};
assert_eq!(SubscriptionUsage::from_cache(&usage.to_cache(7)), Some((usage, 7)));
assert_eq!(SubscriptionUsage::from_cache("{}"), None);
}
#[test]
fn duraciones_cortas() {
assert_eq!(format_duration(Duration::from_secs(45 * 60)), "45 min");
assert_eq!(format_duration(Duration::from_secs(7500)), "2 h 5 min");
assert_eq!(format_duration(Duration::from_secs(3 * 86_400 + 4 * 3600)), "3 d 4 h");
}
}