mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat: 新增 ZenMux Token Plan 供应商,支持手动凭证与 USD 额度富展示 (#2709)
* feat(Token plan): 增加 ZenMux 支持 * chore: format code with prettier * chore: format code with cargo fmt --------- Co-authored-by: 明桓 <jihaodong.jhd@oceanbase.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -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<crate::provider::UsageData> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ enum CodingPlanProvider {
|
||||
ZhipuEn,
|
||||
MiniMaxCn,
|
||||
MiniMaxEn,
|
||||
ZenMux,
|
||||
}
|
||||
|
||||
fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
@@ -30,6 +31,8 @@ fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
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<QuotaTier> {
|
||||
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<QuotaTier> {
|
||||
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<QuotaTier> {
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,12 @@ pub struct QuotaTier {
|
||||
pub utilization: f64,
|
||||
/// ISO 8601 重置时间
|
||||
pub resets_at: Option<String>,
|
||||
/// ZenMux: 已用额度(USD)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub used_value_usd: Option<f64>,
|
||||
/// ZenMux: 窗口上限(USD)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_value_usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// 超额使用信息
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -902,6 +902,8 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
utilization,
|
||||
resets_at: None,
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user