Files
KimiCodeSwitch/src-tauri/src/services/coding_plan.rs
T
KimiSwitch Dev ace9842556
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
feat: 新增部分套餐账单查询(v0.6.4)
- 账单/用量查询:balance:kimi(Moonshot 开放平台余额)、NewAPI 中转站余额(accessToken+userId)、Kimi/智谱/MiniMax 套餐档位(5小时/7天)
- managed:kimi-code 走 Kimi Code OAuth 登录凭据查询订阅用量,access token 过期自动 refresh 并写回凭据(单 flight 锁 + 写回前重读防 CLI 竞态)
- 全屏「配置用量查询」页面:启用开关 / 自动检测 vs NewAPI 模板 / 超时与自动查询间隔 / 测试查询
- 供应商卡片紧凑用量行(切换使用左侧)+ 多档明细行;managed 供应商列表置顶(仅展示层)
- 用量显示中英文适配(five_hour→5小时、weekly_limit→7天,Rust 错误串本地化映射)
- 修复:usage_kinds serde rename 导致账单入口不显示;测试查询因 save 触发 loading 翻页卸载面板导致结果丢失(silent save)
2026-08-01 09:44:36 +08:00

420 lines
15 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.
// Adapted from cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch
//! Token Plan 套餐额度查询服务
//!
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的套餐额度查询。
//! cc-switch 的 SubscriptionQuota/tiers 结构在此展平为 `Vec<UsageData>`:
//! 每个窗口(tier)一条 UsageData,`plan_name` = tier 名("five_hour" /
//! "weekly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
//!
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
use super::balance::{get_json, AuthStyle, Fetched};
use super::usage_types::{UsageData, UsageResult};
use std::time::Duration;
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax 及未来新增)
// 都只用这两个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖此约定
// 做本地化映射——新增 tier id 时必须同步加映射。
const TIER_FIVE_HOUR: &str = "five_hour";
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
/// 套餐条目的统一构造:按百分比表示用量。
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
UsageData {
plan_name: Some(name.to_string()),
remaining: Some((100.0 - used_percent).max(0.0)),
total: Some(100.0),
used: Some(used_percent),
unit: Some("%".to_string()),
is_valid: Some(true),
resets_at,
}
}
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;0/负值视为无重置时间
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() {
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()))
}
// ── Kimi For Coding ─────────────────────────────────────────
// GET https://api.kimi.com/coding/v1/usages
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } }
pub async fn query_kimi_coding(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
match get_json(
"https://api.kimi.com/coding/v1/usages",
api_key,
AuthStyle::Bearer,
timeout,
)
.await?
{
Fetched::Body(body) => Ok(UsageResult::ok(parse_kimi_coding(&body))),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
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") {
tiers.push(kimi_limit_tier(TIER_FIVE_HOUR, detail));
}
}
}
// 总体用量(周限额)
if let Some(usage) = body.get("usage") {
tiers.push(kimi_limit_tier(TIER_WEEKLY_LIMIT, usage));
}
tiers
}
fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData {
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
};
percent_tier(name, utilization, resets_at)
}
// ── 智谱 GLM ────────────────────────────────────────────────
// GET {open.bigmodel.cn | api.z.ai}/api/monitor/usage/quota/limit
// 注意:智谱鉴权不加 Bearer 前缀(cc-switch 实测行为,照搬)。
/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。
/// 实测:`unit: 3` → 5 小时滚动窗口;`unit: 6` → 每周窗口。
/// 缺失或不识别时走重置时间启发式兜底。
fn parse_zhipu(body: &serde_json::Value) -> Result<Vec<UsageData>, UsageResult> {
// 业务级别错误
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 Err(UsageResult::failure(format!("API error: {msg}")));
}
let data = match body.get("data") {
Some(d) => d,
None => {
return Err(UsageResult::failure(
"Missing 'data' field in response".to_string(),
))
}
};
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("");
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 limit_item.get("unit").and_then(|v| v.as_i64()) {
Some(3) if five_hour.is_none() => five_hour = Some(entry),
Some(6) if weekly.is_none() => weekly = Some(entry),
_ => unclassified.push(entry),
}
}
}
// 兜底:无 nextResetTime 的优先归 five_hour,其余按 reset 升序填空槽。
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);
}
}
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(percent_tier(name, percentage, resets_at));
}
}
Ok(tiers)
}
/// 额度接口与推理接口同 host:bigmodel.cn 与 z.ai 共用同一后端与 JSON shape。
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"
}
}
pub async fn query_zhipu(base_url: &str, api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
let url = format!(
"{}/api/monitor/usage/quota/limit",
zhipu_quota_base(base_url)
);
match get_json(&url, api_key, AuthStyle::Raw, timeout).await? {
Fetched::Body(body) => Ok(match parse_zhipu(&body) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
}),
Fetched::Failed(err) => Ok(err),
}
}
// ── MiniMax ─────────────────────────────────────────────────
// GET https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains
// (海外站 api.minimax.io)
// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。
pub async fn query_minimax(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
let domain = if is_cn {
"api.minimaxi.com"
} else {
"api.minimax.io"
};
let url = format!("https://{domain}/v1/api/openplatform/coding_plan/remains");
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(match parse_minimax(&body) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
}),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_minimax(body: &serde_json::Value) -> Result<Vec<UsageData>, UsageResult> {
// 业务级别错误
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 Err(UsageResult::failure(format!(
"API error (code {status_code}): {msg}"
)));
}
}
let mut tiers = Vec::new();
let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) else {
return Ok(tiers);
};
// 只取 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 Ok(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(percent_tier(TIER_FIVE_HOUR, 100.0 - remain_pct, resets_at));
}
// 周桶:仅 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(percent_tier(
TIER_WEEKLY_LIMIT,
100.0 - remain_pct,
resets_at,
));
}
}
Ok(tiers)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn kimi_coding_flattens_limits_and_usage() {
let body = json!({
"limits": [
{ "detail": { "limit": 100, "remaining": 40, "resetTime": 1_754_000_000_000i64 } }
],
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(60.0));
assert_eq!(tiers[0].total, Some(100.0));
assert_eq!(tiers[0].remaining, Some(40.0));
assert!(tiers[0].resets_at.is_some());
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(10.0));
assert_eq!(
tiers[1].resets_at.as_deref(),
Some("2026-08-01T00:00:00Z")
);
}
#[test]
fn kimi_coding_reset_time_seconds_vs_millis() {
// 秒级时间戳自动 ×1000
let v = json!(1_754_000_000i64);
assert!(extract_reset_time(&v).is_some());
// 0 / 负值视为无重置时间
assert_eq!(extract_reset_time(&json!(0)), None);
assert_eq!(extract_reset_time(&json!(-1)), None);
}
#[test]
fn zhipu_classifies_by_unit_field() {
let body = json!({
"success": true,
"data": {
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 35.0, "unit": 3, "nextResetTime": 1_754_000_000_000i64 },
{ "type": "TOKENS_LIMIT", "percentage": 80.0, "unit": 6, "nextResetTime": 1_754_500_000_000i64 }
]
}
});
let tiers = parse_zhipu(&body).unwrap();
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(35.0));
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(80.0));
}
#[test]
fn zhipu_business_error_is_deterministic_failure() {
let body = json!({ "success": false, "msg": "token invalid" });
let err = parse_zhipu(&body).unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("token invalid"));
}
#[test]
fn minimax_picks_general_and_inverts_remaining() {
let body = json!({
"base_resp": { "status_code": 0, "status_msg": "success" },
"model_remains": [
{ "model_name": "video", "current_interval_remaining_percent": 50.0 },
{
"model_name": "general",
"current_interval_remaining_percent": 70.0,
"end_time": 1_754_000_000_000i64,
"current_weekly_status": 1,
"current_weekly_remaining_percent": 95.0,
"weekly_end_time": 1_754_500_000_000i64
}
]
});
let tiers = parse_minimax(&body).unwrap();
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(30.0));
assert_eq!(tiers[0].remaining, Some(70.0));
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(5.0));
}
#[test]
fn minimax_skips_inactive_weekly_bucket() {
let body = json!({
"model_remains": [
{
"model_name": "general",
"current_interval_remaining_percent": 70.0,
"current_weekly_status": 3,
"current_weekly_remaining_percent": 100.0
}
]
});
let tiers = parse_minimax(&body).unwrap();
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
}
#[test]
fn minimax_business_error_is_deterministic_failure() {
let body = json!({ "base_resp": { "status_code": 1002, "status_msg": "invalid key" } });
let err = parse_minimax(&body).unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("invalid key"));
}
}