fix(subscription): display Codex free-plan 30-day quota window (#3651) (#4886)

* fix(subscription): display Codex free-plan 30-day quota window (#3651)

Codex free accounts are metered on a rolling 30-day secondary window
(limit_window_seconds = 2,592,000) instead of the weekly window used by
paid plans. The backend already maps this correctly to the tier name
"30_day", but the frontend TIER_I18N_KEYS whitelist had no entry for it,
so SubscriptionQuotaView filtered the tier out. When that was the only
surviving tier (as happens for free accounts), the whole quota footer
rendered nothing — free accounts showed no remaining quota or reset time
while paid accounts worked.

- Add "30_day" -> subscription.thirtyDay to TIER_I18N_KEYS
- Add the thirtyDay label to all four locales (zh/en/ja/zh-TW)
- Add a unit test locking in window_seconds_to_tier_name mappings,
  including the 30-day case

Fixes #3651

* fix(tray): render Codex free-plan 30-day window in tray menu

The tray keeps its own tier-name whitelist (TIER_LABEL_GROUPS) independent
of the frontend TIER_I18N_KEYS. Its month group only listed "monthly", so a
free Codex account whose only tier is "30_day" produced empty labeled parts
→ format_subscription_summary returned None → the tray showed no quota, even
though the footer now does. That breaks the invariant the existing
gemini_summary_lite_only_still_renders test guards: any tier visible in the
footer must not leave the tray blank.

- Add TIER_THIRTY_DAY ("30_day") constant so the string is single-sourced
  across backend mapping, tray grouping, and the frontend whitelist
- Map 2_592_000s explicitly to it in window_seconds_to_tier_name
- Add TIER_THIRTY_DAY to the tray month ("m") group
- Add codex_summary_thirty_day_only_still_renders regression test

Follow-up to the review on #3651 / PR for the 30-day footer fix.
This commit is contained in:
SaladDay
2026-07-05 10:54:20 -04:00
committed by GitHub
parent ffc22ea714
commit 7a7d41c873
7 changed files with 54 additions and 5 deletions
+30 -2
View File
@@ -312,6 +312,12 @@ pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
/// 映射到 `subscription.monthly`。
pub const TIER_MONTHLY: &str = "monthly";
/// Codex 免费方案的 30 天(月)滚动窗口 tier 名。付费方案的次要窗口是 7 天
/// (`seven_day`),免费方案则是 30 天。由 `window_seconds_to_tier_name` 产出、
/// tray 的月分组渲染、前端 `TIER_I18N_KEYS` 映射到 `subscription.thirtyDay`
/// 三处共用同一标识。见 #3651。
pub const TIER_THIRTY_DAY: &str = "30_day";
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
@@ -632,8 +638,12 @@ struct CodexUsageResponse {
/// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n)
fn window_seconds_to_tier_name(secs: i64) -> String {
match secs {
18000 => "five_hour".to_string(),
604800 => "seven_day".to_string(),
18000 => TIER_FIVE_HOUR.to_string(),
604800 => TIER_SEVEN_DAY.to_string(),
// Codex 免费方案的 30 天窗口。显式映射到常量,与 tray 月分组、前端
// TIER_I18N_KEYS 保持同一标识(否则动态回退虽也得到 "30_day",但字符串
// 分散在多处、易和托盘/前端白名单脱节)。见 #3651。
2_592_000 => TIER_THIRTY_DAY.to_string(),
s => {
let hours = s / 3600;
if hours >= 24 {
@@ -1340,3 +1350,21 @@ fn now_millis() -> i64 {
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_seconds_map_to_expected_tier_names() {
// 官方特例窗口
assert_eq!(window_seconds_to_tier_name(18000), TIER_FIVE_HOUR);
assert_eq!(window_seconds_to_tier_name(604800), TIER_SEVEN_DAY);
// Codex 免费方案的次要窗口是 30 天(30 * 24 * 3600 = 2_592_000 秒)。
// 前端 TIER_I18N_KEYS 与 tray 月分组都需要认得 "30_day",见 #3651。
assert_eq!(window_seconds_to_tier_name(2_592_000), TIER_THIRTY_DAY);
// 其他窗口按小时/天回退命名
assert_eq!(window_seconds_to_tier_name(3600), "1_hour");
assert_eq!(window_seconds_to_tier_name(86400), "1_day");
}
}
+18 -3
View File
@@ -19,8 +19,13 @@ const W_TIER_NAMES: &[&str] = &[
crate::services::subscription::TIER_SEVEN_DAY_OPUS,
crate::services::subscription::TIER_SEVEN_DAY_SONNET,
];
// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)
const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY];
// 月窗口分组:火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)
// 以及 Codex 免费方案的 30 天窗口(#3651)——两者都归入 "m" 档,避免免费
// Codex 账号在托盘里空白(前端 footer 能看到、托盘却不显示的不对称)。
const M_TIER_NAMES: &[&str] = &[
crate::services::subscription::TIER_MONTHLY,
crate::services::subscription::TIER_THIRTY_DAY,
];
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
@@ -882,7 +887,7 @@ mod tests {
use crate::services::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT,
TIER_SEVEN_DAY_SONNET, TIER_THIRTY_DAY, TIER_WEEKLY_LIMIT,
};
#[test]
@@ -964,6 +969,16 @@ mod tests {
assert!(s.contains("l80%"), "expected l80% in {s}");
}
#[test]
fn codex_summary_thirty_day_only_still_renders() {
// Codex 免费方案的唯一 tier 是 30 天窗口。前端 footer 已能显示(TIER_I18N_KEYS
// 有 "30_day"),托盘也必须能显示——否则就是这条不变量要防的非对称:footer
// 能看到、托盘却空白。30_day 归入 "m" 月分组。见 #3651。
let quota = make_quota("codex", true, vec![tier(TIER_THIRTY_DAY, 85.0)]);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("m85%"), "expected m85% in {s}");
}
#[test]
fn gemini_summary_emoji_reflects_highest_tier_including_lite() {
// lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。
@@ -27,6 +27,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
seven_day: "subscription.sevenDay",
seven_day_opus: "subscription.sevenDayOpus",
seven_day_sonnet: "subscription.sevenDaySonnet",
// Codex 免费方案的次要窗口是 30 天(付费方案为 7 天)
"30_day": "subscription.thirtyDay",
// Gemini 模型分类
gemini_pro: "subscription.geminiPro",
gemini_flash: "subscription.geminiFlash",
+1
View File
@@ -2903,6 +2903,7 @@
"sevenDay": "7-Day",
"sevenDayOpus": "7-Day (Opus)",
"sevenDaySonnet": "7-Day (Sonnet)",
"thirtyDay": "30-Day",
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
+1
View File
@@ -2903,6 +2903,7 @@
"sevenDay": "7日間",
"sevenDayOpus": "7日間(Opus)",
"sevenDaySonnet": "7日間(Sonnet)",
"thirtyDay": "30日間",
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
+1
View File
@@ -2875,6 +2875,7 @@
"sevenDay": "7 天",
"sevenDayOpus": "7 天 (Opus)",
"sevenDaySonnet": "7 天 (Sonnet)",
"thirtyDay": "30 天",
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
+1
View File
@@ -2903,6 +2903,7 @@
"sevenDay": "7天",
"sevenDayOpus": "7天(Opus)",
"sevenDaySonnet": "7天(Sonnet)",
"thirtyDay": "30天",
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",