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)
This commit is contained in:
KimiSwitch Dev committed 2026-10-10 19:50:03 +08:00
1 parent 317562781c
commit 35788c2783
20 files changed
+4217 -3893

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 };
}