feat: add Grok official subscription quota query

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
This commit is contained in:
Jason
2026-07-23 11:13:07 +08:00
parent a377d79303
commit 15d5dbe065
17 changed files with 1162 additions and 10 deletions
+24 -5
View File
@@ -3,6 +3,7 @@ use tauri::{Emitter, Manager, State};
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::commands::xai_oauth::XaiOAuthState;
use crate::error::AppError;
use crate::provider::{ClaudeDesktopMode, Provider};
use crate::services::{
@@ -442,6 +443,7 @@ pub async fn queryProviderUsage(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
xai_state: State<'_, XaiOAuthState>,
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
app: String,
) -> Result<crate::provider::UsageResult, String> {
@@ -454,8 +456,14 @@ pub async fn queryProviderUsage(
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
// 回 query 缓存,抹掉 reject 本该保留的旧值。
let inner =
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
let inner = query_provider_usage_inner(
&state,
&copilot_state,
&xai_state,
app_type.clone(),
&providerId,
)
.await;
if let Ok(snapshot) = &inner {
let payload = serde_json::json!({
"kind": "script",
@@ -521,6 +529,7 @@ fn resolve_coding_plan_credentials(
async fn query_provider_usage_inner(
state: &AppState,
copilot_state: &CopilotAuthState,
xai_state: &XaiOAuthState,
app_type: AppType,
provider_id: &str,
) -> Result<crate::provider::UsageResult, String> {
@@ -689,9 +698,19 @@ async fn query_provider_usage_inner(
});
}
let quota = crate::services::subscription::get_subscription_quota(app_type.as_str())
.await
.map_err(|e| format!("Failed to query subscription quota: {e}"))?;
// xAI OAuth 托管供应商的额度属绑定的 SuperGrok 账号,而非所在 app 的
// CLI 凭据(对 codex/claude 而言 CLI 凭据是 ChatGPT/Claude 订阅,跨了
// 订阅体系,查出来的数字张冠李戴)。
let quota = if provider.map(Provider::is_xai_oauth).unwrap_or(false) {
let account_id = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.managed_account_id_for("xai_oauth"));
crate::commands::xai_oauth::query_xai_oauth_quota_for(xai_state, account_id).await?
} else {
crate::services::subscription::get_subscription_quota(app_type.as_str())
.await
.map_err(|e| format!("Failed to query subscription quota: {e}"))?
};
if !quota.success {
return Ok(crate::provider::UsageResult {
+63
View File
@@ -3,6 +3,7 @@
use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager;
use crate::proxy::providers::XAI_API_BASE_URL;
use crate::services::model_fetch::FetchedModel;
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
use serde::Deserialize;
use std::sync::Arc;
use std::time::Duration;
@@ -11,6 +12,68 @@ use tokio::sync::RwLock;
pub struct XaiOAuthState(pub Arc<RwLock<XaiOAuthManager>>);
/// 查询 xAI OAuth (SuperGrok 反代) 订阅额度的共享核心
///
/// 与 `get_codex_oauth_quota` 平行:数据走 cc-switch 自管的 xAI OAuth token
/// 而非 Grok CLI 的 ~/.grok/auth.json。两者是同一个 OAuth client
/// client_id 与 Grok CLI 一致),token 对 grok.com 账单端点等效,因此
/// 复用 `subscription_grok::query_grok_quota`,协议与 Grok CLI 路径完全一致。
///
/// 供两处调用:`get_xai_oauth_quota` 命令(前端 footer)与
/// `commands::provider` 的 official_subscription 分支(用量脚本/托盘路径,
/// xai_oauth 供应商的额度属绑定的 SuperGrok 账号而非所在 app 的 CLI 凭据)。
///
/// - `account_id` 未指定时回退到 `XaiOAuthManager` 的默认账号
/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染
/// - 瞬时传输失败以 `Err` 传播(前端 reject → retry + 保留上次成功值)
pub(crate) async fn query_xai_oauth_quota_for(
state: &XaiOAuthState,
account_id: Option<String>,
) -> Result<SubscriptionQuota, String> {
let manager = state.0.read().await;
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
let resolved = match account_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
{
Some(id) => Some(id.to_string()),
None => manager.default_account_id().await,
};
let Some(id) = resolved else {
return Ok(SubscriptionQuota::not_found("xai_oauth"));
};
// 获取(必要时自动刷新)access_token
let token = match manager.get_valid_token_for_account(&id).await {
Ok(t) => t,
Err(e) => {
return Ok(SubscriptionQuota::error(
"xai_oauth",
CredentialStatus::Expired,
format!("xAI OAuth token unavailable: {e}"),
));
}
};
crate::services::subscription_grok::query_grok_quota(
&token,
"xai_oauth",
"Please re-login via cc-switch.",
)
.await
}
/// 查询 xAI OAuth (SuperGrok 反代) 订阅额度
#[tauri::command(rename_all = "camelCase")]
pub async fn get_xai_oauth_quota(
account_id: Option<String>,
state: State<'_, XaiOAuthState>,
) -> Result<SubscriptionQuota, String> {
query_xai_oauth_quota_for(&state, account_id).await
}
#[derive(Debug, Deserialize)]
struct ModelsResponse {
#[serde(default)]