From 0200fe79aea82aef776bd9dca9eec97b8fd95fcd Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Apr 2026 17:47:16 +0800 Subject: [PATCH] feat: add official subscription quota display for Codex Read Codex OAuth credentials from ~/.codex/auth.json (with macOS Keychain fallback) and query chatgpt.com/backend-api/wham/usage to show rate limit utilization on official Codex provider cards. Reuses the same tier naming (five_hour, seven_day) for frontend i18n compat. --- src-tauri/src/services/subscription.rs | 328 +++++++++++++++++++++- src/components/providers/ProviderCard.tsx | 17 +- 2 files changed, 339 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 47f0957dd..727413d10 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -410,6 +410,302 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { } } +// ── Codex 凭据读取 ────────────────────────────────────── + +#[derive(Deserialize)] +struct CodexAuthJson { + auth_mode: Option, + tokens: Option, + last_refresh: Option, +} + +#[derive(Deserialize)] +struct CodexTokens { + access_token: Option, + account_id: Option, +} + +/// (access_token, account_id, status, message) +type CodexCredentials = ( + Option, + Option, + CredentialStatus, + Option, +); + +/// 读取 Codex OAuth 凭据 +/// +/// 按优先级尝试以下来源: +/// 1. macOS Keychain (service: "Codex Auth") +/// 2. 凭据文件 ~/.codex/auth.json +/// +/// 仅 auth_mode == "chatgpt" (OAuth) 时有效,API key 模式不支持用量查询。 +fn read_codex_credentials() -> CodexCredentials { + #[cfg(target_os = "macos")] + { + if let Some(result) = read_codex_credentials_from_keychain() { + return result; + } + } + + read_codex_credentials_from_file() +} + +/// 从 macOS Keychain 读取 Codex 凭据 +#[cfg(target_os = "macos")] +fn read_codex_credentials_from_keychain() -> Option { + let output = std::process::Command::new("security") + .args(["find-generic-password", "-s", "Codex Auth", "-w"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let json_str = String::from_utf8(output.stdout).ok()?; + let json_str = json_str.trim(); + if json_str.is_empty() { + return None; + } + + Some(parse_codex_credentials_json(json_str)) +} + +/// 从文件读取 Codex 凭据 +fn read_codex_credentials_from_file() -> CodexCredentials { + let auth_path = crate::codex_config::get_codex_auth_path(); + + if !auth_path.exists() { + return (None, None, CredentialStatus::NotFound, None); + } + + let content = match std::fs::read_to_string(&auth_path) { + Ok(c) => c, + Err(e) => { + return ( + None, + None, + CredentialStatus::ParseError, + Some(format!("Failed to read Codex auth file: {e}")), + ); + } + }; + + parse_codex_credentials_json(&content) +} + +/// 解析 Codex 凭据 JSON(Keychain 和文件共用) +fn parse_codex_credentials_json(content: &str) -> CodexCredentials { + let auth: CodexAuthJson = match serde_json::from_str(content) { + Ok(a) => a, + Err(e) => { + return ( + None, + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse Codex auth JSON: {e}")), + ); + } + }; + + // 仅 OAuth 模式有用量数据 + if auth.auth_mode.as_deref() != Some("chatgpt") { + return ( + None, + None, + CredentialStatus::NotFound, + Some("Codex not using OAuth mode".to_string()), + ); + } + + let tokens = match auth.tokens { + Some(t) => t, + None => { + return ( + None, + None, + CredentialStatus::ParseError, + Some("No tokens in Codex auth".to_string()), + ); + } + }; + + let access_token = match tokens.access_token { + Some(t) if !t.is_empty() => t, + _ => { + return ( + None, + None, + CredentialStatus::ParseError, + Some("access_token is empty or missing".to_string()), + ); + } + }; + + // 检查 token 是否可能过期(距上次刷新 > 8 天) + if let Some(ref last_refresh) = auth.last_refresh { + if is_codex_token_stale(last_refresh) { + return ( + Some(access_token), + tokens.account_id, + CredentialStatus::Expired, + Some("Codex token may be stale (>8 days since last refresh)".to_string()), + ); + } + } + + ( + Some(access_token), + tokens.account_id, + CredentialStatus::Valid, + None, + ) +} + +/// 判断 Codex token 是否可能过期(Codex CLI 在 >8 天时自动刷新) +fn is_codex_token_stale(last_refresh: &str) -> bool { + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(last_refresh) { + let age_secs = now_secs.saturating_sub(dt.timestamp() as u64); + age_secs > 8 * 24 * 3600 + } else { + false + } +} + +// ── Codex API 查询 ────────────────────────────────────── + +#[derive(Deserialize)] +struct CodexRateLimitWindow { + used_percent: Option, + limit_window_seconds: Option, + reset_at: Option, +} + +#[derive(Deserialize)] +struct CodexRateLimit { + primary_window: Option, + secondary_window: Option, +} + +#[derive(Deserialize)] +struct CodexUsageResponse { + rate_limit: Option, +} + +/// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n) +fn window_seconds_to_tier_name(secs: i64) -> String { + match secs { + 18000 => "five_hour".to_string(), + 604800 => "seven_day".to_string(), + s => { + let hours = s / 3600; + if hours >= 24 { + format!("{}_day", hours / 24) + } else { + format!("{}_hour", hours) + } + } + } +} + +/// Unix 时间戳(秒)转 ISO 8601 字符串 +fn unix_ts_to_iso(ts: i64) -> Option { + chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.to_rfc3339()) +} + +/// 查询 Codex 官方订阅额度 +async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .header("Authorization", format!("Bearer {access_token}")) + .header("User-Agent", "codex-cli") + .header("Accept", "application/json"); + + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + + let resp = match req.timeout(std::time::Duration::from_secs(10)).send().await { + Ok(r) => r, + Err(e) => { + return SubscriptionQuota::error( + "codex", + CredentialStatus::Valid, + format!("Network error: {e}"), + ); + } + }; + + let status = resp.status(); + + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota::error( + "codex", + CredentialStatus::Expired, + format!("Authentication failed (HTTP {status}). Please re-login with Codex CLI."), + ); + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return SubscriptionQuota::error( + "codex", + CredentialStatus::Valid, + format!("API error (HTTP {status}): {body}"), + ); + } + + let body: CodexUsageResponse = match resp.json().await { + Ok(v) => v, + Err(e) => { + return SubscriptionQuota::error( + "codex", + CredentialStatus::Valid, + format!("Failed to parse API response: {e}"), + ); + } + }; + + let mut tiers = Vec::new(); + + if let Some(rate_limit) = body.rate_limit { + for window in [rate_limit.primary_window, rate_limit.secondary_window] + .into_iter() + .flatten() + { + if let Some(used) = window.used_percent { + tiers.push(QuotaTier { + name: window + .limit_window_seconds + .map(window_seconds_to_tier_name) + .unwrap_or_else(|| "unknown".to_string()), + utilization: used, + resets_at: window.reset_at.and_then(unix_ts_to_iso), + }); + } + } + } + + SubscriptionQuota { + tool: "codex".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + // ── 入口函数 ────────────────────────────────────────────── /// 查询指定 CLI 工具的官方订阅额度 @@ -445,7 +741,37 @@ pub async fn get_subscription_quota(tool: &str) -> Result { + let (token, account_id, status, message) = read_codex_credentials(); + + match status { + CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("codex")), + CredentialStatus::ParseError => Ok(SubscriptionQuota::error( + "codex", + CredentialStatus::ParseError, + message.unwrap_or_else(|| "Failed to parse credentials".to_string()), + )), + CredentialStatus::Expired => { + // 即使可能过期也尝试调用 API + if let Some(token) = token { + let result = query_codex_quota(&token, account_id.as_deref()).await; + if result.success { + return Ok(result); + } + } + Ok(SubscriptionQuota::error( + "codex", + CredentialStatus::Expired, + message.unwrap_or_else(|| "Codex OAuth token may be stale".to_string()), + )) + } + CredentialStatus::Valid => { + let token = token.expect("token must be Some when status is Valid"); + Ok(query_codex_quota(&token, account_id.as_deref()).await) + } + } + } + // Gemini: 暂不支持 _ => Ok(SubscriptionQuota::not_found(tool)), } } diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 289e27b3f..f327d8d84 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -56,12 +56,19 @@ interface ProviderCardProps { onSetAsDefault?: () => void; } -/** 判断是否为官方供应商(无自定义 base URL,直连官方 API) */ +/** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */ function isOfficialProvider(provider: Provider, appId: AppId): boolean { - if (appId !== "claude") return false; - const baseUrl = (provider.settingsConfig as Record)?.env - ?.ANTHROPIC_BASE_URL; - return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); + const config = provider.settingsConfig as Record; + if (appId === "claude") { + const baseUrl = config?.env?.ANTHROPIC_BASE_URL; + return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); + } + if (appId === "codex") { + // 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方) + const apiKey = config?.auth?.OPENAI_API_KEY; + return !apiKey || (typeof apiKey === "string" && apiKey.trim() === ""); + } + return false; } const extractApiUrl = (provider: Provider, fallbackText: string) => {