Files
cc-switch/src-tauri/src/services/coding_plan.rs
T
涂瑜 fafc122d82 fix(coding-plan): correct zhipu weekly tier name by reset time (#2420)
Zhipu's `data.limits[]` returns 1 entry for legacy plans (subscribed
before 2026-02-12) and 2 entries for current plans. Previously every
TOKENS_LIMIT entry was hardcoded as `five_hour`, so the weekly bucket
was rendered with the 5-hour i18n label.

Sort TOKENS_LIMIT entries by nextResetTime ascending and assign
`five_hour` to index 0, `weekly_limit` to index 1. Legacy plans
naturally degrade to a single five_hour tier.

Also harden the parser: case-insensitive type match (defends against
upstream casing changes), reuse TIER_FIVE_HOUR/TIER_WEEKLY_LIMIT
constants, and add 8 unit tests covering both plan shapes plus
defensive edge cases.
2026-04-30 22:38:34 +08:00

613 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 国产 Token Plan 额度查询服务
//!
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
use super::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
};
use std::time::{SystemTime, UNIX_EPOCH};
// ── 供应商检测 ──────────────────────────────────────────────
enum CodingPlanProvider {
Kimi,
ZhipuCn,
ZhipuEn,
MiniMaxCn,
MiniMaxEn,
}
fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
let url = base_url.to_lowercase();
if url.contains("api.kimi.com/coding") {
Some(CodingPlanProvider::Kimi)
} else if url.contains("open.bigmodel.cn") || url.contains("bigmodel.cn") {
Some(CodingPlanProvider::ZhipuCn)
} else if url.contains("api.z.ai") {
Some(CodingPlanProvider::ZhipuEn)
} else if url.contains("api.minimaxi.com") {
Some(CodingPlanProvider::MiniMaxCn)
} else if url.contains("api.minimax.io") {
Some(CodingPlanProvider::MiniMaxEn)
} else {
None
}
}
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn millis_to_iso8601(ms: i64) -> Option<String> {
let secs = ms / 1000;
let nsecs = ((ms % 1000) * 1_000_000) as u32;
chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339())
}
/// 从 JSON 值提取重置时间,兼容字符串和数字格式
/// - 字符串:直接返回(ISO 8601)
/// - 数字:自动判断秒/毫秒并转为 ISO 8601
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
if let Some(s) = value.as_str() {
return Some(s.to_string());
}
if let Some(n) = value.as_i64() {
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
return millis_to_iso8601(ms);
}
None
}
/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`
fn parse_f64(value: &serde_json::Value) -> Option<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
}
fn make_error(msg: String) -> SubscriptionQuota {
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success: false,
tiers: vec![],
extra_usage: None,
error: Some(msg),
queried_at: Some(now_millis()),
}
}
// ── Kimi For Coding ─────────────────────────────────────────
async fn query_kimi(api_key: &str) -> SubscriptionQuota {
let client = crate::proxy::http_client::get();
let resp = client
.get("https://api.kimi.com/coding/v1/usages")
.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}")),
};
let mut tiers = Vec::new();
// 5 小时窗口限额(优先显示)
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
if let Some(detail) = limit_item.get("detail") {
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
let resets_at = detail.get("resetTime").and_then(extract_reset_time);
let used = (limit - remaining).max(0.0);
let utilization = if limit > 0.0 {
(used / limit) * 100.0
} else {
0.0
};
tiers.push(QuotaTier {
name: "five_hour".to_string(),
utilization,
resets_at,
});
}
}
}
// 总体用量(周限额)
if let Some(usage) = body.get("usage") {
let limit = usage.get("limit").and_then(parse_f64).unwrap_or(1.0);
let remaining = usage.get("remaining").and_then(parse_f64).unwrap_or(0.0);
let resets_at = usage.get("resetTime").and_then(extract_reset_time);
let used = (limit - remaining).max(0.0);
let utilization = if limit > 0.0 {
(used / limit) * 100.0
} else {
0.0
};
tiers.push(QuotaTier {
name: "weekly_limit".to_string(),
utilization,
resets_at,
});
}
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success: true,
tiers,
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
}
// ── 智谱 GLM ────────────────────────────────────────────────
/// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。
///
/// 按 `nextResetTime` 升序后:第 0 条 = 五小时桶(`five_hour`)、
/// 第 1 条 = 每周桶(`weekly_limit`)。老套餐(2026-02-12 前订阅)只回 1 条
/// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。
/// 缺失 `nextResetTime` 时按 `i64::MAX` 排到末位。
fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
let mut token_limits: Vec<(i64, f64, Option<String>)> = Vec::new();
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
let limit_type = limit_item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
// 大小写不敏感比较:上游若把 "TOKENS_LIMIT" 改成小写或驼峰,依然能识别
if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") {
continue;
}
let percentage = limit_item
.get("percentage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let reset_ms = limit_item
.get("nextResetTime")
.and_then(|v| v.as_i64())
.unwrap_or(i64::MAX);
let reset_iso = if reset_ms == i64::MAX {
None
} else {
millis_to_iso8601(reset_ms)
};
token_limits.push((reset_ms, percentage, reset_iso));
}
}
token_limits.sort_by_key(|(reset, _, _)| *reset);
token_limits
.into_iter()
.enumerate()
.filter_map(|(idx, (_, percentage, resets_at))| {
let name = match idx {
0 => TIER_FIVE_HOUR,
1 => TIER_WEEKLY_LIMIT,
_ => return None, // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略
};
Some(QuotaTier {
name: name.to_string(),
utilization: percentage,
resets_at,
})
})
.collect()
}
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
let client = crate::proxy::http_client::get();
// 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制)
let resp = client
.get("https://api.z.ai/api/monitor/usage/quota/limit")
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
.header("Content-Type", "application/json")
.header("Accept-Language", "en-US,en")
.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(false) {
let msg = body
.get("msg")
.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 tiers = parse_zhipu_token_tiers(data);
// 套餐等级存入 credential_message
let level = data
.get("level")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: level,
success: true,
tiers,
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
}
// ── MiniMax ─────────────────────────────────────────────────
async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
let client = crate::proxy::http_client::get();
let api_domain = if is_cn {
"api.minimaxi.com"
} else {
"api.minimax.io"
};
let url = format!("https://{api_domain}/v1/api/openplatform/coding_plan/remains");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "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 let Some(base_resp) = body.get("base_resp") {
let status_code = base_resp
.get("status_code")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
if status_code != 0 {
let msg = base_resp
.get("status_msg")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return make_error(format!("API error (code {status_code}): {msg}"));
}
}
let mut tiers = Vec::new();
if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) {
// 只取第一个模型(MiniMax-M*,主力编程模型)
if let Some(item) = model_remains.first() {
// usage_count 是剩余量(满额=total,用完=0),需反转为已用百分比
let interval_total = item
.get("current_interval_total_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let interval_remaining = item
.get("current_interval_usage_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let end_time = item.get("end_time").and_then(|v| v.as_i64());
if interval_total > 0.0 {
tiers.push(QuotaTier {
name: "five_hour".to_string(),
utilization: ((interval_total - interval_remaining) / interval_total) * 100.0,
resets_at: end_time.and_then(millis_to_iso8601),
});
}
// 周额度
let weekly_total = item
.get("current_weekly_total_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let weekly_remaining = item
.get("current_weekly_usage_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let weekly_end = item.get("weekly_end_time").and_then(|v| v.as_i64());
if weekly_total > 0.0 {
tiers.push(QuotaTier {
name: "weekly_limit".to_string(),
utilization: ((weekly_total - weekly_remaining) / weekly_total) * 100.0,
resets_at: weekly_end.and_then(millis_to_iso8601),
});
}
}
}
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success: true,
tiers,
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
}
// ── 公开入口 ────────────────────────────────────────────────
pub async fn get_coding_plan_quota(
base_url: &str,
api_key: &str,
) -> Result<SubscriptionQuota, String> {
if api_key.trim().is_empty() {
return Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::NotFound,
credential_message: None,
success: false,
tiers: vec![],
extra_usage: None,
error: None,
queried_at: None,
});
}
let provider = match detect_provider(base_url) {
Some(p) => p,
None => {
return Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::NotFound,
credential_message: None,
success: false,
tiers: vec![],
extra_usage: None,
error: None,
queried_at: None,
})
}
};
let quota = match provider {
CodingPlanProvider::Kimi => query_kimi(api_key).await,
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,
};
Ok(quota)
}
#[cfg(test)]
mod tests {
use super::{parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
use serde_json::json;
#[test]
fn zhipu_new_plan_two_tiers_sorted_by_reset_time() {
// 新套餐:两条 TOKENS_LIMITnextResetTime 较近的归 five_hour、较远的归 weekly_limit。
// 故意把"周限"放数组前面,验证不依赖输入顺序。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 53.0, "nextResetTime": 2_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TIME_LIMIT", "percentage": 7.0 },
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 44.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 53.0);
}
#[test]
fn zhipu_old_plan_single_tier_falls_back_to_five_hour() {
// 老套餐(2026-02-12 前订阅):仅一条 TOKENS_LIMIT,无周限。
let data = json!({
"limits": [
{
"type": "TOKENS_LIMIT",
"percentage": 2.0,
"nextResetTime": 1_774_967_594_803_i64
},
{ "type": "TIME_LIMIT", "percentage": 0.0 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 2.0);
}
#[test]
fn zhipu_no_token_limits_returns_empty() {
let data = json!({ "limits": [{ "type": "TIME_LIMIT", "percentage": 5.0 }] });
assert!(parse_zhipu_token_tiers(&data).is_empty());
}
#[test]
fn zhipu_missing_reset_time_sorts_last() {
// 防御性:没有 nextResetTime 的条目排到末位,避免抢占 five_hour 槽位。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 99.0 },
{ "type": "TOKENS_LIMIT", "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 10.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 99.0);
assert!(tiers[1].resets_at.is_none());
}
#[test]
fn zhipu_type_is_case_insensitive() {
// 防御性:上游若把 "TOKENS_LIMIT" 改成 "tokens_limit"(仅大小写变化)仍能识别。
// 注意:分隔符差异(如 "TokensLimit" 去掉下划线)不在兼容范围。
let data = json!({
"limits": [
{ "type": "tokens_limit", "percentage": 12.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "Tokens_Limit", "percentage": 34.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 12.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 34.0);
}
#[test]
fn zhipu_invalid_percentage_falls_back_to_zero() {
// percentage 为字符串或 null 时不应崩溃,按 0 处理(仍展示 tier,但用量为 0)。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": "invalid", "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": null, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].utilization, 0.0);
assert_eq!(tiers[1].utilization, 0.0);
}
#[test]
fn zhipu_extreme_percentage_values_pass_through() {
// 负数 / 超 100 不做范围裁剪——下游渲染层负责显示策略,解析层只负责忠实搬运。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": -5.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 150.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].utilization, -5.0);
assert_eq!(tiers[1].utilization, 150.0);
}
#[test]
fn zhipu_more_than_two_token_limits_keeps_first_two() {
// 防御性:智谱当前最多两条 TOKENS_LIMIT,若上游意外增加第三条应被丢弃,避免命名空缺。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 1.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 2.0, "nextResetTime": 2_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 3.0, "nextResetTime": 3_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
}
}