From fcd08eae3202f46d0fd22b904dd9d9dacb1d2f31 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Sun, 20 Sep 2026 09:49:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=98=88=E5=80=BC=E9=A2=84=E8=AD=A6/?= =?UTF-8?q?=E5=8A=A0=E5=8A=9B=E9=92=B1=E5=8C=85/api=5Fkey=5Fenv/=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=B7=9F=E8=B8=AA=E5=99=A8/=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E4=BD=93=E6=A3=80=20=E2=80=94=20bump=20to=20v0.7.20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- release-notes-v0.7.20.md | 23 + src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 966 +++++++++++++++++++++++++- src-tauri/src/db.rs | 30 +- src-tauri/src/kimi_code_io.rs | 150 +++- src-tauri/src/lib.rs | 4 + src-tauri/src/models.rs | 13 + src-tauri/src/oauth.rs | 1 + src-tauri/src/pi_io.rs | 3 + src-tauri/src/services/coding_plan.rs | 85 ++- src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/probe.rs | 155 +++++ src-tauri/tauri.conf.json | 2 +- src/components/AgentSettingsPanel.tsx | 27 + src/components/ProviderEdit.tsx | 88 ++- src/components/ProviderList.tsx | 156 ++++- src/components/SettingsModal.tsx | 120 ++++ src/components/UsageConfigModal.tsx | 27 +- src/components/UsageFooter.tsx | 49 +- src/hooks/useHealthCheck.test.ts | 125 ++++ src/hooks/useHealthCheck.ts | 175 +++++ src/hooks/useKimiCodeVersion.test.ts | 28 + src/hooks/useKimiCodeVersion.ts | 101 +++ src/hooks/useUsageQuery.test.ts | 44 ++ src/hooks/useUsageQuery.ts | 31 +- src/i18n/en.ts | 49 ++ src/i18n/zh.ts | 47 ++ src/lib/agent-settings.test.ts | 98 ++- src/lib/agent-settings.ts | 17 + src/lib/usage-display.test.ts | 1 + src/lib/usage-display.ts | 9 + src/lib/validation.ts | 5 +- src/types/index.ts | 21 + 35 files changed, 2567 insertions(+), 91 deletions(-) create mode 100644 release-notes-v0.7.20.md create mode 100644 src-tauri/src/services/probe.rs create mode 100644 src/hooks/useHealthCheck.test.ts create mode 100644 src/hooks/useHealthCheck.ts create mode 100644 src/hooks/useKimiCodeVersion.test.ts create mode 100644 src/hooks/useKimiCodeVersion.ts create mode 100644 src/hooks/useUsageQuery.test.ts diff --git a/package.json b/package.json index cbbeb7a..e4a1cd6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.7.19", + "version": "0.7.20", "type": "module", "scripts": { "dev": "vite", diff --git a/release-notes-v0.7.20.md b/release-notes-v0.7.20.md new file mode 100644 index 0000000..af80d56 --- /dev/null +++ b/release-notes-v0.7.20.md @@ -0,0 +1,23 @@ +# KimiSwitch v0.7.20 + +## 新增 + +- **套餐用量阈值预警**:用量配置面板新增「预警阈值 %」(0-100,0 或空 = 关闭)。每次账单查询后,任一百分比用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除;自动查询间隔会周期性重复评估 +- **Kimi For Coding 加力钱包**:0.43.1 起服务端 quota 响应中的 `boosterWallet`(加力钱包)现在解析展示为一行余额(💰 月度用量 / 总额 / 余额,含币种),与四个用量窗口并列 +- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762——供应商编辑页「凭证来源」可切换「直接填写 / 环境变量」,密钥不写进 config.toml;导出时与 `api_key` 互斥;账单查询 / 获取模型列表会实时解析环境变量(变量未设置给出明确错误);SQLite 新增列(含自动迁移) +- **Kimi Code 版本跟踪器**:设置页新增「Kimi Code 版本」卡片——本机已装版本(`kimi --version` 检测)、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 KimiSwitch / 橙部分兼容 / 灰未安装),可一键打开上游 Releases +- **供应商一键体检**:供应商列表新增「一键体检」按钮——并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重新查询账单,每行状态点 + 延迟 tooltip + 顶部汇总。状态判定为三态:账单或探活任一真实通过 = 绿;鉴权失败必红;端点不支持模型列表(404)计中性不误报 + +## 设置 + +- **压缩重试次数**:Agent 设置新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5,0/空不写键) +- **文件监听开关**:新增 `[watch] enabled` 开关(上游 2.0.1 #3892,2.0.2 起上游默认关闭),并探测 `KIMI_CODE_WATCH` 环境变量 + +## 适配 + +- **kimi-code 2.0.2 已适配确认**:wire 记录格式、实验 flag(5 个)、config 模式均无变化;`api_key_env` / `[watch]` / `[database]` 新增配置节经现有合并式写入验证无损往返 + +## 备注 + +- 版本跟踪器的「最新版本」查询直连 GitHub,受限网络环境下可能显示 `—`(已装版本与状态判定不受影响) +- 体检跳过大模型平台类供应商(anthropic / google-genai / vertexai 无 `/v1/models` 路由),显示灰点「未检测」 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index baa1e0f..378a343 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.7.19" +version = "0.7.20" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a568dac..7065d61 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a2c39d1..6c9db94 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -266,7 +266,7 @@ pub fn get_app_version() -> String { /// Fetch available models from a provider's API. #[tauri::command] pub async fn list_provider_models(provider: Provider) -> Result, String> { - let api_key = resolve_api_key(&provider) + let api_key = resolve_api_key(&provider)? .ok_or_else(|| format!("Provider '{}' has no API key configured", provider.name))?; let base = resolve_base_url(&provider); @@ -333,21 +333,53 @@ pub async fn test_connectivity(provider: Provider) -> Result Option { +/// `api_key_env` (kimi-code 2.0.0+): resolve the referenced environment +/// variable into the key itself. `Ok(None)` = the provider has no env-var +/// reference configured; `Err` = a reference that this process cannot satisfy. +/// +/// The error is deterministic (re-exporting the variable is the only fix) and +/// names the provider plus the variable name — never a key value — so it is +/// safe to surface in the UI as-is. +fn resolve_api_key_env(provider: &Provider) -> Result, String> { + let Some(env_name) = provider + .api_key_env + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + return Ok(None); + }; + match std::env::var(env_name) { + Ok(value) if !value.trim().is_empty() => Ok(Some(value)), + _ => Err(format!( + "provider '{}' reads its API key from the environment variable \ + {env_name}, which is not set in this process", + provider.name + )), + } +} + +/// Static credential for a request: an `api_key_env` reference (resolved from +/// this process's environment) outranks a stored `api_key`, which outranks the +/// provider's `[providers..env]` entry for its type. +fn resolve_api_key(provider: &Provider) -> Result, String> { if provider.managed { - return Some("managed".to_string()); + return Ok(Some("managed".to_string())); + } + if let Some(key) = resolve_api_key_env(provider)? { + return Ok(Some(key)); } if let Some(key) = &provider.api_key { if !key.is_empty() { - return Some(key.clone()); + return Ok(Some(key.clone())); } } if let Some(env_key) = expected_api_key_key(&provider.provider_type) { if let Some(key) = provider.env.get(env_key).filter(|s| !s.is_empty()) { - return Some(key.clone()); + return Ok(Some(key.clone())); } } - None + Ok(None) } fn resolve_base_url(provider: &Provider) -> String { @@ -606,9 +638,37 @@ fn usage_cache() -> &'static UsageCache { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +/// Resolve the credential a usage query sends: an `api_key_env` reference +/// (kimi-code 2.0.0+, resolved from this process's environment) > a stored +/// `api_key` > the provider's `[providers..env]` entry for its type. +/// +/// Unlike `resolve_api_key` this does NOT special-case managed (OAuth) +/// providers — the caller falls back to the OAuth session token itself, so a +/// missing static key must stay `None` here rather than become a placeholder. +/// +/// `Err(msg)` is a deterministic failure: the provider is configured to read +/// its key from an environment variable this process does not have. +fn resolve_usage_api_key(provider: &Provider) -> Result, String> { + if let Some(key) = resolve_api_key_env(provider)? { + return Ok(Some(key)); + } + Ok(provider + .api_key + .clone() + .filter(|s| !s.trim().is_empty()) + .or_else(|| { + expected_api_key_key(&provider.provider_type) + .and_then(|env_key| provider.env.get(env_key)) + .cloned() + .filter(|s| !s.is_empty()) + })) +} + /// 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. +/// A provider configured with `api_key_env` resolves its key from this +/// process's environment (see `resolve_usage_api_key`). /// /// Error channel semantics (cc-switch): /// - `Err(_)` = transient failure (network/timeout/body read) → frontend @@ -656,16 +716,12 @@ pub async fn query_provider_usage( // error messages, or the cache key. Managed (OAuth-login) providers have // no static key; their credential comes from the Kimi Code OAuth session // file (refreshed on demand when the 15-min access token expires). - let mut api_key = provider - .api_key - .clone() - .filter(|s| !s.trim().is_empty()) - .or_else(|| { - expected_api_key_key(&provider.provider_type) - .and_then(|env_key| provider.env.get(env_key)) - .cloned() - .filter(|s| !s.is_empty()) - }); + let mut api_key = match resolve_usage_api_key(provider) { + Ok(key) => key, + // Deterministic: a missing `api_key_env` variable will not appear on + // retry, so report it instead of letting the frontend keep retrying. + Err(msg) => return Ok(UsageResult::failure(msg)), + }; let mut oauth_err: Option = None; if api_key.is_none() && provider.managed { // Resolve the credential slot + refresh host from the provider's oauth @@ -778,6 +834,512 @@ pub async fn query_provider_usage( } } +// --------------------------------------------------------------------------- +// 供应商一键体检(探活 + 账单/用量) +// --------------------------------------------------------------------------- + +/// 体检并发上限:同时最多 8 个供应商在途,避免瞬间打爆网络 / 触发对端限流。 +const HEALTH_CHECK_CONCURRENCY: usize = 8; + +/// 整个体检的总超时:超时后返回已完成的部分结果,绝不无限期挂住 UI。 +const HEALTH_CHECK_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); + +/// 单项检测(探活 / 账单)的三态结论。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthCheck { + /// 真实通过。 + Pass, + /// 真实失败。 + Fail, + /// 中性:无法得出结论,不计入失败。目前只有探活 404 —— 很多中转 / + /// 套餐端点只代理 chat/completions,没实现 /v1/models,这不能说明 + /// 供应商不可用。 + Neutral, +} + +/// 单项检测的结论 + 原始错误文案。错误只用于 tooltip 展示与鉴权判定, +/// 中性(404)的原因不会进 [`HealthResult::error`] 汇总。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckResult { + pub state: HealthCheck, + pub error: Option, +} + +/// 单个供应商的体检结果(IPC 侧 camelCase)。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthResult { + pub provider_name: String, + /// 总体判定:pass=绿 / fail=红 / neutral=灰(判定规则见 + /// [`overall_verdict`],Rust 侧是唯一事实来源)。 + pub verdict: HealthCheck, + /// `verdict == Pass` 的便捷布尔,供「x 正常 / y 失败」汇总计数。 + pub ok: bool, + /// 失败原因汇总(仅 fail 项,`; ` 分隔);无失败为 None。 + pub error: Option, + /// 账单/用量查询结论;该供应商未配置 usageKinds(或已关闭用量查询)时为 + /// None,表示未执行。 + pub usage: Option, + /// 推理端点探活结论;探活是必做项,恒为 Some。 + pub probe: Option, + /// 探活耗时(毫秒)。 + pub latency_ms: u64, +} + +/// 体检目标:启用、且探活方式适用的供应商。探活走 OpenAI 兼容的 +/// `GET /v1/models`,anthropic / google-genai / vertexai 类型的端点没有这个 +/// 路由,硬探只会得到假阴性,故跳过(前端按「未检测」展示)。 +fn is_health_target(provider: &Provider) -> bool { + provider.enabled && provider.provider_type.is_openai_compatible() +} + +/// 是否顺带查账单:配置了 usageKinds,且用户没有在配置面板关掉用量查询 +/// (关掉是明确意图,不该记成失败)。 +fn wants_usage_check(provider: &Provider) -> bool { + provider + .usage_kinds + .as_ref() + .is_some_and(|v| !v.is_empty()) + && provider + .usage_config + .as_ref() + .map(|c| c.enabled) + .unwrap_or(true) +} + +/// 探活 404 的判定前缀,与 services/probe.rs 的 `endpoint not found` 对应。 +fn is_not_found_error(error: &str) -> bool { + error.starts_with("endpoint not found") +} + +/// 剥掉账单错误里的 kind 前缀:commands.rs 的 kinds 循环把单条错误包成 +/// `balance:deepseek: msg` / `plan:zhipu: msg`(与前端 localizeUsageError +/// 同一套规则),剥掉后才能按前缀匹配。 +fn strip_kind_prefix(error: &str) -> &str { + for kind in ["balance:", "plan:"] { + if let Some(rest) = error.strip_prefix(kind) { + if let Some((_, msg)) = rest.split_once(": ") { + return msg; + } + } + } + error +} + +/// 账单查询的鉴权类失败(401/403)。key 错了必须红,不能被「探活通过」抵消; +/// 多 kind 时任一条鉴权失败即算(错误串用 `; ` 拼接)。 +fn is_auth_error(error: &str) -> bool { + error + .split("; ") + .any(|part| strip_kind_prefix(part).starts_with("Authentication failed")) +} + +/// 探活结果 → 三态。404(端点未实现模型列表)归中性,其余网络 / 状态码错误 +/// 都是真实失败。 +fn classify_probe(outcome: Result<(), String>) -> CheckResult { + match outcome { + Ok(()) => CheckResult { + state: HealthCheck::Pass, + error: None, + }, + Err(e) if is_not_found_error(&e) => CheckResult { + state: HealthCheck::Neutral, + error: Some(e), + }, + Err(e) => CheckResult { + state: HealthCheck::Fail, + error: Some(e), + }, + } +} + +/// 账单查询结果 → 三态。`Err(_)`(瞬时失败)与 `Ok(success:false)` +/// (确定性失败)都是真实失败;账单链路不产生中性结论。 +fn classify_usage(outcome: Result<(), String>) -> CheckResult { + match outcome { + Ok(()) => CheckResult { + state: HealthCheck::Pass, + error: None, + }, + Err(e) => CheckResult { + state: HealthCheck::Fail, + error: Some(e), + }, + } +} + +/// 综合判定,优先级从高到低(纯函数,便于单测): +/// 1. 账单鉴权失败(401/403)→ fail(key 错了必须红) +/// 2. 任一 check 真实通过 → pass(账单通或端点通都说明供应商可用) +/// 3. 有 check 失败且无一通过 → fail +/// 4. 其余(全部中性 / 未执行)→ neutral +fn overall_verdict(probe: &CheckResult, usage: Option<&CheckResult>) -> HealthCheck { + if usage.is_some_and(|u| { + u.state == HealthCheck::Fail && u.error.as_deref().is_some_and(is_auth_error) + }) { + return HealthCheck::Fail; + } + let any_pass = probe.state == HealthCheck::Pass + || usage.is_some_and(|u| u.state == HealthCheck::Pass); + if any_pass { + return HealthCheck::Pass; + } + let any_fail = probe.state == HealthCheck::Fail + || usage.is_some_and(|u| u.state == HealthCheck::Fail); + if any_fail { + return HealthCheck::Fail; + } + HealthCheck::Neutral +} + +/// 失败原因汇总(只收 fail 项,中性 / 通过的原始文案不进汇总),按 +/// 「探活 → 账单」顺序用 `; ` 拼接。纯函数,便于单测。 +fn fail_summary(probe: &CheckResult, usage: Option<&CheckResult>) -> Option { + let mut errors: Vec = Vec::new(); + for check in std::iter::once(probe).chain(usage) { + if check.state == HealthCheck::Fail { + if let Some(e) = &check.error { + errors.push(e.clone()); + } + } + } + if errors.is_empty() { + None + } else { + Some(errors.join("; ")) + } +} + +/// 体检用的凭据:复用 query_provider_usage 的解析顺序(api_key_env > +/// 存储的 api_key > 供应商 env 段);托管(OAuth)供应商没有静态 key, +/// 回退到 OAuth 会话里的 access token(与 query_provider_usage 一致)。 +async fn resolve_health_api_key(provider: &Provider) -> Result { + match resolve_usage_api_key(provider)? { + Some(key) => Ok(key), + None if provider.managed => { + let oauth_ref = crate::oauth::oauth_ref_from_provider(provider); + let base_url = provider.base_url.as_deref().unwrap_or(""); + crate::oauth::get_valid_access_token(oauth_ref.as_ref(), base_url).await + } + None => Err("no API key configured".to_string()), + } +} + +/// 单个供应商的体检:①推理端点探活(必做)②账单/用量(配了 usageKinds 时 +/// 顺带查一次)。信号量限流在函数内部获取,调用方可一次性把全部目标并发起来。 +async fn check_one_provider( + agent: Agent, + provider: Provider, + sem: &tokio::sync::Semaphore, +) -> HealthResult { + let name = provider.name.clone(); + // 信号量只在本轮体检内使用、永不 close,acquire 不会失败。 + let _permit = sem + .acquire() + .await + .expect("health-check semaphore is never closed"); + + let base_url = resolve_base_url(&provider); + let api_key = match resolve_health_api_key(&provider).await { + Ok(key) => key, + // 凭据缺失是确定性失败,探活无法进行(拿不到 key 必然 401)。 + Err(e) => { + let probe = CheckResult { + state: HealthCheck::Fail, + error: Some(e), + }; + let error = fail_summary(&probe, None); + return HealthResult { + provider_name: name, + verdict: HealthCheck::Fail, + ok: false, + error, + usage: None, + probe: Some(probe), + latency_ms: 0, + }; + } + }; + + let start = Instant::now(); + let probe = + services::probe::probe_provider(&base_url, &api_key, services::probe::PROBE_TIMEOUT).await; + let latency_ms = start.elapsed().as_millis() as u64; + let probe = classify_probe(probe); + + // 复用 query_provider_usage 的全部逻辑(缓存 / usage_config 模板 / + // OAuth / kinds 路由);forceRefresh=true 保证拿到实时值。 + // Err(_) = 瞬时失败,Ok(success:false) = 确定性失败,两者都算体检失败。 + let usage = if wants_usage_check(&provider) { + Some(classify_usage( + match query_provider_usage(agent, name.clone(), Some(true)).await { + Ok(result) if result.success => Ok(()), + Ok(result) => Err(result + .error + .unwrap_or_else(|| "usage query failed".to_string())), + Err(e) => Err(e), + }, + )) + } else { + None + }; + + let verdict = overall_verdict(&probe, usage.as_ref()); + HealthResult { + provider_name: name, + ok: verdict == HealthCheck::Pass, + verdict, + error: fail_summary(&probe, usage.as_ref()), + usage, + probe: Some(probe), + latency_ms, + } +} + +/// 一键体检:并行检测该 agent 下所有启用且探活适用的供应商。 +/// +/// 每个供应商跑「探活 + 账单查询」,结论汇总成 [`HealthResult`],前端按 +/// provider 名索引结果并渲染状态点。凭据在 Rust 侧解析,key 不过 IPC。 +#[tauri::command] +pub async fn health_check_all(agent: Agent) -> Result, String> { + use futures_util::stream::{FuturesUnordered, StreamExt}; + + let config = load_agent_config_command(agent)?; + let targets: Vec = config + .providers + .values() + .filter(|p| is_health_target(p)) + .cloned() + .collect(); + + let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(HEALTH_CHECK_CONCURRENCY)); + let mut pending = FuturesUnordered::new(); + for provider in targets { + pending.push(check_one_provider(agent, provider, &sem)); + } + + // 总超时兜底:到期返回已完成的部分结果(其余行前端按「未检测」展示), + // 单个供应商的耗时另有探活 5s + 账单查询自带超时的上界。 + let deadline = tokio::time::Instant::now() + HEALTH_CHECK_TOTAL_TIMEOUT; + let mut results = Vec::new(); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining, pending.next()).await { + Ok(Some(result)) => results.push(result), + // 全部完成(None),或总超时(Err)——都收工。 + Ok(None) | Err(_) => break, + } + } + Ok(results) +} + +#[cfg(test)] +mod health_check_tests { + use super::*; + use serde_json::Value; + + fn provider(provider_type: ProviderType, enabled: bool) -> Provider { + Provider { + name: "p".to_string(), + provider_type, + base_url: None, + api_key: None, + api_key_env: None, + env: IndexMap::new(), + note: None, + official_url: None, + managed: false, + enabled, + active: false, + icon: None, + icon_color: None, + raw_other: Value::Null, + usage_kinds: None, + usage_config: None, + } + } + + #[test] + fn health_target_requires_enabled_and_openai_compatible() { + assert!(is_health_target(&provider(ProviderType::Kimi, true))); + assert!(is_health_target(&provider(ProviderType::Openai, true))); + assert!(is_health_target(&provider( + ProviderType::OpenaiResponses, + true + ))); + // 停用的供应商不体检 + assert!(!is_health_target(&provider(ProviderType::Kimi, false))); + // 非 OpenAI 兼容类型没有 /v1/models 路由,硬探是假阴性 + assert!(!is_health_target(&provider(ProviderType::Anthropic, true))); + assert!(!is_health_target(&provider( + ProviderType::GoogleGenai, + true + ))); + assert!(!is_health_target(&provider(ProviderType::Vertexai, true))); + assert!(!is_health_target(&provider( + ProviderType::Unknown("mystery".to_string()), + true + ))); + } + + #[test] + fn usage_check_skips_empty_kinds_and_disabled_panel() { + let mut p = provider(ProviderType::Openai, true); + assert!(!wants_usage_check(&p)); + + p.usage_kinds = Some(vec![]); + assert!(!wants_usage_check(&p)); + + p.usage_kinds = Some(vec!["balance:deepseek".to_string()]); + assert!(wants_usage_check(&p)); + + // 面板里明确关掉用量查询 → 不查、也不记成失败 + p.usage_config = Some(UsageConfig { + enabled: false, + template_type: "auto".to_string(), + base_url: None, + access_token: None, + user_id: None, + auto_query_interval_minutes: None, + timeout_seconds: None, + threshold: None, + }); + assert!(!wants_usage_check(&p)); + } + + #[test] + fn probe_404_is_neutral_other_errors_fail() { + assert_eq!(classify_probe(Ok(())).state, HealthCheck::Pass); + // 端点未实现 /v1/models(zhipuai-coding-plan 等中转)→ 中性,不算失败 + assert_eq!( + classify_probe(Err("endpoint not found (HTTP 404)".to_string())).state, + HealthCheck::Neutral + ); + for err in [ + "request timed out", + "connection failed (DNS or refused)", + "Authentication failed (HTTP 401)", + "API error (HTTP 502)", + "response is not valid JSON", + "missing base_url", + "no API key configured", + ] { + assert_eq!( + classify_probe(Err(err.to_string())).state, + HealthCheck::Fail, + "err: {err}" + ); + } + } + + #[test] + fn usage_never_yields_neutral() { + assert_eq!(classify_usage(Ok(())).state, HealthCheck::Pass); + assert_eq!( + classify_usage(Err("no API key configured".to_string())).state, + HealthCheck::Fail + ); + } + + #[test] + fn auth_error_detection_survives_the_kind_prefix() { + assert!(is_auth_error("Authentication failed (HTTP 401)")); + assert!(is_auth_error("Authentication failed (HTTP 403)")); + // kinds 循环的 `{kind}: {msg}` 包装 + assert!(is_auth_error( + "balance:deepseek: Authentication failed (HTTP 401)" + )); + assert!(is_auth_error("plan:kimi_coding: Authentication failed (HTTP 401)")); + // 多 kind 拼接时任一条命中即算 + assert!(is_auth_error( + "balance:deepseek: API error (HTTP 500); plan:zhipu: Authentication failed (HTTP 401)" + )); + // 非鉴权失败不得误判 + assert!(!is_auth_error("API error (HTTP 500)")); + assert!(!is_auth_error("balance:deepseek: API error (HTTP 500)")); + assert!(!is_auth_error("usage query disabled in config panel")); + } + + #[test] + fn verdict_is_green_when_either_channel_passes() { + let pass = classify_probe(Ok(())); + let neutral = classify_probe(Err("endpoint not found (HTTP 404)".to_string())); + let fail = classify_probe(Err("API error (HTTP 502)".to_string())); + let usage_pass = classify_usage(Ok(())); + + // 账单通 + 探活 404(zhipuai-coding-plan 的真实场景)→ 绿 + assert_eq!(overall_verdict(&neutral, Some(&usage_pass)), HealthCheck::Pass); + // 账单通 + 探活失败 → 绿(账单通了说明供应商可用) + assert_eq!(overall_verdict(&fail, Some(&usage_pass)), HealthCheck::Pass); + // 探活通 + 账单失败(非鉴权)→ 绿 + let usage_fail = classify_usage(Err("API error (HTTP 500)".to_string())); + assert_eq!(overall_verdict(&pass, Some(&usage_fail)), HealthCheck::Pass); + // 只有探活且通过 → 绿 + assert_eq!(overall_verdict(&pass, None), HealthCheck::Pass); + } + + #[test] + fn verdict_is_gray_only_when_everything_is_neutral() { + let neutral = classify_probe(Err("endpoint not found (HTTP 404)".to_string())); + // 探活 404 且没配 usageKinds → 灰(既没通过也没失败) + assert_eq!(overall_verdict(&neutral, None), HealthCheck::Neutral); + assert!(!fail_summary(&neutral, None).is_some()); + } + + #[test] + fn verdict_is_red_when_everything_fails_or_the_key_is_bad() { + let neutral = classify_probe(Err("endpoint not found (HTTP 404)".to_string())); + let pass = classify_probe(Ok(())); + let fail = classify_probe(Err("request timed out".to_string())); + + // 探活失败且没有账单结果 → 红 + assert_eq!(overall_verdict(&fail, None), HealthCheck::Fail); + // 探活中性 + 账单失败 → 红 + let usage_fail = classify_usage(Err("API error (HTTP 500)".to_string())); + assert_eq!(overall_verdict(&neutral, Some(&usage_fail)), HealthCheck::Fail); + // 账单鉴权失败 → 红,即使探活通过(key 错了必须红) + let usage_auth = classify_usage(Err("Authentication failed (HTTP 401)".to_string())); + assert_eq!(overall_verdict(&pass, Some(&usage_auth)), HealthCheck::Fail); + let usage_auth_kinded = + classify_usage(Err("balance:deepseek: Authentication failed (HTTP 401)".to_string())); + assert_eq!( + overall_verdict(&neutral, Some(&usage_auth_kinded)), + HealthCheck::Fail + ); + } + + #[test] + fn fail_summary_lists_only_failures() { + let pass = classify_probe(Ok(())); + let neutral = classify_probe(Err("endpoint not found (HTTP 404)".to_string())); + let fail = classify_probe(Err("request timed out".to_string())); + + assert_eq!(fail_summary(&pass, None), None); + // 中性原因不进失败汇总 + assert_eq!(fail_summary(&neutral, None), None); + assert_eq!( + fail_summary(&fail, None), + Some("request timed out".to_string()) + ); + // 探活中性 + 账单失败 → 只汇总账单那条 + let usage_fail = classify_usage(Err("Authentication failed (HTTP 401)".to_string())); + assert_eq!( + fail_summary(&neutral, Some(&usage_fail)), + Some("Authentication failed (HTTP 401)".to_string()) + ); + // 两条都失败:按「探活 → 账单」顺序拼接 + assert_eq!( + fail_summary(&fail, Some(&usage_fail)), + Some("request timed out; Authentication failed (HTTP 401)".to_string()) + ); + } +} + // --------------------------------------------------------------------------- // Version check (lightweight: GET Gitea releases API, compare tag_name) // --------------------------------------------------------------------------- @@ -1237,6 +1799,268 @@ fn update_temp_filename() -> String { format!("KimiSwitch_update.{ext}") } +// --------------------------------------------------------------------------- +// Kimi Code CLI version tracking +// +// Settings shows three things: the `kimi` version installed on this machine, +// the latest upstream release, and whether this KimiSwitch build is known to +// work with that CLI version. The status mapping is a static baseline table — +// bump it whenever a CLI release changes something KimiSwitch depends on. +// --------------------------------------------------------------------------- + +const KIMI_CODE_LATEST_API: &str = + "https://api.github.com/repos/MoonshotAI/kimi-code/releases/latest"; + +/// Hard deadline for `kimi --version`. The CLI is a bundled runtime that needs +/// ~2.5–3.8s just to boot on Windows (measured), so keep generous headroom — +/// a process that overruns it is killed and reported as "not installed". +const KIMI_CODE_VERSION_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct KimiCodeVersionInfo { + /// Installed CLI version, `None` when the `kimi` command is unavailable. + pub installed: Option, + /// Latest upstream version, normalized to a bare `major.minor.patch`, + /// `None` when unknown. + pub latest: Option, + /// One of: current | outdated | partial | unknown | not_installed. + pub status: String, +} + +/// Extract the first `major.minor.patch` triple out of free-form text. Used on +/// both the CLI's `--version` output ("2.0.2", "kimi-code v0.43.1") and on +/// upstream release tags ("@moonshot-ai/kimi-code@2.0.2"); `None` when nothing +/// parseable shows up. +fn parse_kimi_version(output: &str) -> Option { + let re = regex::Regex::new(r"(\d+)\.(\d+)\.(\d+)").ok()?; + let caps = re.captures(output)?; + Some(format!("{}.{}.{}", &caps[1], &caps[2], &caps[3])) +} + +/// Parse a `major.minor.patch` triple. A leading `v` and any pre-release / +/// build suffix are ignored; missing minor/patch default to 0. Hand-rolled on +/// purpose — three integers do not justify a semver dependency. +fn semver_triple(version: &str) -> Option<(u32, u32, u32)> { + let core = version + .trim() + .trim_start_matches(|c: char| c == 'v' || c == 'V') + .split(|c: char| c == '-' || c == '+') + .next()?; + let mut parts = core.split('.'); + let major = parts.next()?.trim().parse::().ok()?; + let minor = parts.next().unwrap_or("0").trim().parse::().ok()?; + let patch = parts.next().unwrap_or("0").trim().parse::().ok()?; + Some((major, minor, patch)) +} + +/// Adaptation baseline against the kimi-code CLI: +/// >= 2.0.2 fully supported ("current") +/// >= 0.43.1 quota parsing moved upstream — upgrade KimiSwitch ("outdated") +/// >= 0.43.0 the auto_session_title flag is gone ("partial") +/// < 0.43.0 untested ("unknown") +fn kimi_code_status(installed: Option<&str>) -> &'static str { + let Some(version) = installed else { + return "not_installed"; + }; + match semver_triple(version) { + Some(t) if t >= (2, 0, 2) => "current", + Some(t) if t >= (0, 43, 1) => "outdated", + Some(t) if t >= (0, 43, 0) => "partial", + _ => "unknown", + } +} + +/// Wait for a child process with a hard deadline, polling `try_wait` so a hung +/// CLI cannot block the check forever. `None` on spawn failure, wait error or +/// timeout (the child is killed on timeout). +fn run_with_timeout( + mut cmd: std::process::Command, + timeout: Duration, +) -> Option { + let mut child = cmd.spawn().ok()?; + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => std::thread::sleep(Duration::from_millis(50)), + Err(_) => return None, + } + } + child.wait_with_output().ok() +} + +/// Run `kimi --version` and parse the version out of it. Windows goes through +/// `cmd /C` so npm-style `kimi.cmd` shims resolve as well; a missing binary, a +/// timeout and unparseable output all collapse to `None`. +fn detect_kimi_code_version() -> Option { + let mut cmd = if cfg!(windows) { + let mut c = std::process::Command::new("cmd"); + c.arg("/C").arg("kimi").arg("--version"); + c + } else { + let mut c = std::process::Command::new("kimi"); + c.arg("--version"); + c + }; + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW: no console window flashing next to the GUI. + cmd.creation_flags(0x08000000); + } + + let output = run_with_timeout(cmd, KIMI_CODE_VERSION_TIMEOUT)?; + // The version normally lands on stdout; some shims write it to stderr, so + // fall back to stderr only for a successful exit — a failed command's + // error text must not be scraped for digits. + if let Some(version) = parse_kimi_version(&String::from_utf8_lossy(&output.stdout)) { + return Some(version); + } + if !output.status.success() { + return None; + } + parse_kimi_version(&String::from_utf8_lossy(&output.stderr)) +} + +/// Locally installed kimi-code CLI version, `None` when the CLI is missing, +/// times out, or prints nothing parseable. Blocking work runs off the UI +/// thread so the 5s deadline cannot freeze the window. +#[tauri::command] +pub async fn get_kimi_code_version() -> Option { + tauri::async_runtime::spawn_blocking(detect_kimi_code_version) + .await + .unwrap_or(None) +} + +/// Latest upstream kimi-code release, normalized to a bare version by +/// `normalize_release_tag`. Mirrors `check_for_update`: GitHub requires a +/// User-Agent, the timeout keeps the check snappy, and network/parse failures +/// surface as `Err` so the UI can tell "unknown" apart from "no release". +#[tauri::command] +pub async fn get_kimi_code_latest() -> Result, String> { + let client = reqwest::Client::builder() + .user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(8)) + .build() + .map_err(|e| format!("client build failed: {e}"))?; + + let release: serde_json::Value = client + .get(KIMI_CODE_LATEST_API) + .send() + .await + .map_err(|e| format!("request failed: {e}"))? + .json() + .await + .map_err(|e| format!("parse failed: {e}"))?; + + Ok(release + .get("tag_name") + .and_then(|v| v.as_str()) + .map(normalize_release_tag) + .filter(|tag| !tag.is_empty())) +} + +/// Reduce a release tag to a bare version. Upstream does not use plain +/// `v1.2.3` tags — they look like `@moonshot-ai/kimi-code@2.0.2` — so pull the +/// first `major.minor.patch` triple out of whatever shape shows up; only when +/// that fails fall back to the tag minus a leading `v`. +fn normalize_release_tag(tag: &str) -> String { + if let Some(version) = parse_kimi_version(tag) { + return version; + } + tag.trim() + .trim_start_matches(|c: char| c == 'v' || c == 'V') + .to_string() +} + +/// Installed version + upstream latest + adaptation status, in one round trip +/// for the Settings card. A failing upstream lookup degrades to +/// `latest: None` instead of an error so the local half still renders. +#[tauri::command] +pub async fn get_kimi_code_version_info() -> KimiCodeVersionInfo { + let installed = get_kimi_code_version().await; + let latest = get_kimi_code_latest().await.unwrap_or(None); + KimiCodeVersionInfo { + status: kimi_code_status(installed.as_deref()).to_string(), + installed, + latest, + } +} + +#[cfg(test)] +mod kimi_code_version_tests { + use super::*; + + #[test] + fn parses_bare_and_decorated_version_output() { + assert_eq!(parse_kimi_version("2.0.2"), Some("2.0.2".to_string())); + assert_eq!(parse_kimi_version("0.43.1\n"), Some("0.43.1".to_string())); + assert_eq!( + parse_kimi_version("kimi-code v1.2.3"), + Some("1.2.3".to_string()) + ); + assert_eq!( + parse_kimi_version("kimi-code 0.43.1 (build abc123)"), + Some("0.43.1".to_string()) + ); + // A not-found error or empty output must not yield a version. + assert_eq!(parse_kimi_version("'kimi' is not recognized"), None); + assert_eq!(parse_kimi_version(""), None); + } + + #[test] + fn semver_triple_handles_prefix_and_suffix() { + assert_eq!(semver_triple("v2.0.2"), Some((2, 0, 2))); + assert_eq!(semver_triple("0.43.1-beta.2"), Some((0, 43, 1))); + assert_eq!(semver_triple("2.0"), Some((2, 0, 0))); + assert_eq!(semver_triple(" 1.2.3 "), Some((1, 2, 3))); + assert_eq!(semver_triple("nope"), None); + assert_eq!(semver_triple(""), None); + } + + #[test] + fn normalizes_the_upstream_tag_shapes() { + // Upstream ships scoped npm-style tags, not plain `vX.Y.Z`. + assert_eq!( + normalize_release_tag("@moonshot-ai/kimi-code@2.0.2"), + "2.0.2".to_string() + ); + assert_eq!(normalize_release_tag("v0.43.1"), "0.43.1".to_string()); + assert_eq!(normalize_release_tag("2.0.2"), "2.0.2".to_string()); + // Unparseable tag falls back to the raw tag minus a leading `v`. + assert_eq!(normalize_release_tag("nightly"), "nightly".to_string()); + assert_eq!(normalize_release_tag(""), String::new()); + } + + #[test] + fn status_follows_the_adaptation_baseline() { + assert_eq!(kimi_code_status(None), "not_installed"); + assert_eq!(kimi_code_status(Some("2.0.2")), "current"); + assert_eq!(kimi_code_status(Some("2.1.0")), "current"); + assert_eq!(kimi_code_status(Some("0.43.1")), "outdated"); + // 2.0.1 predates the fully-supported baseline. + assert_eq!(kimi_code_status(Some("2.0.1")), "outdated"); + assert_eq!(kimi_code_status(Some("0.43.0")), "partial"); + assert_eq!(kimi_code_status(Some("0.42.9")), "unknown"); + assert_eq!(kimi_code_status(Some("garbage")), "unknown"); + } + + #[test] + fn missing_binary_yields_none_instead_of_hanging() { + let cmd = std::process::Command::new("kimi-code-not-installed-xyz"); + assert!(run_with_timeout(cmd, Duration::from_secs(2)).is_none()); + } +} + #[cfg(test)] mod asset_picker_tests { use super::*; @@ -1355,6 +2179,12 @@ pub async fn kimi_oauth_poll( /// `search_worker` were promoted or removed earlier and are no longer /// probed here. /// +/// Non-flag probes: `KIMI_CODE_LEGACY_FLAG` (v1 engine switch) and +/// `KIMI_CODE_WATCH` (kimi-code 2.0.1+ `[watch] enabled` override) are not +/// experimental flags — they gate a config section instead — but they are +/// returned through the same map so the frontend can show the config value +/// that is actually in effect. +/// /// The flag id → env var mapping mirrors `EXPERIMENTAL_FLAGS` in /// src/lib/subagent-settings.ts; keep both lists in sync. /// @@ -1362,7 +2192,7 @@ pub async fn kimi_oauth_poll( /// the raw values are returned and the truthy check happens on the frontend. #[tauri::command] pub fn get_experimental_env_status() -> HashMap { - const VARS: [(&str, &str); 7] = [ + const VARS: [(&str, &str); 8] = [ ("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"), ("tower", "KIMI_CODE_EXPERIMENTAL_TOWER"), ("subagent_fork", "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK"), @@ -1371,6 +2201,9 @@ pub fn get_experimental_env_status() -> HashMap { // Non-flag probes consumed by the frontend: ("master", "KIMI_CODE_EXPERIMENTAL_FLAG"), ("legacy", "KIMI_CODE_LEGACY_FLAG"), + // Not an experimental flag either: the `[watch] enabled` override + // (kimi-code 2.0.1+). Consumed by AgentSettingsPanel's watch toggle. + ("watch", "KIMI_CODE_WATCH"), ]; let mut out = HashMap::new(); for (_, name) in VARS { @@ -1382,3 +2215,102 @@ pub fn get_experimental_env_status() -> HashMap { } out } + +#[cfg(test)] +mod usage_key_tests { + use super::*; + use serde_json::Value; + + fn provider( + api_key: Option<&str>, + api_key_env: Option<&str>, + env: IndexMap, + ) -> Provider { + Provider { + name: "p".to_string(), + provider_type: ProviderType::Openai, + base_url: None, + api_key: api_key.map(String::from), + api_key_env: api_key_env.map(String::from), + env, + note: None, + official_url: None, + managed: false, + enabled: true, + active: false, + icon: None, + icon_color: None, + raw_other: Value::Null, + usage_kinds: None, + usage_config: None, + } + } + + #[test] + fn api_key_env_resolves_from_the_process_environment() { + std::env::set_var("KIMI_SWITCH_TEST_KEY_FROM_ENV", "sk-from-env"); + let p = provider(None, Some("KIMI_SWITCH_TEST_KEY_FROM_ENV"), IndexMap::new()); + assert_eq!( + resolve_usage_api_key(&p).unwrap().as_deref(), + Some("sk-from-env") + ); + } + + #[test] + fn api_key_env_outranks_a_stored_api_key() { + std::env::set_var("KIMI_SWITCH_TEST_KEY_WINS", "sk-from-env"); + let p = provider( + Some("sk-stored"), + Some("KIMI_SWITCH_TEST_KEY_WINS"), + IndexMap::new(), + ); + assert_eq!( + resolve_usage_api_key(&p).unwrap().as_deref(), + Some("sk-from-env") + ); + } + + #[test] + fn unset_api_key_env_fails_naming_provider_and_variable() { + let mut p = provider(None, Some("KIMI_SWITCH_TEST_KEY_MISSING"), IndexMap::new()); + p.name = "my-proxy".to_string(); + let err = resolve_usage_api_key(&p).unwrap_err(); + assert!(err.contains("my-proxy"), "{err}"); + assert!(err.contains("KIMI_SWITCH_TEST_KEY_MISSING"), "{err}"); + } + + #[test] + fn blank_api_key_env_value_fails_and_blank_field_falls_through() { + std::env::set_var("KIMI_SWITCH_TEST_KEY_BLANK", " "); + let p = provider(None, Some("KIMI_SWITCH_TEST_KEY_BLANK"), IndexMap::new()); + assert!(resolve_usage_api_key(&p).is_err()); + + // An empty field (env mode selected, name not typed yet) is not a + // reference at all — the stored key is used. + let p = provider(Some("sk-stored"), Some(""), IndexMap::new()); + assert_eq!( + resolve_usage_api_key(&p).unwrap().as_deref(), + Some("sk-stored") + ); + } + + #[test] + fn falls_back_to_stored_key_then_provider_env_section() { + let p = provider(Some("sk-stored"), None, IndexMap::new()); + assert_eq!( + resolve_usage_api_key(&p).unwrap().as_deref(), + Some("sk-stored") + ); + + let mut env = IndexMap::new(); + env.insert("OPENAI_API_KEY".to_string(), "sk-env-section".to_string()); + let p = provider(None, None, env); + assert_eq!( + resolve_usage_api_key(&p).unwrap().as_deref(), + Some("sk-env-section") + ); + + let p = provider(None, None, IndexMap::new()); + assert_eq!(resolve_usage_api_key(&p).unwrap(), None); + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 4ccaca8..875e111 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -81,6 +81,7 @@ pub fn init_db() -> DbResult { 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 { 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 { 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 = row.get(4)?; - let raw_json: Option = row.get(12)?; + let env_json: Option = row.get(5)?; + let raw_json: Option = 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, diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs index 6a8e7ef..4b4f0c4 100644 --- a/src-tauri/src/kimi_code_io.rs +++ b/src-tauri/src/kimi_code_io.rs @@ -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")); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cae8669..a0f7926 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 694bdee..b13b9e3 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -107,6 +107,12 @@ pub struct Provider { pub provider_type: ProviderType, pub base_url: Option, pub api_key: Option, + /// 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, #[serde(default)] pub env: IndexMap, #[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", &"") + .field("api_key_env", &self.api_key_env) .field("env", &"") .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, + /// 用量预警阈值(百分比,0-100);None/0 = 关闭。判定在前端 + /// src/hooks/useUsageQuery.ts,Rust 只负责透传——缺了此字段,serde 会在 + /// 保存时把前端写下的 threshold 静默丢掉。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threshold: Option, } impl UsageConfig { diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs index 0e32a63..fde9736 100644 --- a/src-tauri/src/oauth.rs +++ b/src-tauri/src/oauth.rs @@ -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, diff --git a/src-tauri/src/pi_io.rs b/src-tauri/src/pi_io.rs index 4e0086e..663caf9 100644 --- a/src-tauri/src/pi_io.rs +++ b/src-tauri/src/pi_io.rs @@ -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, diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index d8e5aad..ae6446a 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -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) -> UsageData { @@ -75,7 +82,9 @@ fn parse_f64(value: &serde_json::Value) -> Option { // 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 { } /// 解析 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> { let usages = body.get("usages")?; let mut tiers = Vec::new(); @@ -163,9 +172,46 @@ fn parse_kimi_quota(body: &serde_json::Value) -> Option> { 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 { + 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。 diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index f1bc7a4..0b06744 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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); diff --git a/src-tauri/src/services/probe.rs b/src-tauri/src/services/probe.rs new file mode 100644 index 0000000..8b07c00 --- /dev/null +++ b/src-tauri/src/services/probe.rs @@ -0,0 +1,155 @@ +//! 供应商推理端点探活(「一键体检」的第二步)。 +//! +//! GET `{base_url}/v1/models`,带 `Authorization: Bearer `。 +//! 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 { + 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 { + 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::(&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" + ); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 852143b..67c17dc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.7.19", + "version": "0.7.20", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AgentSettingsPanel.tsx b/src/components/AgentSettingsPanel.tsx index 38911b0..7524c1a 100644 --- a/src/components/AgentSettingsPanel.tsx +++ b/src/components/AgentSettingsPanel.tsx @@ -78,6 +78,10 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro update({ background: { ...settings.background, ...patch } }); }; + const updateWatch = (patch: Partial) => { + update({ watch: { ...settings.watch, ...patch } }); + }; + const setRules = (rules: PermissionRule[]) => { update({ permission: { rules } }); }; @@ -141,6 +145,15 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro value={settings.loop_control?.reserved_context_size ?? 50000} onChange={(v) => updateLoopControl({ reserved_context_size: v })} /> + {/* kimi-code 0.43.0+ `loop_control.compaction_max_attempts`. The + upstream default is 5; an empty field or 0 removes the key instead + of writing a literal 0 (see setAgentSettings). */} + updateLoopControl({ compaction_max_attempts: v })} + /> +

{t("compactionMaxAttemptsHint")}

@@ -156,6 +169,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro /> + + {/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since + 2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env + var is probed by get_experimental_env_status as a non-flag entry + but deliberately locks nothing here, the toggle just documents the + config value that applies when the env var is unset. */} + updateWatch({ enabled: checked })} + /> +

