Compare commits

..
21 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
KimiSwitch Dev 6a8331d28b chore(release): bump to v0.8.2 — 用量统计性能专项 + models.dev 快照同步
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-10-09 16:44:10 +08:00
KimiSwitch Dev c08de0ebb0 chore(data): 同步 models-dev 2026-10-09(8,467 模型 / 225 供应商) 2026-10-09 16:44:09 +08:00
KimiSwitch Dev ded166c4c8 perf(dashboard): 用量统计从秒级降到感知不到 — SQLite 连接单例、配置读取缓存、聚合结果缓存、快照持久化 + 后台校验
功能与数据口径零变化(用量数字、字段、IPC 签名、导出格式均与 v0.8.1 一致)。

- db.rs:新增进程级连接单例(含迁移只跑一次),卡片墙场景由 200+ 次「开连接 + 7 条 DDL」降为 1 次;
  新增写路径代际计数器供各处读缓存判失效
- commands.rs:load_agent_config_command 结果缓存(db 代际 + config.toml 文件戳双重校验),
  N 张供应商卡片由 N 次全量加载(config.toml 解析 + SQLite 全量读 + 逐供应商 settings 读)降为 1 次
- dashboard.rs:
  * build_all_models 单遍轻量聚合,替换原先为取一张模型表而白算日/明细/供应商三份数据的整表遍历
  * SUMMARY_CACHE 按记录集 Arc 身份 + 日期 + 分钟桶缓存热力图/模型总览/各范围汇总,切范围来回近乎零成本
  * 归档会话列表与合成记录按代际 + Arc 身份缓存(合并后的 Arc 保持稳定,否则上层缓存永不命中)
  * 解析结果持久化到 ~/.kimi-switch/scan-cache-<hash>.json:重启后首次 get_summary 直读快照
    (实测 6.4 万条记录 / 1.03 GB:22~27s → 0.85s),随后后台线程全量校验,
    有变化时经 dashboard://records-updated 事件推送,前端无感刷新(先快后准);
    快照损坏/指纹不符静默降级为全量扫描,refresh=true 语义不变
- useDashboard.ts:stale-while-revalidate,切范围/重进页面先呈现旧数据再静默刷新,不再闪加载态;
  订阅上述事件做无感更新,保留原世代号防竞态

