feat: 阈值预警/加力钱包/api_key_env/版本跟踪器/一键体检 — bump to v0.7.20
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

This commit is contained in:
KimiSwitch Dev committed 2026-09-20 09:49:49 +08:00
1 parent 35581103ed
commit fcd08eae32
35 files changed
+2567 -91

No files matched your search

+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.19"
version = "0.7.20"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.19"
version = "0.7.20"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+949 -17
View File
File diff suppressed because it is too large. Load diff
+18 -12
View File
@@ -81,6 +81,7 @@ pub fn init_db() -> DbResult<Connection> {
provider_type TEXT NOT NULL,
base_url TEXT,
api_key TEXT,
api_key_env TEXT,
env TEXT,
note TEXT,
official_url TEXT,
@@ -141,6 +142,9 @@ pub fn init_db() -> DbResult<Connection> {
for stmt in [
"ALTER TABLE providers ADD COLUMN icon TEXT",
"ALTER TABLE providers ADD COLUMN icon_color TEXT",
// `api_key_env` (kimi-code 2.0.0+): the name of the environment
// variable holding the credential, an alternative to `api_key`.
"ALTER TABLE providers ADD COLUMN api_key_env TEXT",
] {
if let Err(e) = conn.execute(stmt, []) {
let msg = e.to_string();
@@ -162,28 +166,29 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut providers = IndexMap::new();
{
let mut stmt = tx.prepare(
"SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
"SELECT name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
FROM providers WHERE agent = ?1 ORDER BY id",
)?;
let provider_rows = stmt.query_map(params![agent.as_str()], |row| {
let provider_type: String = row.get(1)?;
let env_json: Option<String> = row.get(4)?;
let raw_json: Option<String> = row.get(12)?;
let env_json: Option<String> = row.get(5)?;
let raw_json: Option<String> = row.get(13)?;
Ok(Provider {
name: row.get(0)?,
provider_type: provider_type_for_str(&provider_type),
base_url: row.get(2)?,
api_key: row.get(3)?,
api_key_env: row.get(4)?,
env: env_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(),
note: row.get(5)?,
official_url: row.get(6)?,
managed: row.get::<_, i32>(7)? != 0,
enabled: row.get::<_, i32>(8)? != 0,
active: row.get::<_, i32>(9)? != 0,
icon: row.get(10)?,
icon_color: row.get(11)?,
note: row.get(6)?,
official_url: row.get(7)?,
managed: row.get::<_, i32>(8)? != 0,
enabled: row.get::<_, i32>(9)? != 0,
active: row.get::<_, i32>(10)? != 0,
icon: row.get(11)?,
icon_color: row.get(12)?,
raw_other: raw_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
@@ -253,8 +258,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
{
let mut insert_provider = tx.prepare(
"INSERT INTO providers
(agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
(agent, name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?;
for provider in config.providers.values() {
@@ -264,6 +269,7 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
provider.provider_type.as_str(),
provider.base_url,
provider.api_key,
provider.api_key_env,
serde_json::to_string(&provider.env).ok(),
provider.note,
provider.official_url,
+148 -2
View File
@@ -153,6 +153,14 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
// kimi-code 2.0.0+: `api_key_env` names the environment variable
// holding the credential; it is mutually exclusive with `api_key`.
// Captured as its own field (not left in raw_other) so export can
// enforce the exclusivity and the UI can edit it.
let api_key_env = table
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let managed = table.contains_key("oauth")
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -181,6 +189,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
rest.remove("type");
rest.remove("base_url");
rest.remove("api_key");
rest.remove("api_key_env");
rest.remove("managed");
rest.remove("enabled");
rest.remove("env");
@@ -200,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
provider_type,
base_url: base_url.filter(|s| !s.is_empty()),
api_key: api_key.filter(|s| !s.is_empty()),
api_key_env: api_key_env.filter(|s| !s.is_empty()),
env,
note: None,
official_url: None,
@@ -289,11 +299,30 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
pt.insert("base_url".to_string(), TomlValue::String(base_url));
}
// `api_key_env` (kimi-code 2.0.0+) and `api_key` are mutually
// exclusive upstream: emitting both makes the CLI reject the provider
// block, so the env var name alone is written whenever the provider is
// configured that way. A legacy row that still carries the key in
// raw_other (imports before the field existed) is used as the source
// too, so the setting survives the upgrade.
let api_key_env = provider
.api_key_env
.clone()
.or_else(|| {
provider
.raw_other
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty());
// Managed (OAuth) providers persist an empty api_key line so the block
// matches the official CLI's provisioned shape (apiKey: ''). A
// user-set api_key on a managed provider (api_key outranks OAuth in
// the CLI's credential priority) must survive the round-trip.
if provider.managed
if let Some(env_key) = api_key_env {
pt.insert("api_key_env".to_string(), TomlValue::String(env_key));
} else if provider.managed
&& provider.api_key.as_deref().map_or(true, |s| s.is_empty())
{
pt.insert("api_key".to_string(), TomlValue::String("".to_string()));
@@ -328,10 +357,12 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
});
pt.insert("oauth".to_string(), oauth);
}
// Merge remaining raw fields (oauth and env are handled explicitly above).
// Merge remaining raw fields (oauth, env and api_key_env are handled
// explicitly above).
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("oauth");
extra.remove("env");
extra.remove("api_key_env");
// Strip Kimi-Switch-private field: the remembered per-provider
// default model is stored in raw_other.default_model and must NOT
// leak into the agent's config.toml.
@@ -562,6 +593,7 @@ api_key = ""
provider_type: ProviderType::Anthropic,
base_url: Some("https://fast.cdks.work".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -623,6 +655,7 @@ api_key = ""
provider_type: ProviderType::Openai,
base_url: Some(base_url.to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -760,6 +793,7 @@ default_effort = "low"
provider_type: ProviderType::Anthropic,
base_url: Some("https://example.com".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -868,6 +902,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic,
base_url: Some("https://a.example.com".to_string()),
api_key: Some("sk-a".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -888,6 +923,7 @@ max_context_size = 1048576
provider_type: ProviderType::Openai,
base_url: Some("https://b.example.com".to_string()),
api_key: Some("sk-b".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -929,6 +965,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic,
base_url: None,
api_key: Some("sk-x".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -974,6 +1011,7 @@ max_context_size = 1048576
provider_type: ProviderType::Kimi,
base_url: Some("https://api.kimi.com/coding/v1".to_string()),
api_key: api_key.map(String::from),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -1153,4 +1191,112 @@ enabled = true
"user-removed section must be dropped on export"
);
}
#[test]
fn kimi_code_import_extracts_api_key_env() {
// kimi-code 2.0.0+: `api_key_env` names the environment variable that
// holds the credential. It must become its own Provider field and stop
// living in raw_other, so export can enforce the api_key exclusivity.
let toml_str = r#"
default_model = "p1/m1"
[providers.p1]
type = "openai"
base_url = "https://proxy.example.com/v1"
api_key_env = "MY_PROXY_KEY"
[models."p1/m1"]
provider = "p1"
model = "m1"
max_context_size = 128000
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
let provider = config.providers.get("p1").unwrap();
assert_eq!(provider.api_key_env.as_deref(), Some("MY_PROXY_KEY"));
assert_eq!(provider.api_key, None);
assert!(
provider.raw_other.get("api_key_env").is_none(),
"api_key_env must not stay in raw_other"
);
let exported = config_to_kimi_code(&config, Some(&value));
let exported_provider = exported
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p1").unwrap()
.as_table().unwrap();
assert_eq!(
exported_provider.get("api_key_env").and_then(|v| v.as_str()),
Some("MY_PROXY_KEY")
);
assert!(
!exported_provider.contains_key("api_key"),
"api_key and api_key_env are mutually exclusive upstream"
);
}
#[test]
fn kimi_code_export_api_key_env_wins_over_stored_api_key() {
// A provider switched to env-var credentials in the UI must export
// ONLY `api_key_env`: writing both keys makes the CLI reject the block.
// A legacy raw_other copy of the key acts as the source when the field
// is empty (rows saved before the field existed).
let make_provider = |api_key: Option<&str>, api_key_env: Option<&str>, raw: Value| Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://proxy.example.com/v1".to_string()),
api_key: api_key.map(String::from),
api_key_env: api_key_env.map(String::from),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other: raw,
usage_kinds: None,
usage_config: None,
};
let export = |p: Provider| {
let config = Config {
default_model: None,
providers: IndexMap::from([("p".to_string(), p)]),
models: IndexMap::new(),
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
config_to_kimi_code(&config, None)
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p").unwrap()
.as_table()
.unwrap()
.clone()
};
// Both fields set (a DB row saved before the UI cleared the key).
let pt = export(make_provider(Some("sk-stale"), Some("MY_PROXY_KEY"), Value::Null));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("MY_PROXY_KEY"));
assert!(!pt.contains_key("api_key"), "the stored key must be dropped");
// Legacy row: only raw_other carries the key.
let pt = export(make_provider(
Some("sk-stale"),
None,
serde_json::json!({"api_key_env": "LEGACY_KEY"}),
));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("LEGACY_KEY"));
assert!(!pt.contains_key("api_key"));
// No env reference → the stored key is written as before.
let pt = export(make_provider(Some("sk-keep"), None, Value::Null));
assert_eq!(pt.get("api_key").and_then(|v| v.as_str()), Some("sk-keep"));
assert!(!pt.contains_key("api_key_env"));
}
}
+4
View File
@@ -103,10 +103,14 @@ pub fn run() {
commands::list_provider_models,
commands::test_connectivity,
commands::query_provider_usage,
commands::health_check_all,
commands::debug_log,
commands::get_app_setting,
commands::set_app_setting,
commands::check_for_update,
commands::get_kimi_code_version,
commands::get_kimi_code_latest,
commands::get_kimi_code_version_info,
commands::download_update,
commands::open_installer,
commands::open_external_url,
+13
View File
@@ -107,6 +107,12 @@ pub struct Provider {
pub provider_type: ProviderType,
pub base_url: Option<String>,
pub api_key: Option<String>,
/// kimi-code 2.0.0+: name of the process environment variable holding the
/// credential, as an alternative to a stored `api_key` (the two are
/// mutually exclusive upstream — exporting both makes the CLI reject the
/// provider block). Only the variable *name* is stored, never its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default)]
pub env: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -149,6 +155,7 @@ impl PartialEq for Provider {
&& self.provider_type == other.provider_type
&& self.base_url == other.base_url
&& self.api_key == other.api_key
&& self.api_key_env == other.api_key_env
&& self.env == other.env
&& self.note == other.note
&& self.official_url == other.official_url
@@ -172,6 +179,7 @@ impl std::fmt::Debug for Provider {
.field("provider_type", &self.provider_type)
.field("base_url", &self.base_url)
.field("api_key", &"<redacted>")
.field("api_key_env", &self.api_key_env)
.field("env", &"<redacted>")
.field("managed", &self.managed)
.field("enabled", &self.enabled)
@@ -285,6 +293,11 @@ pub struct UsageConfig {
/// Per-request timeout in seconds; 0/None = default (8s).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
/// 用量预警阈值(百分比,0-100);None/0 = 关闭。判定在前端
/// src/hooks/useUsageQuery.ts,Rust 只负责透传——缺了此字段,serde 会在
/// 保存时把前端写下的 threshold 静默丢掉。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<u32>,
}
impl UsageConfig {
+1
View File
@@ -659,6 +659,7 @@ fn provision_managed_provider(region: KimiRegion, oauth_key: &str) -> Result<(),
provider_type: crate::models::ProviderType::Kimi,
base_url: None,
api_key: None,
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
+3
View File
@@ -332,6 +332,9 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
provider_type,
base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()),
api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()),
// Pi's provider file has no env-var credential reference; the
// field stays empty so a Kimi-Code-only setting never leaks here.
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
+82 -3
View File
@@ -9,6 +9,9 @@
//! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
//!
//! 例外:Kimi 的加力钱包(`PLAN_BOOSTER_WALLET`)不是百分比窗口而是金额行,
//! `used` / `total` / `remaining` 均为货币金额(分换算而来),`unit` 为币种。
//!
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched};
@@ -23,6 +26,10 @@ const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
const TIER_MONTHLY_LIMIT: &str = "monthly_limit";
/// Kimi quota 模型的月度代码窗口(kimi-code #3787 后 `limit_month_code`)。
const TIER_MONTH_CODE: &str = "month_code";
/// Kimi quota 响应里的加力钱包(boosterWallet)。**不是百分比 tier**:这条是
/// 金额行,前端 src/lib/usage-display.ts 的 planLabel() 负责本地化,
/// UsageFooter 按「金额行」而非配额行渲染(used/total 是钱数,不是配额比例)。
const PLAN_BOOSTER_WALLET: &str = "booster_wallet";
/// 套餐条目的统一构造:按百分比表示用量。
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
@@ -75,7 +82,9 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
// Response(kimi-code #3787 / 0.43.1 起服务端切换为 quota 模型):
// { usages: { limit_5h, limit_7d, limit_month_total, limit_month_code:
// { used_ratio: 0-1, reset_time?: ISO 8601 } },
// boosterWallet, goods_version }
// boosterWallet: { balanceCents, totalCents, monthlyChargeLimitEnabled,
// monthlyChargeLimitCents, monthlyUsedCents, currency },
// goods_version }
// 旧结构(兼容保留): { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } }
@@ -143,8 +152,8 @@ fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
}
/// 解析 quota 模型的 `usages` 窗口映射(`limit_5h` / `limit_7d` /
/// `limit_month_total` / `limit_month_code`)。全部窗口缺失或无可解析
/// 条目时返回 None,让调用方回退旧结构。
/// `limit_month_total` / `limit_month_code`)+ 加力钱包。窗口与钱包都缺失或
/// 无可解析条目时返回 None,让调用方回退旧结构。
fn parse_kimi_quota(body: &serde_json::Value) -> Option<Vec<UsageData>> {
let usages = body.get("usages")?;
let mut tiers = Vec::new();
@@ -163,9 +172,46 @@ fn parse_kimi_quota(body: &serde_json::Value) -> Option<Vec<UsageData>> {
let resets_at = entry.get("reset_time").and_then(extract_reset_time);
tiers.push(percent_tier(tier, used_percent, resets_at));
}
// 加力钱包排在四个窗口之后(钱包是余额,不是滚动窗口)。
if let Some(wallet) = body.get("boosterWallet").and_then(booster_wallet_tier) {
tiers.push(wallet);
}
if tiers.is_empty() { None } else { Some(tiers) }
}
/// 解析加力钱包(boosterWallet)为金额行。字段单位为「分」,统一换算成元/美元。
///
/// 只在响应含新版 `*Cents` 字段时产出条目:kimi-code #3787 之前的
/// `boosterWallet` 只有 `balance`(无币种/精度语义),产出全 0 的伪行只会误导。
fn booster_wallet_tier(wallet: &serde_json::Value) -> Option<UsageData> {
let cents = |key: &str| wallet.get(key).and_then(parse_f64);
let (balance, total, used) = (
cents("balanceCents"),
cents("totalCents"),
cents("monthlyUsedCents"),
);
if balance.is_none() && total.is_none() && used.is_none() {
return None;
}
// 币种缺失时不猜(前端 formatAmount 会退化为纯数字展示)。
let unit = wallet
.get("currency")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Some(UsageData {
plan_name: Some(PLAN_BOOSTER_WALLET.to_string()),
remaining: balance.map(|c| c / 100.0),
total: total.map(|c| c / 100.0),
used: used.map(|c| c / 100.0),
unit,
is_valid: Some(true),
// 钱包没有窗口重置时间。
resets_at: None,
})
}
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);
@@ -510,6 +556,7 @@ mod tests {
"boosterWallet": { "balance": 0 }
});
let tiers = parse_kimi_coding(&body);
// 旧版 boosterWallet(只有 balance,无 *Cents)不产出钱包行。
assert_eq!(tiers.len(), 4);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(60.0));
@@ -529,6 +576,38 @@ mod tests {
assert_eq!(tiers[3].remaining, Some(12.0));
}
#[test]
fn kimi_coding_quota_appends_booster_wallet_row() {
// 加力钱包是金额行(分 → 元/美元),追加在四个窗口之后。
let body = json!({
"usages": {
"limit_5h": { "used_ratio": 0.6 },
"limit_7d": { "used_ratio": 0.25 },
"limit_month_total": { "used_ratio": 0.1 },
"limit_month_code": { "used_ratio": 0.88 }
},
"boosterWallet": {
"balanceCents": 1234,
"totalCents": 5000,
"monthlyChargeLimitEnabled": true,
"monthlyChargeLimitCents": 2000,
"monthlyUsedCents": 800,
"currency": "USD"
}
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 5);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
let wallet = &tiers[4];
assert_eq!(wallet.plan_name.as_deref(), Some("booster_wallet"));
assert_eq!(wallet.used, Some(8.0));
assert_eq!(wallet.total, Some(50.0));
assert_eq!(wallet.remaining, Some(12.34));
assert_eq!(wallet.unit.as_deref(), Some("USD"));
assert_eq!(wallet.is_valid, Some(true));
assert!(wallet.resets_at.is_none());
}
#[test]
fn kimi_coding_quota_ratio_above_one_is_percent() {
// 防御:>1 的 used_ratio 视为已是百分数,不再乘 100。
+2
View File
@@ -10,6 +10,7 @@
pub mod balance;
pub mod coding_plan;
pub mod probe;
pub mod usage_types;
pub use usage_types::{UsageData, UsageResult};
@@ -336,6 +337,7 @@ mod tests {
user_id: uid.map(str::to_string),
auto_query_interval_minutes: None,
timeout_seconds: None,
threshold: None,
};
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None);
+155
View File
@@ -0,0 +1,155 @@
//! 供应商推理端点探活(「一键体检」的第二步)。
//!
//! GET `{base_url}/v1/models`,带 `Authorization: Bearer <api_key>`。
//! 2xx 且响应体是合法 JSON 即算通过(不校验内容结构,只验证端点能应答);
//! 其余状态码 / 网络错误归类成确定性错误文案。
//!
//! 错误文案与 balance.rs 一致用英文短句,前端 localizeHealthError 负责
//! 双语展示(见 src/hooks/useHealthCheck.ts)。
use std::time::Duration;
/// 探活请求超时。体检要快速出结果、不卡住 UI,比账单查询的 8s 更短。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// 由 base_url 拼出 `/v1/models` 探测地址。
///
/// 供应商配置里的 base_url 多数已经带 `/v1` 后缀(如
/// `https://api.kimi.com/coding/v1`),直接再拼 `/v1/models` 会得到
/// `/v1/v1/models`,所以先剥掉重复后缀;结尾斜杠与首尾空白一并归一。
/// 空/纯空白返回 None(调用方直接判失败,不发请求)。纯函数,便于单测。
fn models_url(base_url: &str) -> Option<String> {
let base = base_url.trim().trim_end_matches('/');
if base.is_empty() {
return None;
}
let root = base.strip_suffix("/v1").unwrap_or(base);
Some(format!("{root}/v1/models"))
}
/// 探活前置校验:返回最终请求 URL。base_url 缺失、api_key 为空都是确定性
/// 配置错误,不发请求即失败(没有 key 时必然 401,直接给出更可操作的结论)。
fn validate_probe_input(base_url: &str, api_key: &str) -> Result<String, String> {
let Some(url) = models_url(base_url) else {
return Err("missing base_url".to_string());
};
if api_key.trim().is_empty() {
return Err("no API key configured".to_string());
}
Ok(url)
}
/// 探活一个供应商的推理端点。`Ok(())` = 端点正常应答且返回合法 JSON。
pub async fn probe_provider(
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<(), String> {
let url = validate_probe_input(base_url, api_key)?;
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
let resp = client
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"request timed out".to_string()
} else if e.is_connect() {
"connection failed (DNS or refused)".to_string()
} else {
format!("network error: {e}")
}
})?;
let status = resp.status();
if status.is_success() {
// 先取完整响应体再解析:读体超时/中断是瞬时错误,与「返回的不是
// JSON」区分开(reqwest 的 .json() 会把两者都包成 decode)。
let raw = resp
.bytes()
.await
.map_err(|e| format!("failed to read response: {e}"))?;
return match serde_json::from_slice::<serde_json::Value>(&raw) {
Ok(_) => Ok(()),
Err(_) => Err("response is not valid JSON".to_string()),
};
}
match status.as_u16() {
401 | 403 => Err(format!("Authentication failed (HTTP {status})")),
404 => Err(format!("endpoint not found (HTTP {status})")),
code => Err(format!("API error (HTTP {code})")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn models_url_dedupes_v1_suffix() {
assert_eq!(
models_url("https://api.kimi.com/coding/v1").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
// 不带 /v1 的裸域补上 /v1
assert_eq!(
models_url("https://api.deepseek.com").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
}
#[test]
fn models_url_normalizes_slashes_and_whitespace() {
// 结尾斜杠(含多个)与首尾空白都要归一,且不得拼出 /v1/v1
assert_eq!(
models_url("https://api.kimi.com/coding/v1/").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
assert_eq!(
models_url(" https://api.deepseek.com/v1/// ").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
assert_eq!(
models_url("https://api.deepseek.com/").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
// 非 /v1 的路径后缀原样保留(只有 /v1 需要去重)
assert_eq!(
models_url("https://api.novita.ai/v3").as_deref(),
Some("https://api.novita.ai/v3/v1/models")
);
}
#[test]
fn models_url_rejects_empty_base() {
assert_eq!(models_url(""), None);
assert_eq!(models_url(" "), None);
assert_eq!(models_url("/"), None);
}
#[test]
fn probe_input_validation_rejects_empty_base_and_missing_key() {
// 两者都是确定性配置错误,不发请求即返回。
assert_eq!(
validate_probe_input(" ", "sk-x").unwrap_err(),
"missing base_url"
);
assert_eq!(
validate_probe_input("https://example.com", " ").unwrap_err(),
"no API key configured"
);
assert_eq!(
validate_probe_input("https://example.com/v1", "sk-x").unwrap(),
"https://example.com/v1/models"
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.19",
"version": "0.7.20",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",