use std::collections::hash_map::DefaultHasher;
use std::env;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use serde_json::Value;
use crate::copilot_common::resolve_github_token;
use crate::quota::{parse_anthropic_usage, Cooldown, LimitChecker, QuotaSnapshot};
use crate::types::RunnerError;
const ANTHROPIC_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
const CACHE_MAX_AGE: Duration = Duration::from_hours(6);
const COOLDOWN_MAX: Duration = Duration::from_hours(6);
fn fingerprint(secret: &str) -> String {
let mut h = DefaultHasher::new();
secret.hash(&mut h);
format!("{:016x}", h.finish())
}
#[derive(Default)]
struct CheckerState {
last_good: Option<(SystemTime, Vec<QuotaSnapshot>)>,
cooldown: Option<Cooldown>,
}
pub struct AnthropicUsageChecker {
client: reqwest::Client,
token: String,
url: String,
state: Mutex<CheckerState>,
}
impl AnthropicUsageChecker {
#[must_use]
pub fn new(token: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
token: token.into(),
url: ANTHROPIC_USAGE_URL.to_owned(),
state: Mutex::new(CheckerState::default()),
}
}
#[must_use]
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.url = url.into();
self
}
#[must_use]
pub fn from_env() -> Option<Self> {
env::var("CLAUDE_CODE_OAUTH_TOKEN")
.ok()
.filter(|t| !t.trim().is_empty())
.map(Self::new)
}
}
fn cooldown_for(retry_after: Option<u64>) -> Duration {
let stated = retry_after.map_or(Duration::from_mins(1), Duration::from_secs);
stated.clamp(Duration::from_mins(1), COOLDOWN_MAX)
}
#[async_trait]
impl LimitChecker for AnthropicUsageChecker {
fn name(&self) -> &str {
"anthropic-usage"
}
async fn check(&self) -> Result<Vec<QuotaSnapshot>, RunnerError> {
let fp = fingerprint(&self.token);
let now = SystemTime::now();
if let Ok(state) = self.state.lock() {
if let Some(cd) = &state.cooldown {
if cd.blocks(now, Some(&fp)) {
return serve_cache(&state, now).ok_or_else(|| {
RunnerError::rate_limit(
"anthropic-usage",
"usage endpoint is in cooldown and no fresh-enough reading is cached",
)
});
}
}
}
let response = self
.client
.get(&self.url)
.header("Authorization", format!("Bearer {}", self.token))
.header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
.timeout(Duration::from_secs(15))
.send()
.await
.map_err(|e| RunnerError::external_service("anthropic-usage", e.to_string()))?;
let status = response.status();
if status.as_u16() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok());
if let Ok(mut state) = self.state.lock() {
state.cooldown = Some(Cooldown::starting_at(
now,
cooldown_for(retry_after),
Some(fp),
));
}
return Err(RunnerError::rate_limit(
"anthropic-usage",
"usage endpoint refused (429) — this can also mean the OAuth token has expired",
));
}
if !status.is_success() {
return Err(RunnerError::external_service(
"anthropic-usage",
format!("usage endpoint returned HTTP {status}"),
));
}
let body: Value = response
.json()
.await
.map_err(|e| RunnerError::external_service("anthropic-usage", e.to_string()))?;
let snapshots = parse_anthropic_usage(&body, now)?;
if let Ok(mut state) = self.state.lock() {
state.cooldown = None;
state.last_good = Some((now, snapshots.clone()));
}
Ok(snapshots)
}
}
fn serve_cache(state: &CheckerState, now: SystemTime) -> Option<Vec<QuotaSnapshot>> {
let (taken_at, snapshots) = state.last_good.as_ref()?;
let age = now.duration_since(*taken_at).ok()?;
(age <= CACHE_MAX_AGE).then(|| snapshots.clone())
}
pub struct GithubHeadroomChecker {
client: reqwest::Client,
token: String,
url: String,
}
impl GithubHeadroomChecker {
#[must_use]
pub fn new(token: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
token: token.into(),
url: "https://api.github.com/rate_limit".to_owned(),
}
}
#[must_use]
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.url = url.into();
self
}
#[must_use]
pub fn from_env() -> Option<Self> {
resolve_github_token()
.filter(|t| !t.trim().is_empty())
.map(Self::new)
}
pub async fn rate_limit(&self) -> Result<GithubRateLimit, RunnerError> {
let response = self
.client
.get(&self.url)
.header("Authorization", format!("token {}", self.token))
.header("User-Agent", "embacle-quota")
.timeout(Duration::from_secs(15))
.send()
.await
.map_err(|e| RunnerError::external_service("github-headroom", e.to_string()))?;
if !response.status().is_success() {
return Err(RunnerError::external_service(
"github-headroom",
format!("rate_limit returned HTTP {}", response.status()),
));
}
let body: Value = response
.json()
.await
.map_err(|e| RunnerError::external_service("github-headroom", e.to_string()))?;
parse_github_rate_limit_counts(&body)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GithubRateLimit {
pub remaining: u64,
pub limit: u64,
pub used: u64,
pub resets_at: SystemTime,
}
impl GithubRateLimit {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn percent_used(&self) -> f32 {
if self.limit == 0 {
100.0
} else {
(self.used as f32 / self.limit as f32) * 100.0
}
}
}
pub fn parse_github_rate_limit_counts(body: &Value) -> Result<GithubRateLimit, RunnerError> {
let core = body
.get("resources")
.and_then(|r| r.get("core"))
.ok_or_else(|| {
RunnerError::external_service(
"github-headroom",
"rate_limit response carried no resources.core",
)
})?;
let limit = core.get("limit").and_then(Value::as_u64).unwrap_or(0);
let used = core.get("used").and_then(Value::as_u64).unwrap_or(0);
let remaining = core
.get("remaining")
.and_then(Value::as_u64)
.unwrap_or_else(|| limit.saturating_sub(used));
let reset = core.get("reset").and_then(Value::as_u64).ok_or_else(|| {
RunnerError::external_service("github-headroom", "rate_limit core carried no reset")
})?;
Ok(GithubRateLimit {
remaining,
limit,
used,
resets_at: UNIX_EPOCH + Duration::from_secs(reset),
})
}
pub fn parse_github_rate_limit(
body: &Value,
observed_at: SystemTime,
) -> Result<Vec<QuotaSnapshot>, RunnerError> {
let counts = parse_github_rate_limit_counts(body)?;
Ok(vec![github_core_snapshot(&counts, observed_at)])
}
fn github_core_snapshot(counts: &GithubRateLimit, observed_at: SystemTime) -> QuotaSnapshot {
QuotaSnapshot {
key: "github_core".to_owned(),
label: "GitHub core rate limit (Copilot proxy)".to_owned(),
percent: counts.percent_used(),
resets_at: counts.resets_at,
observed_at,
}
}
#[async_trait]
impl LimitChecker for GithubHeadroomChecker {
fn name(&self) -> &str {
"github-headroom"
}
async fn check(&self) -> Result<Vec<QuotaSnapshot>, RunnerError> {
let counts = self.rate_limit().await?;
Ok(vec![github_core_snapshot(&counts, SystemTime::now())])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(epoch: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(epoch)
}
#[test]
fn headroom_reports_the_share_consumed() {
let body = serde_json::json!({"resources":{"core":{
"limit":5000,"used":1250,"remaining":3750,"reset":1_788_955_723_u64}}});
let out = parse_github_rate_limit(&body, at(0)).unwrap(); assert_eq!(out.len(), 1);
assert_eq!(out[0].key, "github_core");
assert!((out[0].percent - 25.0).abs() < f32::EPSILON);
assert_eq!(out[0].resets_at, at(1_788_955_723));
}
#[test]
fn the_proxy_says_so_in_its_own_label() {
let body = serde_json::json!({"resources":{"core":{
"limit":5000,"used":0,"reset":1_u64}}});
let out = parse_github_rate_limit(&body, at(0)).unwrap(); assert!(
out[0].label.contains("proxy"),
"label was {:?}",
out[0].label
);
}
#[test]
fn a_zero_limit_reads_as_exhausted_not_as_infinite() {
let body = serde_json::json!({"resources":{"core":{
"limit":0,"used":0,"reset":1_u64}}});
let out = parse_github_rate_limit(&body, at(0)).unwrap(); assert!((out[0].percent - 100.0).abs() < f32::EPSILON);
}
#[test]
fn a_shape_change_is_an_error_not_full_headroom() {
assert!(parse_github_rate_limit(&serde_json::json!({}), at(0)).is_err());
let no_reset = serde_json::json!({"resources":{"core":{"limit":5000,"used":1}}});
assert!(parse_github_rate_limit(&no_reset, at(0)).is_err());
}
#[test]
fn a_stated_retry_after_is_honoured_in_full() {
assert_eq!(cooldown_for(Some(2_708)), Duration::from_secs(2_708));
}
#[test]
fn a_zero_retry_after_still_waits() {
assert_eq!(cooldown_for(Some(0)), Duration::from_mins(1));
}
#[test]
fn an_absurd_retry_after_hits_the_sanity_rail() {
assert_eq!(cooldown_for(Some(u64::MAX)), COOLDOWN_MAX);
}
#[test]
fn a_fingerprint_is_stable_and_hides_the_secret() {
let fp = fingerprint("sk-secret-value");
assert_eq!(fp, fingerprint("sk-secret-value"));
assert_ne!(fp, fingerprint("sk-other-value"));
assert!(
!fp.contains("secret"),
"the token must not survive into a loggable string"
);
}
}