From 1b4cd547c40d44dbd3e8bde987e976e4ece7388c Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 21 Oct 2026 07:26:26 +0800 Subject: [PATCH] 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. --- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/subscription.rs | 1 + src-tauri/src/services/subscription_agy.rs | 204 ++++++++++++++++++ src/components/SubscriptionQuotaFooter.tsx | 55 +++-- src/components/providers/ProviderCard.tsx | 3 +- src/components/quota/quotaRules.ts | 2 + src/i18n/locales/en.json | 10 + src/i18n/locales/ja.json | 10 + src/i18n/locales/zh-TW.json | 10 + src/i18n/locales/zh.json | 10 + src/lib/query/subscription.ts | 3 +- .../components/ProviderCard.agyModel.test.tsx | 36 +++- .../SubscriptionQuotaFooter.test.tsx | 35 +++ 13 files changed, 365 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/services/subscription_agy.rs diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 5451a06ab..90439351e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 38a7fdaad..695fc8c9c 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -1645,6 +1645,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result crate::services::subscription_grok::get_grok_subscription_quota().await, + "agy" => crate::services::subscription_agy::get_agy_subscription_quota().await, _ => Ok(SubscriptionQuota::not_found(tool)), } } diff --git a/src-tauri/src/services/subscription_agy.rs b/src-tauri/src/services/subscription_agy.rs new file mode 100644 index 000000000..ada3fd7ba --- /dev/null +++ b/src-tauri/src/services/subscription_agy.rs @@ -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, + command: Option, +} + +#[derive(Deserialize)] +struct UsageCommand { + data: Option, +} + +#[derive(Deserialize)] +struct UsageData { + #[serde(default)] + groups: Vec, +} + +#[derive(Deserialize)] +struct UsageGroup { + #[serde(default)] + buckets: Vec, +} + +#[derive(Deserialize)] +struct UsageBucket { + id: Option, + remaining_fraction: Option, + reset_time: Option, +} + +/// 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> { + 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 = 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 { + 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()); + } +} diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 3bb6daf50..71038dbd0 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -50,6 +50,11 @@ export const TIER_I18N_KEYS: Record = { 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 = { /** 卡片上不显示的档(展开时仍列出) */ const HIDDEN_INLINE_TIERS = new Set(["seven_day_sonnet"]); +/** + * 卡片上按组只留剩余最少的一档、标签写组名(展开时仍逐档列出): + * Antigravity 的 Gemini、Claude 和 GPT 两组各有 5 小时和每周两档,四档在卡片上放不下 + */ +const INLINE_TIER_GROUPS: Record = { + 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(); + 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, + }), + }; + }); } /** diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index e6d692759..6e11e84d5 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -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; diff --git a/src/components/quota/quotaRules.ts b/src/components/quota/quotaRules.ts index 53b56bfa3..975d42a1c 100644 --- a/src/components/quota/quotaRules.ts +++ b/src/components/quota/quotaRules.ts @@ -95,6 +95,8 @@ export function countdownStr( /** 档位窗口的长短:卡片第一行写最短的那档;不认识的档排最后 */ const TIER_WINDOW_ORDER: Record = { five_hour: 0, + agy_gemini_five_hour: 0, + agy_third_party_five_hour: 0, gemini_pro: 1, gemini_flash: 1, gemini_flash_lite: 1, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2e6cd1e6f..7477adcfe 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 26b4ec65d..621d7deb6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "クレジット", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index fc12bf880..06740ccf8 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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": "額度", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b555cb91b..ae7bee0e3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "额度", diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index 55a742e8b..1a4177ffc 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -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), diff --git a/tests/components/ProviderCard.agyModel.test.tsx b/tests/components/ProviderCard.agyModel.test.tsx index 0d8dba45c..608a1aa2b 100644 --- a/tests/components/ProviderCard.agyModel.test.tsx +++ b/tests/components/ProviderCard.agyModel.test.tsx @@ -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 }) => ( + {appId} + ), })); 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(); + }); +}); diff --git a/tests/components/SubscriptionQuotaFooter.test.tsx b/tests/components/SubscriptionQuotaFooter.test.tsx index 0cb00bde2..9ee0e3dd2 100644 --- a/tests/components/SubscriptionQuotaFooter.test.tsx +++ b/tests/components/SubscriptionQuotaFooter.test.tsx @@ -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, {