use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use serde_json::Value;
use crate::model::{Account, CredentialSource, Snapshot, Status, Unit, Window, WindowKind};
use crate::providers::{FetchError, Provider};
const CLIENT_ID: &str = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
const CLIENT_SECRET: &str = "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";
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
const ANTIGRAVITY_UA: &str = "vscode/1.100.0 (Antigravity/1.107.0)";
const HOSTS: [&str; 3] = [
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://daily-cloudcode-pa.googleapis.com",
"https://cloudcode-pa.googleapis.com",
];
pub struct Antigravity;
impl Provider for Antigravity {
fn id(&self) -> &'static str {
"antigravity"
}
fn discover(&self) -> Vec<Account> {
let path = std::env::home_dir()
.unwrap_or_default()
.join(".gemini")
.join("antigravity-cli")
.join("antigravity-oauth-token");
if !path.exists() {
return vec![];
}
vec![Account {
provider: "antigravity",
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 creds: Value = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.ok_or(FetchError::AuthMissing)?;
let refresh = creds["token"]["refresh_token"]
.as_str()
.ok_or(FetchError::AuthMissing)?;
let access = refresh_token(refresh)?;
let agent = build_agent();
let (project_id, plan, host) = load_code_assist(&agent, &access);
let summary = retrieve_quota_summary(&agent, &access, project_id.as_deref(), host)?;
Ok(Snapshot {
provider: "antigravity".into(),
account: acct.id.clone(),
label: acct.label.clone(),
plan,
windows: parse_quota_summary(&summary),
fetched_at: Utc::now(),
status: Status::Ok,
error: None,
raw: Some(summary),
})
}
}
fn build_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(3))
.timeout(Duration::from_secs(8))
.build()
}
fn refresh_token(refresh: &str) -> Result<String, FetchError> {
let agent = build_agent();
let (ok, body) = match agent
.post(TOKEN_URL)
.set("User-Agent", ANTIGRAVITY_UA)
.send_form(&[
("client_id", CLIENT_ID),
("client_secret", CLIENT_SECRET),
("refresh_token", refresh),
("grant_type", "refresh_token"),
]) {
Ok(r) => (
true,
r.into_string()
.map_err(|e| FetchError::Network(e.to_string()))?,
),
Err(ureq::Error::Status(_, r)) => (false, r.into_string().unwrap_or_default()),
Err(ureq::Error::Transport(t)) => return Err(FetchError::Network(t.to_string())),
};
if !ok || body.contains("invalid_grant") {
return Err(FetchError::AuthMissing);
}
serde_json::from_str::<Value>(&body)
.ok()
.and_then(|j| j["access_token"].as_str().map(str::to_string))
.ok_or(FetchError::AuthMissing)
}
fn post_json(agent: &ureq::Agent, url: &str, token: &str, body: &Value) -> Option<(u16, String)> {
let req = agent
.post(url)
.set("Authorization", &format!("Bearer {token}"))
.set("Content-Type", "application/json")
.set("User-Agent", ANTIGRAVITY_UA);
match req.send_string(&body.to_string()) {
Ok(r) => {
let code = r.status();
let s = r.into_string().ok()?;
Some((code, s))
}
Err(ureq::Error::Status(code, r)) => Some((code, r.into_string().unwrap_or_default())),
Err(ureq::Error::Transport(_)) => None,
}
}
fn sweep<'a>(
agent: &ureq::Agent,
hosts: &[&'a str],
path: &str,
token: &str,
body: &Value,
) -> Option<(&'a str, u16, String)> {
for &host in hosts {
match post_json(agent, &format!("{host}{path}"), token, body) {
Some((code, _)) if code == 429 || (500..600).contains(&code) => continue,
Some((code, s)) => return Some((host, code, s)),
None => continue,
}
}
None
}
fn load_code_assist(
agent: &ureq::Agent,
token: &str,
) -> (Option<String>, Option<String>, Option<&'static str>) {
let body = serde_json::json!({ "metadata": { "ideType": "ANTIGRAVITY" } });
match sweep(agent, &HOSTS, "/v1internal:loadCodeAssist", token, &body) {
Some((host, 200, s)) => {
let json: Value = serde_json::from_str(&s).unwrap_or(Value::Null);
(
project_id_from_load(&json),
plan_from_load(&json),
Some(host),
)
}
_ => (None, None, None),
}
}
fn project_id_from_load(v: &Value) -> Option<String> {
let p = &v["cloudaicompanionProject"];
if let Some(s) = p.as_str() {
return Some(s.to_string());
}
p["id"].as_str().map(str::to_string)
}
fn plan_from_load(v: &Value) -> Option<String> {
let tier = &v["currentTier"];
tier["name"]
.as_str()
.or_else(|| tier["id"].as_str())
.map(str::to_string)
}
fn retrieve_quota_summary(
agent: &ureq::Agent,
token: &str,
project_id: Option<&str>,
preferred: Option<&'static str>,
) -> Result<Value, FetchError> {
let hosts = ordered_hosts(preferred);
let path = "/v1internal:retrieveUserQuotaSummary";
let body = match project_id {
Some(p) => serde_json::json!({ "project": p }),
None => serde_json::json!({}),
};
let (host, code, body_str) = sweep(agent, &hosts, path, token, &body)
.ok_or_else(|| FetchError::Network("all quota hosts exhausted".into()))?;
match code {
401 => Err(FetchError::AuthMissing),
403 if project_id.is_some() => {
match post_json(
agent,
&format!("{host}{path}"),
token,
&serde_json::json!({}),
) {
Some((200..=299, s)) => parse_summary(&s),
_ => Err(FetchError::AuthMissing),
}
}
403 => Err(FetchError::AuthMissing),
200..=299 => parse_summary(&body_str),
other => Err(FetchError::Parse(format!("http {other}"))),
}
}
fn ordered_hosts(preferred: Option<&'static str>) -> Vec<&'static str> {
match preferred {
Some(h) => {
let mut v = vec![h];
v.extend(HOSTS.iter().copied().filter(|&x| x != h));
v
}
None => HOSTS.to_vec(),
}
}
fn parse_summary(body: &str) -> Result<Value, FetchError> {
serde_json::from_str(body).map_err(|e| FetchError::Parse(e.to_string()))
}
fn parse_quota_summary(raw: &Value) -> Vec<Window> {
let Some(groups) = raw.get("groups").and_then(Value::as_array) else {
return vec![];
};
let mut windows: Vec<Window> = Vec::new();
for group in groups {
let Some(buckets) = group.get("buckets").and_then(Value::as_array) else {
continue;
};
for bucket in buckets {
let Some(frac) = bucket["remainingFraction"].as_f64() else {
continue;
};
let used_pct = (1.0 - frac) * 100.0;
let bucket_id = bucket["bucketId"].as_str().unwrap_or_default();
let slug = bucket_id
.rsplit_once('-')
.map_or(bucket_id, |(head, _)| head);
let key = match bucket["window"].as_str().unwrap_or_default() {
"5h" => format!("5h:{slug}"),
"weekly" => format!("7d:{slug}"),
other => format!("{other}:{slug}"),
};
windows.push(Window {
key,
kind: WindowKind::Rolling,
unit: Unit::Percent,
used_pct,
used: None,
limit: None,
unlimited: false,
resets_at: parse_reset(&bucket["resetTime"]),
});
}
}
windows.sort_by(|a, b| a.key.cmp(&b.key));
windows
}
fn parse_reset(v: &Value) -> Option<DateTime<Utc>> {
if let Some(s) = v.as_str() {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(secs) = s.parse::<i64>() {
return Utc.timestamp_opt(secs, 0).single();
}
return None;
}
v.as_i64()
.and_then(|secs| Utc.timestamp_opt(secs, 0).single())
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/antigravity_quota_summary.json"
));
fn fixture() -> Value {
serde_json::from_str(FIXTURE).unwrap()
}
#[test]
fn parses_quota_summary_fixture() {
let w = parse_quota_summary(&fixture());
assert_eq!(w.len(), 3);
let keys: Vec<&str> = w.iter().map(|x| x.key.as_str()).collect();
assert_eq!(keys, ["5h:gemini", "7d:3p", "7d:gemini"]);
let g5 = w.iter().find(|x| x.key == "5h:gemini").unwrap();
assert!((g5.used_pct - 10.0).abs() < 1e-9);
assert_eq!(g5.kind, WindowKind::Rolling);
assert_eq!(g5.unit, Unit::Percent);
assert!(g5.resets_at.is_some());
assert_eq!(g5.used, None);
assert_eq!(g5.limit, None);
assert!(!g5.unlimited);
let gw = w.iter().find(|x| x.key == "7d:gemini").unwrap();
assert_eq!(gw.used_pct, 25.0);
let tp = w.iter().find(|x| x.key == "7d:3p").unwrap();
assert_eq!(tp.used_pct, 0.0);
assert!(w.iter().all(|x| x.key != "5h:3p"));
}
#[test]
fn skips_bucket_missing_fraction() {
let v = serde_json::json!({ "groups": [ { "buckets": [
{ "bucketId": "a-5h", "window": "5h", "remainingFraction": 0.5, "resetTime": "2026-07-19T05:00:00Z" },
{ "bucketId": "b-5h", "window": "5h", "resetTime": "2026-07-19T05:00:00Z" }
] } ] });
let w = parse_quota_summary(&v);
assert_eq!(w.len(), 1);
assert_eq!(w[0].key, "5h:a");
}
#[test]
fn resets_at_accepts_epoch_seconds() {
let v = serde_json::json!({ "groups": [ { "buckets": [
{ "bucketId": "x-5h", "window": "5h", "remainingFraction": 0.5, "resetTime": 1784500000 }
] } ] });
let w = parse_quota_summary(&v);
assert_eq!(w.len(), 1);
assert!(w[0].resets_at.is_some());
}
#[test]
fn plan_from_load_uses_current_not_paid_tier() {
let v = serde_json::json!({
"currentTier": { "name": "Antigravity", "id": "free-tier" },
"paidTier": { "name": "Google AI Pro" }
});
assert_eq!(plan_from_load(&v), Some("Antigravity".to_string()));
}
}