mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-19 03:29:45 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CodexOauthQuotaFooterProps> = ({
|
||||
meta,
|
||||
inline = false,
|
||||
}) => {
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useCodexOauthQuota(meta, true);
|
||||
|
||||
return (
|
||||
<SubscriptionQuotaView
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint="codex_oauth"
|
||||
inline={inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexOauthQuotaFooter;
|
||||
@@ -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<string, string> = {
|
||||
five_hour: "subscription.fiveHour",
|
||||
@@ -78,16 +87,22 @@ function formatRelativeTime(
|
||||
return t("usage.daysAgo", { count: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
||||
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<SubscriptionQuotaViewProps> = ({
|
||||
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<SubscriptionQuotaFooterProps> = ({
|
||||
<div>
|
||||
<span className="font-medium">{t("subscription.expired")}</span>
|
||||
<span className="ml-2 text-amber-500/70 dark:text-amber-400/70">
|
||||
{t("subscription.expiredHint", { tool: appId })}
|
||||
{t("subscription.expiredHint", { tool: appIdForExpiredHint })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,4 +379,29 @@ const TierBar: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CLI 凭据路径下的薄 wrapper:通过 useSubscriptionQuota(appId) 自取数据
|
||||
* 后转发到 SubscriptionQuotaView。对外 props/行为与重构前完全一致。
|
||||
*/
|
||||
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
||||
appId,
|
||||
inline = false,
|
||||
}) => {
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useSubscriptionQuota(appId, true);
|
||||
|
||||
return (
|
||||
<SubscriptionQuotaView
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint={appId}
|
||||
inline={inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionQuotaFooter;
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-1">
|
||||
{isCopilot ? (
|
||||
<CopilotQuotaFooter meta={provider.meta} inline={true} />
|
||||
) : isCodexOauth ? (
|
||||
<CodexOauthQuotaFooter meta={provider.meta} inline={true} />
|
||||
) : isOfficial ? (
|
||||
<SubscriptionQuotaFooter appId={appId} inline={true} />
|
||||
) : hasMultiplePlans ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Provider 类型常量
|
||||
export const PROVIDER_TYPES = {
|
||||
GITHUB_COPILOT: "github_copilot",
|
||||
CODEX_OAUTH: "codex_oauth",
|
||||
} as const;
|
||||
|
||||
// 用量脚本模板类型常量
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { SubscriptionQuota } from "@/types/subscription";
|
||||
export const subscriptionApi = {
|
||||
getQuota: (tool: string): Promise<SubscriptionQuota> =>
|
||||
invoke("get_subscription_quota", { tool }),
|
||||
getCodexOauthQuota: (accountId: string | null): Promise<SubscriptionQuota> =>
|
||||
invoke("get_codex_oauth_quota", { accountId }),
|
||||
getCodingPlanQuota: (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user