#[cfg(target_os = "macos")]
use std::process::{Command, Stdio};
use std::time::Duration;
use reqwest::{blocking::Client, redirect::Policy};
use serde::Deserialize;
#[cfg(target_os = "macos")]
use crate::{
cancellation::AgentCancellation,
tools::process::{BoundedChildProcessLimits, run_bounded_child_process},
};
use crate::{
http_body::read_bounded_response_text, providers::claude_subscription::cli_subscription_status,
};
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const AUTH_ERROR: &str = "Claude usage authentication failed; run `claude auth login`";
#[derive(Debug, Deserialize)]
struct CredentialFile {
#[serde(rename = "claudeAiOauth")]
oauth: OAuthToken,
}
#[derive(Debug, Deserialize)]
struct OAuthToken {
#[serde(rename = "accessToken")]
access: String,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct ClaudeUsage {
pub(crate) five_hour: Option<ClaudeWindow>,
pub(crate) seven_day: Option<ClaudeWindow>,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct ClaudeWindow {
pub(crate) utilization: f64,
pub(crate) resets_at: Option<String>,
}
pub(crate) fn connected_account() -> Option<[u8; 32]> {
use sha2::{Digest, Sha256};
let status = cli_subscription_status()?;
let email = status.get("email")?.as_str()?.trim();
let org = status
.get("orgId")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if email.is_empty() {
return None;
}
Some(Sha256::digest(format!("{email}\0{org}").as_bytes()).into())
}
pub(crate) fn load_claude_usage() -> Result<ClaudeUsage, String> {
let status = cli_subscription_status().ok_or(AUTH_ERROR)?;
let token = read_cli_token(&status)?;
let client = Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(15))
.redirect(Policy::none())
.build()
.map_err(|_| "Could not create Claude usage HTTP client".to_owned())?;
let response = client
.get(USAGE_URL)
.bearer_auth(&token)
.header("anthropic-beta", "oauth-2025-04-20")
.header("Accept", "application/json")
.header("Cache-Control", "no-cache")
.send()
.map_err(|error| {
if error.is_timeout() {
"Claude usage request timed out"
} else {
"Claude usage request failed"
}
.to_owned()
})?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED
|| response.status() == reqwest::StatusCode::FORBIDDEN
{
return Err(AUTH_ERROR.into());
}
if !response.status().is_success() {
return Err(format!(
"Claude usage request failed (HTTP {})",
response.status().as_u16()
));
}
let body = read_bounded_response_text(response, 256 * 1024).map_err(|_| {
"Could not read Claude usage response within size and time limits".to_owned()
})?;
parse_usage(&body)
}
fn parse_usage(body: &str) -> Result<ClaudeUsage, String> {
let usage: ClaudeUsage =
serde_json::from_str(body).map_err(|_| "Invalid Claude usage response".to_owned())?;
if usage.five_hour.is_none() && usage.seven_day.is_none() {
return Err("Claude usage response has no quota windows".into());
}
if [usage.five_hour.as_ref(), usage.seven_day.as_ref()]
.into_iter()
.flatten()
.any(|window| !window.utilization.is_finite() || window.utilization < 0.0)
{
return Err("Invalid Claude usage percentage".into());
}
Ok(usage)
}
fn read_cli_token(status: &serde_json::Value) -> Result<String, String> {
#[cfg(target_os = "macos")]
{
let mut command = Command::new("security");
command
.args([
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
use std::os::unix::process::CommandExt;
command.process_group(0);
let child = command.spawn().map_err(|_| AUTH_ERROR.to_owned())?;
let output = run_bounded_child_process(
child,
BoundedChildProcessLimits {
stdout_max_bytes: 64 * 1024,
stderr_max_bytes: 1024,
timeout: Duration::from_secs(3),
poll_interval: Duration::from_millis(20),
},
&AgentCancellation::default(),
)
.map_err(|_| AUTH_ERROR.to_owned())?;
if output.timed_out || output.stdout_truncated || output.cleanup_warning.is_some() {
return Err(AUTH_ERROR.into());
}
if output.status.is_some_and(|status| status.success()) {
return parse_token(&output.stdout);
}
if output.status.and_then(|status| status.code()) != Some(44) {
return Err(AUTH_ERROR.into());
}
}
let directory = status
.get("configDirectory")
.and_then(serde_json::Value::as_str)
.filter(|path| !path.is_empty())
.ok_or(AUTH_ERROR)?;
let path = std::path::Path::new(directory).join(".credentials.json");
let metadata = std::fs::symlink_metadata(&path).map_err(|_| AUTH_ERROR.to_owned())?;
if !metadata.is_file() || metadata.len() > 64 * 1024 {
return Err(AUTH_ERROR.into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o077 != 0 {
return Err(AUTH_ERROR.into());
}
}
parse_token(&std::fs::read_to_string(path).map_err(|_| AUTH_ERROR.to_owned())?)
}
fn parse_token(text: &str) -> Result<String, String> {
let credentials: CredentialFile =
serde_json::from_str(text).map_err(|_| AUTH_ERROR.to_owned())?;
let access = credentials.oauth.access;
if access.trim().is_empty() {
return Err(AUTH_ERROR.into());
}
Ok(access)
}
pub(crate) fn quota_label(
usage: &ClaudeUsage,
now: chrono::DateTime<chrono::Utc>,
) -> Option<String> {
let (window, suffix) = usage
.five_hour
.as_ref()
.map(|window| (window, "5h"))
.or_else(|| usage.seven_day.as_ref().map(|window| (window, "w")))?;
let reset = if suffix == "5h" {
window.resets_at.as_deref()
} else {
None
}
.and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok())
.map(|reset| reset.signed_duration_since(now).num_seconds())
.filter(|&seconds| seconds > 0)
.map(|seconds| {
let minutes = (seconds + 59) / 60;
if minutes < 60 {
format!("{minutes}m")
} else {
format!("{}h{}m", minutes / 60, minutes % 60)
}
});
Some(format!(
"Claude {:.0}/100%({})",
window.utilization,
reset.as_deref().unwrap_or(suffix)
))
}