Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 106 additions & 10 deletions src/poller/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,25 @@ use serde::Deserialize;

use super::{build_agent, parse_iso8601, windows_credentials, PollError};
use crate::diagnose;
use crate::models::{UsageData, UsageSection};
use crate::models::{limit_slug, UsageData, UsageLimit, UsageSection};

const ANTIGRAVITY_CREDENTIAL_TARGET: &str = "gemini:antigravity";
const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
const EXPIRY_SKEW: Duration = Duration::from_secs(60);
// The server returns the "Gemini 4 Argon" group only for this UA form, and
// only from the daily endpoint; old CLI versions are rejected.
const ANTIGRAVITY_USER_AGENT: &str = "antigravity/cli/1.4.0";
// The daily endpoint goes first: official agy CLI queries daily-cloudcode-pa,
// which returns accurate quota for Gemini, Claude and GPT, and Gemini 4 Argon.
// Production cloudcode-pa returns static remainingFraction=1.0 and
// resetTime=now+window for Gemini, but is kept as a fallback and to
// supplement any groups daily might miss.
const ANTIGRAVITY_DAILY_ENDPOINT: &str = "https://daily-cloudcode-pa.googleapis.com";
const ANTIGRAVITY_PRODUCTION_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com";
const ANTIGRAVITY_ENDPOINTS: &[&str] = &[
"https://daily-cloudcode-pa.googleapis.com",
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_PRODUCTION_ENDPOINT,
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
];

#[derive(Deserialize)]
Expand Down Expand Up @@ -319,7 +329,22 @@ pub(super) fn fetch_antigravity_usage(token: &str) -> Result<UsageData, PollErro

