From d164191bd1e13448454f7d7f08675119a6649810 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 12:11:05 +0800 Subject: [PATCH] feat: display subscription quota for Codex OAuth provider cards Codex OAuth (ChatGPT Plus/Pro) providers previously fell through to the default UsageFooter branch and showed no quota at all, while Copilot and official Codex providers already had a wham/usage-backed quota footer. This wires up the same five-hour / seven-day tier badges for codex_oauth provider cards by reusing the existing query_codex_quota function and SubscriptionQuotaFooter rendering, parameterized to keep both the CLI credential path ("codex") and the cc-switch managed OAuth path ("codex_oauth") working from a single source of truth. - Parameterize services::subscription::query_codex_quota with tool_label and expired_message; promote SubscriptionQuota constructors to pub(crate). The CLI path keeps its existing "codex" label and the "re-login with Codex CLI" message; the new path passes "codex_oauth" and a cc-switch-specific re-login hint. - Add a new get_codex_oauth_quota Tauri command in commands/codex_oauth.rs that resolves the ChatGPT account (explicit binding > default account > not_found), pulls a valid access_token from CodexOAuthManager (auto-refresh handled), and delegates to query_codex_quota. - Extract SubscriptionQuotaFooter's render body into a pure SubscriptionQuotaView component (props: quota / loading / refetch / appIdForExpiredHint / inline). The existing SubscriptionQuotaFooter becomes a thin wrapper with identical props and behavior, so CopilotQuotaFooter and the official Claude/Codex/Gemini paths are untouched. This avoids duplicating ~280 lines of five-state rendering. - Add CodexOauthQuotaFooter, a 38-line wrapper that calls the new useCodexOauthQuota hook and forwards to SubscriptionQuotaView. - ProviderCard inserts an isCodexOauth branch between isCopilot and isOfficial, keyed off PROVIDER_TYPES.CODEX_OAUTH (newly added to config/constants.ts to centralize the previously scattered string). - Frontend hook caches per (codex_oauth, accountId) so multiple cards bound to the same ChatGPT account share one fetch via react-query dedup; cards bound to different accounts get independent fetches. - No new i18n keys: existing subscription.fiveHour / sevenDay / expired / refresh / queryFailed / expiredHint are reused. --- src-tauri/src/commands/codex_oauth.rs | 49 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/services/subscription.rs | 45 ++++++++++++----- src/components/CodexOauthQuotaFooter.tsx | 38 ++++++++++++++ src/components/SubscriptionQuotaFooter.tsx | 58 ++++++++++++++++++---- src/components/providers/ProviderCard.tsx | 5 ++ src/config/constants.ts | 1 + src/lib/api/subscription.ts | 2 + src/lib/query/subscription.ts | 28 +++++++++++ 9 files changed, 204 insertions(+), 23 deletions(-) create mode 100644 src/components/CodexOauthQuotaFooter.tsx diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs index 040de914..7e83e0dc 100644 --- a/src-tauri/src/commands/codex_oauth.rs +++ b/src-tauri/src/commands/codex_oauth.rs @@ -2,12 +2,57 @@ //! //! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。 //! -//! 注意:实际的命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端, -//! 此处仅定义 State wrapper。 +//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端, +//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。 use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; +use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota}; use std::sync::Arc; +use tauri::State; use tokio::sync::RwLock; /// Codex OAuth 认证状态 pub struct CodexOAuthState(pub Arc>); + +/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度 +/// +/// - `account_id` 未指定时回退到 `CodexOAuthManager` 的默认账号 +/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染 +/// - 复用 `services::subscription::query_codex_quota`,因此 wham/usage 端点协议 +/// 与 Codex CLI 路径完全一致 +#[tauri::command(rename_all = "camelCase")] +pub async fn get_codex_oauth_quota( + account_id: Option, + state: State<'_, CodexOAuthState>, +) -> Result { + let manager = state.0.read().await; + + // 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found) + let resolved = match account_id { + Some(id) => Some(id), + None => manager.default_account_id().await, + }; + let Some(id) = resolved else { + return Ok(SubscriptionQuota::not_found("codex_oauth")); + }; + + // 获取(必要时自动刷新)access_token + let token = match manager.get_valid_token_for_account(&id).await { + Ok(t) => t, + Err(e) => { + return Ok(SubscriptionQuota::error( + "codex_oauth", + CredentialStatus::Expired, + format!("Codex OAuth token unavailable: {e}"), + )); + } + }; + + Ok(query_codex_quota( + &token, + Some(&id), + "codex_oauth", + "Codex OAuth access token expired or rejected. Please re-login via cc-switch.", + ) + .await) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62444ba6..34673168 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -963,6 +963,7 @@ pub fn run() { commands::testUsageScript, // subscription quota commands::get_subscription_quota, + commands::get_codex_oauth_quota, commands::get_coding_plan_quota, commands::get_balance, // New MCP via config.json (SSOT) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 5c2d4b4d..ee4a0b11 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -60,7 +60,7 @@ pub struct SubscriptionQuota { } impl SubscriptionQuota { - fn not_found(tool: &str) -> Self { + pub(crate) fn not_found(tool: &str) -> Self { Self { tool: tool.to_string(), credential_status: CredentialStatus::NotFound, @@ -73,7 +73,7 @@ impl SubscriptionQuota { } } - fn error(tool: &str, status: CredentialStatus, message: String) -> Self { + pub(crate) fn error(tool: &str, status: CredentialStatus, message: String) -> Self { Self { tool: tool.to_string(), credential_status: status, @@ -621,8 +621,17 @@ 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 { +/// 查询 Codex / ChatGPT 反代订阅额度 +/// +/// 参数化 `tool_label` 和 `expired_message` 让该函数可被两个调用点共用: +/// - `"codex"` + "Please re-login with Codex CLI."(CLI 凭据路径) +/// - `"codex_oauth"` + "Please re-login via cc-switch."(cc-switch 自管 OAuth 路径) +pub(crate) async fn query_codex_quota( + access_token: &str, + account_id: Option<&str>, + tool_label: &str, + expired_message: &str, +) -> SubscriptionQuota { let client = crate::proxy::http_client::get(); let mut req = client @@ -639,7 +648,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs Ok(r) => r, Err(e) => { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("Network error: {e}"), ); @@ -650,16 +659,16 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Expired, - format!("Authentication failed (HTTP {status}). Please re-login with Codex CLI."), + format!("{expired_message} (HTTP {status})"), ); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("API error (HTTP {status}): {body}"), ); @@ -669,7 +678,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs Ok(v) => v, Err(e) => { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("Failed to parse API response: {e}"), ); @@ -697,7 +706,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs } SubscriptionQuota { - tool: "codex".to_string(), + tool: tool_label.to_string(), credential_status: CredentialStatus::Valid, credential_message: None, success: true, @@ -1221,7 +1230,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result { // 即使可能过期也尝试调用 API if let Some(token) = token { - let result = query_codex_quota(&token, account_id.as_deref()).await; + let result = query_codex_quota( + &token, + account_id.as_deref(), + "codex", + "Authentication failed. Please re-login with Codex CLI.", + ) + .await; if result.success { return Ok(result); } @@ -1234,7 +1249,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_codex_quota(&token, account_id.as_deref()).await) + Ok(query_codex_quota( + &token, + account_id.as_deref(), + "codex", + "Authentication failed. Please re-login with Codex CLI.", + ) + .await) } } } diff --git a/src/components/CodexOauthQuotaFooter.tsx b/src/components/CodexOauthQuotaFooter.tsx new file mode 100644 index 00000000..be9121b5 --- /dev/null +++ b/src/components/CodexOauthQuotaFooter.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import type { ProviderMeta } from "@/types"; +import { useCodexOauthQuota } from "@/lib/query/subscription"; +import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter"; + +interface CodexOauthQuotaFooterProps { + meta?: ProviderMeta; + inline?: boolean; +} + +/** + * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度 footer + * + * 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。 + * 数据源切换为 cc-switch 自管的 OAuth token 而非 Codex CLI 凭据。 + */ +const CodexOauthQuotaFooter: React.FC = ({ + meta, + inline = false, +}) => { + const { + data: quota, + isFetching: loading, + refetch, + } = useCodexOauthQuota(meta, true); + + return ( + + ); +}; + +export default CodexOauthQuotaFooter; diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 4226544e..e345b58f 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -3,13 +3,22 @@ import { RefreshCw, AlertCircle, Clock } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AppId } from "@/lib/api"; import { useSubscriptionQuota } from "@/lib/query/subscription"; -import type { QuotaTier } from "@/types/subscription"; +import type { QuotaTier, SubscriptionQuota } from "@/types/subscription"; interface SubscriptionQuotaFooterProps { appId: AppId; inline?: boolean; } +interface SubscriptionQuotaViewProps { + quota: SubscriptionQuota | undefined; + loading: boolean; + refetch: () => void; + /** 用于 `subscription.expiredHint` 的 {tool} 插值;解耦了 hook 的 appId */ + appIdForExpiredHint: string; + inline?: boolean; +} + /** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */ export const TIER_I18N_KEYS: Record = { five_hour: "subscription.fiveHour", @@ -78,16 +87,22 @@ function formatRelativeTime( return t("usage.daysAgo", { count: Math.floor(diff / 86400) }); } -const SubscriptionQuotaFooter: React.FC = ({ - appId, +/** + * 纯展示组件:渲染 SubscriptionQuota 的 5 种状态(not_found / parse_error / + * expired / API 失败 / 成功),支持 inline / expanded 两种布局。 + * + * 数据源由调用方 hook 注入,方便不同的额度后端复用同一套渲染逻辑: + * - `SubscriptionQuotaFooter`(CLI 凭据路径,by appId) + * - `CodexOauthQuotaFooter`(cc-switch 自管 OAuth 路径,by ChatGPT account) + */ +export const SubscriptionQuotaView: React.FC = ({ + quota, + loading, + refetch, + appIdForExpiredHint, inline = false, }) => { const { t } = useTranslation(); - const { - data: quota, - isFetching: loading, - refetch, - } = useSubscriptionQuota(appId, true); // 定期更新相对时间显示 const [now, setNow] = React.useState(Date.now()); @@ -131,7 +146,7 @@ const SubscriptionQuotaFooter: React.FC = ({
{t("subscription.expired")} - {t("subscription.expiredHint", { tool: appId })} + {t("subscription.expiredHint", { tool: appIdForExpiredHint })}
@@ -364,4 +379,29 @@ const TierBar: React.FC<{ ); }; +/** + * CLI 凭据路径下的薄 wrapper:通过 useSubscriptionQuota(appId) 自取数据 + * 后转发到 SubscriptionQuotaView。对外 props/行为与重构前完全一致。 + */ +const SubscriptionQuotaFooter: React.FC = ({ + appId, + inline = false, +}) => { + const { + data: quota, + isFetching: loading, + refetch, + } = useSubscriptionQuota(appId, true); + + return ( + + ); +}; + export default SubscriptionQuotaFooter; diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 52f662a9..24166b00 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -13,6 +13,7 @@ import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; +import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter"; import { PROVIDER_TYPES } from "@/config/constants"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; @@ -177,6 +178,8 @@ export function ProviderCard({ const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; + const isCodexOauth = + provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent @@ -355,6 +358,8 @@ export function ProviderCard({
{isCopilot ? ( + ) : isCodexOauth ? ( + ) : isOfficial ? ( ) : hasMultiplePlans ? ( diff --git a/src/config/constants.ts b/src/config/constants.ts index 158d983e..d6a3aab8 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,6 +1,7 @@ // Provider 类型常量 export const PROVIDER_TYPES = { GITHUB_COPILOT: "github_copilot", + CODEX_OAUTH: "codex_oauth", } as const; // 用量脚本模板类型常量 diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index 79f29eb1..d11000ff 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -4,6 +4,8 @@ import type { SubscriptionQuota } from "@/types/subscription"; export const subscriptionApi = { getQuota: (tool: string): Promise => invoke("get_subscription_quota", { tool }), + getCodexOauthQuota: (accountId: string | null): Promise => + invoke("get_codex_oauth_quota", { accountId }), getCodingPlanQuota: ( baseUrl: string, apiKey: string, diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index c70545b7..8353fcca 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -1,6 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { subscriptionApi } from "@/lib/api/subscription"; import type { AppId } from "@/lib/api/types"; +import type { ProviderMeta } from "@/types"; +import { resolveManagedAccountId } from "@/lib/authBinding"; +import { PROVIDER_TYPES } from "@/config/constants"; const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes @@ -15,3 +18,28 @@ export function useSubscriptionQuota(appId: AppId, enabled: boolean) { retry: 1, }); } + +/** + * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook + * + * 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token, + * 而不是 Codex CLI 的 ~/.codex/auth.json。 + * + * Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。 + * accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。 + */ +export function useCodexOauthQuota( + meta: ProviderMeta | undefined, + enabled: boolean, +) { + const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH); + return useQuery({ + queryKey: ["codex_oauth", "quota", accountId ?? "default"], + queryFn: () => subscriptionApi.getCodexOauthQuota(accountId), + enabled, + refetchInterval: REFETCH_INTERVAL, + refetchOnWindowFocus: true, + staleTime: REFETCH_INTERVAL, + retry: 1, + }); +}