use std::time::Duration;
use chrono::{DateTime, Utc};
use serde_json::Value;
use crate::model::{Account, CredentialSource, Snapshot, Status, Unit, Window, WindowKind};
use crate::providers::{FetchError, Provider};
const USER_URL: &str = "https://api.github.com/copilot_internal/user";
pub struct Copilot;
impl Provider for Copilot {
fn id(&self) -> &'static str {
"copilot"
}
fn discover(&self) -> Vec<Account> {
let path = std::env::home_dir()
.unwrap_or_default()
.join(".config")
.join("github-copilot")
.join("apps.json");
if !path.exists() {
return vec![];
}
vec![Account {
provider: "copilot",
id: "default".into(),
source: CredentialSource::FilePath(path),
label: None,
display: None,
}]
}
fn fetch(&self, acct: &Account) -> Result<Snapshot, FetchError> {
let path = match &acct.source {
CredentialSource::FilePath(p) => p,
_ => return Err(FetchError::AuthMissing),
};
let apps: Value = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.ok_or(FetchError::AuthMissing)?;
let token = oauth_token(&apps)?;
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(3))
.timeout(Duration::from_secs(8))
.build();
let resp = match agent
.get(USER_URL)
.set("Authorization", &format!("token {token}"))
.set("User-Agent", concat!("quotch/", env!("CARGO_PKG_VERSION")))
.set("Editor-Version", "vscode/1.100.0")
.call()
{
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => {
return Err(match code {
401 | 403 => FetchError::AuthMissing,
429 => FetchError::RateLimited,
other => FetchError::Network(format!("http {other}")),
});
}
Err(ureq::Error::Transport(t)) => return Err(FetchError::Network(t.to_string())),
};
let body = resp
.into_string()
.map_err(|e| FetchError::Network(e.to_string()))?;
let json: Value =
serde_json::from_str(&body).map_err(|e| FetchError::Parse(e.to_string()))?;
Ok(Snapshot {
provider: "copilot".into(),
account: acct.id.clone(),
label: acct.label.clone(),
plan: json["copilot_plan"].as_str().map(str::to_string),
windows: parse_windows(&json),
fetched_at: Utc::now(),
status: Status::Ok,
error: None,
raw: Some(json),
})
}
}
fn oauth_token(apps: &Value) -> Result<String, FetchError> {
apps.as_object()
.and_then(|obj| obj.iter().find(|(k, _)| k.starts_with("github.com")))
.and_then(|(_, v)| v["oauth_token"].as_str())
.map(str::to_string)
.ok_or(FetchError::AuthMissing)
}
fn parse_windows(raw: &Value) -> Vec<Window> {
let Some(snapshots) = raw.get("quota_snapshots").and_then(Value::as_object) else {
return vec![];
};
let resets_at = parse_rfc3339(&raw["quota_reset_date_utc"]).or_else(|| {
raw["quota_reset_date"]
.as_str()
.and_then(|d| DateTime::parse_from_rfc3339(&format!("{d}T00:00:00Z")).ok())
.map(|dt| dt.with_timezone(&Utc))
});
let mut keys: Vec<&String> = snapshots
.iter()
.filter(|(_, v)| v["unlimited"].as_bool() != Some(true))
.map(|(k, _)| k)
.collect();
keys.sort_by(|a, b| {
let rank = |k: &str| usize::from(k != "premium_interactions");
rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
});
keys.into_iter()
.filter_map(|k| snapshot_to_window(k, &snapshots[k], resets_at))
.collect()
}
fn snapshot_to_window(key: &str, snap: &Value, resets_at: Option<DateTime<Utc>>) -> Option<Window> {
let used_pct = (100.0 - snap["percent_remaining"].as_f64()?).max(0.0);
let window_key = if key == "premium_interactions" {
"monthly".to_string()
} else {
format!("monthly:{key}")
};
Some(Window {
key: window_key,
kind: WindowKind::Calendar,
unit: Unit::Requests,
used_pct,
used: snap["credits_used"].as_f64(),
limit: snap["entitlement"].as_f64(),
unlimited: false,
resets_at,
})
}
fn parse_rfc3339(v: &Value) -> Option<DateTime<Utc>> {
v.as_str()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/copilot_user.json"
));
fn fixture() -> Value {
serde_json::from_str(FIXTURE).unwrap()
}
fn expected_reset() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn parses_premium_interactions_window() {
let w = parse_windows(&fixture());
assert_eq!(w.len(), 1);
assert_eq!(w[0].key, "monthly");
assert_eq!(w[0].kind, WindowKind::Calendar);
assert_eq!(w[0].unit, Unit::Requests);
assert_eq!(w[0].used_pct, 11.0);
assert_eq!(w[0].used, Some(2729.0));
assert_eq!(w[0].limit, Some(25000.0));
assert_eq!(w[0].resets_at, Some(expected_reset()));
}
#[test]
fn skips_entry_missing_percent_remaining() {
let v = serde_json::json!({
"quota_reset_date_utc": "2026-08-01T00:00:00.000Z",
"quota_snapshots": {
"premium_interactions": {
"unlimited": false, "percent_remaining": 89.0,
"credits_used": 2729, "entitlement": 25000
},
"weird": { "unlimited": false, "credits_used": 5, "entitlement": 10 }
}
});
let w = parse_windows(&v);
assert_eq!(w.len(), 1);
assert_eq!(w[0].key, "monthly");
}
#[test]
fn all_unlimited_yields_empty() {
let v = serde_json::json!({
"quota_snapshots": {
"chat": { "unlimited": true, "percent_remaining": 100.0 },
"completions": { "unlimited": true, "percent_remaining": 100.0 }
}
});
assert!(parse_windows(&v).is_empty());
}
#[test]
fn used_pct_clamped_at_zero_when_over_full() {
let v = serde_json::json!({
"quota_snapshots": {
"premium_interactions": { "unlimited": false, "percent_remaining": 150.0 }
}
});
let w = parse_windows(&v);
assert_eq!(w.len(), 1);
assert_eq!(w[0].used_pct, 0.0);
}
#[test]
fn falls_back_to_date_only_reset() {
let mut v = fixture();
v.as_object_mut().unwrap().remove("quota_reset_date_utc");
let w = parse_windows(&v);
assert_eq!(w.len(), 1);
assert_eq!(w[0].resets_at, Some(expected_reset()));
}
}