mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
fafc122d82
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.
613 lines
22 KiB
Rust
613 lines
22 KiB
Rust
//! 国产 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_LIMIT,nextResetTime 较近的归 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);
|
||
}
|
||
}
|