Files
KimiCodeSwitch/src-tauri/src/services/mod.rs
T
KimiSwitch Dev 1a11cfbaaf feat(usage): CodingPlan.site 自动识别 NewAPI + 账单查询体验修复
- detect_provider 新增 codingplan.site → balance:newapi(该站为
  NewAPI 实例,实测确认),保存补齐 TS 侧缺失的 balance:newapi 断言
- 凭据缺失等确定性错误改走 Ok(failure)(原 Err 被前端一律显示为
  「网络异常」),错误消息按 kind 前缀包裹后仍可本地化映射
- formatAmount 收口到 usage-display 统一复用:¤ 占位符号不再渲染、
  金额统一两位小数(配置弹窗此前直接拼原始数值)
2026-09-05 16:04:38 +08:00

339 lines
13 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
//! 供应商账单/用量查询统一入口。
//!
//! - [`UsageKind`]:11 种查询类型,字符串形式与前端 / SQLite settings 约定一致
//! (如 `"balance:deepseek"`、`"plan:kimi_coding"`)。
//! - [`detect_provider`]:按 base_url host 子串匹配,旧用户无显式配置时自动识别。
//! - [`query_kind`]:按 kind 路由到 balance / coding_plan 的具体实现。
pub mod balance;
pub mod coding_plan;
pub mod usage_types;
pub use usage_types::{UsageData, UsageResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UsageKind {
BalanceDeepseek,
BalanceSiliconflow,
BalanceOpenrouter,
BalanceStepfun,
BalanceNovita,
BalanceKimi,
BalanceNewapi,
PlanKimiCoding,
PlanZhipu,
PlanMinimax,
PlanOpencodeGo,
}
impl UsageKind {
/// 与前端 / SQLite settings(`usage_kinds:<provider_name>`)约定的字符串形式。
pub fn as_str(&self) -> &'static str {
match self {
UsageKind::BalanceDeepseek => "balance:deepseek",
UsageKind::BalanceSiliconflow => "balance:siliconflow",
UsageKind::BalanceOpenrouter => "balance:openrouter",
UsageKind::BalanceStepfun => "balance:stepfun",
UsageKind::BalanceNovita => "balance:novita",
UsageKind::BalanceKimi => "balance:kimi",
UsageKind::BalanceNewapi => "balance:newapi",
UsageKind::PlanKimiCoding => "plan:kimi_coding",
UsageKind::PlanZhipu => "plan:zhipu",
UsageKind::PlanMinimax => "plan:minimax",
UsageKind::PlanOpencodeGo => "plan:opencode_go",
}
}
pub const ALL: [UsageKind; 11] = [
UsageKind::BalanceDeepseek,
UsageKind::BalanceSiliconflow,
UsageKind::BalanceOpenrouter,
UsageKind::BalanceStepfun,
UsageKind::BalanceNovita,
UsageKind::BalanceKimi,
UsageKind::BalanceNewapi,
UsageKind::PlanKimiCoding,
UsageKind::PlanZhipu,
UsageKind::PlanMinimax,
UsageKind::PlanOpencodeGo,
];
}
impl std::str::FromStr for UsageKind {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"balance:deepseek" => UsageKind::BalanceDeepseek,
"balance:siliconflow" => UsageKind::BalanceSiliconflow,
"balance:openrouter" => UsageKind::BalanceOpenrouter,
"balance:stepfun" => UsageKind::BalanceStepfun,
"balance:novita" => UsageKind::BalanceNovita,
"balance:kimi" => UsageKind::BalanceKimi,
"balance:newapi" => UsageKind::BalanceNewapi,
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
"plan:zhipu" => UsageKind::PlanZhipu,
"plan:minimax" => UsageKind::PlanMinimax,
"plan:opencode_go" => UsageKind::PlanOpencodeGo,
_ => return Err(()),
})
}
}
/// 按 base_url 子串匹配可支持的查询类型;无匹配返回空 vec。
/// 一个 base_url 理论上可同时命中多种(套餐 + 余额),故返回 Vec。
pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
let url = base_url.to_lowercase();
let mut kinds = Vec::new();
if url.contains("api.deepseek.com") {
kinds.push(UsageKind::BalanceDeepseek);
}
if url.contains("api.siliconflow.cn") {
kinds.push(UsageKind::BalanceSiliconflow);
}
if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter);
}
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
}
if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun);
}
if url.contains("api.novita.ai") {
kinds.push(UsageKind::BalanceNovita);
}
// Kimi 开放平台(Moonshot):国内站 api.moonshot.cn / 国际站 api.moonshot.ai。
// 注意别与 api.kimi.com(Kimi Code 官方端点)混淆:后者只有 /coding 路径命中套餐。
if url.contains("api.moonshot.cn") || url.contains("api.moonshot.ai") {
kinds.push(UsageKind::BalanceKimi);
}
if (url.contains("api.kimi.com") || url.contains("api.kimi.ai"))
&& url.contains("/coding")
{
kinds.push(UsageKind::PlanKimiCoding);
}
if url.contains("open.bigmodel.cn") || url.contains("api.z.ai") {
kinds.push(UsageKind::PlanZhipu);
}
if url.contains("api.minimaxi.com") {
kinds.push(UsageKind::PlanMinimax);
}
// OpenCode Go 套餐:只认 /zen/go 路径;/zen/v1(OpenCode Zen 按量付费)不得命中。
if url.contains("/zen/go") {
kinds.push(UsageKind::PlanOpencodeGo);
}
kinds
}
/// BalanceNewapi 模板所需凭据(web 控制台 access token + user id)。
/// 两者都必须存在且非空;返回 None 表示缺少凭据(确定性配置错误)。
fn newapi_creds(
usage_config: Option<&crate::models::UsageConfig>,
) -> Option<(&str, &str)> {
usage_config.and_then(|c| {
c.access_token
.as_deref()
.zip(c.user_id.as_deref())
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
})
}
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
/// 其余分支忽略;调用方在 templateType=="newapi" 时保证其存在。
pub async fn query_kind(
kind: UsageKind,
base_url: &str,
api_key: &str,
usage_config: Option<&crate::models::UsageConfig>,
) -> Result<UsageResult, String> {
let lower = base_url.to_lowercase();
// 用户配置的超时(秒);0/未配置回退默认 8s。
let timeout = usage_config
.and_then(|c| c.timeout_seconds)
.filter(|&s| s > 0)
.map(std::time::Duration::from_secs)
.unwrap_or(balance::REQUEST_TIMEOUT);
match kind {
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key, timeout).await,
UsageKind::BalanceSiliconflow => {
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com"), timeout).await
}
UsageKind::BalanceOpenrouter => balance::query_openrouter(api_key, timeout).await,
UsageKind::BalanceStepfun => balance::query_stepfun(api_key, timeout).await,
UsageKind::BalanceNovita => balance::query_novita(api_key, timeout).await,
UsageKind::BalanceKimi => {
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
}
UsageKind::BalanceNewapi => {
// 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示),
// 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。
let Some((token, uid)) = newapi_creds(usage_config) else {
return Ok(UsageResult::failure(
"newapi template requires accessToken and userId".to_string(),
));
};
let url = usage_config
.and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(base_url);
balance::query_newapi(url, token, uid, timeout).await
}
UsageKind::PlanKimiCoding => {
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
}
UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key, timeout).await,
UsageKind::PlanMinimax => {
coding_plan::query_minimax(api_key, !lower.contains("minimax.io"), timeout).await
}
UsageKind::PlanOpencodeGo => coding_plan::query_opencode_go(api_key, timeout).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_provider_maps_known_hosts() {
let cases: [(&str, UsageKind); 10] = [
("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek),
("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow),
("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter),
("https://api.stepfun.com/v1", UsageKind::BalanceStepfun),
("https://api.novita.ai/v3", UsageKind::BalanceNovita),
("https://api.moonshot.cn/v1", UsageKind::BalanceKimi),
("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding),
(
"https://open.bigmodel.cn/api/paas/v4",
UsageKind::PlanZhipu,
),
("https://api.minimaxi.com/v1", UsageKind::PlanMinimax),
("https://opencode.ai/zen/go/v1", UsageKind::PlanOpencodeGo),
];
for (url, expected) in cases {
assert_eq!(
detect_provider(url),
vec![expected],
"url: {url}"
);
}
// z.ai 也命中智谱
assert_eq!(
detect_provider("https://api.z.ai/api/paas/v4"),
vec![UsageKind::PlanZhipu]
);
}
#[test]
fn detect_provider_unknown_url_returns_empty() {
assert!(detect_provider("https://api.openai.com/v1").is_empty());
assert!(detect_provider("https://example.com").is_empty());
assert!(detect_provider("").is_empty());
// api.kimi.com 但无 /coding 路径 → 不命中套餐查询,也不命中 Moonshot 余额
assert!(detect_provider("https://api.kimi.com/v1").is_empty());
}
#[test]
fn detect_provider_kimi_coding_matches_global_ai_host() {
// global 站 api.kimi.ai + /coding 命中套餐
assert_eq!(
detect_provider("https://api.kimi.ai/coding/v1"),
vec![UsageKind::PlanKimiCoding]
);
assert_eq!(
detect_provider("https://api.kimi.ai/coding/v1/usages"),
vec![UsageKind::PlanKimiCoding]
);
// api.kimi.ai 但无 /coding 路径 → 不命中(也不命中 Moonshot 余额)
assert!(detect_provider("https://api.kimi.ai/v1").is_empty());
// .com 老路径不受影响
assert_eq!(
detect_provider("https://api.kimi.com/coding/v1"),
vec![UsageKind::PlanKimiCoding]
);
}
#[test]
fn detect_provider_opencode_go_excludes_payg_zen() {
// /zen/go 命中套餐查询
assert_eq!(
detect_provider("https://opencode.ai/zen/go/v1"),
vec![UsageKind::PlanOpencodeGo]
);
// /zen/v1(OpenCode Zen 按量付费)不得命中
assert!(detect_provider("https://opencode.ai/zen/v1").is_empty());
// 裸域名也命中(go 套餐 base 无 v1 后缀时)
assert_eq!(
detect_provider("https://opencode.ai/zen/go"),
vec![UsageKind::PlanOpencodeGo]
);
}
#[test]
fn detect_provider_codingplan_site_family() {
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
assert_eq!(
detect_provider("https://codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
}
#[test]
fn usage_kind_string_roundtrip() {
use std::str::FromStr;
for kind in UsageKind::ALL {
let s = kind.as_str();
assert_eq!(UsageKind::from_str(s), Ok(kind), "kind: {s}");
}
assert!(UsageKind::from_str("balance:unknown").is_err());
assert!(UsageKind::from_str("").is_err());
}
#[test]
fn newapi_creds_requires_both_and_nonempty() {
use crate::models::UsageConfig;
let cfg = |token: Option<&str>, uid: Option<&str>| UsageConfig {
enabled: true,
template_type: "newapi".to_string(),
base_url: None,
access_token: token.map(str::to_string),
user_id: uid.map(str::to_string),
auto_query_interval_minutes: None,
timeout_seconds: None,
};
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None);
assert_eq!(newapi_creds(Some(&cfg(None, None))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), None))), None);
assert_eq!(newapi_creds(Some(&cfg(None, Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some(""), Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), Some("")))), None);
// 两项齐全 → Some
assert_eq!(
newapi_creds(Some(&cfg(Some("tok"), Some("uid")))),
Some(("tok", "uid"))
);
}
}