Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11286419b1 | ||
|
|
26e783c2ba | ||
|
|
11f87fd9af |
No files matched your search
@@ -73,7 +73,8 @@ jobs:
|
|||||||
id: notes
|
id: notes
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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'
|
echo 'body<<RELEASE_BODY_EOF'
|
||||||
if [ -f "$FILE" ]; then
|
if [ -f "$FILE" ]; then
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
**Kimi Switch** — Windows 桌面端的 Kimi Code CLI 配置管理器,统一管理多家 LLM 供应商、模型、图标、连通性、用量统计与版本更新。
|
||||||
|
|
||||||
|
本文件汇总各版本要点,按版本倒序排列;每个版本的**完整 release notes**(含安装说明、平台产物、已知问题)见 [`docs/release-notes/`](./docs/release-notes/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 供应商)
|
||||||
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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+88829
-82134
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Fetch the latest model reference data from models.dev and write:
|
* Fetch the latest model reference data from models.dev and write:
|
||||||
* 1. src/lib/models-dev.json — compact per-model snapshot (frontend)
|
* 1. src/lib/models-dev.json — compact per-model snapshot; embedded
|
||||||
* 2. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
|
* into the Rust binary by dashboard.rs
|
||||||
* 3. src/lib/models-dev.last-good.json — local backup of the last success
|
* (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
|
* The data source is https://models.dev/api.json (176 providers; each model
|
||||||
* carries `cost` = { input, output, cache_read?, cache_write? } in $/M tokens).
|
* 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 SOURCE_URL = "https://models.dev/api.json";
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const SNAPSHOT = join(ROOT, "src", "lib", "models-dev.json");
|
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");
|
const FULL = join(ROOT, "src", "lib", "models-dev-full.json");
|
||||||
// Local backup of the last successful snapshot. Not committed to git (see
|
// Local backup of the last successful snapshot. Not committed to git (see
|
||||||
// .gitignore); the committed models-dev.json itself is the versioned fallback.
|
// .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), { recursive: true });
|
||||||
|
await mkdir(dirname(SNAPSHOT_PUBLIC), { recursive: true });
|
||||||
const json = JSON.stringify(snapshot, null, 2) + "\n";
|
const json = JSON.stringify(snapshot, null, 2) + "\n";
|
||||||
await writeFile(SNAPSHOT, json, "utf8");
|
await writeFile(SNAPSHOT, json, "utf8");
|
||||||
|
await writeFile(SNAPSHOT_PUBLIC, json, "utf8");
|
||||||
await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8");
|
await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8");
|
||||||
await writeFile(BACKUP, json, "utf8");
|
await writeFile(BACKUP, json, "utf8");
|
||||||
|
|
||||||
console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`);
|
console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`);
|
||||||
|
console.log(`models.dev static asset: -> ${SNAPSHOT_PUBLIC}`);
|
||||||
console.log(
|
console.log(
|
||||||
`models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`,
|
`models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
# (git.codingplan.site/admin/KimiCodeSwitch).
|
# (git.codingplan.site/admin/KimiCodeSwitch).
|
||||||
#
|
#
|
||||||
# Usage: publish-gitea.sh <tag> <asset> [asset...]
|
# 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, ...)
|
# assets : files to attach (MSI, install-macos.sh, ...)
|
||||||
#
|
#
|
||||||
# Auth: reuses the stored git credential for git.codingplan.site
|
# Auth: reuses the stored git credential for git.codingplan.site
|
||||||
@@ -15,7 +16,8 @@ REPO="admin/KimiCodeSwitch"
|
|||||||
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
|
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
|
||||||
shift
|
shift
|
||||||
|
|
||||||
NOTES_FILE="release-notes-${TAG}.md"
|
NOTES_FILE="docs/release-notes/${TAG}.md"
|
||||||
|
[ -f "$NOTES_FILE" ] || NOTES_FILE="release-notes-${TAG}.md"
|
||||||
json_escape() {
|
json_escape() {
|
||||||
python - "$1" <<'PYEOF'
|
python - "$1" <<'PYEOF'
|
||||||
import json, sys
|
import json, sys
|
||||||
|
|||||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.20"
|
version = "0.7.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.20"
|
version = "0.7.21"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["codingplan.site"]
|
authors = ["codingplan.site"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+59
-44
@@ -6,7 +6,8 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||||
|
use std::time::SystemTime;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -534,50 +535,62 @@ struct ModelsDevCost {
|
|||||||
cache_write: Option<f64>,
|
cache_write: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compiled-in models.dev snapshot (`src/lib/models-dev.json`, generated by
|
/// models.dev price index over the effective snapshot (runtime-synced copy
|
||||||
/// scripts/fetch-models-dev.mjs before every build via the `pretauri` hook).
|
/// when present, else the compiled-in `src/lib/models-dev.json` generated by
|
||||||
/// Keyed by "<provider>/<model>", lowercased.
|
/// scripts/fetch-models-dev.mjs). Keyed by "<provider>/<model>", lowercased.
|
||||||
const MODELS_DEV_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
|
/// 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() -> &'static HashMap<String, ModelsDevCost> {
|
fn models_dev_cost_index() -> Arc<HashMap<String, ModelsDevCost>> {
|
||||||
static INDEX: OnceLock<HashMap<String, ModelsDevCost>> = OnceLock::new();
|
static CACHE: OnceLock<RwLock<Option<(Option<SystemTime>, Arc<HashMap<String, ModelsDevCost>>)>>>=
|
||||||
INDEX.get_or_init(|| {
|
OnceLock::new();
|
||||||
let mut map = HashMap::new();
|
let cache = CACHE.get_or_init(|| RwLock::new(None));
|
||||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(MODELS_DEV_SNAPSHOT) else {
|
let stamp = fs::metadata(crate::models_dev::synced_path())
|
||||||
return map;
|
.and_then(|m| m.modified())
|
||||||
};
|
.ok();
|
||||||
let Some(obj) = v.as_object() else {
|
{
|
||||||
return map;
|
let cached = cache.read().expect("price index lock");
|
||||||
};
|
if let Some((cached_stamp, idx)) = cached.as_ref() {
|
||||||
for (key, entry) in obj {
|
if *cached_stamp == stamp {
|
||||||
// Skip the "last_updated" metadata key and entries without cost.
|
return idx.clone();
|
||||||
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"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
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:
|
/// Warm the models.dev price index off the first dashboard open: parsing the
|
||||||
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
|
/// ~1.3 MB snapshot costs tens of milliseconds, so build it on a background
|
||||||
/// background thread at startup instead of lazily on the first get_summary.
|
/// thread at startup instead of lazily on the first get_summary.
|
||||||
pub fn warm_price_index() {
|
pub fn warm_price_index() {
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
let _ = models_dev_cost_index();
|
let _ = models_dev_cost_index();
|
||||||
@@ -608,14 +621,15 @@ fn provider_rank(key: &str) -> usize {
|
|||||||
/// (deterministic tie-break). Returns None when nothing matches.
|
/// (deterministic tie-break). Returns None when nothing matches.
|
||||||
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
||||||
let lower = model_name.to_ascii_lowercase();
|
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));
|
return Some((lower, *cost));
|
||||||
}
|
}
|
||||||
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
||||||
let bare_l = bare.to_ascii_lowercase();
|
let bare_l = bare.to_ascii_lowercase();
|
||||||
// Match on the model part after the last '/', not the full key: aliases
|
// 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").
|
// 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()
|
.iter()
|
||||||
.filter(|(key, _)| {
|
.filter(|(key, _)| {
|
||||||
key.rsplit_once('/')
|
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)> {
|
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 = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
||||||
let bare_l = bare.to_ascii_lowercase();
|
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()
|
.iter()
|
||||||
.filter(|(key, cost)| {
|
.filter(|(key, cost)| {
|
||||||
(cost.input > 0.0 || cost.output > 0.0)
|
(cost.input > 0.0 || cost.output > 0.0)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod dashboard;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod kimi_code_io;
|
pub mod kimi_code_io;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod models_dev;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod pi_io;
|
pub mod pi_io;
|
||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
@@ -119,6 +120,10 @@ pub fn run() {
|
|||||||
commands::kimi_oauth_start,
|
commands::kimi_oauth_start,
|
||||||
commands::kimi_oauth_poll,
|
commands::kimi_oauth_poll,
|
||||||
commands::get_experimental_env_status,
|
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_paths,
|
||||||
dashboard::get_prices,
|
dashboard::get_prices,
|
||||||
dashboard::get_summary,
|
dashboard::get_summary,
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ import {
|
|||||||
type ValidationErrorKey,
|
type ValidationErrorKey,
|
||||||
} from "../lib/subagent-settings";
|
} from "../lib/subagent-settings";
|
||||||
import { Card, Toggle } from "./ui/controls";
|
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 {
|
interface SubagentSettingsPageProps {
|
||||||
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
|
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
|
||||||
@@ -155,13 +164,65 @@ export function SubagentSettingsPage({
|
|||||||
const [addSelection, setAddSelection] = useState("");
|
const [addSelection, setAddSelection] = useState("");
|
||||||
/** WebUI-open button in flight; disables both buttons while non-null. */
|
/** WebUI-open button in flight; disables both buttons while non-null. */
|
||||||
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(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(() => {
|
useEffect(() => {
|
||||||
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
|
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
|
||||||
.then(setEnv)
|
.then(setEnv)
|
||||||
.catch(() => 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 flags = getExperimentalFlags(rawOther);
|
||||||
const pool = getSubagentModelPool(rawOther);
|
const pool = getSubagentModelPool(rawOther);
|
||||||
const poolKeys = pool ? Object.keys(pool.models) : [];
|
const poolKeys = pool ? Object.keys(pool.models) : [];
|
||||||
@@ -398,6 +459,66 @@ export function SubagentSettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Subagent: secondary model picker / pool + inherited settings */}
|
||||||
<Card title={t("secondaryModelSection")}>
|
<Card title={t("secondaryModelSection")}>
|
||||||
{isPoolView && pool ? (
|
{isPoolView && pool ? (
|
||||||
|
|||||||
@@ -594,6 +594,21 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
openWebUIBrowser: "Open in Browser",
|
openWebUIBrowser: "Open in Browser",
|
||||||
webuiOpening: "Opening...",
|
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
|
// Plugin marketplace
|
||||||
pluginMarketplace: "Plugin Marketplace",
|
pluginMarketplace: "Plugin Marketplace",
|
||||||
pluginMarketplaceSubtitle:
|
pluginMarketplaceSubtitle:
|
||||||
|
|||||||
@@ -583,6 +583,21 @@ export const zhTranslations = {
|
|||||||
openWebUIBrowser: "在浏览器打开",
|
openWebUIBrowser: "在浏览器打开",
|
||||||
webuiOpening: "打开中...",
|
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
|
// Plugin marketplace
|
||||||
pluginMarketplace: "插件市场",
|
pluginMarketplace: "插件市场",
|
||||||
pluginMarketplaceSubtitle:
|
pluginMarketplaceSubtitle:
|
||||||
|
|||||||
+103069
-97686
File diff suppressed because it is too large.
Load diff
+88605
-84002
File diff suppressed because it is too large.
Load diff
+60
-23
@@ -3,13 +3,18 @@
|
|||||||
* (see scripts/fetch-models-dev.mjs).
|
* (see scripts/fetch-models-dev.mjs).
|
||||||
*
|
*
|
||||||
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
|
* 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
|
* parsing a 1.3 MB JSON literal at startup blocks first paint. Loading order
|
||||||
* as a static asset (`/models-dev.json`) and loaded once in the background.
|
* (see modelsDevReady): the runtime-synced copy under ~/.kimi-switch/
|
||||||
* `getModelRef` stays synchronous and returns undefined until the index is
|
* (via models_dev.rs, written by the 高级设置 sync button) when present,
|
||||||
* ready — callers already fall back to defaults — and `modelsDevReady()`
|
* otherwise the bundled static asset (`/models-dev.json`). `getModelRef`
|
||||||
* lets the app re-render once the index arrives.
|
* 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 {
|
export interface ModelCost {
|
||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
@@ -49,32 +54,64 @@ function buildIndex(raw: Record<string, unknown>) {
|
|||||||
return lower;
|
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;
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export function modelsDevReady(): Promise<void> {
|
export function modelsDevReady(): Promise<void> {
|
||||||
if (!loadPromise) {
|
if (!loadPromise) {
|
||||||
loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
|
// Prefer the runtime-synced copy (~/.kimi-switch/models-dev.json, via
|
||||||
.then((r) => {
|
// models_dev.rs); outside Tauri (or without one) fall back to the bundled
|
||||||
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
|
// static asset. A failed load is permanent for this session.
|
||||||
return r.json() as Promise<Record<string, unknown>>;
|
loadPromise = (async () => {
|
||||||
})
|
let raw: Record<string, unknown> | null = null;
|
||||||
.then((raw) => {
|
try {
|
||||||
byLowerKey = buildIndex(raw);
|
const synced = await invoke<string | null>("get_models_dev_snapshot");
|
||||||
const listeners = readyListeners;
|
if (synced) raw = JSON.parse(synced) as Record<string, unknown>;
|
||||||
readyListeners = [];
|
} catch {
|
||||||
for (const cb of listeners) cb();
|
/* not running inside Tauri — use the bundled asset */
|
||||||
})
|
}
|
||||||
.catch((err) => {
|
applySnapshot(raw ?? (await fetchBundled()));
|
||||||
// A failed load is permanent for this session: fall back to defaults.
|
})().catch((err) => {
|
||||||
byLowerKey = {};
|
byLowerKey = {};
|
||||||
loadPromise = null; // allow one retry next time
|
loadPromise = null; // allow one retry next time
|
||||||
console.warn("models-dev.json load failed:", err);
|
console.warn("models-dev.json load failed:", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return loadPromise;
|
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 {
|
export function onModelsDevReady(cb: () => void): void {
|
||||||
if (byLowerKey) {
|
if (byLowerKey) {
|
||||||
cb();
|
cb();
|
||||||
|
|||||||
Reference in new issue
Block a user