//! 国产 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, ZenMux, } fn detect_provider(base_url: &str) -> Option { 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 if url.contains("zenmux") { Some(CodingPlanProvider::ZenMux) } 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 { 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 { 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 { 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(15)) .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, used_value_usd: None, max_value_usd: None, }); } } } // 总体用量(周限额) 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, used_value_usd: None, max_value_usd: None, }); } 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 ──────────────────────────────────────────────── /// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。 enum ZhipuWindow { FiveHour, Weekly, } /// 按 `unit` 字段判定 TOKENS_LIMIT 条目所属窗口。 /// /// 实测形态(bigmodel.cn 与 z.ai 共用同一后端,字段一致): /// - `unit: 3, number: 5` → 5 小时滚动窗口(老/新套餐均有) /// - `unit: 6, number: 7` 与 `unit: 6, number: 1` → 每周窗口(两种取值都被 /// 实测过,故只锚定 `unit`、不绑 `number`) /// /// `unit` 缺失或值不认识时返回 None,由调用方走重置时间启发式兜底。 fn classify_zhipu_window(item: &serde_json::Value) -> Option { match item.get("unit").and_then(|v| v.as_i64()) { Some(3) => Some(ZhipuWindow::FiveHour), Some(6) => Some(ZhipuWindow::Weekly), _ => None, } } /// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。 /// /// 分类优先级: /// 1. 显式字段:`unit` 标识窗口类型(见 [`classify_zhipu_window`])。不能按 /// `nextResetTime` 排序代替——周期末尾每周窗口会比 5 小时窗口更早重置 /// (issue #3036),时间排序在该场景必然把两桶标反。 /// 2. 兜底启发式(`unit` 缺失或不识别):无 `nextResetTime` 的条目优先归 /// five_hour(5 小时桶在 0% 等状态下可能没有 reset),其余按 reset 升序 /// 依次填入仍空缺的槽位。 /// /// 老套餐(2026-02-12 前订阅)只回 1 条 /// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。 fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec { type Entry = (Option, f64, Option); let mut five_hour: Option = None; let mut weekly: Option = None; let mut unclassified: Vec = 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()); let reset_iso = reset_ms.and_then(millis_to_iso8601); let entry = (reset_ms, percentage, reset_iso); match classify_zhipu_window(limit_item) { Some(ZhipuWindow::FiveHour) if five_hour.is_none() => five_hour = Some(entry), Some(ZhipuWindow::Weekly) if weekly.is_none() => weekly = Some(entry), _ => unclassified.push(entry), } } } unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN))); for entry in unclassified { if five_hour.is_none() { five_hour = Some(entry); } else if weekly.is_none() { weekly = Some(entry); } // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略 } let mut tiers = Vec::new(); for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] { if let Some((_, percentage, resets_at)) = slot { tiers.push(QuotaTier { name: name.to_string(), utilization: percentage, resets_at, used_value_usd: None, max_value_usd: None, }); } } tiers } /// Resolve the Zhipu quota endpoint from the user's configured `base_url`. /// /// Zhipu ships as two distinct presets (Zhipu GLM = `open.bigmodel.cn`, /// Zhipu GLM en = `api.z.ai`) that share the same quota path and JSON shape. /// The quota endpoint lives on the same host as the user's coding endpoint, /// so we route by `base_url` and let the caller's existing reachability /// (they're already using this host to run coding) determine success — no /// cross-host fallback, no auth-error heuristics. fn zhipu_quota_base(base_url: &str) -> &'static str { if base_url.to_lowercase().contains("bigmodel.cn") { "https://open.bigmodel.cn" } else { "https://api.z.ai" } } async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { let client = crate::proxy::http_client::get(); let url = format!( "{}/api/monitor/usage/quota/limit", zhipu_quota_base(base_url) ); let resp = client .get(&url) .header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀 .header("Content-Type", "application/json") .header("Accept-Language", "en-US,en") .timeout(std::time::Duration::from_secs(15)) .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(15)) .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}")); } } // 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比 let tiers = parse_minimax_tiers(&body); 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()), } } // ── 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(15)) .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), /// `model_remains` 数组里有 `general`(编程套餐)和 `video` 等其他模型, /// 这里只取 `general`,跳过 video。 /// /// 5h 桶始终存在;周桶并非所有套餐都有,靠 `current_weekly_status == 1` /// 判定激活(无周限额套餐该字段为 3,`remaining_percent` 恒为 100,不应展示)。 fn parse_minimax_tiers(body: &serde_json::Value) -> Vec { let mut tiers = Vec::new(); let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) else { return tiers; }; // 只取 model_name == "general" 的条目,跳过 video 等非编程模型 let Some(item) = model_remains.iter().find(|item| { item.get("model_name") .and_then(|v| v.as_str()) .map(|s| s == "general") .unwrap_or(false) }) else { return tiers; }; // 5h 桶:剩余百分比 → 已用百分比 if let Some(remain_pct) = item .get("current_interval_remaining_percent") .and_then(|v| v.as_f64()) { let resets_at = item .get("end_time") .and_then(|v| v.as_i64()) .and_then(millis_to_iso8601); tiers.push(QuotaTier { name: TIER_FIVE_HOUR.to_string(), utilization: 100.0 - remain_pct, resets_at, used_value_usd: None, max_value_usd: None, }); } // 周桶:仅当 status=1 时激活;status=3 等表示该套餐无周限额,跳过 if item.get("current_weekly_status").and_then(|v| v.as_i64()) == Some(1) { if let Some(remain_pct) = item .get("current_weekly_remaining_percent") .and_then(|v| v.as_f64()) { let resets_at = item .get("weekly_end_time") .and_then(|v| v.as_i64()) .and_then(millis_to_iso8601); tiers.push(QuotaTier { name: TIER_WEEKLY_LIMIT.to_string(), utilization: 100.0 - remain_pct, resets_at, used_value_usd: None, max_value_usd: None, }); } } tiers } // ── 公开入口 ──────────────────────────────────────────────── pub async fn get_coding_plan_quota( base_url: &str, api_key: &str, ) -> Result { 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, // 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败 error: Some("API key is empty".to_string()), 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: Some("Unknown coding plan provider".to_string()), queried_at: None, }); } }; let quota = match provider { CodingPlanProvider::Kimi => query_kimi(api_key).await, CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => { query_zhipu(base_url, 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) } #[cfg(test)] mod tests { use super::{ parse_minimax_tiers, parse_zhipu_token_tiers, zhipu_quota_base, 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_is_five_hour_when_weekly_has_reset() { // 真实反馈:5 小时桶为 0% 时可能没有 nextResetTime;每周桶带 reset。 // 这种形态不能按 reset 升序把每周桶误判为 five_hour。 let data = json!({ "limits": [ { "type": "TOKENS_LIMIT", "percentage": 25.0, "nextResetTime": 2_000_000_000_000_i64 }, { "type": "TOKENS_LIMIT", "percentage": 0.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, 0.0); assert!(tiers[0].resets_at.is_none()); assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); assert_eq!(tiers[1].utilization, 25.0); assert!(tiers[1].resets_at.is_some()); } #[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_unit_field_overrides_reset_order_when_weekly_resets_sooner() { // 真实案例(issue #3036,2026-06-10 再次复现):每周周期末尾,周桶比 // 5 小时桶更早重置。官网真实值:5h 用 1%(约 5h 后重置)、每周用 42% // (约 1h 后重置)。旧逻辑按 reset 升序必然标反,unit 字段须优先。 let data = json!({ "limits": [ { "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_003_600_000_i64 }, { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 1.0, "nextResetTime": 1_000_018_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, 1.0); assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); assert_eq!(tiers[1].utilization, 42.0); } #[test] fn zhipu_weekly_unit_six_number_one_variant() { // z.ai 也观测过 (unit:6, number:1) 表示每周窗口(按"1 周"计), // 分类只看 unit,number 取值不影响。 let data = json!({ "limits": [ { "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 30.0, "nextResetTime": 1_000_000_000_000_i64 }, { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.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, 10.0); assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); assert_eq!(tiers[1].utilization, 30.0); } #[test] fn zhipu_partial_unit_fields_fill_remaining_slot() { // 只有周桶带 unit 时,缺 unit 的另一条应填入剩下的 five_hour 槽位, // 即便它的 reset 更晚——显式分类结果不受时间排序干扰。 let data = json!({ "limits": [ { "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_000_000_000_i64 }, { "type": "TOKENS_LIMIT", "percentage": 1.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, 1.0); assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); assert_eq!(tiers[1].utilization, 42.0); } #[test] fn zhipu_unknown_unit_values_fall_back_to_reset_order() { // 未识别的 unit 枚举值不猜语义,整体回落旧的重置时间启发式。 let data = json!({ "limits": [ { "type": "TOKENS_LIMIT", "unit": 9, "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 }, { "type": "TOKENS_LIMIT", "unit": 9, "percentage": 53.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, 44.0); assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); assert_eq!(tiers[1].utilization, 53.0); } #[test] fn zhipu_duplicate_unit_classification_fills_other_slot() { // 防御性:两条都标成 5 小时窗(上游异常)时,第一条占 five_hour, // 第二条降级走兜底填入 weekly,保证不丢数据也不 panic。 let data = json!({ "limits": [ { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 }, { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 20.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, 10.0); assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); assert_eq!(tiers[1].utilization, 20.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); } // ── MiniMax ── #[test] fn minimax_general_two_tiers_from_remaining_percent() { // 主路径:general 桶 5h 剩 98% / weekly 剩 95% → 已用 2% / 5% let body = json!({ "model_remains": [ { "model_name": "general", "current_interval_remaining_percent": 98.0, "current_weekly_remaining_percent": 95.0, "current_interval_status": 1, "current_weekly_status": 1, "end_time": 1_780_329_600_000_i64, "weekly_end_time": 1_780_848_000_000_i64 }, { "model_name": "video", "current_interval_remaining_percent": 100.0, "current_weekly_remaining_percent": 100.0 } ], "base_resp": { "status_code": 0, "status_msg": "success" } }); let tiers = parse_minimax_tiers(&body); assert_eq!(tiers.len(), 2); assert_eq!(tiers[0].name, TIER_FIVE_HOUR); assert_eq!(tiers[0].utilization, 2.0); assert!(tiers[0].resets_at.is_some()); assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); assert_eq!(tiers[1].utilization, 5.0); assert!(tiers[1].resets_at.is_some()); } #[test] fn minimax_skips_video_and_finds_general_in_any_position() { // 防御性:即使 video 排在数组前面,general 排在后面,仍应被定位到。 let body = json!({ "model_remains": [ { "model_name": "video", "current_interval_remaining_percent": 50.0, "current_weekly_remaining_percent": 50.0 }, { "model_name": "general", "current_interval_remaining_percent": 80.0, "current_weekly_remaining_percent": 70.0, "current_interval_status": 1, "current_weekly_status": 1 } ] }); let tiers = parse_minimax_tiers(&body); assert_eq!(tiers.len(), 2); // 取的是 general 桶,不是 video(20%/30% 而非 50%/50%) assert_eq!(tiers[0].utilization, 20.0); assert_eq!(tiers[1].utilization, 30.0); } #[test] fn minimax_missing_general_returns_empty() { // model_remains 只有 video / 空 / 缺字段 → 不应崩溃,tiers 为空 let body = json!({ "model_remains": [ { "model_name": "video", "current_interval_remaining_percent": 100.0, "current_weekly_remaining_percent": 100.0 } ] }); assert!(parse_minimax_tiers(&body).is_empty()); let body_empty: serde_json::Value = json!({ "model_remains": [] }); assert!(parse_minimax_tiers(&body_empty).is_empty()); let body_no_field = json!({}); assert!(parse_minimax_tiers(&body_no_field).is_empty()); } #[test] fn minimax_missing_percent_fields_skips_tier() { // 字段缺失时只跳过对应桶,另一边仍能展示 let body = json!({ "model_remains": [{ "model_name": "general", "current_interval_remaining_percent": 60.0, "current_weekly_status": 1 // 缺 current_weekly_remaining_percent }] }); let tiers = parse_minimax_tiers(&body); assert_eq!(tiers.len(), 1); assert_eq!(tiers[0].name, TIER_FIVE_HOUR); assert_eq!(tiers[0].utilization, 40.0); } #[test] fn minimax_negative_percent_passes_through() { // 防御性:与 parse_zhipu_token_tiers 约定一致,负数 / 超 100 不做范围裁剪 let body = json!({ "model_remains": [{ "model_name": "general", "current_interval_remaining_percent": -5.0, "current_weekly_remaining_percent": 150.0, "current_interval_status": 1, "current_weekly_status": 1 }] }); let tiers = parse_minimax_tiers(&body); assert_eq!(tiers.len(), 2); assert_eq!(tiers[0].utilization, 105.0); // 100 - (-5) assert_eq!(tiers[1].utilization, -50.0); // 100 - 150 } #[test] fn minimax_weekly_status_3_skips_weekly_tier() { // 无周限额套餐:current_weekly_status=3,remaining_percent 恒为 100, // 不应推 weekly_limit tier(否则会显示"0% 已用"的假周桶) let body = json!({ "model_remains": [ { "model_name": "general", "start_time": 1_780_347_600_000_i64, "end_time": 1_780_365_600_000_i64, "remains_time": 4_161_372_i64, "current_interval_remaining_percent": 99, "current_interval_status": 1, "current_weekly_total_count": 0, "current_weekly_usage_count": 0, "weekly_start_time": 1_780_243_200_000_i64, "weekly_end_time": 1_780_848_000_000_i64, "weekly_remains_time": 486_561_372_i64, "current_weekly_status": 3, "current_weekly_remaining_percent": 100 }, { "model_name": "video", "current_interval_remaining_percent": 100, "current_weekly_status": 3, "current_weekly_remaining_percent": 100 } ], "base_resp": { "status_code": 0, "status_msg": "success" } }); let tiers = parse_minimax_tiers(&body); assert_eq!(tiers.len(), 1); assert_eq!(tiers[0].name, TIER_FIVE_HOUR); assert_eq!(tiers[0].utilization, 1.0); assert!(tiers[0].resets_at.is_some()); } #[test] fn minimax_weekly_status_2_also_skips_weekly_tier() { // 防御性:除 1 之外的 status 都视为周桶未激活,跳过 let body = json!({ "model_remains": [{ "model_name": "general", "current_interval_remaining_percent": 80.0, "current_weekly_remaining_percent": 50.0, "current_weekly_status": 2 }] }); let tiers = parse_minimax_tiers(&body); assert_eq!(tiers.len(), 1); assert_eq!(tiers[0].name, TIER_FIVE_HOUR); assert_eq!(tiers[0].utilization, 20.0); } #[test] fn zhipu_quota_base_routes_bigmodel_url_to_cn_endpoint() { assert_eq!( zhipu_quota_base("https://open.bigmodel.cn/api/paas/v4"), "https://open.bigmodel.cn" ); } #[test] fn zhipu_quota_base_routes_z_ai_url_to_en_endpoint() { assert_eq!( zhipu_quota_base("https://api.z.ai/api/paas/v4"), "https://api.z.ai" ); } #[test] fn zhipu_quota_base_defaults_to_en_for_unknown_url() { // 没有明显 Zhipu 域名特征时,默认走国际站(更通用的入口) assert_eq!( zhipu_quota_base("https://example.com/zhipu"), "https://api.z.ai" ); } #[test] fn zhipu_quota_base_routes_uppercase_cn_url_to_cn_endpoint() { // 大小写不敏感:与 detect_provider 保持一致的约定,避免大写 preset URL 静默路由到国际站 assert_eq!( zhipu_quota_base("HTTPS://OPEN.BIGMODEL.CN/api/paas/v4"), "https://open.bigmodel.cn" ); assert_eq!( zhipu_quota_base("https://Open.BigModel.cn/api/paas/v4"), "https://open.bigmodel.cn" ); } }