测试:Rust 193 项(+16)、前端 130 项全绿
2026-10-09 16:44:03 +08:00
KimiSwitch Dev 803c346854 fix(dashboard): useDashboard 加请求世代校验 — 快速连续切换时间范围时并发 get_summary 响应可能乱序返回导致标签与数据错位;参照 useUsageQuery 的 genRef 模式丢弃过期响应(data/loadStats/error/loading 全部受保护),卸载时使世代失效 2026-10-08 00:28:39 +08:00
KimiSwitch Dev f9f9af0fc9 perf(dashboard): 扫描输入缓存 + 归档合并零浪费 + 锁加固 — resolve_scan_inputs 结果按 config.toml/备份文件 (mtime,len) stat 指纹缓存(命中跳过 15 个配置文件 TOML 解析与 SQLite 读写,set_setting_pub 仅在值变化时写);归档合并先合成后克隆,合成结果为空时原 Arc 直接返回(跳过 6.3 万条 clone+sort,已排序输入的稳定排序为恒等变换);SCAN_CACHE/SCAN_INPUTS_CACHE 改 into_inner 防 Mutex 中毒;新增 4 个测试(Arc::ptr_eq 正反例、配置失效链、跨 home 隔离)并修复测试间 KIMI_SWITCH_DB_PATH 全局 env 并行污染;聚合口径零变化 2026-10-08 00:28:22 +08:00
KimiSwitch Dev 9f51ea5e84 perf(dashboard): 加载统计去掉对整个返回值的 JSON.stringify(数 MB 大对象在 webview 主线程序列化造成卡顿),loadStats 仅保留耗时 ms,中英文案同步 2026-10-07 23:35:30 +08:00
KimiSwitch Dev d190a55bf2 perf(dashboard): 用量统计性能优化 — get_summary/get_day_detail 改异步执行不再阻塞 UI;range_totals 单遍统计(每次调用 7 次全量聚合 → 2 次);扫描缓存改 Arc 共享消除整表深拷贝;wire.jsonl 按 (mtime,len) 逐文件增量解析替换 8s TTL 整表缓存(920MB/6.3万条实测:冷扫描 ~17.7s → ~1.0s,任意间隔切换范围 ~1.5s 且 UI 全程可交互);新增单遍等价性与增量行为测试(含混合增删改 vs 强制全量重扫的全字段指纹比对),聚合口径零变化 2026-10-07 23:35:17 +08:00
KimiSwitch Dev ac82135dee feat(settings): 适配 kimi-code 新增 auto_session_title / repeat_breaker 顶层开关;最大输出未匹配回填 131072;watch 默认值修正 — bump to v0.8.1
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
包含未发布的 ef6749b / 24cb04b / 977f433 三个提交,官网同步更新至 v0.8.1
2026-10-04 00:59:59 +08:00
KimiSwitch Dev ef6749b208 feat(models): 最大输出未匹配 models.dev 时回填默认值 131072(打开编辑页一次性回填 + 获取模型列表预填同口径) 2026-10-04 00:51:50 +08:00
KimiSwitch Dev 24cb04b98f style(i18n): 「最大输出 Token」列名精简为「最大输出」 2026-10-04 00:46:52 +08:00
KimiSwitch Dev 977f433bb2 fix(settings): 适配 kimi-code 近期变更 — [watch] 默认值修正为开启(上游 #4015 翻回默认开);新增顶层开关 auto_session_title(#3962)与 repeat_breaker(#3995,默认开、false 显式写入/true 删键);最大输出 Token 文案修正为「留空则不发送输出上限」(上游 #4091 未配置即省略 max_tokens) 2026-10-04 00:26:05 +08:00
KimiSwitch Dev 3c3c18925d feat(models): 模型映射新增最大输出 Token 列(max_output_size 直写 config.toml,models.dev 参考值一键填入/空值自动补全),移除无效的声明支持 1M 复选框;思考等级顺序修正为 low/medium/high/xhigh/max 且标签全英文 — bump to v0.8.0
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-10-03 23:28:36 +08:00
KimiSwitch Dev 30371a1ba6 feat(website): 官网更新至 v0.8.0(下载版本号/截图说明/JSON-LD/模型库数据 8,385·226,更新日志补 v0.8.0 与 v0.7.23 条目) 2026-10-03 23:28:15 +08:00
KimiSwitch Dev 4d87183768 chore(data): 同步 models-dev 快照至 2026-10-03(8,385 模型 / 226 供应商,新增 output 字段) 2026-10-03 23:28:04 +08:00
KimiSwitch Dev 88683b331e fix(release): GitHub workflow 兼容 docs/release-notes/release-notes-<tag>.md 命名 2026-10-02 19:54:56 +08:00
KimiSwitch Dev 26cb88ad0e feat(thinking): 思考等级扩展五档(Max/XHigh)+ 按默认模型 support_efforts 能力感知;删除过时的 max→high 迁移;Segmented 支持逐选项禁用/tooltip — bump to v0.7.23
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-10-02 13:00:35 +08:00
KimiSwitch Dev 50f59cac79 fix(release): publish-gitea.sh 兼容 docs/release-notes/release-notes-<tag>.md 命名(此前 Release 正文回退为 tag 名) 2026-10-02 13:00:33 +08:00
KimiSwitch Dev ecc98fd8cd chore(data): 同步 models-dev 快照至 2026-10-02(8,359 模型 / 225 供应商) 2026-10-02 13:00:32 +08:00
KimiSwitch Dev 13e15657af fix(usage): 列表支持显式模板查询(Sub2API/NewAPI)——对齐后端绕过 usage_kinds 的口径 — bump to v0.7.22
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-29 10:42:13 +08:00
54 changed files with 45096 additions and 11052 deletions

No files matched your search

+1
View File
@@ -74,6 +74,7 @@ jobs:
shell: bash
run: |
FILE="docs/release-notes/${{ github.ref_name }}.md"
[ -f "$FILE" ] || FILE="docs/release-notes/release-notes-${{ github.ref_name }}.md"
[ -f "$FILE" ] || FILE="release-notes-${{ github.ref_name }}.md"
{
echo 'body<<RELEASE_BODY_EOF'
+47
View File
@@ -6,6 +6,53 @@
---
## 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 一致)。
- **仪表盘重启后首次打开:约 22~27 秒 → 约 0.85 秒**(实测 6.4 万条记录 / 1.03 GB)。用量解析结果持久化到 `~/.kimi-switch/scan-cache-*.json`,启动直读快照渲染,后台线程全量校验,有变化时事件推送无感更新(先快后准)
- **切换时间范围几乎瞬时**:热力图 / 模型总览 / 各范围汇总按记录集缓存;并去掉原「全时段」聚合的整表无用遍历
- **界面不再闪加载态**:切范围与重进页面先呈现旧数据再静默刷新(stale-while-revalidate)
- **供应商卡片墙变快**:供应商配置读取缓存(含外部 CLI 改动失效路径),N 张卡片由 N 次全量加载降为 1 次
- **SQLite 连接复用**:连接与迁移改为每库一次,卡片墙场景由 200+ 次降为 1 次
- **models.dev 快照更新**:同步至 2026-10-09(8,467 模型 / 225 供应商)
## v0.8.1 (2026-10-04)
- **全局配置新增两个顶层开关**:自动生成会话标题(`auto_session_title`)与重复调用拦截(`repeat_breaker`),默认开启,关闭显式写 `false`、开启删键跟随上游默认(适配 kimi-code #3962 / #3995)
- **最大输出未匹配 models.dev 时回填默认值 131072**(128K 兜底,编辑页回填与获取模型列表预填同口径)
- **修复 `[watch] enabled` 默认值显示**:上游 #4015 已翻回默认开启,设置页同步修正
- **「最大输出 Token」列名精简为「最大输出」**,描述对齐上游 #4091(留空即不发送输出上限)
## v0.8.0 (2026-10-03)
- **模型映射新增「最大输出 Token」列**:逐模型设置 `max_output_size`(写入 config.toml,留空用上游默认),models.dev 有 output 上限时显示可点击参考值一键填入;获取模型列表时自动预填
- **models.dev 快照新增 output 上限字段**:同步至 2026-10-03(8,385 模型 / 226 供应商,output 覆盖 97.4%);应用内同步(Rust 侧)同步支持,避免同步后字段丢失
- **移除无效的「声明支持 1M」复选框**:该控件读时由上下文长度派生、写时不持久化,勾选从不生效,由「最大输出 Token」列取代
## v0.7.23 (2026-09-30)
- **思考等级五档 + 能力感知**:全局配置的思考等级扩展为低 / 中 / 高 / Max / XHigh,按当前默认模型声明的 `support_efforts` 自动置灰不支持档位并提示原因;models.dev 标记不支持思考的模型整体禁用思考区;未声明档位的模型保留可选并提示"上游可能拒绝"
- **修正过时适配**:删除 max→high 读取迁移(kimi-code 2.1.1 中 max 为合法档位),存储的 max 与任意手写档位值原样保留显示;子代理页继承档位显示同步修正
## v0.7.22 (2026-09-24)
- **修复 Sub2API / NewAPI 模板查询不显示**:为无预设识别类型的自定义供应商配置 Sub2API(或 NewAPI)模板后,弹窗测试可查但供应商列表始终不显示——前端列表的查询/显示门槛只看预设 `usageKinds`,显式模板被漏掉;现在与后端口径对齐(模板查询绕过 usage_kinds),列表正常显示余额 / 配额
## v0.7.21 (2026-09-24)
- **models.dev 在线同步**:高级设置新增「模型参考数据」卡片——一键在线拉取 models.dev 最新快照(上下文长度 / 能力标记 / 单价),前端与仪表盘计价即时生效,无需等待新版本;支持一键恢复随版本内置数据;显示当前数据来源与版本(同步 / 内置 + 日期 + 模型/供应商数)
+2 -2
View File
@@ -79,7 +79,7 @@
| **连通性测试** | 实测 `base_url` 延迟,绿/橙/红彩色气泡,6 秒自动消失 |
| **重复供应商** | 一键深拷贝供应商 + 全部模型,key 改 `xxx-copy` |
| **图标按钮操作** | 启用 / 编辑 / 复制 / 测试连通 / 删除 全图标化(lucide-react) |
| **模型映射** | 别名("provider/model" 形式)↔ 实际请求模型 ID,自定义显示名、上下文长度、1M 上下文声明、能力 |
| **模型映射** | 别名("provider/model" 形式)↔ 实际请求模型 ID,自定义显示名、上下文长度、最大输出 Token(`max_output_size`)、能力(仅 Kimi Code) |
| **自动上下文** | 拉取模型时 **API 返回 > models.dev ref > 正则兜底** 三级优先级自动适配 |
| **能力自动推导** | `image_in / video_in / tool_use` 全部由 models.dev 推得;UI 仅暴露 `thinking` 一个手动开关 |
| **全局设置** | `[thinking]` 表完整支持(enabled / effort / keep),仅 Kimi Code 生效 |
@@ -111,7 +111,7 @@
![编辑供应商-模型映射](docs/screenshots/provider-model-mapping.png)
一张表管理全部模型映射:显示名、实际请求模型、上下文长度、1M 上下文声明、能力(仅"思考")、设为默认、删除。
一张表管理全部模型映射:显示名、实际请求模型、上下文长度、最大输出 Token(`max_output_size`,可点参考值填 models.dev 上限)、能力(仅"思考")、设为默认、删除(后两项仅 Kimi Code)。
**用量仪表盘**
+3 -3
View File
@@ -73,7 +73,7 @@ Build instructions: [`docs/BUILD.md`](./docs/BUILD.md).
| **Connectivity test** | Real `base_url` latency, coloured bubble (green / orange / red), auto-dismiss in 6 seconds |
| **Duplicate provider** | Deep-copy a provider + all its models; key auto-suffixed to `xxx-copy` |
| **Iconified actions** | Activate / Edit / Duplicate / Test Connectivity / Delete via lucide-react |
| **Model mapping** | Alias (`"provider/model"`) ↔ real model ID, with display name, context size, 1M-context flag, and capabilities |
| **Model mapping** | Alias (`"provider/model"`) ↔ real model ID, with display name, context size, max output tokens (`max_output_size`), and capabilities (Kimi Code only) |
| **Auto context size** | On model fetch: **API response > models.dev ref > regex fallback** — three-tier priority |
| **Auto capabilities** | `image_in / video_in / tool_use` all derived from models.dev; UI only exposes `thinking` as a manual toggle |
| **Global settings** | Full `[thinking]` table (enabled / effort / keep); Kimi Code only |
@@ -105,7 +105,7 @@ Provider name, notes, official URL, managed-provider toggle, API format, API key
![Edit provider — model mapping](docs/screenshots/provider-model-mapping.png)
A single table for all model mappings: display name, real model ID, context length, 1M-context flag, capability (thinking only), default toggle, delete.
A single table for all model mappings: display name, real model ID, context length, max output tokens (`max_output_size`, with a click-to-fill models.dev reference), capability (thinking only), default toggle, delete (the last two are Kimi Code only).
**Usage dashboard**
@@ -175,7 +175,7 @@ Per-workspace Kimi Code session browsing, active / archived / all filters; strea
- **`config.toml` is the authoritative source for Kimi Code**: all providers and models are always written in full; `default_model` selects the active one (matching the CLI's native `/provider` behaviour). Switching only changes `default_model`; newly added providers are auto-promoted to the top of the list and never get overwritten
- **SQLite holds Kimi Switch-private metadata only**: notes, official URLs, per-agent remembered default model (the `settings` table), ordering. Theme / language / last-update-check live in frontend `localStorage` (WebView2), not under `~/.kimi-switch`. It acts as a fallback when `config.toml` is incomplete
- **`raw_other` passes unknown fields through untouched**, including `[oauth]` blocks — round-trips never drop fields
- **models.dev snapshot**: derived from `https://models.dev/api.json`, cached to a local JSON; `capabilitiesFromRef` derives `thinking / image_in / video_in / tool_use`, `getModelRef` derives `max_context_size / display_name`
- **models.dev snapshot**: derived from `https://models.dev/api.json`, cached to a local JSON; `capabilitiesFromRef` derives `thinking / image_in / video_in / tool_use`, `getModelRef` derives `max_context_size / display_name / max output tokens`
- **Override env vars**: `KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` override the Kimi Code / Pi dirs; Kimi Switch's own data dir is fixed at `~/.kimi-switch` (no env override yet). See [Data Storage Locations](#data-storage-locations)
## Feature Details
+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,9 @@
# KimiSwitch v0.7.22
## 修复
- **Sub2API / NewAPI 模板查询在供应商列表不显示**:为没有预设识别类型的自定义供应商(如自家中转站)配置「Sub2API 中转站」或「NewAPI 中转站」模板后,配置弹窗里「测试查询」正常返回余额,但回到供应商列表却始终空白。根因:列表的查询与显示门槛只认预设注入的 `usageKinds`,而后端模板查询本就绕过 `usageKinds` 直接执行——前端口径与后端不一致。现在二者对齐,显式模板的供应商在列表正常显示余额 / 配额窗口、支持自动查询间隔与预警阈值。
## 备注
- 仅前端修复(`useUsageQuery` 支持判定 + 卡片渲染门槛),后端查询逻辑无变化;已补 3 个单测覆盖支持判定。
@@ -0,0 +1,18 @@
# KimiSwitch v0.7.23
## 新功能
- **思考等级五档 + 能力感知**:全局配置的思考等级由「低 / 中 / 高」三档扩展为「低 / 中 / 高 / Max / XHigh」五档,并按当前默认模型的实际能力自适应——
- 模型在 config.toml 声明了 `support_efforts` 时,不支持的档位自动置灰并给出原因提示(如 deepseek-v4.1-flash 仅支持「高」)
- models.dev 标记不支持思考(reasoning=false)的模型,整个思考等级区禁用并给出警告
- 未声明档位的模型保留全部可选,Max / XHigh 附带「上游可能拒绝此档位」提示
- 等级行下方新增说明:实际生效档位取决于当前默认模型
## 修复
- **max 档位被误显示为「高」**:此前代码基于过时信息("上游已移除 max 档位")把存储的 `effort = "max"` 读取时强制改为 `high`;实测 kimi-code 2.1.1 中 max 为合法档位(如 kimi-for-coding 默认即为 max),现已原样保留与显示。手写的任意档位值(上游本就是自由字符串)也会作为额外可选项正常显示,不再被丢弃
- **子代理设置页**:继承档位显示同步修正,max / xhigh 正常显示,未知值按原样展示
## 备注
- 仅前端改动(新增 `thinking-efforts` 能力判定模块 + `Segmented` 控件逐选项禁用/tooltip),无 Rust 变更;新增 14 个单测覆盖三层能力判定规则,全量 117 个测试通过
@@ -0,0 +1,14 @@
# KimiSwitch v0.8.0
## 新功能
- **模型映射表新增「最大输出 Token」列**(Kimi Code):为每个模型单独设置 `max_output_size`,写入 config.toml 的 `[models."<alias>"]`,留空则用上游默认值。models.dev 收录了该模型的输出上限时,输入框下方显示可点击的参考值(如 `参考 128000`),一键填入;「获取模型列表」添加模型时也会自动预填参考值
- **models.dev 快照新增 output 上限字段**:内置快照同步至 2026-10-03(8,385 模型 / 226 供应商,97.4% 模型带 output 上限);应用内「同步 models.dev」(Rust 侧)同步支持该字段,同步后参考值不丢失
## 修复
- **移除无效的「声明支持 1M」复选框**(Kimi Code):该控件在读取时由上下文长度派生、写入时不持久化,勾选永远不会生效,已由「最大输出 Token」列取代
## 备注
- 前端 + 抓取脚本 + Rust 快照构建逻辑;新增 `max_output_size` 读写纯函数 9 个单测 + config.toml 全链路往返测试,全量测试通过(前端 126 / Rust 162)
@@ -0,0 +1,17 @@
# KimiSwitch v0.8.1
## 新功能
- **全局配置新增两个顶层开关**(适配 kimi-code 近期版本):
- **自动生成会话标题**(`auto_session_title`,上游 #3962):允许客户端自动生成会话标题,默认开启;关闭后显式写入 `false`,开启即删键跟随上游默认
- **重复调用拦截**(`repeat_breaker`,上游 #3995):连续重复相同工具调用时提醒并强制停止,默认开启;可被环境变量 `KIMI_CODE_REPEAT_BREAKER` 覆盖
- **最大输出未匹配时回填默认值 131072**(Kimi Code):打开供应商编辑页自动回填时,models.dev 未收录的模型按 128K(131072)兜底;「获取模型列表」新增模型同口径预填
## 修复
- **`[watch] enabled` 默认值修正为开启**:kimi-code #4015 已把文件监听默认值翻回开启(2.0.2 曾短暂默认关闭),设置页的开关显示默认值同步修正
- **「最大输出 Token」列名精简为「最大输出」**;列描述修正为「留空则不发送输出上限」(对齐上游 #4091:未显式配置时不发送 `max_tokens`,避免严格服务栈如 bare vLLM 报 400)
## 备注
- 前端 130 测试 / Rust 162 测试全量通过
@@ -0,0 +1,21 @@
# KimiSwitch v0.8.2
本版为**性能专项**:把主要操作的等待时间从「秒级」压到「感知不到」,功能与数据口径完全不变。
## 性能优化
- **仪表盘重启后首次打开:约 22~27 秒 → 约 0.85 秒**(实测 1065 个 `wire.jsonl` / 1.03 GB / 6.4 万条记录)。用量解析结果持久化到本地快照(`~/.kimi-switch/scan-cache-*.json`),启动时直接读快照渲染,后台线程同时做全量校验;校验发现新记录时自动推送事件、无感更新数字(先快后准,无需手动刷新)
- **切换时间范围几乎瞬时**:热力图、模型总览、各范围汇总结果按记录集与日期缓存,来回切换不再重复全量聚合;未命中时也不再做无用的整表遍历(原「全时段」统计会为取一张模型表而白算日/明细/供应商三份数据)
- **界面不再闪加载态**:仪表盘切范围与重新进入页面时先呈现上次数据再后台静默刷新,不再出现数据已经拿过却先显示骨架屏的情况
- **供应商卡片墙显著变快**:供应商配置读取结果加缓存(含敏感字段写入与外部 CLI 改动两条失效路径),N 张卡片从 N 次全量读库 + 配置解析降为 1 次
- **SQLite 连接复用**:库连接与建表迁移从「每次调用各做一遍」改为每库一次,卡片墙场景下由 200+ 次降为 1 次
## 其他
- **models.dev 快照更新**:同步至 2026-10-09(8,467 模型 / 225 供应商),价格与上下文长度随之刷新
## 备注
- 数据口径与功能行为与 v0.8.1 完全一致:用量数字、字段、IPC 接口、导出格式均无变化;「强制刷新」仍会忽略一切缓存重新全量解析
- 测试全量通过:Rust 193 项(新增 16 项,覆盖缓存失效、磁盘快照往返等价、损坏快照降级等)、前端 130 项
- 首次使用本版本时会重建一次磁盘快照(沿用原有全量扫描耗时),此后每次启动均为快速加载
@@ -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.7.21",
"version": "0.8.3-rc1",
"type": "module",
"scripts": {
"dev": "vite",
+15769 -3378
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -133,6 +133,11 @@ async function main() {
if (typeof m.limit?.context === "number" && m.limit.context > 0) {
entry.context = m.limit.context;
}
// Max output tokens — the reference value behind the max_output_size
// column (model mapping table).
if (typeof m.limit?.output === "number" && m.limit.output > 0) {
entry.output = m.limit.output;
}
if (m.reasoning === true) entry.reasoning = true;
if (m.tool_call === true) entry.tool_call = true;
if (m.structured_output === true) entry.structured_output = true;
+1
View File
@@ -17,6 +17,7 @@ TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
shift
NOTES_FILE="docs/release-notes/${TAG}.md"
[ -f "$NOTES_FILE" ] || NOTES_FILE="docs/release-notes/release-notes-${TAG}.md"
[ -f "$NOTES_FILE" ] || NOTES_FILE="release-notes-${TAG}.md"
json_escape() {
python - "$1" <<'PYEOF'
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.21"
version = "0.8.3-rc1"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.21"
version = "0.8.3-rc1"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+350 -2
View File
@@ -6,7 +6,7 @@ use indexmap::IndexMap;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, UNIX_EPOCH};
use tauri::Manager;
use tauri_plugin_opener::OpenerExt;
@@ -79,8 +79,61 @@ fn load_pi_native_config() -> Result<Config, String> {
Ok(config)
}
/// Cached `load_agent_config_command` results, keyed by agent. The full load
/// parses config.toml, reads all of SQLite and runs `merge_usage_kinds` (two
/// settings reads per provider); a usage-card wall triggers one load per
/// provider card, so caching turns N loads into one. Entries are validated
/// against the db generation counter (bumped by every write path in db.rs)
/// plus the config.toml file stamp (catches edits made outside the app, e.g.
/// the CLI's /provider command).
static CONFIG_CACHE: Mutex<Option<HashMap<String, (u64, Option<(u64, u64)>, Config)>>> =
Mutex::new(None);
/// (mtime_ms, len) of the authoritative on-disk config file for the agent, or
/// None when the file is absent / the agent has no authoritative file (Pi is
/// SQLite-first, so the db generation alone covers it).
fn config_file_stamp(agent: &Agent) -> Option<(u64, u64)> {
match agent {
Agent::KimiCode => {
let md = std::fs::metadata(crate::kimi_code_io::kimi_code_config_path()).ok()?;
let mtime_ms = md
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_millis() as u64;
Some((mtime_ms, md.len()))
}
Agent::Pi => None,
}
}
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
let stamp = config_file_stamp(&agent);
let gen = db::generation();
let key = agent.as_str().to_string();
{
let guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(map) = guard.as_ref() {
if let Some((cached_gen, cached_stamp, config)) = map.get(&key) {
if *cached_gen == gen && *cached_stamp == stamp {
return Ok(config.clone());
}
}
}
}
let config = load_agent_config_uncached(agent)?;
let mut guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
guard
.get_or_insert_with(HashMap::new)
.insert(key, (gen, stamp, config.clone()));
Ok(config)
}
fn load_agent_config_uncached(agent: Agent) -> Result<Config, String> {
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
let db_config = db::load_config(&agent).ok();
@@ -122,11 +175,18 @@ pub fn load_agent_config_command(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());
}
}
@@ -177,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(())
}
@@ -2314,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("{}"));
});
}
}
+2268 -117
View File
File diff suppressed because it is too large. Load diff
+100 -7
View File
@@ -6,6 +6,8 @@
//! a provider.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use anyhow::Context;
use indexmap::IndexMap;
@@ -17,6 +19,51 @@ use crate::models::{Agent, Config, Model, Provider, ProviderType};
pub type DbResult<T> = anyhow::Result<T>;
/// Process-wide cached connection, keyed by the resolved db path. Opening a
/// SQLite connection plus running the DDL migrations costs several ms; before
/// this cache every public helper paid that cost on every call (a usage-card
/// wall could open 200+ connections per refresh). `Connection` is `Send` but
/// not `Sync`, so a `Mutex` is the correct `static` wrapper — all db access is
/// serialized, which is fine for a single-user desktop app.
static DB_CONN: Mutex<Option<(PathBuf, Connection)>> = Mutex::new(None);
/// Monotonic generation counter, bumped by every write path. Read-side caches
/// (config cache, archived-session cache) key on this to detect staleness.
static DB_GEN: AtomicU64 = AtomicU64::new(0);
pub(crate) fn generation() -> u64 {
DB_GEN.load(Ordering::Relaxed)
}
pub(crate) fn bump_generation() {
DB_GEN.fetch_add(1, Ordering::Relaxed);
}
/// Run `f` against the cached connection for the current `db_path()`, opening
/// (and migrating) it on first use or whenever the path changed (tests point
/// `KIMI_SWITCH_DB_PATH` at per-test temp dirs).
fn with_conn<T>(f: impl FnOnce(&mut Connection) -> DbResult<T>) -> DbResult<T> {
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
let path = db_path();
let needs_open = match guard.as_ref() {
Some((cached_path, _)) => cached_path != &path,
None => true,
};
if needs_open {
*guard = Some((path, init_db()?));
}
let (_, conn) = guard.as_mut().expect("connection just opened");
f(conn)
}
/// Drop the cached connection so tests that switch `KIMI_SWITCH_DB_PATH`
/// between temp dirs never observe cross-test state.
#[cfg(test)]
pub(crate) fn close_cached_conn_for_tests() {
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
*guard = None;
}
pub fn kimi_switch_data_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".kimi-switch"))
@@ -158,7 +205,10 @@ pub fn init_db() -> DbResult<Connection> {
}
pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut conn = init_db()?;
with_conn(|conn| load_config_inner(conn, agent))
}
fn load_config_inner(conn: &mut Connection, agent: &Agent) -> DbResult<Config> {
let tx = conn.transaction()?;
let default_model = get_setting_tx(&tx, &default_model_key(agent))?;
@@ -249,7 +299,12 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
}
pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
let mut conn = init_db()?;
with_conn(|conn| save_config_inner(conn, agent, config))?;
bump_generation();
Ok(())
}
fn save_config_inner(conn: &mut Connection, agent: &Agent, config: &Config) -> DbResult<()> {
let tx = conn.transaction()?;
tx.execute("DELETE FROM providers WHERE agent = ?1", params![agent.as_str()])?;
@@ -340,7 +395,7 @@ fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResul
/// Public helper: read a single setting without an explicit transaction.
pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
let conn = init_db()?;
with_conn(|conn| {
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
@@ -348,22 +403,56 @@ pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
} else {
Ok(None)
}
})
}
/// Public helper: write a single setting in its own transaction.
pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
let mut conn = init_db()?;
with_conn(|conn| {
let tx = conn.transaction()?;
set_setting_tx(&tx, key, value)?;
tx.commit()?;
Ok(())
})?;
bump_generation();
Ok(())
}
/// Public helper: delete a single setting (no-op if the key does not exist).
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
let conn = init_db()?;
with_conn(|conn| {
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
Ok(())
})?;
bump_generation();
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(())
}
// ---------------------------------------------------------------------------
@@ -413,7 +502,7 @@ pub struct ArchivedSessionSnapshot {
/// Insert or replace one archived-session snapshot (keyed by session id, which
/// is a UUID and never reused).
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
let conn = init_db()?;
with_conn(|conn| {
let day_stats = serde_json::to_string(&row.day_stats)?;
conn.execute(
"INSERT OR REPLACE INTO archived_sessions
@@ -432,12 +521,15 @@ pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
],
)?;
Ok(())
})?;
bump_generation();
Ok(())
}
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
/// to an empty breakdown instead of failing the whole list.
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
let conn = init_db()?;
with_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
total_tokens, total_cost_usd, day_stats
@@ -462,6 +554,7 @@ pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
out.push(row?);
}
Ok(out)
})
}
fn provider_type_for_str(s: &str) -> ProviderType {
+108
View File
@@ -648,6 +648,114 @@ api_key = ""
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
}
#[test]
fn kimi_code_export_writes_and_drops_max_output_size() {
// max_output_size is not a first-class Model field: it round-trips
// through raw_other and must land in config.toml verbatim, and must be
// removable (unset = upstream default) without disturbing its siblings.
let mut providers = IndexMap::new();
providers.insert(
"p".to_string(),
Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://a.example.com".to_string()),
api_key: Some("sk-a".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
usage_config: None,
},
);
let make_model = |raw_other: Value| Model {
alias: "p/m".to_string(),
provider: "p".to_string(),
model: "m".to_string(),
max_context_size: 128_000,
display_name: None,
supports_1m: false,
capabilities: vec![],
raw_other,
};
let mut models = IndexMap::new();
models.insert(
"p/m".to_string(),
make_model(serde_json::json!({
"max_output_size": 32768,
"support_efforts": ["low", "high"],
})),
);
let config = Config {
default_model: None,
providers,
models,
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let model = root
.get("models")
.unwrap()
.as_table()
.unwrap()
.get("p/m")
.unwrap()
.as_table()
.unwrap();
assert_eq!(model.get("max_output_size").and_then(|v| v.as_integer()), Some(32768));
assert!(model.get("support_efforts").is_some());
// Re-import through real TOML text (the same serialize → parse path
// save_kimi_code_config / load_kimi_code_config use): the key must
// come back in the model's raw_other as an integer.
let toml_text = toml::to_string_pretty(&exported).unwrap();
assert!(toml_text.contains("max_output_size = 32768"), "toml: {toml_text}");
let reparsed: TomlValue = toml_text.parse().unwrap();
let parsed = kimi_code_to_config(&reparsed);
let loaded = parsed.models.get("p/m").unwrap();
assert_eq!(
loaded.raw_other.get("max_output_size").and_then(|v| v.as_i64()),
Some(32768)
);
// Clearing the override drops the key entirely.
let mut models = IndexMap::new();
models.insert(
"p/m".to_string(),
make_model(serde_json::json!({ "support_efforts": ["low", "high"] })),
);
let config = Config {
default_model: None,
providers: IndexMap::new(),
models,
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let model = root
.get("models")
.unwrap()
.as_table()
.unwrap()
.get("p/m")
.unwrap()
.as_table()
.unwrap();
assert!(model.get("max_output_size").is_none());
}
#[test]
fn kimi_code_export_adds_opencode_go_session_header() {
let make_provider = |base_url: &str, raw_other: Value| Provider {
+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;
+12 -2
View File
@@ -138,6 +138,14 @@ fn build_snapshot(raw: &Value) -> Result<Value, String> {
{
entry.insert("context".into(), num(ctx));
}
// Max output tokens — reference for the max_output_size column.
if let Some(out) = m
.pointer("/limit/output")
.and_then(|x| x.as_f64())
.filter(|o| *o > 0.0)
{
entry.insert("output".into(), num(out));
}
for flag in ["reasoning", "tool_call", "structured_output"] {
if m.get(flag).and_then(|x| x.as_bool()) == Some(true) {
entry.insert(flag.into(), Value::Bool(true));
@@ -286,7 +294,7 @@ mod tests {
},
"kimi-image": {
"name": "Kimi Image",
"limit": { "context": 0 },
"limit": { "context": 0, "output": 0 },
"modalities": { "input": ["text", "image", "video"] }
}
}
@@ -309,6 +317,7 @@ mod tests {
let k3 = &obj["moonshotai/kimi-k3"];
assert_eq!(k3["name"], "Kimi K3");
assert_eq!(k3["context"], 262144);
assert_eq!(k3["output"], 16384);
assert_eq!(k3["reasoning"], true);
assert_eq!(k3["tool_call"], true);
assert_eq!(k3["structured_output"], true);
@@ -318,9 +327,10 @@ mod tests {
assert_eq!(k3["cost"]["cache_read"], 0.3);
assert_eq!(k3["cost"]["cache_write"], 0.0);
// Context 0 → dropped; video modality → flagged.
// Context 0 / output 0 → dropped; video modality → flagged.
let img = &obj["moonshotai/kimi-image"];
assert!(img.get("context").is_none());
assert!(img.get("output").is_none());
assert_eq!(img["video"], true);
assert!(img.get("cost").is_none());
+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.7.21",
"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,
};
+104 -20
View File
@@ -1,12 +1,20 @@
import { useEffect, useState } from "react";
import { useEffect, useReducer, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import { getModelRef, modelsDevReady } from "../lib/models-dev";
import {
THINKING_EFFORTS,
readSupportEfforts,
resolveThinkingEffortSupport,
type EffortAvailability,
} from "../lib/thinking-efforts";
import type {
AgentSettings,
ExperimentalEnvStatus,
Hook,
Model,
PermissionRule,
} from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
@@ -14,14 +22,18 @@ import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps {
rawOther: unknown;
onChange: (nextRawOther: unknown) => void;
/** Configured models (alias → entry) — used to resolve effort support. */
models?: Record<string, Model>;
/** Alias of the model the effort tier actually applies to. */
defaultModel?: string | null;
}
// Upstream removed the "max" effort tier (auto-migrates to "high").
const THINKING_LEVELS = ["low", "medium", "high"] as const;
const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = {
const THINKING_LABELS: Record<(typeof THINKING_EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
};
const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const;
@@ -44,7 +56,7 @@ const COMMON_EVENTS = [
"SessionEnd",
] as const;
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
export function AgentSettingsPanel({ rawOther, onChange, models, defaultModel }: AgentSettingsPanelProps) {
const { t } = useTranslation();
const settings = getAgentSettings(rawOther);
/**
@@ -52,6 +64,9 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
* v1-engine users; the default (v2) writes only max_attempts_per_step.
*/
const [legacyV1, setLegacyV1] = useState(false);
// The models.dev index loads in the background; re-render once it lands so
// the reasoning flag can disable the thinking area.
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -62,6 +77,18 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
.catch(() => setLegacyV1(false));
}, []);
useEffect(() => {
// The panel only needs the flag once; App owns the permanent
// onModelsDevReady listener for later hot swaps.
let alive = true;
modelsDevReady().then(() => {
if (alive) forceModelsDevReady();
});
return () => {
alive = false;
};
}, []);
const update = (patch: Partial<AgentSettings>) => {
onChange(setAgentSettings(rawOther, patch, { legacyV1 }));
};
@@ -92,6 +119,42 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
const thinkingEnabled = settings.thinking?.enabled ?? true;
// Which tiers the current default model accepts: `[models."<alias>"]
// support_efforts` first, then the models.dev `reasoning` flag.
const defaultEntry = defaultModel ? models?.[defaultModel] : undefined;
const effortSupport = resolveThinkingEffortSupport({
supportEfforts: readSupportEfforts(defaultEntry),
reasoning: defaultEntry ? getModelRef(defaultEntry.model)?.reasoning : undefined,
});
const effortNote = (availability: EffortAvailability): string | undefined => {
const note = availability.note;
if (!note) return undefined;
if (note.kind === "thinkingUnsupported") return t("thinkingEffortUnsupported");
if (note.kind === "tierUnsupported") {
return t("thinkingEffortTierUnsupported", { levels: note.supported.join(", ") });
}
return t("thinkingEffortTierUndeclared");
};
// Upstream takes a free-form string: keep a hand-written tier visible and
// selectable instead of dropping it from the control.
const effort = settings.thinking?.effort ?? "medium";
const effortOptions: {
key: string;
label: string;
disabled?: boolean;
title?: string;
}[] = THINKING_EFFORTS.map((tier) => ({
key: tier,
label: t(THINKING_LABELS[tier]),
disabled: !effortSupport.levels[tier].enabled,
title: effortNote(effortSupport.levels[tier]),
}));
if (!THINKING_EFFORTS.includes(effort as (typeof THINKING_EFFORTS)[number])) {
effortOptions.push({ key: effort, label: effort });
}
return (
<div className="mt-6 space-y-4">
<h3 className="text-content-muted text-sm font-medium">{t("agentSettings")}</h3>
@@ -105,17 +168,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-content-muted">{t("thinkingLevel")}</span>
<Segmented
options={THINKING_LEVELS.map((lvl) => ({
key: lvl,
label: t(THINKING_LABELS[lvl]),
}))}
value={settings.thinking?.effort ?? "medium"}
onChange={(effort) =>
updateThinking({ effort: effort as NonNullable<AgentSettings["thinking"]>["effort"] })
}
disabled={!thinkingEnabled}
options={effortOptions}
value={effort}
onChange={(next) => updateThinking({ effort: next })}
disabled={!thinkingEnabled || !effortSupport.thinkingSupported}
/>
</div>
{!effortSupport.thinkingSupported && (
<p className="text-xs text-amber-500 dark:text-amber-400">
{t("thinkingEffortUnsupported")}
</p>
)}
<p className="text-xs text-content-muted">
{t("thinkingEffortDependsOnDefaultModel")}
</p>
<Checkbox
label={t("thinkingKeep")}
checked={settings.thinking?.keep === "all"}
@@ -170,19 +236,37 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</Card>
<Card title={t("watchSettings")}>
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since
2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env
var is probed by get_experimental_env_status as a non-flag entry
but deliberately locks nothing here, the toggle just documents the
config value that applies when the env var is unset. */}
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; 2.0.2 flipped the
default off, #4015 flipped it back on). KIMI_CODE_WATCH outranks
this config at runtime — the env var is probed by
get_experimental_env_status as a non-flag entry but deliberately
locks nothing here, the toggle just documents the config value
that applies when the env var is unset. */}
<Checkbox
label={t("watchEnabled")}
checked={settings.watch?.enabled ?? false}
checked={settings.watch?.enabled ?? true}
onChange={(checked) => updateWatch({ enabled: checked })}
/>
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
</Card>
<Card title={t("behaviorSettings")}>
{/* Top-level booleans, both default true upstream: false is written
explicitly, true removes the key (see setAgentSettings). */}
<Checkbox
label={t("autoSessionTitle")}
checked={settings.auto_session_title ?? true}
onChange={(checked) => update({ auto_session_title: checked })}
/>
<p className="text-xs text-content-muted">{t("autoSessionTitleDesc")}</p>
<Checkbox
label={t("repeatBreaker")}
checked={settings.repeat_breaker ?? true}
onChange={(checked) => update({ repeat_breaker: checked })}
/>
<p className="text-xs text-content-muted">{t("repeatBreakerDesc")}</p>
</Card>
<Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
+120 -15
View File
@@ -1,11 +1,17 @@
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { findPresetForProvider } from "../config/providerPresets";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import {
parseMaxOutputInput,
readMaxOutputSize,
setMaxOutputSize,
withMaxOutputSize,
} from "../lib/model-max-output";
import { capabilitiesFromRef, getModelRef, modelsDevReady } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog";
@@ -33,6 +39,9 @@ const CAPABILITY_LABELS: Record<
tool_use: "capToolUse",
};
/** Fallback max-output cap (128K) for models models.dev doesn't know. */
const FALLBACK_MAX_OUTPUT = 131_072;
const PROVIDER_TYPES: ProviderType[] = [
"openai",
"openai_responses",
@@ -142,6 +151,13 @@ export function ProviderEdit({
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
// Alias → entry lookup for panels that resolve a model by alias
// (AgentSettingsPanel reads the default model's support_efforts).
const modelsByAlias = useMemo(
() => Object.fromEntries(models.map((m) => [m.alias, m])),
[models]
);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
@@ -462,6 +478,8 @@ export function ProviderEdit({
<AgentSettingsPanel
rawOther={rawOther}
onChange={onRawOtherChange}
models={modelsByAlias}
defaultModel={defaultModel}
/>
)}
</>
@@ -558,6 +576,38 @@ function ModelMapping({
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [fetchThinking, setFetchThinking] = useState(true);
// The models.dev index loads in the background; re-render once it lands so
// the max-output "参考" hints appear even when this panel mounted first.
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
// One-shot backfill: models with no max_output_size yet get the models.dev
// `output` cap, or the 128K fallback when nothing matches. onModelChange
// only mutates the in-memory config — the user still reviews and presses
// 保存配置.
const backfillDone = useRef(false);
const modelsRef = useRef(models);
modelsRef.current = models;
useEffect(() => {
let alive = true;
modelsDevReady().then(() => {
if (!alive) return;
forceModelsDevReady();
if (backfillDone.current || agent !== "kimi_code") return;
backfillDone.current = true;
for (const m of modelsRef.current) {
if (readMaxOutputSize(m.raw_other) !== undefined) continue;
onModelChange(
withMaxOutputSize(
m,
getModelRef(m.model)?.output ?? FALLBACK_MAX_OUTPUT
)
);
}
});
return () => {
alive = false;
};
}, []);
const handleDiscover = async () => {
setDiscovering(true);
setDiscoverError(null);
@@ -627,6 +677,17 @@ function ModelMapping({
: fetchThinking
? ["thinking"]
: [],
// Seed the max_output_size override with the models.dev cap, falling
// back to 128K when nothing matches. kimi_code only: Pi's equivalent
// is `maxTokens` (not written by this UI).
...(agent === "kimi_code"
? {
raw_other: setMaxOutputSize(
undefined,
ref?.output ?? FALLBACK_MAX_OUTPUT
),
}
: {}),
});
}
onBulkAdd(toAdd);
@@ -639,7 +700,10 @@ function ModelMapping({
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-content-primary">{t("modelMapping")}</h3>
<p className="text-xs text-content-muted mt-1">{t("modelMappingDesc")}</p>
<p className="text-xs text-content-muted mt-1">
{t("modelMappingDesc")}
{agent === "kimi_code" ? ` ${t("maxOutputSizeDesc")}` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<button
@@ -747,7 +811,9 @@ function ModelMapping({
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
<th className="text-center px-4 py-3 font-medium w-28">{t("supports1M")}</th>
{agent === "kimi_code" && (
<th className="text-left px-4 py-3 font-medium w-32">{t("maxOutputSize")}</th>
)}
{agent === "kimi_code" && (
<th className="text-left px-4 py-3 font-medium">{t("capabilities")}</th>
)}
@@ -788,19 +854,14 @@ function ModelMapping({
}}
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={m.supports_1m || false}
onChange={(e) =>
onModelChange({
...m,
supports_1m: e.target.checked,
})
}
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
{agent === "kimi_code" && (
<td className="px-4 py-2">
<MaxOutputCell
model={m}
onChange={(next) => onModelChange(next)}
/>
</td>
)}
{agent === "kimi_code" && (
<td className="px-4 py-2">
<CapabilitiesCell
@@ -976,3 +1037,47 @@ function CapabilitiesCell({
</div>
);
}
/**
* Max output tokens override (`[models."<alias>"] max_output_size`).
*
* Not a first-class Model field, so the value lives in `raw_other` (see
* lib/model-max-output.ts). Blank input removes the key — "unset" means the
* upstream default applies. When models.dev knows the model's cap it is shown
* as a click-to-fill reference underneath.
*/
function MaxOutputCell({
model,
onChange,
}: {
model: Model;
onChange: (next: Model) => void;
}) {
const { t } = useTranslation();
const current = readMaxOutputSize(model.raw_other);
const refOutput = getModelRef(model.model)?.output;
return (
<div>
<input
type="number"
min={0}
step={1024}
className="w-full bg-transparent border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={current ?? ""}
onChange={(e) =>
onChange(withMaxOutputSize(model, parseMaxOutputInput(e.target.value)))
}
/>
{refOutput !== undefined && refOutput !== current && (
<button
type="button"
onClick={() => onChange(withMaxOutputSize(model, refOutput))}
className="mt-1 text-[11px] text-content-muted hover:text-blue-500"
>
{t("maxOutputRef", { value: refOutput })}
</button>
)}
</div>
);
}
+4 -2
View File
@@ -96,7 +96,9 @@ function ProviderCard({
provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold
provider.usageConfig?.threshold,
provider.usageConfig?.templateType === "newapi" ||
provider.usageConfig?.templateType === "sub2api"
);
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
@@ -221,7 +223,7 @@ function ProviderCard({
)}
{/* compact usage summary — sits left of the switch button,
mirroring cc-switch's card layout (usage → action buttons) */}
{(provider.usageKinds?.length ?? 0) > 0 && (
{usage.supported && (
<UsageFooter usage={usage} variant="compact" />
)}
<button
+7 -5
View File
@@ -43,12 +43,15 @@ interface SubagentSettingsPageProps {
onBack: () => void;
}
// Upstream removed the "max" effort tier (auto-migrates to "high").
const EFFORTS = ["low", "medium", "high"] as const;
// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and
// per-model support comes from `[models."<alias>"] support_efforts`.
const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
@@ -281,9 +284,8 @@ export function SubagentSettingsPage({
? `${Math.round(n / 1000)}K`
: String(n);
const effortLabel = (e: string): string => {
// Stored "max" tiers from old configs are shown as "high" (upstream
// removed the tier; it auto-migrates to "high").
const key = EFFORT_LABELS[e === "max" ? "high" : (e as (typeof EFFORTS)[number])];
// Unknown values (hand-written config) are shown verbatim.
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
return key ? t(key) : e;
};
+1 -1
View File
@@ -179,7 +179,7 @@ export function DashboardPage() {
</div>
{loadStats && !loading && (
<span className="hidden text-[10px] text-content-muted sm:inline">
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
{t("loadStats", { ms: loadStats.ms })}
</span>
)}
</div>
+12 -6
View File
@@ -106,7 +106,7 @@ export function Segmented({
onChange,
disabled,
}: {
options: { key: string; label: string }[];
options: { key: string; label: string; disabled?: boolean; title?: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
@@ -117,21 +117,27 @@ export function Segmented({
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
{options.map((opt) => {
const optDisabled = disabled || opt.disabled === true;
return (
// The title lives on the wrapper: a disabled button swallows pointer
// events in Chromium, so its own tooltip would never show.
<span key={opt.key} title={opt.title} className="inline-flex">
<button
key={opt.key}
type="button"
disabled={disabled}
disabled={optDisabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
} ${optDisabled ? "cursor-not-allowed opacity-40 hover:bg-input" : ""}`}
>
{opt.label}
</button>
))}
</span>
);
})}
</div>
);
}
+85 -13
View File
@@ -1,32 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { SummaryResult } from "../types/dashboard";
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(() => {
/**
* Emitted by the backend when the background verification walk found records the
* fast (disk-cache) path did not have. The payload is empty — the hook only
* needs to know that a re-fetch is worth making.
*/
const RECORDS_UPDATED_EVENT = "dashboard://records-updated";
const RANGES: readonly DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
function readStoredRange(): DashboardRange {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
if (stored && (RANGES as readonly string[]).includes(stored)) {
return stored;
}
} catch {
// ignore
}
return "30d";
});
}
const [data, setData] = useState<SummaryResult | null>(null);
/**
* Last successful payload per range, kept across mounts so a range switch (and
* reopening the page) paints instantly from memory and revalidates in the
* background instead of showing a spinner over data that is already known.
* Bounded by the five ranges the dashboard offers.
*/
const summaryCache = new Map<DashboardRange, SummaryResult>();
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(readStoredRange);
const [data, setData] = useState<SummaryResult | null>(
() => summaryCache.get(readStoredRange()) ?? null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Round-trip timing for the last get_summary call (user-perceived lag). */
const [loadStats, setLoadStats] = useState<{ ms: number; kb: number } | null>(null);
const [loadStats, setLoadStats] = useState<{ ms: number } | null>(null);
// Generation counter: stale responses (superseded range / unmounted) are dropped.
const genRef = useRef(0);
// The event handler must call the *current* refresh, not the one captured when
// the listener was installed, so it reads the callback through a ref.
const refreshRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
const refresh = useCallback(async (force = false) => {
setLoading(true);
const gen = ++genRef.current;
// Stale-while-revalidate: hand the cached payload for this range to the
// view before the request goes out, so a range switch is instant and the
// response only refreshes numbers that are already on screen. `loading` is
// raised only when there is nothing to show (first load of a range) or when
// the user asked for a refresh — the button keeps its busy state, the silent
// revalidation behind a cached range does not.
const cached = summaryCache.get(range);
if (cached) setData(cached);
setLoading(force || !cached);
setError(null);
const start = performance.now();
try {
@@ -34,23 +70,59 @@ export function useDashboard() {
range,
refresh: force,
});
if (genRef.current !== gen) return;
summaryCache.set(range, result);
setData(result);
setLoadStats({
ms: Math.round(performance.now() - start),
kb: Math.round(JSON.stringify(result).length / 1024),
});
setLoadStats({ ms: Math.round(performance.now() - start) });
} catch (err) {
if (genRef.current !== gen) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setLoading(false);
if (genRef.current === gen) setLoading(false);
}
}, [range]);
refreshRef.current = refresh;
useEffect(() => {
refresh();
}, [refresh]);
// The first load of a session answers from the on-disk snapshot, which can be
// behind a session written since the app last ran. The backend verifies in the
// background and signals here when the numbers moved; re-fetching silently
// (never raising `loading`) keeps the already-drawn dashboard on screen and
// just corrects it. A silent call still takes a fresh generation, so it cannot
// be interleaved with a range switch in flight.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
listen(RECORDS_UPDATED_EVENT, () => {
refreshRef.current();
})
.then((fn) => {
// The effect may have been torn down before `listen` resolved.
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {
// No event bridge (e.g. a browser build): the dashboard still works,
// it just will not be corrected mid-session.
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
// Ignore late responses after unmount.
useEffect(() => {
return () => {
genRef.current += 1;
};
}, []);
const changeRange = useCallback((next: DashboardRange) => {
setRange(next);
try {
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isAlertTier, type UsageData } from "./useUsageQuery";
import { isAlertTier, usageQuerySupported, type UsageData } from "./useUsageQuery";
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
function percentTier(used: number): UsageData {
@@ -42,3 +42,22 @@ describe("isAlertTier", () => {
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
});
});
describe("usageQuerySupported", () => {
it("有识别类型即可查询", () => {
expect(usageQuerySupported(["balance:deepseek"])).toBe(true);
expect(usageQuerySupported([], true)).toBe(true);
});
it("显式模板(newapi/sub2api)绕过 usage_kinds——自定义供应商也该查", () => {
expect(usageQuerySupported(undefined, true)).toBe(true);
});
it("无识别类型且非显式模板时不查询", () => {
expect(usageQuerySupported(undefined)).toBe(false);
expect(usageQuerySupported([])).toBe(false);
// auto 模板没有识别到类型 = 不支持(与弹窗短路口径一致)
expect(usageQuerySupported([], false)).toBe(false);
expect(usageQuerySupported(undefined, undefined)).toBe(false);
});
});
+17 -2
View File
@@ -75,15 +75,30 @@ export function isAlertTier(
return d.unit === "%" && d.used != null && d.used >= threshold;
}
/**
* 是否值得为该供应商发起用量查询:有预设识别类型,或后端按显式模板
* (newapi / sub2api,二者绕过 usage_kinds)查询。
*/
export function usageQuerySupported(
usageKinds?: string[],
explicitTemplate?: boolean
): boolean {
return (usageKinds?.length ?? 0) > 0 || explicitTemplate === true;
}
export function useUsageQuery(
agent: Agent,
providerName: string,
usageKinds?: string[],
autoIntervalMinutes?: number,
disabled?: boolean,
threshold?: number
threshold?: number,
/** Backend resolves the query from an explicit template (newapi / sub2api)
* regardless of usageKinds — those bypass kinds entirely, so the row is
* supported even when the provider has no preset-detected kinds. */
explicitTemplate?: boolean
): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0;
const supported = usageQuerySupported(usageKinds, explicitTemplate);
// Cache key includes the agent so a Kimi Code provider and a Pi provider
// with the same name do not clobber each other's cached result.
const cacheKey = `${agent}:${providerName}`;
+22 -4
View File
@@ -111,7 +111,8 @@ export const enTranslations: Record<TranslationKey, string> = {
baseUrlHint: "Enter a {type} API-compatible endpoint without a trailing slash.",
envPairs: "Env pairs",
addEnv: "+ Add",
modelMappingDesc: "Display name only affects the /model menu; 1M declares context capability for Kimi Code.",
modelMappingDesc: "Display name only affects the /model menu.",
maxOutputSizeDesc: "Max output writes max_output_size — leave blank to send no output cap upstream.",
oneClickSetup: "One-click setup",
fetchModels: "Fetch models",
fetchingModels: "Fetching...",
@@ -121,7 +122,8 @@ export const enTranslations: Record<TranslationKey, string> = {
displayName: "Display name",
actualModel: "Actual model",
contextSize: "Context size",
supports1M: "Supports 1M",
maxOutputSize: "Max output",
maxOutputRef: "Ref {value}",
default: "Default",
operation: "Operation",
isDefault: "Default",
@@ -164,6 +166,15 @@ export const enTranslations: Record<TranslationKey, string> = {
thinkingLow: "Low",
thinkingMedium: "Medium",
thinkingHigh: "High",
thinkingMax: "Max",
thinkingXHigh: "XHigh",
thinkingEffortUnsupported: "The current default model does not support thinking.",
thinkingEffortTierUnsupported:
"The current default model does not support this tier (supported: {levels}).",
thinkingEffortTierUndeclared:
"This model declares no thinking effort tiers; the upstream may reject this one.",
thinkingEffortDependsOnDefaultModel:
"The tier that actually applies depends on the current default model.",
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step",
@@ -177,7 +188,14 @@ export const enTranslations: Record<TranslationKey, string> = {
watchSettings: "File Watching",
watchEnabled: "Enable file watching",
watchEnabledDesc:
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, off by default since 2.0.2). The KIMI_CODE_WATCH environment variable overrides this setting.",
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, on by default). The KIMI_CODE_WATCH environment variable overrides this setting.",
behaviorSettings: "Behavior",
autoSessionTitle: "Auto session title",
autoSessionTitleDesc:
"Let clients auto-generate session titles (on by default; turning it off writes false).",
repeatBreaker: "Repeat breaker",
repeatBreakerDesc:
"Remind and force-stop on consecutively repeated identical tool calls (on by default). The KIMI_CODE_REPEAT_BREAKER environment variable overrides this setting.",
permissionRules: "Permission Rules",
permissionDecision: "Decision",
permissionPattern: "Pattern",
@@ -239,7 +257,7 @@ export const enTranslations: Record<TranslationKey, string> = {
scanning: "Scanning…",
refresh: "Refresh",
refreshing: "Refreshing…",
loadStats: "Loaded in {ms} ms · {kb} KB payload",
loadStats: "Loaded in {ms} ms",
refreshed: "Refreshed",
cancel: "Cancel",
confirmDeleteTitle: "Delete session forever?",
+22 -7
View File
@@ -108,7 +108,8 @@ export const zhTranslations = {
baseUrlHint: "填写兼容 {type} API 的服务端点地址,不要以斜杠结尾",
envPairs: "Env 键值对",
addEnv: "+ 添加",
modelMappingDesc: "显示名称只影响 /model 菜单;1M 只是给 Kimi Code 的上下文能力声明。",
modelMappingDesc: "显示名称只影响 /model 菜单。",
maxOutputSizeDesc: "「最大输出」写入 max_output_size,留空则不发送输出上限。",
oneClickSetup: "一键设置",
fetchModels: "获取模型列表",
fetchingModels: "获取中...",
@@ -118,7 +119,8 @@ export const zhTranslations = {
displayName: "显示名称",
actualModel: "实际请求模型",
contextSize: "上下文长度",
supports1M: "声明支持 1M",
maxOutputSize: "最大输出",
maxOutputRef: "参考 {value}",
default: "默认",
operation: "操作",
isDefault: "已默认",
@@ -158,9 +160,15 @@ export const zhTranslations = {
enableThinking: "启用思考",
thinkingLevel: "思考等级",
thinkingKeep: "保留思考内容",
thinkingLow: "低",
thinkingMedium: "中",
thinkingHigh: "高",
thinkingLow: "Low",
thinkingMedium: "Medium",
thinkingHigh: "High",
thinkingMax: "Max",
thinkingXHigh: "XHigh",
thinkingEffortUnsupported: "当前默认模型不支持思考。",
thinkingEffortTierUnsupported: "当前默认模型不支持该档位(支持:{levels})。",
thinkingEffortTierUndeclared: "该模型未声明思考等级,上游可能拒绝此档位。",
thinkingEffortDependsOnDefaultModel: "实际生效的档位取决于当前默认模型。",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数",
@@ -173,7 +181,14 @@ export const zhTranslations = {
watchSettings: "文件监听",
watchEnabled: "启用文件监听",
watchEnabledDesc:
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,默认开启)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
behaviorSettings: "行为开关",
autoSessionTitle: "自动生成会话标题",
autoSessionTitleDesc:
"允许客户端自动生成会话标题(默认开启;关闭后写入 false)。",
repeatBreaker: "重复调用拦截",
repeatBreakerDesc:
"连续重复相同工具调用时提醒并强制停止(默认开启)。环境变量 KIMI_CODE_REPEAT_BREAKER 会覆盖本配置。",
permissionRules: "权限规则",
permissionDecision: "处置",
permissionPattern: "模式",
@@ -234,7 +249,7 @@ export const zhTranslations = {
scanning: "扫描中…",
refresh: "刷新",
refreshing: "刷新中…",
loadStats: "加载 {ms} ms · 数据 {kb} KB",
loadStats: "加载 {ms} ms",
refreshed: "已刷新",
cancel: "取消",
confirmDeleteTitle: "永久删除会话?",
+69 -9
View File
@@ -20,7 +20,7 @@ function loopOf(raw: unknown): Record<string, unknown> {
}
// ---------------------------------------------------------------------------
// Reading — legacy off values normalized to "off", "max" → "high"
// Reading — legacy off values normalized to "off"; effort tiers pass through
// ---------------------------------------------------------------------------
describe("getAgentSettings — thinking.keep normalization", () => {
@@ -52,12 +52,29 @@ describe("getAgentSettings — thinking.keep normalization", () => {
});
});
describe("getAgentSettings — effort \"max\" read mapping", () => {
it("normalizes a stored \"max\" to \"high\" on read", () => {
describe("getAgentSettings — thinking.effort read mapping", () => {
it("keeps a stored \"max\" as-is (the tier is valid upstream)", () => {
expect(getAgentSettings({ thinking: { effort: "max" } }).thinking?.effort).toBe(
"high"
"max"
);
});
it("passes an \"xhigh\" tier through untouched", () => {
expect(getAgentSettings({ thinking: { effort: "xhigh" } }).thinking?.effort).toBe(
"xhigh"
);
});
it("keeps the default \"medium\" when the key is absent", () => {
expect(getAgentSettings({}).thinking?.effort).toBe("medium");
});
it("round-trips an arbitrary hand-written tier on save", () => {
const next = setAgentSettings({ thinking: { effort: "ultra" } }, {
thinking: { effort: "ultra" },
});
expect(thinkingOf(next).effort).toBe("ultra");
});
});
// ---------------------------------------------------------------------------
@@ -184,8 +201,8 @@ describe("loop_control.compaction_max_attempts", () => {
});
// ---------------------------------------------------------------------------
// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since
// 2.0.2; mirrors the [background] handling
// [watch] enabled (kimi-code 2.0.1+) — top-level section; 2.0.2 flipped the
// default off, #4015 flipped it back on. Mirrors the [background] handling.
// ---------------------------------------------------------------------------
function watchOf(raw: unknown): Record<string, unknown> {
@@ -196,9 +213,9 @@ function watchOf(raw: unknown): Record<string, unknown> {
}
describe("[watch] enabled", () => {
it("reads as false when the section or key is absent (2.0.2 default)", () => {
expect(getAgentSettings({}).watch?.enabled).toBe(false);
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(false);
it("reads as true when the section or key is absent (#4015 default)", () => {
expect(getAgentSettings({}).watch?.enabled).toBe(true);
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(true);
});
it("reads an explicit value", () => {
@@ -231,6 +248,49 @@ describe("[watch] enabled", () => {
});
});
// ---------------------------------------------------------------------------
// Top-level booleans (auto_session_title #3962 / repeat_breaker #3995):
// upstream default true — false is written explicitly, true removes the key.
// ---------------------------------------------------------------------------
describe("top-level boolean switches", () => {
it("reads as undefined when the key is absent (default true)", () => {
const s = getAgentSettings({});
expect(s.auto_session_title).toBeUndefined();
expect(s.repeat_breaker).toBeUndefined();
});
it("reads explicit false values", () => {
const s = getAgentSettings({
auto_session_title: false,
repeat_breaker: false,
});
expect(s.auto_session_title).toBe(false);
expect(s.repeat_breaker).toBe(false);
});
it("writes false explicitly and removes the key on true", () => {
const off = setAgentSettings({}, { repeat_breaker: false }) as Record<
string,
unknown
>;
expect(off.repeat_breaker).toBe(false);
const on = setAgentSettings(
{ repeat_breaker: false },
{ repeat_breaker: true }
) as Record<string, unknown>;
expect("repeat_breaker" in on).toBe(false);
});
it("keeps an explicit false on saves that do not touch it", () => {
const next = setAgentSettings(
{ auto_session_title: false },
{ thinking: { effort: "high" } }
) as Record<string, unknown>;
expect(next.auto_session_title).toBe(false);
});
});
describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => {
+27 -8
View File
@@ -13,10 +13,11 @@ const DEFAULT_SETTINGS: AgentSettings = {
background: {
keep_alive_on_exit: false,
},
// `[watch] enabled` — kimi-code 2.0.1 added the key, 2.0.2 flipped the
// default to off, so an absent key reads as "off".
// `[watch] enabled` — kimi-code 2.0.1 added the key; 2.0.2 flipped the
// default to off, then #4015 flipped it back on, so an absent key reads
// as "on".
watch: {
enabled: false,
enabled: true,
},
permission: { rules: [] },
hooks: [],
@@ -37,6 +38,7 @@ function getSection<T>(rawOther: unknown, key: string): T | undefined {
}
export function getAgentSettings(rawOther: unknown): AgentSettings {
const root = asRecord(rawOther);
const sectionLoop = getSection<AgentSettings["loop_control"]>(
rawOther,
"loop_control"
@@ -65,11 +67,6 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
if (thinking.keep !== undefined && thinking.keep !== "all") {
thinking.keep = "off";
}
// The "max" effort tier was removed upstream (auto-migrates to "high");
// old configs still carrying it are shown as "high" (not rewritten on read).
if (thinking.effort === "max") {
thinking.effort = "high";
}
const sectionPermission = getSection<AgentSettings["permission"]>(
rawOther,
"permission"
@@ -97,6 +94,16 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
: {}),
},
hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
// Top-level booleans (upstream default true): keep only explicit values,
// an absent key means "on" — the UI shows `?? true`.
auto_session_title:
typeof root.auto_session_title === "boolean"
? root.auto_session_title
: undefined,
repeat_breaker:
typeof root.repeat_breaker === "boolean"
? root.repeat_breaker
: undefined,
};
}
@@ -171,5 +178,17 @@ export function setAgentSettings(
delete root.hooks;
}
// Top-level booleans with an upstream default of true: an explicit false
// is written; true/undefined removes the key so the config tracks the
// upstream default instead of pinning it.
for (const key of ["auto_session_title", "repeat_breaker"] as const) {
const value = patch[key] ?? current[key];
if (value === false) {
root[key] = false;
} else {
delete root[key];
}
}
return root;
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
parseMaxOutputInput,
readMaxOutputSize,
setMaxOutputSize,
withMaxOutputSize,
} from "./model-max-output";
const model = (raw_other: unknown) =>
({
alias: "a",
provider: "p",
model: "m",
max_context_size: 0,
display_name: null,
raw_other,
}) as const;
describe("readMaxOutputSize", () => {
it("reads the key from a model entry's preserved config fields", () => {
expect(readMaxOutputSize({ max_output_size: 32768 })).toBe(32768);
});
it("treats absent / malformed / non-positive values as unset", () => {
expect(readMaxOutputSize(undefined)).toBeUndefined();
expect(readMaxOutputSize(null)).toBeUndefined();
expect(readMaxOutputSize({})).toBeUndefined();
expect(readMaxOutputSize("32768")).toBeUndefined();
expect(readMaxOutputSize({ max_output_size: 0 })).toBeUndefined();
expect(readMaxOutputSize({ max_output_size: -1 })).toBeUndefined();
expect(readMaxOutputSize({ max_output_size: NaN })).toBeUndefined();
expect(readMaxOutputSize([1, 2])).toBeUndefined();
});
});
describe("parseMaxOutputInput", () => {
it("keeps positive integers", () => {
expect(parseMaxOutputInput("32768")).toBe(32768);
});
it("maps blank / zero / garbage to unset", () => {
expect(parseMaxOutputInput("")).toBeUndefined();
expect(parseMaxOutputInput("0")).toBeUndefined();
expect(parseMaxOutputInput("-5")).toBeUndefined();
expect(parseMaxOutputInput("abc")).toBeUndefined();
});
});
describe("setMaxOutputSize", () => {
it("sets the key while preserving other preserved fields", () => {
expect(setMaxOutputSize({ support_efforts: ["low"] }, 16384)).toEqual({
support_efforts: ["low"],
max_output_size: 16384,
});
});
it("drops the key when the value is unset", () => {
expect(setMaxOutputSize({ max_output_size: 16384, force: true }, undefined)).toEqual({
force: true,
});
});
it("starts from an empty object for null / non-object raw_other", () => {
expect(setMaxOutputSize(null, 1024)).toEqual({ max_output_size: 1024 });
expect(setMaxOutputSize("junk", 1024)).toEqual({ max_output_size: 1024 });
expect(setMaxOutputSize({ max_output_size: 1 }, undefined)).toEqual({});
});
});
describe("withMaxOutputSize", () => {
it("returns a new model with the override applied", () => {
const before = model({ max_output_size: 4096 });
const after = withMaxOutputSize(before, 8192);
expect(readMaxOutputSize(after.raw_other)).toBe(8192);
expect(readMaxOutputSize(before.raw_other)).toBe(4096);
expect(after).not.toBe(before);
});
it("clears the override without touching other fields", () => {
const after = withMaxOutputSize(model({ max_output_size: 4096 }), undefined);
expect(after.raw_other).toEqual({});
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { Model } from "../types";
/**
* Helpers for the `max_output_size` model field.
*
* kimi-code keeps it in `[models."<alias>"] max_output_size`; it is not a
* first-class field of Kimi Switch's Rust `Model` struct, so it round-trips
* through `raw_other` (the pass-through bucket for unknown keys). Absent key =
* unset = the upstream default applies, so clearing the field removes the key
* rather than writing 0.
*/
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
/** Read the override; undefined when absent or malformed (treated as unset). */
export function readMaxOutputSize(rawOther: unknown): number | undefined {
const value = asRecord(rawOther).max_output_size;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
/** Parse the number input: blank / 0 / NaN mean "unset". */
export function parseMaxOutputInput(text: string): number | undefined {
const value = parseInt(text, 10);
if (isNaN(value) || value <= 0) return undefined;
return value;
}
/**
* Set (positive value) or drop (undefined) `max_output_size` on a raw_other
* blob; every other preserved key is kept.
*/
export function setMaxOutputSize(
rawOther: unknown,
value: number | undefined,
): Record<string, unknown> {
const next = { ...asRecord(rawOther) };
if (value === undefined) {
delete next.max_output_size;
} else {
next.max_output_size = Math.floor(value);
}
return next;
}
/** Immutable update of a model entry's max_output_size override. */
export function withMaxOutputSize(
model: Model,
value: number | undefined,
): Model {
return { ...model, raw_other: setMaxOutputSize(model.raw_other, value) };
}
+8913 -4010
View File
File diff suppressed because it is too large. Load diff
+15769 -3378
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -25,6 +25,8 @@ export interface ModelCost {
export interface ModelRef {
name?: string;
context?: number;
/** Upper bound on generated tokens (`limit.output` on models.dev). */
output?: number;
reasoning?: boolean;
tool_call?: boolean;
structured_output?: boolean;
+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 };
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
THINKING_EFFORTS,
normalizeSupportEfforts,
readSupportEfforts,
resolveThinkingEffortSupport,
} from "./thinking-efforts";
// ---------------------------------------------------------------------------
// support_efforts normalization — an absent / malformed key means "unknown",
// never "no tiers supported"
// ---------------------------------------------------------------------------
describe("normalizeSupportEfforts", () => {
it("keeps a non-empty list of strings", () => {
expect(normalizeSupportEfforts(["low", "high"])).toEqual(["low", "high"]);
});
it("drops blanks and non-strings but keeps the order", () => {
expect(normalizeSupportEfforts(["low", "", 3, null, "max"])).toEqual([
"low",
"max",
]);
});
it("returns null for absent / empty / non-array values", () => {
expect(normalizeSupportEfforts(undefined)).toBeNull();
expect(normalizeSupportEfforts([])).toBeNull();
expect(normalizeSupportEfforts([" "])).toBeNull();
expect(normalizeSupportEfforts("low")).toBeNull();
});
});
describe("readSupportEfforts", () => {
const model = (raw_other: unknown) =>
({ alias: "a", provider: "p", model: "m", max_context_size: 0, display_name: null, raw_other }) as const;
it("reads the key from a model entry's preserved config fields", () => {
expect(readSupportEfforts(model({ support_efforts: ["low", "max"] }))).toEqual([
"low",
"max",
]);
});
it("returns null when the entry or the key is absent", () => {
expect(readSupportEfforts(undefined)).toBeNull();
expect(readSupportEfforts(model(undefined))).toBeNull();
expect(readSupportEfforts(model({ other: 1 }))).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Tier resolution — three layers, in priority order
// ---------------------------------------------------------------------------
describe("resolveThinkingEffortSupport — declared support_efforts wins", () => {
const support = resolveThinkingEffortSupport({
supportEfforts: ["low", "high", "max"],
reasoning: true,
});
it("exposes the declared list and keeps thinking enabled", () => {
expect(support.declared).toEqual(["low", "high", "max"]);
expect(support.thinkingSupported).toBe(true);
});
it("enables exactly the declared tiers", () => {
for (const tier of THINKING_EFFORTS) {
expect(support.levels[tier].enabled).toBe(tier === "low" || tier === "high" || tier === "max");
}
});
it("explains a disabled tier with the supported list", () => {
expect(support.levels.medium).toEqual({
enabled: false,
note: { kind: "tierUnsupported", supported: ["low", "high", "max"] },
});
expect(support.levels.xhigh.enabled).toBe(false);
});
it("does not disable thinking even when models.dev says reasoning: false", () => {
const conflicting = resolveThinkingEffortSupport({
supportEfforts: ["high"],
reasoning: false,
});
expect(conflicting.thinkingSupported).toBe(true);
expect(conflicting.levels.low.enabled).toBe(false);
expect(conflicting.levels.high.enabled).toBe(true);
});
it("matches tiers case-insensitively", () => {
const upper = resolveThinkingEffortSupport({ supportEfforts: ["HIGH"] });
expect(upper.levels.high.enabled).toBe(true);
});
});
describe("resolveThinkingEffortSupport — model without thinking", () => {
const support = resolveThinkingEffortSupport({ reasoning: false });
it("disables the whole thinking area", () => {
expect(support.thinkingSupported).toBe(false);
expect(support.declared).toBeNull();
for (const tier of THINKING_EFFORTS) {
expect(support.levels[tier]).toEqual({
enabled: false,
note: { kind: "thinkingUnsupported" },
});
}
});
});
describe("resolveThinkingEffortSupport — nothing known about the model", () => {
it("offers low/medium/high plain and flags max/xhigh as undeclared", () => {
const support = resolveThinkingEffortSupport({});
expect(support.thinkingSupported).toBe(true);
expect(support.declared).toBeNull();
for (const tier of ["low", "medium", "high"] as const) {
expect(support.levels[tier]).toEqual({ enabled: true });
}
for (const tier of ["max", "xhigh"] as const) {
expect(support.levels[tier]).toEqual({
enabled: true,
note: { kind: "tierUndeclared" },
});
}
});
it("treats an undefined reasoning flag as unknown, not as unsupported", () => {
expect(
resolveThinkingEffortSupport({ reasoning: undefined }).thinkingSupported
).toBe(true);
});
it("ignores a malformed support_efforts value and falls back to unknown", () => {
const support = resolveThinkingEffortSupport({ supportEfforts: "low" });
expect(support.declared).toBeNull();
expect(support.levels.low).toEqual({ enabled: true });
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Thinking-effort capability resolution.
*
* kimi-code's `[thinking] effort` is a free-form string (low/medium/high/
* xhigh/max in practice) that the CLI forwards verbatim to OpenAI-compatible
* upstreams as `reasoning_effort`. Which tiers a given model accepts is
* declared per model in config.toml (`[models."<alias>"] support_efforts`);
* models.dev only carries a boolean `reasoning` flag with no tier list. The
* two sources are merged here so the UI can grey out tiers the current default
* model would reject, without ever rejecting a stored value on read.
*/
import type { Model } from "../types";
export const THINKING_EFFORTS = [
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
export type ThinkingEffort = (typeof THINKING_EFFORTS)[number];
/** Why a tier is blocked, or (for undeclared models) merely unverified. */
export type EffortNote =
| { kind: "thinkingUnsupported" }
| { kind: "tierUnsupported"; supported: string[] }
| { kind: "tierUndeclared" };
export interface EffortAvailability {
/** Whether the tier can be picked. */
enabled: boolean;
/** Advisory message — set for blocked tiers and for unverified ones. */
note?: EffortNote;
}
export interface ThinkingEffortSupport {
/** False only when the model is known not to support thinking at all. */
thinkingSupported: boolean;
/** Declared tiers, or null when nothing is known about the model. */
declared: string[] | null;
levels: Record<ThinkingEffort, EffortAvailability>;
}
/** Tiers offered unverified when a model declares nothing at all. */
const ALWAYS_OFFERED = new Set<string>(["low", "medium", "high"]);
/**
* Normalize a `support_efforts` value into a comparable list, or null when the
* key is absent / malformed (treating it as "unknown", not "none supported").
*/
export function normalizeSupportEfforts(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const tiers = value
.filter((v): v is string => typeof v === "string")
.map((v) => v.trim())
.filter((v) => v.length > 0);
return tiers.length > 0 ? tiers : null;
}
/** Read `support_efforts` from a model entry's preserved config.toml fields. */
export function readSupportEfforts(model: Model | undefined): string[] | null {
if (!model) return null;
const raw = model.raw_other;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return normalizeSupportEfforts(
(raw as Record<string, unknown>).support_efforts
);
}
function buildLevels(
resolve: (effort: ThinkingEffort) => EffortAvailability
): Record<ThinkingEffort, EffortAvailability> {
const levels = {} as Record<ThinkingEffort, EffortAvailability>;
for (const effort of THINKING_EFFORTS) levels[effort] = resolve(effort);
return levels;
}
/**
* Resolve per-tier availability from the two capability sources, in priority
* order:
* 1. `supportEfforts` declared → tiers follow the list exactly.
* 2. models.dev `reasoning === false` → the whole thinking area is off.
* 3. Nothing known → low/medium/high are offered plain, max/xhigh stay
* selectable but carry a "not declared" note (upstream may reject them).
*/
export function resolveThinkingEffortSupport(input: {
supportEfforts?: unknown;
reasoning?: boolean;
}): ThinkingEffortSupport {
const declared = normalizeSupportEfforts(input.supportEfforts);
if (declared) {
const supported = new Set(declared.map((t) => t.toLowerCase()));
return {
thinkingSupported: true,
declared,
levels: buildLevels((effort) =>
supported.has(effort)
? { enabled: true }
: {
enabled: false,
note: { kind: "tierUnsupported", supported: declared },
}
),
};
}
if (input.reasoning === false) {
return {
thinkingSupported: false,
declared: null,
levels: buildLevels(() => ({
enabled: false,
note: { kind: "thinkingUnsupported" },
})),
};
}
return {
thinkingSupported: true,
declared: null,
levels: buildLevels((effort) =>
ALWAYS_OFFERED.has(effort)
? { enabled: true }
: { enabled: true, note: { kind: "tierUndeclared" } }
),
};
}
+12 -4
View File
@@ -111,11 +111,12 @@ export interface DiscoveredModel {
export interface ThinkingConfig {
enabled?: boolean;
/**
* Effort tier. `max` is read-compatible only — upstream removed the tier
* (old configs auto-migrate to `high`); the UI normalizes it and the
* serialization path never writes it.
* Effort tier, forwarded verbatim to OpenAI-compatible upstreams as
* `reasoning_effort`. Upstream accepts a free-form string; the UI offers
* low/medium/high/max/xhigh and derives per-model support from
* `[models."<alias>"] support_efforts`.
*/
effort?: "low" | "medium" | "high" | "max";
effort?: string;
/**
* Keep thinking content. The legacy off values (`false`, `0`, "no", "none",
* `null`) are read-compatible — old configs may carry them; the UI
@@ -171,6 +172,13 @@ export interface AgentSettings {
background?: BackgroundConfig;
/** Top-level `[watch]` section (kimi-code 2.0.1+). */
watch?: WatchConfig;
/** Top-level config.toml key (kimi-code #3962): let clients auto-generate
* session titles. Default true; only an explicit false disables. */
auto_session_title?: boolean;
/** Top-level config.toml key (kimi-code #3995): repeat-breaker for
* consecutively repeated identical tool calls. Default true; env
* KIMI_CODE_REPEAT_BREAKER outranks this config. */
repeat_breaker?: boolean;
permission?: {
rules?: PermissionRule[];
/** kimi-code `[permission]` key. Default true when the key is
+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.7.12",
"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",
+84 -12
View File
@@ -16,10 +16,10 @@ const zh = {
performance: "性能",
changelog: "更新日志",
download: "下载",
downloadBtn: "下载 v0.7.12",
downloadBtn: "下载 v0.8.2",
},
hero: {
badge: "v0.7.12 · 开源 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: "7495", label: "模型价格库" },
{ value: "8467", label: "模型价格库" },
{ value: "MIT", label: "开源协议" },
],
},
@@ -76,7 +76,7 @@ const zh = {
{
id: "model-discovery",
title: "模型自动发现",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 7495 个模型、212 家供应商)。",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,467 个模型、225 家供应商)。",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -89,7 +89,7 @@ const zh = {
},
showcase: {
title: "界面演示",
subtitle: "以下截图取自 v0.7.12 实机运行。",
subtitle: "以下截图取自 v0.8.2 实机运行。",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -140,6 +140,42 @@ 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",
items: [
"全局配置新增两个顶层开关:自动生成会话标题(auto_session_title)与重复调用拦截(repeat_breaker),默认开启",
"最大输出未匹配 models.dev 时回填默认值 131072(128K 兜底)",
"修复 [watch] enabled 默认值显示(上游 #4015 翻回默认开启);「最大输出」列描述对齐上游 #4091",
],
},
{
version: "v0.8.0",
date: "2026-10-03",
items: [
"模型映射新增「最大输出 Token」列:逐模型设置 max_output_size(留空用上游默认),models.dev 有 output 上限时显示可点击参考值,打开编辑页自动补全空值",
"models.dev 快照新增 output 上限字段,同步至 2026-10-03(8,385 模型 / 226 供应商,output 覆盖 97.4%)",
"移除无效的「声明支持 1M」复选框(勾选从不生效),由「最大输出 Token」列取代",
],
},
{
version: "v0.7.23",
date: "2026-09-30",
items: [
"思考等级五档 + 能力感知:Low / Medium / High / XHigh / Max,按当前默认模型声明的 support_efforts 自动置灰不支持档位并提示原因",
"models.dev 标记不支持思考的模型整体禁用思考区;未声明档位的模型保留可选并提示上游可能拒绝",
],
},
{
version: "v0.7.12",
date: "2026-09-03",
@@ -322,7 +358,7 @@ const zh = {
},
download: {
title: "下载 Kimi Switch",
subtitle: "当前版本 v0.7.12 · Windows / macOS / Linux 三平台已发布",
subtitle: "当前版本 v0.8.2 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
ready: "已发布",
wip: "开发中",
@@ -391,10 +427,10 @@ const en: Dict = {
performance: "Performance",
changelog: "Changelog",
download: "Download",
downloadBtn: "Download v0.7.12",
downloadBtn: "Download v0.8.2",
},
hero: {
badge: "v0.7.12 · 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: "",
@@ -404,7 +440,7 @@ const en: Dict = {
source: "View source",
stats: [
{ value: "22", label: "Provider presets" },
{ value: "7495", label: "Model price DB" },
{ value: "8467", label: "Model price DB" },
{ value: "MIT", label: "License" },
],
},
@@ -451,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 (7495 models, 212 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",
},
{
@@ -464,7 +500,7 @@ const en: Dict = {
},
showcase: {
title: "Screenshots",
subtitle: "All screenshots below are taken from the running v0.7.12 build.",
subtitle: "All screenshots below are taken from the running v0.8.2 build.",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -515,6 +551,42 @@ 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",
items: [
"Two new top-level switches in Global Settings: auto session title (auto_session_title) and repeat breaker (repeat_breaker), on by default",
"Max output backfills a 131072 (128K) default when models.dev has no match",
"Fixed the [watch] enabled default display (upstream #4015 flipped it back on); Max output column wording aligned with upstream #4091",
],
},
{
version: "v0.8.0",
date: "2026-10-03",
items: [
"New per-model Max Output Token column: sets max_output_size in config.toml (blank keeps the upstream default), shows a click-to-fill reference when models.dev knows the cap, and backfills empty rows automatically",
"models.dev snapshot gains the output-limit field, synced to 2026-10-03 (8,385 models / 226 providers, 97.4% output coverage)",
"Removed the ineffective 'Supports 1M' checkbox (it was never persisted), replaced by the Max Output Token column",
],
},
{
version: "v0.7.23",
date: "2026-09-30",
items: [
"Thinking levels expanded to five tiers with capability awareness: Low / Medium / High / XHigh / Max, greying out tiers the current default model's support_efforts doesn't declare, with an explanation",
"Models flagged non-reasoning by models.dev disable the whole thinking area; undeclared tiers stay selectable with an upstream-may-reject hint",
],
},
{
version: "v0.7.12",
date: "2026-09-03",
@@ -696,7 +768,7 @@ const en: Dict = {
},
download: {
title: "Download Kimi Switch",
subtitle: "Current version v0.7.12 · 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.7.12";
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