for base_url in ANTIGRAVITY_ENDPOINTS {
match fetch_antigravity_usage_from_endpoint(base_url, token) {
Ok(data) => return Ok(data),
Ok(mut data) => {
if *base_url == ANTIGRAVITY_DAILY_ENDPOINT {
// Daily is the primary endpoint matching official agy CLI.
// Supplement any groups only present in production.
match fetch_antigravity_usage_from_endpoint(
ANTIGRAVITY_PRODUCTION_ENDPOINT,
token,
) {
Ok(prod) => merge_antigravity_limits(&mut data.limits, prod.limits),
Err(error) => diagnose::log(format!(
"Antigravity production endpoint failed, skipping its extra groups: {error:?}"
)),
}
}
return Ok(data);
}
Err(PollError::AuthRequired) => auth_error = true,
Err(error) => last_error = error,
}
Expand Down Expand Up @@ -376,7 +401,7 @@ pub(super) fn fetch_antigravity_project(
.post(&format!("{base_url}/v1internal:loadCodeAssist"))
.header("Authorization", &format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("User-Agent", "antigravity")
.header("User-Agent", ANTIGRAVITY_USER_AGENT)
.send_json(&body)
.and_then(super::check_http_status)
{
Expand Down Expand Up @@ -419,7 +444,7 @@ pub(super) fn fetch_antigravity_model_quota(
.post(&format!("{base_url}/v1internal:fetchAvailableModels"))
.header("Authorization", &format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("User-Agent", "antigravity")
.header("User-Agent", ANTIGRAVITY_USER_AGENT)
.send_json(&body)
.and_then(super::check_http_status)
{
Expand Down Expand Up @@ -469,7 +494,7 @@ pub(super) fn fetch_antigravity_quota_summary(
.post(&format!("{base_url}/v1internal:retrieveUserQuotaSummary"))
.header("Authorization", &format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("User-Agent", "antigravity")
.header("User-Agent", ANTIGRAVITY_USER_AGENT)
.send_json(&body)
.and_then(super::check_http_status)
{
Expand Down Expand Up @@ -520,22 +545,76 @@ pub(super) fn antigravity_section_from_summary_bucket(
pub(super) fn antigravity_usage_from_summary(
response: AntigravityQuotaSummaryResponse,
) -> Option<UsageData> {
let groups = response.groups.unwrap_or_default();
let limits = antigravity_limits_from_summary_groups(&groups);
let mut primary = None;
let mut fallback = None;

for group in response.groups.unwrap_or_default() {
for group in groups {
let is_gemini = is_antigravity_gemini_summary_group(&group);
let usage = antigravity_usage_from_summary_group(group);

if is_gemini && usage.is_some() {
return usage;
primary = usage;
break;
}

if fallback.is_none() {
fallback = usage;
}
}

fallback
let mut usage = primary.or(fallback)?;
usage.limits = limits;
Some(usage)
}

// The primary 5h/weekly pair shows only one group; themes need every group
// (e.g. "Claude and GPT", "Gemini 4 Argon") to show per-group quota.
pub(super) fn antigravity_limits_from_summary_groups(
groups: &[AntigravityQuotaSummaryGroup],
) -> Vec<UsageLimit> {
let mut limits = Vec::new();
for group in groups {
let Some(name) = group
.display_name
.as_deref()
.filter(|name| !name.trim().is_empty())
else {
continue;
};
for bucket in group.buckets.iter().flatten() {
let kind = match bucket.window.as_deref() {
Some(window) if window.eq_ignore_ascii_case("5h") => "session",
Some(window) if window.eq_ignore_ascii_case("weekly") => "weekly",
_ => continue,
};
let Some(usage) = antigravity_section_from_summary_bucket(bucket) else {
continue;
};
limits.push(UsageLimit {
key: format!("{}_{kind}", limit_slug(name)),
kind: kind.to_string(),
label: name.to_string(),
model: Some(name.to_string()),
model_id: bucket.bucket_id.clone(),
scope: None,
is_active: false,
usage,
});
}
}
limits
}

// Adds the groups only `extra` has; a group both sides know keeps `base`'s buckets.
pub(super) fn merge_antigravity_limits(base: &mut Vec<UsageLimit>, extra: Vec<UsageLimit>) {
let known: Vec<Option<String>> = base.iter().map(|limit| limit.model.clone()).collect();
base.extend(
extra
.into_iter()
.filter(|limit| !known.contains(&limit.model)),
);
}

pub(super) fn antigravity_usage_from_summary_group(
Expand Down Expand Up @@ -633,6 +712,23 @@ mod auth_tests {
}
}

#[test]
fn daily_endpoint_is_tried_before_production() {
assert_eq!(
ANTIGRAVITY_ENDPOINTS.first(),
Some(&"https://daily-cloudcode-pa.googleapis.com")
);
}

#[test]
fn user_agent_uses_the_cli_form_that_unlocks_argon() {
assert!(ANTIGRAVITY_USER_AGENT.starts_with("antigravity/cli/"));
assert_eq!(
ANTIGRAVITY_ENDPOINTS.get(1),
Some(&ANTIGRAVITY_PRODUCTION_ENDPOINT)
);
}

#[test]
fn valid_token_uses_existing_request() {
let calls = Cell::new(0);
Expand Down
117 changes: 117 additions & 0 deletions src/poller/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,123 @@ fn antigravity_summary_prefers_gemini_group() {
assert!(usage.session.resets_at.is_some());
}

#[test]
fn antigravity_summary_exposes_every_group_as_limits() {
let response: antigravity::AntigravityQuotaSummaryResponse = serde_json::from_str(
r#"{
"groups": [
{
"displayName": "Gemini Models",
"buckets": [
{"bucketId": "gemini-5h", "window": "5h", "remainingFraction": 0.75},
{"bucketId": "gemini-weekly", "window": "weekly", "remainingFraction": 0.5}
]
},
{
"displayName": "Claude and GPT",
"buckets": [
{"bucketId": "3p-5h", "window": "5h", "remainingFraction": 1},
{"bucketId": "3p-weekly", "window": "weekly", "remainingFraction": 0}
]
},
{
"displayName": "Gemini 4 Argon",
"buckets": [
{"bucketId": "argon-weekly", "window": "weekly", "remainingFraction": 0.9},
{"bucketId": "argon-daily", "window": "daily", "remainingFraction": 0.1}
]
}
]
}"#,
)
.expect("summary response should deserialize");

let usage = antigravity::antigravity_usage_from_summary(response)
.expect("Gemini quota should be selected");

assert_eq!(usage.session.percentage, 25.0);
assert_eq!(usage.weekly.percentage, 50.0);
let expected = [
("gemini_models_session", 25.0),
("gemini_models_weekly", 50.0),
("claude_and_gpt_session", 0.0),
("claude_and_gpt_weekly", 100.0),
("gemini_4_argon_weekly", 10.0),
];
assert_eq!(usage.limits.len(), expected.len());
for (limit, (key, percentage)) in usage.limits.iter().zip(expected) {
assert_eq!(limit.key, key);
assert!((limit.usage.percentage - percentage).abs() < 0.000001);
}
}

fn antigravity_limits_from_json(json: &str) -> Vec<crate::models::UsageLimit> {
let response: antigravity::AntigravityQuotaSummaryResponse =
serde_json::from_str(json).expect("summary response should deserialize");
antigravity::antigravity_usage_from_summary(response)
.expect("summary should have quota")
.limits
}

const ANTIGRAVITY_PRODUCTION_SUMMARY: &str = r#"{"groups": [
{"displayName": "Gemini Models", "buckets": [
{"bucketId": "gemini-5h", "window": "5h", "remainingFraction": 1},
{"bucketId": "gemini-weekly", "window": "weekly", "remainingFraction": 1}]},
{"displayName": "Claude and GPT models", "buckets": [
{"bucketId": "3p-weekly", "window": "weekly", "remainingFraction": 0.5}]}
]}"#;

const ANTIGRAVITY_DAILY_SUMMARY: &str = r#"{"groups": [
{"displayName": "Gemini Models", "buckets": [
{"bucketId": "gemini-5h", "window": "5h", "remainingFraction": 0.7},
{"bucketId": "gemini-weekly", "window": "weekly", "remainingFraction": 0.9}]},
{"displayName": "Claude and GPT models", "buckets": [
{"bucketId": "3p-weekly", "window": "weekly", "remainingFraction": 0.1}]},
{"displayName": "Gemini 4 Argon", "buckets": [
{"bucketId": "gemini-argon-wtu-weekly", "window": "weekly", "remainingFraction": 0.816}]}
]}"#;

#[test]
fn antigravity_merge_prefers_daily_and_adds_production_only_groups() {
let mut limits = antigravity_limits_from_json(ANTIGRAVITY_DAILY_SUMMARY);
antigravity::merge_antigravity_limits(
&mut limits,
antigravity_limits_from_json(ANTIGRAVITY_PRODUCTION_SUMMARY),
);

let percentage = |key: &str| {
limits
.iter()
.find(|limit| limit.key == key)
.unwrap_or_else(|| panic!("missing {key}"))
.usage
.percentage
};
assert_eq!(limits.len(), 4);
assert!((percentage("gemini_models_session") - 30.0).abs() < 0.000001);
assert!((percentage("gemini_models_weekly") - 10.0).abs() < 0.000001);
assert!((percentage("claude_and_gpt_models_weekly") - 90.0).abs() < 0.000001);
assert!((percentage("gemini_4_argon_weekly") - 18.4).abs() < 0.000001);
}

#[test]
fn antigravity_merge_with_failed_endpoint_keeps_the_other_side() {
let production = antigravity_limits_from_json(ANTIGRAVITY_PRODUCTION_SUMMARY);

let mut only_production = production.clone();
antigravity::merge_antigravity_limits(&mut only_production, Vec::new());
assert_eq!(only_production.len(), production.len());

let mut only_daily = Vec::new();
antigravity::merge_antigravity_limits(
&mut only_daily,
antigravity_limits_from_json(ANTIGRAVITY_DAILY_SUMMARY),
);
assert!(only_daily
.iter()
.any(|limit| limit.key == "gemini_4_argon_weekly"));
}

#[test]
fn one_provider_failing_does_not_blank_its_row() {
let previous: AppUsageData = [
Expand Down