Compare commits

...
2 Commits
Author SHA1 Message Date
KimiSwitch Dev 35788c2783 fix(config): 供应商改名同步重键模型别名 + 删除时清理关联配置(v0.8.3-rc1)
Release / Version consistency (push) Waiting to run
修复「改名后模型别名保留旧前缀,僵尸条目删了又出现」这一类配置不一致。

- 改名时同步重键模型别名、全局默认模型、供应商记住的默认模型、子代理池条目
- 改名冲突(撞已存在供应商名 / 遗留裸别名)加 -2/-3 后缀,不再静默吞模型
- 修复复制供应商复用移动语义 rekey 导致源供应商模型被清空
- 加载时不再合并「供应商已不存在」的 SQLite 僵尸模型
- 删除供应商/模型时清理子代理池条目、池默认回落、default_model 置空、
  SQLite 用量查询配置(不误删另一 agent,读取失败即跳过)
- 防呆:复制供应商改为 dirty-only,放弃修改不再落盘
- 测试基建:统一 crate 级 test_state 锁,消除环境变量并发竞争

测试:前端 146(新增 16)、Rust 197(新增 4)
2026-10-10 19:50:03 +08:00
KimiSwitch Dev 317562781c feat(website): 官网更新至 v0.8.2(下载版本号 v0.8.2、模型库 8,467·225、更新日志补 v0.8.2 性能专项条目、JSON-LD softwareVersion/dateModified 同步) 2026-10-09 20:00:13 +08:00
23 changed files with 4253 additions and 3909 deletions

No files matched your search

