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.
This commit is contained in:
Jason
2026-04-07 12:11:05 +08:00
parent 6a34253934
commit d164191bd1
9 changed files with 204 additions and 23 deletions
+47 -2
View File
@@ -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<RwLock<CodexOAuthManager>>);
/// 查询 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<String>,
state: State<'_, CodexOAuthState>,
) -> Result<SubscriptionQuota, String> {
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)
}
+1
View File
@@ -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)
+33 -12
View File
@@ -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<String> {
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<SubscriptionQuota, Str
CredentialStatus::Expired => {
// 即使可能过期也尝试调用 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<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
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)
}
}
}