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
This commit is contained in:
1 parent
a1fdc6c93d
commit
2614f80d0f
27 files changed
+3442
-67
No files matched your search
@@ -0,0 +1,414 @@
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user