feat(agy): show the signed-in quota on the official card

- Ask agy's own /usage command (agy -p /usage --output-format json) instead of
  reading credentials; it makes no model call and opens no conversation.
- Keep the Gemini group and the Claude/GPT group, each with its 5-hour and
  weekly window, as four tiers; unknown buckets are skipped.
- On the card each group shows only its window with the least left, since
  four tiers do not fit; the expanded view lists all four.
- A missing agy is treated as no login (nothing shown); a timeout propagates
  so the last good value is kept; other failures do not echo agy's output.
This commit is contained in:
Jason committed 2026-10-21 07:26:26 +08:00
1 parent dfe100813f
commit 1b4cd547c4
13 files changed
+365 -15

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ pub mod speedtest;
pub mod sql_helpers;
pub mod stream_check;
pub mod subscription;
pub mod subscription_agy;
pub mod subscription_grok;
pub mod sync_protocol;
pub mod usage_cache;
+1
View File
@@ -1645,6 +1645,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
}
}
"grokbuild" => crate::services::subscription_grok::get_grok_subscription_quota().await,
"agy" => crate::services::subscription_agy::get_agy_subscription_quota().await,
_ => Ok(SubscriptionQuota::not_found(tool)),
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Antigravity CLI 官方登录的剩余额度。
//!
//! 不读登录凭据:凭据在系统钥匙串里,归 agy 自己管。额度直接问 agy 的 `/usage` 命令
//! (`agy -p /usage --output-format json`),这条命令不调模型、不新建会话。
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
const TOOL: &str = "agy";
const TIMEOUT: Duration = Duration::from_secs(30);
pub const TIER_AGY_GEMINI_FIVE_HOUR: &str = "agy_gemini_five_hour";
pub const TIER_AGY_GEMINI_WEEKLY: &str = "agy_gemini_weekly";
pub const TIER_AGY_THIRD_PARTY_FIVE_HOUR: &str = "agy_third_party_five_hour";
pub const TIER_AGY_THIRD_PARTY_WEEKLY: &str = "agy_third_party_weekly";
#[derive(Deserialize)]
struct UsageOutput {
status: Option<String>,
command: Option<UsageCommand>,
}
#[derive(Deserialize)]
struct UsageCommand {
data: Option<UsageData>,
}
#[derive(Deserialize)]
struct UsageData {
#[serde(default)]
groups: Vec<UsageGroup>,
}
#[derive(Deserialize)]
struct UsageGroup {
#[serde(default)]
buckets: Vec<UsageBucket>,
}
#[derive(Deserialize)]
struct UsageBucket {
id: Option<String>,
remaining_fraction: Option<f64>,
reset_time: Option<String>,
}
/// agy 的额度分组:Gemini 一组,Claude 和 GPT 共用一组,每组各有 5 小时和每周两档。
/// 不认识的桶(以后新增的组)跳过,免得显示成没有文案的档。
fn tier_name(bucket_id: &str) -> Option<&'static str> {
match bucket_id {
"gemini-5h" => Some(TIER_AGY_GEMINI_FIVE_HOUR),
"gemini-weekly" => Some(TIER_AGY_GEMINI_WEEKLY),
"3p-5h" => Some(TIER_AGY_THIRD_PARTY_FIVE_HOUR),
"3p-weekly" => Some(TIER_AGY_THIRD_PARTY_WEEKLY),
_ => None,
}
}
fn sort_order(name: &str) -> usize {
match name {
TIER_AGY_GEMINI_FIVE_HOUR => 0,
TIER_AGY_GEMINI_WEEKLY => 1,
TIER_AGY_THIRD_PARTY_FIVE_HOUR => 2,
_ => 3,
}
}
fn parse_usage(stdout: &[u8]) -> Option<Vec<QuotaTier>> {
let output: UsageOutput = serde_json::from_slice(stdout).ok()?;
if output.status.as_deref() != Some("SUCCESS") {
return None;
}
let groups = output.command?.data?.groups;
let mut tiers: Vec<QuotaTier> = groups
.into_iter()
.flat_map(|group| group.buckets)
.filter_map(|bucket| {
let name = tier_name(bucket.id.as_deref()?)?;
let remaining = bucket.remaining_fraction?.clamp(0.0, 1.0);
Some(QuotaTier {
name: name.to_string(),
utilization: (1.0 - remaining) * 100.0,
resets_at: bucket.reset_time,
used_value_usd: None,
max_value_usd: None,
})
})
.collect();
if tiers.is_empty() {
return None;
}
tiers.sort_by_key(|tier| sort_order(&tier.name));
Some(tiers)
}
/// 由 `subscription::get_subscription_quota("agy")` 分发。没装 agy 时按「没有凭据」处理(卡片不显示);
/// 超时、没登录或输出看不懂都写成查询失败,错误里不带 agy 的原始输出。
pub(crate) async fn get_agy_subscription_quota() -> Result<SubscriptionQuota, String> {
let Some(bin) = tokio::task::spawn_blocking(crate::agy_config::real_agy_bin)
.await
.map_err(|e| e.to_string())?
else {
return Ok(SubscriptionQuota::not_found(TOOL));
};
let mut command = tokio::process::Command::new(bin);
command
.args(["--print", "/usage", "--output-format", "json"])
// 只问官方登录:GUI 进程里残留的 Key 会让 agy 改走 Key 模式。
.env_remove("GEMINI_API_KEY")
.env_remove("GOOGLE_GEMINI_BASE_URL")
.stdin(std::process::Stdio::null())
.kill_on_drop(true);
let output = match tokio::time::timeout(TIMEOUT, command.output()).await {
Ok(Ok(output)) => output,
Ok(Err(_)) => return Ok(SubscriptionQuota::not_found(TOOL)),
Err(_) => return Err("Antigravity quota query timed out".to_string()),
};
let tiers = output
.status
.success()
.then(|| parse_usage(&output.stdout))
.flatten();
let Some(tiers) = tiers else {
return Ok(SubscriptionQuota::error(
TOOL,
CredentialStatus::Valid,
"Antigravity did not return quota. Sign in with agy and try again.".to_string(),
));
};
Ok(SubscriptionQuota {
tool: TOOL.to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success: true,
tiers,
extra_usage: None,
reset_credits: None,
credits_balance: None,
error: None,
queried_at: Some(now_millis()),
})
}
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
// agy 1.4.3 `/usage --output-format json` 的真实结构(数值改过)
const SAMPLE: &str = r#"{
"conversation_id": "", "status": "SUCCESS", "response": "...",
"usage": {"input_tokens": 0, "output_tokens": 0},
"command": {"name": "usage", "data": {"description": "...", "groups": [
{"name": "Gemini Models", "buckets": [
{"id": "gemini-weekly", "window": "weekly", "remaining_fraction": 0.75, "reset_time": "2026-10-27T15:13:36Z"},
{"id": "gemini-5h", "window": "5h", "remaining_fraction": 1, "reset_time": "2026-10-21T04:16:37Z"}
]},
{"name": "Claude and GPT models", "buckets": [
{"id": "3p-weekly", "window": "weekly", "remaining_fraction": 0.1, "reset_time": "2026-10-27T23:16:37Z"},
{"id": "3p-5h", "window": "5h", "remaining_fraction": 0, "reset_time": "2026-10-21T04:16:37Z"},
{"id": "future-bucket", "remaining_fraction": 0.5}
]}
]}}
}"#;
#[test]
fn keeps_each_group_and_window_as_its_own_tier() {
let tiers = parse_usage(SAMPLE.as_bytes()).unwrap();
let names: Vec<_> = tiers.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
[
TIER_AGY_GEMINI_FIVE_HOUR,
TIER_AGY_GEMINI_WEEKLY,
TIER_AGY_THIRD_PARTY_FIVE_HOUR,
TIER_AGY_THIRD_PARTY_WEEKLY,
]
);
let used: Vec<_> = tiers.iter().map(|t| t.utilization.round()).collect();
assert_eq!(used, [0.0, 25.0, 100.0, 90.0]);
assert_eq!(tiers[1].resets_at.as_deref(), Some("2026-10-27T15:13:36Z"));
}
#[test]
fn failed_or_empty_output_is_not_quota() {
assert!(parse_usage(b"not json").is_none());
assert!(parse_usage(br#"{"status":"ERROR","command":null}"#).is_none());
assert!(parse_usage(br#"{"status":"SUCCESS","command":{"data":{"groups":[]}}}"#).is_none());
// 缺比例的桶不当成满额
assert!(parse_usage(
br#"{"status":"SUCCESS","command":{"data":{"groups":[{"buckets":[{"id":"gemini-5h"}]}]}}}"#
)
.is_none());
}
}
+43 -12
View File
@@ -50,6 +50,11 @@ export const TIER_I18N_KEYS: Record<string, string> = {
gemini_pro: "subscription.geminiPro",
gemini_flash: "subscription.geminiFlash",
gemini_flash_lite: "subscription.geminiFlashLite",
// Antigravity:Gemini 一组、Claude 和 GPT 一组,各有 5 小时和每周两档
agy_gemini_five_hour: "subscription.agyGeminiFiveHour",
agy_gemini_weekly: "subscription.agyGeminiWeekly",
agy_third_party_five_hour: "subscription.agyThirdPartyFiveHour",
agy_third_party_weekly: "subscription.agyThirdPartyWeekly",
// Token Plan(five_hour 已在上方官方映射中)
weekly_limit: "subscription.sevenDay",
// 火山方舟 Agent Plan / Coding Plan 的月窗口
@@ -63,6 +68,31 @@ export const TIER_I18N_KEYS: Record<string, string> = {
/** 卡片上不显示的档(展开时仍列出) */
const HIDDEN_INLINE_TIERS = new Set(["seven_day_sonnet"]);
/**
* 卡片上按组只留剩余最少的一档、标签写组名(展开时仍逐档列出):
* Antigravity 的 Gemini、Claude 和 GPT 两组各有 5 小时和每周两档,四档在卡片上放不下
*/
const INLINE_TIER_GROUPS: Record<string, string> = {
agy_gemini_five_hour: "subscription.agyGemini",
agy_gemini_weekly: "subscription.agyGemini",
agy_third_party_five_hour: "subscription.agyThirdParty",
agy_third_party_weekly: "subscription.agyThirdParty",
};
function collapseInlineGroups(tiers: QuotaTier[]): QuotaTier[] {
const kept = new Map<string, QuotaTier>();
for (const tier of tiers) {
const group = INLINE_TIER_GROUPS[tier.name] ?? tier.name;
const current = kept.get(group);
if (!current || tier.utilization > current.utilization) {
kept.set(group, tier);
}
}
return tiers.filter((tier) =>
[...kept.values()].some((keptTier) => keptTier === tier),
);
}
export function tierLabel(t: TFunction, name: string): string {
return TIER_I18N_KEYS[name] ? t(TIER_I18N_KEYS[name]) : name;
}
@@ -82,19 +112,20 @@ export function tierLines(
display = "left",
}: { inline?: boolean; display?: QuotaDisplay } = {},
): { label: string; line: QuotaLine }[] {
return tiers
const known = tiers
.filter((tier) => tier.name in TIER_I18N_KEYS)
.filter((tier) => !inline || !HIDDEN_INLINE_TIERS.has(tier.name))
.map((tier) => {
const label = tierLabel(t, tier.name);
return {
label,
line: tierLine(t, tier, label, {
short: tierShortLabel(t, tier.name),
display,
}),
};
});
.filter((tier) => !inline || !HIDDEN_INLINE_TIERS.has(tier.name));
return (inline ? collapseInlineGroups(known) : known).map((tier) => {
const group = inline ? INLINE_TIER_GROUPS[tier.name] : undefined;
const label = group ? t(group) : tierLabel(t, tier.name);
return {
label,
line: tierLine(t, tier, label, {
short: group ? label : tierShortLabel(t, tier.name),
display,
}),
};
});
}
/**
+2 -1
View File
@@ -248,7 +248,8 @@ export function ProviderCard({
provider.meta?.usage_script?.enabled ?? isBoundCodexOfficial;
const isOfficial = isOfficialProvider(provider, appId);
const supportsOfficialSubscription =
isOfficial && ["claude", "codex", "gemini", "grokbuild"].includes(appId);
isOfficial &&
["claude", "codex", "gemini", "grokbuild", "agy"].includes(appId);
const isOfficialSubscriptionUsage =
provider.meta?.usage_script?.templateType ===
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
+2
View File
@@ -95,6 +95,8 @@ export function countdownStr(
/** 档位窗口的长短:卡片第一行写最短的那档;不认识的档排最后 */
const TIER_WINDOW_ORDER: Record<string, number> = {
five_hour: 0,
agy_gemini_five_hour: 0,
agy_third_party_five_hour: 0,
gemini_pro: 1,
gemini_flash: 1,
gemini_flash_lite: 1,
+10
View File
@@ -4104,6 +4104,10 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Lite",
"agyGeminiFiveHour": "Gemini 5h",
"agyGeminiWeekly": "Gemini Wk",
"agyThirdPartyFiveHour": "Claude 5h",
"agyThirdPartyWeekly": "Claude Wk",
"monthly": "Mo",
"credits": "Credits",
"copilotPremium": "Premium"
@@ -4117,6 +4121,12 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"agyGemini": "Gemini",
"agyThirdParty": "Claude/GPT",
"agyGeminiFiveHour": "Gemini 5-hour",
"agyGeminiWeekly": "Gemini weekly",
"agyThirdPartyFiveHour": "Claude/GPT 5-hour",
"agyThirdPartyWeekly": "Claude/GPT weekly",
"weeklyLimit": "Weekly",
"monthly": "Monthly",
"credits": "Credits",
+10
View File
@@ -4104,6 +4104,10 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Lite",
"agyGeminiFiveHour": "Gemini 5h",
"agyGeminiWeekly": "Gemini 週",
"agyThirdPartyFiveHour": "Claude 5h",
"agyThirdPartyWeekly": "Claude 週",
"monthly": "月",
"credits": "クレジット",
"copilotPremium": "プレミアム"
@@ -4117,6 +4121,12 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"agyGemini": "Gemini",
"agyThirdParty": "Claude/GPT",
"agyGeminiFiveHour": "Gemini 5時間",
"agyGeminiWeekly": "Gemini 週間",
"agyThirdPartyFiveHour": "Claude/GPT 5時間",
"agyThirdPartyWeekly": "Claude/GPT 週間",
"weeklyLimit": "週間",
"monthly": "月間",
"credits": "クレジット",
+10
View File
@@ -4105,6 +4105,10 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Lite",
"agyGeminiFiveHour": "Gemini 5 小時",
"agyGeminiWeekly": "Gemini 每週",
"agyThirdPartyFiveHour": "Claude 5 小時",
"agyThirdPartyWeekly": "Claude 每週",
"monthly": "每月",
"credits": "額度",
"copilotPremium": "進階請求"
@@ -4118,6 +4122,12 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"agyGemini": "Gemini",
"agyThirdParty": "Claude/GPT",
"agyGeminiFiveHour": "Gemini 5 小時",
"agyGeminiWeekly": "Gemini 每週",
"agyThirdPartyFiveHour": "Claude/GPT 5 小時",
"agyThirdPartyWeekly": "Claude/GPT 每週",
"weeklyLimit": "每週",
"monthly": "每月",
"credits": "額度",
+10
View File
@@ -4104,6 +4104,10 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Lite",
"agyGeminiFiveHour": "Gemini 5 小时",
"agyGeminiWeekly": "Gemini 每周",
"agyThirdPartyFiveHour": "Claude 5 小时",
"agyThirdPartyWeekly": "Claude 每周",
"monthly": "每月",
"credits": "额度",
"copilotPremium": "高级请求"
@@ -4117,6 +4121,12 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"agyGemini": "Gemini",
"agyThirdParty": "Claude/GPT",
"agyGeminiFiveHour": "Gemini 5 小时",
"agyGeminiWeekly": "Gemini 每周",
"agyThirdPartyFiveHour": "Claude/GPT 5 小时",
"agyThirdPartyWeekly": "Claude/GPT 每周",
"weeklyLimit": "每周",
"monthly": "每月",
"credits": "额度",
+2 -1
View File
@@ -91,7 +91,8 @@ export function useSubscriptionQuota(
queryKey: subscriptionKeys.quota(appId),
queryFn: () => subscriptionApi.getQuota(appId),
enabled:
enabled && ["claude", "codex", "gemini", "grokbuild"].includes(appId),
enabled &&
["claude", "codex", "gemini", "grokbuild", "agy"].includes(appId),
refetchInterval,
refetchIntervalInBackground: Boolean(refetchInterval),
refetchOnWindowFocus: Boolean(refetchInterval),
@@ -11,7 +11,9 @@ vi.mock("@/components/providers/ProviderCardActions", () => ({
vi.mock("@/components/ProviderIcon", () => ({ ProviderIcon: () => null }));
vi.mock("@/components/UsageFooter", () => ({ default: () => null }));
vi.mock("@/components/SubscriptionQuotaFooter", () => ({
default: () => null,
default: ({ appId }: { appId: string }) => (
<span data-testid="subscription-quota">{appId}</span>
),
}));
vi.mock("@/components/CopilotQuotaFooter", () => ({ default: () => null }));
vi.mock("@/components/CodexOauthQuotaFooter", () => ({ default: () => null }));
@@ -75,3 +77,35 @@ describe("ProviderCard Antigravity model", () => {
expect(screen.queryByText("Gemini 3.1 Pro (High)")).not.toBeInTheDocument();
});
});
describe("ProviderCard Antigravity official quota", () => {
const official: Provider = {
id: "official",
name: "Antigravity",
category: "official",
settingsConfig: { config: {} },
meta: {
usage_script: {
enabled: true,
language: "javascript",
code: "",
templateType: "official_subscription",
},
},
};
it("shows the signed-in quota once official usage is turned on", () => {
renderCard(official);
expect(screen.getByTestId("subscription-quota")).toHaveTextContent("agy");
});
it("stays hidden while usage is off", () => {
renderCard({
...official,
meta: {
usage_script: { ...official.meta!.usage_script!, enabled: false },
},
});
expect(screen.queryByTestId("subscription-quota")).not.toBeInTheDocument();
});
});
@@ -194,6 +194,41 @@ describe("Claude Fable subscription quota", () => {
});
});
describe("Antigravity quota groups", () => {
const agyTiers: QuotaTier[] = [
{ name: "agy_gemini_five_hour", utilization: 10, resetsAt: null },
{
name: "agy_gemini_weekly",
utilization: 40,
resetsAt: "2026-09-12T00:00:00Z",
},
{ name: "agy_third_party_five_hour", utilization: 95, resetsAt: null },
{ name: "agy_third_party_weekly", utilization: 20, resetsAt: null },
];
it("keeps one line per group on the card: the window with the least left", () => {
renderQuota(agyTiers);
const lines = screen.getByRole("button").children;
expect(lines).toHaveLength(2);
expect(lines[0]).toHaveTextContent("Gemini 剩余 60%");
expect(lines[1]).toHaveTextContent("Claude/GPT 剩余 5%");
// 倒计时跟着留下的那档(Gemini 每周)
expect(screen.getByText("2d12h")).toBeInTheDocument();
});
it("lists every group and window when expanded", () => {
renderQuota(agyTiers, false);
for (const name of [
"Gemini 5 小时: 剩余 90%",
"Gemini 每周: 剩余 60%",
"Claude/GPT 5 小时: 剩余 5%",
"Claude/GPT 每周: 剩余 80%",
]) {
expect(screen.getByRole("meter", { name })).toBeInTheDocument();
}
});
});
describe("credential failures", () => {
const failed = (credentialStatus: SubscriptionQuota["credentialStatus"]) =>
renderQuota([], true, {