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:
KimiSwitch Dev committed 2026-07-29 20:59:53 +08:00
1 parent a1fdc6c93d
commit 2614f80d0f
27 files changed
+3442 -67

No files matched your search

+212 -23
View File
@@ -1,8 +1,12 @@
use crate::db;
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType};
use crate::pi_io;
use crate::services::{self, UsageKind, UsageResult};
use indexmap::IndexMap;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri_plugin_opener::OpenerExt;
fn fmt_anyhow(err: anyhow::Error) -> String {
@@ -17,12 +21,60 @@ pub fn debug_log(message: String) {
eprintln!("[frontend] {}", message);
}
/// SQLite settings key for a provider's billing/usage query kinds
/// (JSON array of kind strings, e.g. `["balance:deepseek"]`).
fn usage_kinds_key(provider_name: &str) -> String {
format!("usage_kinds:{provider_name}")
}
/// Merge per-provider `usage_kinds` into a loaded config: explicit SQLite
/// settings first, host-based detection as fallback so existing installs get
/// billing support automatically. The field never enters config.toml.
fn merge_usage_kinds(config: &mut Config) {
for p in config.providers.values_mut() {
let from_settings = db::get_setting_pub(&usage_kinds_key(&p.name))
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.filter(|v| !v.is_empty());
p.usage_kinds = from_settings.or_else(|| {
let kinds = services::detect_provider(&resolve_base_url(p));
if kinds.is_empty() {
None
} else {
Some(kinds.iter().map(|k| k.as_str().to_string()).collect())
}
});
}
}
fn load_pi_native_config() -> Result<Config, String> {
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config
.models
.values()
.find(|m| m.provider == provider && m.model == model_id)
{
config.default_model = Some(alias.alias.clone());
}
}
}
}
Ok(config)
}
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
let db_config = db::load_config(&agent).ok();
match agent {
let mut config = match agent {
Agent::KimiCode => {
// config.toml is the authoritative source for provider/model data
// because the user can add or edit providers at any time via the
@@ -69,33 +121,19 @@ pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
}
}
Ok(config)
config
}
Agent::Pi => {
// Pi: SQLite first, fall back to native config on first use.
if let Some(config) = db_config {
if !config.providers.is_empty() {
return Ok(config);
}
match db_config {
Some(config) if !config.providers.is_empty() => config,
_ => load_pi_native_config()?,
}
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config.models.values().find(|m| {
m.provider == provider && m.model == model_id
}) {
config.default_model = Some(alias.alias.clone());
}
}
}
}
Ok(config)
}
}
};
merge_usage_kinds(&mut config);
Ok(config)
}
#[tauri::command]
@@ -108,6 +146,19 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
if matches!(agent, Agent::KimiCode) {
crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow)?;
}
// Persist usage_kinds to the SQLite settings table (never config.toml;
// the field is skip_serializing and the TOML export is hand-built).
// None / empty array → delete the key.
for provider in config.providers.values() {
let key = usage_kinds_key(&provider.name);
match &provider.usage_kinds {
Some(kinds) if !kinds.is_empty() => {
let json = serde_json::to_string(kinds).map_err(|e| e.to_string())?;
db::set_setting_pub(&key, &json).map_err(fmt_anyhow)?;
}
_ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?,
}
}
Ok(())
}
@@ -511,6 +562,144 @@ pub fn set_app_setting(key: String, value: String) -> Result<(), String> {
db::set_setting_pub(&key, &value).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Provider billing / usage query (cc-switch semantics)
// ---------------------------------------------------------------------------
/// 5-minute in-memory cache keyed by (agent, provider_name).
/// Only successful results are cached; failures are always re-queryable.
const USAGE_CACHE_TTL: Duration = Duration::from_secs(300);
type UsageCache = Mutex<HashMap<(String, String), (Instant, UsageResult)>>;
fn usage_cache() -> &'static UsageCache {
static CACHE: OnceLock<UsageCache> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Query a provider's balance / plan quota. The frontend passes only the
/// provider name — base_url, api_key and usage kinds are all resolved here,
/// so the API key never crosses IPC and the host routing cannot be spoofed.
///
/// Error channel semantics (cc-switch):
/// - `Err(_)` = transient failure (network/timeout/body read) → frontend
/// retries and keeps the last good value.
/// - `Ok(success:false)` = deterministic failure (no key / auth / non-2xx /
/// bad JSON / unsupported provider) → show the error text directly.
#[tauri::command]
pub async fn query_provider_usage(
agent: Agent,
provider_name: String,
force_refresh: Option<bool>,
) -> Result<UsageResult, String> {
let cache_key = (agent.as_str().to_string(), provider_name.clone());
if !force_refresh.unwrap_or(false) {
let cached = usage_cache()
.lock()
.unwrap()
.get(&cache_key)
.and_then(|(ts, result)| (ts.elapsed() < USAGE_CACHE_TTL).then(|| result.clone()));
if let Some(result) = cached {
return Ok(result);
}
}
// Load via the same path as load_agent_config_command so usage_kinds
// (SQLite merge + host-detect fallback) is already resolved.
let config = load_agent_config_command(agent)?;
let Some(provider) = config.providers.get(&provider_name) else {
return Ok(UsageResult::failure(format!(
"provider '{provider_name}' not found"
)));
};
// The api_key only ever goes into request headers — never into logs,
// error messages, or the cache key.
let api_key = provider
.api_key
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
provider
.env
.get(expected_api_key_key(&provider.provider_type))
.cloned()
.filter(|s| !s.is_empty())
});
let Some(api_key) = api_key else {
return Ok(UsageResult::failure(if provider.managed {
"provider uses managed OAuth; usage query requires an API key".to_string()
} else {
"no API key configured".to_string()
}));
};
let base_url = resolve_base_url(provider);
let kinds: Vec<UsageKind> = provider
.usage_kinds
.as_ref()
.filter(|v| !v.is_empty())
.map(|v| {
v.iter()
.filter_map(|s| s.parse::<UsageKind>().ok())
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.unwrap_or_else(|| services::detect_provider(&base_url));
if kinds.is_empty() {
return Ok(UsageResult::failure(
"unsupported provider: no usage query available for this base URL".to_string(),
));
}
// A failing kind must not take down the others: collect successes,
// deterministic failures and transient failures separately.
let mut data: Vec<crate::services::UsageData> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let mut transient: Vec<String> = Vec::new();
let mut any_success = false;
for kind in kinds {
match services::query_kind(kind, &base_url, &api_key).await {
Ok(result) if result.success => {
any_success = true;
if let Some(d) = result.data {
data.extend(d);
}
}
Ok(result) => {
if let Some(e) = result.error {
errors.push(format!("{}: {e}", kind.as_str()));
}
}
Err(e) => transient.push(format!("{}: {e}", kind.as_str())),
}
}
if any_success {
let result = UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: if errors.is_empty() {
None
} else {
Some(errors.join("; "))
},
};
usage_cache()
.lock()
.unwrap()
.insert(cache_key, (Instant::now(), result.clone()));
Ok(result)
} else if !transient.is_empty() {
// All kinds failed transiently → propagate Err so the frontend
// rejects and retries (keep-last-good).
Err(transient.join("; "))
} else {
Ok(UsageResult::failure(errors.join("; ")))
}
}
// ---------------------------------------------------------------------------
// Version check (lightweight: GET Gitea releases API, compare tag_name)
// ---------------------------------------------------------------------------