Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cf194b5c |
No files matched your search
@@ -253,6 +253,17 @@ KimiSwitch 现有 `usageKinds` 枚举是**每家中转站一个 Rust 函数**的
|
|||||||
|
|
||||||
## 5. 新增能力实现清单(改动点)
|
## 5. 新增能力实现清单(改动点)
|
||||||
|
|
||||||
|
> 实施状态更新(2026-09-13):**Sub2API 兼容已实现(`balance:sub2api`)**。
|
||||||
|
> 实测澄清:`codingplan.site`(主域)**不是** NewAPI 而是 Wei-Shaw/sub2api 面板
|
||||||
|
> (NewAPI 端点 `/api/status`、`/api/user/self` 全 404;`/api/v1/settings/public`
|
||||||
|
> 返回 `data.affiliate_enabled` 指纹命中;`/v1/usage`、`/v1/sub2api/billing` 为
|
||||||
|
> API-key 网关端点)。`ai.codingplan.site` 才是 NewAPI 实例。detect_provider
|
||||||
|
> 已按此拆分家族规则。Sub2API 查询复用推理 sk- key(`GET {base}/v1/usage`,
|
||||||
|
> Bearer),解析三种 mode:quota_limited(quota + 5h/1d/7d rate_limits)、
|
||||||
|
> unrestricted 订阅组(daily/weekly/monthly usage/limit USD)、钱包余额。
|
||||||
|
> UsageConfigModal 模板加 "sub2api"(无需凭据,可选 Base URL 覆盖)。
|
||||||
|
> 验证:cargo test 124 通过(含 6 个 sub2api 解析用例)、tsc 零错误。
|
||||||
|
|
||||||
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
|
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
|
||||||
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
|
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
|
||||||
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
|
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.16",
|
"version": "0.7.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# KimiSwitch v0.7.17
|
||||||
|
|
||||||
|
## 新增
|
||||||
|
|
||||||
|
- **账单查询适配 Sub2API 中转站(`balance:sub2api`)**:支持 Wei-Shaw/sub2api 面板的余额/额度查询(`GET {base}/v1/usage`),自动复用推理 `sk-` API Key,**无需**网页后台 Access Token;解析三种模式——单 Key 总额度(quota)、5 小时/每日/7 天速率窗口(含重置倒计时)、订阅组日/周/月额度与钱包余额(USD)
|
||||||
|
- 用量配置面板新增「Sub2API 中转站」模板,可选覆盖查询地址(Base URL)
|
||||||
|
- `detect_provider` 家族规则拆分:`codingplan.site` 主域识别为 Sub2API,`ai.codingplan.site` 识别为 NewAPI(修正此前主域被误判为 NewAPI 导致查询 404 的问题)
|
||||||
|
- **模型映射「获取模型列表」新增批量选择**:发现列表标题右侧新增「全选 / 取消全选 / 反选」三个按钮,配合已有的已添加标记与「添加选中的」,批量接入中转站模型列表更省事
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 修复 `codingplan.site`(Sub2API 面板)走 NewAPI 模板查询必然失败的问题——NewAPI 模板(Access Token + User ID)仍适用于 NewAPI / OneAPI 站点,两者互不影响
|
||||||
|
|
||||||
|
## 备注
|
||||||
|
|
||||||
|
- Sub2API 查询的金额单位由上游硬编码为 USD;速率窗口标签沿用现有本地化(5 小时 / 每日 / 7 天)
|
||||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.16"
|
version = "0.7.17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.16"
|
version = "0.7.17"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["codingplan.site"]
|
authors = ["codingplan.site"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+11
-10
@@ -690,19 +690,20 @@ pub async fn query_provider_usage(
|
|||||||
|
|
||||||
let base_url = resolve_base_url(provider);
|
let base_url = resolve_base_url(provider);
|
||||||
|
|
||||||
// NewAPI template: query the gateway's own usage endpoints with its
|
// NewAPI / Sub2API template: query the gateway's own usage endpoints.
|
||||||
// dedicated credentials, bypassing usage_kinds entirely.
|
// 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 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
|
// Err = transient (network) → propagate for retry, same semantics
|
||||||
// as the kinds loop below. Config errors surface as Ok(failure).
|
// as the kinds loop below. Config errors surface as Ok(failure).
|
||||||
let result = services::query_kind(
|
let result =
|
||||||
UsageKind::BalanceNewapi,
|
services::query_kind(kind, &base_url, &api_key, Some(cfg)).await?;
|
||||||
&base_url,
|
|
||||||
&api_key,
|
|
||||||
Some(cfg),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if result.success {
|
if result.success {
|
||||||
usage_cache()
|
usage_cache()
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ pub struct UsageConfig {
|
|||||||
impl UsageConfig {
|
impl UsageConfig {
|
||||||
pub const TEMPLATE_AUTO: &'static str = "auto";
|
pub const TEMPLATE_AUTO: &'static str = "auto";
|
||||||
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
|
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
|
||||||
|
pub const TEMPLATE_SUB2API: &'static str = "sub2api";
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Config {
|
impl std::fmt::Debug for Config {
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -688,4 +841,105 @@ mod tests {
|
|||||||
assert_eq!(data[0].remaining, Some(1.0));
|
assert_eq!(data[0].remaining, Some(1.0));
|
||||||
assert_eq!(data[0].used, Some(0.2));
|
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,6 +23,7 @@ pub enum UsageKind {
|
|||||||
BalanceNovita,
|
BalanceNovita,
|
||||||
BalanceKimi,
|
BalanceKimi,
|
||||||
BalanceNewapi,
|
BalanceNewapi,
|
||||||
|
BalanceSub2Api,
|
||||||
PlanKimiCoding,
|
PlanKimiCoding,
|
||||||
PlanZhipu,
|
PlanZhipu,
|
||||||
PlanMinimax,
|
PlanMinimax,
|
||||||
@@ -40,6 +41,7 @@ impl UsageKind {
|
|||||||
UsageKind::BalanceNovita => "balance:novita",
|
UsageKind::BalanceNovita => "balance:novita",
|
||||||
UsageKind::BalanceKimi => "balance:kimi",
|
UsageKind::BalanceKimi => "balance:kimi",
|
||||||
UsageKind::BalanceNewapi => "balance:newapi",
|
UsageKind::BalanceNewapi => "balance:newapi",
|
||||||
|
UsageKind::BalanceSub2Api => "balance:sub2api",
|
||||||
UsageKind::PlanKimiCoding => "plan:kimi_coding",
|
UsageKind::PlanKimiCoding => "plan:kimi_coding",
|
||||||
UsageKind::PlanZhipu => "plan:zhipu",
|
UsageKind::PlanZhipu => "plan:zhipu",
|
||||||
UsageKind::PlanMinimax => "plan:minimax",
|
UsageKind::PlanMinimax => "plan:minimax",
|
||||||
@@ -47,7 +49,7 @@ impl UsageKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ALL: [UsageKind; 11] = [
|
pub const ALL: [UsageKind; 12] = [
|
||||||
UsageKind::BalanceDeepseek,
|
UsageKind::BalanceDeepseek,
|
||||||
UsageKind::BalanceSiliconflow,
|
UsageKind::BalanceSiliconflow,
|
||||||
UsageKind::BalanceOpenrouter,
|
UsageKind::BalanceOpenrouter,
|
||||||
@@ -55,6 +57,7 @@ impl UsageKind {
|
|||||||
UsageKind::BalanceNovita,
|
UsageKind::BalanceNovita,
|
||||||
UsageKind::BalanceKimi,
|
UsageKind::BalanceKimi,
|
||||||
UsageKind::BalanceNewapi,
|
UsageKind::BalanceNewapi,
|
||||||
|
UsageKind::BalanceSub2Api,
|
||||||
UsageKind::PlanKimiCoding,
|
UsageKind::PlanKimiCoding,
|
||||||
UsageKind::PlanZhipu,
|
UsageKind::PlanZhipu,
|
||||||
UsageKind::PlanMinimax,
|
UsageKind::PlanMinimax,
|
||||||
@@ -74,6 +77,7 @@ impl std::str::FromStr for UsageKind {
|
|||||||
"balance:novita" => UsageKind::BalanceNovita,
|
"balance:novita" => UsageKind::BalanceNovita,
|
||||||
"balance:kimi" => UsageKind::BalanceKimi,
|
"balance:kimi" => UsageKind::BalanceKimi,
|
||||||
"balance:newapi" => UsageKind::BalanceNewapi,
|
"balance:newapi" => UsageKind::BalanceNewapi,
|
||||||
|
"balance:sub2api" => UsageKind::BalanceSub2Api,
|
||||||
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
|
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
|
||||||
"plan:zhipu" => UsageKind::PlanZhipu,
|
"plan:zhipu" => UsageKind::PlanZhipu,
|
||||||
"plan:minimax" => UsageKind::PlanMinimax,
|
"plan:minimax" => UsageKind::PlanMinimax,
|
||||||
@@ -97,10 +101,13 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
|||||||
if url.contains("openrouter.ai") {
|
if url.contains("openrouter.ai") {
|
||||||
kinds.push(UsageKind::BalanceOpenrouter);
|
kinds.push(UsageKind::BalanceOpenrouter);
|
||||||
}
|
}
|
||||||
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
|
// codingplan.site 家族:主域(含任意子域除 ai. 外)是 Sub2API 面板
|
||||||
// 家族匹配意味着 ai. 子域也自然命中该规则。
|
// (/api/v1/settings/public 指纹命中,NewAPI 端点全 404);
|
||||||
if url.contains("codingplan.site") {
|
// ai.codingplan.site 才是 NewAPI 实例(/api/status、/api/user/self 可用)。
|
||||||
|
if url.contains("ai.codingplan.site") {
|
||||||
kinds.push(UsageKind::BalanceNewapi);
|
kinds.push(UsageKind::BalanceNewapi);
|
||||||
|
} else if url.contains("codingplan.site") {
|
||||||
|
kinds.push(UsageKind::BalanceSub2Api);
|
||||||
}
|
}
|
||||||
if url.contains("api.stepfun.com") {
|
if url.contains("api.stepfun.com") {
|
||||||
kinds.push(UsageKind::BalanceStepfun);
|
kinds.push(UsageKind::BalanceStepfun);
|
||||||
@@ -186,6 +193,14 @@ pub async fn query_kind(
|
|||||||
.unwrap_or(base_url);
|
.unwrap_or(base_url);
|
||||||
balance::query_newapi(url, token, uid, timeout).await
|
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 => {
|
UsageKind::PlanKimiCoding => {
|
||||||
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
|
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
|
||||||
}
|
}
|
||||||
@@ -279,15 +294,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_provider_codingplan_site_family() {
|
fn detect_provider_codingplan_site_family() {
|
||||||
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
|
// codingplan.site 家族分家:主域是 Sub2API 面板(推理 key 直查 /v1/usage),
|
||||||
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
|
// ai. 子域才是 NewAPI 实例。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect_provider("https://codingplan.site"),
|
detect_provider("https://codingplan.site"),
|
||||||
vec![UsageKind::BalanceNewapi]
|
vec![UsageKind::BalanceSub2Api]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect_provider("https://codingplan.site/v1"),
|
detect_provider("https://codingplan.site/v1"),
|
||||||
vec![UsageKind::BalanceNewapi]
|
vec![UsageKind::BalanceSub2Api]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect_provider("https://ai.codingplan.site"),
|
detect_provider("https://ai.codingplan.site"),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.7.16",
|
"version": "0.7.17",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -527,6 +527,18 @@ function ModelMapping({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectAllModels = () => {
|
||||||
|
if (!discovered) return;
|
||||||
|
setSelected(new Set(discovered.map((m) => m.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAllModels = () => setSelected(new Set());
|
||||||
|
|
||||||
|
const invertSelection = () => {
|
||||||
|
if (!discovered) return;
|
||||||
|
setSelected((prev) => new Set(discovered.filter((m) => !prev.has(m.id)).map((m) => m.id)));
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddSelected = () => {
|
const handleAddSelected = () => {
|
||||||
if (!discovered || selected.size === 0) return;
|
if (!discovered || selected.size === 0) return;
|
||||||
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||||
@@ -596,8 +608,33 @@ function ModelMapping({
|
|||||||
|
|
||||||
{discovered && (
|
{discovered && (
|
||||||
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
|
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
|
||||||
<div className="text-sm font-medium">
|
<div className="flex items-center justify-between">
|
||||||
{t("discoveredModels", { count: discovered.length })}
|
<div className="text-sm font-medium">
|
||||||
|
{t("discoveredModels", { count: discovered.length })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectAllModels}
|
||||||
|
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("selectAll")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deselectAllModels}
|
||||||
|
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("deselectAll")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={invertSelection}
|
||||||
|
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("invertSelection")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ interface UsageConfigModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TEMPLATES: ReadonlyArray<{
|
const TEMPLATES: ReadonlyArray<{
|
||||||
id: "auto" | "newapi";
|
id: "auto" | "newapi" | "sub2api";
|
||||||
labelKey: TranslationKey;
|
labelKey: TranslationKey;
|
||||||
hintKey: TranslationKey;
|
hintKey: TranslationKey;
|
||||||
}> = [
|
}> = [
|
||||||
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
|
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
|
||||||
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
|
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
|
||||||
|
{ id: "sub2api", labelKey: "usageTemplateSub2api", hintKey: "usageSub2apiHint" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function defaultConfig(provider: Provider): UsageConfig {
|
function defaultConfig(provider: Provider): UsageConfig {
|
||||||
@@ -225,7 +226,9 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
|||||||
<p className="text-xs text-content-muted">
|
<p className="text-xs text-content-muted">
|
||||||
{cfg.templateType === "newapi"
|
{cfg.templateType === "newapi"
|
||||||
? t("usageNewapiHint")
|
? t("usageNewapiHint")
|
||||||
: t("usageAutoDetectHint")}
|
: cfg.templateType === "sub2api"
|
||||||
|
? t("usageSub2apiHint")
|
||||||
|
: t("usageAutoDetectHint")}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -237,9 +240,9 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
|||||||
<span className="text-blue-500">{"{{baseUrl}}"}</span>
|
<span className="text-blue-500">{"{{baseUrl}}"}</span>
|
||||||
<span className="text-content-muted">=</span>
|
<span className="text-content-muted">=</span>
|
||||||
<span className="text-content-primary break-all">
|
<span className="text-content-primary break-all">
|
||||||
{cfg.templateType === "newapi"
|
{cfg.templateType === "auto"
|
||||||
? cfg.baseUrl || provider.base_url || "-"
|
? provider.base_url || "-"
|
||||||
: provider.base_url || "-"}
|
: cfg.baseUrl || provider.base_url || "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs font-mono">
|
<div className="flex items-center gap-2 text-xs font-mono">
|
||||||
@@ -344,6 +347,21 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* sub2api: no extra credentials (reuses the inference key), optional gateway override */}
|
||||||
|
{!isManaged && cfg.templateType === "sub2api" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
value={cfg.baseUrl ?? ""}
|
||||||
|
placeholder={provider.base_url ?? "https://your-sub2api-site.com"}
|
||||||
|
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* timeout + auto query interval */}
|
{/* timeout + auto query interval */}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type UsageKind =
|
|||||||
| "balance:novita"
|
| "balance:novita"
|
||||||
| "balance:kimi"
|
| "balance:kimi"
|
||||||
| "balance:newapi"
|
| "balance:newapi"
|
||||||
|
| "balance:sub2api"
|
||||||
| "plan:kimi_coding"
|
| "plan:kimi_coding"
|
||||||
| "plan:zhipu"
|
| "plan:zhipu"
|
||||||
| "plan:minimax"
|
| "plan:minimax"
|
||||||
@@ -32,6 +33,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
|||||||
"balance:novita",
|
"balance:novita",
|
||||||
"balance:kimi",
|
"balance:kimi",
|
||||||
"balance:newapi",
|
"balance:newapi",
|
||||||
|
"balance:sub2api",
|
||||||
"plan:kimi_coding",
|
"plan:kimi_coding",
|
||||||
"plan:zhipu",
|
"plan:zhipu",
|
||||||
"plan:minimax",
|
"plan:minimax",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
statusAll: "All",
|
statusAll: "All",
|
||||||
selectAll: "Select all",
|
selectAll: "Select all",
|
||||||
deselectAll: "Deselect all",
|
deselectAll: "Deselect all",
|
||||||
|
invertSelection: "Invert selection",
|
||||||
selectedCount: "{n} selected",
|
selectedCount: "{n} selected",
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
unarchive: "Unarchive",
|
unarchive: "Unarchive",
|
||||||
@@ -408,8 +409,10 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
usageTemplate: "Query template",
|
usageTemplate: "Query template",
|
||||||
usageTemplateAuto: "Auto-detect",
|
usageTemplateAuto: "Auto-detect",
|
||||||
usageTemplateNewapi: "NewAPI gateway",
|
usageTemplateNewapi: "NewAPI gateway",
|
||||||
|
usageTemplateSub2api: "Sub2API gateway",
|
||||||
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
|
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
|
||||||
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
|
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
|
||||||
|
usageSub2apiHint: "Query a Sub2API (Wei-Shaw/sub2api) gateway balance / quota (GET /v1/usage). Reuses the inference sk- API key — no extra credentials; the query URL is optionally overridable",
|
||||||
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
|
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
|
||||||
usageDetectedKinds: "Detected query kinds",
|
usageDetectedKinds: "Detected query kinds",
|
||||||
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
|
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
|
||||||
@@ -430,6 +433,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
usageSwitchToNewapi: "Switch to NewAPI template",
|
usageSwitchToNewapi: "Switch to NewAPI template",
|
||||||
usageUnsupportedProvider: "This provider is not supported by auto query",
|
usageUnsupportedProvider: "This provider is not supported by auto query",
|
||||||
usageTier5h: "5-hour",
|
usageTier5h: "5-hour",
|
||||||
|
usageTierDaily: "Daily",
|
||||||
usageTierWeekly: "Weekly",
|
usageTierWeekly: "Weekly",
|
||||||
usageTierMonthly: "Monthly",
|
usageTierMonthly: "Monthly",
|
||||||
usageErrNoKey: "No API key configured",
|
usageErrNoKey: "No API key configured",
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export const zhTranslations = {
|
|||||||
statusAll: "全部",
|
statusAll: "全部",
|
||||||
selectAll: "全选",
|
selectAll: "全选",
|
||||||
deselectAll: "取消全选",
|
deselectAll: "取消全选",
|
||||||
|
invertSelection: "反选",
|
||||||
selectedCount: "已选 {n} 项",
|
selectedCount: "已选 {n} 项",
|
||||||
archive: "归档",
|
archive: "归档",
|
||||||
unarchive: "取消归档",
|
unarchive: "取消归档",
|
||||||
@@ -403,8 +404,10 @@ export const zhTranslations = {
|
|||||||
usageTemplate: "查询模板",
|
usageTemplate: "查询模板",
|
||||||
usageTemplateAuto: "自动检测",
|
usageTemplateAuto: "自动检测",
|
||||||
usageTemplateNewapi: "NewAPI 中转站",
|
usageTemplateNewapi: "NewAPI 中转站",
|
||||||
|
usageTemplateSub2api: "Sub2API 中转站",
|
||||||
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
|
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
|
||||||
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
|
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
|
||||||
|
usageSub2apiHint: "查询 Sub2API (Wei-Shaw/sub2api) 面板的余额 / 额度(GET /v1/usage),自动复用推理 sk- API Key,无需额外凭据;可选覆盖查询地址",
|
||||||
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
|
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
|
||||||
usageDetectedKinds: "检测到的查询类型",
|
usageDetectedKinds: "检测到的查询类型",
|
||||||
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
|
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
|
||||||
@@ -425,6 +428,7 @@ export const zhTranslations = {
|
|||||||
usageSwitchToNewapi: "切换到 NewAPI 模板",
|
usageSwitchToNewapi: "切换到 NewAPI 模板",
|
||||||
usageUnsupportedProvider: "此供应商暂不支持自动查询",
|
usageUnsupportedProvider: "此供应商暂不支持自动查询",
|
||||||
usageTier5h: "5小时",
|
usageTier5h: "5小时",
|
||||||
|
usageTierDaily: "每日",
|
||||||
usageTierWeekly: "7天",
|
usageTierWeekly: "7天",
|
||||||
usageTierMonthly: "30天",
|
usageTierMonthly: "30天",
|
||||||
usageErrNoKey: "未配置 API Key",
|
usageErrNoKey: "未配置 API Key",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function planLabel(name: string, t: TranslateFn): string {
|
|||||||
switch (name) {
|
switch (name) {
|
||||||
case "five_hour":
|
case "five_hour":
|
||||||
return t("usageTier5h");
|
return t("usageTier5h");
|
||||||
|
case "daily_limit":
|
||||||
|
return t("usageTierDaily");
|
||||||
case "weekly_limit":
|
case "weekly_limit":
|
||||||
return t("usageTierWeekly");
|
return t("usageTierWeekly");
|
||||||
case "monthly_limit":
|
case "monthly_limit":
|
||||||
|
|||||||
+3
-2
@@ -75,11 +75,12 @@ export interface Config {
|
|||||||
/**
|
/**
|
||||||
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
|
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
|
||||||
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
|
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
|
||||||
* NewAPI/OneAPI gateway with accessToken + userId.
|
* NewAPI/OneAPI gateway with accessToken + userId; "sub2api" = query a
|
||||||
|
* Sub2API (Wei-Shaw/sub2api) gateway, reusing the inference API key.
|
||||||
*/
|
*/
|
||||||
export interface UsageConfig {
|
export interface UsageConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
templateType: "auto" | "newapi";
|
templateType: "auto" | "newapi" | "sub2api";
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** NewAPI web-console access token (NOT the sk- inference key). */
|
/** NewAPI web-console access token (NOT the sk- inference key). */
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user