Files
CC-Switch/src-tauri/src/services/coding_plan.rs
T
zhanxin.xu 95c917b337 feat(provider): add Zhipu team plan quota query support (#5128)
智谱团队套餐(Team Plan)的额度查询与个人版不同:同一 quota 端点加
`?type=2`,需额外 `bigmodel-organization` / `bigmodel-project` 请求头
(api_key + 组织 ID + 项目 ID 三者缺一不可),且仅存在于国内站
open.bigmodel.cn。参考 token-monitor/src/shared/zaiTeamLimits.js 实现。

- Backend (services/coding_plan.rs): 新增 query_zhipu_team(固定 CN 站、
  ?type=2、org/project 头);抽出 zhipu_quota_from_body 与个人版共用解析;
  入口 get_coding_plan_quota 靠显式 coding_plan_provider == "zhipu_team"
  路由(base_url 与个人版智谱相同,detect_provider 无法区分)。新字段经
  UsageScript、IPC 命令、后台查询路径(query_provider_usage_inner)透传。
- Frontend: UsageScriptModal 新增「Zhipu GLM Team」选项 + 组织/项目 ID
  输入;模板切换时保留 team 字段;测试与保存逻辑按 team 传参。
- i18n: en/zh/zh-TW/ja 四个 locale 更新。
- Tests: 凭据校验/路由(缺任一凭据 → NotFound,标识大小写不敏感)+ 本地
  server 验证 ?type=2 与 org/project 请求头形状。

Co-authored-by: XuZhanXin <1239576606@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:21:10 +08:00

2238 lines
90 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_MONTHLY, TIER_WEEKLY_LIMIT,
};
use std::time::{SystemTime, UNIX_EPOCH};
// ── 供应商检测 ──────────────────────────────────────────────
enum CodingPlanProvider {
Kimi,
ZhipuCn,
ZhipuEn,
MiniMaxCn,
MiniMaxEn,
ZenMux,
/// 火山方舟 Agent Plan / Coding Planbase_url 形如
/// `https://ark.cn-beijing.volces.com/api/coding[/v3]`)。
Volcengine,
}
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 if url.contains("zenmux") {
Some(CodingPlanProvider::ZenMux)
} else if url.contains("volces.com/api/coding") {
// 仅匹配 Coding/Agent Plan 入口;DouBaoSeed 按量付费走 /api/v3 与
// /api/compatible,没有套餐额度,不在此命中。
Some(CodingPlanProvider::Volcengine)
} 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() {
// 0/负时间戳(如火山 session 无活跃窗口回 -1)视为无重置时间
if n <= 0 {
return None;
}
// 区分秒和毫秒:秒级时间戳 < 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) -> Result<SubscriptionQuota, String> {
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 Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Ok(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 Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return Ok(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,
});
}
Ok(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<ZhipuWindow> {
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_hour5 小时桶在 0% 等状态下可能没有 reset),其余按 reset 升序
/// 依次填入仍空缺的槽位。
///
/// 老套餐(2026-02-12 前订阅)只回 1 条
/// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。
fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
type Entry = (Option<i64>, f64, Option<String>);
let mut five_hour: Option<Entry> = None;
let mut weekly: Option<Entry> = None;
let mut unclassified: Vec<Entry> = 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) -> Result<SubscriptionQuota, String> {
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 Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Ok(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 Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
Ok(zhipu_quota_from_body(&body))
}
/// 解析智谱额度响应体(个人版与团队版共用同一 shape)。
/// 仅在 HTTP 成功、body 已完整读取并解析为 JSON 后调用——本函数不做任何网络 IO,
/// 故无瞬时失败通道,确定性失败直接落进 `Ok(success:false)`。
fn zhipu_quota_from_body(body: &serde_json::Value) -> SubscriptionQuota {
// 检查业务级别错误
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) -> Result<SubscriptionQuota, String> {
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 Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Ok(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 Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return Ok(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 Ok(make_error(format!("API error (code {status_code}): {msg}")));
}
}
// 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比
let tiers = parse_minimax_tiers(&body);
Ok(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) -> Result<SubscriptionQuota, String> {
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 Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Ok(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 Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return Ok(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 Ok(make_error(format!("API error: {msg}")));
}
let data = match body.get("data") {
Some(d) => d,
None => return Ok(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()
};
Ok(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<QuotaTier> {
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
}
// ── 火山方舟 Agent Plan / Coding Plan ───────────────────────
//
// 与 Kimi/MiniMax(数据面 Bearer 余额接口)不同,火山用量接口是**控制面
// OpenAPI**:统一网关 `open.volcengineapi.com`**不是**数据面推理域名
// `ark.cn-beijing.volces.com`),形如
// `POST https://open.volcengineapi.com/?Action=...&Version=2024-01-01&Region=cn-beijing`
// **强制火山引擎签名 V4(AK/SK)**——实测复用推理 Bearer Key 会被网关以
// `400 InvalidAuthorization` 拒绝(格式层拒绝,非权限问题)。因此用户需在用量查询
// 里另填火山账号的 AccessKey ID + Secret(与推理 Key 是两套凭据)。两个 plan 用
// 同一份 AK/SK,故鉴权类错误直接停、不再试另一个 plan。
//
// 自动探测:先调 `GetAFPUsage`Agent Plan,回绝对额度 Quota/Used),未订阅再调
// `GetCodingPlanUsage`Coding Plan,回百分比)。
/// 控制面 OpenAPI 统一网关(区别于数据面推理域名 ark.cn-beijing.volces.com)。
const VOLCENGINE_OPENAPI_HOST: &str = "open.volcengineapi.com";
const VOLCENGINE_API_VERSION: &str = "2024-01-01";
/// ark 控制面 OpenAPI 的默认 RegionAgent/Coding Plan 目前在 cn-beijing)。
const VOLCENGINE_DEFAULT_REGION: &str = "cn-beijing";
/// 单次 OpenAPI 调用的归类结果。
enum VolcCall {
/// 2xx 且 JSON 可解析、无 OpenAPI 级错误(业务 Result 仍可能为空=未订阅)。
Body(serde_json::Value),
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
/// 共用凭据,命中即停。
Auth(String),
/// 非鉴权 HTTP 错误 / 响应体非法 JSON——记录后可继续尝试另一个 plan。
Soft(String),
/// 瞬时传输失败(网络/超时/读体中断)——同 host 的另一个 plan 大概率同样
/// 失败,调用方应立即以 `Err` 传播(前端 reject → retry + 保留上次成功值)。
Transient(String),
}
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
/// `ark.cn-beijing.volces.com` → `cn-beijing`);无法识别时回落 cn-beijing。
/// 控制面 Host 是固定网关(`VOLCENGINE_OPENAPI_HOST`),不随 base_url 变化。
fn volcengine_region(base_url: &str) -> String {
let host = base_url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(base_url)
.split('/')
.next()
.unwrap_or("");
host.split('.')
.find(|p| p.starts_with("cn-") || p.starts_with("ap-"))
.map(|p| p.to_string())
.unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string())
}
/// 判断 OpenAPI 错误码是否属于鉴权类(需要硬停并提示换 AK/SK)。
fn volcengine_is_auth_error_code(code: &str) -> bool {
let c = code.to_lowercase();
c.contains("auth")
|| c.contains("signature")
|| c.contains("accessdenied")
|| c.contains("denied")
|| c.contains("unauthorized")
|| c.contains("forbidden")
|| c.contains("credential")
|| c.contains("token")
}
/// 提取火山 OpenAPI 响应里的 `ResponseMetadata.Error`(或顶层 `Error`)。
fn volcengine_response_error(body: &serde_json::Value) -> Option<(String, String)> {
let err = body
.get("ResponseMetadata")
.and_then(|m| m.get("Error"))
.or_else(|| body.get("Error"))?;
let code = err
.get("Code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let msg = err
.get("Message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if code.is_empty() && msg.is_empty() {
None
} else {
Some((code, msg))
}
}
/// 鉴权失败时的引导文案,附加在错误后。
const VOLCENGINE_AKSK_HINT: &str =
"Check the AccessKey ID / Secret are correct and the account has Ark usage-query (OpenAPI) permission.";
// ── 火山引擎签名 V4(AK/SK)─────────────────────────────────
//
// 算法是 AWS SigV4 的火山变体(对照官方 volc-openapi-demos/signature/java/Sign.java)。
// **两处致命差异,照搬 s3.rs 的标准 SigV4 会签名失败**
// 1. canonical headers 与 SignedHeaders 用**固定顺序**
// `host;x-date;x-content-sha256;content-type`**不按字母序**s3.rs 是字母序);
// 2. algorithm 串 `HMAC-SHA256`(无 `AWS4` 前缀)、credential scope 结尾 `request`
// (非 `aws4_request`)、签名密钥 `kDate=HMAC(SK, date)`SK 不加 `AWS4` 前缀)。
// canonical query 仍按 key 字母序(与标准 SigV4 一致);service=`ark`、POST、空 body。
const VOLCENGINE_SERVICE: &str = "ark";
const VOLCENGINE_CONTENT_TYPE: &str = "application/json; charset=utf-8";
const VOLCENGINE_SIGNED_HEADERS: &str = "host;x-date;x-content-sha256;content-type";
fn volc_hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
use hmac::{Hmac, Mac};
type HmacSha256 = Hmac<sha2::Sha256>;
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn volc_sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(data))
}
/// RFC3986 unreserved 之外全部按 `%XX` 编码(用于 canonical query string)。
fn volc_uri_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{byte:02X}");
}
}
}
out
}
/// 构造按 key 字母序排序、逐段 URL 编码的 canonical query string。
/// 同一份字符串既用于签名也用于实际请求 URL,保证两者完全一致。
fn volcengine_canonical_query(action: &str, region: &str) -> String {
let mut pairs = [
("Action", action),
("Region", region),
("Version", VOLCENGINE_API_VERSION),
];
pairs.sort_by(|a, b| a.0.cmp(b.0));
pairs
.iter()
.map(|(k, v)| format!("{}={}", volc_uri_encode(k), volc_uri_encode(v)))
.collect::<Vec<_>>()
.join("&")
}
/// 生成火山引擎签名 V4 的鉴权头,返回 `(Authorization, X-Date, X-Content-Sha256)`
/// 三者都要塞进请求头;`canonical_query` 必须与实际请求 URL 的 query 完全一致。
/// `now` 作参数传入便于写确定性单测。
fn volcengine_sign(
access_key_id: &str,
secret_access_key: &str,
region: &str,
canonical_query: &str,
body: &[u8],
now: chrono::DateTime<chrono::Utc>,
) -> (String, String, String) {
let x_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let short_date = now.format("%Y%m%d").to_string();
let x_content_sha256 = volc_sha256_hex(body);
// 固定顺序 canonical headers(火山特有,**不排序**)。
let canonical_headers = format!(
"host:{VOLCENGINE_OPENAPI_HOST}\nx-date:{x_date}\nx-content-sha256:{x_content_sha256}\ncontent-type:{VOLCENGINE_CONTENT_TYPE}\n"
);
let canonical_request = format!(
"POST\n/\n{canonical_query}\n{canonical_headers}\n{VOLCENGINE_SIGNED_HEADERS}\n{x_content_sha256}"
);
let credential_scope = format!("{short_date}/{region}/{VOLCENGINE_SERVICE}/request");
let string_to_sign = format!(
"HMAC-SHA256\n{x_date}\n{credential_scope}\n{}",
volc_sha256_hex(canonical_request.as_bytes())
);
// 签名密钥派生:kDate=HMAC(SK, date)SK **不加** AWS4 前缀),终止串 `request`。
let k_date = volc_hmac_sha256(secret_access_key.as_bytes(), short_date.as_bytes());
let k_region = volc_hmac_sha256(&k_date, region.as_bytes());
let k_service = volc_hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes());
let k_signing = volc_hmac_sha256(&k_service, b"request");
let signature: String = volc_hmac_sha256(&k_signing, string_to_sign.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let authorization = format!(
"HMAC-SHA256 Credential={access_key_id}/{credential_scope}, SignedHeaders={VOLCENGINE_SIGNED_HEADERS}, Signature={signature}"
);
(authorization, x_date, x_content_sha256)
}
async fn volcengine_openapi_call(
region: &str,
access_key_id: &str,
secret_access_key: &str,
action: &str,
) -> VolcCall {
let client = crate::proxy::http_client::get();
// canonical query 同时用于签名与实际 URL,确保两者逐字一致(否则签名不匹配)。
let canonical_query = volcengine_canonical_query(action, region);
let url = format!("https://{VOLCENGINE_OPENAPI_HOST}/?{canonical_query}");
let body: &[u8] = b"";
let (authorization, x_date, x_content_sha256) = volcengine_sign(
access_key_id,
secret_access_key,
region,
&canonical_query,
body,
chrono::Utc::now(),
);
let resp = client
.post(&url)
.header("X-Date", x_date)
.header("X-Content-Sha256", x_content_sha256)
.header("Content-Type", VOLCENGINE_CONTENT_TYPE)
.header("Authorization", authorization)
.body(body.to_vec())
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => return VolcCall::Transient(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return VolcCall::Auth(format!(
"Authentication failed (HTTP {status}). {VOLCENGINE_AKSK_HINT}"
));
}
if !status.is_success() {
// 火山 OpenAPI 网关对签名/凭据类错误常返 4xx(多为 HTTP 400)并携带与 200
// 路径相同的 ResponseMetadata.Error 信封,而非 401/403。这里也解析信封,让
// Bearer 被拒时仍能给出 AK/SK 引导并标记凭据失效,而不是当成普通 API 错误。
let raw = resp.text().await.unwrap_or_default();
if let Ok(body) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some((code, msg)) = volcengine_response_error(&body) {
if volcengine_is_auth_error_code(&code) {
return VolcCall::Auth(format!(
"Authentication failed (HTTP {status}, {code}): {msg}. {VOLCENGINE_AKSK_HINT}"
));
}
return VolcCall::Soft(format!("API error (HTTP {status}, {code}): {msg}"));
}
}
return VolcCall::Soft(format!("API error (HTTP {status}): {raw}"));
}
// 同 Bearer 路径:先 bytes() 再解析——读体失败是瞬时(Transient),解析失败
// 是确定性(Soft)。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return VolcCall::Transient(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")),
};
// 火山 OpenAPI 业务错误常以 200 + ResponseMetadata.Error 返回。
if let Some((code, msg)) = volcengine_response_error(&body) {
if volcengine_is_auth_error_code(&code) {
return VolcCall::Auth(format!(
"Authentication failed ({code}): {msg}. {VOLCENGINE_AKSK_HINT}"
));
}
return VolcCall::Soft(format!("API error ({code}): {msg}"));
}
VolcCall::Body(body)
}
/// 解析 `GetAFPUsage` 的 `Result` 为 tier 列表。
///
/// 展示 5h / 周 / 月三个窗口(与控制台一致);`AFPDaily` 被官方控制台隐藏
/// (其 Quota 常高于周上限,属历史默认值而非强制限额),故跳过。
/// `Quota`/`Used` 是绝对 AFP 值,已用百分比 = Used/Quota×100`Quota<=0` 视为
/// 该窗口未订阅/未启用,跳过——也用于把"已鉴权但无 Agent Plan"识别为空结果,
/// 从而回落到 Coding Plan 探测。
fn parse_afp_tiers(result: &serde_json::Value) -> Vec<QuotaTier> {
let mut tiers = Vec::new();
for (key, name) in [
("AFPFiveHour", TIER_FIVE_HOUR),
("AFPWeekly", TIER_WEEKLY_LIMIT),
("AFPMonthly", TIER_MONTHLY),
] {
let Some(win) = result.get(key) else { continue };
let quota = win.get("Quota").and_then(parse_f64).unwrap_or(0.0);
if quota <= 0.0 {
continue;
}
let used = win.get("Used").and_then(parse_f64).unwrap_or(0.0);
// 已用百分比;不做范围裁剪,与 parse_zhipu_token_tiers/parse_minimax_tiers
// 的约定一致(下游渲染层负责显示策略)。
let utilization = used / quota * 100.0;
let resets_at = win.get("ResetTime").and_then(extract_reset_time);
tiers.push(QuotaTier {
name: name.to_string(),
utilization,
resets_at,
used_value_usd: None,
max_value_usd: None,
});
}
tiers
}
/// 把 `GetCodingPlanUsage` 的 window 标签归一到 tier 名。
fn volcengine_coding_window(label: &str) -> Option<&'static str> {
match label.to_lowercase().as_str() {
"session" | "5h" | "fivehour" | "five_hour" | "rolling_5h" => Some(TIER_FIVE_HOUR),
"weekly" | "week" | "7d" => Some(TIER_WEEKLY_LIMIT),
"monthly" | "month" => Some(TIER_MONTHLY),
_ => None,
}
}
/// 解析 `GetCodingPlanUsage` 的 `Result` 为 tier 列表(防御式)。
///
/// 该接口官方文档未给出逐字段规格,依据官方 ark-cli 描述:回 session/weekly/
/// monthly 窗口、**只给百分比**(已用)、重置时间是秒级。这里宽松匹配
/// `QuotaUsage`/`Usages`/`Details` 数组及多种字段名,命中即用、未命中跳过。
fn parse_coding_plan_tiers(result: &serde_json::Value) -> Vec<QuotaTier> {
let mut tiers = Vec::new();
let arr = result
.get("QuotaUsage")
.and_then(|v| v.as_array())
.or_else(|| result.get("Usages").and_then(|v| v.as_array()))
.or_else(|| result.get("Details").and_then(|v| v.as_array()));
let Some(arr) = arr else { return tiers };
for item in arr {
// 真实字段是 `Level`(实测 2026-06-21session/weekly/monthly);其余作防御式 fallback。
let label = item
.get("Level")
.and_then(|v| v.as_str())
.or_else(|| item.get("Type").and_then(|v| v.as_str()))
.or_else(|| item.get("Period").and_then(|v| v.as_str()))
.or_else(|| item.get("Label").and_then(|v| v.as_str()))
.or_else(|| item.get("Window").and_then(|v| v.as_str()))
.unwrap_or("");
let Some(name) = volcengine_coding_window(label) else {
continue;
};
let utilization = item
.get("Percent")
.and_then(parse_f64)
.or_else(|| item.get("UsedPercent").and_then(parse_f64))
.or_else(|| item.get("UsagePercent").and_then(parse_f64))
.unwrap_or(0.0);
// 兼容秒/毫秒/字符串(extract_reset_time 内部已区分秒与毫秒)。
let resets_at = item
.get("ResetTime")
.or_else(|| item.get("ResetTimestamp"))
.and_then(extract_reset_time);
tiers.push(QuotaTier {
name: name.to_string(),
utilization,
resets_at,
used_value_usd: None,
max_value_usd: None,
});
}
tiers
}
fn volcengine_success(tiers: Vec<QuotaTier>, plan: Option<String>) -> SubscriptionQuota {
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: plan,
success: true,
tiers,
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
}
fn volcengine_auth_error(detail: String) -> SubscriptionQuota {
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(detail),
queried_at: Some(now_millis()),
}
}
async fn query_volcengine(
base_url: &str,
access_key_id: &str,
secret_access_key: &str,
) -> Result<SubscriptionQuota, String> {
let region = volcengine_region(base_url);
let mut soft_errors: Vec<String> = Vec::new();
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
// 与"字段名/包裹层猜错")。签名若不通会走 Auth/Soft 分支,到不了这里。
let mut empty_responses: Vec<String> = Vec::new();
let summarize = |action: &str, body: &serde_json::Value| -> String {
let raw: String = body.to_string().chars().take(700).collect();
format!("{action}={raw}")
};
// 1) Agent PlanGetAFPUsage
match volcengine_openapi_call(&region, access_key_id, secret_access_key, "GetAFPUsage").await {
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
VolcCall::Transient(detail) => return Err(format!("GetAFPUsage: {detail}")),
VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")),
VolcCall::Body(body) => {
let result = body.get("Result").unwrap_or(&body);
let tiers = parse_afp_tiers(result);
if !tiers.is_empty() {
let plan = result
.get("PlanType")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| format!("Agent Plan {s}"));
return Ok(volcengine_success(tiers, plan));
}
empty_responses.push(summarize("GetAFPUsage", &body));
}
}
// 2) Coding PlanGetCodingPlanUsage
match volcengine_openapi_call(
&region,
access_key_id,
secret_access_key,
"GetCodingPlanUsage",
)
.await
{
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
VolcCall::Transient(detail) => return Err(format!("GetCodingPlanUsage: {detail}")),
VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")),
VolcCall::Body(body) => {
let result = body.get("Result").unwrap_or(&body);
let tiers = parse_coding_plan_tiers(result);
if !tiers.is_empty() {
return Ok(volcengine_success(tiers, Some("Coding Plan".to_string())));
}
empty_responses.push(summarize("GetCodingPlanUsage", &body));
}
}
if !soft_errors.is_empty() {
Ok(make_error(soft_errors.join("; ")))
} else if !empty_responses.is_empty() {
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
// 便于核对真实字段名/包裹层,或确认确实未订阅。
Ok(make_error(format!(
"No active subscription found (signature OK). Raw: {}",
empty_responses.join(" || ")
)))
} else {
Ok(make_error(
"No active Agent Plan or Coding Plan subscription found for this credential"
.to_string(),
))
}
}
// ── 公开入口 ────────────────────────────────────────────────
/// 构造"凭据缺失 / 域名未命中"的失败结果(NotFound 状态 + 明确错误文案)。
fn coding_plan_not_found(error: &str) -> SubscriptionQuota {
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::NotFound,
credential_message: None,
success: false,
tiers: vec![],
extra_usage: None,
error: Some(error.to_string()),
queried_at: None,
}
}
// ── 智谱团队套餐(Team Plan)──────────────────────────────────
//
// 与个人版的差异仅在请求构造(参考 token-monitor/src/shared/zaiTeamLimits.js):
// - 固定走国内站 open.bigmodel.cn(团队版仅存在于国内站,z.ai 国际站无 team 档)
// - 同一 quota 路径加 `?type=2`
// - 额外请求头 bigmodel-organization / bigmodel-project(两者 + api_key 缺一不可)
// 响应 shape 与个人版完全一致 → 复用 zhipu_quota_from_body / parse_zhipu_token_tiers。
const ZHIPU_TEAM_QUOTA_URL: &str = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
async fn query_zhipu_team(
api_key: &str,
organization_id: &str,
project_id: &str,
) -> Result<SubscriptionQuota, String> {
query_zhipu_team_at(ZHIPU_TEAM_QUOTA_URL, api_key, organization_id, project_id).await
}
/// 团队版额度查询。`quota_url_base` 为不含 query 的 quota 端点;团队版与个人版同路径,
/// 靠 `?type=2` 区分(在此拼上)。拆出 url 参数便于用本地 server 测试请求形状。
async fn query_zhipu_team_at(
quota_url_base: &str,
api_key: &str,
organization_id: &str,
project_id: &str,
) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let url = format!("{quota_url_base}?type=2");
let resp = client
.get(&url)
.header("Authorization", api_key) // 与个人版一致:智谱不加 Bearer 前缀
.header("bigmodel-organization", organization_id)
.header("bigmodel-project", project_id)
.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 Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Ok(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 Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
Ok(zhipu_quota_from_body(&body))
}
/// 查询编程套餐额度。瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject →
/// retry + 保留上次成功值);确定性失败(凭据缺失/未知域名/鉴权/非 2xx/业务错误)
/// 返回 `Ok(success:false)` 立即透出文案。判定按 reqwest 错误种类在折叠点完成。
///
/// `coding_plan_provider` 显式标识用于无法靠 base_url 区分的供应商(当前为智谱团队版
/// `zhipu_team`——其 base_url 与个人版智谱相同);其余情况走 `detect_provider`。
pub async fn get_coding_plan_quota(
base_url: &str,
api_key: &str,
access_key_id: Option<&str>,
secret_access_key: Option<&str>,
coding_plan_provider: Option<&str>,
team_organization_id: Option<&str>,
team_project_id: Option<&str>,
) -> Result<SubscriptionQuota, String> {
// 智谱团队版:base_url 与个人版智谱(open.bigmodel.cn)相同,detect_provider 无法
// 区分,必须靠显式 coding_plan_provider == "zhipu_team" 路由。需 api_key + 组织 ID
// + 项目 ID 三者齐全,缺任一返回 NotFound 引导补全。
if coding_plan_provider
.map(|p| p.eq_ignore_ascii_case("zhipu_team"))
.unwrap_or(false)
{
let organization_id = team_organization_id.unwrap_or("").trim();
let project_id = team_project_id.unwrap_or("").trim();
if api_key.trim().is_empty() || organization_id.is_empty() || project_id.is_empty() {
return Ok(coding_plan_not_found(
"Zhipu team plan needs the API key + organization ID + project ID",
));
}
return query_zhipu_team(api_key, organization_id, project_id).await;
}
let provider = match detect_provider(base_url) {
Some(p) => p,
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
None => return Ok(coding_plan_not_found("Unknown coding plan provider")),
};
// 火山方舟走控制面 AK/SK 签名(区别于其他供应商的数据面 Bearer api_key),凭据
// 校验与查询路径都不同,单独分支提前处理。
if let CodingPlanProvider::Volcengine = provider {
let ak = access_key_id.unwrap_or("").trim();
let sk = secret_access_key.unwrap_or("").trim();
if ak.is_empty() || sk.is_empty() {
return Ok(coding_plan_not_found(
"Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)",
));
}
return query_volcengine(base_url, ak, sk).await;
}
// 其余供应商:数据面 Bearer api_key。
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
if api_key.trim().is_empty() {
return Ok(coding_plan_not_found("API key is empty"));
}
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,
// 火山已在上面的 AK/SK 分支提前返回,此处不可达。
CodingPlanProvider::Volcengine => {
unreachable!("volcengine handled via AK/SK branch above")
}
}
}
#[cfg(test)]
mod tests {
use super::{
parse_afp_tiers, parse_coding_plan_tiers, parse_minimax_tiers, parse_zhipu_token_tiers,
query_zhipu_team_at, volcengine_canonical_query, volcengine_is_auth_error_code,
volcengine_region, volcengine_response_error, volcengine_sign, zhipu_quota_base,
TIER_FIVE_HOUR, TIER_MONTHLY, 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_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 #30362026-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 周"计),
// 分类只看 unitnumber 取值不影响。
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"
);
}
// ── 火山方舟 Agent Plan / Coding Plan ──
#[test]
fn volcengine_afp_three_windows_from_official_example() {
// 官方文档 GetAFPUsage 返回示例(逐字):5h 25% / weekly 30% / monthly
// 42.525%AFPDaily 被控制台隐藏,应跳过。
let result = json!({
"PlanType": "Large",
"AFPFiveHour": { "Quota": 50.0, "Used": 12.5, "SubscribeTime": 1778788800000_i64, "ResetTime": 1778806800000_i64 },
"AFPDaily": { "Quota": 100.0, "Used": 22.5, "SubscribeTime": 1778716800000_i64, "ResetTime": 1778803200000_i64 },
"AFPWeekly": { "Quota": 500.0, "Used": 150.0, "SubscribeTime": 1778457600000_i64, "ResetTime": 1779062400000_i64 },
"AFPMonthly": { "Quota": 2000.0, "Used": 850.5, "SubscribeTime": 1777939200000_i64, "ResetTime": 1780531200000_i64 }
});
let tiers = parse_afp_tiers(&result);
assert_eq!(tiers.len(), 3, "daily 应被跳过,只剩 5h/周/月");
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert!((tiers[0].utilization - 25.0).abs() < 1e-9);
assert!(tiers[0].resets_at.is_some());
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert!((tiers[1].utilization - 30.0).abs() < 1e-9);
assert_eq!(tiers[2].name, TIER_MONTHLY);
assert!((tiers[2].utilization - 42.525).abs() < 1e-9);
assert!(tiers[2].resets_at.is_some());
}
#[test]
fn volcengine_afp_zero_quota_windows_treated_as_unbound() {
// 已鉴权但无 Agent Plan:窗口 Quota=0 → 空结果,调用方据此回落 Coding Plan。
let result = json!({
"PlanType": "",
"AFPFiveHour": { "Quota": 0.0, "Used": 0.0 },
"AFPWeekly": { "Quota": 0.0, "Used": 0.0 },
"AFPMonthly": { "Quota": 0.0, "Used": 0.0 }
});
assert!(parse_afp_tiers(&result).is_empty());
}
#[test]
fn volcengine_afp_partial_windows_only_subscribed_ones() {
// 仅 5h 窗口有额度(缺周/月)→ 只产出一个 tier。
let result = json!({
"AFPFiveHour": { "Quota": 40.0, "Used": 10.0, "ResetTime": 1778806800000_i64 },
"AFPWeekly": { "Quota": 0.0, "Used": 0.0 }
});
let tiers = parse_afp_tiers(&result);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert!((tiers[0].utilization - 25.0).abs() < 1e-9);
}
#[test]
fn volcengine_coding_plan_real_response_levels() {
// 真实 GetCodingPlanUsage 响应(用户实测 2026-06-21):字段名是 `Level`(非 `Type`),
// 仅百分比,秒级 ResetTimestampsession 无活跃窗口回 -1 → 无重置时间。
let result = json!({
"Status": "Running",
"UpdateTimestamp": 1782053286_i64,
"QuotaUsage": [
{ "Level": "session", "Percent": 0.0, "ResetTimestamp": -1_i64 },
{ "Level": "weekly", "Percent": 1.672568, "ResetTimestamp": 1782057600_i64 },
{ "Level": "monthly", "Percent": 0.836284, "ResetTimestamp": 1784303999_i64 }
]
});
let tiers = parse_coding_plan_tiers(&result);
assert_eq!(tiers.len(), 3);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert!((tiers[0].utilization - 0.0).abs() < 1e-9);
assert!(
tiers[0].resets_at.is_none(),
"session ResetTimestamp=-1 应无重置时间"
);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert!((tiers[1].utilization - 1.672568).abs() < 1e-6);
assert!(tiers[1].resets_at.is_some());
assert_eq!(tiers[2].name, TIER_MONTHLY);
assert!((tiers[2].utilization - 0.836284).abs() < 1e-6);
}
#[test]
fn volcengine_coding_plan_unknown_window_skipped_and_missing_array_empty() {
let result = json!({
"QuotaUsage": [
{ "Level": "daily", "Percent": 9.0 },
{ "Level": "weekly", "Percent": 20.0 }
]
});
let tiers = parse_coding_plan_tiers(&result);
assert_eq!(tiers.len(), 1, "未知 daily 窗口跳过");
assert_eq!(tiers[0].name, TIER_WEEKLY_LIMIT);
assert!(parse_coding_plan_tiers(&json!({})).is_empty());
}
#[test]
fn volcengine_region_derivation() {
assert_eq!(
volcengine_region("https://ark.cn-beijing.volces.com/api/coding"),
"cn-beijing"
);
// 其他 region 的数据面域名按段提取。
assert_eq!(
volcengine_region("https://ark.cn-shanghai.volces.com/api/coding/v3"),
"cn-shanghai"
);
// 无可识别 region 段时回落默认 cn-beijing。
assert_eq!(
volcengine_region("https://example.com/api/coding"),
"cn-beijing"
);
}
#[test]
fn volcengine_canonical_query_is_sorted_and_encoded() {
// 按 key 字母序:Action < Region < Version;值含 `-` 属 unreserved,不编码。
assert_eq!(
volcengine_canonical_query("GetAFPUsage", "cn-beijing"),
"Action=GetAFPUsage&Region=cn-beijing&Version=2024-01-01"
);
}
#[test]
fn volcengine_sign_structure_and_determinism() {
// 没有服务端金标准向量时,锁定签名的结构契约 + 确定性(足以抓住 header 顺序、
// scope 后缀、algorithm 前缀、空 body hash 等实现错误)。真实正确性靠用户实测。
let now = chrono::DateTime::parse_from_rfc3339("2024-06-21T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let region = "cn-beijing";
let query = volcengine_canonical_query("GetAFPUsage", region);
let (auth, x_date, x_content) =
volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now);
// 空 body 的 SHA-256(固定值),证明走的是空 body。
assert_eq!(
x_content,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
// X-Date 形如 20240621T000000Z。
assert_eq!(x_date, "20240621T000000Z");
// Authorization 结构:算法无 AWS4 前缀、scope 结尾 ark/request、固定 SignedHeaders。
assert!(
auth.starts_with("HMAC-SHA256 Credential=AKLTtest/20240621/cn-beijing/ark/request,"),
"unexpected credential/scope: {auth}"
);
assert!(
auth.contains("SignedHeaders=host;x-date;x-content-sha256;content-type,"),
"unexpected signed headers: {auth}"
);
// Signature 是 64 位十六进制。
let sig = auth.rsplit("Signature=").next().unwrap();
assert_eq!(sig.len(), 64);
assert!(sig.bytes().all(|b| b.is_ascii_hexdigit()));
// 确定性:同输入同输出。
let (auth2, _, _) = volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now);
assert_eq!(auth, auth2);
}
#[test]
fn volcengine_auth_error_code_detection_and_extraction() {
assert!(volcengine_is_auth_error_code("AccessDenied"));
assert!(volcengine_is_auth_error_code("SignatureDoesNotMatch"));
assert!(volcengine_is_auth_error_code("InvalidAuthorization"));
assert!(volcengine_is_auth_error_code("Unauthorized"));
assert!(!volcengine_is_auth_error_code("InvalidParameter.Action"));
assert!(!volcengine_is_auth_error_code("InternalError"));
// ResponseMetadata.Error 抽取
let body = json!({
"ResponseMetadata": { "RequestId": "x", "Error": { "Code": "AccessDenied", "Message": "no permission" } }
});
let (code, msg) = volcengine_response_error(&body).expect("应抽到 Error");
assert_eq!(code, "AccessDenied");
assert_eq!(msg, "no permission");
// 无 Error 时返回 None
let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} });
assert!(volcengine_response_error(&ok_body).is_none());
}
// ── 传输层错误通道语义:瞬时 → Err(前端 reject/retry),确定性 → Ok(success:false) ──
//
// 借 ZenMux 分支可指向任意 base_url 的特性,用本地 listener 驱动真实 HTTP
// 路径,锁定 send 失败 / 读体中断 / 4xx / 非法 JSON 各自落在哪条通道。
// balance / subscription 服务与本文件共用同一折叠模式,这里的用例同时充当
// 三个服务的语义回归锚。
use super::get_coding_plan_quota;
use crate::services::subscription::CredentialStatus;
use std::io::{Read, Write};
/// 测试进程内可能有其他用例临时 set_var HTTP_PROXYhttp_client 的
/// loopback 检测测试),NO_PROXY 保证本地回环请求始终直连。
fn ensure_no_proxy_for_loopback() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
std::env::set_var("no_proxy", "127.0.0.1,localhost");
});
}
/// 起一个只服务一次连接的本地 HTTP server。`response=None` 表示读完请求
/// 直接断开(模拟响应前连接中断)。返回可命中 ZenMux 分支的 base_url。
fn spawn_once_server(response: Option<String>) -> (String, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener");
let port = listener.local_addr().expect("local addr").port();
let handle = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 2048];
let _ = stream.read(&mut buf);
if let Some(resp) = response {
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
}
});
(format!("http://127.0.0.1:{port}/zenmux"), handle)
}
fn http_response(status_line: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
#[tokio::test]
async fn transient_connection_refused_returns_err() {
ensure_no_proxy_for_loopback();
// 绑定后立刻释放端口 → 连接被拒(send 失败)
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
let result = get_coding_plan_quota(
&format!("http://127.0.0.1:{port}/zenmux"),
"k",
None,
None,
None,
None,
None,
)
.await;
let err = result.expect_err("send 失败必须走 Err 通道(瞬时,前端 reject 后重试)");
assert!(err.contains("Network error"), "err={err}");
}
#[tokio::test]
async fn transient_connection_closed_before_response_returns_err() {
ensure_no_proxy_for_loopback();
let (base_url, handle) = spawn_once_server(None);
let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await;
let err = result.expect_err("响应前连接中断必须走 Err 通道(瞬时)");
assert!(err.contains("Network error"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn transient_truncated_body_returns_err() {
ensure_no_proxy_for_loopback();
// 声明 content-length: 100 但只写一小段就断开 → 读体中断。
// 锁定 bytes() 先于解析:这类失败必须走 Err(瞬时),不能因 reqwest 把
// 读体错误包成 decode 而被误判成确定性的 "Failed to parse response"。
let (base_url, handle) = spawn_once_server(Some(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\npartial"
.to_string(),
));
let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await;
let err = result.expect_err("读体中断必须走 Err 通道(瞬时,前端 reject 后重试)");
assert!(err.contains("Failed to read response"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn deterministic_http_401_stays_ok_with_auth_error() {
ensure_no_proxy_for_loopback();
let (base_url, handle) = spawn_once_server(Some(http_response("401 Unauthorized", "{}")));
let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None)
.await
.expect("鉴权失败是确定性失败,必须保持 Ok(success:false) 展示文案");
assert!(!quota.success);
assert!(matches!(quota.credential_status, CredentialStatus::Expired));
let err = quota.error.expect("应有错误文案");
assert!(err.contains("Authentication failed (HTTP 401"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn deterministic_http_429_stays_ok_with_status_in_error() {
ensure_no_proxy_for_loopback();
let (base_url, handle) =
spawn_once_server(Some(http_response("429 Too Many Requests", "slow down")));
let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None)
.await
.expect("非 2xx 保持 Ok(success:false),状态码留在文案里交前端分类");
assert!(!quota.success);
// 前端 isTransientUsageError 靠 /http\s+(\d{3})/ 提取状态码把 429 归瞬时,
// 文案格式是跨层契约,勿改。
let err = quota.error.expect("应有错误文案");
assert!(err.contains("HTTP 429"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn deterministic_invalid_json_body_stays_ok_with_parse_error() {
ensure_no_proxy_for_loopback();
// 完整读到响应体但不是 JSON → is_decode → 确定性解析失败
let (base_url, handle) = spawn_once_server(Some(http_response("200 OK", "not-json")));
let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None)
.await
.expect("完整但非法的响应体是确定性失败,必须保持 Ok(success:false)");
assert!(!quota.success);
let err = quota.error.expect("应有错误文案");
assert!(err.contains("Failed to parse response"), "err={err}");
handle.join().expect("server thread");
}
// ── 智谱团队套餐(Team Plan)──
/// 起一个只服务一次连接的本地 HTTP server,捕获原始请求文本(请求行 + 头),
/// 用于断言 team 查询发出的 URL query 与组织/项目请求头。`response=None` 时只捕获不回包。
fn spawn_request_capturing_server(
response: Option<String>,
) -> (
String,
std::sync::Arc<std::sync::Mutex<Option<String>>>,
std::thread::JoinHandle<()>,
) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener");
let port = listener.local_addr().expect("local addr").port();
let captured = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
let captured_clone = captured.clone();
let handle = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf: Vec<u8> = Vec::new();
let mut tmp = [0u8; 4096];
// GET 无 body,读到 header 末尾(\r\n\r\n)即可
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
match stream.read(&mut tmp) {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&tmp[..n]);
if buf.len() > 16 * 1024 {
break;
}
}
}
}
*captured_clone.lock().unwrap() = Some(String::from_utf8_lossy(&buf).into_owned());
if let Some(resp) = response {
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
}
});
(format!("http://127.0.0.1:{port}/team"), captured, handle)
}
#[tokio::test]
async fn zhipu_team_missing_creds_returns_not_found() {
// 团队版必需 api_key + 组织 ID + 项目 ID,缺任一在触网前返回 NotFound(不联网)。
let cases: [(&str, Option<&str>, Option<&str>); 3] = [
("key", Some("org"), None), // 缺 project
("key", None, Some("proj")), // 缺 organization
(" ", Some("org"), Some("proj")), // 空 api_key
];
for (api_key, org, project) in cases {
let q = get_coding_plan_quota(
"https://open.bigmodel.cn/api/coding",
api_key,
None,
None,
Some("zhipu_team"),
org,
project,
)
.await
.expect("凭据缺失是确定性失败,保持 Ok(success:false)");
assert!(!q.success, "应失败: api_key={api_key:?}");
assert!(
matches!(q.credential_status, CredentialStatus::NotFound),
"应为 NotFound: api_key={api_key:?}"
);
}
// 标识大小写不敏感(eq_ignore_ascii_case),大写仍命中 team 分支并返回引导文案。
let msg = get_coding_plan_quota(
"https://open.bigmodel.cn/api/coding",
"key",
None,
None,
Some("Zhipu_Team"),
None,
None,
)
.await
.expect("ok")
.error
.expect("应有错误文案");
assert!(
msg.contains("API key + organization ID + project ID"),
"err={msg}"
);
}
#[tokio::test]
async fn zhipu_team_request_carries_type2_and_org_project_headers() {
ensure_no_proxy_for_loopback();
// 响应 shape 与个人版一致:两条 TOKENS_LIMITunit 3/6)→ five_hour + weekly。
let body = serde_json::json!({
"success": true,
"data": {
"level": "max",
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 26.0 },
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 5.0 }
]
}
});
let body_str = body.to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body_str}",
body_str.len()
);
let (base, captured, handle) = spawn_request_capturing_server(Some(resp));
let quota = query_zhipu_team_at(&base, "team-key", "org-xxx", "proj_xxx")
.await
.expect("2xx + 合法 body 应成功");
handle.join().expect("server thread");
// 请求形状契约(与 token-monitor zaiTeamLimits 对齐):
// 同路径加 ?type=2 + bigmodel-organization / bigmodel-project 头 + 鉴权头。
// reqwest/hyper 发头会小写化,故整体转小写做包含匹配。
let raw = captured.lock().unwrap().clone().expect("应捕获到请求");
let raw_lc = raw.to_lowercase();
assert!(raw_lc.contains("/team?type=2"), "缺 ?type=2: {raw}");
assert!(
raw_lc.contains("bigmodel-organization: org-xxx"),
"缺组织头: {raw}"
);
assert!(
raw_lc.contains("bigmodel-project: proj_xxx"),
"缺项目头: {raw}"
);
assert!(
raw_lc.contains("authorization: team-key"),
"缺鉴权头: {raw}"
);
// 解析复用个人版 zhipu_quota_from_body / parse_zhipu_token_tiers。
assert!(quota.success);
assert_eq!(quota.tiers.len(), 2);
assert_eq!(quota.tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(quota.tiers[0].utilization, 26.0);
assert_eq!(quota.tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(quota.tiers[1].utilization, 5.0);
}
}