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
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:
1 parent
35581103ed
commit
fcd08eae32
35 files changed
+2567
-91
No files matched your search
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.19"
|
||||
version = "0.7.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+18
-12
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.19",
|
||||
"version": "0.7.20",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user