{t("watchEnabledDesc")}

+
+ {/* kimi-code `[permission] dangerous_command_guard`. The env var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false") diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx index dc3b50e..495d2f6 100644 --- a/src/components/ProviderEdit.tsx +++ b/src/components/ProviderEdit.tsx @@ -11,6 +11,7 @@ import { AgentSettingsPanel } from "./AgentSettingsPanel"; import { KimiOAuthDialog } from "./KimiOAuthDialog"; import { ProviderIcon } from "./ProviderIcon"; import { IconPicker } from "./IconPicker"; +import { Segmented } from "./ui/controls"; import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types"; const KNOWN_CAPABILITIES = [ @@ -127,6 +128,19 @@ export function ProviderEdit({ // Preset this provider was created from (if any) — provides the // "Get API Key" / referral links shown under the key input. const preset = findPresetForProvider(provider); + /** + * Credential source. `api_key_env` (kimi-code 2.0.0+) names an environment + * variable instead of storing the key in config.toml; the two are mutually + * exclusive upstream, so the UI is a mode switch and each mode clears the + * other field. Derived from the provider itself (no local state) so the + * switch follows provider switches and reloads; an empty-but-present value + * ("env mode picked, name not typed yet") still reads as env mode. + * Only Kimi Code understands `api_key_env` — Pi's provider file has no + * equivalent, so the switch and the env input are both gated on the agent + * (a Pi provider that somehow carries the key keeps its plain key input). + */ + const apiKeyEnvSupported = agent === "kimi_code"; + const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null; useEffect(() => { const def = defaultBaseUrl(agent, provider.provider_type); @@ -322,27 +336,73 @@ export function ProviderEdit({ {!provider.managed && (
+ {apiKeyEnvSupported && ( +
+ + {t("apiKeySource")} + + { + // Mutual exclusion: picking a source clears the + // other one so export never writes both keys. + if (mode === "env") { + onChange({ + ...provider, + api_key: null, + api_key_env: provider.api_key_env ?? "", + }); + } else { + onChange({ ...provider, api_key_env: null }); + } + }} + /> +
+ )} -
+ {apiKeyEnvMode ? ( - onChange({ ...provider, api_key: e.target.value || null }) + onChange({ ...provider, api_key_env: e.target.value }) } /> - -
+ ) : ( +
+ + onChange({ ...provider, api_key: e.target.value || null }) + } + /> + +
+ )} + {apiKeyEnvMode && ( +

+ {t("apiKeyEnvHint")} +

+ )} {(() => { const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null; if (!linkUrl) return null; diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index e3fd821..8abb2ab 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -1,10 +1,26 @@ import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react"; +import { + Pencil, + Copy, + Activity, + Loader2, + Trash2, + BarChart3, + Stethoscope, +} from "lucide-react"; import { useTranslation } from "../i18n"; import { ProviderIcon } from "./ProviderIcon"; import { UsageFooter } from "./UsageFooter"; import { useUsageQuery } from "../hooks/useUsageQuery"; +import { + useHealthCheck, + localizeHealthError, + probeLine, + usageLine, + healthDotClass, + type HealthResult, +} from "../hooks/useHealthCheck"; import type { Agent, Model, Provider } from "../types"; interface ConnectivityResult { @@ -40,6 +56,12 @@ interface ProviderCardProps { models: Record; /** Inline connectivity-test state for this provider, or undefined when idle. */ ts: TestState | undefined; + /** 一键体检结论;undefined = 本轮未出结果(检测中或未检测)。 */ + health: HealthResult | undefined; + /** 体检请求进行中。 */ + healthRunning: boolean; + /** 体检至少跑过一轮(决定是否渲染灰色「未检测」点)。 */ + healthRan: boolean; onEdit: (name: string) => void; onDelete: (name: string) => void; onDuplicate: (name: string) => void; @@ -54,6 +76,9 @@ function ProviderCard({ defaultModel, models, ts, + health, + healthRunning, + healthRan, onEdit, onDelete, onDuplicate, @@ -70,7 +95,8 @@ function ProviderCard({ provider.name, provider.usageKinds, provider.usageConfig?.autoQueryIntervalMinutes, - provider.usageConfig?.enabled === false + provider.usageConfig?.enabled === false, + provider.usageConfig?.threshold ); const providerModels = Object.values(models).filter( (m) => m.provider === provider.name @@ -80,6 +106,17 @@ function ProviderCard({ : null; const isActive = provider.active === true; + // 一键体检状态点 tooltip:两项检测各自如实展示——探活可能是「不支持模型 + // 列表」(中性),账单可能是「失败: xxx」,都不掩盖。 + const healthTip = health + ? [ + health.probe ? probeLine(health.probe, health.latencyMs, t) : null, + health.usage ? usageLine(health.usage, t) : null, + ] + .filter((s): s is string => s !== null) + .join(" · ") + : ""; + return (
{provider.name} + {/* 一键体检状态点:绿=正常、红=失败、灰=未判定(未检测 / 检测中) */} + {health ? ( + + ) : healthRunning ? ( + + + + ) : healthRan ? ( + + ) : null} {isActive && ( {t("inUse")} @@ -273,6 +338,7 @@ export function ProviderList({ }: ProviderListProps) { const { t } = useTranslation(); const [testState, setTestState] = useState>({}); + const health = useHealthCheck(agent); // managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。 const sortedProviders = [...providers].sort( @@ -312,28 +378,69 @@ export function ProviderList({ return (
-
-

{t("providers")}

- + {health.running ? ( + + ) : ( + + )} + {t("healthCheck")} + + +
@@ -371,6 +478,9 @@ export function ProviderList({ defaultModel={defaultModel} models={models} ts={testState[provider.name]} + health={health.results[provider.name]} + healthRunning={health.running} + healthRan={health.ran} onEdit={onEdit} onDelete={onDelete} onDuplicate={onDuplicate} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 363bbef..2e8d833 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -5,12 +5,20 @@ import { listen } from "@tauri-apps/api/event"; import { useTranslation, type Language } from "../i18n"; import { useTheme, type Theme } from "../hooks/useTheme"; import type { UpdateInfo } from "../hooks/useUpdateCheck"; +import { + useKimiCodeVersion, + isNewerVersion, + type KimiCodeStatus, +} from "../hooks/useKimiCodeVersion"; function formatVersion(version: string): string { const v = version.trim(); return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`; } +/** 上游 kimi-code releases 页面。 */ +const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases"; + interface SettingsModalProps { open: boolean; onClose: () => void; @@ -30,6 +38,11 @@ export function SettingsModal({ }: SettingsModalProps) { const { t, lang, setLang } = useTranslation(); const { theme, setTheme } = useTheme(); + const { + info: kimiCodeInfo, + checking: kimiCodeChecking, + checkNow: checkKimiCode, + } = useKimiCodeVersion(); const [appVersion, setAppVersion] = useState(""); const [downloadProgress, setDownloadProgress] = useState(null); const [downloadedPath, setDownloadedPath] = useState(null); @@ -158,6 +171,47 @@ export function SettingsModal({ return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); }; + // 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装 + const kimiCodeBadge = (status: KimiCodeStatus) => { + switch (status) { + case "current": + return { + label: t("kimiCodeStatusCurrent"), + className: + "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30", + }; + case "outdated": + return { + label: t("kimiCodeStatusOutdated"), + className: + "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30", + }; + case "partial": + return { + label: t("kimiCodeStatusPartial"), + className: + "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30", + }; + case "unknown": + return { + label: t("kimiCodeStatusUnknown"), + className: "bg-hover-2 text-content-muted border-border", + }; + default: + return { + label: t("kimiCodeStatusNotInstalled"), + className: "bg-hover-2 text-content-muted border-border", + }; + } + }; + + const kimiCodeUpdateAvailable = + !!kimiCodeInfo?.installed && + !!kimiCodeInfo?.latest && + isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed); + + const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null; + return createPortal(
+ {/* Kimi Code CLI version & compatibility */} +
+

+ {t("kimiCodeSection")} +

+
+
+ {t("kimiCodeInstalled")} + + {kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"} + +
+
+ {t("kimiCodeLatest")} + + {kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"} + +
+
+ {t("kimiCodeStatusLabel")} + {kimiCodeBadgeInfo ? ( + + {kimiCodeBadgeInfo.label} + + ) : ( + — + )} +
+
+ + {kimiCodeUpdateAvailable && ( + + )} +
+
+
+ {/* About */}

diff --git a/src/components/UsageConfigModal.tsx b/src/components/UsageConfigModal.tsx index e3db32a..95d33d0 100644 --- a/src/components/UsageConfigModal.tsx +++ b/src/components/UsageConfigModal.tsx @@ -364,7 +364,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa )}

- {/* timeout + auto query interval */} + {/* timeout + auto query interval + alert threshold */}
@@ -394,6 +394,31 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa />

{t("usageAutoIntervalHint")}

+
+ + { + const raw = e.target.value.trim(); + // 清空输入框写回 undefined(关闭),而不是 0:两者判定等价, + // 但 undefined 会被 serde 的 skip_serializing_if 从 SQLite + // JSON 里省掉,不留冗余字段。 + if (raw === "") { + setCfg({ ...cfg, threshold: undefined }); + return; + } + const n = parseInt(raw, 10); + // 非数字(如 "1e" / "-")不落库,保留上一次的有效值。 + if (Number.isNaN(n)) return; + setCfg({ ...cfg, threshold: Math.min(100, Math.max(0, n)) }); + }} + /> +

{t("usageThresholdHint")}

+
)} diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index d2307cc..a4e5ba4 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -1,7 +1,13 @@ import { useEffect, useState } from "react"; import { Loader2, RefreshCw } from "lucide-react"; import { useTranslation } from "../i18n"; -import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display"; +import { + BOOSTER_WALLET_PLAN, + formatAmount, + localizeUsageError, + planLabel, +} from "../lib/usage-display"; +import { isAlertTier } from "../hooks/useUsageQuery"; import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery"; interface UsageFooterProps { @@ -14,6 +20,20 @@ interface UsageFooterProps { variant?: "detail" | "compact"; } +/** + * 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其 + * used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看 + * planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之 + * 多少」,按 unit 判定会把它误渲染成余额。 + */ +function isMoneyRow(d: UsageData): boolean { + return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN; +} + +function isPlanRow(d: UsageData): boolean { + return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null); +} + export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) { const { t } = useTranslation(); const { status, data, error, updatedAt, supported, refresh } = usage; @@ -85,10 +105,7 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) { // A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance // entries carry only remaining + unit — their plan_name is a currency / // brand label and must render as an amount, not a percentage. - const isPlan = - !!d.planName && - d.planName !== "NewAPI" && - (d.total != null || d.used != null); + const isPlan = isPlanRow(d); if (d.isValid === false) { return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" }; } @@ -207,13 +224,14 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) { {data.map((d, i) => { // Same plan-vs-balance rule as compactSummary: balance rows have no // quota fields (total/used), so they render as an amount. - const isPlan = - !!d.planName && - d.planName !== "NewAPI" && - (d.total != null || d.used != null); + const isPlan = isPlanRow(d); const pct = isPlan ? percentOf(d) : null; - const color = - pct == null + // 预警高亮:usage.alert 已按「查询成功」门控,失败时保留的上次数据 + // 不会被误标红(此处是唯一的高亮来源,随每次查询结果重算)。 + const rowAlert = usage.alert && isAlertTier(d, usage.threshold); + const color = rowAlert + ? "text-red-500 dark:text-red-400" + : pct == null ? "text-content-muted" : pct < 70 ? "text-green-600 dark:text-green-400" @@ -248,6 +266,15 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) { > {main} + {rowAlert && ( + + ⚠ + + )} {resetText && ( {resetText} )} diff --git a/src/hooks/useHealthCheck.test.ts b/src/hooks/useHealthCheck.test.ts new file mode 100644 index 0000000..3a83ef5 --- /dev/null +++ b/src/hooks/useHealthCheck.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + localizeHealthError, + probeLine, + usageLine, + healthDotClass, + type CheckResult, +} from "./useHealthCheck"; + +/** + * 只回显 key,便于断言选中的是哪条文案(真实插值由 i18n Provider 负责)。 + */ +const t = ((key: string) => `<${key}>`) as unknown as Parameters< + typeof localizeHealthError +>[1]; + +/** 带插值的假 t:``,用于断言参数透传。 */ +const tWithVars = ((key: string, vars?: Record) => + vars + ? `<${key}|${Object.entries(vars) + .map(([k, v]) => `${k}=${v}`) + .join(",")}>` + : `<${key}>`) as unknown as Parameters[1]; + +describe("localizeHealthError", () => { + it("探活错误按前缀映射到对应文案", () => { + expect(localizeHealthError("missing base_url", t)).toBe( + "" + ); + expect(localizeHealthError("no API key configured", t)).toBe( + "" + ); + expect(localizeHealthError("Authentication failed (HTTP 401)", t)).toBe( + "" + ); + expect(localizeHealthError("endpoint not found (HTTP 404)", t)).toBe( + "" + ); + expect(localizeHealthError("request timed out", t)).toBe( + "" + ); + expect( + localizeHealthError("connection failed (DNS or refused)", t) + ).toBe(""); + expect(localizeHealthError("response is not valid JSON", t)).toBe( + "" + ); + }); + + it("未知错误原样透出(HTTP 状态码等诊断细节)", () => { + expect(localizeHealthError("API error (HTTP 502)", t)).toBe( + "API error (HTTP 502)" + ); + }); + + it("探活 + 账单两段拼接时逐段映射", () => { + expect( + localizeHealthError( + "endpoint not found (HTTP 404); usage query disabled in config panel", + t + ) + ).toBe("; "); + }); + + it("账单链路的报错复用用量错误映射", () => { + // commands.rs 的 kinds 循环会把单条错误包成 `{kind}: {msg}`。 + expect( + localizeHealthError( + "Authentication failed (HTTP 401); balance:deepseek: Authentication failed (HTTP 401)", + t + ) + ).toBe("; "); + }); +}); + +describe("probeLine", () => { + it("通过时带延迟", () => { + expect(probeLine({ state: "pass" }, 123, tWithVars)).toBe( + "" + ); + }); + + it("中性(404)说明端点不支持模型列表,而不是报失败", () => { + expect( + probeLine( + { state: "neutral", error: "endpoint not found (HTTP 404)" }, + 0, + tWithVars + ) + ).toBe(""); + }); + + it("失败时透出本地化后的原因", () => { + expect( + probeLine({ state: "fail", error: "request timed out" }, 0, tWithVars) + ).toBe(">"); + }); +}); + +describe("usageLine", () => { + it("通过 / 失败 / 兜底三态", () => { + const pass: CheckResult = { state: "pass" }; + expect(usageLine(pass, tWithVars)).toBe(""); + + expect( + usageLine( + { state: "fail", error: "Authentication failed (HTTP 401)" }, + tWithVars + ) + ).toBe(">"); + + // 账单链路不会产生中性结论,兜底按「未判定」展示 + expect(usageLine({ state: "neutral" }, tWithVars)).toBe( + "" + ); + }); +}); + +describe("healthDotClass", () => { + it("绿=通过、红=失败、灰=未判定(中性)", () => { + expect(healthDotClass("pass")).toBe("bg-green-500"); + expect(healthDotClass("fail")).toBe("bg-red-500"); + expect(healthDotClass("neutral")).toBe("bg-gray-400 dark:bg-gray-500"); + }); +}); diff --git a/src/hooks/useHealthCheck.ts b/src/hooks/useHealthCheck.ts new file mode 100644 index 0000000..b3e1176 --- /dev/null +++ b/src/hooks/useHealthCheck.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import type { Agent } from "../types"; +import type { TranslationKey } from "../i18n/zh"; +import { localizeUsageError } from "../lib/usage-display"; + +type TranslateFn = ( + key: TranslationKey, + vars?: Record +) => string; + +/** 单项检测的三态结论(mirrors Rust `HealthCheck`)。 */ +export type HealthCheck = "pass" | "fail" | "neutral"; + +/** 单项检测结论(探活 / 账单各一份,mirrors Rust `CheckResult`)。 */ +export interface CheckResult { + state: HealthCheck; + /** 失败 / 中性原因的原始文案;通过为 null。 */ + error?: string | null; +} + +/** Mirrors the Rust `HealthResult` (camelCase via serde). */ +export interface HealthResult { + providerName: string; + /** 总体判定(Rust 侧是唯一事实来源):pass=绿 / fail=红 / neutral=灰。 */ + verdict: HealthCheck; + /** verdict === "pass" 的便捷布尔。 */ + ok: boolean; + /** 失败原因汇总(仅 fail 项,`; ` 分隔);无失败为 null。 */ + error?: string | null; + /** 账单/用量结论;null/undefined = 未执行。 */ + usage?: CheckResult | null; + /** 推理端点探活结论;探活是必做项,恒为 Some。 */ + probe?: CheckResult | null; + /** 探活耗时(毫秒)。 */ + latencyMs: number; +} + +/** + * 单条 Rust 错误文案 → 本地化文案。未知错误原样透出(HTTP 状态码等诊断 + * 细节)。前缀与 src-tauri/src/services/probe.rs、commands.rs 保持一致。 + */ +function localizeHealthPart(error: string, t: TranslateFn): string { + if (error.startsWith("missing base_url")) return t("healthErrNoBaseUrl"); + if (error.startsWith("no API key configured")) return t("healthErrNoKey"); + if (error.startsWith("Authentication failed")) return t("healthErrAuth"); + if (error.startsWith("endpoint not found")) return t("healthErrNotFound"); + if (error.startsWith("request timed out")) return t("healthErrTimeout"); + if (error.startsWith("connection failed")) return t("healthErrUnreachable"); + if (error.startsWith("response is not valid JSON")) return t("healthErrBadJson"); + // 账单链路的报错复用已有的用量错误映射(禁用 / OAuth 过期 / NewAPI 凭据…)。 + return localizeUsageError(error, t); +} + +/** + * 体检结论里的错误文案本地化。探活与账单两条链路的错误由 Rust 用 `; ` + * 拼进同一个字段,逐段映射后再拼回,保证两段都能翻译。 + */ +export function localizeHealthError(error: string, t: TranslateFn): string { + return error + .split("; ") + .map((part) => localizeHealthPart(part, t)) + .join("; "); +} + +/** 探活一项的 tooltip 文案。 */ +export function probeLine( + probe: CheckResult, + latencyMs: number, + t: TranslateFn +): string { + if (probe.state === "pass") return t("healthProbeOk", { latency: latencyMs }); + // 中性 = 端点未实现 /v1/models(404),如实说明「不支持」而不是报失败。 + if (probe.state === "neutral") return t("healthProbeUnsupported"); + return t("healthProbeFail", { + error: probe.error ? localizeHealthError(probe.error, t) : "", + }); +} + +/** 账单一项的 tooltip 文案。 */ +export function usageLine(usage: CheckResult, t: TranslateFn): string { + if (usage.state === "pass") return t("healthUsageOk"); + if (usage.state === "fail") { + return t("healthUsageFail", { + error: usage.error ? localizeHealthError(usage.error, t) : "", + }); + } + // 账单链路不产生中性结论(只有探活 404 会),兜底按「未判定」展示。 + return t("healthNotChecked"); +} + +/** + * 状态点颜色:绿=正常、红=失败、灰=未判定(未检测 / 检测中 / 全部中性)。 + * 判定本身由 Rust 的 overall_verdict 给出,这里只做映射。 + */ +export function healthDotClass(verdict: HealthCheck): string { + switch (verdict) { + case "pass": + return "bg-green-500"; + case "fail": + return "bg-red-500"; + default: + return "bg-gray-400 dark:bg-gray-500"; + } +} + +export interface HealthCheckState { + /** 体检进行中:按钮转圈、尚未出结果的行显示灰色检测中指示。 */ + running: boolean; + /** 至少跑过一轮(决定是否渲染状态点)。 */ + ran: boolean; + /** provider name → 结果。 */ + results: Record; + okCount: number; + failCount: number; + /** 整体请求失败(配置读取失败 / 总超时)的文案;null = 无错误。 */ + error: string | null; + run: () => void; +} + +/** + * 一键体检:invoke `health_check_all`,把结果按 provider 名索引。 + * 结果保留到下次点击或组件卸载(页面刷新),不在后台轮询。 + */ +export function useHealthCheck(agent: Agent): HealthCheckState { + const [running, setRunning] = useState(false); + const [ran, setRan] = useState(false); + const [results, setResults] = useState>({}); + const [error, setError] = useState(null); + // Generation counter: 重跑/卸载后到达的旧响应直接丢弃,不写回状态。 + const genRef = useRef(0); + + useEffect( + () => () => { + genRef.current += 1; + }, + [] + ); + + const run = useCallback(async () => { + const gen = ++genRef.current; + setRunning(true); + setError(null); + // 清空上一轮结果:检测中的行显示灰色转圈,而不是留着旧结论误导。 + setResults({}); + try { + const list = await invoke("health_check_all", { agent }); + if (genRef.current !== gen) return; + const map: Record = {}; + for (const r of list) map[r.providerName] = r; + setResults(map); + } catch (e) { + if (genRef.current !== gen) return; + // invoke 拒绝时 Rust 侧 Err(String) 是字符串,不是 Error 实例。 + setError(typeof e === "string" ? e : e instanceof Error ? e.message : String(e)); + } finally { + if (genRef.current === gen) { + setRunning(false); + setRan(true); + } + } + }, [agent]); + + const values = Object.values(results); + return { + running, + ran, + results, + // 中性(灰)既不算正常也不算失败,不计入汇总。 + okCount: values.filter((r) => r.verdict === "pass").length, + failCount: values.filter((r) => r.verdict === "fail").length, + error, + run: () => void run(), + }; +} diff --git a/src/hooks/useKimiCodeVersion.test.ts b/src/hooks/useKimiCodeVersion.test.ts new file mode 100644 index 0000000..7ab9e43 --- /dev/null +++ b/src/hooks/useKimiCodeVersion.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isNewerVersion } from "./useKimiCodeVersion"; + +describe("isNewerVersion", () => { + it("同版本不算有更新", () => { + expect(isNewerVersion("2.0.2", "2.0.2")).toBe(false); + expect(isNewerVersion("v2.0.2", "2.0.2")).toBe(false); + }); + + it("按 major/minor/patch 逐段比较", () => { + expect(isNewerVersion("2.0.2", "2.0.1")).toBe(true); + expect(isNewerVersion("2.1.0", "2.0.9")).toBe(true); + expect(isNewerVersion("3.0.0", "2.99.99")).toBe(true); + expect(isNewerVersion("2.0.1", "2.0.2")).toBe(false); + expect(isNewerVersion("1.9.9", "2.0.0")).toBe(false); + }); + + it("忽略 v 前缀与预发布后缀", () => { + expect(isNewerVersion("v0.44.0-beta.1", "0.43.1")).toBe(true); + expect(isNewerVersion("0.43.1", "0.43.1-rc.2")).toBe(false); + }); + + it("缺位补 0,无法解析时不显示更新", () => { + expect(isNewerVersion("2.1", "2.0.9")).toBe(true); + expect(isNewerVersion("abc", "2.0.1")).toBe(false); + expect(isNewerVersion("2.0.2", "abc")).toBe(false); + }); +}); diff --git a/src/hooks/useKimiCodeVersion.ts b/src/hooks/useKimiCodeVersion.ts new file mode 100644 index 0000000..39a254e --- /dev/null +++ b/src/hooks/useKimiCodeVersion.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +/** kimi-code 与本应用的适配状态(Rust 侧基线表决定)。 */ +export type KimiCodeStatus = + | "current" + | "outdated" + | "partial" + | "unknown" + | "not_installed"; + +export interface KimiCodeVersionInfo { + /** 本机安装的 kimi-code 版本;未安装 / 检测失败为 null。 */ + installed: string | null; + /** 上游最新 release tag(已去掉 v 前缀);查询失败为 null。 */ + latest: string | null; + status: KimiCodeStatus; +} + +const THROTTLE_KEY = "kimi-switch-last-kc-version-check"; +// 与 useUpdateCheck 一致:节流窗口与定时复查同为 8h,长时间挂着的窗口也能 +// 自动拿到新版本,同时避免短时间反复触发后端调用。 +const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours + +/** 解析 major.minor.patch 三元组;缺位补 0,无法解析返回 null。 */ +function versionTriple(version: string): [number, number, number] | null { + const core = version.trim().replace(/^[vV]/, "").split(/[-+]/)[0]; + const nums = core.split(".").slice(0, 3).map((p) => Number.parseInt(p.trim(), 10)); + if (nums.length === 0 || nums.some((n) => !Number.isFinite(n) || n < 0)) return null; + while (nums.length < 3) nums.push(0); + return [nums[0], nums[1], nums[2]]; +} + +/** latest 是否严格高于 installed(用于决定是否显示 releases 链接)。 */ +export function isNewerVersion(latest: string, installed: string): boolean { + const l = versionTriple(latest); + const i = versionTriple(installed); + if (!l || !i) return false; + for (let k = 0; k < 3; k += 1) { + if (l[k] !== i[k]) return l[k] > i[k]; + } + return false; +} + +/** + * 读取「本机 kimi-code 版本 / 上游最新版 / 适配状态」,模式与 useUpdateCheck + * 相同:挂载时按 8h 节流自动查一次,之后定时复查,checkNow 强制刷新。 + */ +export function useKimiCodeVersion() { + const [info, setInfo] = useState(null); + const [checking, setChecking] = useState(false); + const [lastChecked, setLastChecked] = useState(() => { + try { + const v = localStorage.getItem(THROTTLE_KEY); + return v ? Number(v) : null; + } catch { + return null; + } + }); + + const doCheck = useCallback( + async (force: boolean) => { + if (!force) { + if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return; + } + setChecking(true); + try { + const next = await invoke("get_kimi_code_version_info"); + setInfo(next); + const now = Date.now(); + setLastChecked(now); + try { + localStorage.setItem(THROTTLE_KEY, String(now)); + } catch { + // ignore + } + } catch { + // 静默失败:检测不到 kimi / 网络不通都不该打扰用户,手动检查仍可用。 + } finally { + setChecking(false); + } + }, + [lastChecked] + ); + + // 定时器不随 lastChecked 重建,所以用 ref 持有最新的 doCheck。 + const doCheckRef = useRef(doCheck); + useEffect(() => { + doCheckRef.current = doCheck; + }, [doCheck]); + + useEffect(() => { + doCheckRef.current(false); + const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS); + return () => clearInterval(interval); + }, []); + + const checkNow = useCallback(() => doCheck(true), [doCheck]); + + return { info, checking, checkNow, lastChecked }; +} diff --git a/src/hooks/useUsageQuery.test.ts b/src/hooks/useUsageQuery.test.ts new file mode 100644 index 0000000..12e8be2 --- /dev/null +++ b/src/hooks/useUsageQuery.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { isAlertTier, type UsageData } from "./useUsageQuery"; + +/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */ +function percentTier(used: number): UsageData { + return { planName: "five_hour", used, total: 100, remaining: 100 - used, unit: "%" }; +} + +describe("isAlertTier", () => { + it("阈值缺省或 0(关闭)时不告警", () => { + expect(isAlertTier(percentTier(100), undefined)).toBe(false); + expect(isAlertTier(percentTier(100), null)).toBe(false); + expect(isAlertTier(percentTier(100), 0)).toBe(false); + }); + + it("低于阈值不告警", () => { + expect(isAlertTier(percentTier(79), 80)).toBe(false); + expect(isAlertTier(percentTier(79.9), 80)).toBe(false); + }); + + it("等于或超过阈值告警", () => { + expect(isAlertTier(percentTier(80), 80)).toBe(true); + expect(isAlertTier(percentTier(80.5), 80)).toBe(true); + expect(isAlertTier(percentTier(100), 80)).toBe(true); + }); + + it("非 % unit 的行不告警(金额行的 used 是钱数)", () => { + // Kimi 加力钱包:used = monthlyUsedCents/100,与百分比阈值不可比。 + expect( + isAlertTier( + { planName: "booster_wallet", used: 8, total: 50, remaining: 12.34, unit: "USD" }, + 5 + ) + ).toBe(false); + // sub2api 的 USD 配额行同理,不参与百分比预警。 + expect( + isAlertTier({ planName: "daily_limit", used: 90, total: 100, unit: "USD" }, 50) + ).toBe(false); + }); + + it("used 缺失时不告警", () => { + expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false); + }); +}); diff --git a/src/hooks/useUsageQuery.ts b/src/hooks/useUsageQuery.ts index 4b4f092..68631c6 100644 --- a/src/hooks/useUsageQuery.ts +++ b/src/hooks/useUsageQuery.ts @@ -56,6 +56,23 @@ export interface UsageQueryState { updatedAt: number | null; refresh: (force: boolean) => void; supported: boolean; + /** 生效的预警阈值(0/缺失已归一为 undefined);footer 用它判定单行高亮。 */ + threshold?: number; + /** 查询成功且任一百分比行 used ≥ threshold。失败/加载中恒为 false, */ + /** 所以失败时保留的上次数据不会被误高亮。 */ + alert: boolean; +} + +/** + * 单行是否触发预警。只判定百分比行(unit === "%"):余额/钱包等金额行的 + * used 是钱数,与百分比阈值不可比。threshold 缺失或 ≤0(关闭)时恒为 false。 + */ +export function isAlertTier( + d: UsageData, + threshold?: number | null +): boolean { + if (threshold == null || threshold <= 0) return false; + return d.unit === "%" && d.used != null && d.used >= threshold; } export function useUsageQuery( @@ -63,7 +80,8 @@ export function useUsageQuery( providerName: string, usageKinds?: string[], autoIntervalMinutes?: number, - disabled?: boolean + disabled?: boolean, + threshold?: number ): UsageQueryState { const supported = (usageKinds?.length ?? 0) > 0; // Cache key includes the agent so a Kimi Code provider and a Pi provider @@ -175,6 +193,15 @@ export function useUsageQuery( return () => clearInterval(id); }, [supported, autoIntervalMinutes, runQuery, disabled]); + // 阈值 0/负数/非有限值一律视为关闭(与配置面板「0 或空 = 关闭」一致)。 + // 每次查询结果写入 data 后此处重新求值,因此刷新后预警自动出现/消除。 + const alertThreshold = + typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0 + ? threshold + : undefined; + const alert = + status === "success" && data.some((d) => isAlertTier(d, alertThreshold)); + return { status, data, @@ -182,5 +209,7 @@ export function useUsageQuery( updatedAt, refresh: runQuery, supported, + threshold: alertThreshold, + alert, }; } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b10f579..c440da0 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -43,6 +43,26 @@ export const enTranslations: Record = { connectivityOk: "{name} reachable ({latency}ms)", connectivitySlow: "{name} reachable but slow ({latency}ms)", connectivityFail: "{name} unreachable: {error}", + // Health check (ProviderList toolbar button + per-row status dot) + healthCheck: "Health check", + healthCheckHint: + "Probe every enabled provider's inference endpoint and billing query in parallel", + healthCheckRunning: "Checking…", + healthSummary: "{ok} OK / {fail} failed", + healthProbeOk: "Endpoint OK ({latency}ms)", + healthProbeUnsupported: "Endpoint exposes no model list (404)", + healthProbeFail: "Endpoint failed: {error}", + healthUsageOk: "Billing OK", + healthUsageFail: "Billing failed: {error}", + healthNotChecked: "Not checked", + healthRequestFailed: "Health check request failed: {error}", + healthErrNoBaseUrl: "Missing base URL", + healthErrNoKey: "No API key configured", + healthErrAuth: "Authentication failed", + healthErrNotFound: "Endpoint not found", + healthErrTimeout: "Connection timed out", + healthErrUnreachable: "Cannot connect (DNS failure or connection refused)", + healthErrBadJson: "Response is not valid JSON", copiedProvider: "Duplicated as {name}", // Provider edit @@ -78,6 +98,12 @@ export const enTranslations: Record = { authField: "Auth Field", apiKey: "API Key", getApiKeyLink: "Get API Key", + apiKeySource: "Credential source", + apiKeySourceDirect: "Enter key", + apiKeySourceEnv: "Environment variable", + apiKeyEnvName: "Variable name", + apiKeyEnvHint: + "Only the variable name (api_key_env) is written; Kimi Code reads the secret from its environment. The two sources are mutually exclusive, so switching clears the other.", show: "Show", hide: "Hide", requestUrl: "Request URL", @@ -142,9 +168,16 @@ export const enTranslations: Record = { loopControlSettings: "Loop Control", maxAttemptsPerStep: "Max attempts per step", reservedContextSize: "Reserved context size", + compactionMaxAttempts: "Max compaction attempts", + compactionMaxAttemptsHint: + "Retry limit after a failed context compaction. Empty or 0 leaves the key out of the config (upstream default 5).", backgroundSettings: "Background Tasks", maxRunningTasks: "Max running tasks", keepAliveOnExit: "Keep alive on exit", + watchSettings: "File Watching", + watchEnabled: "Enable file watching", + watchEnabledDesc: + "Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, off by default since 2.0.2). The KIMI_CODE_WATCH environment variable overrides this setting.", permissionRules: "Permission Rules", permissionDecision: "Decision", permissionPattern: "Pattern", @@ -283,6 +316,18 @@ export const enTranslations: Record = { install: "Install", updateCheckFailed: "Update check failed", lastChecked: "Last checked", + // Kimi Code version tracking + kimiCodeSection: "Kimi Code version", + kimiCodeInstalled: "Installed", + kimiCodeLatest: "Latest", + kimiCodeStatusLabel: "Compatibility", + kimiCodeStatusCurrent: "Supported", + kimiCodeStatusOutdated: "Update KimiSwitch recommended", + kimiCodeStatusPartial: "Partially compatible", + kimiCodeStatusUnknown: "Unknown version", + kimiCodeStatusNotInstalled: "Not installed", + kimiCodeCheck: "Check", + kimiCodeOpenReleases: "Open releases", appDescription: "Windows desktop LLM provider config manager", settingsWebsite: "Official website", kimiEyesSite: "Kimi Eyes plugin site", @@ -450,6 +495,10 @@ export const enTranslations: Record = { usageTierWeekly: "Weekly", usageTierMonthly: "Monthly", usageTierMonthCode: "Monthly (code)", + usageTierBoosterWallet: "Booster", + usageThreshold: "Alert threshold", + usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off", + usageThresholdAlert: "Threshold reached", usageErrNoKey: "No API key configured", usageErrDisabled: "Usage query is disabled in the config panel", usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 5ffc936..8df95bb 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -41,6 +41,25 @@ export const zhTranslations = { connectivityOk: "{name} 连通正常({latency}ms)", connectivitySlow: "{name} 连通较慢({latency}ms)", connectivityFail: "{name} 无法连通:{error}", + // 一键体检(ProviderList 工具栏按钮 + 行内状态点) + healthCheck: "一键体检", + healthCheckHint: "并行检测所有启用供应商的推理端点与账单查询", + healthCheckRunning: "体检中…", + healthSummary: "{ok} 正常 / {fail} 失败", + healthProbeOk: "端点正常({latency}ms)", + healthProbeUnsupported: "端点不支持模型列表(404)", + healthProbeFail: "端点失败:{error}", + healthUsageOk: "账单正常", + healthUsageFail: "账单失败:{error}", + healthNotChecked: "未检测", + healthRequestFailed: "体检请求失败:{error}", + healthErrNoBaseUrl: "缺少 base_url", + healthErrNoKey: "未配置 API Key", + healthErrAuth: "鉴权失败", + healthErrNotFound: "端点不存在", + healthErrTimeout: "连接超时", + healthErrUnreachable: "无法连接(DNS 解析失败或连接被拒绝)", + healthErrBadJson: "响应不是合法 JSON", copiedProvider: "已复制为 {name}", // Provider edit @@ -76,6 +95,12 @@ export const zhTranslations = { authField: "认证字段", apiKey: "API Key", getApiKeyLink: "获取 API Key", + apiKeySource: "凭证来源", + apiKeySourceDirect: "直接填写", + apiKeySourceEnv: "环境变量", + apiKeyEnvName: "环境变量名", + apiKeyEnvHint: + "只写入变量名(api_key_env),密钥由 Kimi Code 从运行环境读取;两者互斥,切换后会清空另一种。", show: "显示", hide: "隐藏", requestUrl: "请求地址", @@ -140,9 +165,15 @@ export const zhTranslations = { loopControlSettings: "循环控制", maxAttemptsPerStep: "单步最大尝试次数", reservedContextSize: "上下文预留大小", + compactionMaxAttempts: "压缩最大尝试次数", + compactionMaxAttemptsHint: "上下文压缩失败后的重试上限,留空或 0 表示不写入该项(上游默认 5)。", backgroundSettings: "后台任务", maxRunningTasks: "最大并发数", keepAliveOnExit: "退出时保持运行", + watchSettings: "文件监听", + watchEnabled: "启用文件监听", + watchEnabledDesc: + "监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。", permissionRules: "权限规则", permissionDecision: "处置", permissionPattern: "模式", @@ -277,6 +308,18 @@ export const zhTranslations = { install: "安装", updateCheckFailed: "检查更新失败", lastChecked: "上次检查", + // Kimi Code 版本跟踪 + kimiCodeSection: "Kimi Code 版本", + kimiCodeInstalled: "已安装版本", + kimiCodeLatest: "最新版本", + kimiCodeStatusLabel: "适配状态", + kimiCodeStatusCurrent: "已适配", + kimiCodeStatusOutdated: "建议升级 KimiSwitch", + kimiCodeStatusPartial: "部分兼容", + kimiCodeStatusUnknown: "未知版本", + kimiCodeStatusNotInstalled: "未安装", + kimiCodeCheck: "检查", + kimiCodeOpenReleases: "打开 Releases", appDescription: "Windows 桌面端 LLM 供应商配置管理器", settingsWebsite: "官方网站", kimiEyesSite: "Kimi Eyes 插件站", @@ -444,6 +487,10 @@ export const zhTranslations = { usageTierWeekly: "7天", usageTierMonthly: "30天", usageTierMonthCode: "30天(代码)", + usageTierBoosterWallet: "加力钱包", + usageThreshold: "预警阈值", + usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭", + usageThresholdAlert: "已达预警阈值", usageErrNoKey: "未配置 API Key", usageErrDisabled: "用量查询已在配置面板中停用", usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`", diff --git a/src/lib/agent-settings.test.ts b/src/lib/agent-settings.test.ts index 21e9ee9..544f938 100644 --- a/src/lib/agent-settings.test.ts +++ b/src/lib/agent-settings.test.ts @@ -134,10 +134,104 @@ describe("loop_control — v1/v2 key handling", () => { }); // --------------------------------------------------------------------------- -// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means -// upstream default ON; an explicit false must survive even with no rules +// loop_control.compaction_max_attempts (kimi-code 0.43.0) — read via the loop +// spread, written only when set, and 0/empty removes the key (upstream +// default 5 applies) // --------------------------------------------------------------------------- +describe("loop_control.compaction_max_attempts", () => { + it("reads an existing value through the loop spread", () => { + expect( + getAgentSettings({ loop_control: { compaction_max_attempts: 7 } }) + .loop_control?.compaction_max_attempts + ).toBe(7); + }); + + it("reads as undefined when the key is absent (UI falls back to 5)", () => { + expect( + getAgentSettings({ loop_control: {} }).loop_control?.compaction_max_attempts + ).toBeUndefined(); + }); + + it("writes the value when the patch sets it", () => { + const next = setAgentSettings({}, { + loop_control: { compaction_max_attempts: 8 }, + }); + expect(loopOf(next).compaction_max_attempts).toBe(8); + }); + + it("drops the key for 0 and for a cleared (undefined) field", () => { + const raw = { loop_control: { compaction_max_attempts: 6 } }; + expect(loopOf(setAgentSettings(raw, { + loop_control: { compaction_max_attempts: 0 }, + }))).not.toHaveProperty("compaction_max_attempts"); + expect(loopOf(setAgentSettings(raw, { + loop_control: { compaction_max_attempts: undefined }, + }))).not.toHaveProperty("compaction_max_attempts"); + }); + + it("does not materialize the upstream default on an unrelated save", () => { + // An absent key must stay absent — a plain save must not pin `5`. + const next = setAgentSettings({ thinking: { effort: "high" } }, {}); + expect(loopOf(next)).not.toHaveProperty("compaction_max_attempts"); + // An existing value survives a save that does not touch it. + const kept = setAgentSettings( + { loop_control: { compaction_max_attempts: 4 } }, + { thinking: { effort: "low" } } + ); + expect(loopOf(kept).compaction_max_attempts).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since +// 2.0.2; mirrors the [background] handling +// --------------------------------------------------------------------------- + +function watchOf(raw: unknown): Record { + const w = (raw as { watch?: unknown })?.watch; + return w && typeof w === "object" && !Array.isArray(w) + ? (w as Record) + : {}; +} + +describe("[watch] enabled", () => { + it("reads as false when the section or key is absent (2.0.2 default)", () => { + expect(getAgentSettings({}).watch?.enabled).toBe(false); + expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(false); + }); + + it("reads an explicit value", () => { + expect(getAgentSettings({ watch: { enabled: true } }).watch?.enabled).toBe(true); + expect(getAgentSettings({ watch: { enabled: false } }).watch?.enabled).toBe(false); + }); + + it("writes the top-level [watch] section", () => { + const next = setAgentSettings({}, { watch: { enabled: true } }) as { + watch?: Record; + }; + expect(next.watch).toEqual({ enabled: true }); + }); + + it("keeps the section on saves that do not touch it", () => { + const next = setAgentSettings( + { watch: { enabled: true } }, + { thinking: { effort: "high" } } + ) as { watch?: Record }; + expect(next.watch).toEqual({ enabled: true }); + }); + + it("does not disturb the other managed sections", () => { + const next = setAgentSettings( + { background: { max_running_tasks: 2 } }, + { watch: { enabled: true } } + ) as { background?: Record }; + expect(next.background?.max_running_tasks).toBe(2); + expect(watchOf(next).enabled).toBe(true); + }); +}); + + describe("permission.dangerous_command_guard", () => { it("reads as undefined when the key is absent (default on)", () => { expect( diff --git a/src/lib/agent-settings.ts b/src/lib/agent-settings.ts index 27b8688..3e5715a 100644 --- a/src/lib/agent-settings.ts +++ b/src/lib/agent-settings.ts @@ -13,6 +13,11 @@ const DEFAULT_SETTINGS: AgentSettings = { background: { keep_alive_on_exit: false, }, + // `[watch] enabled` — kimi-code 2.0.1 added the key, 2.0.2 flipped the + // default to off, so an absent key reads as "off". + watch: { + enabled: false, + }, permission: { rules: [] }, hooks: [], }; @@ -76,6 +81,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings { ...DEFAULT_SETTINGS.background, ...getSection(rawOther, "background"), }, + watch: { + ...DEFAULT_SETTINGS.watch, + ...getSection(rawOther, "watch"), + }, permission: { rules: sectionPermission?.rules ?? [], // `dangerous_command_guard` stays undefined unless the config carries @@ -111,6 +120,7 @@ export function setAgentSettings( thinking: { ...current.thinking, ...patch.thinking }, loop_control: { ...current.loop_control, ...patch.loop_control }, background: { ...current.background, ...patch.background }, + watch: { ...current.watch, ...patch.watch }, permission, hooks: patch.hooks ?? current.hooks ?? [], }; @@ -129,9 +139,16 @@ export function setAgentSettings( if (!opts?.legacyV1) { delete next.loop_control?.max_retries_per_step; } + // `compaction_max_attempts` (kimi-code 0.43.0+): 0 / undefined means "leave + // the key out" — the config then falls back to the upstream default (5) + // instead of pinning a literal 0. + if (!next.loop_control?.compaction_max_attempts) { + delete next.loop_control?.compaction_max_attempts; + } if (next.thinking) root.thinking = next.thinking; if (next.loop_control) root.loop_control = next.loop_control; if (next.background) root.background = next.background; + if (next.watch) root.watch = next.watch; // Keep `[permission]` when it carries rules *or* an explicit // dangerous_command_guard — dropping the section would silently lose a // guard=false write (absent key = upstream default on). An empty rules diff --git a/src/lib/usage-display.test.ts b/src/lib/usage-display.test.ts index ad6012c..155241b 100644 --- a/src/lib/usage-display.test.ts +++ b/src/lib/usage-display.test.ts @@ -59,6 +59,7 @@ describe("planLabel", () => { expect(planLabel("five_hour", t)).toBe("usageTier5h"); expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly"); expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly"); + expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet"); expect(planLabel("NewAPI", t)).toBe("NewAPI"); expect(planLabel("¥", t)).toBe("¥"); }); diff --git a/src/lib/usage-display.ts b/src/lib/usage-display.ts index 81ac2e9..89ab833 100644 --- a/src/lib/usage-display.ts +++ b/src/lib/usage-display.ts @@ -5,6 +5,13 @@ type TranslateFn = ( vars?: Record ) => string; +/** + * Kimi quota 的加力钱包行 id(src-tauri/src/services/coding_plan.rs 的 + * PLAN_BOOSTER_WALLET)。它是金额行而非配额百分比行,planLabel() 与 + * UsageFooter 的金额行判定共用此常量,避免字面量散落。 + */ +export const BOOSTER_WALLET_PLAN = "booster_wallet"; + /** * Map a raw Rust plan/tier name to a localized label. Unknown names (proper * nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged. @@ -28,6 +35,8 @@ export function planLabel(name: string, t: TranslateFn): string { return t("usageTierMonthly"); case "month_code": return t("usageTierMonthCode"); + case BOOSTER_WALLET_PLAN: + return t("usageTierBoosterWallet"); default: return name; } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 8a2340c..85054da 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -50,7 +50,10 @@ export function validateProviders( const reasons: string[] = []; const apiKey = provider.api_key?.trim(); - if (!apiKey && !provider.managed) { + // kimi-code 2.0.0+: the credential may come from an environment variable + // instead of a stored key — a provider configured that way is complete. + const apiKeyEnv = provider.api_key_env?.trim(); + if (!apiKey && !apiKeyEnv && !provider.managed) { reasons.push(t("validationNoApiKey")); } diff --git a/src/types/index.ts b/src/types/index.ts index b9b6e80..ac12b12 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,6 +15,12 @@ export interface Provider { provider_type: ProviderType; base_url: string | null; api_key: string | null; + /** + * Name of the process environment variable holding the credential + * (kimi-code 2.0.0+). Mutually exclusive with `api_key` upstream — the + * export writes only one of the two. Only the variable *name* is stored. + */ + api_key_env?: string | null; env: Record; /** Optional note / remark for the provider. */ note?: string | null; @@ -90,6 +96,9 @@ export interface UsageConfig { autoQueryIntervalMinutes?: number; /** Per-request timeout in seconds; 0/undefined = default (8s). */ timeoutSeconds?: number; + /** 用量预警阈值(百分比 0-100);0/undefined = 关闭预警。 + * 判定见 src/hooks/useUsageQuery.ts 的 isAlertTier()。 */ + threshold?: number; } /** A model discovered from a provider's API endpoint. */ @@ -125,6 +134,9 @@ export interface LoopControlConfig { * KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */ max_retries_per_step?: number; reserved_context_size?: number; + /** Retry budget for context compaction (kimi-code 0.43.0+). Absent/0 = the + * key is not written and upstream's default (5) applies. */ + compaction_max_attempts?: number; } export interface BackgroundConfig { @@ -132,6 +144,13 @@ export interface BackgroundConfig { keep_alive_on_exit?: boolean; } +/** Top-level `[watch]` section (kimi-code 2.0.1+). The key was added in + * 2.0.1 and its default flipped to off in 2.0.2, so an absent key reads as + * disabled. `KIMI_CODE_WATCH` overrides the config value at runtime. */ +export interface WatchConfig { + enabled?: boolean; +} + export interface PermissionRule { decision?: "allow" | "deny" | "ask"; scope?: string; @@ -150,6 +169,8 @@ export interface AgentSettings { thinking?: ThinkingConfig; loop_control?: LoopControlConfig; background?: BackgroundConfig; + /** Top-level `[watch]` section (kimi-code 2.0.1+). */ + watch?: WatchConfig; permission?: { rules?: PermissionRule[]; /** kimi-code `[permission]` key. Default true when the key is