Files
KimiCodeSwitch/src-tauri/src/services/coding_plan.rs
T
KimiSwitch Dev 2614f80d0f v0.6.0: 预设供应商 + 供应商账单查询
## 新增

- 15 条主流供应商预设(Kimi Coding / Moonshot / Anthropic /
  DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun /
  SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio /
  火山方舟),通过 PresetPickerModal 一键填表
- 供应商余额/套餐查询(卡片底部 UsageFooter):余额类
  (DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita) +
  套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL +
  force_refresh + 并发 ≤3
- Rust 端 services/ 模块(balance / coding_plan / usage_types),
  参考 cc-switch(MIT,© Jason Young)实现
- query_provider_usage 命令:Rust 端从 config 加载 key,
  不经 IPC 序列化 API key
- usageKinds 持久化到 SQLite settings(JSON 数组),
  load 时合并回 config,导出 config.toml 时不写
- detect_provider 启发式:旧用户升级后自动获得账单支持
- save-time 校验:未完成供应商(缺 api_key / base_url / 模型)
  保存时弹 confirm 列出原因;新增未提交 back 时静默 drop
- docs/PROPOSAL-presets-and-usage.md(v0.4-draft 实施档)
- docs/VERIFICATION-CHECKLIST.md(70+ 项手动验证清单)

## 修复

- handleDuplicateProvider 的 alias.slice 漏洞:老数据
  非规范 alias 复制不再错位
- handleSelectPreset 不再 auto-save,半成品不会落盘
- handleSwitchProvider 校验取消时自动 refresh 回滚,
  不会出现内存改 / 盘没动的脏态

## 文档

- README / README_EN 致谢段补 cc-switch(预设 + 账单)
- README 已知限制与后续建议段(/reload 上游限制调研结论)
- i18n 补 5 条校验 key + 11 条预设 UI key + 12 条账单 key

详见 docs/PROPOSAL-presets-and-usage.md 与
docs/VERIFICATION-CHECKLIST.md
2026-07-29 20:59:53 +08:00

415 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};
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) -> Result<UsageResult, String> {
match get_json(
"https://api.kimi.com/coding/v1/usages",
api_key,
AuthStyle::Bearer,
)
.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) -> Result<UsageResult, String> {
let url = format!(
"{}/api/monitor/usage/quota/limit",
zhipu_quota_base(base_url)
);
match get_json(&url, api_key, AuthStyle::Raw).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) -> 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).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"));
}
}