Compare commits

...
7 Commits
Author SHA1 Message Date
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
KimiSwitch Dev 11286419b1 feat(models-dev): 高级设置新增 models.dev 在线同步 — 上下文/能力/单价即时生效 + 一键恢复内置;修复 public 快照构建漂移;确认适配 kimi-code 2.1.0 — bump to v0.7.21
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-24 00:19:10 +08:00
KimiSwitch Dev 26e783c2ba chore(data): 同步 models-dev 快照至 2026-09-20(7870 模型 / 222 供应商) 2026-09-20 10:29:40 +08:00
KimiSwitch Dev 11f87fd9af chore(docs): release notes 归档 docs/release-notes/ + 根目录汇总 CHANGELOG.md
- 20 份 release-notes-v*.md 移入 docs/release-notes/(git mv 保留历史)
- 新增 CHANGELOG.md:v0.7.1~v0.7.20 倒序浓缩汇总(日期取各文件原始添加提交)
- publish-gitea.sh 与 release.yml 改为 docs/release-notes/<tag>.md 优先、根目录回退
2026-09-20 09:58:01 +08:00
52 changed files with 291043 additions and 263642 deletions

No files matched your search

+2 -1
View File
@@ -73,7 +73,8 @@ jobs:
id: notes
shell: bash
run: |
FILE="release-notes-${{ github.ref_name }}.md"
FILE="docs/release-notes/${{ github.ref_name }}.md"
[ -f "$FILE" ] || FILE="release-notes-${{ github.ref_name }}.md"
{
echo 'body<<RELEASE_BODY_EOF'
if [ -f "$FILE" ]; then
+156
View File
@@ -0,0 +1,156 @@
# Changelog
**Kimi Switch** — Windows 桌面端的 Kimi Code CLI 配置管理器,统一管理多家 LLM 供应商、模型、图标、连通性、用量统计与版本更新。
本文件汇总各版本要点,按版本倒序排列;每个版本的**完整 release notes**(含安装说明、平台产物、已知问题)见 [`docs/release-notes/`](./docs/release-notes/)。
---
## 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 最新快照(上下文长度 / 能力标记 / 单价),前端与仪表盘计价即时生效,无需等待新版本;支持一键恢复随版本内置数据;显示当前数据来源与版本(同步 / 内置 + 日期 + 模型/供应商数)
- **修复构建链路数据漂移**:`fetch-models-dev.mjs` 现在同时写入 `src/lib/models-dev.json`(Rust 计价内嵌)与 `public/models-dev.json`(前端静态资源)——此前 public 副本需手工拷贝,已两次滞后于 src/lib
- **kimi-code 2.1.0 已适配确认**:实验 flag 注册表(5 个)、config 模式、用量/quota 格式均无变化;上游 `[watch]` 默认关闭与现有开关行为一致;新增 `tui_mode`(tui.toml)不涉及配置管理面
- models-dev 快照同步至 2026-09-23(8,126 模型 / 223 供应商)
## v0.7.20 (2026-09-20)
- **套餐用量阈值预警**:用量配置新增「预警阈值 %」(0-100,0/空为关闭),任一用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除
- **Kimi For Coding 加力钱包**:0.43.1 起解析 `boosterWallet` 并展示为独立余额行(💰 月度用量 / 总额 / 余额,含币种)
- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762,凭证来源可切换「直接填写 / 环境变量」,密钥不写入 `config.toml`,与 `api_key` 互斥;SQLite 新增列含自动迁移
- **Kimi Code 版本跟踪器**:设置页新增版本卡片——本机已装版本、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 / 橙部分兼容 / 灰未安装),可一键打开上游 Releases
- **供应商一键体检**:并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重查账单,三态判定避免误报(端点不支持模型列表计中性)
- 新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5)与 `[watch] enabled` 开关(上游 2.0.1 #3892);已确认适配 kimi-code 2.0.2
## v0.7.19 (2026-09-14)
- **适配 kimi-code 0.43.0 实验 flag 镜像(7→5)**:移除已转正的 `auto_session_title`(上游 #3749),上游注册表现剩 `wait_for` / `tool-select` / `notify_user` / `tower` / `subagent_fork`
- 兼容性核查:0.43.0 的 wire 持久化重建(#3737)不影响用量统计,相关记录全部保留;`loop_control.compaction_max_attempts`(#3750)保存不丢失
- 残留的 `auto_session_title` 旧键会被上游静默忽略,无需手动清理
## v0.7.18 (2026-09-14)
- **会话管理「一键归档」**:支持按 1 个月前 / 半个月前 / 1 星期前 / 指定日期批量归档,替代逐个选择,并反馈已归档 / 跳过数量
- **归档用量快照落库**:归档瞬间按「天 × 模型」粒度把 token 用量与费用写入 SQLite `archived_sessions` 表
- 删除已归档会话释放磁盘后不再损失历史统计——趋势图、热力图、模型统计、区间合计自动合并快照
- 快照与现存文件实时统计严格去重(只合并文件已删除的归档会话,绝不双计)
- models-dev 快照同步至 2026-09-14(7784 模型 / 213 供应商),pricing 断言跟随 DeepSeek 官方调价(deepseek-v4-flash input 0.14→0.15 / output 0.28→0.60)
## v0.7.17 (2026-09-13)
- **账单查询适配 Sub2API 中转站(`balance:sub2api`)**:支持 Wei-Shaw/sub2api 面板余额查询(`GET {base}/v1/usage`),自动复用推理 `sk-` API Key,无需网页后台 Access Token
- 解析三种模式:单 Key 总额度(quota)、5 小时 / 每日 / 7 天速率窗口(含重置倒计时)、订阅组日/周/月额度与钱包余额(USD)
- `detect_provider` 家族规则拆分:`codingplan.site` 主域识别为 Sub2API,`ai.codingplan.site` 识别为 NewAPI,修正主域被误判导致查询 404 的问题
- 用量配置面板新增「Sub2API 中转站」模板,可选覆盖查询地址(Base URL)
- **模型映射「获取模型列表」新增批量选择**:全选 / 取消全选 / 反选,配合已添加标记批量接入中转站模型
## v0.7.16 (2026-09-10)
- **适配 kimi-code 0.42.0 实验 flag 镜像(9→6)**:移除 4 个已转正/删除的开关(子代理次主力模型、MiniDB 读模型、远程控制、搜索 Worker),新增 `notify_user`(实验性 Updates 面板,默认关闭)
- 用量统计「模型用量统计」图表固定最大高度 360px 并支持滚动,与右侧模型用量表一致
- models-dev 快照同步至 2026-09-10(7615 模型 / 213 供应商),`public/` 静态目录同步
## v0.7.15 (2026-09-09)
- **用量统计新增「昨天」选项卡**:时间范围为 今天 / 昨天 / 7 天 / 30 天 / 全部,「昨天」按本地时区严格统计昨日 0 点至今日 0 点,概览卡扩展为 5 张
- **新增「模型用量统计」趋势选项卡**:跨供应商按模型名汇总(同一模型合并为一行),横向条形图按 Token 降序,悬停显示请求数 / Token / 缓存命中 / 费用
- **修复 opencode Go 套餐子代理请求 400**:Go 网关强制要求 `x-opencode-session` 头,导出时凡指向 Go 端点的供应商自动补写(已有自定义值保留),按量计费的 `zen/v1` 不受影响
- models-dev 快照同步至 2026-09-09(7583 模型 / 213 供应商);`kimi/k2.5` 官方条目下架后暂解析到 302ai 转售价(0.66 / 3.3)
## v0.7.14 (2026-09-06)
- **修复关闭「启用用量查询」后仍残留查询行为**:此前关闭后前端仍发起查询并持续显示「查询失败 · 用量查询已关闭」红色错误行,现在关闭即停止查询并隐藏整行用量信息,重开后自动恢复
- 在途查询结果在开关切换瞬间作废且不再写入共享缓存,避免过期失败状态在 5 分钟内复活
## v0.7.13 (2026-09-05)
- **适配 kimi-code 0.41.0**:上游移除 `file_history` flag(轮级文件历史转为恒开),镜像全链路同步删除;Auto(永不询问)模式不再拦截危险命令,设置页文案(中英)同步修正
- **账单查询改进**:`codingplan.site` 系供应商自动识别为 NewAPI 余额查询;凭据缺失不再误报「网络异常」而给出本地化配置提示;金额显示统一为两位小数并去掉 `¤` 占位符
- 新增 usage 错误本地化单元测试
## v0.7.12 (2026-09-03)
- **适配 kimi-code 0.40.1(flag 9→10)**:新增 `file_history` / `search_worker`;`secondary-model` 默认开启并显示徽章;新增危险命令守卫开关 `[permission] dangerous_command_guard`(默认开启)
- **flag 优先级语义修正**:单 flag 环境变量 > `[experimental]` 显式配置 > 总开关环境变量 > 默认值,总开关不再锁定全部开关
- **修复 WebUI 打开竞态**:并发调用串行化 + 后到点击自动聚焦已有窗口,失败路径不再误杀正在使用的 server,前端增加「打开中...」防重入态
- models-dev 快照更新至 7495 模型 / 212 供应商
## v0.7.11 (2026-08-29)
- **适配 kimi-code 0.39.1**:`[thinking]` 保留开关改写 `keep = "off"`(修复布尔值导致整个节被 v2 校验丢弃);`loop_control` 默认只写新键 `max_attempts_per_step` 并清理旧键;思考强度移除 `max` 档(上游迁移为 `high`)
- **会话归档与上游 v2 对齐**:不再搬动文件,改为在 `state.json` 写入 `archived` / `archivedAt` 元数据,CLI 侧与 Kimi Switch 侧归档状态互通;旧 `.kcd-archive/` 物理归档仍可识别恢复
- **修复子代理模型池无法添加第二个条目**:默认模型自动物化进池,不再被校验拦截
- models.dev 快照刷新至 7482 个模型 / 207 个供应商
## v0.7.10 (2026-08-29)
- **修复保存配置时误删 CLI 新增的顶层配置节**:以导入时顶层键为基线,CLI 后加的 `[task]` / `[swarm]` / `[cron]` / `[tools]` / `[identity]` / `[token_counting]` 等节原样保留,界面中显式删除的节仍会移除
- **修复未知供应商类型被改写**:`config.toml` 中新增的供应商 `type` 往返读写后原样保留(此前会被改成 `kimi`),下拉框标注「未知类型」,SQLite 快照恢复路径同步修复
- 新增 3 个配置往返回归测试,Rust 测试 106 项全绿,前端 tsc / vitest 通过
## v0.7.9 (2026-08-27)
- 模型库刷新至 **7343 个模型 / 203 个供应商**,内置快照与官网静态资源同步更新
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare Workers AI、DeepInfra、HuggingFace、火山引擎等约 20 条渠道,GLM-5.3-Flash 定价 $0.075/$0.25(每百万 token)
- 新增 Qwen3.8 系列、豆包 Seed 2.x 全系、DeepSeek-V4 GA、MiniMax-M2.7/M3 等共 74 个模型条目;价格更新 58 处、能力信息修正 56 处
## v0.7.8 (2026-08-25)
- **修复模型用量上下显示不同步**:上方用量摘要刷新后下方进度条仍停留旧数据(如 24% vs 8%),用量查询状态提升至卡片层统一持有,两处同帧更新
- **实验功能开关同步 kimi-code 上游 7 项旗标**:新增 Tower 模式、子代理 Fork 上下文、WaitFor 工具、自动会话标题;移除已废弃的 ACP v2 开关(手写配置仍完整保留)
- 自动刷新间隔改为每卡片恰好一份定时器,行为不变
## v0.7.7 (2026-08-22)
- **双区域 OAuth 支持(适配 kimi-code 0.38.0)**:适配 mainland-cn `auth.kimi.com` / global `auth.kimi.ai`(#2862),按 provider 的 oauth ref 推导凭据文件与刷新端点,global 账号(`credentials/kimi-code-env-<sha256>.json`)可正常查询用量
- **内置登录区域选择**:应用内 Kimi 登录对话框新增「中国大陆 / 国际版 (kimi.ai)」,登录成功后按官方 CLI 行为 provision `[providers."managed:kimi-code"]`(global 写 `oauthHost`,cn 不写)
- **修复**:手动添加模型后别名自动跟随为 `<provider>/<model-id>`;managed(OAuth)供应商自填 `api_key` 保存时不再被清空
- models.dev 快照刷新至 **7246 模型 / 193 供应商**,新增 DeepSeek V4 Flash Vision Exp、Ox Alpha Free(opencode-go,免费)
## v0.7.6 (2026-08-18)
- **新增 OpenCode Go 套餐用量查询**:卡片用量页脚显示 **5 小时滚动 / 7 天 / 30 天** 三窗口的已用百分比与重置时间(数据源 `GET https://opencode.ai/zen/go/v1/usage`)
- 已配置 OpenCode Go API Key 的老用户无需手动设置,程序按 `base_url` 自动识别并启用;已适配 opencode.ai 的 Cloudflare 拦截(携带浏览器 User-Agent)
- 插件目录被运行中进程占用时的错误提示给出明确指引
## v0.7.5 (2026-08-15)
- **子代理模型池适配 kimi-code 0.36.0 新引擎**:适配 `[secondary_model]` 的 `default_model` + `[secondary_model.models]` 表 + `force`;写入时 `model` 与 `default_model` 双写同值,v1 legacy 与 v2 引擎均可识别
- **高级设置页新增模型池管理**:升级旧配置、增删池条目、编辑路由描述(渲染进主代理 Agent/AgentSwarm 工具描述);支持 force 强制默认模型(带二次确认);保存前 6 类错误前置拦截
- 新增 32 个单元测试覆盖池读写与校验逻辑(vitest)
- **热力图详情改为按需加载**:双击方块调用后端 `get_day_detail` 聚合,不再受 7d / 30d 范围限制
- models.dev 快照刷新至 2026-08-15(6583 模型 / 185 供应商),新增 GLM-5.3 等
## v0.7.4 (2026-08-09)
- **热力图方块双击弹窗**:查看当日模型用量分布,每种模型独立展示 Token 用量 / 请求次数 / 费用 / 缓存命中率(此前仅有 token 数)
- 后端 `[dashboard] by_model` 从纯 token 数升级为结构化对象(含 requests / cost / cacheHitRate),弹窗与柱状图双击均展示全量指标
- 范围外日期(不在当前 7d / 30d / all 内)不可双击;Escape / 遮罩 / 关闭按钮均可关闭弹窗
## v0.7.3 (2026-08-07)
- **Kimi Code WebUI 应用内嵌窗口**:新版 Web 界面以独立顶层窗口打开(1100×750、可缩放、居中),不再跳转外部浏览器
- **单例 + 服务器自动管理**:窗口最多一个,重复点击自动聚焦;复用已运行的 `kimi web` 服务,自启动的服务器在窗口关闭或应用退出时自动清理
- 「在应用内打开」与「在浏览器打开」两个入口并存(后者复用本地服务器,直接打开 `http://127.0.0.1:58627`)
## v0.7.2 (2026-08-07)
- 「子代理设置」升级为「**高级设置**」:聚合子代理模型指定、实验功能开关与 WebUI 快捷入口
- **新增 Kimi Code WebUI 快捷打开**:高级设置页可将新版 Web 界面以独立窗口在应用内打开,也可在系统浏览器打开(需 kimi-code 0.33+,kimi 命令在 PATH 中)
- **子代理模型指定**:为子代理选择次主力模型后默认绑定该模型,不再继承主模型;实验开关改为滑动开关并修复开启态不可见问题
- 兼容 kimi-code 0.33+(v2 引擎):`loop_control` 改用新键名 `max_attempts_per_step`(旧配置自动迁移,保留旧键兼容 legacy 引擎);Release 附带 `install-macos.sh` 一键安装脚本
## v0.7.1 (2026-08-06)
- **新增「子代理模型(次主力模型)」设置**:选择后子代理默认绑定该模型,不再继承主模型,仅保存模型引用即可继承上下文长度与思考模式
- **仪表盘用量统计**:识别子代理请求(`__secondary__`)归为「子代理模型(估算)」独立展示,并缓存次主力模型定价,删除配置后历史记录仍稳定估算
- 自定义网关模型(未收录 models.dev)按官方同名模型跨 provider 匹配价格,不再落入兜底估算
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,23 @@
# KimiSwitch v0.7.21
## 新增
- **models.dev 在线同步**:高级设置新增「模型参考数据(models.dev)」卡片——一键在线拉取 models.dev 最新快照,新模型发布后无需等待 KimiSwitch 发版:
- 覆盖三类参考数据:模型上下文长度、能力标记(思考 / 工具 / 图像 / 视频)、单价($/M tokens)
- 同步后立即生效:供应商编辑页的参数自动填充、仪表盘用量计价(Rust 侧价格索引按快照文件 mtime 自动重建,无需重启应用)
- 数据落在 `~/.kimi-switch/models-dev.json`,打包内置快照始终作为兜底;无效副本自动回退
- 支持一键「恢复内置数据」,随时可重新同步
- 状态行显示当前数据来源(在线同步 / 随版本内置)、快照日期与模型 / 供应商数量
- 代理环境自动适配:依次读取 `HTTPS_PROXY` / `HTTP_PROXY` 环境变量与 git `http.proxy` 配置
## 修复
- **构建链路数据漂移**:`fetch-models-dev.mjs` 此前只写 `src/lib/models-dev.json`(Rust 计价内嵌),前端实际加载的 `public/models-dev.json` 需手工拷贝,历史上已两次滞后。现在两个文件由脚本同步写入,彻底消除漂移
## 适配
- **kimi-code 2.1.0 已适配确认**:实验 flag 注册表(5 个)、config 模式、用量 / quota 格式均无变化;上游 `[watch]` 默认关闭与现有开关行为一致;新增 `tui_mode`(`~/.kimi-code/tui.toml`)不涉及配置管理面,暂无适配需求
## 数据
- models-dev 快照同步至 2026-09-23(8,126 模型 / 223 供应商)
@@ -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 个测试通过
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.7.20",
"version": "0.7.23",
"type": "module",
"scripts": {
"dev": "vite",
+91821 -82126
View File
File diff suppressed because it is too large. Load diff
+16 -3
View File
@@ -1,9 +1,17 @@
#!/usr/bin/env node
/**
* Fetch the latest model reference data from models.dev and write:
* 1. src/lib/models-dev.json — compact per-model snapshot (frontend)
* 2. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
* 3. src/lib/models-dev.last-good.json — local backup of the last success
* 1. src/lib/models-dev.json — compact per-model snapshot; embedded
* into the Rust binary by dashboard.rs
* (include_str!) and loaded by the
* frontend as fallback
* 2. public/models-dev.json — the same snapshot as a static asset;
* this is what the frontend actually
* serves at runtime (must stay in sync
* with #1 — they serve different halves
* of the app)
* 3. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
* 4. src/lib/models-dev.last-good.json — local backup of the last success
*
* The data source is https://models.dev/api.json (176 providers; each model
* carries `cost` = { input, output, cache_read?, cache_write? } in $/M tokens).
@@ -63,6 +71,8 @@ if (proxy && process.env.NODE_USE_ENV_PROXY !== "1") {
const SOURCE_URL = "https://models.dev/api.json";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const SNAPSHOT = join(ROOT, "src", "lib", "models-dev.json");
// Static-asset copy served to the frontend (see header — must equal SNAPSHOT).
const SNAPSHOT_PUBLIC = join(ROOT, "public", "models-dev.json");
const FULL = join(ROOT, "src", "lib", "models-dev-full.json");
// Local backup of the last successful snapshot. Not committed to git (see
// .gitignore); the committed models-dev.json itself is the versioned fallback.
@@ -168,12 +178,15 @@ async function main() {
}
await mkdir(dirname(SNAPSHOT), { recursive: true });
await mkdir(dirname(SNAPSHOT_PUBLIC), { recursive: true });
const json = JSON.stringify(snapshot, null, 2) + "\n";
await writeFile(SNAPSHOT, json, "utf8");
await writeFile(SNAPSHOT_PUBLIC, json, "utf8");
await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8");
await writeFile(BACKUP, json, "utf8");
console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`);
console.log(`models.dev static asset: -> ${SNAPSHOT_PUBLIC}`);
console.log(
`models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`,
);
+5 -2
View File
@@ -3,7 +3,8 @@
# (git.codingplan.site/admin/KimiCodeSwitch).
#
# Usage: publish-gitea.sh <tag> <asset> [asset...]
# tag : e.g. v0.7.15; notes read from release-notes-<tag>.md
# tag : e.g. v0.7.15; notes read from docs/release-notes/<tag>.md
# (legacy root release-notes-<tag>.md as fallback)
# assets : files to attach (MSI, install-macos.sh, ...)
#
# Auth: reuses the stored git credential for git.codingplan.site
@@ -15,7 +16,9 @@ REPO="admin/KimiCodeSwitch"
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
shift
NOTES_FILE="release-notes-${TAG}.md"
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'
import json, sys
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.20"
version = "0.7.23"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.20"
version = "0.7.23"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+59 -44
View File
@@ -6,7 +6,8 @@ use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::time::SystemTime;
use walkdir::WalkDir;
// ---------------------------------------------------------------------------
@@ -534,50 +535,62 @@ struct ModelsDevCost {
cache_write: Option<f64>,
}
/// Compiled-in models.dev snapshot (`src/lib/models-dev.json`, generated by
/// scripts/fetch-models-dev.mjs before every build via the `pretauri` hook).
/// Keyed by "<provider>/<model>", lowercased.
const MODELS_DEV_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
static INDEX: OnceLock<HashMap<String, ModelsDevCost>> = OnceLock::new();
INDEX.get_or_init(|| {
let mut map = HashMap::new();
let Ok(v) = serde_json::from_str::<serde_json::Value>(MODELS_DEV_SNAPSHOT) else {
return map;
};
let Some(obj) = v.as_object() else {
return map;
};
for (key, entry) in obj {
// Skip the "last_updated" metadata key and entries without cost.
if !entry.is_object() || entry.get("cost").is_none() {
continue;
/// models.dev price index over the effective snapshot (runtime-synced copy
/// when present, else the compiled-in `src/lib/models-dev.json` generated by
/// scripts/fetch-models-dev.mjs). Keyed by "<provider>/<model>", lowercased.
/// Cached and rebuilt only when the synced file's mtime changes, so an online
/// sync (models_dev::sync_models_dev) refreshes prices without a restart.
fn models_dev_cost_index() -> Arc<HashMap<String, ModelsDevCost>> {
static CACHE: OnceLock<RwLock<Option<(Option<SystemTime>, Arc<HashMap<String, ModelsDevCost>>)>>>=
OnceLock::new();
let cache = CACHE.get_or_init(|| RwLock::new(None));
let stamp = fs::metadata(crate::models_dev::synced_path())
.and_then(|m| m.modified())
.ok();
{
let cached = cache.read().expect("price index lock");
if let Some((cached_stamp, idx)) = cached.as_ref() {
if *cached_stamp == stamp {
return idx.clone();
}
let Some(cost) = entry.get("cost").and_then(|c| c.as_object()) else {
continue;
};
let num = |k: &str| cost.get(k).and_then(|x| x.as_f64());
let (Some(input), Some(output)) = (num("input"), num("output")) else {
continue;
};
map.insert(
key.to_ascii_lowercase(),
ModelsDevCost {
input,
output,
cache_read: num("cache_read").unwrap_or(0.0),
cache_write: num("cache_write"),
},
);
}
map
})
}
let mut map = HashMap::new();
let v = serde_json::from_str::<serde_json::Value>(&crate::models_dev::effective_snapshot());
if let Ok(v) = v {
if let Some(obj) = v.as_object() {
for (key, entry) in obj {
// Skip the "last_updated" metadata key and entries without cost.
if !entry.is_object() || entry.get("cost").is_none() {
continue;
}
let Some(cost) = entry.get("cost").and_then(|c| c.as_object()) else {
continue;
};
let num = |k: &str| cost.get(k).and_then(|x| x.as_f64());
let (Some(input), Some(output)) = (num("input"), num("output")) else {
continue;
};
map.insert(
key.to_ascii_lowercase(),
ModelsDevCost {
input,
output,
cache_read: num("cache_read").unwrap_or(0.0),
cache_write: num("cache_write"),
},
);
}
}
}
let idx = Arc::new(map);
*cache.write().expect("price index lock") = Some((stamp, idx.clone()));
idx
}
/// Warm the compiled-in models.dev price index off the first dashboard open:
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
/// background thread at startup instead of lazily on the first get_summary.
/// Warm the models.dev price index off the first dashboard open: parsing the
/// ~1.3 MB snapshot costs tens of milliseconds, so build it on a background
/// thread at startup instead of lazily on the first get_summary.
pub fn warm_price_index() {
std::thread::spawn(|| {
let _ = models_dev_cost_index();
@@ -608,14 +621,15 @@ fn provider_rank(key: &str) -> usize {
/// (deterministic tie-break). Returns None when nothing matches.
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
let lower = model_name.to_ascii_lowercase();
if let Some(cost) = models_dev_cost_index().get(&lower) {
let index = models_dev_cost_index();
if let Some(cost) = index.get(&lower) {
return Some((lower, *cost));
}
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
let bare_l = bare.to_ascii_lowercase();
// Match on the model part after the last '/', not the full key: aliases
// strip the family prefix (record "k2.5" vs models.dev "kimi-k2.5").
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
let mut matches: Vec<(&String, &ModelsDevCost)> = index
.iter()
.filter(|(key, _)| {
key.rsplit_once('/')
@@ -695,7 +709,8 @@ fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
fn priced_suffix_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
let bare_l = bare.to_ascii_lowercase();
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
let index = models_dev_cost_index();
let mut matches: Vec<(&String, &ModelsDevCost)> = index
.iter()
.filter(|(key, cost)| {
(cost.input > 0.0 || cost.output > 0.0)
+5
View File
@@ -4,6 +4,7 @@ pub mod dashboard;
pub mod db;
pub mod kimi_code_io;
pub mod models;
pub mod models_dev;
pub mod oauth;
pub mod pi_io;
pub mod plugins;
@@ -119,6 +120,10 @@ pub fn run() {
commands::kimi_oauth_start,
commands::kimi_oauth_poll,
commands::get_experimental_env_status,
models_dev::sync_models_dev,
models_dev::get_models_dev_status,
models_dev::get_models_dev_snapshot,
models_dev::reset_models_dev,
dashboard::get_paths,
dashboard::get_prices,
dashboard::get_summary,
+352
View File
@@ -0,0 +1,352 @@
//! models.dev reference-data sync.
//!
//! The app carries a compiled-in models.dev snapshot (generated by
//! scripts/fetch-models-dev.mjs before every build). This module lets the
//! user refresh it at runtime from https://models.dev/api.json without
//! rebuilding: the synced copy lives at `~/.kimi-switch/models-dev.json` and
//! takes precedence whenever it exists and parses. The dashboard price index
//! (see dashboard::models_dev_cost_index) re-checks that file's mtime so new
//! prices take effect without an app restart.
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use chrono::Utc;
use serde::Serialize;
use serde_json::Value;
use crate::db::kimi_switch_data_dir;
/// Compiled-in snapshot — the same file the dashboard price index used to
/// embed directly. Always available; the runtime-synced copy (if any) wins.
const BUILTIN_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
const SOURCE_URL: &str = "https://models.dev/api.json";
const SYNCED_FILE: &str = "models-dev.json";
/// Where the runtime-synced snapshot lives. The dashboard stats this path's
/// mtime to decide when to rebuild its price index.
pub fn synced_path() -> PathBuf {
kimi_switch_data_dir().join(SYNCED_FILE)
}
/// Read + validate the synced copy. Anything unparsable or missing
/// `last_updated` is treated as absent so the built-in snapshot takes over.
fn read_synced() -> Option<String> {
let raw = fs::read_to_string(synced_path()).ok()?;
let v: Value = serde_json::from_str(&raw).ok()?;
if v.get("last_updated").and_then(|x| x.as_str()).is_some() {
Some(raw)
} else {
None
}
}
/// The snapshot the app should use right now: the synced copy when present
/// and valid, otherwise the compiled-in one.
pub fn effective_snapshot() -> String {
read_synced().unwrap_or_else(|| BUILTIN_SNAPSHOT.to_string())
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelsDevStatus {
/// "synced" = runtime online copy; "builtin" = compiled at build time.
pub source: &'static str,
pub last_updated: String,
pub model_count: u64,
pub provider_count: u64,
}
/// Count models / providers and read the `last_updated` stamp from a
/// snapshot string ("<provider>/<model>" keys, plus the `last_updated` meta).
fn status_of(source: &'static str, raw: &str) -> ModelsDevStatus {
let v: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
let mut model_count = 0u64;
let mut providers = HashSet::new();
if let Some(obj) = v.as_object() {
for (k, entry) in obj {
if k == "last_updated" || !entry.is_object() {
continue;
}
model_count += 1;
if let Some((p, _)) = k.split_once('/') {
providers.insert(p.to_string());
}
}
}
ModelsDevStatus {
source,
last_updated: v
.get("last_updated")
.and_then(|x| x.as_str())
.unwrap_or("unknown")
.to_string(),
model_count,
provider_count: providers.len() as u64,
}
}
fn status(source: &'static str) -> ModelsDevStatus {
match source {
"synced" => {
let raw = read_synced().unwrap_or_default();
status_of("synced", &raw)
}
_ => status_of("builtin", BUILTIN_SNAPSHOT),
}
}
/// Numbers serialize as integers when they are whole (matches the mjs
/// script's JSON output, e.g. context 1000000 not 1000000.0).
fn num(v: f64) -> Value {
if v.fract() == 0.0 && v.abs() < 9e15 {
Value::from(v as i64)
} else {
Value::from(v)
}
}
/// Transform models.dev api.json into the compact snapshot shape produced by
/// scripts/fetch-models-dev.mjs (the canonical field-picking logic lives
/// there; this is the runtime port). Pure — unit-tested below.
fn build_snapshot(raw: &Value) -> Result<Value, String> {
let providers = raw.as_object().ok_or("api.json is not a JSON object")?;
let mut snapshot = serde_json::Map::new();
snapshot.insert(
"last_updated".into(),
Value::String(Utc::now().format("%Y-%m-%d").to_string()),
);
for (provider_id, provider) in providers {
let Some(models) = provider.get("models").and_then(|m| m.as_object()) else {
continue;
};
for (model_id, m) in models {
let mut entry = serde_json::Map::new();
if let Some(name) = m.get("name").and_then(|x| x.as_str()) {
entry.insert("name".into(), Value::String(name.to_string()));
}
// Context 0 means "not applicable" (image/audio models) — treat as
// missing so callers fall back to defaults.
if let Some(ctx) = m
.pointer("/limit/context")
.and_then(|x| x.as_f64())
.filter(|c| *c > 0.0)
{
entry.insert("context".into(), num(ctx));
}
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));
}
}
if let Some(input) = m.pointer("/modalities/input").and_then(|x| x.as_array()) {
let has = |s: &str| input.iter().any(|v| v.as_str() == Some(s));
if has("image") {
entry.insert("image".into(), Value::Bool(true));
}
if has("video") {
entry.insert("video".into(), Value::Bool(true));
}
}
if let Some(cost) = m.get("cost").and_then(|c| c.as_object()) {
let mut c = serde_json::Map::new();
for k in ["input", "output", "cache_read", "cache_write"] {
if let Some(n) = cost.get(k).and_then(|x| x.as_f64()) {
c.insert(k.into(), num(n));
}
}
if !c.is_empty() {
entry.insert("cost".into(), Value::Object(c));
}
}
snapshot.insert(format!("{provider_id}/{model_id}"), Value::Object(entry));
}
}
Ok(Value::Object(snapshot))
}
/// reqwest is built without the default system-proxy feature, so proxies must
/// be attached explicitly. Mirror fetch-models-dev.mjs: env vars first, then
/// git's http.proxy.
fn proxy_url() -> Option<String> {
let env = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
.iter()
.find_map(|k| std::env::var(k).ok().filter(|p| !p.trim().is_empty()));
env.or_else(git_http_proxy)
}
fn git_http_proxy() -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", "http.proxy"])
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
fn http_client() -> Result<reqwest::Client, String> {
let mut b = reqwest::Client::builder()
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(60));
if let Some(p) = proxy_url() {
b = b.proxy(reqwest::Proxy::all(&p).map_err(|e| format!("invalid proxy '{p}': {e}"))?);
}
b.build().map_err(|e| format!("failed to build HTTP client: {e}"))
}
/// Download api.json, transform it and atomically replace the synced copy.
/// On any failure the previous snapshot (synced or built-in) stays untouched.
async fn sync_from_remote() -> Result<ModelsDevStatus, String> {
let client = http_client()?;
let raw: Value = client
.get(SOURCE_URL)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("request failed: {e}"))?
.error_for_status()
.map_err(|e| format!("models.dev returned {e}"))?
.json()
.await
.map_err(|e| format!("failed to parse api.json: {e}"))?;
let snapshot = build_snapshot(&raw)?;
let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())? + "\n";
let path = synced_path();
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?;
}
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, &json).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?;
// Windows rename replaces an existing destination (MOVEFILE_REPLACE_EXISTING).
fs::rename(&tmp, &path).map_err(|e| {
let _ = fs::remove_file(&tmp);
format!("failed to replace {}: {e}", path.display())
})?;
Ok(status_of("synced", &json))
}
/// Refresh the models.dev snapshot from the network.
#[tauri::command]
pub async fn sync_models_dev() -> Result<ModelsDevStatus, String> {
sync_from_remote().await
}
/// Where the current reference data comes from (for the settings card).
#[tauri::command]
pub fn get_models_dev_status() -> Result<ModelsDevStatus, String> {
Ok(if read_synced().is_some() {
status("synced")
} else {
status("builtin")
})
}
/// The synced snapshot's raw JSON, or None when the frontend should keep
/// using the bundled static asset.
#[tauri::command]
pub fn get_models_dev_snapshot() -> Result<Option<String>, String> {
Ok(read_synced())
}
/// Drop the synced copy and fall back to the compiled-in snapshot.
#[tauri::command]
pub fn reset_models_dev() -> Result<ModelsDevStatus, String> {
match fs::remove_file(synced_path()) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("failed to remove synced snapshot: {e}")),
}
Ok(status("builtin"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_snapshot_picks_reference_fields() {
let raw = json!({
"moonshotai": {
"models": {
"kimi-k3": {
"name": "Kimi K3",
"limit": { "context": 262144, "output": 16384 },
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"cost": { "input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 0.0 }
},
"kimi-image": {
"name": "Kimi Image",
"limit": { "context": 0 },
"modalities": { "input": ["text", "image", "video"] }
}
}
},
"zhipuai": {
"models": {
"glm-5.3": {
"name": "GLM-5.3",
"reasoning": false,
"cost": { "input": 0.6, "output": 2.2 }
}
}
}
});
let snap = build_snapshot(&raw).unwrap();
let obj = snap.as_object().unwrap();
assert!(obj.contains_key("last_updated"));
let k3 = &obj["moonshotai/kimi-k3"];
assert_eq!(k3["name"], "Kimi K3");
assert_eq!(k3["context"], 262144);
assert_eq!(k3["reasoning"], true);
assert_eq!(k3["tool_call"], true);
assert_eq!(k3["structured_output"], true);
assert_eq!(k3["image"], true);
assert!(k3.get("video").is_none());
assert_eq!(k3["cost"]["input"], 3.0);
assert_eq!(k3["cost"]["cache_read"], 0.3);
assert_eq!(k3["cost"]["cache_write"], 0.0);
// Context 0 → dropped; video modality → flagged.
let img = &obj["moonshotai/kimi-image"];
assert!(img.get("context").is_none());
assert_eq!(img["video"], true);
assert!(img.get("cost").is_none());
// Explicit false is omitted (same as the mjs script).
let glm = &obj["zhipuai/glm-5.3"];
assert!(glm.get("reasoning").is_none());
assert_eq!(glm["cost"]["input"], 0.6);
}
#[test]
fn status_counts_models_and_providers() {
let raw = r#"{
"last_updated": "2026-09-21",
"moonshotai/kimi-k3": { "cost": { "input": 3.0 } },
"moonshotai/kimi-k2": { "name": "K2" },
"zhipuai/glm-5.3": {}
}"#;
let st = status_of("builtin", raw);
assert_eq!(st.last_updated, "2026-09-21");
assert_eq!(st.model_count, 3);
assert_eq!(st.provider_count, 2);
}
#[test]
fn num_serializes_whole_numbers_as_integers() {
assert_eq!(num(262144.0), json!(262144));
assert_eq!(num(0.3), json!(0.3));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.20",
"version": "0.7.23",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+80 -14
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"}
+10 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
@@ -142,6 +142,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 +469,8 @@ export function ProviderEdit({
<AgentSettingsPanel
rawOther={rawOther}
onChange={onRawOtherChange}
models={modelsByAlias}
defaultModel={defaultModel}
/>
)}
</>
+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
+128 -5
View File
@@ -24,6 +24,15 @@ import {
type ValidationErrorKey,
} from "../lib/subagent-settings";
import { Card, Toggle } from "./ui/controls";
import { applyModelsDevSnapshot, reloadModelsDev } from "../lib/models-dev";
/** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */
interface ModelsDevStatus {
source: "synced" | "builtin";
lastUpdated: string;
modelCount: number;
providerCount: number;
}
interface SubagentSettingsPageProps {
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
@@ -34,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", "max", "xhigh"] 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 }> = {
@@ -155,13 +167,65 @@ export function SubagentSettingsPage({
const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
/** models.dev snapshot provenance (synced copy vs bundled asset). */
const [modelsDevStatus, setModelsDevStatus] = useState<ModelsDevStatus | null>(null);
/** Sync/restore button in flight; blocks the card's buttons. */
const [modelsDevBusy, setModelsDevBusy] = useState(false);
/** Last sync error (inline) and success flag (auto-cleared on next action). */
const [syncError, setSyncError] = useState<string | null>(null);
const [syncOk, setSyncOk] = useState(false);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => setModelsDevStatus(null));
}, []);
const refreshModelsDevStatus = () => {
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => {});
};
const handleModelsDevSync = async () => {
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("sync_models_dev");
setModelsDevStatus(st);
// Hot-swap the frontend index; the dashboard price index rebuilds on
// its next query (mtime-triggered on the Rust side).
const raw = await invoke<string | null>("get_models_dev_snapshot");
if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record<string, unknown>);
setSyncOk(true);
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
} finally {
setModelsDevBusy(false);
}
};
const handleModelsDevRestore = async () => {
if (!confirm(t("modelsDataRestoreConfirm"))) return;
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("reset_models_dev");
setModelsDevStatus(st);
await reloadModelsDev();
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
refreshModelsDevStatus();
} finally {
setModelsDevBusy(false);
}
};
const flags = getExperimentalFlags(rawOther);
const pool = getSubagentModelPool(rawOther);
const poolKeys = pool ? Object.keys(pool.models) : [];
@@ -220,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;
};
@@ -398,6 +461,66 @@ export function SubagentSettingsPage({
</div>
</Card>
{/* models.dev reference data: online sync / restore bundled */}
<Card title={t("modelsDataSection")}>
<p className="text-xs text-content-muted">{t("modelsDataDesc")}</p>
{modelsDevStatus && (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span
className={`shrink-0 rounded border px-1.5 py-0.5 text-xs ${
modelsDevStatus.source === "synced"
? "border-blue-500/30 text-blue-600 dark:text-blue-400"
: "border-border text-content-muted"
}`}
>
{modelsDevStatus.source === "synced"
? t("modelsDataSourceSynced")
: t("modelsDataSourceBuiltin")}
</span>
<span className="text-xs text-content-muted">
{t("modelsDataStats", {
date: modelsDevStatus.lastUpdated,
models: modelsDevStatus.modelCount.toLocaleString(),
providers: modelsDevStatus.providerCount.toLocaleString(),
})}
</span>
</div>
)}
{syncOk && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-3 py-2 text-xs text-green-700 dark:text-green-400">
{t("modelsDataSyncOk")}
</div>
)}
{syncError && (
<div
role="alert"
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-xs text-red-700 dark:text-red-400"
>
{t("modelsDataSyncFailed")}: {syncError}
</div>
)}
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevSync}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{modelsDevBusy ? t("modelsDataSyncing") : t("modelsDataSyncNow")}
</button>
{modelsDevStatus?.source === "synced" && (
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevRestore}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("modelsDataRestore")}
</button>
)}
</div>
</Card>
{/* Subagent: secondary model picker / pool + inherited settings */}
<Card title={t("secondaryModelSection")}>
{isPoolView && pool ? (
+22 -16
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) => (
<button
key={opt.key}
type="button"
disabled={disabled}
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"
}`}
>
{opt.label}
</button>
))}
{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
type="button"
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>
);
}
+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}`;
+24
View File
@@ -164,6 +164,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",
@@ -594,6 +603,21 @@ export const enTranslations: Record<TranslationKey, string> = {
openWebUIBrowser: "Open in Browser",
webuiOpening: "Opening...",
// models.dev reference data (context / capabilities / pricing)
modelsDataSection: "Model Reference Data (models.dev)",
modelsDataDesc:
"Model context limits, capability flags, and pricing come from an online models.dev snapshot. Syncing applies new models immediately — no app update needed.",
modelsDataSourceSynced: "Online sync",
modelsDataSourceBuiltin: "Bundled with release",
modelsDataStats: "{date} · {models} models / {providers} providers",
modelsDataSyncNow: "Sync Now",
modelsDataSyncing: "Syncing...",
modelsDataSyncOk: "Synced — model parameters and pricing refreshed",
modelsDataSyncFailed: "Sync failed",
modelsDataRestore: "Restore Built-in Data",
modelsDataRestoreConfirm:
"Delete the local synced copy and fall back to the snapshot bundled with this release? You can sync again at any time.",
// Plugin marketplace
pluginMarketplace: "Plugin Marketplace",
pluginMarketplaceSubtitle:
+21
View File
@@ -161,6 +161,12 @@ export const zhTranslations = {
thinkingLow: "低",
thinkingMedium: "中",
thinkingHigh: "高",
thinkingMax: "Max",
thinkingXHigh: "XHigh",
thinkingEffortUnsupported: "当前默认模型不支持思考。",
thinkingEffortTierUnsupported: "当前默认模型不支持该档位(支持:{levels})。",
thinkingEffortTierUndeclared: "该模型未声明思考等级,上游可能拒绝此档位。",
thinkingEffortDependsOnDefaultModel: "实际生效的档位取决于当前默认模型。",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数",
@@ -583,6 +589,21 @@ export const zhTranslations = {
openWebUIBrowser: "在浏览器打开",
webuiOpening: "打开中...",
// models.dev reference data (context / capabilities / pricing)
modelsDataSection: "模型参考数据(models.dev)",
modelsDataDesc:
"模型上下文长度、能力标记与单价参考来自 models.dev 在线快照。同步后新模型的参数与价格立即生效,无需等待新版本。",
modelsDataSourceSynced: "在线同步",
modelsDataSourceBuiltin: "随版本内置",
modelsDataStats: "{date} · {models} 模型 / {providers} 供应商",
modelsDataSyncNow: "立即同步",
modelsDataSyncing: "同步中...",
modelsDataSyncOk: "已同步,模型参数与单价已刷新",
modelsDataSyncFailed: "同步失败",
modelsDataRestore: "恢复内置数据",
modelsDataRestoreConfirm:
"删除本地同步副本并回退到随版本内置的快照数据?之后可随时重新同步。",
// Plugin marketplace
pluginMarketplace: "插件市场",
pluginMarketplaceSubtitle:
+21 -4
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");
});
});
// ---------------------------------------------------------------------------
-5
View File
@@ -65,11 +65,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"
+106304 -97397
View File
File diff suppressed because it is too large. Load diff
+91591 -83988
View File
File diff suppressed because it is too large. Load diff
+60 -23
View File
@@ -3,13 +3,18 @@
* (see scripts/fetch-models-dev.mjs).
*
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
* parsing a 1.3 MB JSON literal at startup blocks first paint. It is served
* as a static asset (`/models-dev.json`) and loaded once in the background.
* `getModelRef` stays synchronous and returns undefined until the index is
* ready — callers already fall back to defaults — and `modelsDevReady()`
* lets the app re-render once the index arrives.
* parsing a 1.3 MB JSON literal at startup blocks first paint. Loading order
* (see modelsDevReady): the runtime-synced copy under ~/.kimi-switch/
* (via models_dev.rs, written by the 高级设置 sync button) when present,
* otherwise the bundled static asset (`/models-dev.json`). `getModelRef`
* stays synchronous and returns undefined until the index is ready — callers
* already fall back to defaults — and `modelsDevReady()` lets the app
* re-render once the index arrives. `applyModelsDevSnapshot` hot-swaps the
* index after an online sync without a restart.
*/
import { invoke } from "@tauri-apps/api/core";
export interface ModelCost {
input?: number;
output?: number;
@@ -49,32 +54,64 @@ function buildIndex(raw: Record<string, unknown>) {
return lower;
}
/**
* Swap in a new snapshot (initial load, online sync, or restore-to-builtin)
* and re-notify listeners so context/capability/price columns re-render.
* Listeners stay registered across reloads by design.
*/
function applySnapshot(raw: Record<string, unknown>) {
byLowerKey = buildIndex(raw);
for (const cb of readyListeners) cb();
}
async function fetchBundled(): Promise<Record<string, unknown>> {
const r = await fetch(`${import.meta.env.BASE_URL}models-dev.json`);
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
return (await r.json()) as Record<string, unknown>;
}
let loadPromise: Promise<void> | null = null;
export function modelsDevReady(): Promise<void> {
if (!loadPromise) {
loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
.then((r) => {
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
return r.json() as Promise<Record<string, unknown>>;
})
.then((raw) => {
byLowerKey = buildIndex(raw);
const listeners = readyListeners;
readyListeners = [];
for (const cb of listeners) cb();
})
.catch((err) => {
// A failed load is permanent for this session: fall back to defaults.
byLowerKey = {};
loadPromise = null; // allow one retry next time
console.warn("models-dev.json load failed:", err);
});
// Prefer the runtime-synced copy (~/.kimi-switch/models-dev.json, via
// models_dev.rs); outside Tauri (or without one) fall back to the bundled
// static asset. A failed load is permanent for this session.
loadPromise = (async () => {
let raw: Record<string, unknown> | null = null;
try {
const synced = await invoke<string | null>("get_models_dev_snapshot");
if (synced) raw = JSON.parse(synced) as Record<string, unknown>;
} catch {
/* not running inside Tauri — use the bundled asset */
}
applySnapshot(raw ?? (await fetchBundled()));
})().catch((err) => {
byLowerKey = {};
loadPromise = null; // allow one retry next time
console.warn("models-dev.json load failed:", err);
});
}
return loadPromise;
}
/** Register a callback invoked once the models.dev index is ready. */
/**
* Apply a freshly synced snapshot (already fetched by the caller) and notify
* listeners so the UI refreshes without a restart.
*/
export function applyModelsDevSnapshot(raw: Record<string, unknown>): void {
applySnapshot(raw);
}
/**
* Reload from the bundled static asset (after "restore built-in data" —
* the synced copy was already deleted on the Rust side).
*/
export function reloadModelsDev(): Promise<void> {
return fetchBundled().then(applySnapshot);
}
/** Register a callback invoked whenever the models.dev index changes. */
export function onModelsDevReady(cb: () => void): void {
if (byLowerKey) {
cb();
+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/
* max/xhigh 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",
"max",
"xhigh",
] 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" } }
),
};
}
+5 -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