feat(usage): Sub2API 中转站账单查询 + 模型发现列表全选/反选 — bump to v0.7.17
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
Release / Attach macOS install script (push) Canceled after 0s

- 新增 balance:sub2api:GET {base}/v1/usage 复用推理 sk- key,解析 quota /
  5h·1d·7d rate_limits / 订阅组日周月额度 / 钱包余额(USD)
- detect_provider 修正 codingplan.site 家族:主域=Sub2API,ai.=NewAPI
- UsageConfigModal 新增 Sub2API 模板(无需凭据,可选 Base URL 覆盖)
- ProviderEdit 模型发现列表新增 全选/取消全选/反选
- cargo test 124 通过(+6 sub2api 解析用例)、tsc 零错误
This commit is contained in:
KimiSwitch Dev committed 2026-09-13 14:51:09 +08:00
1 parent b9002281a3
commit 00cf194b5c
17 files changed
+397 -31

No files matched your search

+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.16"
version = "0.7.17"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.16"
version = "0.7.17"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+11 -10
View File
@@ -690,19 +690,20 @@ pub async fn query_provider_usage(
let base_url = resolve_base_url(provider);
// NewAPI template: query the gateway's own usage endpoints with its
// dedicated credentials, bypassing usage_kinds entirely.
// NewAPI / Sub2API template: query the gateway's own usage endpoints.
// NewAPI needs web-console credentials (accessToken + userId); Sub2API
// reuses the inference API key directly — both bypass usage_kinds.
if let Some(cfg) = &provider.usage_config {
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
let template_kind = match cfg.template_type.as_str() {
UsageConfig::TEMPLATE_NEWAPI => Some(UsageKind::BalanceNewapi),
UsageConfig::TEMPLATE_SUB2API => Some(UsageKind::BalanceSub2Api),
_ => None,
};
if let Some(kind) = template_kind {
// Err = transient (network) → propagate for retry, same semantics
// as the kinds loop below. Config errors surface as Ok(failure).
let result = services::query_kind(
UsageKind::BalanceNewapi,
&base_url,
&api_key,
Some(cfg),
)
.await?;
let result =
services::query_kind(kind, &base_url, &api_key, Some(cfg)).await?;
if result.success {
usage_cache()
.lock()
+1
View File
@@ -290,6 +290,7 @@ pub struct UsageConfig {
impl UsageConfig {
pub const TEMPLATE_AUTO: &'static str = "auto";
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
pub const TEMPLATE_SUB2API: &'static str = "sub2api";
}
impl std::fmt::Debug for Config {
+254
View File
@@ -521,6 +521,159 @@ fn parse_newapi(
}])
}
// ── Sub2API (Wei-Shaw/sub2api) 中转站 ────────────────────────
// GET {base_url}/v1/usage
// 头:Authorization: Bearer <api_key>(就是推理用的 sk- key,无需额外凭据)
// Response(三种 mode):
// - quota_limited:key 设了总额度/速率限制 → quota{limit,used,remaining} +
// rate_limits[{window: 5h|1d|7d, limit, used, remaining, reset_at}]
// - unrestricted + subscription:订阅组 → planName + subscription 的
// daily/weekly/monthly usage/limit(USD)
// - unrestricted + 钱包:planName="钱包余额" → balance / remaining(USD)
//
// 站点指纹(可选探测):GET /api/v1/settings/public 返回 data.affiliate_enabled
// 即为 sub2api 面板。注意 NewAPI 的 /api/user/self、/api/status 在 sub2api 上
// 全部 404 —— 两套中转站协议不互通。
/// 把供应商 base_url 归一成站点根(去掉结尾 `/` 与 `/v1`),网关路由挂在根路径。
fn sub2api_site_root(base_url: &str) -> String {
let mut base = base_url.trim_end_matches('/');
if let Some(stripped) = base.strip_suffix("/v1") {
base = stripped;
}
base.to_string()
}
pub async fn query_sub2api(
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<UsageResult, String> {
let url = format!("{}/v1/usage", sub2api_site_root(base_url));
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(parse_sub2api(&body)),
Fetched::Failed(err) => Ok(err),
}
}
/// 速率窗口名 → 本地化 tier 名(前端 planLabel 会翻译;five_hour /
/// weekly_limit 已有映射,daily_limit 新增)。
fn sub2api_window_tier(window: &str) -> &'static str {
match window {
"5h" => "five_hour",
"1d" => "daily_limit",
"7d" => "weekly_limit",
_ => "window",
}
}
fn parse_sub2api(body: &serde_json::Value) -> UsageResult {
let is_valid = body
.get("isValid")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("");
if mode.is_empty() {
return UsageResult::failure(
"Missing 'mode' field in response (not a sub2api gateway?)".to_string(),
);
}
let mut out: Vec<UsageData> = Vec::new();
// key 总额度(quota_limited 模式才有)
if let Some(quota) = body.get("quota") {
if let Some(limit) = parse_f64_field(quota, "limit").filter(|&l| l > 0.0) {
let used = parse_f64_field(quota, "used").unwrap_or(0.0);
let remaining = parse_f64_field(quota, "remaining").unwrap_or(limit - used);
out.push(UsageData {
plan_name: Some("Sub2API".to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(used),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
..Default::default()
});
}
}
// 速率窗口(5h / 1d / 7d;reset_at 为 RFC3339,窗口过期时缺省)
if let Some(rates) = body.get("rate_limits").and_then(|v| v.as_array()) {
for w in rates {
let window = w.get("window").and_then(|v| v.as_str()).unwrap_or("");
let Some(limit) = parse_f64_field(w, "limit").filter(|&l| l > 0.0) else {
continue;
};
let used = parse_f64_field(w, "used").unwrap_or(0.0);
let remaining = parse_f64_field(w, "remaining").unwrap_or(limit - used);
out.push(UsageData {
plan_name: Some(sub2api_window_tier(window).to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(used),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
resets_at: w
.get("reset_at")
.and_then(|v| v.as_str())
.map(str::to_string),
});
}
}
// unrestricted 订阅组:daily/weekly/monthly 三窗口(usage_usd vs limit_usd)
if mode == "unrestricted" {
if let Some(sub) = body.get("subscription") {
for (prefix, tier) in [
("daily", "daily_limit"),
("weekly", "weekly_limit"),
("monthly", "monthly_limit"),
] {
let Some(usage) = parse_f64_field(sub, &format!("{prefix}_usage_usd")) else {
continue;
};
let Some(limit) = parse_f64_field(sub, &format!("{prefix}_limit_usd"))
.filter(|&l| l > 0.0)
else {
continue;
};
let remaining = limit - usage;
out.push(UsageData {
plan_name: Some(tier.to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(usage),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
resets_at: None,
});
}
}
}
// 钱包余额(unrestricted 非订阅)或以上全部缺失时的兜底
if out.is_empty() {
if let Some(balance) = parse_f64_field(body, "balance")
.or_else(|| parse_f64_field(body, "remaining"))
{
out.push(UsageData {
plan_name: Some("Sub2API".to_string()),
remaining: Some(balance),
unit: Some("USD".to_string()),
is_valid: Some(is_valid),
..Default::default()
});
}
}
if out.is_empty() {
UsageResult::failure("No quota/rate-limit/balance fields in response".to_string())
} else {
UsageResult::ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -688,4 +841,105 @@ mod tests {
assert_eq!(data[0].remaining, Some(1.0));
assert_eq!(data[0].used, Some(0.2));
}
#[test]
fn sub2api_site_root_strips_v1_and_slash() {
assert_eq!(sub2api_site_root("https://x.com"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/v1"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/v1/"), "https://x.com");
}
#[test]
fn sub2api_quota_limited_parses_quota_and_windows() {
let body = json!({
"mode": "quota_limited",
"isValid": true,
"quota": { "limit": 50.0, "used": 20.0, "remaining": 30.0, "unit": "USD" },
"remaining": 30.0,
"unit": "USD",
"rate_limits": [
{ "window": "5h", "limit": 10.0, "used": 4.0, "remaining": 6.0,
"window_start": "2026-09-13T00:00:00Z", "reset_at": "2026-09-13T05:00:00Z" },
{ "window": "1d", "limit": 30.0, "used": 10.0, "remaining": 20.0,
"window_start": "2026-09-13T00:00:00Z" }
]
});
let result = parse_sub2api(&body);
assert!(result.success);
let data = result.data.unwrap();
assert_eq!(data.len(), 3);
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
assert_eq!(data[0].remaining, Some(30.0));
assert_eq!(data[0].total, Some(50.0));
assert_eq!(data[0].used, Some(20.0));
assert_eq!(data[0].unit.as_deref(), Some("USD"));
assert_eq!(data[1].plan_name.as_deref(), Some("five_hour"));
assert_eq!(data[1].remaining, Some(6.0));
assert_eq!(data[1].resets_at.as_deref(), Some("2026-09-13T05:00:00Z"));
assert_eq!(data[2].plan_name.as_deref(), Some("daily_limit"));
assert_eq!(data[2].resets_at, None);
}
#[test]
fn sub2api_subscription_windows() {
let body = json!({
"mode": "unrestricted",
"isValid": true,
"planName": "claude-pro",
"unit": "USD",
"subscription": {
"daily_usage_usd": 1.0, "daily_limit_usd": 10.0,
"weekly_usage_usd": 8.0, "weekly_limit_usd": 30.0,
"monthly_usage_usd": 55.0, "monthly_limit_usd": 60.0
}
});
let result = parse_sub2api(&body);
assert!(result.success);
let data = result.data.unwrap();
assert_eq!(data.len(), 3);
assert_eq!(data[0].plan_name.as_deref(), Some("daily_limit"));
assert_eq!(data[0].remaining, Some(9.0));
assert_eq!(data[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(data[1].remaining, Some(22.0));
assert_eq!(data[2].plan_name.as_deref(), Some("monthly_limit"));
assert_eq!(data[2].remaining, Some(5.0));
assert_eq!(data[2].is_valid, Some(true));
}
#[test]
fn sub2api_wallet_balance() {
let body = json!({
"mode": "unrestricted",
"isValid": true,
"planName": "钱包余额",
"remaining": 12.5,
"unit": "USD",
"balance": 12.5
});
let data = parse_sub2api(&body).data.unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
assert_eq!(data[0].remaining, Some(12.5));
assert_eq!(data[0].unit.as_deref(), Some("USD"));
}
#[test]
fn sub2api_missing_mode_is_deterministic_failure() {
let body = json!({ "foo": 1 });
let result = parse_sub2api(&body);
assert!(!result.success);
assert!(result.error.unwrap().contains("Missing 'mode'"));
}
#[test]
fn sub2api_exhausted_window_is_invalid() {
let body = json!({
"mode": "quota_limited",
"isValid": true,
"quota": { "limit": 10.0, "used": 10.0, "remaining": 0.0, "unit": "USD" }
});
let data = parse_sub2api(&body).data.unwrap();
assert_eq!(data[0].is_valid, Some(false));
}
}
+23 -8
View File
@@ -23,6 +23,7 @@ pub enum UsageKind {
BalanceNovita,
BalanceKimi,
BalanceNewapi,
BalanceSub2Api,
PlanKimiCoding,
PlanZhipu,
PlanMinimax,
@@ -40,6 +41,7 @@ impl UsageKind {
UsageKind::BalanceNovita => "balance:novita",
UsageKind::BalanceKimi => "balance:kimi",
UsageKind::BalanceNewapi => "balance:newapi",
UsageKind::BalanceSub2Api => "balance:sub2api",
UsageKind::PlanKimiCoding => "plan:kimi_coding",
UsageKind::PlanZhipu => "plan:zhipu",
UsageKind::PlanMinimax => "plan:minimax",
@@ -47,7 +49,7 @@ impl UsageKind {
}
}
pub const ALL: [UsageKind; 11] = [
pub const ALL: [UsageKind; 12] = [
UsageKind::BalanceDeepseek,
UsageKind::BalanceSiliconflow,
UsageKind::BalanceOpenrouter,
@@ -55,6 +57,7 @@ impl UsageKind {
UsageKind::BalanceNovita,
UsageKind::BalanceKimi,
UsageKind::BalanceNewapi,
UsageKind::BalanceSub2Api,
UsageKind::PlanKimiCoding,
UsageKind::PlanZhipu,
UsageKind::PlanMinimax,
@@ -74,6 +77,7 @@ impl std::str::FromStr for UsageKind {
"balance:novita" => UsageKind::BalanceNovita,
"balance:kimi" => UsageKind::BalanceKimi,
"balance:newapi" => UsageKind::BalanceNewapi,
"balance:sub2api" => UsageKind::BalanceSub2Api,
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
"plan:zhipu" => UsageKind::PlanZhipu,
"plan:minimax" => UsageKind::PlanMinimax,
@@ -97,10 +101,13 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter);
}
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
// codingplan.site 家族:主域(含任意子域除 ai. 外)是 Sub2API 面板
// (/api/v1/settings/public 指纹命中,NewAPI 端点全 404);
// ai.codingplan.site 才是 NewAPI 实例(/api/status、/api/user/self 可用)。
if url.contains("ai.codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
} else if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceSub2Api);
}
if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun);
@@ -186,6 +193,14 @@ pub async fn query_kind(
.unwrap_or(base_url);
balance::query_newapi(url, token, uid, timeout).await
}
UsageKind::BalanceSub2Api => {
// sub2api 复用推理 API Key,无需额外凭据;base_url 同样允许面板覆盖。
let url = usage_config
.and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(base_url);
balance::query_sub2api(url, api_key, timeout).await
}
UsageKind::PlanKimiCoding => {
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
}
@@ -279,15 +294,15 @@ mod tests {
#[test]
fn detect_provider_codingplan_site_family() {
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
// codingplan.site 家族分家:主域是 Sub2API 面板(推理 key 直查 /v1/usage),
// ai. 子域才是 NewAPI 实例。
assert_eq!(
detect_provider("https://codingplan.site"),
vec![UsageKind::BalanceNewapi]
vec![UsageKind::BalanceSub2Api]
);
assert_eq!(
detect_provider("https://codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
vec![UsageKind::BalanceSub2Api]
);
assert_eq!(
detect_provider("https://ai.codingplan.site"),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.16",
"version": "0.7.17",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",