mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
15d5dbe065
Add SuperGrok subscription usage display, following the existing Claude Code / Codex official-subscription pattern (protocol ported from steipete/CodexBar): - New subscription_grok service: reads Grok CLI credentials from ~/.grok/auth.json, calls the grok.com GrokBuildBilling gRPC-web endpoint, and parses the response via heuristic protobuf scanning (used percent, reset time, zero-usage special case) - Transient failures (network errors, HTTP 408, gRPC deadline/ cancelled) propagate as Err so the frontend retries and keeps the last good value; auth failures map to Expired with a re-login hint - Tier naming by reset distance: weekly limit, monthly, or a new "credits" tier (i18n added for zh/en/ja/zh-TW; tray shows "c") - New get_xai_oauth_quota command: xai_oauth providers (managed SuperGrok OAuth accounts) query the same billing endpoint with their bound account token; ProviderCard auto-renders the quota footer for them and hides the usage-script entry, and the tray / usage-script path routes xai_oauth providers to the managed account instead of the host app's CLI credentials - UsageScriptModal: drop the config-content heuristic for official detection; category === "official" is the single source of truth Claude-Session: https://claude.ai/code/session_01LSNvhEfuoJHaQLZcYQgBU5
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
import { useRef } from "react";
|
||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||
import { subscriptionApi } from "@/lib/api/subscription";
|
||
import type { AppId } from "@/lib/api/types";
|
||
import type { ProviderMeta } from "@/types";
|
||
import type { SubscriptionQuota } from "@/types/subscription";
|
||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||
import { PROVIDER_TYPES } from "@/config/constants";
|
||
import { resolveDisplayUsage, type LastGoodSnapshot } from "./queries";
|
||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||
|
||
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||
|
||
export const subscriptionKeys = {
|
||
all: ["subscription"] as const,
|
||
quota: (appId: AppId) => [...subscriptionKeys.all, "quota", appId] as const,
|
||
};
|
||
|
||
/**
|
||
* reject 且无可展示值时的失败占位:首次查询就失败(data 为 undefined),或
|
||
* react-query 保留的旧成功已超出 keep-last-good 窗口——合成一个失败结果,让
|
||
* 订阅视图仍渲染「查询失败」+ 刷新按钮,而不是 footer 整体消失、无从手动重查。
|
||
*/
|
||
const QUERY_REJECTED_PLACEHOLDER: SubscriptionQuota = {
|
||
tool: "",
|
||
credentialStatus: "valid",
|
||
credentialMessage: null,
|
||
success: false,
|
||
tiers: [],
|
||
extraUsage: null,
|
||
error: null,
|
||
queriedAt: null,
|
||
};
|
||
|
||
/**
|
||
* Keep-last-good:与 useUsageQuery 同一策略(resolveDisplayUsage)。
|
||
*
|
||
* 后端对纯传输失败(网络/超时/读体中断)已 reject——react-query 保留上次 data
|
||
* 并触发 retry,但那份 data 是陈旧的:以 `rejected` 标志交给 resolveDisplayUsage
|
||
* 按同一窗口处理(窗口内继续展示,超窗透出失败),与仍以 `Ok(success:false)`
|
||
* 返回的瞬时失败(HTTP 5xx/429)行为一致。确定性失败(过期/鉴权/解析)不掩盖,
|
||
* 立即透出。
|
||
*
|
||
* `scopeKey` 标识查询身份(appId / 绑定的账号 id):身份变化时丢弃旧快照,
|
||
* 避免用上一个账号的额度掩盖新账号的瞬时失败。
|
||
*/
|
||
function useQuotaKeepLastGood(
|
||
query: UseQueryResult<SubscriptionQuota>,
|
||
scopeKey: string,
|
||
) {
|
||
const lastGoodRef = useRef<{
|
||
key: string;
|
||
snap: LastGoodSnapshot<SubscriptionQuota> | null;
|
||
}>({ key: scopeKey, snap: null });
|
||
if (lastGoodRef.current.key !== scopeKey) {
|
||
lastGoodRef.current = { key: scopeKey, snap: null };
|
||
}
|
||
const { data, lastGood } = resolveDisplayUsage(
|
||
query.data,
|
||
query.dataUpdatedAt,
|
||
lastGoodRef.current.snap,
|
||
Date.now(),
|
||
{ rejected: query.isError },
|
||
);
|
||
lastGoodRef.current.snap = lastGood;
|
||
return {
|
||
...query,
|
||
data:
|
||
data ??
|
||
(query.isError
|
||
? {
|
||
...QUERY_REJECTED_PLACEHOLDER,
|
||
error: extractErrorMessage(query.error) || null,
|
||
}
|
||
: undefined),
|
||
};
|
||
}
|
||
|
||
export function useSubscriptionQuota(
|
||
appId: AppId,
|
||
enabled: boolean,
|
||
autoQuery = false,
|
||
autoQueryIntervalMinutes = 5,
|
||
) {
|
||
const refetchInterval =
|
||
autoQuery && autoQueryIntervalMinutes > 0
|
||
? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000
|
||
: false;
|
||
|
||
const query = useQuery({
|
||
queryKey: subscriptionKeys.quota(appId),
|
||
queryFn: () => subscriptionApi.getQuota(appId),
|
||
enabled:
|
||
enabled && ["claude", "codex", "gemini", "grokbuild"].includes(appId),
|
||
refetchInterval,
|
||
refetchIntervalInBackground: Boolean(refetchInterval),
|
||
refetchOnWindowFocus: Boolean(refetchInterval),
|
||
staleTime:
|
||
autoQueryIntervalMinutes > 0
|
||
? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000
|
||
: REFETCH_INTERVAL,
|
||
retry: 1,
|
||
});
|
||
|
||
return useQuotaKeepLastGood(query, appId);
|
||
}
|
||
|
||
export interface UseCodexOauthQuotaOptions {
|
||
enabled?: boolean;
|
||
/** 是否启用自动轮询(5 分钟)与窗口 focus 重取 */
|
||
autoQuery?: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
options: UseCodexOauthQuotaOptions = {},
|
||
) {
|
||
const { enabled = true, autoQuery = false } = options;
|
||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
||
const query = useQuery({
|
||
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
||
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
||
enabled,
|
||
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
|
||
refetchIntervalInBackground: autoQuery,
|
||
refetchOnWindowFocus: autoQuery,
|
||
staleTime: REFETCH_INTERVAL,
|
||
retry: 1,
|
||
});
|
||
|
||
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||
}
|
||
|
||
/**
|
||
* xAI OAuth (SuperGrok 反代) 订阅额度查询 hook
|
||
*
|
||
* 与 `useCodexOauthQuota` 平行:数据走 cc-switch 自管的 xAI OAuth token,
|
||
* 而不是 Grok CLI 的 ~/.grok/auth.json;后端复用同一个 grok.com 账单端点,
|
||
* 因此与 Grok Build 分区的官方订阅显示同一份额度。
|
||
*/
|
||
export function useXaiOauthQuota(
|
||
meta: ProviderMeta | undefined,
|
||
options: UseCodexOauthQuotaOptions = {},
|
||
) {
|
||
const { enabled = true, autoQuery = false } = options;
|
||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.XAI_OAUTH);
|
||
const query = useQuery({
|
||
queryKey: ["xai_oauth", "quota", accountId ?? "default"],
|
||
queryFn: () => subscriptionApi.getXaiOauthQuota(accountId),
|
||
enabled,
|
||
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
|
||
refetchIntervalInBackground: autoQuery,
|
||
refetchOnWindowFocus: autoQuery,
|
||
staleTime: REFETCH_INTERVAL,
|
||
retry: 1,
|
||
});
|
||
|
||
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||
}
|