From c1dff06625806a68071e9745812eb08603bfb2c5 Mon Sep 17 00:00:00 2001 From: Eter Date: Wed, 3 Jun 2026 14:45:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20ZenMux=20Token=20P?= =?UTF-8?q?lan=20=E4=BE=9B=E5=BA=94=E5=95=86=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=87=AD=E8=AF=81=E4=B8=8E=20USD=20=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E5=AF=8C=E5=B1=95=E7=A4=BA=20(#2709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Token plan): 增加 ZenMux 支持 * chore: format code with prettier * chore: format code with cargo fmt --------- Co-authored-by: 明桓 Co-authored-by: Jason --- src-tauri/src/commands/provider.rs | 147 ++++++++++++++++++++- src-tauri/src/services/coding_plan.rs | 146 ++++++++++++++++++++ src-tauri/src/services/subscription.rs | 14 ++ src-tauri/src/tray.rs | 2 + src/components/SubscriptionQuotaFooter.tsx | 7 + src/components/UsageFooter.tsx | 37 +++++- src/components/UsageScriptModal.tsx | 84 +++++++++++- src/config/codingPlanProviders.ts | 7 +- src/icons/extracted/index.ts | 1 + src/icons/extracted/metadata.ts | 7 + src/types/subscription.ts | 3 + 11 files changed, 438 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index fcd3ec233..d0dd4043c 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -417,6 +417,42 @@ fn resolve_native_credentials(app_type: &AppType, provider: Option<&Provider>) - .unwrap_or_default() } +fn resolve_coding_plan_credentials( + app_type: &AppType, + provider: Option<&Provider>, + usage_script: Option<&crate::provider::UsageScript>, +) -> (String, String) { + let is_zenmux = usage_script + .and_then(|s| s.coding_plan_provider.as_deref()) + .map(|provider| provider.eq_ignore_ascii_case("zenmux")) + .unwrap_or(false); + + if !is_zenmux { + return resolve_native_credentials(app_type, provider); + } + + let script_base_url = usage_script + .and_then(|s| s.base_url.as_deref()) + .unwrap_or("") + .trim_end_matches('/') + .to_string(); + let script_api_key = usage_script + .and_then(|s| s.api_key.as_deref()) + .unwrap_or("") + .to_string(); + + if !script_base_url.is_empty() && !script_api_key.is_empty() { + return (script_base_url, script_api_key); + } + + let native = resolve_native_credentials(app_type, provider); + if !native.0.is_empty() && !native.1.is_empty() { + native + } else { + (script_base_url, script_api_key) + } +} + async fn query_provider_usage_inner( state: &AppState, copilot_state: &CopilotAuthState, @@ -474,8 +510,8 @@ async fn query_provider_usage_inner( // ── Coding Plan 专用路径 ── if template_type == TEMPLATE_TYPE_TOKEN_PLAN { - // 从供应商配置中提取 API Key 和 Base URL(按 app 区分存储格式) - let (base_url, api_key) = resolve_native_credentials(&app_type, provider); + let (base_url, api_key) = + resolve_coding_plan_credentials(&app_type, provider, usage_script); let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key) .await @@ -490,6 +526,19 @@ async fn query_provider_usage_inner( }); } + // ZenMux 的 tier 携带 USD 额度信息,需要编码为 JSON extra + let has_usd = quota + .tiers + .first() + .map(|t| t.used_value_usd.is_some()) + .unwrap_or(false); + let plan_label = quota + .credential_message + .as_deref() + .and_then(|msg| msg.split(' ').next()) + .map(|tier| format!("ZenMux·{}", tier.to_uppercase())); + let mut first_tier = true; + let data: Vec = quota .tiers .iter() @@ -497,6 +546,26 @@ async fn query_provider_usage_inner( let total = 100.0; let used = tier.utilization; let remaining = total - used; + let extra = if has_usd { + let mut extra_json = serde_json::json!({ + "resetsAt": tier.resets_at, + }); + if let Some(v) = tier.used_value_usd { + extra_json["usedValueUsd"] = serde_json::json!(v); + } + if let Some(v) = tier.max_value_usd { + extra_json["maxValueUsd"] = serde_json::json!(v); + } + if first_tier { + if let Some(ref label) = plan_label { + extra_json["planLabel"] = serde_json::json!(label); + } + first_tier = false; + } + Some(extra_json.to_string()) + } else { + tier.resets_at.clone() + }; crate::provider::UsageData { plan_name: Some(tier.name.clone()), remaining: Some(remaining), @@ -505,7 +574,7 @@ async fn query_provider_usage_inner( unit: Some("%".to_string()), is_valid: Some(true), invalid_message: None, - extra: tier.resets_at.clone(), + extra, } }) .collect(); @@ -950,11 +1019,31 @@ mod import_claude_desktop_tests { #[cfg(test)] mod native_query_credentials_tests { - use super::resolve_native_credentials; + use super::{resolve_coding_plan_credentials, resolve_native_credentials}; use crate::app_config::AppType; - use crate::provider::Provider; + use crate::provider::{Provider, UsageScript}; use serde_json::json; + fn usage_script( + coding_plan_provider: Option<&str>, + base_url: Option<&str>, + api_key: Option<&str>, + ) -> UsageScript { + UsageScript { + enabled: true, + language: "javascript".to_string(), + code: String::new(), + timeout: Some(10), + api_key: api_key.map(str::to_string), + base_url: base_url.map(str::to_string), + access_token: None, + user_id: None, + template_type: Some("token_plan".to_string()), + auto_query_interval: None, + coding_plan_provider: coding_plan_provider.map(str::to_string), + } + } + #[test] fn delegates_to_provider_for_codex() { let provider = Provider::with_id( @@ -979,4 +1068,52 @@ mod native_query_credentials_tests { assert!(base_url.is_empty()); assert!(api_key.is_empty()); } + + #[test] + fn zenmux_coding_plan_uses_script_credentials_first() { + let provider = Provider::with_id( + "test".to_string(), + "Test".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://provider.zenmux.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-provider" + } + }), + None, + ); + let script = usage_script( + Some("zenmux"), + Some("https://script.zenmux.example/api/usage/"), + Some("sk-script"), + ); + + let (base_url, api_key) = + resolve_coding_plan_credentials(&AppType::Claude, Some(&provider), Some(&script)); + + assert_eq!(base_url, "https://script.zenmux.example/api/usage"); + assert_eq!(api_key, "sk-script"); + } + + #[test] + fn zenmux_coding_plan_falls_back_to_provider_credentials() { + let provider = Provider::with_id( + "test".to_string(), + "Test".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://provider.zenmux.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-provider" + } + }), + None, + ); + let script = usage_script(Some("zenmux"), Some("https://script.zenmux.example"), None); + + let (base_url, api_key) = + resolve_coding_plan_credentials(&AppType::Claude, Some(&provider), Some(&script)); + + assert_eq!(base_url, "https://provider.zenmux.example/v1"); + assert_eq!(api_key, "sk-provider"); + } } diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index e872e7fde..1798b10c5 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -16,6 +16,7 @@ enum CodingPlanProvider { ZhipuEn, MiniMaxCn, MiniMaxEn, + ZenMux, } fn detect_provider(base_url: &str) -> Option { @@ -30,6 +31,8 @@ fn detect_provider(base_url: &str) -> Option { Some(CodingPlanProvider::MiniMaxCn) } else if url.contains("api.minimax.io") { Some(CodingPlanProvider::MiniMaxEn) + } else if url.contains("zenmux") { + Some(CodingPlanProvider::ZenMux) } else { None } @@ -145,6 +148,8 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { name: "five_hour".to_string(), utilization, resets_at, + used_value_usd: None, + max_value_usd: None, }); } } @@ -166,6 +171,8 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { name: "weekly_limit".to_string(), utilization, resets_at, + used_value_usd: None, + max_value_usd: None, }); } @@ -225,6 +232,8 @@ fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec { name: name.to_string(), utilization: percentage, resets_at, + used_value_usd: None, + max_value_usd: None, }) }) .collect() @@ -385,6 +394,138 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { } } +// ── ZenMux ────────────────────────────────────────────────── + +async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let resp = client + .get(base_url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }; + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + // 检查业务级别错误 + if body.get("success").and_then(|v| v.as_bool()) != Some(true) { + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return make_error(format!("API error: {msg}")); + } + + let data = match body.get("data") { + Some(d) => d, + None => return make_error("Missing 'data' field in response".to_string()), + }; + + let mut tiers = Vec::new(); + + // 5 小时窗口限额 + if let Some(q5h) = data.get("quota_5_hour") { + let usage_pct = q5h + .get("usage_percentage") + .and_then(parse_f64) + .unwrap_or(0.0); + let resets_at = q5h + .get("resets_at") + .and_then(|v| v.as_str()) + .map(String::from); + let used_usd = q5h.get("used_value_usd").and_then(parse_f64); + let max_usd = q5h.get("max_value_usd").and_then(parse_f64); + tiers.push(QuotaTier { + name: "five_hour".to_string(), + utilization: usage_pct * 100.0, + resets_at, + used_value_usd: used_usd, + max_value_usd: max_usd, + }); + } + + // 7 天窗口限额 + if let Some(q7d) = data.get("quota_7_day") { + let usage_pct = q7d + .get("usage_percentage") + .and_then(parse_f64) + .unwrap_or(0.0); + let resets_at = q7d + .get("resets_at") + .and_then(|v| v.as_str()) + .map(String::from); + let used_usd = q7d.get("used_value_usd").and_then(parse_f64); + let max_usd = q7d.get("max_value_usd").and_then(parse_f64); + tiers.push(QuotaTier { + name: "weekly_limit".to_string(), + utilization: usage_pct * 100.0, + resets_at, + used_value_usd: used_usd, + max_value_usd: max_usd, + }); + } + + // 套餐等级和账户状态存入 credential_message + let plan_tier = data + .get("plan") + .and_then(|p| p.get("tier")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let account_status = data + .get("account_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let plan_info = if !plan_tier.is_empty() { + format!("{plan_tier} ({account_status})") + } else { + String::new() + }; + + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: if plan_info.is_empty() { + None + } else { + Some(plan_info) + }, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + /// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。 /// /// 新接口语义:`current_*_remaining_percent` 是"剩余百分比"(0-100), @@ -423,6 +564,8 @@ fn parse_minimax_tiers(body: &serde_json::Value) -> Vec { name: TIER_FIVE_HOUR.to_string(), utilization: 100.0 - remain_pct, resets_at, + used_value_usd: None, + max_value_usd: None, }); } @@ -440,6 +583,8 @@ fn parse_minimax_tiers(body: &serde_json::Value) -> Vec { name: TIER_WEEKLY_LIMIT.to_string(), utilization: 100.0 - remain_pct, resets_at, + used_value_usd: None, + max_value_usd: None, }); } } @@ -487,6 +632,7 @@ pub async fn get_coding_plan_quota( CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await, CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await, CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await, + CodingPlanProvider::ZenMux => query_zenmux(base_url, api_key).await, }; Ok(quota) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 0349bfeb9..c7a6fa66c 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -32,6 +32,12 @@ pub struct QuotaTier { pub utilization: f64, /// ISO 8601 重置时间 pub resets_at: Option, + /// ZenMux: 已用额度(USD) + #[serde(skip_serializing_if = "Option::is_none")] + pub used_value_usd: Option, + /// ZenMux: 窗口上限(USD) + #[serde(skip_serializing_if = "Option::is_none")] + pub max_value_usd: Option, } /// 超额使用信息 @@ -377,6 +383,8 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { name: tier_name.to_string(), utilization: util, resets_at: w.resets_at, + used_value_usd: None, + max_value_usd: None, }); } } @@ -395,6 +403,8 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { name: key.clone(), utilization: util, resets_at: w.resets_at, + used_value_usd: None, + max_value_usd: None, }); } } @@ -714,6 +724,8 @@ pub(crate) async fn query_codex_quota( .unwrap_or_else(|| "unknown".to_string()), utilization: used, resets_at: window.reset_at.and_then(unix_ts_to_iso), + used_value_usd: None, + max_value_usd: None, }); } } @@ -1179,6 +1191,8 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { name, utilization: (1.0 - remaining) * 100.0, resets_at: reset_time, + used_value_usd: None, + max_value_usd: None, }) .collect(); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 71842a5ad..1c30145cf 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -902,6 +902,8 @@ mod tests { name: name.to_string(), utilization, resets_at: None, + used_value_usd: None, + max_value_usd: None, } } diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index a07914080..77bc1f2c0 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -309,6 +309,8 @@ export const TierBadge: React.FC<{ : tier.name; const countdown = countdownStr(tier.resetsAt); + const hasUsd = tier.usedValueUsd != null && tier.maxValueUsd != null; + return (
{label}: @@ -317,6 +319,11 @@ export const TierBadge: React.FC<{ > {t("subscription.utilization", { value: Math.round(tier.utilization) })} + {hasUsd && ( + + (${tier.usedValueUsd!.toFixed(2)}/${tier.maxValueUsd!.toFixed(2)}) + + )} {countdown && ( diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index 9b0654e52..397ab784c 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -19,10 +19,26 @@ interface UsageFooterProps { /** UsageData → QuotaTier 转换(Token Plan 使用) */ function toQuotaTier(data: UsageData): QuotaTier { + const extra = data.extra; + if (extra && extra.startsWith("{")) { + try { + const parsed = JSON.parse(extra); + return { + name: data.planName || "", + utilization: data.used || 0, + resetsAt: parsed.resetsAt || null, + usedValueUsd: parsed.usedValueUsd ?? null, + maxValueUsd: parsed.maxValueUsd ?? null, + planLabel: parsed.planLabel ?? null, + }; + } catch { + // fall through to plain string + } + } return { name: data.planName || "", utilization: data.used || 0, - resetsAt: data.extra || null, + resetsAt: extra || null, }; } @@ -147,9 +163,22 @@ const UsageFooter: React.FC = ({
{/* 第二行:tier 徽章(复用官方订阅的 TierBadge) */}
- {usageDataList.map((data, index) => ( - - ))} + {(() => { + const tiers = usageDataList.map((d) => toQuotaTier(d)); + const planLabel = tiers[0]?.planLabel; + return ( + <> + {planLabel && ( + + 💰 {planLabel} + + )} + {tiers.map((tier, index) => ( + + ))} + + ); + })()}
); diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 9047e2c21..84a21e194 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -444,8 +444,14 @@ const UsageScriptModal: React.FC = ({ // Coding Plan 模板使用专用 API if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) { - const baseUrl = providerCredentials.baseUrl ?? ""; - const apiKey = providerCredentials.apiKey ?? ""; + // ZenMux 使用用户在脚本配置中手动填入的 API Key 和 Base URL + const isZenMux = script.codingPlanProvider === "zenmux"; + const baseUrl = isZenMux + ? (script.baseUrl ?? "") + : (providerCredentials.baseUrl ?? ""); + const apiKey = isZenMux + ? (script.apiKey ?? "") + : (providerCredentials.apiKey ?? ""); const { subscriptionApi } = await import("@/lib/api/subscription"); const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey); if (quota.success && quota.tiers.length > 0) { @@ -626,15 +632,17 @@ const UsageScriptModal: React.FC = ({ const autoDetected = detectCodingPlanProvider( providerCredentials.baseUrl, ); + const provider = script.codingPlanProvider || autoDetected || "kimi"; + // ZenMux 允许手动填写 API Key 和 Base URL,不清除 + const isZenMux = provider === "zenmux"; setScript({ ...script, code: "", - apiKey: undefined, - baseUrl: undefined, + apiKey: isZenMux ? script.apiKey : undefined, + baseUrl: isZenMux ? script.baseUrl : undefined, accessToken: undefined, userId: undefined, - codingPlanProvider: - script.codingPlanProvider || autoDetected || "kimi", + codingPlanProvider: provider, }); } else if (presetName === TEMPLATE_TYPES.BALANCE) { // 官方余额查询模板不需要脚本,使用 Rust 原生查询 @@ -653,7 +661,9 @@ const UsageScriptModal: React.FC = ({ const shouldShowCredentialsConfig = selectedTemplate === TEMPLATE_TYPES.GENERAL || - selectedTemplate === TEMPLATE_TYPES.NEW_API; + selectedTemplate === TEMPLATE_TYPES.NEW_API || + (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && + script.codingPlanProvider === "zenmux"); const footer = ( <> @@ -1050,6 +1060,66 @@ const UsageScriptModal: React.FC = ({ )} + + {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && + script.codingPlanProvider === "zenmux" && ( + <> +
+ + + setScript({ ...script, baseUrl: e.target.value }) + } + placeholder="https://api.zenmux.com/v1/..." + autoComplete="off" + className="border-white/10" + /> +
+ +
+ +
+ + setScript({ + ...script, + apiKey: e.target.value, + }) + } + placeholder="sk-..." + autoComplete="off" + className="border-white/10" + /> + {script.apiKey && ( + + )} +
+
+ + )} )} diff --git a/src/config/codingPlanProviders.ts b/src/config/codingPlanProviders.ts index f2fc75440..fee5b908e 100644 --- a/src/config/codingPlanProviders.ts +++ b/src/config/codingPlanProviders.ts @@ -11,7 +11,7 @@ import { TEMPLATE_TYPES } from "@/config/constants"; export interface CodingPlanProviderEntry { /** 与后端 QuotaTier 的 `codingPlanProvider` 取值对齐 */ - id: "kimi" | "zhipu" | "minimax"; + id: "kimi" | "zhipu" | "minimax" | "zenmux"; /** UsageScriptModal 下拉显示用 */ label: string; /** base_url 匹配规则 */ @@ -30,6 +30,11 @@ export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [ label: "MiniMax", pattern: /api\.minimaxi?\.com|api\.minimax\.io/i, }, + { + id: "zenmux", + label: "ZenMux", + pattern: /zenmux\./i, + }, ] as const; /** 根据 Base URL 自动检测 Coding Plan 供应商;未命中返回 null */ diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index dd7143fb8..d11976b52 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -52,6 +52,7 @@ export const icons: Record = { meta: `Meta`, midjourney: `Midjourney`, minimax: `Minimax`, + zenmux: `ZenMux`, mistral: `Mistral`, newapi: `NewAPI`, notion: `Notion`, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index df8ad7250..b589dc691 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -306,6 +306,13 @@ export const iconMetadata: Record = { keywords: ["minimax"], defaultColor: "#FF6B6B", }, + zenmux: { + name: "zenmux", + displayName: "ZenMux", + category: "ai-provider", + keywords: ["zenmux", "zen", "mux"], + defaultColor: "#6366F1", + }, mistral: { name: "mistral", displayName: "Mistral", diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 3e1157caf..a9f0fd906 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -8,6 +8,9 @@ export interface QuotaTier { name: string; utilization: number; // 0-100 resetsAt: string | null; + usedValueUsd?: number | null; + maxValueUsd?: number | null; + planLabel?: string | null; } export interface ExtraUsage {