+14
View File
@@ -6,6 +6,20 @@
---
## v0.8.3-rc1 (2026-10-10)
配置一致性修复预发布版(RC,请先本地验证)。
- **供应商改名时模型别名同步重键**:此前只改模型的 `provider` 字段,别名键保留旧前缀(如 `opencode-go-copy/...`)形成僵尸条目;现在模型别名、全局默认模型、供应商记住的默认模型、子代理池条目全部同步更新
- **僵尸模型不再复活**:加载时 SQLite 镜像会把 config.toml 中缺失的模型合并回来,导致已删模型保存后重现;现在供应商已不存在的模型不再合并
- **改名冲突不再静默吞模型**:改名撞已存在的供应商名或遗留裸别名时,目标别名自动加 `-2`/`-3` 后缀
- **复制供应商不再清空源供应商**:修复复制复用改名逻辑(移动语义)导致源供应商模型被搬走;副本独立、源完整
- **删除供应商/模型的关联清理**:子代理模型池中引用被删模型的条目一并移除;池默认模型自动回落到剩余条目(池清空则移除整节);`default_model` 置空;SQLite 中该供应商的用量查询配置随之清理,且不误删另一 agent(Pi)的设置
- **防呆:放弃修改不落盘**:复制供应商不再立即静默保存,与其他编辑一致,放弃后配置保持原样
- 修复测试基建中 `KIMI_CODE_HOME` / `KIMI_SWITCH_DB_PATH` 的并发竞争(统一 crate 级共享锁)
- models.dev 快照同步至 2026-10-10(8,456 模型 / 226 供应商)
- 测试:前端 146 项(新增 16 项)、Rust 197 项(新增 4 项)
## v0.8.2 (2026-10-09)
性能专项,功能与数据口径不变(用量数字、字段、IPC 接口、导出格式均与 v0.8.1 一致)。
+81
View File
@@ -0,0 +1,81 @@
# 本地验证清单 — 配置一致性修复(待发 v0.8.3)
构建版本:0.8.2(含未提交改动)。自动化测试已全绿(前端 146 / Rust 197),
以下是**必须人工验证**的场景,重点是引擎侧对配置结构的实际反应。
验证前备份:`~/.kimi-code/config.toml`、`~/.kimi-switch/kimi-switch.db`
---
## 0. 前置:确认基线干净
- [ ] 打开 `~/.kimi-code/config.toml`,搜索 `opencode-go-copy` / `opencode-go-1` → 应为 0 命中
- [ ] KimiSwitch 供应商列表中 `opencode-go` 正常显示,模型 3 条(longcat / mimo / step-5)
- [ ] 高级设置 → 子代理模型池中 3 条 opencode-go 条目都在
- [ ] kimi-code 会话能正常启动(`kimi` 命令),确认 `default_model` 生效
---
## 1. 复制供应商(本次修复的严重回归点)
- [ ] 供应商列表 → `opencode-go` 点「复制」
- [ ] **源供应商 `opencode-go` 的 3 个模型仍在**(这是关键:旧实现会清空)
- [ ] 副本 `opencode-go-copy` 也有 3 个模型,别名带 `-copy` 前缀
- [ ] 副本默认状态为「未启用 / 未使用中」
- [ ] 点返回 → 弹出未保存确认 → 选「放弃」→ **列表里副本消失**(验证防呆:放弃不落盘)
- [ ] 重新复制 → Ctrl+S 保存 → 重启应用 → 副本仍在(验证保存生效)
- [ ] 删除副本 → Ctrl+S → 重启 → 副本消失,`opencode-go` 完好
## 2. 供应商改名(僵尸别名根源)
- [ ] 编辑 `opencode-go-copy` → 名称改为 `opencode-renamed` → 保存 → 返回
- [ ] `config.toml` 中该供应商的模型别名变为 `opencode-renamed/*`,**无 `opencode-go-copy/*` 残留**
- [ ] 全局默认模型、该供应商记住的默认模型都指向新别名
- [ ] 子代理池中若有副本的条目,键同步变为新别名(描述保留)
- [ ] kimi-code 会话能启动,默认模型正确
## 3. 删除供应商(关联清理)
- [ ] 先把某个模型加入子代理池,并设为池默认
- [ ] 删除该供应商 → Ctrl+S → 打开 `config.toml` 检查:
- [ ] 该供应商的 `[models.*]` 段落全部消失
- [ ] `[secondary_model.models]` 中引用它的条目已消失
- [ ] 池 `default_model` / `model` 已回落到剩余条目(不是悬空)
- [ ] 池被清空时整个 `[secondary_model]` 节消失
- [ ] `default_model` 若指向被删模型则为 `""` 或不再指向它
- [ ] **kimi-code 会话能正常启动**(重点:验证引擎对 `default_model = ""` 的容忍度)
- [ ] SQLite 检查:`~/.kimi-switch/kimi-switch.db` 的 `settings` 表中
`usage_kinds:<该供应商名>` / `usage_config:<该供应商名>` 行已删除
- [ ] 无关的 settings 行(如 `app.language`)未受影响
## 4. 删除单个模型
- [ ] 把某模型加入子代理池 → 编辑该供应商 → 删除该模型 → 保存
- [ ] `config.toml` 中 `[secondary_model.models]` 对应条目消失
- [ ] 池默认为被删模型时已回落到其他条目
## 5. 防呆:放弃修改不落盘
- [ ] 编辑供应商改名 → 点返回 → 选「放弃」→ 重新打开:名称为旧名,**config.toml 未变**
- [ ] 删除模型 → 点返回 → 放弃 → 重新打开:模型仍在
- [ ] 复制供应商 → 放弃 → 副本消失
- [ ] 任何改动后不点保存就关闭应用 → 重启后配置不变
## 6. Pi agent 不受牵连
- [ ] 切到 Pi agent 面板,确认其供应商与用量查询配置正常
- [ ] 切回 KimiCode 保存一次 → 再看 Pi 的用量查询设置,应仍在
- [ ] 检查 SQLite `settings` 表:Pi 供应商的 `usage_*` 行未被误删
## 7. 防呆:改名冲突
- [ ] 存在 `opencode-go` 和 `opencode-go-copy` 时,把后者改名为 `opencode-go` 并保存
- [ ] 结果:两个供应商条目与各自模型都保留,冲突方别名带 `-2` 后缀,**无静默丢模型**
---
## 已知遗留(不属于本次范围)
- config.toml 中若存在 provider 已删除的僵尸 `[models.*]` 段落,应用不会自动删除
(用户权威文件,只防复发),需手动清理一次
- `default_model = ""` 时 kimi-code CLI 的确切回退行为,建议实测确认
@@ -0,0 +1,43 @@
# KimiSwitch v0.8.3-rc1
配置一致性修复版:解决「供应商改名后模型别名没跟着改,产生的僵尸条目删了又出现」这一类问题,并补齐删除供应商时的关联清理与防呆。
## 修复
- **供应商改名时模型别名同步重键**:此前改名只更新模型的 `provider` 字段,模型别名键仍保留旧前缀(如 `opencode-go-copy/...`),形成永远指向新供应商的僵尸条目;现在别名、全局默认模型、该供应商记住的默认模型、子代理池条目全部同步更新
- **僵尸模型不再复活**:应用加载时会把 SQLite 镜像中缺失于 config.toml 的模型合并回来(迁移保护),导致已删除的模型在保存后重新出现;现在供应商已不存在的模型不再被合并
- **改名冲突不再静默吞模型**:改名为已存在的供应商名、或遗留裸别名与新名冲突时,目标别名自动加 `-2`/`-3` 后缀,与「应用 JSON」路径的既有规则一致
- **复制供应商不再清空源供应商**:修复复制时复用改名逻辑导致源供应商模型被整体搬走的问题;副本为独立副本,源供应商模型完整保留
## 删除供应商 / 模型时的关联清理
- 子代理模型池(`[secondary_model]`)中引用被删模型的条目一并移除
- 池默认模型指向被删模型时自动回落到池中剩余条目;池被清空时整个 `[secondary_model]` 节移除
- 全局 `default_model` 指向被删模型时置空
- SQLite 中该供应商的用量查询配置(`usage_kinds:` / `usage_config:`)随之清理;保留另一 agent(Pi)的设置,读取失败时跳过清理而非误删
- 模型占位符自动改名为真实模型 id 时,池条目同步跟随
## 防呆
- **放弃修改不落盘**:复制供应商不再立即静默保存,与其他编辑一致——点「返回」并放弃后配置保持原样
- 改名/删除/JSON 应用等所有编辑路径统一遵守「必须保存才写盘」
## 其他
- 修复测试基建中进程级环境变量(`KIMI_CODE_HOME` / `KIMI_SWITCH_DB_PATH`)的并发竞争:dashboard 与 oauth 测试此前各自持锁,改为统一的 crate 级共享锁
- models.dev 快照同步至 2026-10-10(8,456 模型 / 226 供应商)
## 测试
- 前端 146 项(新增 16 项:改名重键、复制保留源、冲突去重、子代理池清理与空表防护)
- Rust 197 项(新增 4 项:删除后双存储持久化与不复活、孤儿配置修剪、跨 agent 修剪保护)
## 已知限制
- config.toml 中若已存在「供应商已删除」的僵尸 `[models.*]` 段落,升级后不会自动删除(config.toml 是用户权威文件,本版只防复发),需在界面中手动删除一次
- 删除供应商后 `default_model` 置空时的 kimi-code CLI 回退行为,建议实测确认
## 下载
- **Windows**:`.exe`(NSIS 安装包,本版仅提供)
- **macOS / Linux**:见 [Releases](https://github.com/billowliu2/KimiSwitch/releases)(由 CI 构建)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.8.2",
"version": "0.8.3-rc1",
"type": "module",
"scripts": {
"dev": "vite",
+1052 -1253
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.8.2"
version = "0.8.3-rc1"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.8.2"
version = "0.8.3-rc1"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+296 -1
View File
@@ -175,11 +175,18 @@ fn load_agent_config_uncached(agent: Agent) -> Result<Config, String> {
}
// Migration safety: include providers/models that exist in
// SQLite but not in config.toml (e.g. after upgrading from the
// old single-provider-write behaviour).
// old single-provider-write behaviour). Models whose provider
// no longer exists anywhere are skipped — they can never be
// shown or selected in the UI, and merging them back would
// resurrect zombie `[models."old-name/..."]` entries into
// config.toml on the next save.
for (name, p) in &db.providers {
config.providers.entry(name.clone()).or_insert_with(|| p.clone());
}
for (alias, m) in &db.models {
if !config.providers.contains_key(&m.provider) {
continue;
}
config.models.entry(alias.clone()).or_insert_with(|| m.clone());
}
}
@@ -230,6 +237,25 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
None => db::delete_setting_pub(&cfg_key).map_err(fmt_anyhow)?,
}
}
// Prune usage settings of providers that no longer exist (deleted in the
// UI). The keys are agent-agnostic, so the live set includes the other
// agent's providers too. If the other agent's config cannot be read we
// skip this round entirely — pruning against an incomplete live set
// could delete the other agent's still-live settings.
{
let other_agent = match agent {
Agent::KimiCode => Agent::Pi,
Agent::Pi => Agent::KimiCode,
};
let other_cfg = match db::load_config(&other_agent) {
Ok(cfg) => cfg,
Err(_) => return Ok(()),
};
let mut live: std::collections::HashSet<String> =
config.providers.keys().cloned().collect();
live.extend(other_cfg.providers.keys().cloned());
db::prune_orphan_usage_settings(&live).map_err(fmt_anyhow)?;
}
Ok(())
}
@@ -2367,3 +2393,272 @@ mod usage_key_tests {
assert_eq!(resolve_usage_api_key(&p).unwrap(), None);
}
}
/// End-to-end persistence tests for the Kimi Code agent: a UI edit followed
/// by save_agent_config_command must land in BOTH stores (config.toml and
/// SQLite) and must not resurrect deleted models on the next load.
#[cfg(test)]
mod config_persistence_tests {
use super::*;
/// Serialize env mutation with every other env-mutating test in the
/// binary (shared crate-level lock; see lib.rs `test_state`).
fn with_env_homes<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
let _guard = crate::test_state::lock();
let home = tempfile::tempdir().unwrap();
let db_dir = tempfile::tempdir().unwrap();
std::env::set_var("KIMI_CODE_HOME", home.path());
std::env::set_var("KIMI_SWITCH_DB_PATH", db_dir.path().join("test.db"));
db::close_cached_conn_for_tests();
let out = f(home.path());
db::close_cached_conn_for_tests();
std::env::remove_var("KIMI_CODE_HOME");
std::env::remove_var("KIMI_SWITCH_DB_PATH");
out
}
const TOML_WITH_STALE_MODELS: &str = r#"
default_model = "opencode-go/longcat-2.5-preview-free"
[providers.opencode-go]
type = "openai"
base_url = "https://opencode.ai/zen/go/v1"
api_key = "sk-test"
[models."opencode-go-copy/deepseek-v4-flash"]
provider = "opencode-go"
model = "deepseek-v4-flash"
max_context_size = 512000
[models."opencode-go-1/space-bunny-free"]
provider = "opencode-go"
model = "space-bunny-free"
max_context_size = 512000
[models."opencode-go/longcat-2.5-preview-free"]
provider = "opencode-go"
model = "longcat-2.5-preview-free"
max_context_size = 1000000
"#;
#[test]
fn ui_delete_then_save_persists_to_both_stores_and_never_resurrects() {
with_env_homes(|home| {
std::fs::write(home.join("config.toml"), TOML_WITH_STALE_MODELS).unwrap();
// Load from the seeded config.toml, then mirror it into SQLite to
// reproduce the user's real state: stale aliases in BOTH stores.
let mut config = load_agent_config_uncached(Agent::KimiCode).unwrap();
assert!(config.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(config.models.contains_key("opencode-go-1/space-bunny-free"));
db::save_config(&Agent::KimiCode, &config).unwrap();
// Simulate the UI's model delete (onModelDelete): drop the stale
// aliases from the in-memory config, then save.
config.models.retain(|alias, _| {
!alias.starts_with("opencode-go-copy/") && !alias.starts_with("opencode-go-1/")
});
save_agent_config_command(Agent::KimiCode, config).unwrap();
// config.toml: stale sections gone, live model kept.
let toml_text = std::fs::read_to_string(home.join("config.toml")).unwrap();
assert!(!toml_text.contains("opencode-go-copy"));
assert!(!toml_text.contains("opencode-go-1"));
assert!(toml_text.contains("opencode-go/longcat-2.5-preview-free"));
// SQLite mirror: stale rows gone.
let db_config = db::load_config(&Agent::KimiCode).unwrap();
assert!(!db_config.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(!db_config.models.contains_key("opencode-go-1/space-bunny-free"));
// Reload through the real load path: no resurrection from SQLite.
let reloaded = load_agent_config_uncached(Agent::KimiCode).unwrap();
assert!(!reloaded.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(!reloaded.models.contains_key("opencode-go-1/space-bunny-free"));
assert!(reloaded.models.contains_key("opencode-go/longcat-2.5-preview-free"));
});
}
#[test]
fn sqlite_models_whose_provider_was_deleted_do_not_resurrect() {
with_env_homes(|home| {
// config.toml already clean: only the live provider + model.
let toml = r#"
default_model = "opencode-go/longcat-2.5-preview-free"
[providers.opencode-go]
type = "openai"
base_url = "https://opencode.ai/zen/go/v1"
api_key = "sk-test"
[models."opencode-go/longcat-2.5-preview-free"]
provider = "opencode-go"
model = "longcat-2.5-preview-free"
max_context_size = 1000000
"#;
std::fs::write(home.join("config.toml"), toml).unwrap();
// Seed SQLite with a zombie model whose provider no longer exists
// (left behind by an old rename-then-delete).
let mut providers = IndexMap::new();
providers.insert(
"opencode-go".to_string(),
Provider {
name: "opencode-go".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://opencode.ai/zen/go/v1".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: serde_json::Value::Null,
usage_kinds: None,
usage_config: None,
},
);
let mut models = IndexMap::new();
models.insert(
"opencode-go-copy/deepseek-v4-flash".to_string(),
Model {
alias: "opencode-go-copy/deepseek-v4-flash".to_string(),
provider: "opencode-go-copy".to_string(),
model: "deepseek-v4-flash".to_string(),
max_context_size: 512_000,
display_name: None,
supports_1m: false,
capabilities: vec![],
raw_other: serde_json::Value::Null,
},
);
let seeded = Config {
default_model: Some("opencode-go/longcat-2.5-preview-free".to_string()),
providers,
models,
raw_other: serde_json::Value::Null,
imported_section_keys: vec![],
};
db::save_config(&Agent::KimiCode, &seeded).unwrap();
// Loading must NOT merge the zombie back: its provider is gone,
// so it can never be shown or selected, and merging it would
// rewrite it into config.toml on the next save.
let loaded = load_agent_config_uncached(Agent::KimiCode).unwrap();
assert!(!loaded.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(loaded.models.contains_key("opencode-go/longcat-2.5-preview-free"));
// And a save after that load must keep config.toml clean.
save_agent_config_command(Agent::KimiCode, loaded).unwrap();
let toml_text = std::fs::read_to_string(home.join("config.toml")).unwrap();
assert!(!toml_text.contains("opencode-go-copy"));
});
}
fn plain_provider(name: &str) -> Provider {
Provider {
name: name.to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://opencode.ai/zen/go/v1".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: serde_json::Value::Null,
usage_kinds: None,
usage_config: None,
}
}
#[test]
fn deleting_a_provider_prunes_its_orphan_usage_settings() {
with_env_homes(|home| {
std::fs::write(home.join("config.toml"), TOML_WITH_STALE_MODELS).unwrap();
let mut config = load_agent_config_uncached(Agent::KimiCode).unwrap();
// Provider present: its usage settings survive the save.
config.providers.get_mut("opencode-go").unwrap().usage_kinds =
Some(vec!["plan:opencode_go".to_string()]);
save_agent_config_command(Agent::KimiCode, config.clone()).unwrap();
assert_eq!(
db::get_setting_pub("usage_kinds:opencode-go").unwrap().as_deref(),
Some(r#"["plan:opencode_go"]"#)
);
// Seed orphans the way history did (renamed/deleted providers).
db::set_setting_pub("usage_kinds:opencode-go-copy", r#"["plan:opencode_go"]"#)
.unwrap();
db::set_setting_pub("usage_config:opencode-go-1", "{}").unwrap();
// Non-usage settings must never be touched by the prune.
db::set_setting_pub("app.language", "zh").unwrap();
// Delete the provider (UI semantics) and save.
config.providers.shift_remove("opencode-go");
config.models.retain(|_, m| m.provider != "opencode-go");
config.default_model = None;
save_agent_config_command(Agent::KimiCode, config).unwrap();
// The provider's own settings and the historical orphans are gone.
assert_eq!(db::get_setting_pub("usage_kinds:opencode-go").unwrap(), None);
assert_eq!(db::get_setting_pub("usage_kinds:opencode-go-copy").unwrap(), None);
assert_eq!(db::get_setting_pub("usage_config:opencode-go-1").unwrap(), None);
assert_eq!(db::get_setting_pub("app.language").unwrap().as_deref(), Some("zh"));
// Re-adding a provider with the same name starts clean (no
// resurrection of the pruned rows).
let mut config = load_agent_config_uncached(Agent::KimiCode).unwrap();
config
.providers
.insert("opencode-go".to_string(), plain_provider("opencode-go"));
save_agent_config_command(Agent::KimiCode, config).unwrap();
assert_eq!(db::get_setting_pub("usage_kinds:opencode-go").unwrap(), None);
assert_eq!(db::get_setting_pub("app.language").unwrap().as_deref(), Some("zh"));
});
}
#[test]
fn pruning_never_touches_the_other_agents_usage_settings() {
with_env_homes(|home| {
std::fs::write(home.join("config.toml"), TOML_WITH_STALE_MODELS).unwrap();
// Pi (the other agent) has its own provider with usage settings.
let mut pi_providers = IndexMap::new();
pi_providers.insert("pi-provider".to_string(), plain_provider("pi-provider"));
db::save_config(
&Agent::Pi,
&Config {
default_model: None,
providers: pi_providers,
models: IndexMap::new(),
raw_other: serde_json::Value::Null,
imported_section_keys: vec![],
},
)
.unwrap();
db::set_setting_pub("usage_kinds:pi-provider", r#"["balance:deepseek"]"#)
.unwrap();
db::set_setting_pub("usage_config:pi-provider", "{}").unwrap();
// Save the Kimi Code config (which has no `pi-provider`).
let config = load_agent_config_uncached(Agent::KimiCode).unwrap();
save_agent_config_command(Agent::KimiCode, config).unwrap();
// The other agent's settings survive the prune.
assert_eq!(
db::get_setting_pub("usage_kinds:pi-provider").unwrap().as_deref(),
Some(r#"["balance:deepseek"]"#)
);
assert_eq!(db::get_setting_pub("usage_config:pi-provider").unwrap().as_deref(), Some("{}"));
});
}
}
+1 -2
View File
@@ -1670,8 +1670,7 @@ fn scan_usage_cached2(home: &Path, refresh: bool) -> (Arc<Vec<UsageRecord>>, Sca
/// home, redirected database or purged cache.
#[cfg(test)]
fn test_state_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
crate::test_state::lock()
}
/// The record table is shared behind an `Arc` so the unchanged path hands out
+27
View File
@@ -428,6 +428,33 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
Ok(())
}
/// Delete `usage_kinds:<name>` / `usage_config:<name>` settings rows whose
/// provider no longer exists in any agent's config, so deleting a provider in
/// the UI also clears its usage-query settings instead of leaving orphan rows
/// that outlive every rename of that provider.
pub fn prune_orphan_usage_settings(
live_providers: &std::collections::HashSet<String>,
) -> DbResult<()> {
with_conn(|conn| {
let keys: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT key FROM settings WHERE key LIKE 'usage_kinds:%' OR key LIKE 'usage_config:%'",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.collect::<Result<Vec<_>, _>>()?
};
for key in keys {
let name = key.split_once(':').map(|(_, n)| n).unwrap_or("");
if !live_providers.contains(name) {
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
}
}
Ok(())
})?;
bump_generation();
Ok(())
}
// ---------------------------------------------------------------------------
// Archived-session usage snapshots
// ---------------------------------------------------------------------------
+14
View File
@@ -10,6 +10,20 @@ pub mod pi_io;
pub mod plugins;
pub mod services;
/// Process-global state guard for tests that redirect shared environment
/// (`KIMI_CODE_HOME`, `KIMI_SWITCH_DB_PATH`) or in-memory caches (scan cache,
/// cached DB connection). Every env-mutating test must hold this lock so a
/// parallel test can never observe another one's temp home or redirected
/// database.
#[cfg(test)]
pub(crate) mod test_state {
static STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) fn lock() -> std::sync::MutexGuard<'static, ()> {
STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
}
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder};
use tauri::Manager;
+1 -2
View File
@@ -925,8 +925,7 @@ mod tests {
/// dir). A process-wide mutex serializes env mutation so parallel tests in
/// this binary can't observe a stale override.
fn with_kimi_code_home<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
let _guard = LOCK.get_or_init(|| std::sync::Mutex::new(())).lock().unwrap();
let _guard = crate::test_state::lock();
let home = tempfile::tempdir().unwrap();
std::env::set_var("KIMI_CODE_HOME", home.path());
let out = f(home.path());
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.8.2",
"version": "0.8.3-rc1",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+66 -34
View File
@@ -20,6 +20,14 @@ import {
type ProviderPreset,
} from "./config/providerPresets";
import { validateProviders } from "./lib/validation";
import {
copyModelsOnProviderDuplicate,
rekeyModelsOnProviderRename,
} from "./lib/providerRename";
import {
purgeSubagentPoolAliases,
rekeySubagentPoolAliases,
} from "./lib/referenceCleanup";
import type { Agent, Model, Provider, UsageConfig } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
@@ -153,9 +161,6 @@ export default function App() {
// provider being switched to — only that one needs to be
// complete; sibling providers being merely listed in the config
// must not block the switch.
// 3. Programmatic auto-saves (duplicate) call `save` directly with
// no validation — duplicates inherit completeness from their
// source.
// Returns `true` when the config was actually persisted, `false` when
// the user cancelled the validation confirm (callers can react to this
// to roll back in-memory mutations made before the save).
@@ -287,15 +292,30 @@ export default function App() {
);
if (oldEntry && oldEntry[0] !== provider.name) {
delete providers[oldEntry[0]];
const models = { ...cfg.models };
for (const key of Object.keys(models)) {
if (models[key].provider === editingProvider) {
models[key] = { ...models[key], provider: provider.name };
// Re-key this provider's model aliases so the `<provider>/<model>`
// prefix tracks the new name (see rekeyModelsOnProviderRename).
const { models, aliasMap } = rekeyModelsOnProviderRename(
cfg.models,
editingProvider,
provider.name
);
// The provider's remembered default model and the global default
// model are stored as aliases and must follow the re-key.
const remembered = getProviderDefaultModel(provider);
providers[provider.name] =
remembered && aliasMap[remembered]
? setProviderDefaultModel(provider, aliasMap[remembered])
: provider;
let default_model = cfg.default_model;
if (default_model && aliasMap[default_model]) {
default_model = aliasMap[default_model];
}
}
providers[provider.name] = provider;
// The subagent pool references model aliases as its keys and
// defaults; they must follow the re-key or they turn into zombie
// pool entries.
const raw_other = rekeySubagentPoolAliases(cfg.raw_other, aliasMap);
setEditingProvider(provider.name);
return { ...cfg, providers, models };
return { ...cfg, providers, models, default_model, raw_other };
}
providers[provider.name] = provider;
return { ...cfg, providers };
@@ -308,12 +328,21 @@ export default function App() {
const providers = { ...cfg.providers };
delete providers[name];
const models = { ...cfg.models };
const removedAliases: string[] = [];
for (const key of Object.keys(models)) {
if (models[key].provider === name) {
removedAliases.push(key);
delete models[key];
}
}
return { ...cfg, providers, models };
// Everything keyed on the deleted aliases must go with them: the
// global default model and the subagent pool entries / pool default.
const default_model =
cfg.default_model && removedAliases.includes(cfg.default_model)
? null
: cfg.default_model;
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, removedAliases);
return { ...cfg, providers, models, default_model, raw_other };
});
if (editingProvider === name) {
setEditingProvider("");
@@ -356,29 +385,14 @@ export default function App() {
null
);
const providers = { ...cfg.providers, [newName]: copied };
// Re-key this provider's models to the new provider name so the copy
// has its own independent model set.
const models = { ...cfg.models };
for (const [alias, m] of Object.entries(cfg.models)) {
if (m.provider === name) {
// The model belongs to this provider by `m.provider === name`. To
// re-key it under the new provider, we want a `newName/...`
// alias. The original code assumed `alias` is exactly
// `${name}/${modelId}` and used `alias.slice(name.length)`; that
// breaks for legacy / non-standard aliases (e.g. `kimi-k3` from
// pre-v0.6 data) which would produce `newName-k3` — missing the
// `/` separator and pointing at the wrong model. Use an explicit
// prefix check and fall back to a full re-prefix.
const prefix = `${name}/`;
const newAlias = alias.startsWith(prefix)
? newName + alias.slice(name.length)
: `${newName}/${alias}`;
models[newAlias] = { ...m, alias: newAlias, provider: newName };
}
}
// Clone the provider's models under the duplicate's name — COPY
// semantics: the source keeps its models (a rekey would move them).
const models = copyModelsOnProviderDuplicate(cfg.models, name, newName);
return { ...cfg, providers, models };
});
await save();
// No immediate save: the copy lives in memory only, like every other
// edit. Abandoning (back → discard) must leave the config unchanged;
// the copy persists when the user saves (Ctrl+S / footer / switch).
setSwitchMessage(t("copiedProvider", { name: `${name}-copy` }));
setTimeout(() => setSwitchMessage(null), 3000);
};
@@ -529,6 +543,14 @@ export default function App() {
default_model = null;
}
// Pool entries pointing at aliases that disappeared in this apply
// (provider renamed, models dropped) must not survive as zombies.
const removedAliases = Object.keys(cfg.models).filter(
(key) =>
cfg.models[key].provider === oldName && !(key in updatedModels)
);
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, removedAliases);
// Remember this provider's default model preference so it survives
// provider switches.
const updatedProvider = providers[provider.name];
@@ -536,7 +558,7 @@ export default function App() {
providers[provider.name] = setProviderDefaultModel(updatedProvider, default_model);
}
return { ...cfg, providers, models: updatedModels, default_model };
return { ...cfg, providers, models: updatedModels, default_model, raw_other };
});
if (provider.name !== editingProvider) {
setEditingProvider(provider.name);
@@ -744,16 +766,26 @@ export default function App() {
if (default_model === oldKey) default_model = effective.alias;
else if (effective !== model && default_model === model.alias)
default_model = effective.alias;
return { ...cfg, models, default_model };
// Pool entries keyed on the discarded alias (placeholder or
// replaced old key) must follow the rename or they dangle.
const poolAliasMap: Record<string, string> = {};
if (effective !== model) poolAliasMap[model.alias] = effective.alias;
if (oldKey && oldKey !== effective.alias)
poolAliasMap[oldKey] = effective.alias;
const raw_other = rekeySubagentPoolAliases(cfg.raw_other, poolAliasMap);
return { ...cfg, models, default_model, raw_other };
});
}}
onModelDelete={(alias) => {
updateConfig((cfg) => {
const models = { ...cfg.models };
delete models[alias];
// Pool entries keyed on the deleted alias must not survive.
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, [alias]);
return {
...cfg,
models,
raw_other,
default_model:
cfg.default_model === alias ? null : cfg.default_model,
};
+1126 -1344
View File
File diff suppressed because it is too large. Load diff
+1052 -1253
View File
File diff suppressed because it is too large. Load diff
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
copyModelsOnProviderDuplicate,
rekeyModelsOnProviderRename,
} from "./providerRename";
import type { Model } from "../types";
function model(alias: string, provider: string): Model {
return {
alias,
provider,
model: alias.slice(provider.length + 1),
max_context_size: 512000,
display_name: null,
};
}
describe("rekeyModelsOnProviderRename", () => {
it("re-keys aliases and provider when a provider is renamed (opencode-go-copy scenario)", () => {
const models = {
"opencode-go-copy/deepseek-v4-flash": model("opencode-go-copy/deepseek-v4-flash", "opencode-go-copy"),
"opencode-go-copy/deepseek-v4.1-flash": model("opencode-go-copy/deepseek-v4.1-flash", "opencode-go-copy"),
"opencode-go/longcat-2.5-preview-free": model("opencode-go/longcat-2.5-preview-free", "opencode-go"),
"other/m1": model("other/m1", "other"),
};
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "opencode-go-copy", "opencode-go");
expect(Object.keys(next).sort()).toEqual([
"opencode-go/deepseek-v4-flash",
"opencode-go/deepseek-v4.1-flash",
"opencode-go/longcat-2.5-preview-free",
"other/m1",
]);
expect(next["opencode-go/deepseek-v4-flash"].provider).toBe("opencode-go");
expect(next["opencode-go/deepseek-v4-flash"].model).toBe("deepseek-v4-flash");
expect(aliasMap).toEqual({
"opencode-go-copy/deepseek-v4-flash": "opencode-go/deepseek-v4-flash",
"opencode-go-copy/deepseek-v4.1-flash": "opencode-go/deepseek-v4.1-flash",
});
// Untouched providers stay identical.
expect(next["opencode-go/longcat-2.5-preview-free"]).toBe(models["opencode-go/longcat-2.5-preview-free"]);
expect(next["other/m1"]).toBe(models["other/m1"]);
});
it("fully re-prefixes non-standard aliases missing the old `<name>/` prefix", () => {
const models = { "legacy-k3": model("legacy-k3", "kimi-code") };
const { models: next } = rekeyModelsOnProviderRename(models, "kimi-code", "renamed");
expect(Object.keys(next)).toEqual(["renamed/legacy-k3"]);
expect(next["renamed/legacy-k3"].provider).toBe("renamed");
});
it("leaves other providers' models alone when renaming", () => {
const models = { "a/m1": model("a/m1", "a"), "b/m1": model("b/m1", "b") };
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "a", "c");
expect(Object.keys(next).sort()).toEqual(["b/m1", "c/m1"]);
expect(aliasMap).toEqual({ "a/m1": "c/m1" });
});
it("suffixes instead of overwriting when the renamed alias collides (rename into an occupied name)", () => {
// Renaming provider `a` to `b` while `b` already has b/m1 — the re-keyed
// entry must not silently swallow b's model.
const models = {
"a/m1": model("a/m1", "a"),
"b/m1": model("b/m1", "b"),
};
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "a", "b");
expect(next["b/m1"].model).toBe("m1"); // b's original survives
expect(next["b/m1-2"].provider).toBe("b"); // a's model re-keyed with suffix
expect(aliasMap).toEqual({ "a/m1": "b/m1-2" });
expect(Object.keys(next).sort()).toEqual(["b/m1", "b/m1-2"]);
});
it("suffixes when a legacy bare alias collides with the standard alias after rename", () => {
// Pre-v0.6 data: bare alias `k3` (no provider prefix) and `kimi/k3` both
// belong to `kimi`; both re-key to `x/k3`.
const models = {
k3: { ...model("k3", "kimi"), model: "k3" },
"kimi/k3": model("kimi/k3", "kimi"),
};
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "kimi", "x");
expect(Object.keys(next).sort()).toEqual(["x/k3", "x/k3-2"]);
expect(next["x/k3"]).toBeDefined();
expect(next["x/k3-2"]).toBeDefined();
// Both old keys are mapped; the pool re-key consumes the map, so every
// entry lands on a distinct target.
expect(Object.values(aliasMap).sort()).toEqual(["x/k3", "x/k3-2"]);
});
});
describe("copyModelsOnProviderDuplicate", () => {
it("clones the provider's models and KEEPS the source (copy, not move)", () => {
const models = {
"opencode-go/longcat": model("opencode-go/longcat", "opencode-go"),
"other/m1": model("other/m1", "other"),
};
const next = copyModelsOnProviderDuplicate(models, "opencode-go", "opencode-go-copy");
expect(Object.keys(next).sort()).toEqual([
"opencode-go-copy/longcat",
"opencode-go/longcat",
"other/m1",
]);
expect(next["opencode-go/longcat"].provider).toBe("opencode-go");
expect(next["opencode-go-copy/longcat"].provider).toBe("opencode-go-copy");
});
it("fully re-prefixes non-standard aliases and suffixes collisions", () => {
const models = {
"legacy-k3": { ...model("legacy-k3", "kimi"), model: "k3" },
"dupe/legacy-k3": model("dupe/legacy-k3", "other"),
};
const next = copyModelsOnProviderDuplicate(models, "kimi", "dupe");
// `dupe/legacy-k3` is taken → the clone gets a suffix.
expect(Object.keys(next).sort()).toEqual([
"dupe/legacy-k3",
"dupe/legacy-k3-2",
"legacy-k3",
]);
});
});
+79
View File
@@ -0,0 +1,79 @@
import type { Model } from "../types";
export interface ProviderRenameResult {
models: Record<string, Model>;
/** old alias → new alias (collision-deduped), for callers fixing alias references. */
aliasMap: Record<string, string>;
}
function rekeyedAlias(alias: string, oldName: string, newName: string): string {
const prefix = `${oldName}/`;
return alias.startsWith(prefix)
? newName + alias.slice(oldName.length)
: `${newName}/${alias}`;
}
/**
* Re-key a provider's model aliases after the provider is renamed so the
* `<provider>/<model>` prefix tracks the new name. Only patching
* `m.provider` (the old behaviour) left stale prefixes like
* `opencode-go-copy/...` pointing at the renamed provider — zombie aliases
* that the UI then re-saved to config.toml and SQLite forever.
*
* Collision-safe: a target alias that is already taken (renaming into an
* existing provider's name, or a legacy bare alias colliding with a standard
* `<name>/<model>` one — both map to the same new key) gets a `-2`/`-3`
* suffix instead of silently overwriting the other entry. Non-standard
* aliases without the `<oldName>/` prefix fall back to a full re-prefix.
*/
export function rekeyModelsOnProviderRename(
models: Record<string, Model>,
oldName: string,
newName: string
): ProviderRenameResult {
const next: Record<string, Model> = {};
const toRename: Array<[string, Model]> = [];
for (const [key, m] of Object.entries(models)) {
if (m.provider === oldName) toRename.push([key, m]);
else next[key] = m;
}
const aliasMap: Record<string, string> = {};
for (const [key, m] of toRename) {
const base = rekeyedAlias(key, oldName, newName);
let target = base;
let n = 2;
while (target in next) {
target = `${base}-${n}`;
n++;
}
next[target] = { ...m, alias: target, provider: newName };
aliasMap[key] = target;
}
return { models: next, aliasMap };
}
/**
* Clone a provider's models under the duplicate's name — COPY semantics:
* the source provider keeps its models (unlike rekeyModelsOnProviderRename,
* which moves them). Collisions with any existing alias get a `-2`/`-3`
* suffix.
*/
export function copyModelsOnProviderDuplicate(
models: Record<string, Model>,
oldName: string,
newName: string
): Record<string, Model> {
const next = { ...models };
for (const [key, m] of Object.entries(models)) {
if (m.provider !== oldName) continue;
const base = rekeyedAlias(key, oldName, newName);
let target = base;
let n = 2;
while (target in next) {
target = `${base}-${n}`;
n++;
}
next[target] = { ...m, alias: target, provider: newName };
}
return next;
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { purgeSubagentPoolAliases, rekeySubagentPoolAliases } from "./referenceCleanup";
const rawWithPool = {
thinking: { enabled: true },
secondary_model: {
default_model: "opencode-go/longcat-2.5-preview-free",
model: "opencode-go/longcat-2.5-preview-free",
models: {
"TokenHub/deepseek-v4.1-flash": "",
"opencode-go/longcat-2.5-preview-free": "",
"opencode-go/mimo-v2.6-flash": "",
"opencode-go-copy/deepseek-v4-flash": "route to deepseek",
},
force: false,
},
services: { moonshot_fetch: { api_key: "" } },
};
describe("rekeySubagentPoolAliases", () => {
it("re-keys pool keys and defaults when the provider is renamed", () => {
const next = rekeySubagentPoolAliases(rawWithPool, {
"opencode-go-copy/deepseek-v4-flash": "opencode-go/deepseek-v4-flash",
}) as typeof rawWithPool;
const section = next.secondary_model as Record<string, any>;
expect(Object.keys(section.models).sort()).toEqual([
"TokenHub/deepseek-v4.1-flash",
"opencode-go/deepseek-v4-flash",
"opencode-go/longcat-2.5-preview-free",
"opencode-go/mimo-v2.6-flash",
]);
// Descriptions survive the re-key.
expect(section.models["opencode-go/deepseek-v4-flash"]).toBe("route to deepseek");
// Untouched defaults stay put.
expect(section.default_model).toBe("opencode-go/longcat-2.5-preview-free");
// Unrelated sections are preserved.
expect(next.thinking).toEqual({ enabled: true });
});
it("follows the default when it referenced a renamed alias", () => {
const raw = {
secondary_model: {
default_model: "old/m1",
model: "old/m1",
models: { "old/m1": "" },
},
};
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
expect(next.secondary_model.default_model).toBe("new/m1");
expect(next.secondary_model.model).toBe("new/m1");
expect(Object.keys(next.secondary_model.models)).toEqual(["new/m1"]);
});
it("returns the same reference when nothing matches", () => {
expect(rekeySubagentPoolAliases(rawWithPool, { "a/b": "c/d" })).toBe(rawWithPool);
});
it("never injects an empty models table into the implicit single-model form", () => {
// `{ model: "old/m1" }` with no pool table: re-keying the default must
// not add `[secondary_model.models]` — an empty table is invalid for the
// engine (its default has to be a key of the table).
const raw = { secondary_model: { model: "old/m1" } };
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
expect(next.secondary_model).toEqual({ model: "new/m1" });
expect(next.secondary_model.models).toBeUndefined();
});
it("never injects a models table into the force form", () => {
const raw = { secondary_model: { force: true, default_model: "old/m1", model: "old/m1" } };
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
expect(next.secondary_model).toEqual({
force: true,
default_model: "new/m1",
model: "new/m1",
});
expect(next.secondary_model.models).toBeUndefined();
});
});
describe("purgeSubagentPoolAliases", () => {
it("removes deleted aliases and keeps the rest of the section intact", () => {
const next = purgeSubagentPoolAliases(rawWithPool, [
"opencode-go-copy/deepseek-v4-flash",
]) as typeof rawWithPool;
const section = next.secondary_model as Record<string, any>;
expect(Object.keys(section.models).sort()).toEqual([
"TokenHub/deepseek-v4.1-flash",
"opencode-go/longcat-2.5-preview-free",
"opencode-go/mimo-v2.6-flash",
]);
expect(section.default_model).toBe("opencode-go/longcat-2.5-preview-free");
expect(section.force).toBe(false);
expect(next.thinking).toEqual({ enabled: true });
});
it("falls back to the first remaining key when the default was purged", () => {
const next = purgeSubagentPoolAliases(rawWithPool, [
"opencode-go/longcat-2.5-preview-free",
]) as any;
const section = next.secondary_model;
expect(section.models).not.toHaveProperty("opencode-go/longcat-2.5-preview-free");
expect(section.default_model).toBe("TokenHub/deepseek-v4.1-flash");
expect(section.model).toBe("TokenHub/deepseek-v4.1-flash");
});
it("drops the whole section when the pool becomes empty", () => {
const raw = {
thinking: { enabled: true },
secondary_model: {
default_model: "p/m",
model: "p/m",
models: { "p/m": "" },
},
};
const next = purgeSubagentPoolAliases(raw, ["p/m"]) as any;
expect(next).not.toHaveProperty("secondary_model");
expect(next.thinking).toEqual({ enabled: true });
});
it("returns the same reference when no pool entry is affected", () => {
expect(purgeSubagentPoolAliases(rawWithPool, ["other/m9"])).toBe(rawWithPool);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Keep `[secondary_model]` (the subagent model pool in `raw_other`) free of
* dangling model-alias references.
*
* Model aliases are deleted or re-keyed in several places (provider delete,
* single-model delete, provider rename). The pool stores aliases as its
* `models` table keys and as the `default_model` / `model` values — none of
* those may outlive the alias they point at, or the engine resolves the pool
* entry to nothing and the zombie key lingers in config.toml forever.
*/
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/**
* Re-key pool aliases after a provider rename: `aliasMap` maps old alias →
* new alias. Pool keys keep their descriptions; `default_model` / `model`
* follow when they referenced a renamed alias. Returns the input reference
* when nothing changed.
*/
export function rekeySubagentPoolAliases(
rawOther: unknown,
aliasMap: Record<string, string>
): unknown {
if (Object.keys(aliasMap).length === 0) return rawOther;
const root = asRecord(rawOther);
const section = asRecord(root.secondary_model);
if (Object.keys(section).length === 0) return rawOther;
const models = asRecord(section.models);
let changed = false;
const nextModels: Record<string, unknown> = {};
for (const [k, v] of Object.entries(models)) {
const mapped = aliasMap[k];
if (mapped && mapped !== k) {
nextModels[mapped] = v;
changed = true;
} else {
nextModels[k] = v;
}
}
let nextDefault = section.default_model;
let nextModel = section.model;
if (typeof nextDefault === "string" && aliasMap[nextDefault]) {
nextDefault = aliasMap[nextDefault];
changed = true;
}
if (typeof nextModel === "string" && aliasMap[nextModel]) {
nextModel = aliasMap[nextModel];
changed = true;
}
if (!changed) return rawOther;
// Only write a `models` table when the section had one — the implicit
// single-model form (`{ model: "..." }`) and the force form must not gain
// an empty `[secondary_model.models]` table, which the engine can never
// validate (its default must be a key of the table).
const hadModelsTable = section.models !== undefined;
const nextSection: Record<string, unknown> = { ...section };
if (hadModelsTable || Object.keys(nextModels).length > 0) {
nextSection.models = nextModels;
}
if (nextDefault !== section.default_model) nextSection.default_model = nextDefault;
if (nextModel !== section.model) nextSection.model = nextModel;
return { ...root, secondary_model: nextSection };
}
/**
* Remove pool entries pointing at deleted aliases. When the pool's default
* referenced a deleted alias it falls back to the first remaining key; when
* the pool becomes empty the whole `[secondary_model]` section is dropped
* (an empty pool is invalid for the engine — subagents then inherit the
* primary model). Returns the input reference when nothing changed.
*/
export function purgeSubagentPoolAliases(
rawOther: unknown,
removedAliases: Iterable<string>
): unknown {
const removed = new Set(removedAliases);
if (removed.size === 0) return rawOther;
const root = asRecord(rawOther);
const section = asRecord(root.secondary_model);
if (Object.keys(section).length === 0) return rawOther;
const models = asRecord(section.models);
const kept: Record<string, unknown> = {};
let removedAny = false;
for (const [k, v] of Object.entries(models)) {
if (removed.has(k)) {
removedAny = true;
} else {
kept[k] = v;
}
}
const defaultGone =
(typeof section.default_model === "string" && removed.has(section.default_model)) ||
(typeof section.model === "string" && removed.has(section.model));
if (!removedAny && !defaultGone) return rawOther;
if (Object.keys(kept).length === 0) {
const nextRoot: Record<string, unknown> = { ...root };
delete nextRoot.secondary_model;
return nextRoot;
}
const nextSection: Record<string, unknown> = { ...section, models: kept };
if (defaultGone) {
const fallback = Object.keys(kept)[0];
nextSection.default_model = fallback;
nextSection.model = fallback;
}
return { ...root, secondary_model: nextSection };
}
+2 -2
View File
@@ -50,8 +50,8 @@
"url": "https://billowliu2.github.io/KimiSwitch/",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows 10, Windows 11, macOS, Linux",
"softwareVersion": "0.8.1",
"dateModified": "2026-09-03",
"softwareVersion": "0.8.2",
"dateModified": "2026-10-09",
"description": "桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
"softwareHelp": "https://github.com/billowliu2/KimiSwitch",
+32 -12
View File
@@ -16,10 +16,10 @@ const zh = {
performance: "性能",
changelog: "更新日志",
download: "下载",
downloadBtn: "下载 v0.8.0",
downloadBtn: "下载 v0.8.2",
},
hero: {
badge: "v0.8.0 · 开源 MIT · Windows / macOS / Linux",
badge: "v0.8.2 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的",
titleAccent: "AI 供应商",
titleAfter: "",
@@ -29,7 +29,7 @@ const zh = {
source: "查看源码",
stats: [
{ value: "22", label: "预设供应商" },
{ value: "8385", label: "模型价格库" },
{ value: "8467", label: "模型价格库" },
{ value: "MIT", label: "开源协议" },
],
},
@@ -76,7 +76,7 @@ const zh = {
{
id: "model-discovery",
title: "模型自动发现",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,385 个模型、226 家供应商)。",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,467 个模型、225 家供应商)。",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -89,7 +89,7 @@ const zh = {
},
showcase: {
title: "界面演示",
subtitle: "以下截图取自 v0.8.0 实机运行。",
subtitle: "以下截图取自 v0.8.2 实机运行。",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -140,6 +140,16 @@ const zh = {
syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录",
entries: [
{
version: "v0.8.2",
date: "2026-10-09",
items: [
"仪表盘重启后首次打开从约 25 秒降到 1 秒内:用量解析快照持久化到本地,启动直读并在后台校验,有变化自动无感更新(6.4 万条记录实测)",
"切换时间范围几乎瞬时:热力图 / 模型总览 / 各范围汇总按记录集缓存;去除原「全时段」聚合的整表无用遍历",
"界面不再闪加载态:切范围与重新进入页面先呈现上次数据再后台静默刷新;供应商卡片墙由 N 次配置全量加载降为 1 次",
"models.dev 快照同步至 2026-10-09(8,467 模型 / 225 供应商)",
],
},
{
version: "v0.8.1",
date: "2026-10-04",
@@ -348,7 +358,7 @@ const zh = {
},
download: {
title: "下载 Kimi Switch",
subtitle: "当前版本 v0.8.0 · Windows / macOS / Linux 三平台已发布",
subtitle: "当前版本 v0.8.2 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
ready: "已发布",
wip: "开发中",
@@ -417,10 +427,10 @@ const en: Dict = {
performance: "Performance",
changelog: "Changelog",
download: "Download",
downloadBtn: "Download v0.8.0",
downloadBtn: "Download v0.8.2",
},
hero: {
badge: "v0.8.0 · Open source MIT · Windows / macOS / Linux",
badge: "v0.8.2 · Open source MIT · Windows / macOS / Linux",
titleBefore: "One app for all your ",
titleAccent: "AI providers",
titleAfter: "",
@@ -430,7 +440,7 @@ const en: Dict = {
source: "View source",
stats: [
{ value: "22", label: "Provider presets" },
{ value: "8385", label: "Model price DB" },
{ value: "8467", label: "Model price DB" },
{ value: "MIT", label: "License" },
],
},
@@ -477,7 +487,7 @@ const en: Dict = {
{
id: "model-discovery",
title: "Model discovery",
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (8,385 models, 226 providers).",
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (8,467 models, 225 providers).",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -490,7 +500,7 @@ const en: Dict = {
},
showcase: {
title: "Screenshots",
subtitle: "All screenshots below are taken from the running v0.8.0 build.",
subtitle: "All screenshots below are taken from the running v0.8.2 build.",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -541,6 +551,16 @@ const en: Dict = {
syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes",
entries: [
{
version: "v0.8.2",
date: "2026-10-09",
items: [
"First dashboard open after a restart drops from ~25s to under 1s: the usage snapshot is persisted locally and read straight at startup, then verified in the background with silent updates when things changed (measured at 64k records)",
"Switching the time range is near-instant: heatmap / model overview / per-range totals are cached per record set, and the wasteful full-table pass behind the 'all time' aggregate is gone",
"No more loading flicker: switching ranges or reopening the page shows the previous data first and revalidates silently; the provider card wall loads config once instead of once per card",
"models.dev snapshot synced to 2026-10-09 (8,467 models / 225 providers)",
],
},
{
version: "v0.8.1",
date: "2026-10-04",
@@ -748,7 +768,7 @@ const en: Dict = {
},
download: {
title: "Download Kimi Switch",
subtitle: "Current version v0.8.0 · Windows, macOS and Linux now released",
subtitle: "Current version v0.8.2 · Windows, macOS and Linux now released",
autoUpdate: "Update checks run at startup and every 8 hours; Settings also has a manual check.",
ready: "Available",
wip: "In development",
+2 -2
View File
@@ -8,13 +8,13 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo,
};
const VERSION = "0.8.1";
const VERSION = "0.8.2";
const GITHUB = "https://github.com/billowliu2/KimiSwitch";
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
/** Per-platform download assets for the current release (names match the CI
* release workflow exactly — verified against the published v0.7.12 assets:
* release workflow exactly — verified against the published v0.8.2 assets:
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform,
* except the rpm which uses `Kimi.Switch-<version>-1` (hyphens).
* Filenames interpolate VERSION, so a release bump only changes the