//! 国产 Token Plan 额度查询服务 //! //! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。 //! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。 use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota}; use std::time::{SystemTime, UNIX_EPOCH}; // ── 供应商检测 ────────────────────────────────────────────── enum CodingPlanProvider { Kimi, ZhipuCn, ZhipuEn, MiniMaxCn, MiniMaxEn, } 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 { 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(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 ──────────────────────────────────────────────── 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 mut tiers = 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(""); let percentage = limit_item .get("percentage") .and_then(|v| v.as_f64()) .unwrap_or(0.0); let next_reset = limit_item .get("nextResetTime") .and_then(|v| v.as_i64()) .and_then(millis_to_iso8601); if limit_type != "TOKENS_LIMIT" { continue; } tiers.push(QuotaTier { name: "five_hour".to_string(), utilization: percentage, resets_at: next_reset, }); } } // 套餐等级存入 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 { 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) }