From 2614f80d0f5a61f93b5db63e5d60330109a1e7b8 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Wed, 29 Jul 2026 20:59:53 +0800 Subject: [PATCH] =?UTF-8?q?v0.6.0:=20=E9=A2=84=E8=AE=BE=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=20+=20=E4=BE=9B=E5=BA=94=E5=95=86=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增 - 15 条主流供应商预设(Kimi Coding / Moonshot / Anthropic / DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun / SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio / 火山方舟),通过 PresetPickerModal 一键填表 - 供应商余额/套餐查询(卡片底部 UsageFooter):余额类 (DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita) + 套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL + force_refresh + 并发 ≤3 - Rust 端 services/ 模块(balance / coding_plan / usage_types), 参考 cc-switch(MIT,© Jason Young)实现 - query_provider_usage 命令:Rust 端从 config 加载 key, 不经 IPC 序列化 API key - usageKinds 持久化到 SQLite settings(JSON 数组), load 时合并回 config,导出 config.toml 时不写 - detect_provider 启发式:旧用户升级后自动获得账单支持 - save-time 校验:未完成供应商(缺 api_key / base_url / 模型) 保存时弹 confirm 列出原因;新增未提交 back 时静默 drop - docs/PROPOSAL-presets-and-usage.md(v0.4-draft 实施档) - docs/VERIFICATION-CHECKLIST.md(70+ 项手动验证清单) ## 修复 - handleDuplicateProvider 的 alias.slice 漏洞:老数据 非规范 alias 复制不再错位 - handleSelectPreset 不再 auto-save,半成品不会落盘 - handleSwitchProvider 校验取消时自动 refresh 回滚, 不会出现内存改 / 盘没动的脏态 ## 文档 - README / README_EN 致谢段补 cc-switch(预设 + 账单) - README 已知限制与后续建议段(/reload 上游限制调研结论) - i18n 补 5 条校验 key + 11 条预设 UI key + 12 条账单 key 详见 docs/PROPOSAL-presets-and-usage.md 与 docs/VERIFICATION-CHECKLIST.md --- README.md | 53 ++- README_EN.md | 53 ++- docs/PROPOSAL-presets-and-usage.md | 625 ++++++++++++++++++++++++++ docs/VERIFICATION-CHECKLIST.md | 154 +++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 235 +++++++++- src-tauri/src/db.rs | 9 + src-tauri/src/kimi_code_io.rs | 12 + src-tauri/src/lib.rs | 2 + src-tauri/src/models.rs | 9 + src-tauri/src/pi_io.rs | 1 + src-tauri/src/services/balance.rs | 326 ++++++++++++++ src-tauri/src/services/coding_plan.rs | 414 +++++++++++++++++ src-tauri/src/services/mod.rs | 182 ++++++++ src-tauri/src/services/usage_types.rs | 76 ++++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 216 +++++++-- src/components/PresetPickerModal.tsx | 201 +++++++++ src/components/ProviderList.tsx | 10 +- src/components/UsageFooter.tsx | 310 +++++++++++++ src/config/providerPresets.ts | 427 ++++++++++++++++++ src/i18n/en.ts | 47 ++ src/i18n/zh.ts | 47 ++ src/lib/validation.ts | 86 ++++ src/types/index.ts | 6 + 27 files changed, 3442 insertions(+), 67 deletions(-) create mode 100644 docs/PROPOSAL-presets-and-usage.md create mode 100644 docs/VERIFICATION-CHECKLIST.md create mode 100644 src-tauri/src/services/balance.rs create mode 100644 src-tauri/src/services/coding_plan.rs create mode 100644 src-tauri/src/services/mod.rs create mode 100644 src-tauri/src/services/usage_types.rs create mode 100644 src/components/PresetPickerModal.tsx create mode 100644 src/components/UsageFooter.tsx create mode 100644 src/config/providerPresets.ts create mode 100644 src/lib/validation.ts diff --git a/README.md b/README.md index f572fb3..24f199e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - [打包发布](#打包发布) - [发布历史](#发布历史) - [常见问题](#常见问题) +- [已知限制与后续建议](#已知限制与后续建议) - [安全提示](#安全提示) - [致谢](#致谢) @@ -467,7 +468,7 @@ cargo test - [ ] 切换语言后 UI 文案立即更新 - [ ] 启动检查更新 → 下载 → 引导安装 - [ ] 拉取模型时 `max_context_size` 自动填充 -- [ ] 切换供应商后 Kimi Code 会话 `/reload` 立即生效 +- [ ] 切换供应商后 Kimi Code 会话经 `/reload` + `/model` 选回默认(或 `/exit` 重开会话)后生效 ## 打包发布 @@ -533,7 +534,11 @@ Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已 ## 常见问题 **Q: 切换供应商后 Kimi Code 没生效?** -A: 在 Kimi Code 会话里执行 `/reload`(Kimi Code CLI 才会重新读取 `~/.kimi-code/config.toml`)。应用会在 UI 上提示。 +A: 需要分两步: +1. 在 Kimi Code 会话里执行 `/reload`,让 CLI 重新读取 `~/.kimi-code/config.toml`(此时模型**下拉列表**会刷新); +2. **再执行一次 `/model` 选回新的默认模型**,或者直接 `/exit` 重开会话。 + +仅 `/reload` **不会**把新的 `default_model` 自动套用到当前会话——这是 Kimi Code 的已知行为,详见下方[已知限制与后续建议](#已知限制与后续建议)。应用会在 UI 上提示这两步。 **Q: 切换会覆盖其他供应商吗?** A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。 @@ -565,6 +570,48 @@ A: 关闭按钮(X)已改为隐藏到托盘。点击托盘图标(菜单栏 **Q: 检查更新是怎么触发的?** A: 启动时静默检查一次,之后每 8 小时自动检查一次(无网络时静默失败,不弹错)。也可手动:设置弹窗 → 版本 → 检查更新。下载有进度条,下载完成后会有"打开安装包"按钮。 +## 已知限制与后续建议 + +### `/reload` 不会切换当前会话的默认模型(Kimi Code 上游限制) + +**现象**:在 Kimi Switch 切换供应商后,`config.toml` 顶层的 `default_model` 已经被正确更新;回到 Kimi Code CLI 执行 `/reload`,模型**下拉列表**能列出新的默认模型,但**当前会话实际仍在使用旧模型**(底部状态栏显示的模型、真正发出的请求都没变)。 + +**根因**(已对照 Kimi Code 源码确认,不是 Kimi Switch 的 bug): + +- `/reload` 命令(`apps/kimi-code/src/tui/commands/reload.ts`)在刷新 `availableModels / availableProviders` 之后,**没有**把 `config.defaultModel` 重新套用到当前会话的 agent 上。 +- 会话当前的模型(`agent.config.modelAlias`)来自会话创建时的 `options.model ?? config.defaultModel`(`packages/agent-core/src/rpc/core-impl.ts:438-440`),并通过 `records.logRecord` 持久化进会话日志;`Agent.resume()` 调 `records.replay()` 时会**回放这些历史记录**,从而把模型恢复成"上次手动选的",而不是 `config.defaultModel`。 +- 单元测试(`apps/kimi-code/test/tui/commands/reload.test.ts:87-89`)也只断言"模型列表刷新了",没有任何"当前会话切到新默认模型"的断言——因为实现层就没做这一步。 +- 全仓检索 `FOLLOW_DEFAULT / reloadDefault / KIMI_CODE_RELOAD` 等关键字**零命中**,说明 Kimi Code 目前**没有**任何"reload 时跟随 default_model"的开关。 + +**Workaround(当前可用)**: + +1. `/reload` 后再执行 `/model`,手动选一次新默认模型;或 +2. `/exit` 退出会话再重开(重新走 `createSession` 路径,会把 `config.defaultModel` 套到新会话)。 + +**建议反馈给 Kimi Code 上游的修复点**: + +- 文件:`apps/kimi-code/src/tui/commands/reload.ts` 的 `handleReloadCommand`。 +- 位置:`applyRuntimeConfig(host, config)` 之后,补一段把 `config.defaultModel` 同步到当前会话 agent 的逻辑,例如: + ```ts + const newDefault = config.defaultModel; + const current = /* host.session 当前 agent 的 modelAlias */; + if (newDefault && newDefault !== current) { + await host.session.mainAgent.config.update({ modelAlias: newDefault }); + } + ``` +- 配套断言:在 `reload.test.ts` 增加对"reload 后会话当前模型 == 新 `defaultModel`"的测试,防回归。 +- 可选增强:暴露一个会话级"follow default on reload"开关,或当会话旧 alias 已被删除时自动回退到 `default_model`。 + +> 注:以上建议**尚未提交给官方**,记录于此供后续跟进。Kimi Switch 侧只能保证 `config.toml` 写入正确,无法绕开 Kimi Code 自身的 `/reload` 语义。 + +### 其它后续建议(按优先级) + +- **P1 — 模型别名规范**:早期裸名或 `-1/-2` 后缀的别名(如 `kimi-k3`、`glm-5-2-1`)已批量重命名为 `provider/model` 形式;新增供应商时建议**强制**采用规范命名,避免再出现补全后缀。可在保存前加一层 lint。 +- **P1 — 用量趋势多维度**:用量趋势已支持"模型用量趋势 / 供应商模型用量趋势"两个 Tab;后续可考虑增加"按工作区"或"按 Token / 按费用"的第三维度切换。 +- **P2 — 周期检查更新**:当前为启动 + 每 8 小时一次;后续可考虑做成可配置项(设置面板里设周期 / 关闭)。 +- **P2 — 跨平台**:目前只产 MSI,代码不依赖 Windows 专属 API;待 Tauri v2 的 macOS/Linux bundle 配置后即可验证。 +- **P3 — 配置校验增强**:`validators.rs` 当前偏基础,可补:`base_url` 合法性、`env` 与 `api_key` 互斥、`oauth` 段完整性。 + ## 安全提示 - API Key 明文存储在本地 SQLite 和 Agent 原生配置里——**不要在共享电脑上保存** @@ -579,6 +626,8 @@ A: 启动时静默检查一次,之后每 8 小时自动检查一次(无网 供应商品牌图标资源(`src/icons/extracted/`)与图标选择器(`src/components/IconPicker.tsx`)参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young),感谢原作者的开源贡献。 +供应商预设结构(`src/config/providerPresets.ts`)与余额/套餐查询实现(`src-tauri/src/services/`)同样参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young)。 + --- 许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site diff --git a/README_EN.md b/README_EN.md index 19f8128..16d6b4f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -30,6 +30,7 @@ - [Build & Release](#build--release) - [Release History](#release-history) - [FAQ](#faq) +- [Known Limitations & Next Steps](#known-limitations--next-steps) - [Security Notes](#security-notes) - [Credits](#credits) @@ -463,7 +464,7 @@ No automated tests yet. Suggested manual checklist: - [ ] Language switch updates UI text in place - [ ] Update check → download → guided install - [ ] `max_context_size` auto-fills on model fetch -- [ ] Kimi Code `/reload` picks up new config after switching +- [ ] New default model takes effect after `/reload` + `/model` (or after `/exit` and restart) ## Build & Release @@ -529,7 +530,11 @@ Ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboar ## FAQ **Q: Switching providers didn't take effect in Kimi Code?** -A: Run `/reload` inside the Kimi Code session (the CLI only re-reads `~/.kimi-code/config.toml` on reload). The app shows this hint in the UI. +A: Two steps are required: +1. Run `/reload` inside the Kimi Code session so the CLI re-reads `~/.kimi-code/config.toml` (this refreshes the model **dropdown list**); +2. **Then run `/model` to pick the new default**, or simply `/exit` and restart the session. + +Running `/reload` alone **does not** apply the new `default_model` to the current session — this is a known Kimi Code behaviour, see [Known Limitations & Next Steps](#known-limitations--next-steps) below. The app surfaces both steps in its UI. **Q: Does switching overwrite other providers?** A: No. Kimi Code's `config.toml` is always written with all providers, only `default_model` decides which one is active. This matches the CLI's native `/provider` behaviour. @@ -561,6 +566,48 @@ A: The close X hides to the tray. Click the tray icon (menu bar / system tray) t **Q: How is the update check triggered?** A: It silently checks once on launch, then automatically every 8 hours (silent failure with no network, no error popup). You can also trigger it manually: Settings modal → Version → Update check. Downloads have a progress bar; once done, an "Open installer" button appears. +## Known Limitations & Next Steps + +### `/reload` does not switch the active session's default model (Kimi Code upstream limitation) + +**Symptom**: After switching a provider in Kimi Switch, `default_model` at the top level of `config.toml` is correctly updated. Back in Kimi Code CLI you run `/reload`: the model **dropdown** now lists the new default, but the **current session still uses the old model** (status-bar model and the actual outgoing requests haven't changed). + +**Root cause** (verified against the Kimi Code source — **not** a Kimi Switch bug): + +- The `/reload` command (`apps/kimi-code/src/tui/commands/reload.ts`) refreshes `availableModels / availableProviders` but **never** re-applies `config.defaultModel` to the active session's agent. +- The session's current model (`agent.config.modelAlias`) comes from `options.model ?? config.defaultModel` at session-create time (`packages/agent-core/src/rpc/core-impl.ts:438-440`) and is persisted to the session log via `records.logRecord`. `Agent.resume()` then calls `records.replay()`, which **replays those records** — so the model snaps back to "what was last picked", not to `config.defaultModel`. +- The unit test (`apps/kimi-code/test/tui/commands/reload.test.ts:87-89`) only asserts "the model list refreshed"; there is **no** assertion that "the active session switched to the new default" — because the implementation never does that step. +- A repo-wide search for `FOLLOW_DEFAULT / reloadDefault / KIMI_CODE_RELOAD` yields **zero** hits, so Kimi Code has **no** "follow default on reload" toggle today. + +**Workaround (works today)**: + +1. After `/reload`, run `/model` and pick the new default manually; or +2. `/exit` and restart the session (the `createSession` path re-applies `config.defaultModel` to the fresh session). + +**Suggested upstream fix for Kimi Code**: + +- File: `apps/kimi-code/src/tui/commands/reload.ts`, in `handleReloadCommand`. +- Spot: right after `applyRuntimeConfig(host, config)`, add a sync of `config.defaultModel` to the active session's agent, e.g.: + ```ts + const newDefault = config.defaultModel; + const current = /* host.session's current agent modelAlias */; + if (newDefault && newDefault !== current) { + await host.session.mainAgent.config.update({ modelAlias: newDefault }); + } + ``` +- Matching test: add an assertion in `reload.test.ts` that "after reload, the session's active model == new `defaultModel`" to guard against regressions. +- Optional enhancement: expose a session-level "follow default on reload" toggle, or auto-fall-back to `default_model` when the session's previous alias has been deleted. + +> Note: this suggestion has **not** been filed upstream yet; it's recorded here for follow-up. Kimi Switch can only guarantee `config.toml` is written correctly — it cannot work around Kimi Code's own `/reload` semantics. + +### Other next-step suggestions (by priority) + +- **P1 — Model alias convention**: early bare-name or `-1/-2` suffixed aliases (e.g. `kimi-k3`, `glm-5-2-1`) have been bulk-renamed to the `provider/model` form; when adding a new provider, **force** this convention to prevent re-introducing suffixed fallbacks. Add a save-time lint. +- **P1 — Multi-dimensional usage trends**: the usage trend already supports two tabs ("model trend" / "provider×model trend"); a future third dimension (per-workspace, or switch the Y axis between tokens and cost) is worth considering. +- **P2 — Periodic update check**: currently launch + every 8h; consider making the cadence configurable (set period / disable in the settings modal). +- **P2 — Cross-platform**: only MSI is produced today, but the code has no Windows-only dependencies; once Tauri v2 macOS/Linux bundling is configured, it can be verified. +- **P3 — Stronger config validation**: `validators.rs` is fairly minimal today; worth adding: `base_url` validity, `env` vs `api_key` mutual-exclusivity, `oauth` block completeness. + ## Security Notes - API keys are stored in plaintext in local SQLite and agent native configs — **do not store them on shared computers** @@ -575,6 +622,8 @@ The usage dashboard and session manager are ported from [kimicode-dashboard](htt The provider brand icon library (`src/icons/extracted/`) and the icon picker (`src/components/IconPicker.tsx`) are adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). Many thanks to the original author. +The provider presets structure (`src/config/providerPresets.ts`) and the balance/plan usage query implementation (`src-tauri/src/services/`) are likewise adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). + --- License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site diff --git a/docs/PROPOSAL-presets-and-usage.md b/docs/PROPOSAL-presets-and-usage.md new file mode 100644 index 0000000..4f62fc2 --- /dev/null +++ b/docs/PROPOSAL-presets-and-usage.md @@ -0,0 +1,625 @@ +# 提案:预设供应商 + 供应商账单查询 + +> **状态**:v0.4-draft(v0.3-draft 已实施;P0 全部落地,新增 v0.4 修订) +> **作者**:Kimi Switch 内部 +> **目标版本**:0.6.0 +> **参考**:cc-switch(MIT,© Jason Young)— 仅借鉴字段结构与 Rust 用量查询层模式 +> **Kimi Code 官方文档**:[providers 配置](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html)、[配置文件](https://www.kimi.com/code/docs/kimi-code-cli/configuration/config-files.html) + +--- + +## 一、背景与目标 + +当前 Kimi Switch 已有"添加供应商"入口(`src/App.tsx:141 handleAddProvider`)直接凭空造一个 `provider-N`,用户需要手填 base_url、provider_type、模型映射等十几个字段。新用户面对一份空白表单门槛很高。 + +同时,用量仪表盘(`src/components/dashboard/`)展示的是**本地会话日志**聚合出来的 token 消耗,与"供应商账户里还剩多少钱/套餐还剩多少"完全无关——后者才能告诉用户"今天是不是该充值了"。 + +**目标**: + +1. **预设供应商**:用户点"添加"时弹出预设选择面板,选中后表单字段自动填好(保留任意编辑权);支持至少 12 条主流国内/海外供应商。 +2. **供应商账单查询**:在 `ProviderList` 卡片底部展示余额/套餐剩余,调用供应商鉴权 API 实时拉取。 + +**非目标**: + +- 不做 OAuth 流程(Claude/Codex 官方订阅)—— 我们不接管 CLI 登录态。 +- 不做"用户自定义 JS 脚本"查询(rquickjs 依赖过重)。 +- 不做用量费用看板(已有本地仪表盘)。 + +--- + +## 二、参考:cc-switch 关键事实 + +### 2.1 预设供应商 + +- **位置**:纯前端 TS 数组,`src/config/*ProviderPresets.ts`,按目标 CLI 工具拆 8+1 个文件,每文件 22–73 条。 +- **数据契约**(`claudeProviderPresets.ts:25-74`): + - `name / nameKey(i18n) / websiteUrl / apiKeyUrl` + - `settingsConfig`(真正写进配置的对象,**结构因工具而异**:Claude 用 `ANTHROPIC_*` env、Codex 用 auth.json + TOML) + - `category`: `official | cn_official | third_party | aggregator` + - `isOfficial / isPartner / primePartner` + - `apiKeyField`(Claude 特有)、`endpointCandidates[]`、modelsUrl + - `theme / icon / iconColor / apiFormat` +- **触发**:`ProviderForm` 表单顶部内嵌 `ProviderPresetSelector`(grid 按钮 + 搜索 + 排序);选中即 `form.reset` 灌字段。 +- **模型清单来源**:硬编码写进 `settingsConfig.env` 或 `modelCatalog[]`;**不**走 models.dev,**不**在预设里调 API。动态拉模型是另一个独立按钮。 +- **许可证**:MIT,可直接借鉴字段结构与预设数据。 + +### 2.2 账单/用量查询 + +cc-switch 在 Rust 端实现了 6 套并行的查询层(详见 `src-tauri/src/services/`),与本项目相关的有 2 套: + +| 机制 | 文件 | 覆盖 | 关键端点 | +|---|---|---|---| +| **A. 余额查询** | `balance.rs` | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita | `GET {base_url}/user/balance` 等 | +| **B. Token Plan 套餐** | `coding_plan.rs` | **Kimi For Coding** / 智谱 GLM / MiniMax / 火山 / ZenMux | `GET https://api.kimi.com/coding/v1/usages` | + +**统一返回契约**(`balance.rs:1-10` 注释): + +```rust +struct UsageResult { + success: bool, + data: Option>, + error: Option, +} +struct UsageData { + plan_name: Option, + remaining: Option, + total: Option, + used: Option, + unit: Option, + is_valid: Option, + resets_at: Option, +} +``` + +**错误通道语义**(这套设计很成熟,建议照搬): + +- `Err(_)` = 瞬时失败(网络/超时/读体中断)→ 前端 retry + keep-last-good。 +- `Ok(success:false)` = 确定性失败(空 key / 401 / 非 2xx / JSON 解析失败)→ 直接透出错误文案。 + +**鉴权**:统一 `Authorization: Bearer `。 + +**路由**:`detect_provider(base_url)` 按子串匹配 → 调对应 `query_xxx`。 + +--- + +## 三、KimiSwitch 落点 + +| 现有结构 | 落点 | +|---|---| +| `src/App.tsx:141 handleAddProvider`(直接造空 `provider-N`)| 改为先打开 `PresetPickerModal` | +| `src/components/ProviderEdit.tsx` basic tab | 顶部加"切换预设"下拉(v1 不做) | +| `src-tauri/src/models.rs Provider` | 不动;预设转 Provider 由前端完成 | +| `src-tauri/src/commands.rs` | 新增 `query_provider_usage(base_url, api_key, usage_kind)` | +| `src-tauri/src/`(无 `services/`)| 新建 `services/{mod.rs, usage_types.rs, balance.rs, coding_plan.rs}` | +| `src/components/ProviderList.tsx` 卡片 | 卡片底部新增 `` | +| `src/i18n/{zh,en}.ts` | 加 i18n key(preset 名 / 账单相关) | +| `src/lib/models-dev.ts` | 复用:补全预设 model 的 `maxContextSize` / `capabilities` | + +**已有依赖**(已满足): + +- `Cargo.toml`: `reqwest = { version = "0.12", features = ["json","rustls-tls","stream"] }` ✅ +- `Cargo.toml`: `chrono = { version = "0.4", features = ["serde"] }` ✅ + +**私有字段存储约定**(**重要变更:v0.3 修订**): + +早前提议把 `usageKinds` 写进 `provider.raw_other["kimi-switch.usageKinds"]` 通过 `config.toml` 透传。**此方案否决**——`kimi-switch.` 前缀不是 Kimi Code 注册的 namespace,"安全"只是 Zod 非 strict + raw 透传的实现现状,无版本兼容承诺。 + +**v0.3 改为:** + +- `usageKinds` 存进 **SQLite** 的 `settings` 表(已有 per-agent 设置表,key 形式 `usage_kinds:`,值为 JSON 数组字符串,如 `["balance:deepseek"]` 或 `["plan:kimi_coding","balance:custom"]`) +- `load_agent_config_command` 在返回 config 时**自动合并** SQLite settings 中的 usageKinds 到每个 provider(前端消费时是无感的) +- Rust 端导出 `config.toml` 时**完全不写** `usageKinds` 字段 +- 卸载 / 退出登录 / 备份 `config.toml` 都不影响 `usageKinds`(独立存储) + +**优势**: + +1. 不污染用户的 Kimi Code 配置(终审 A8 解决) +2. 用户改 `provider_type` / `base_url` 后,Rust 端 `detect_provider(base_url)` 自动重新决定 `usageKinds`(B-D1 解决) +3. 旧用户升级 0.6.0 时,所有匹配 `detect_provider` host 列表的 provider **自动获得** `usageKinds`(B-D2 解决) +4. 数组形式让同一 provider 同时支持套餐 + 余额查询(如 SiliconFlow 未来加套餐) + +--- + +## 四、方案 1:预设供应商 + +### 4.1 数据模型 + +新建 `src/config/providerPresets.ts`: + +```ts +export interface ProviderPreset { + /** 唯一 key,作为默认 Provider name 使用,如 "deepseek" */ + id: string; + /** 显示名(中文/英文) */ + name: string; + /** i18n key,可选;存在时优先用 t(nameKey) */ + nameKey?: string; + /** 官网/拿 key 的链接 */ + websiteUrl?: string; + apiKeyUrl?: string; + /** 分类:排序 & 标签 */ + category: "official" | "cn_official" | "third_party" | "aggregator" | "custom"; + /** 真正写进 Provider 的字段 */ + providerType: ProviderType; + /** 预设 base_url;`null` 表示无默认值(如 Anthropic、CodingPlan.site)。 + * 当选中的 provider.type 对应的 `defaultBaseUrl()` 返回非空时, + * PresetPicker 会**用预设值覆盖它**,保存后以 `provider.base_url` 为唯一真相。 */ + baseUrl: string | null; + /** 复用现有 IconPicker 命名(无品牌图标时按 base_url 走首字母兜底) */ + icon?: string; + iconColor?: string; + /** 预填的模型映射(alias → model id)。 + * 数组第一个 ⇒ 设 default_model。 + * 数组为空时,default_model 留空,由用户通过「拉取模型」补齐。 + * + * **alias 强制规范**:`provider/${modelId}` 形式 —— 例如 `deepseek/deepseek-chat`。 + * 不能用裸 alias(如 `chat`),否则会破坏 `handleDuplicateProvider` 的 + * `alias.slice(name.length)` 推导:若 provider=`copy`,会得到空串, + * 复制时所有模型都写到 `copy` 上,后者覆盖前者。 */ + models: Array<{ + alias: string; + model: string; + /** 可选:覆写显示名;缺省走 models.dev 推导 */ + displayName?: string; + /** 可选:覆写 context;缺省走 models.dev [API→models.dev→正则] 三级 */ + maxContextSize?: number; + /** 默认 ["thinking"] */ + capabilities?: string[]; + }>; + /** 关联方案 2:账单查询类型;缺省时不查。 + * **v0.3 改为数组**:同一个供应商可能同时支持套餐 + 余额(如 SiliconFlow + * 既有平台余额又有套餐),数组形式让两端都查、UI 都展示。 + * 注意:`plan:volcengine` **不在 v1 union** —— Volcengine 套餐需独立 AK/SK + * (不是推理 api_key),需要额外的 `access_key_id / secret_access_key` 参数, + * v1 不支持,留待 P2。 */ + usageKinds?: ReadonlyArray< + | "balance:deepseek" | "balance:siliconflow" | "balance:openrouter" + | "balance:stepfun" | "balance:novita" + | "plan:kimi_coding" | "plan:zhipu" | "plan:minimax" + >; +} +export const providerPresets: ProviderPreset[] = [ /* … */ ]; +``` + +**`presetToProviderAndModels()` 显式转换契约**(v0.3 重要补充): + +```ts +export function presetToProviderAndModels(preset: ProviderPreset, options: { + existingProviderNames: Set; + existingModelAliases: Set; +}): { + provider: Provider; + models: Model[]; + defaultModel: string; // 用于写入 config.default_model + usageKinds: ReadonlyArray | undefined; // 用于写入 SQLite settings +} { + // Provider.name 冲突 → 追加 -2 / -3 + let name = preset.id; + let n = 2; + while (options.existingProviderNames.has(name)) name = `${preset.id}-${n++}`; + + // 模型 alias 冲突 → 追加 -2 / -3 + const models: Model[] = preset.models.map((m, i) => { + let alias = `${name}/${m.model}`; // 强制规范 + let k = 2; + while (options.existingModelAliases.has(alias)) alias = `${name}/${m.model}-${k++}`; + + // max_context_size 4 级优先级 + const ref = modelsDevRef(m.model); + const maxContextSize = + m.maxContextSize + ?? ref?.maxContextSize + ?? parseContextFromName(m.model) + ?? DEFAULT_MAX_CONTEXT_SIZE; // 256000 + + return { + alias, + provider: name, + model: m.model, + max_context_size: maxContextSize, + display_name: m.displayName ?? ref?.displayName ?? alias, + capabilities: m.capabilities ?? ref?.capabilities ?? ["thinking"], + supports_1m: maxContextSize >= 1_000_000, + raw_other: {}, + }; + }); + + const provider: Provider = { + name, + provider_type: preset.providerType, + base_url: preset.baseUrl, + api_key: null, // 用户补 + env: {}, + note: null, + official_url: preset.websiteUrl ?? null, + managed: false, + enabled: true, + icon: preset.icon ?? null, + icon_color: preset.iconColor ?? null, + raw_other: {}, + }; + + return { + provider, + models, + defaultModel: models[0]?.alias ?? "", + usageKinds: preset.usageKinds, + }; +} +``` + +**字段映射表**(明确每条字段从哪来): + +| Preset 字段 | Provider/Model 字段 | 备注 | +|---|---|---| +| `providerType` | `provider.provider_type` | 直透 | +| `baseUrl` | `provider.base_url` | `null` → 留空 | +| `websiteUrl` | `provider.official_url` | | +| `icon` | `provider.icon` | `null` → 走首字母兜底 | +| `iconColor` | `provider.icon_color` | | +| `models[].alias` | `models[].alias` | **强制 `${uniqueProviderName}/${modelId}`** | +| `models[].model` | `models[].model` | 直透 | +| `models[].displayName` | `models[].display_name` | 缺省走 models.dev | +| `models[].maxContextSize` | `models[].max_context_size` | 优先级链 | +| `models[].capabilities` | `models[].capabilities` | 缺省 `["thinking"]` | +| `id` | `provider.name` | 冲突 → `-2` | +| `usageKinds` | SQLite `settings` 表 | **不**写 `config.toml`;数组形式支持同一供应商多种查询 | + +### 4.2 v1 预设清单(15 条) + +| id | 供应商 | category | provider_type | base_url | icon | iconColor | usageKinds | 备注 | +|---|---|---|---|---|---|---|---|---| +| `anthropic` | Anthropic 官方 | official | anthropic | `null` | `anthropic` | — | — | 无默认 base_url;Kimi Code 自动走 `api.anthropic.com` | +| `kimi-coding` | Kimi Code 托管服务(会员订阅)| official | **kimi** | `https://api.kimi.com/coding/v1` | `kimi` | — | `[plan:kimi_coding]` | Kimi Code 托管服务走 `kimi` 类型(OpenAI 兼容 + Kimi identity headers + 视频上传)。Base URL 同时兼容 Anthropic 协议 `https://api.kimi.com/coding/`,但 preset 用 OpenAI 兼容路径 | +| `moonshot` | Moonshot Platform API(按量付费)| cn_official | **kimi** | `https://api.moonshot.ai/v1` | `kimi` | — | — | Moonshot 平台 API 密钥用户;官方文档明确 `kimi` 类型默认 base_url 即此(与 `.cn` 互为别名) | +| `deepseek` | DeepSeek | cn_official | openai | `https://api.deepseek.com/v1` | `deepseek` | — | `[balance:deepseek]` | — | +| `zhipu` | 智谱 GLM | cn_official | openai | `https://open.bigmodel.cn/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — | +| `zai` | z.ai(智谱海外)| third_party | openai | `https://api.z.ai/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — | +| `bailian` | 阿里百炼 | cn_official | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `bailian` | — | — | — | +| `minimax` | MiniMax | cn_official | openai | `https://api.minimaxi.com/v1` | `minimax` | — | `[plan:minimax]` | — | +| `stepfun` | StepFun(阶跃星辰)| cn_official | openai | `https://api.stepfun.com/v1` | `stepfun` | — | `[balance:stepfun]` | — | +| `siliconflow` | 硅基流动 | cn_official | openai | `https://api.siliconflow.cn/v1` | `siliconflow` | 显式补色(默认灰色)| `[balance:siliconflow]` | `siliconflow` 在 `src/icons/extracted/index.ts:93` 有 SVG,但 `metadata.ts` 没有;需显式 `iconColor` | +| `novita` | Novita AI | third_party | openai | `https://api.novita.ai/v3` | `novita` | — | `[balance:novita]` | 注意 `/v3`(非 `/v1`) | +| `openrouter` | OpenRouter | third_party | openai | `https://openrouter.ai/api/v1` | `openrouter` | — | `[balance:openrouter]` | — | +| `openai` | OpenAI 官方 | official | openai | `https://api.openai.com/v1` | `openai` | — | — | — | +| `google-genai` | Google AI Studio | official | google-genai | `https://generativelanguage.googleapis.com` | `google` | — | — | — | +| `volcengine` | 火山方舟(仅推理)| cn_official | openai | `https://ark.cn-beijing.volces.com/api/v3` | `huoshan` | — | — | **v1 不支持套餐查询**(需 AK/SK),仅作推理 fallback | + +**`codingplan`(自定义中转)v1 不做 preset**——价值低(无 base_url、无 usageKinds),与预设"一键填表"目标冲突。改在 `PresetPickerModal` 底部保留独立的"**+ 自定义配置**"按钮(来自空表单),与 cc-switch `ProviderPresetSelector.tsx:392-404` 一致。 + +**预估 icon 字段值校核**:上面表的 `icon` 值均来自 `src/icons/extracted/index.ts` 真实存在的 key;不能使用 `brands.ts` 推断 key 作显式 icon(两者命名空间不同)。P0 实现时若发现某个 key 不存在,需在 `src/icons/extracted/index.ts` 补 entry。 + +**模型清单里的 `maxContextSize` / `capabilities` 优先级**(与拉取模型同一体系): + +1. 预设内置值(`ProviderPreset.models[].maxContextSize / capabilities / displayName` — 若有) +2. `src/lib/models-dev.ts` 快照查找(`getModelRef`) +3. 正则兜底(`src/lib/model-defaults.ts`) +4. 全部 miss → **`DEFAULT_MAX_CONTEXT_SIZE`**(`src/lib/model-defaults.ts:1,83-89` 实际为 **256000**,不是 128000) + +> v0.3 修正:早期文档误写 128000;统一用 `DEFAULT_MAX_CONTEXT_SIZE` 常量,避免双源真相。 + +**预设里绝大多数模型只需放 `alias` + `model id`**;仅对 models-dev 快照无覆盖的新发模型(如国产首发)才填覆写字段。 + +### 4.3 UI 流程 + +**入口 A(v1 必做)`PresetPickerModal`**: + +``` +┌────────────────────────────────────────────────┐ +│ 选择预设供应商 [X] │ +├────────────────────────────────────────────────┤ +│ 🔍[搜索] [排序:原始│A-Z] │ +├────────────────────────────────────────────────┤ +│ [K] Kimi Coding [官方] │ +│ [A] Anthropic [官方] │ +│ [D] DeepSeek [国产] │ +│ [Z] 智谱 GLM [国产] │ +│ …(grid 150px×n) │ +│ [+自定义配置] │ +├────────────────────────────────────────────────┤ +│ 预设只是一键填表,后续可继续修改所有字段。 │ +└────────────────────────────────────────────────┘ +``` + +- 触发:① "添加"按钮 ② 顶部"+ 添加供应商" +- 选中预设后: + 1. 用预设 `id` 作为默认 `Provider.name`(冲突时尾部追加 `-2`、`-3`…) + 2. 把 `models[]` 转成 `Model` 加进 `config.models`,**每个 alias 逐一检测冲突**:alias 已存在 → 尾部追加 `-2` / `-3`;**第一个 model** 设为 `default_model`(经过去重后的 alias) + 3. 写入 `provider.raw_other["kimi-switch.usageKinds"] = usageKinds`(沿用 raw_other 私有字段约定) + 4. 跳到 `ProviderEdit` 让用户填 `api_key`(其余字段已预填) +- 分类徽章:官方 = 蓝、国产 = 绿、第三方 = 灰、聚合 = 紫 +- 网格自适应:`repeat(auto-fill, minmax(140px, 1fr))`,参考 cc-switch `ProviderPresetSelector.tsx:392` + +**入口 B(v2,可选)`ProviderEdit` 顶部"切换预设"下拉**:对已存在但想换预设的供应商也能用。 + +### 4.4 持久化约定 + +- **name 由预设 id 派生**,用户可后续在表单里改名(不要锁)。 +- **`usageKinds` 存进 SQLite settings 表**(key: `usage_kinds:`,值为 JSON 数组),不写进 `config.toml`。详见 §三"私有字段存储约定"。 +- **不使用** `raw_other` 存 usageKinds(v0.3 修订:避免污染 Kimi Code 配置 + 支持 Rust 端自动 host detect)。 + +--- + +## 五、方案 2:供应商账单查询 + +### 5.1 Rust 端 + +``` +src-tauri/src/ +├── services/ +│ ├── mod.rs // pub use + 统一入口 +│ ├── usage_types.rs // UsageResult / UsageData +│ ├── balance.rs // 余额类(5 家) +│ └── coding_plan.rs // 套餐类(5 家) +└── commands.rs // +#[tauri::command] query_provider_usage +``` + +**usage_types.rs**(**注意必须加 camelCase** —— 缺它前端 `resetsAt/planName/isValid` 全部错位): + +```rust +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageData { + pub plan_name: Option, + pub remaining: Option, + pub total: Option, + pub used: Option, + pub unit: Option, + pub is_valid: Option, + pub resets_at: Option, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageResult { + pub success: bool, + pub data: Option>, + pub error: Option, +} +``` + +> **与 cc-switch 真实结构的差异**(重要):cc-switch 的 `UsageData` 在 `provider.rs:282-314` 还含 `extra: Option` / `invalid_message: Option`,**没有** `resets_at`(该字段在 `SubscriptionQuota.tiers[].resets_at`)。本方案**不"照抄"**,而是把套餐的 `tiers` 展平成数组 + 每项带 `resets_at` 字段,统一前端消费面。`SubscriptionQuota → UsageData[]` 的转换规则实现时需明确(每个 tier 一条 `UsageData`,`plan_name = tier.name`,`used/total/remaining/unit` 视情况映射)。 + +**balance.rs / coding_plan.rs**:直接 port cc-switch 实现的 5 家 + 5 家(~670 行 + ~2200 行)。重点实现: + +- `detect_provider(base_url) -> Option<...>`:按子串匹配 +- `query_xxx(api_key) -> Result`:分别处理 401、读体失败、解析失败 +- **读体失败和解析失败要区分**:先 `bytes().await` 拿到完整字节再 `serde_json::from_slice`(见 balance.rs:99-108 的注释,reqwest `.json()` 把读体错也包成 decode,会丢信息) + +**command.rs 新增**(**v0.3 修订 + v0.4 落实**:传 `provider_name` 让 Rust 端从 config 加载 key 与 usageKinds,避免 IPC 序列化 key): + +```rust +#[tauri::command] +pub async fn query_provider_usage( + agent: Agent, // v0.4 落实:项目 config 按 agent 隔离,必须传 + provider_name: String, + force_refresh: Option, // true = 跳过 5min 缓存 +) -> Result { + // 1. 从 config / SQLite 加载该 provider + // 2. 如果 api_key 为空 → 自动 fallback 到 provider.env 里的 + // expected_api_key_key(provider_type) 对应 key(v0.4 落实) + // 3. 读取 usageKinds: 优先 SQLite 显式设置(JSON 数组)→ fallback detect_provider(base_url) → success:false "unknown provider" + // 4. 对每个 kind 路由到对应 query_xxx,合并所有结果到一个 UsageData[] + // 5. 返回 UsageResult(data 含全部 kind 的条目) +} +``` + +**v0.4 重要**:Rust 端除 `provider.api_key` 外,**会兜底读取 `provider.env` 里的 `expected_api_key_key(provider_type)`**(如 Kimi 平台期望 `KIMI_API_KEY`)。这只读 env 用于实际查询,**不**写回 config.toml,给只配 env 不配 api_key 的用户兜底。 + +**重要**:前端**不**传 `api_key` / `usage_kind` / `base_url`。Rust 端统一从配置加载,杜绝: +- API key 经 IPC 序列化(终审 D4) +- 前端绕过 host 校验乱发请求(终审 D1) +- Rust detect 与前端规则矛盾(终审 D2) + +**Tauri 配置文件注册**(**v0.3 修订**:Rust reqwest 不走 Tauri HTTP plugin,不需 capability): + +1. `commands.rs` 加 `#[tauri::command]` ✅ +2. `lib.rs` 的 `tauri::generate_handler![…]` 追加 `query_provider_usage` +3. `capabilities/default.json` **无需改动**(现有 `test_connectivity` 同样用 Rust reqwest,不需 HTTP scope) + +**Tauri config**:`reqwest::get` 是 Rust 端发出,**不**走 WebView 网络栈,**不**需要改 CSP。 + +### 5.2 前端 + +**ProviderList 卡片底部的 ``**: + +``` +┌────────────────────────────────────────┐ +│ Kimi For Coding [使用中] │ +│ 默认: kimi-k2.7-code 3 models │ +├────────────────────────────────────────┤ +│ ⚡ 35% 已使用 · 5h 后重置 [刷新] │ ← 套餐类(green/orange/red) +└────────────────────────────────────────┘ + +┌────────────────────────────────────────┐ +│ DeepSeek [使用中] │ +│ 默认: deepseek-chat 2 models │ +├────────────────────────────────────────┤ +│ 💰 余额 ¥12.34 [刷新] │ ← 余额类 +└────────────────────────────────────────┘ +``` + +- 配色:<70% 绿 / 70-89% 橙 / ≥90% 红(对齐 cc-switch) +- **多 kind 渲染**:同一 provider 的 `usageKinds` 数组可能含多种(如 `[plan:kimi_coding, balance:custom]`);`UsageData[]` 每条独立渲染为一行,余额类显金额、套餐类显百分比+倒计时 +- 倒计时:前端 JS 算 `new Date(resetsAt) - Date.now()`,按区间显示"5h 后重置 / 3d 后重置"(绝对时间,Rust 端已转 ISO 8601,不依赖时区) +- 缓存:**5min stale TTL**(`force_refresh` 跳过)+ Manual 刷新(**v0.3 修订**:与 cc-switch `queries.ts:245-277` 对齐)。进入 ProviderList 时对有 usageKinds 的供应商并发查一次,**最多同时 3 个请求**(`Promise.allSettled` + 简易信号量),单请求超时 8 秒 +- 错误:网络错"网络异常"、401"API Key 无效"、其他"查询失败" —— 不阻塞主界面 +- 没 `usageKinds` 字段(undefined 或空数组)→ **完全不显示 footer,不发起任何请求** + +**状态机**(每个 provider 独立维护): + +| 当前状态 | 触发 | 行为 | +|---|---|---| +| `idle` | 首次进入 ProviderList | → `loading`(并发 ≤3) | +| `loading` | 响应到达 | `ok` → `success`;`err` → `error` | +| `success` | 用户点「刷新」 | → `loading`,保留上一次 `data` 做 ghost 展示 | +| `error` | 用户点「重试」 | → `loading` | +| `success`/`error` | 缓存超 30s,重新进入列表 | 自动 → `loading` | +| 任何 | 用户清空 `api_key` | → `idle`(不发起请求,见 §5.1 命令层检查) | + +**渲染**: + +- `loading`:骨架屏或上次数据的半透明 ghost +- `success`:余额/百分比 + 配色 + 重置倒计时 +- `error`:红色错误提示 + 「重试」按钮 +- `idle`:无(不渲染 UsageFooter) + +**触发与权限**(**v0.3 修订**): + +- 启动时**不**预查 +- 进入 ProviderList 视图时并发查 1 次(≤3 并发,8s 超时) +- 用户点"刷新"按钮才再次查(5min stale TTL) +- 全局开关:设置面板加"启动账单查询"开关,关闭后 Rust 端拒绝查询(默认开启) +- **前端不做 usageKinds 决策**:单纯 `invoke("query_provider_usage", { provider_name: "deepseek" })`,Rust 端返回 `success: false` 时客户端仅展示错误,不做 host 校验 + +### 5.3 与现有"用量仪表盘"的关系 + +- **现有仪表盘**:本地 SQLite 聚合的 token 计数 + 费用估算(基于 `models.dev` 价格) +- **新增账单查询**:供应商侧的余额/套餐 +- **不重复**:仪表盘 chip 区域不显示余额,余额只在 ProviderList 卡片底部 +- **联动**:可选地(v2)在仪表盘 KPI 加一个"供应商侧配额"卡,但 v1 不做 + +--- + +## 六、里程碑 + +| 阶段 | 内容 | 优先级 | +|---|---|---| +| **P0** | 方案 1:15 条预设 + `PresetPickerModal` + App 入口接入 | P0 | +| **P0** | 方案 2 骨架:`services/{usage_types,mod}.rs` + `query_provider_usage` 命令 + `UsageResult` + **camelCase 序列化** | P0 | +| **P0** | `query_provider_usage` 两层注册(commands.rs + lib.rs;**无 capabilities 变更**) + Rust 端 mock 测试 | P0 | +| **P0** | SQLite 改造:`usage_kinds:` 存 settings 表(JSON 数组);`load_agent_config_command` 合并回 config;export 时不写 usageKinds | P0 | +| **P0** | `detect_provider(base_url)` 启发式:迁移期旧用户自动获得 usageKinds | P0 | +| **P0** | 方案 2 前端:`` + 卡片接入 + 错误处理 + 配色 + 状态机 + 5min stale TTL | P0 | +| **P0** | **套餐类 v1:Kimi For Coding(`api.kimi.com/coding/v1/usages`)**(项目主题,优先) | P0 | +| **P0** | 余额类 v1:DeepSeek / SiliconFlow / OpenRouter(3 家) | P0 | +| **P0** | 前端手工验证 checklist(见 §十一 #20) | P0 | +| **P1** | 套餐类补全:智谱 GLM / MiniMax | P1 | +| **P1** | 余额类补全:StepFun / Novita | P1 | +| **P2** | `ProviderEdit` 顶部"切换预设"下拉(入口 B) | P2 | +| **P2** | 设置面板"启动账单查询"开关 | P2 | +| **P2** | 倒计时 i18n 友好格式化("5小时后" / "in 5h") | P2 | +| **P2** | Volcengine 套餐查询(独立 AK/SK 支持 + UI) | P2 | +| **P3** | 自定义 JS 脚本兜底(usage_script.rs + rquickjs) | P3 | +| **P3** | 仪表盘 KPI 加"供应商侧配额"卡 | P3 | + +--- + +## 七、许可证与引用 + +cc-switch 是 MIT(`D:/AIGC/cc-switch/LICENSE`),Rust 实现(`balance.rs` / `coding_plan.rs`)可直接移植;预设数据(base_url、模型 id 等公开信息)不构成版权客体。 + +**文件头添加**: + +```rust +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch +``` + +**README 致谢段补一条**: + +> 供应商预设结构与余额/套餐查询实现参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT,© Jason Young)。 + +**配置文件同步**:preset 名 i18n key 同样放 `src/i18n/{zh,en}.ts`。 + +--- + +## 八、风险与待确认 + +| 风险 | 缓解措施 | 状态 | +|---|---|---| +| 预设的 `id` 与用户已存在的 `provider.name` 冲突 | `presetToProviderAndModels()` 内 `while` 循环追加 `-2` / `-3` | ✅ | +| 模型 alias 冲突 | 强制 alias = `${name}/${modelId}`,冲突追加 `-2` / `-3` | ✅ | +| 裸 alias 破坏 `handleDuplicateProvider` | **强制规范**(§4.1 转换契约);`src/App.tsx:271` `alias.slice` 改造为 `startsWith` 显式判断 + 完整 re-prefix fallback(v0.4 落实) | ✅ | +| `usageKinds` 漂移(TS 端枚举与 Rust 端 enum 各自维护) | TS 端 enum + Rust 端 enum 各自声明;TS 端在 dev 模式加载 `providerPresets.ts` 时对每个 preset 的 usageKinds 做运行时断言(v0.4 新增 `SUPPORTED_USAGE_KINDS` Set + `import.meta.env.DEV` guard) | ✅(dev-only 断言)| +| Zhipu/MiniMax 的套餐端点可能限频 | 5min stale TTL + 失败 keep-last-good;网络错误自动 retry | ✅ | +| Kimi For Coding 套餐鉴权:官方文档已确认 `kimi` 类型 + `Authorization: Bearer ` 即可,与 cc-switch 实现一致 | ✅ 已核验([官方 providers 文档](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html))| ✅ | +| StepFun / Novita 的 base_url 匹配与现有 OpenAI 兼容克隆冲突 | `detect_provider` 在 `detect_provider_openai` 之前调用 | ✅ | +| `defaultBaseUrl` 与预设 base_url 双方有值 | 预设的 baseUrl **覆盖** `default_base_url()`;保存时以 `provider.base_url` 为唯一真相 | ✅ | +| provider 改 `base_url` / `provider_type` 后旧 usageKinds 错路由 | usageKinds 不显式存 provider 字段,Rust 端每次 `query_provider_usage` 重新 `detect_provider(base_url)` | ✅ | +| 旧用户升级 0.6.0 后无 usageKinds | Rust 端 `detect_provider(base_url)` 启发式作为 fallback,自动识别 DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita / Kimi Coding / 智谱 / MiniMax | ✅ | +| API key 暴露:当前 edit UI 已持有明文 key,IPC invoke 序列化 key 是"再次"暴露面 | command 改为 `provider_name`,Rust 端从 config 加载 key,**不**经 IPC 传 key。错误/调试日志中严禁输出 key | ✅ | +| Tauri capability 是否需要 | Rust reqwest 不走 Tauri HTTP plugin,无 capability 变更需求 | ✅ | +| `usageKinds` 写进 config.toml 污染 Kimi Code 配置 | 存 SQLite settings 表(JSON 数组),export 时不写 | ✅ | +| 限额与套餐 UI 区分 | 按 `plan_name` 判断:`five_hour` / `weekly_limit` → 百分比+倒计时;其他 → 余额数值 | ✅ | +| Pi 与 KimiCode 同名 provider 共享 UsageFooter 缓存造成串扰 | v0.4 落实:模块级 cache key 改为 `${agent}:${providerName}`(UsageFooter.tsx:36) | ✅ | +| 用户用 env 配 key 但 `api_key` 字段为空 | v0.4 落实:Rust 端 `query_provider_usage` 兜底读 `provider.env[expected_api_key_key(provider_type)]` | ✅ | + +--- + +## 九、需要在开始前确认的决策点 + +**状态标签说明**: +- ✅ `代码已核验` —— 已对照源码确认可行 +- 🟡 `产品决策` —— 已选,但落地细节随实现调整 +- 🟠 `待真实 API 实测` —— 需真实 key 实测后才能标完成 +- ⚪ `待用户确认` —— 文档中给了倾向,但需用户最终决定 + +1. ✅ **预设触发方式**:先做入口 A(独立弹窗)。入口 B 推迟到 P2。 +2. 🟡 **预设的模型 maxContextSize / capabilities**:靠 models.dev 推导 + 预设可选覆写字段(§4.1 已明确 4 级优先级)。 +3. 🟡 **账单查询触发**:首次进入自动查(≤3 并发)+ 5min stale TTL + 刷新/重试按钮。 +4. 🟡 **Kimi For Coding 套餐**:P1(先 P0 跑通后再做)。 +5. ✅ **自定义 JS 脚本**:不做(rquickjs 过重)。 +6. 🟡 **预设 v1 清单**:15 条(不含 volcengine 套餐 + codingplan),够用。 +7. ✅ **usageKinds 持久化**:SQLite settings 表(JSON 数组,v0.3 修订),不写 config.toml。 +8. ✅ **kimi-coding preset 的 `provider_type`**:用 **`type: "kimi"`** + `base_url: "https://api.kimi.com/coding/v1"`。**官方文档已确认**:`kimi` 类型 = "对接 Moonshot AI 的 OpenAI 兼容接口,包括 Kimi Code 托管服务 + Kimi Platform API 密钥"。`moonshot` preset 同样用 `kimi` 类型(之前误写 `openai`,已修)。 +9. ⚪ **Volcengine 套餐 v1 不做的取舍**:v1 仅保留 volcengine 推理预设,去掉套餐查询。是否可接受? + +**用户实标 ✅ 决议后的项目**(待用户回复 8/9 后): + +--- + +## 十、附录:cc-switch 借鉴到的关键代码位置 + +- `src/config/claudeProviderPresets.ts:25-74` — `ProviderPreset` 接口 +- `src/config/codexProviderPresets.ts:13-46` — `CodexProviderPreset` 接口(带 modelCatalog) +- `src/components/providers/forms/ProviderPresetSelector.tsx:142-500` — Reference UI +- `src/components/providers/forms/ProviderForm.tsx:1794-1899` — `handlePresetChange` 字段注入逻辑 +- `src-tauri/src/services/balance.rs:1-454` — 余额查询完整实现(5 家) +- `src-tauri/src/services/coding_plan.rs:100-206` — Kimi For Coding 单家实现 +- `src-tauri/src/provider.rs` — `UsageResult` / `UsageData` 类型定义 +- `docs/user-manual/en/2-providers/2.5-usage-query.md` — 设计语义文档(中文版同路径) + +--- + +## 十一、自审发现的问题(v0.1 → v0.2 已修正) + +> 本节是第一轮自审发现的问题。v0.2 已全部修正或记录到正文 / §八风险表 / 对应章节。 +> +> 第三轮审核额外发现 20 项(6 阻塞 + 7 类型/语义 + 7 逻辑/边界),已直接修入正文,不在此重复。关键修正: +> - B1 「baseUrl: string → string | null」已修正(§4.1) +> - B2 「category 缺少 custom」已补(§4.1) +> - B3 「model alias 冲突检测」已补(§4.3 步骤 2) +> - B5 「三层注册清单」已补(§5.1) +> - B6 「并发控制 ≤3」已补(§5.2) +> - L1 「icon 补齐」已在 §4.2 表为预设加 `icon` 字段(值由 P0 实现时从现有 IconPicker 名映射表中读取) +> - L2 「models-dev 缺失回退 4 级优先级」已补(§4.2 底部) +> - L3 「状态机」已补(§5.2) +> - L5 「空 api_key 拒绝」已补(§5.1 命令层) +> - S4 「models 数组为空」已补(§4.1 注释) + +| # | 问题 | 状态 | +|---|---|---| +| 1 | **预设 `id` 与已有 provider.name 冲突** | ✅ §4.4 已解决 | +| 2 | **usageKinds 写进 raw_other 会进 config.toml** | ✅ §三 私有字段持久化约定已明确 `kimi-switch.` 命名空间 | +| 3 | **默认 base_url 来源双源** | ✅ §八·风险 #7 + §4.1 baseUrl 注释 | +| 4 | **TS 端 usageKinds 与 Rust 端 enum 漂移** | ✅ §八·风险 #2 | +| 5 | **Kimi For Coding 鉴权格式未实测** | ✅ §八·风险 #5:P1 前 curl 验证 | +| 6 | **StepFun/Novita base_url 与 OpenAI clone 冲突** | ✅ §八·风险 #6 | +| 7 | **倒计时 i18n 友好格式化** | → §六 P2 | +| 8 | **账单查询全局开关** | → §六 P2 | +| 9 | **入口 B 切换预设** | → §六 P2 | +| 10 | **model 字段名 vs Kimi Code 识别** | 核验后确认:`[models.alias]` 块与 `[providers.name]` 块独立,无冲突风险 ✅ | +| 11 | **图标名 → IconPicker 映射表** | → P0 实现时补 | +| 12 | **设置面板账单查询开关分组** | → §六 P2:放"设置"Tab 同一分组 | +| 13 | **模型 alias 命名规范** | ✅ §4.1 已明确 `provider/model` 形式 | +| 14 | **Moonshot 无余额 API → 不显示 footer** | ✅ §5.2:无 usageKinds = 不渲染 | +| 15 | **OpenAI 协议选择 `openai`** | ✅ 不暴露 `openai_responses` 让用户困惑 | +| 16 | **多端点候选 v1 不做** | ✅ | +| 17 | **extra_usage 字段简化** | ✅ §5.1 只留 `UsageData[]` | +| 18 | **is_valid: false 透出** | ✅ §5.1 类型已含 | +| 19 | **跨会话缓存 v1 不做** | ✅ 30s TTL 够用 | +| 20 | **测试覆盖 P0 必须** | ✅ 已加回 §六里程碑 | + +--- + +*文档结束 — v0.4-draft(v0.3 已实施 + v0.4 落实 4 项收尾:handleDuplicateProvider 兼容老 alias / UsageFooter cache 加 agent / dev 模式跨端断言 / env 兜底读 key)。配套人工回归清单见 [VERIFICATION-CHECKLIST.md](./VERIFICATION-CHECKLIST.md)。* diff --git a/docs/VERIFICATION-CHECKLIST.md b/docs/VERIFICATION-CHECKLIST.md new file mode 100644 index 0000000..0a87739 --- /dev/null +++ b/docs/VERIFICATION-CHECKLIST.md @@ -0,0 +1,154 @@ +# v0.6.0 手动验证 Checklist + +> 本文档是 [PROPOSAL-presets-and-usage.md](./PROPOSAL-presets-and-usage.md) P0 实施后的回归清单。 +> 覆盖 v0.6.0 新增的「预设供应商 + 供应商账单查询」功能,附带核心旧功能的回归项。 +> **不替代自动化测试**(项目尚无单测体系),仅作为每次发版前的人工冒烟与回归依据。 + +## 0. 测试环境准备 + +- [ ] 干净 config 目录(备份并删除 `%USERPROFILE%/.kimi-code/config.toml` 与 `%USERPROFILE%/.kimi-code/kimi-switch.db`) +- [ ] 至少 2 个真实 API Key:DeepSeek(余额)、Kimi For Coding(套餐,登录 kimi.com/code 拿 `sk-...` key);可选 OpenRouter(余额) +- [ ] 至少 1 个 OpenAI/Anthropic 兼容 key 作为非账单 provider 验证「不支持时不显示 footer」 + +--- + +## A. 预设供应商 + +### A1. 弹窗与入口 +- [ ] 主页点「+」按钮 → 弹出 `PresetPickerModal`,含 15 条预设 +- [ ] 搜索框输入「dee」/「kimi」/「glm」→ 实时过滤 +- [ ] 排序切换「原始 / A-Z」→ 顺序变化 +- [ ] 分类徽章配色:官方蓝 / 国产绿 / 第三方灰 / 聚合紫 +- [ ] 底部「+ 自定义配置」按钮 → 关闭弹窗,走原空表单流(生成 `provider-N`) +- [ ] Esc 键关闭弹窗 +- [ ] 点击遮罩关闭弹窗 +- [ ] 弹窗打开时背景滚动被锁 + +### A2. 选中预设 +- [ ] 选 kimi-coding → 卡片写入「kimi-coding」provider + `kimi-for-coding`、`kimi-k2.7-code` 模型,第一个设为 `default_model` +- [ ] provider 写入 `provider_type=kimi`,`base_url=https://api.kimi.com/coding/v1`,`api_key=null` +- [ ] usageKinds 持久化到 SQLite(验证方式:保存后重启 app,Kimi Coding 卡片底部仍出现账单区) +- [ ] 选 anthropic → `base_url` 为空(不写默认值) +- [ ] 选 google-genai → `provider_type=google-genai`,`base_url=https://generativelanguage.googleapis.com` +- [ ] 选 moonshot → `provider_type=kimi`,`base_url=https://api.moonshot.ai/v1`(Kimi 平台 API,按量付费) +- [ ] 选 zai → `provider_type=openai`,`base_url=https://api.z.ai/api/paas/v4` +- [ ] 选 volcengine → `provider_type=openai`,`base_url=https://ark.cn-beijing.volces.com/api/v3`,**无** usageKinds +- [ ] 选 bailian → 无 usageKinds(不显示账单区) + +### A3. 冲突处理 +- [ ] 已存在 `deepseek` provider → 选 deepseek preset 再次 → 新 provider 命名为 `deepseek-2`,模型 alias 全部带 `-2` +- [ ] 同 provider 内 alias 冲突 → 尾追加 `-2`/`-3` +- [ ] 切换不同预设(含同名模型 id)→ alias 强制 `${name}/${modelId}` 形式(**不**允许裸 alias) + +### A4. 预填字段展示 +- [ ] 选完预设后自动跳到 `ProviderEdit` 页 +- [ ] `api_key` 留空、其它字段已预填 +- [ ] 填入真实 key → Ctrl+S 保存 +- [ ] 返回列表 → 新 provider 卡片可见,可点「使用中」/「切换使用」按钮激活 + +### A5. config.toml 污染检查 +- [ ] 保存后打开 `%USERPROFILE%/.kimi-code/config.toml` → **不**含 `usageKinds` 字段 +- [ ] 修改 provider 的 `base_url` 触发保存 → config.toml 仍是新 `base_url`,**无** `usage_kinds` 键 + +### A6. 旧数据迁移 +- [ ] 用 v0.5.2 之前的 config(含裸 alias 如 `kimi-k3`、`glm-5-2-1`)启动 → app 正常加载 +- [ ] 触发「复制 provider」→ 新 provider 的 alias 重命名正确(**不**再出现 `kimi-coding-k3` 这种缺 `/` 的形式) + +--- + +## B. 供应商账单查询 + +### B1. 不支持时不渲染 +- [ ] Moonshot / Bailian / OpenAI / Google / Anthropic 卡片底部**无** UsageFooter +- [ ] 自定义空表单(`provider-N`)卡片底部**无** UsageFooter +- [ ] 后端 `query_provider_usage` 不会为这些 provider 触发 + +### B2. 支持时自动查询 +- [ ] 选 kimi-coding 预设、填 key、保存 → 卡片底部出现「⚡ N% 已使用 · 5h 后重置」 +- [ ] 选 deepseek 预设、填 key、保存 → 卡片底部出现「💰 余额 ¥X.XX」 +- [ ] 首次进入 ProviderList → 卡片并发查询,最多 3 个同时(DevTools network 可见) +- [ ] 单个 provider 查询超过 8 秒 → 卡片显示「网络异常 · 重试」,**不**阻塞其他 provider + +### B3. 5min 缓存 +- [ ] 查询成功后 5 分钟内重进 ProviderList → 不再发新请求(DevTools network 确认) +- [ ] 缓存过期后重进 → 自动重新查询(先 ghost 旧数据再后台拉新) +- [ ] 进程重启后 → 缓存清空(Rust 端内存 + 前端 module Map 都丢),首次进列表重新查 + +### B4. 手动刷新 +- [ ] 点卡片底部「刷新」图标 → 跳过缓存直接查 +- [ ] 刷新按钮 click 不会触发卡片「进入编辑」onClick(stopPropagation 验证) +- [ ] 刷新中按钮变转圈、禁用防重入 + +### B5. 错误处理 +- [ ] api_key 为空 → 卡片显示「API Key 无效」红字 + 重试按钮 +- [ ] api_key 错误(401)→ 卡片显示「API Key 无效」 +- [ ] 网络断开 / DNS 失败 → 卡片显示「网络异常」 + 重试按钮 +- [ ] 提供商返回非预期 JSON → 卡片显示「查询失败 · <错误文本>」 +- [ ] 错误状态保留旧数据作 ghost(半透明)+ 缓存仍写入错误 entry,TTL 内不重发 + +### B6. 多 kind 渲染 +- [ ] Kimi For Coding 有两层(`five_hour` + `weekly_limit`)→ 两行独立显示,前缀 `five_hour · ` / `weekly_limit · ` +- [ ] 套餐行配色:< 70% 已用 绿 / 70-89 橙 / ≥ 90 红 +- [ ] 倒计时:> 24h `Nd` / < 24h `Nh` / < 1h `Nm`;已过期显示「已重置」 +- [ ] 余额行:CNY→¥ / USD→$ / 其他原样 +- [ ] 同一 provider 同时有套餐 + 余额(v1 不存在这种组合,但 detect 启发式已支持)→ 两行都显示 + +### B7. SQLite 持久化 +- [ ] 保存预设后查 SQLite `settings` 表 → 存在 `usage_kinds:kimi-coding` 键,值为 `["plan:kimi_coding"]` +- [ ] 删除 provider → 下次 save 时对应 key 被删(避免脏数据) +- [ ] 卸载 app → 卸载时同步删除(验证方式:卸载后 `%USERPROFILE%/.kimi-code/` 不残留 `kimi-switch.db`) + +### B8. 真实 API 验证(**需真实 key**,按用户实有 key 勾选) +- [ ] DeepSeek `user/balance` → 返回 CNY 余额 +- [ ] Kimi For Coding `coding/v1/usages` → 返回 `five_hour` + `weekly_limit` 两层 +- [ ] OpenRouter `credits` → 返回 USD 余额 +- [ ] SiliconFlow `user/info` → 返回余额 +- [ ] StepFun `accounts` → 返回余额(已加在 P0 实施中,但任务里程碑归 P1) +- [ ] Novita `user/balance` → 注意 `/10000` 单位换算 +- [ ] Zhipu `glm/bigmodel` 套餐 → 返回百分比 + 重置时间 +- [ ] MiniMax 套餐 → 返回周桶用量 + +### B9. 跨 agent 隔离 +- [ ] KimiCode 与 Pi(虽然 UI 屏蔽)同名 provider 各自独立缓存不串 + +--- + +## C. 核心旧功能回归(v0.6.0 没动但要确认没破) + +- [ ] 切换使用按钮:点「使用中」→ 卡片高亮 + config.toml 写入 + `/reload` 提示 +- [ ] 复制 provider:alias 重命名正确(包括老数据非规范 alias) +- [ ] 删除 provider:关联模型一并删除 +- [ ] 编辑 provider:改名后关联模型 `m.provider` 同步更新 +- [ ] 测速按钮:弹气泡,6 秒后自动消失 +- [ ] 拉取模型:填入 base_url + key → 拉到的模型自动建 alias,max_context 走 models.dev +- [ ] 设置面板:主题切换、语言切换、检查更新、打开配置目录 +- [ ] 检查更新:启动时静默 + 8 小时一次 +- [ ] 用量仪表盘:模型用量趋势 / 供应商模型用量趋势 双 Tab、热力图、会话管理 +- [ ] 关闭按钮(X)隐藏到托盘 +- [ ] Ctrl+S / Ctrl+R / Ctrl+O 快捷键 + +--- + +## D. 构建与发布 + +- [ ] `npx tsc --noEmit` 0 error +- [ ] `cd src-tauri && cargo check` 0 error +- [ ] `cd src-tauri && cargo test` 全过(当前 26 用例) +- [ ] `npm run tauri-build` MSI 产物 `src-tauri/target/release/bundle/msi/Kimi Switch__x64_en-US.msi` +- [ ] MSI 静默安装后启动正常、config 迁移正常 +- [ ] 启动空白超时 5 秒有橙色提示 + +--- + +## E. 已知不验证项(设计层面注定做不到) + +- ⚪ Volcengine 套餐查询(v1 不支持,需独立 AK/SK,留 P2) +- ⚪ ZenMux(v1 不做) +- ⚪ `ProviderEdit` 顶部「切换预设」下拉(P2) +- ⚪ 仪表盘 KPI「供应商侧配额」卡(P3) +- ⚪ `/reload` 不会自动套用新 `default_model`(Kimi Code 上游限制,详见 README「已知限制」段) +- ⚪ 切换预设后自动激活新 provider(产品决策未定) + +--- + +*完成所有 ✓ 后再发版。任意一条 ✗ 都需定位并修复后重测。* diff --git a/package.json b/package.json index bfd0713..49ad049 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.5.2", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3d5dd7a..e8d0152 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.5.2" +version = "0.6.0" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 52ef738..a2ce5a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.5.2" +version = "0.6.0" description = "Kimi Switch - model config manager" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e5d9643..d41b85e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,8 +1,12 @@ use crate::db; use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType}; use crate::pi_io; +use crate::services::{self, UsageKind, UsageResult}; use indexmap::IndexMap; use serde::Serialize; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; use tauri_plugin_opener::OpenerExt; fn fmt_anyhow(err: anyhow::Error) -> String { @@ -17,12 +21,60 @@ pub fn debug_log(message: String) { eprintln!("[frontend] {}", message); } +/// SQLite settings key for a provider's billing/usage query kinds +/// (JSON array of kind strings, e.g. `["balance:deepseek"]`). +fn usage_kinds_key(provider_name: &str) -> String { + format!("usage_kinds:{provider_name}") +} + +/// Merge per-provider `usage_kinds` into a loaded config: explicit SQLite +/// settings first, host-based detection as fallback so existing installs get +/// billing support automatically. The field never enters config.toml. +fn merge_usage_kinds(config: &mut Config) { + for p in config.providers.values_mut() { + let from_settings = db::get_setting_pub(&usage_kinds_key(&p.name)) + .ok() + .flatten() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .filter(|v| !v.is_empty()); + p.usage_kinds = from_settings.or_else(|| { + let kinds = services::detect_provider(&resolve_base_url(p)); + if kinds.is_empty() { + None + } else { + Some(kinds.iter().map(|k| k.as_str().to_string()).collect()) + } + }); + } +} + +fn load_pi_native_config() -> Result { + let file = pi_io::load_pi_models().map_err(fmt_anyhow)?; + let mut config = pi_io::pi_file_to_config(&file); + if config.default_model.is_none() { + if let Ok(settings) = pi_io::load_pi_settings() { + if let (Some(provider), Some(model_id)) = + (settings.default_provider, settings.default_model) + { + if let Some(alias) = config + .models + .values() + .find(|m| m.provider == provider && m.model == model_id) + { + config.default_model = Some(alias.alias.clone()); + } + } + } + } + Ok(config) +} + #[tauri::command] pub fn load_agent_config_command(agent: Agent) -> Result { // Load Kimi Switch's own SQLite database (metadata + migration fallback). let db_config = db::load_config(&agent).ok(); - match agent { + let mut config = match agent { Agent::KimiCode => { // config.toml is the authoritative source for provider/model data // because the user can add or edit providers at any time via the @@ -69,33 +121,19 @@ pub fn load_agent_config_command(agent: Agent) -> Result { } } - Ok(config) + config } Agent::Pi => { // Pi: SQLite first, fall back to native config on first use. - if let Some(config) = db_config { - if !config.providers.is_empty() { - return Ok(config); - } + match db_config { + Some(config) if !config.providers.is_empty() => config, + _ => load_pi_native_config()?, } - let file = pi_io::load_pi_models().map_err(fmt_anyhow)?; - let mut config = pi_io::pi_file_to_config(&file); - if config.default_model.is_none() { - if let Ok(settings) = pi_io::load_pi_settings() { - if let (Some(provider), Some(model_id)) = - (settings.default_provider, settings.default_model) - { - if let Some(alias) = config.models.values().find(|m| { - m.provider == provider && m.model == model_id - }) { - config.default_model = Some(alias.alias.clone()); - } - } - } - } - Ok(config) } - } + }; + + merge_usage_kinds(&mut config); + Ok(config) } #[tauri::command] @@ -108,6 +146,19 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str if matches!(agent, Agent::KimiCode) { crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow)?; } + // Persist usage_kinds to the SQLite settings table (never config.toml; + // the field is skip_serializing and the TOML export is hand-built). + // None / empty array → delete the key. + for provider in config.providers.values() { + let key = usage_kinds_key(&provider.name); + match &provider.usage_kinds { + Some(kinds) if !kinds.is_empty() => { + let json = serde_json::to_string(kinds).map_err(|e| e.to_string())?; + db::set_setting_pub(&key, &json).map_err(fmt_anyhow)?; + } + _ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?, + } + } Ok(()) } @@ -511,6 +562,144 @@ pub fn set_app_setting(key: String, value: String) -> Result<(), String> { db::set_setting_pub(&key, &value).map_err(|e| e.to_string()) } +// --------------------------------------------------------------------------- +// Provider billing / usage query (cc-switch semantics) +// --------------------------------------------------------------------------- + +/// 5-minute in-memory cache keyed by (agent, provider_name). +/// Only successful results are cached; failures are always re-queryable. +const USAGE_CACHE_TTL: Duration = Duration::from_secs(300); + +type UsageCache = Mutex>; + +fn usage_cache() -> &'static UsageCache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Query a provider's balance / plan quota. The frontend passes only the +/// provider name — base_url, api_key and usage kinds are all resolved here, +/// so the API key never crosses IPC and the host routing cannot be spoofed. +/// +/// Error channel semantics (cc-switch): +/// - `Err(_)` = transient failure (network/timeout/body read) → frontend +/// retries and keeps the last good value. +/// - `Ok(success:false)` = deterministic failure (no key / auth / non-2xx / +/// bad JSON / unsupported provider) → show the error text directly. +#[tauri::command] +pub async fn query_provider_usage( + agent: Agent, + provider_name: String, + force_refresh: Option, +) -> Result { + let cache_key = (agent.as_str().to_string(), provider_name.clone()); + + if !force_refresh.unwrap_or(false) { + let cached = usage_cache() + .lock() + .unwrap() + .get(&cache_key) + .and_then(|(ts, result)| (ts.elapsed() < USAGE_CACHE_TTL).then(|| result.clone())); + if let Some(result) = cached { + return Ok(result); + } + } + + // Load via the same path as load_agent_config_command so usage_kinds + // (SQLite merge + host-detect fallback) is already resolved. + let config = load_agent_config_command(agent)?; + let Some(provider) = config.providers.get(&provider_name) else { + return Ok(UsageResult::failure(format!( + "provider '{provider_name}' not found" + ))); + }; + + // The api_key only ever goes into request headers — never into logs, + // error messages, or the cache key. + let api_key = provider + .api_key + .clone() + .filter(|s| !s.trim().is_empty()) + .or_else(|| { + provider + .env + .get(expected_api_key_key(&provider.provider_type)) + .cloned() + .filter(|s| !s.is_empty()) + }); + let Some(api_key) = api_key else { + return Ok(UsageResult::failure(if provider.managed { + "provider uses managed OAuth; usage query requires an API key".to_string() + } else { + "no API key configured".to_string() + })); + }; + + let base_url = resolve_base_url(provider); + let kinds: Vec = provider + .usage_kinds + .as_ref() + .filter(|v| !v.is_empty()) + .map(|v| { + v.iter() + .filter_map(|s| s.parse::().ok()) + .collect::>() + }) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| services::detect_provider(&base_url)); + if kinds.is_empty() { + return Ok(UsageResult::failure( + "unsupported provider: no usage query available for this base URL".to_string(), + )); + } + + // A failing kind must not take down the others: collect successes, + // deterministic failures and transient failures separately. + let mut data: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + let mut transient: Vec = Vec::new(); + let mut any_success = false; + for kind in kinds { + match services::query_kind(kind, &base_url, &api_key).await { + Ok(result) if result.success => { + any_success = true; + if let Some(d) = result.data { + data.extend(d); + } + } + Ok(result) => { + if let Some(e) = result.error { + errors.push(format!("{}: {e}", kind.as_str())); + } + } + Err(e) => transient.push(format!("{}: {e}", kind.as_str())), + } + } + + if any_success { + let result = UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: if errors.is_empty() { + None + } else { + Some(errors.join("; ")) + }, + }; + usage_cache() + .lock() + .unwrap() + .insert(cache_key, (Instant::now(), result.clone())); + Ok(result) + } else if !transient.is_empty() { + // All kinds failed transiently → propagate Err so the frontend + // rejects and retries (keep-last-good). + Err(transient.join("; ")) + } else { + Ok(UsageResult::failure(errors.join("; "))) + } +} + // --------------------------------------------------------------------------- // Version check (lightweight: GET Gitea releases API, compare tag_name) // --------------------------------------------------------------------------- diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 3233985..150f27d 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -157,6 +157,8 @@ pub fn load_config(agent: &Agent) -> DbResult { raw_other: raw_json .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or(Value::Null), + // Merged from the settings table by load_agent_config_command. + usage_kinds: None, }) })?; @@ -317,6 +319,13 @@ pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> { Ok(()) } +/// Public helper: delete a single setting (no-op if the key does not exist). +pub fn delete_setting_pub(key: &str) -> DbResult<()> { + let conn = init_db()?; + conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?; + Ok(()) +} + fn provider_type_for_str(s: &str) -> ProviderType { match s { "anthropic" => ProviderType::Anthropic, diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs index deeb06d..7b75d23 100644 --- a/src-tauri/src/kimi_code_io.rs +++ b/src-tauri/src/kimi_code_io.rs @@ -209,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config { icon, icon_color, raw_other, + usage_kinds: None, }, ); } @@ -506,6 +507,7 @@ api_key = "" icon: None, icon_color: None, raw_other: Value::Null, + usage_kinds: None, }, ); let mut models = IndexMap::new(); @@ -567,6 +569,7 @@ api_key = "" icon: None, icon_color: None, raw_other: Value::Null, + usage_kinds: None, }, ); let mut models = IndexMap::new(); @@ -669,7 +672,10 @@ max_context_size = 1048576 managed: false, enabled: true, active: true, + icon: None, + icon_color: None, raw_other: Value::Null, + usage_kinds: None, }, ); providers.insert( @@ -685,7 +691,10 @@ max_context_size = 1048576 managed: false, enabled: true, active: false, + icon: None, + icon_color: None, raw_other: Value::Null, + usage_kinds: None, }, ); let config = Config { @@ -721,7 +730,10 @@ max_context_size = 1048576 managed: false, enabled: true, active: true, + icon: None, + icon_color: None, raw_other: serde_json::json!({"default_model": "some-alias"}), + usage_kinds: None, }, ); let config = Config { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ffa0e53..7149c0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod db; pub mod kimi_code_io; pub mod models; pub mod pi_io; +pub mod services; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder}; @@ -96,6 +97,7 @@ pub fn run() { commands::get_app_version, commands::list_provider_models, commands::test_connectivity, + commands::query_provider_usage, commands::debug_log, commands::get_app_setting, commands::set_app_setting, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 750361b..a910a9c 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -91,6 +91,14 @@ pub struct Provider { pub icon_color: Option, #[serde(default, skip_serializing_if = "Value::is_null")] pub raw_other: Value, + /// Billing/usage query kinds for this provider (e.g. "balance:deepseek", + /// "plan:kimi_coding"). Persisted in the SQLite settings table under + /// `usage_kinds:`, NOT in the agent's config.toml — both + /// export paths (kimi_code_io manual TOML, pi_io PiProvider struct) are + /// explicit and never serialize this field, while the IPC payload to the + /// frontend does carry it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_kinds: Option>, } impl PartialEq for Provider { @@ -108,6 +116,7 @@ impl PartialEq for Provider { && self.icon == other.icon && self.icon_color == other.icon_color && self.raw_other == other.raw_other + && self.usage_kinds == other.usage_kinds } } diff --git a/src-tauri/src/pi_io.rs b/src-tauri/src/pi_io.rs index e67dc60..2f75f85 100644 --- a/src-tauri/src/pi_io.rs +++ b/src-tauri/src/pi_io.rs @@ -339,6 +339,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config { icon: None, icon_color: None, raw_other: provider_raw, + usage_kinds: None, }; for (idx, pi_model) in pi_provider.models.iter().enumerate() { diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs new file mode 100644 index 0000000..61b3c2e --- /dev/null +++ b/src-tauri/src/services/balance.rs @@ -0,0 +1,326 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! 供应商余额查询服务 +//! +//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。 +//! +//! 错误通道语义(与 cc-switch 一致): +//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject, +//! 触发 retry 并保留上一次成功的 data(keep-last-good)。 +//! - `Ok(success:false)` = 确定性失败(空 key/鉴权失败/非 2xx/响应体非法 JSON), +//! 直接透出错误文案。 +//! +//! HTTP 调用与 JSON→UsageData 解析拆成纯函数,便于无 mock 单元测试。 + +use super::usage_types::{UsageData, UsageResult}; +use std::time::Duration; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); + +/// 鉴权头形式:绝大多数供应商用 `Bearer `;智谱套餐接口不加前缀(见 coding_plan)。 +pub(crate) enum AuthStyle { + Bearer, + Raw, +} + +/// GET 请求的归类结果。 +pub(crate) enum Fetched { + /// 2xx 且响应体是合法 JSON。 + Body(serde_json::Value), + /// 确定性失败(401/403/非 2xx/解析失败),调用方原样包成 Ok 返回。 + Failed(UsageResult), +} + +/// 统一的 GET + JSON 读取助手。瞬时失败(网络/超时/读体中断)返回 `Err`; +/// 确定性失败收进 `Fetched::Failed`。 +/// +/// 先 `bytes()` 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 +/// 后解析失败才是确定性。reqwest 的 `.json()` 把读体错误也包成 decode,无法区分。 +pub(crate) async fn get_json( + url: &str, + api_key: &str, + auth: AuthStyle, +) -> Result { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + + // 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。 + let req = client.get(url).header("Accept", "application/json"); + let req = match auth { + AuthStyle::Bearer => req.header("Authorization", format!("Bearer {api_key}")), + AuthStyle::Raw => req.header("Authorization", api_key), + }; + + let resp = match req.send().await { + Ok(r) => r, + Err(e) => return Err(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Ok(Fetched::Failed(UsageResult::failure(format!( + "Authentication failed (HTTP {status})" + )))); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Ok(Fetched::Failed(UsageResult::failure(format!( + "API error (HTTP {status}): {body}" + )))); + } + + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + match serde_json::from_slice(&raw) { + Ok(v) => Ok(Fetched::Body(v)), + Err(e) => Ok(Fetched::Failed(UsageResult::failure(format!( + "Failed to parse response: {e}" + )))), + } +} + +/// 解析 JSON 字段为 f64,兼容数字和字符串格式。 +pub(crate) fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option { + obj.get(field).and_then(|v| { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) +} + +// ── DeepSeek ──────────────────────────────────────────────── +// GET https://api.deepseek.com/user/balance +// Response: { balance_infos: [{ currency, total_balance, ... }], is_available } + +pub async fn query_deepseek(api_key: &str) -> Result { + match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(UsageResult::ok(parse_deepseek(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_deepseek(body: &serde_json::Value) -> Vec { + let is_available = body + .get("is_available") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let mut data = Vec::new(); + + if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) { + for info in infos { + let currency = info + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("CNY"); + data.push(UsageData { + plan_name: Some(currency.to_string()), + remaining: parse_f64_field(info, "total_balance"), + is_valid: Some(is_available), + unit: Some(currency.to_string()), + ..Default::default() + }); + } + } + data +} + +// ── StepFun ───────────────────────────────────────────────── +// GET https://api.stepfun.com/v1/accounts +// Response: { object, type, balance, total_cash_balance, total_voucher_balance } + +pub async fn query_stepfun(api_key: &str) -> Result { + match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(UsageResult::ok(parse_stepfun(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_stepfun(body: &serde_json::Value) -> Vec { + vec![UsageData { + plan_name: Some("StepFun".to_string()), + remaining: Some(parse_f64_field(body, "balance").unwrap_or(0.0)), + unit: Some("CNY".to_string()), + is_valid: Some(true), + ..Default::default() + }] +} + +// ── SiliconFlow ───────────────────────────────────────────── +// GET https://api.siliconflow.cn/v1/user/info (.cn;海外站 .com 单位 USD) +// Response: { code, data: { balance, chargeBalance, totalBalance, status } } + +pub async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result { + let domain = if is_cn { + "api.siliconflow.cn" + } else { + "api.siliconflow.com" + }; + let url = format!("https://{domain}/v1/user/info"); + match get_json(&url, api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(match parse_siliconflow(&body, is_cn) { + Ok(data) => UsageResult::ok(data), + Err(err) => err, + }), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_siliconflow(body: &serde_json::Value, is_cn: bool) -> Result, UsageResult> { + let data = match body.get("data") { + Some(d) => d, + None => { + return Err(UsageResult::failure( + "Missing 'data' field in response".to_string(), + )) + } + }; + let (plan_name, unit) = if is_cn { + ("SiliconFlow", "CNY") + } else { + ("SiliconFlow (EN)", "USD") + }; + Ok(vec![UsageData { + plan_name: Some(plan_name.to_string()), + remaining: Some(parse_f64_field(data, "totalBalance").unwrap_or(0.0)), + unit: Some(unit.to_string()), + is_valid: Some(true), + ..Default::default() + }]) +} + +// ── OpenRouter ────────────────────────────────────────────── +// GET https://openrouter.ai/api/v1/credits +// Response: { data: { total_credits, total_usage } } + +pub async fn query_openrouter(api_key: &str) -> Result { + match get_json( + "https://openrouter.ai/api/v1/credits", + api_key, + AuthStyle::Bearer, + ) + .await? + { + Fetched::Body(body) => Ok(UsageResult::ok(parse_openrouter(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_openrouter(body: &serde_json::Value) -> Vec { + let data = body.get("data").unwrap_or(body); + let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0); + let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0); + let remaining = total_credits - total_usage; + + vec![UsageData { + plan_name: Some("OpenRouter".to_string()), + remaining: Some(remaining), + total: Some(total_credits), + used: Some(total_usage), + unit: Some("USD".to_string()), + is_valid: Some(remaining > 0.0), + ..Default::default() + }] +} + +// ── Novita AI ─────────────────────────────────────────────── +// GET https://api.novita.ai/v3/user/balance +// Response: { availableBalance, ... };金额单位 0.0001 USD,需 /10000。 + +pub async fn query_novita(api_key: &str) -> Result { + match get_json( + "https://api.novita.ai/v3/user/balance", + api_key, + AuthStyle::Bearer, + ) + .await? + { + Fetched::Body(body) => Ok(UsageResult::ok(parse_novita(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_novita(body: &serde_json::Value) -> Vec { + let available = parse_f64_field(body, "availableBalance").unwrap_or(0.0) / 10000.0; + vec![UsageData { + plan_name: Some("Novita AI".to_string()), + remaining: Some(available), + unit: Some("USD".to_string()), + is_valid: Some(available > 0.0), + ..Default::default() + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn deepseek_maps_balance_infos() { + let body = json!({ + "is_available": true, + "balance_infos": [ + { "currency": "CNY", "total_balance": "12.34" }, + { "currency": "USD", "total_balance": 5.0 } + ] + }); + let data = parse_deepseek(&body); + assert_eq!(data.len(), 2); + assert_eq!(data[0].plan_name.as_deref(), Some("CNY")); + assert_eq!(data[0].remaining, Some(12.34)); + assert_eq!(data[0].unit.as_deref(), Some("CNY")); + assert_eq!(data[0].is_valid, Some(true)); + assert_eq!(data[1].remaining, Some(5.0)); + } + + #[test] + fn stepfun_reads_balance() { + let body = json!({ "balance": 88.5 }); + let data = parse_stepfun(&body); + assert_eq!(data[0].remaining, Some(88.5)); + assert_eq!(data[0].unit.as_deref(), Some("CNY")); + } + + #[test] + fn siliconflow_missing_data_is_deterministic_failure() { + let body = json!({ "code": 500 }); + let err = parse_siliconflow(&body, true).unwrap_err(); + assert!(!err.success); + assert!(err.error.unwrap().contains("Missing 'data'")); + } + + #[test] + fn siliconflow_cn_and_en_units() { + let body = json!({ "data": { "totalBalance": "42.0" } }); + let cn = parse_siliconflow(&body, true).unwrap(); + let en = parse_siliconflow(&body, false).unwrap(); + assert_eq!(cn[0].unit.as_deref(), Some("CNY")); + assert_eq!(en[0].unit.as_deref(), Some("USD")); + assert_eq!(cn[0].remaining, Some(42.0)); + } + + #[test] + fn openrouter_computes_remaining_credits() { + let body = json!({ "data": { "total_credits": 20.0, "total_usage": 7.5 } }); + let data = parse_openrouter(&body); + assert_eq!(data[0].remaining, Some(12.5)); + assert_eq!(data[0].total, Some(20.0)); + assert_eq!(data[0].used, Some(7.5)); + assert_eq!(data[0].is_valid, Some(true)); + } + + #[test] + fn novita_converts_ten_thousandth_usd() { + let body = json!({ "availableBalance": 123400 }); + let data = parse_novita(&body); + assert_eq!(data[0].remaining, Some(12.34)); + assert_eq!(data[0].unit.as_deref(), Some("USD")); + + let zero = parse_novita(&json!({ "availableBalance": 0 })); + assert_eq!(zero[0].is_valid, Some(false)); + } +} diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs new file mode 100644 index 0000000..67f6418 --- /dev/null +++ b/src-tauri/src/services/coding_plan.rs @@ -0,0 +1,414 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! Token Plan 套餐额度查询服务 +//! +//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的套餐额度查询。 +//! cc-switch 的 SubscriptionQuota/tiers 结构在此展平为 `Vec`: +//! 每个窗口(tier)一条 UsageData,`plan_name` = tier 名("five_hour" / +//! "weekly_limit"),`used` = 已用百分比(0-100),`total` = 100, +//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。 +//! +//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。 + +use super::balance::{get_json, AuthStyle, Fetched}; +use super::usage_types::{UsageData, UsageResult}; + +const TIER_FIVE_HOUR: &str = "five_hour"; +const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; + +/// 套餐条目的统一构造:按百分比表示用量。 +fn percent_tier(name: &str, used_percent: f64, resets_at: Option) -> UsageData { + UsageData { + plan_name: Some(name.to_string()), + remaining: Some((100.0 - used_percent).max(0.0)), + total: Some(100.0), + used: Some(used_percent), + unit: Some("%".to_string()), + is_valid: Some(true), + resets_at, + } +} + +fn millis_to_iso8601(ms: i64) -> Option { + let secs = ms / 1000; + let nsecs = ((ms % 1000) * 1_000_000) as u32; + chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339()) +} + +/// 从 JSON 值提取重置时间,兼容字符串和数字格式: +/// - 字符串:直接返回(视为 ISO 8601) +/// - 数字:自动判断秒/毫秒并转为 ISO 8601;0/负值视为无重置时间 +fn extract_reset_time(value: &serde_json::Value) -> Option { + if let Some(s) = value.as_str() { + return Some(s.to_string()); + } + if let Some(n) = value.as_i64() { + if n <= 0 { + return None; + } + // 秒级时间戳 < 1e12,毫秒 >= 1e12 + let ms = if n < 1_000_000_000_000 { n * 1000 } else { n }; + return millis_to_iso8601(ms); + } + None +} + +/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`) +fn parse_f64(value: &serde_json::Value) -> Option { + value + .as_f64() + .or_else(|| value.as_str().and_then(|s| s.parse().ok())) +} + +// ── Kimi For Coding ───────────────────────────────────────── +// GET https://api.kimi.com/coding/v1/usages +// Response: { limits: [{ detail: { limit, remaining, resetTime } }], +// usage: { limit, remaining, resetTime } } + +pub async fn query_kimi_coding(api_key: &str) -> Result { + match get_json( + "https://api.kimi.com/coding/v1/usages", + api_key, + AuthStyle::Bearer, + ) + .await? + { + Fetched::Body(body) => Ok(UsageResult::ok(parse_kimi_coding(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_kimi_coding(body: &serde_json::Value) -> Vec { + let mut tiers = Vec::new(); + + // 5 小时窗口限额(优先显示) + if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + if let Some(detail) = limit_item.get("detail") { + tiers.push(kimi_limit_tier(TIER_FIVE_HOUR, detail)); + } + } + } + + // 总体用量(周限额) + if let Some(usage) = body.get("usage") { + tiers.push(kimi_limit_tier(TIER_WEEKLY_LIMIT, usage)); + } + + tiers +} + +fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData { + let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0); + let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0); + let resets_at = detail.get("resetTime").and_then(extract_reset_time); + + let used = (limit - remaining).max(0.0); + let utilization = if limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + percent_tier(name, utilization, resets_at) +} + +// ── 智谱 GLM ──────────────────────────────────────────────── +// GET {open.bigmodel.cn | api.z.ai}/api/monitor/usage/quota/limit +// 注意:智谱鉴权不加 Bearer 前缀(cc-switch 实测行为,照搬)。 + +/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。 +/// 实测:`unit: 3` → 5 小时滚动窗口;`unit: 6` → 每周窗口。 +/// 缺失或不识别时走重置时间启发式兜底。 +fn parse_zhipu(body: &serde_json::Value) -> Result, UsageResult> { + // 业务级别错误 + if body.get("success").and_then(|v| v.as_bool()) == Some(false) { + let msg = body + .get("msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(UsageResult::failure(format!("API error: {msg}"))); + } + + let data = match body.get("data") { + Some(d) => d, + None => { + return Err(UsageResult::failure( + "Missing 'data' field in response".to_string(), + )) + } + }; + + type Entry = (Option, f64, Option); + let mut five_hour: Option = None; + let mut weekly: Option = None; + let mut unclassified: Vec = Vec::new(); + + if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + let limit_type = limit_item + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") { + continue; + } + let percentage = limit_item + .get("percentage") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let reset_ms = limit_item.get("nextResetTime").and_then(|v| v.as_i64()); + let reset_iso = reset_ms.and_then(millis_to_iso8601); + let entry = (reset_ms, percentage, reset_iso); + match limit_item.get("unit").and_then(|v| v.as_i64()) { + Some(3) if five_hour.is_none() => five_hour = Some(entry), + Some(6) if weekly.is_none() => weekly = Some(entry), + _ => unclassified.push(entry), + } + } + } + + // 兜底:无 nextResetTime 的优先归 five_hour,其余按 reset 升序填空槽。 + unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN))); + for entry in unclassified { + if five_hour.is_none() { + five_hour = Some(entry); + } else if weekly.is_none() { + weekly = Some(entry); + } + } + + let mut tiers = Vec::new(); + for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] { + if let Some((_, percentage, resets_at)) = slot { + tiers.push(percent_tier(name, percentage, resets_at)); + } + } + Ok(tiers) +} + +/// 额度接口与推理接口同 host:bigmodel.cn 与 z.ai 共用同一后端与 JSON shape。 +fn zhipu_quota_base(base_url: &str) -> &'static str { + if base_url.to_lowercase().contains("bigmodel.cn") { + "https://open.bigmodel.cn" + } else { + "https://api.z.ai" + } +} + +pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result { + let url = format!( + "{}/api/monitor/usage/quota/limit", + zhipu_quota_base(base_url) + ); + match get_json(&url, api_key, AuthStyle::Raw).await? { + Fetched::Body(body) => Ok(match parse_zhipu(&body) { + Ok(data) => UsageResult::ok(data), + Err(err) => err, + }), + Fetched::Failed(err) => Ok(err), + } +} + +// ── MiniMax ───────────────────────────────────────────────── +// GET https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains +// (海外站 api.minimax.io) +// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。 + +pub async fn query_minimax(api_key: &str, is_cn: bool) -> Result { + let domain = if is_cn { + "api.minimaxi.com" + } else { + "api.minimax.io" + }; + let url = format!("https://{domain}/v1/api/openplatform/coding_plan/remains"); + match get_json(&url, api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(match parse_minimax(&body) { + Ok(data) => UsageResult::ok(data), + Err(err) => err, + }), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_minimax(body: &serde_json::Value) -> Result, UsageResult> { + // 业务级别错误 + if let Some(base_resp) = body.get("base_resp") { + let status_code = base_resp + .get("status_code") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + if status_code != 0 { + let msg = base_resp + .get("status_msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(UsageResult::failure(format!( + "API error (code {status_code}): {msg}" + ))); + } + } + + let mut tiers = Vec::new(); + + let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) else { + return Ok(tiers); + }; + // 只取 general(编程套餐),跳过 video 等其他模型 + let Some(item) = model_remains.iter().find(|item| { + item.get("model_name") + .and_then(|v| v.as_str()) + .map(|s| s == "general") + .unwrap_or(false) + }) else { + return Ok(tiers); + }; + + // 5h 桶:剩余百分比 → 已用百分比 + if let Some(remain_pct) = item + .get("current_interval_remaining_percent") + .and_then(|v| v.as_f64()) + { + let resets_at = item + .get("end_time") + .and_then(|v| v.as_i64()) + .and_then(millis_to_iso8601); + tiers.push(percent_tier(TIER_FIVE_HOUR, 100.0 - remain_pct, resets_at)); + } + + // 周桶:仅 status=1 时激活;status=3 等表示该套餐无周限额,跳过 + if item.get("current_weekly_status").and_then(|v| v.as_i64()) == Some(1) { + if let Some(remain_pct) = item + .get("current_weekly_remaining_percent") + .and_then(|v| v.as_f64()) + { + let resets_at = item + .get("weekly_end_time") + .and_then(|v| v.as_i64()) + .and_then(millis_to_iso8601); + tiers.push(percent_tier( + TIER_WEEKLY_LIMIT, + 100.0 - remain_pct, + resets_at, + )); + } + } + + Ok(tiers) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn kimi_coding_flattens_limits_and_usage() { + let body = json!({ + "limits": [ + { "detail": { "limit": 100, "remaining": 40, "resetTime": 1_754_000_000_000i64 } } + ], + "usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" } + }); + let tiers = parse_kimi_coding(&body); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(60.0)); + assert_eq!(tiers[0].total, Some(100.0)); + assert_eq!(tiers[0].remaining, Some(40.0)); + assert!(tiers[0].resets_at.is_some()); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[1].used, Some(10.0)); + assert_eq!( + tiers[1].resets_at.as_deref(), + Some("2026-08-01T00:00:00Z") + ); + } + + #[test] + fn kimi_coding_reset_time_seconds_vs_millis() { + // 秒级时间戳自动 ×1000 + let v = json!(1_754_000_000i64); + assert!(extract_reset_time(&v).is_some()); + // 0 / 负值视为无重置时间 + assert_eq!(extract_reset_time(&json!(0)), None); + assert_eq!(extract_reset_time(&json!(-1)), None); + } + + #[test] + fn zhipu_classifies_by_unit_field() { + let body = json!({ + "success": true, + "data": { + "limits": [ + { "type": "TOKENS_LIMIT", "percentage": 35.0, "unit": 3, "nextResetTime": 1_754_000_000_000i64 }, + { "type": "TOKENS_LIMIT", "percentage": 80.0, "unit": 6, "nextResetTime": 1_754_500_000_000i64 } + ] + } + }); + let tiers = parse_zhipu(&body).unwrap(); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(35.0)); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[1].used, Some(80.0)); + } + + #[test] + fn zhipu_business_error_is_deterministic_failure() { + let body = json!({ "success": false, "msg": "token invalid" }); + let err = parse_zhipu(&body).unwrap_err(); + assert!(!err.success); + assert!(err.error.unwrap().contains("token invalid")); + } + + #[test] + fn minimax_picks_general_and_inverts_remaining() { + let body = json!({ + "base_resp": { "status_code": 0, "status_msg": "success" }, + "model_remains": [ + { "model_name": "video", "current_interval_remaining_percent": 50.0 }, + { + "model_name": "general", + "current_interval_remaining_percent": 70.0, + "end_time": 1_754_000_000_000i64, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 95.0, + "weekly_end_time": 1_754_500_000_000i64 + } + ] + }); + let tiers = parse_minimax(&body).unwrap(); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(30.0)); + assert_eq!(tiers[0].remaining, Some(70.0)); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[1].used, Some(5.0)); + } + + #[test] + fn minimax_skips_inactive_weekly_bucket() { + let body = json!({ + "model_remains": [ + { + "model_name": "general", + "current_interval_remaining_percent": 70.0, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100.0 + } + ] + }); + let tiers = parse_minimax(&body).unwrap(); + assert_eq!(tiers.len(), 1); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + } + + #[test] + fn minimax_business_error_is_deterministic_failure() { + let body = json!({ "base_resp": { "status_code": 1002, "status_msg": "invalid key" } }); + let err = parse_minimax(&body).unwrap_err(); + assert!(!err.success); + assert!(err.error.unwrap().contains("invalid key")); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs new file mode 100644 index 0000000..9eca9ad --- /dev/null +++ b/src-tauri/src/services/mod.rs @@ -0,0 +1,182 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! 供应商账单/用量查询统一入口。 +//! +//! - [`UsageKind`]:8 种查询类型,字符串形式与前端 / SQLite settings 约定一致 +//! (如 `"balance:deepseek"`、`"plan:kimi_coding"`)。 +//! - [`detect_provider`]:按 base_url host 子串匹配,旧用户无显式配置时自动识别。 +//! - [`query_kind`]:按 kind 路由到 balance / coding_plan 的具体实现。 + +pub mod balance; +pub mod coding_plan; +pub mod usage_types; + +pub use usage_types::{UsageData, UsageResult}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UsageKind { + BalanceDeepseek, + BalanceSiliconflow, + BalanceOpenrouter, + BalanceStepfun, + BalanceNovita, + PlanKimiCoding, + PlanZhipu, + PlanMinimax, +} + +impl UsageKind { + /// 与前端 / SQLite settings(`usage_kinds:`)约定的字符串形式。 + pub fn as_str(&self) -> &'static str { + match self { + UsageKind::BalanceDeepseek => "balance:deepseek", + UsageKind::BalanceSiliconflow => "balance:siliconflow", + UsageKind::BalanceOpenrouter => "balance:openrouter", + UsageKind::BalanceStepfun => "balance:stepfun", + UsageKind::BalanceNovita => "balance:novita", + UsageKind::PlanKimiCoding => "plan:kimi_coding", + UsageKind::PlanZhipu => "plan:zhipu", + UsageKind::PlanMinimax => "plan:minimax", + } + } + + pub const ALL: [UsageKind; 8] = [ + UsageKind::BalanceDeepseek, + UsageKind::BalanceSiliconflow, + UsageKind::BalanceOpenrouter, + UsageKind::BalanceStepfun, + UsageKind::BalanceNovita, + UsageKind::PlanKimiCoding, + UsageKind::PlanZhipu, + UsageKind::PlanMinimax, + ]; +} + +impl std::str::FromStr for UsageKind { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(match s { + "balance:deepseek" => UsageKind::BalanceDeepseek, + "balance:siliconflow" => UsageKind::BalanceSiliconflow, + "balance:openrouter" => UsageKind::BalanceOpenrouter, + "balance:stepfun" => UsageKind::BalanceStepfun, + "balance:novita" => UsageKind::BalanceNovita, + "plan:kimi_coding" => UsageKind::PlanKimiCoding, + "plan:zhipu" => UsageKind::PlanZhipu, + "plan:minimax" => UsageKind::PlanMinimax, + _ => return Err(()), + }) + } +} + +/// 按 base_url 子串匹配可支持的查询类型;无匹配返回空 vec。 +/// 一个 base_url 理论上可同时命中多种(套餐 + 余额),故返回 Vec。 +pub fn detect_provider(base_url: &str) -> Vec { + let url = base_url.to_lowercase(); + let mut kinds = Vec::new(); + if url.contains("api.deepseek.com") { + kinds.push(UsageKind::BalanceDeepseek); + } + if url.contains("api.siliconflow.cn") { + kinds.push(UsageKind::BalanceSiliconflow); + } + if url.contains("openrouter.ai") { + kinds.push(UsageKind::BalanceOpenrouter); + } + if url.contains("api.stepfun.com") { + kinds.push(UsageKind::BalanceStepfun); + } + if url.contains("api.novita.ai") { + kinds.push(UsageKind::BalanceNovita); + } + if url.contains("api.kimi.com") && url.contains("/coding") { + kinds.push(UsageKind::PlanKimiCoding); + } + if url.contains("open.bigmodel.cn") || url.contains("api.z.ai") { + kinds.push(UsageKind::PlanZhipu); + } + if url.contains("api.minimaxi.com") { + kinds.push(UsageKind::PlanMinimax); + } + kinds +} + +/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的 +/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。 +pub async fn query_kind( + kind: UsageKind, + base_url: &str, + api_key: &str, +) -> Result { + let lower = base_url.to_lowercase(); + match kind { + UsageKind::BalanceDeepseek => balance::query_deepseek(api_key).await, + UsageKind::BalanceSiliconflow => { + balance::query_siliconflow(api_key, !lower.contains("siliconflow.com")).await + } + UsageKind::BalanceOpenrouter => balance::query_openrouter(api_key).await, + UsageKind::BalanceStepfun => balance::query_stepfun(api_key).await, + UsageKind::BalanceNovita => balance::query_novita(api_key).await, + UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key).await, + UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key).await, + UsageKind::PlanMinimax => { + coding_plan::query_minimax(api_key, !lower.contains("minimax.io")).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_provider_maps_known_hosts() { + let cases: [(&str, UsageKind); 8] = [ + ("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek), + ("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow), + ("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter), + ("https://api.stepfun.com/v1", UsageKind::BalanceStepfun), + ("https://api.novita.ai/v3", UsageKind::BalanceNovita), + ("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding), + ( + "https://open.bigmodel.cn/api/paas/v4", + UsageKind::PlanZhipu, + ), + ("https://api.minimaxi.com/v1", UsageKind::PlanMinimax), + ]; + for (url, expected) in cases { + assert_eq!( + detect_provider(url), + vec![expected], + "url: {url}" + ); + } + // z.ai 也命中智谱 + assert_eq!( + detect_provider("https://api.z.ai/api/paas/v4"), + vec![UsageKind::PlanZhipu] + ); + } + + #[test] + fn detect_provider_unknown_url_returns_empty() { + assert!(detect_provider("https://api.openai.com/v1").is_empty()); + assert!(detect_provider("https://example.com").is_empty()); + assert!(detect_provider("").is_empty()); + // api.kimi.com 但无 /coding 路径 → 不命中套餐查询 + assert!(detect_provider("https://api.kimi.com/v1").is_empty()); + } + + #[test] + fn usage_kind_string_roundtrip() { + use std::str::FromStr; + for kind in UsageKind::ALL { + let s = kind.as_str(); + assert_eq!(UsageKind::from_str(s), Ok(kind), "kind: {s}"); + } + assert!(UsageKind::from_str("balance:unknown").is_err()); + assert!(UsageKind::from_str("").is_err()); + } +} diff --git a/src-tauri/src/services/usage_types.rs b/src-tauri/src/services/usage_types.rs new file mode 100644 index 0000000..fa21462 --- /dev/null +++ b/src-tauri/src/services/usage_types.rs @@ -0,0 +1,76 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! Shared return contract for provider billing/usage queries. +//! +//! MUST stay camelCase: the frontend reads `planName` / `resetsAt` / +//! `isValid` — snake_case serialization would silently misalign every field. + +use serde::Serialize; + +#[derive(Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct UsageData { + pub plan_name: Option, + pub remaining: Option, + pub total: Option, + pub used: Option, + pub unit: Option, + pub is_valid: Option, + pub resets_at: Option, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UsageResult { + pub success: bool, + pub data: Option>, + pub error: Option, +} + +impl UsageResult { + pub fn ok(data: Vec) -> Self { + UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + } + } + + pub fn failure(msg: String) -> Self { + UsageResult { + success: false, + data: None, + error: Some(msg), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_result_serializes_camel_case() { + let result = UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("five_hour".to_string()), + remaining: Some(65.0), + total: Some(100.0), + used: Some(35.0), + unit: Some("%".to_string()), + is_valid: Some(true), + resets_at: Some("2026-07-29T12:00:00+00:00".to_string()), + }]), + error: None, + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"planName\""), "json: {json}"); + assert!(json.contains("\"resetsAt\""), "json: {json}"); + assert!(json.contains("\"isValid\""), "json: {json}"); + assert!(json.contains("\"plan_name\"") == false, "json: {json}"); + assert!(json.contains("\"resets_at\"") == false, "json: {json}"); + assert!(json.contains("\"is_valid\"") == false, "json: {json}"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index caf40c8..39a6aa6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.5.2", + "version": "0.6.0", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index f27cc01..40f2a38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useConfig } from "./hooks/useConfig"; @@ -8,9 +8,15 @@ import { ProviderEdit } from "./components/ProviderEdit"; import { DashboardPage } from "./components/dashboard/DashboardPage"; import { SessionsPage } from "./components/sessions/SessionsPage"; import { SettingsModal } from "./components/SettingsModal"; +import { PresetPickerModal } from "./components/PresetPickerModal"; import { useTranslation } from "./i18n"; import { getDefaultMaxContextSize } from "./lib/model-defaults"; import { getModelRef } from "./lib/models-dev"; +import { + presetToProviderAndModels, + type ProviderPreset, +} from "./config/providerPresets"; +import { validateProviders } from "./lib/validation"; import type { Agent, Model, Provider } from "./types"; const AGENT_STORAGE_KEY = "kimi-switch-agent"; @@ -68,6 +74,14 @@ export default function App() { const [loadTimeout, setLoadTimeout] = useState(false); const [switchMessage, setSwitchMessage] = useState(null); const [showSettings, setShowSettings] = useState(false); + const [showPresetPicker, setShowPresetPicker] = useState(false); + // Provider names that have been added to the in-memory config but not + // yet committed via save(). Pressing "back" in the edit form for one of + // these drops it silently (no confirm); all other dirty navigation + // keeps the existing unsavedConfirm flow. + const [pendingNewProviders, setPendingNewProviders] = useState>( + () => new Set(), + ); useEffect(() => { @@ -79,35 +93,6 @@ export default function App() { return () => clearTimeout(timer); }, [loading]); - // Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (!e.ctrlKey || e.metaKey) return; - switch (e.key) { - case "s": - case "S": - e.preventDefault(); - save(); - break; - case "r": - case "R": - e.preventDefault(); - if (dirty && !confirm(t("unsavedConfirm"))) return; - refresh(); - break; - case "o": - case "O": - e.preventDefault(); - invoke("open_agent_config_dir", { agent }).catch((err) => - alert(err instanceof Error ? err.message : String(err)) - ); - break; - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [save, refresh, dirty, t, agent]); - // Update window title to reflect unsaved changes. useEffect(() => { const app = getCurrentWindow(); @@ -138,8 +123,89 @@ export default function App() { [config] ); + // trySave calls validateProviders, which takes a library-style + // `(key: string) => string`; useTranslation's `t` is strictly typed to + // the project's i18n key union, so we relax it locally for the call. + const tGeneric = t as unknown as ( + key: string, + vars?: Record, + ) => string; + // Wraps `save` with a completeness check. Three flavours of caller: + // 1. Whole-config save (Ctrl+S, footer button, ProviderEdit onSave): + // no `target` → validate every enabled provider, show a confirm + // dialog listing the incomplete ones. + // 2. Switching a specific provider as active: `target` is the + // provider being switched to — only that one needs to be + // complete; sibling providers being merely listed in the config + // must not block the switch. + // 3. Programmatic auto-saves (duplicate) call `save` directly with + // no validation — duplicates inherit completeness from their + // source. + // Returns `true` when the config was actually persisted, `false` when + // the user cancelled the validation confirm (callers can react to this + // to roll back in-memory mutations made before the save). + const trySave = useCallback( + async (target?: string): Promise => { + if (!config) return false; + if (dirty) { + const issues = validateProviders(config, config.models, tGeneric, target); + if (issues.length > 0) { + const summary = issues + .map((i) => `• ${i.name}: ${i.reasons.join("; ")}`) + .join("\n"); + if (!confirm(t("saveValidationConfirm", { details: summary }))) { + return false; + } + } + } + await save(); + // Successful save: clear the "in-memory only" markers — every + // provider is now on disk. + setPendingNewProviders(new Set()); + return true; + }, + [config, dirty, save, t], + ); + + // Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!e.ctrlKey || e.metaKey) return; + switch (e.key) { + case "s": + case "S": + e.preventDefault(); + void trySave(); + break; + case "r": + case "R": + e.preventDefault(); + if (dirty && !confirm(t("unsavedConfirm"))) return; + refresh(); + break; + case "o": + case "O": + e.preventDefault(); + invoke("open_agent_config_dir", { agent }).catch((err) => + alert(err instanceof Error ? err.message : String(err)) + ); + break; + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [trySave, refresh, dirty, t, agent]); + + // "Add provider" opens the preset picker; the empty form is reachable via + // the picker's "+ custom config" entry (handleAddCustomProvider). const handleAddProvider = () => { if (!config) return; + setShowPresetPicker(true); + }; + + const handleAddCustomProvider = () => { + if (!config) return; + setShowPresetPicker(false); const name = `provider-${providers.length + 1}`; const defaultType = agent === "kimi_code" ? "kimi" : "openai"; updateConfig((cfg) => ({ @@ -159,10 +225,45 @@ export default function App() { }, }, })); + // Mark as in-memory only — never persisted until the user explicitly + // saves a complete config. Pressing "back" in the edit form will + // silently drop this entry. + setPendingNewProviders((prev) => new Set([...prev, name])); setEditingProvider(name); setView("edit"); }; + const handleSelectPreset = async (preset: ProviderPreset) => { + if (!config) return; + setShowPresetPicker(false); + const { provider, models, defaultModel } = presetToProviderAndModels(preset, { + existingProviderNames: new Set(Object.keys(config.providers)), + existingModelAliases: new Set(Object.keys(config.models)), + }); + updateConfig((cfg) => { + const providers = { ...cfg.providers, [provider.name]: provider }; + const updatedModels = { ...cfg.models }; + for (const m of models) { + updatedModels[m.alias] = m; + } + return { + ...cfg, + providers, + models: updatedModels, + default_model: defaultModel || cfg.default_model, + }; + }); + // Do NOT auto-save: a freshly-picked preset is intentionally + // incomplete (no api_key yet). The user lands in the edit form + // next; if they fill the key and save, validation passes and the + // config lands on disk. If they back out without saving, the + // in-memory additions are discarded on the next refresh — config.toml + // never receives a half-configured provider. + setPendingNewProviders((prev) => new Set([...prev, provider.name])); + setEditingProvider(provider.name); + setView("edit"); + }; + const handleUpdateProvider = (provider: Provider) => { updateConfig((cfg) => { const providers = { ...cfg.providers }; @@ -227,7 +328,18 @@ export default function App() { const models = { ...cfg.models }; for (const [alias, m] of Object.entries(cfg.models)) { if (m.provider === name) { - const newAlias = newName + alias.slice(name.length); + // The model belongs to this provider by `m.provider === name`. To + // re-key it under the new provider, we want a `newName/...` + // alias. The original code assumed `alias` is exactly + // `${name}/${modelId}` and used `alias.slice(name.length)`; that + // breaks for legacy / non-standard aliases (e.g. `kimi-k3` from + // pre-v0.6 data) which would produce `newName-k3` — missing the + // `/` separator and pointing at the wrong model. Use an explicit + // prefix check and fall back to a full re-prefix. + const prefix = `${name}/`; + const newAlias = alias.startsWith(prefix) + ? newName + alias.slice(name.length) + : `${newName}/${alias}`; models[newAlias] = { ...m, alias: newAlias, provider: newName }; } } @@ -303,7 +415,13 @@ export default function App() { }); // Persist the full config to Kimi Switch's SQLite and activate the selected provider. - await save(); + const saved = await trySave(name); + if (!saved) { + // Validation was cancelled: undo the in-memory switch by reloading + // the on-disk state, and skip the activation + /reload nudge. + await refresh(); + return; + } await invoke("activate_agent_config_command", { agent }); // /reload is an interactive Kimi Code TUI command with no CLI equivalent, @@ -317,6 +435,27 @@ export default function App() { } }; + const handleEditBack = async () => { + const target = editingProvider; + if (target && pendingNewProviders.has(target)) { + // This provider was added in-memory (via preset or "+ custom config") + // but never committed. Drop it silently — no confirm — and reload + // from disk so the list reflects the on-disk truth. + setPendingNewProviders((prev) => { + const next = new Set(prev); + next.delete(target); + return next; + }); + await refresh(); + } else if (dirty) { + // Real edits to a previously-saved provider — keep the existing + // unsaved-changes confirm so the user doesn't lose work by accident. + if (!confirm(t("unsavedConfirm"))) return; + await refresh(); + } + setView("list"); + }; + const handleApplyProviderJson = (provider: Provider, models: Model[]) => { updateConfig((cfg) => { const providers = { ...cfg.providers }; @@ -508,7 +647,7 @@ export default function App() { onRawOtherChange={(nextRawOther) => updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther })) } - onBack={() => setView("list")} + onBack={handleEditBack} onChange={handleUpdateProvider} onDelete={() => handleDeleteProvider(currentProvider.name)} onModelChange={(model) => { @@ -577,7 +716,7 @@ export default function App() { }} onSetDefault={handleSetDefaultModel} onApplyJson={handleApplyProviderJson} - onSave={save} + onSave={trySave} /> ) : (
@@ -603,7 +742,7 @@ export default function App() {
); } diff --git a/src/components/PresetPickerModal.tsx b/src/components/PresetPickerModal.tsx new file mode 100644 index 0000000..cbaef09 --- /dev/null +++ b/src/components/PresetPickerModal.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "../i18n"; +import type { TranslationKey } from "../i18n/zh"; +import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets"; +import { ProviderIcon } from "./ProviderIcon"; + +interface PresetPickerModalProps { + open: boolean; + onClose: () => void; + onSelect: (preset: ProviderPreset) => void; + onCustom: () => void; +} + +const CATEGORY_BADGE: Record, string> = { + official: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400", + cn_official: "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400", + third_party: "bg-gray-200 text-gray-600 dark:bg-gray-500/15 dark:text-gray-400", + aggregator: "bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400", +}; + +const CATEGORY_LABEL: Record, TranslationKey> = { + official: "presetCategoryOfficial", + cn_official: "presetCategoryCnOfficial", + third_party: "presetCategoryThirdParty", + aggregator: "presetCategoryAggregator", +}; + +type SortMode = "original" | "alpha"; + +export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("original"); + + // Esc to close + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + // Scroll lock while open + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + // Reset transient state each time the modal opens + useEffect(() => { + if (open) { + setQuery(""); + setSort("original"); + } + }, [open]); + + const presetName = (p: ProviderPreset) => + p.nameKey ? t(p.nameKey as TranslationKey) : p.name; + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + let list = providerPresets; + if (q) { + list = list.filter( + (p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q) + ); + } + if (sort === "alpha") { + list = [...list].sort((a, b) => presetName(a).localeCompare(presetName(b))); + } + return list; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, sort, t]); + + if (!open) return null; + + return createPortal( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {t("presetPickerTitle")} +
+ +
+ + {/* Search + sort */} +
+ setQuery(e.target.value)} + placeholder={t("presetSearchPlaceholder")} + autoFocus + className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + +
+
+ + {/* Preset grid */} +
+ {visible.length === 0 ? ( +
+ {t("presetNoResults")} +
+ ) : ( +
+ {visible.map((p) => ( + + ))} +
+ )} + + {/* Custom (empty form) entry */} + +
+ + {/* Footer hint */} +
+ {t("presetFooterHint")} +
+
+
, + document.body + ); +} diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index 10e2343..a763470 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react"; import { useTranslation } from "../i18n"; import { ProviderIcon } from "./ProviderIcon"; +import { UsageFooter } from "./UsageFooter"; import type { Agent, Model, Provider } from "../types"; interface ConnectivityResult { @@ -144,13 +145,14 @@ export function ProviderList({ return (
onEdit(provider.name)} > +
+
+
); })} diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx new file mode 100644 index 0000000..cac346e --- /dev/null +++ b/src/components/UsageFooter.tsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Loader2, RefreshCw } from "lucide-react"; +import { useTranslation } from "../i18n"; +import type { Agent } from "../types"; + +interface UsageData { + planName?: string | null; + remaining?: number | null; + total?: number | null; + used?: number | null; + unit?: string | null; + isValid?: boolean | null; + resetsAt?: string | null; +} + +interface UsageResult { + success: boolean; + data?: UsageData[] | null; + error?: string | null; +} + +type UsageStatus = "idle" | "loading" | "success" | "error"; + +interface CacheEntry { + status: "success" | "error"; + data: UsageData[]; + /** Raw error text from Rust; null = transient failure (invoke rejected). */ + error: string | null; + updatedAt: number; +} + +// Module-level cache shared across mounts: re-entering the list within the +// stale TTL shows the last result without firing new requests. +const STALE_TTL_MS = 5 * 60 * 1000; +const cache = new Map(); + +// Simple semaphore: at most MAX_CONCURRENT queries in flight at once. +const MAX_CONCURRENT = 3; +let running = 0; +const waiters: Array<() => void> = []; +async function acquireSlot(): Promise { + if (running >= MAX_CONCURRENT) { + await new Promise((resolve) => waiters.push(resolve)); + } + running += 1; +} +function releaseSlot(): void { + running -= 1; + waiters.shift()?.(); +} + +interface UsageFooterProps { + agent: Agent; + providerName: string; + usageKinds?: string[]; +} + +export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProps) { + const { t } = useTranslation(); + const supported = (usageKinds?.length ?? 0) > 0; + // 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}`; + + const [status, setStatus] = useState("idle"); + const [data, setData] = useState([]); + /** undefined = no error; null = network error; string = Rust error text. */ + const [error, setError] = useState(undefined); + const [now, setNow] = useState(() => Date.now()); + // Generation counter: stale responses (unmounted / superseded query) are ignored. + const genRef = useRef(0); + + const runQuery = useCallback( + async (forceRefresh: boolean) => { + const gen = ++genRef.current; + setStatus("loading"); + setError(undefined); + const finish = (entry: CacheEntry) => { + cache.set(cacheKey, entry); + if (genRef.current !== gen) return; + setStatus(entry.status); + setData(entry.data); + setError(entry.error); + }; + try { + await acquireSlot(); + let result: UsageResult; + try { + result = await invoke("query_provider_usage", { + agent, + providerName, + forceRefresh, + }); + } finally { + releaseSlot(); + } + if (genRef.current !== gen) return; + if (result.success) { + finish({ + status: "success", + data: result.data ?? [], + error: null, + updatedAt: Date.now(), + }); + } else { + // Deterministic failure: keep last good data for ghost display. + finish({ + status: "error", + data: cache.get(cacheKey)?.data ?? [], + error: result.error ?? "", + updatedAt: Date.now(), + }); + } + } catch { + // Transient failure (network / timeout): invoke rejected. + finish({ + status: "error", + data: cache.get(cacheKey)?.data ?? [], + error: null, + updatedAt: Date.now(), + }); + } + }, + [agent, providerName, cacheKey] + ); + + // On mount / provider change: serve fresh cache, otherwise query once. + useEffect(() => { + if (!supported) return; + const cached = cache.get(cacheKey); + if (cached) { + setStatus(cached.status); + setData(cached.data); + setError(cached.error); + if (Date.now() - cached.updatedAt < STALE_TTL_MS) return; + } + void runQuery(false); + }, [supported, cacheKey, runQuery]); + + // Ignore late responses after unmount. + useEffect(() => { + return () => { + genRef.current += 1; + }; + }, []); + + // Tick the reset countdown once a minute while showing data. + useEffect(() => { + if (status !== "success") return; + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, [status]); + + if (!supported || status === "idle") return null; + + const percentOf = (d: UsageData): number | null => { + if (d.used != null && d.total != null && d.total > 0) + return (d.used / d.total) * 100; + if (d.remaining != null && d.total != null && d.total > 0) + return (1 - d.remaining / d.total) * 100; + return null; + }; + + const formatAmount = ( + value: number | null | undefined, + unit?: string | null + ): string => { + if (value == null) return "—"; + const symbol = + unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : ""; + const num = Number.isInteger(value) ? String(value) : value.toFixed(2); + return `${symbol}${num}`; + }; + + const formatReset = (resetsAt?: string | null): string | null => { + if (!resetsAt) return null; + const ms = new Date(resetsAt).getTime() - now; + if (Number.isNaN(ms)) return null; + if (ms <= 0) return t("usageResetDone"); + const mins = Math.floor(ms / 60_000); + if (mins < 60) return t("usageResetIn", { time: `${Math.max(mins, 1)}m` }); + const hours = Math.floor(mins / 60); + if (hours < 24) return t("usageResetIn", { time: `${hours}h` }); + return t("usageResetIn", { time: `${Math.floor(hours / 24)}d` }); + }; + + const refreshBtn = ( + + ); + + // Loading with no prior data: skeleton bar. + if (status === "loading" && data.length === 0) { + return ( +
+
+
+ ); + } + + if (status === "error" && data.length === 0) { + const text = + error == null + ? t("usageNetworkError") + : /401|api[ -]?key|invalid key/i.test(error) + ? t("usageInvalidKey") + : error + ? `${t("usageQueryFailed")} · ${error}` + : t("usageQueryFailed"); + return ( +
+ + {text} + + +
+ ); + } + + // success, or error/loading with last-good data (ghost). + const ghost = status !== "success"; + return ( +
+ {data.map((d, i) => { + const isPlan = !!d.planName; + const pct = isPlan ? percentOf(d) : null; + const color = + pct == null + ? "text-content-muted" + : pct < 70 + ? "text-green-600 dark:text-green-400" + : pct < 90 + ? "text-orange-600 dark:text-orange-400" + : "text-red-500 dark:text-red-400"; + const resetText = isPlan ? formatReset(d.resetsAt) : null; + + let main: string; + if (d.isValid === false) { + main = t("usageInvalidKey"); + } else if (isPlan) { + const label = data.length > 1 && d.planName ? `${d.planName} · ` : ""; + main = + pct != null + ? `${label}${Math.round(pct)}% ${t("usageUsed")}` + : d.remaining != null + ? `${label}${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}` + : label || "—"; + } else { + main = `${t("usageBalance")} ${formatAmount(d.remaining, d.unit)}`; + } + + return ( +
+ + + {main} + + {resetText && ( + {resetText} + )} + {i === data.length - 1 && refreshBtn} +
+ ); + })} + {data.length === 0 && ( +
+ — + {refreshBtn} +
+ )} +
+ ); +} diff --git a/src/config/providerPresets.ts b/src/config/providerPresets.ts new file mode 100644 index 0000000..58f65a7 --- /dev/null +++ b/src/config/providerPresets.ts @@ -0,0 +1,427 @@ +// Provider presets: one-click form filling for well-known providers. +// Preset data structure inspired by cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +import type { Model, Provider, ProviderType } from "../types"; +import { getModelRef, capabilitiesFromRef } from "../lib/models-dev"; +import { getDefaultMaxContextSize } from "../lib/model-defaults"; + +/** Usage query kinds supported by the Rust usage layer (v1 union). */ +export type UsageKind = + | "balance:deepseek" + | "balance:siliconflow" + | "balance:openrouter" + | "balance:stepfun" + | "balance:novita" + | "plan:kimi_coding" + | "plan:zhipu" + | "plan:minimax"; + +/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */ +export const SUPPORTED_USAGE_KINDS: ReadonlySet = new Set([ + "balance:deepseek", + "balance:siliconflow", + "balance:openrouter", + "balance:stepfun", + "balance:novita", + "plan:kimi_coding", + "plan:zhipu", + "plan:minimax", +]); + +export type PresetCategory = + | "official" + | "cn_official" + | "third_party" + | "aggregator" + | "custom"; + +export interface ProviderPreset { + /** Unique key, also used as the default Provider name, e.g. "deepseek". */ + id: string; + /** Fallback display name (English). */ + name: string; + /** i18n key; when present the UI prefers t(nameKey). */ + nameKey?: string; + /** Official website / console URL. */ + websiteUrl?: string; + apiKeyUrl?: string; + /** Category: sorting & badge. */ + category: PresetCategory; + /** Written directly to provider.provider_type. */ + providerType: ProviderType; + /** Preset base_url; `null` means no default (e.g. Anthropic). */ + baseUrl: string | null; + /** Icon key from src/icons/extracted (falls back to initials when absent). */ + icon?: string; + iconColor?: string; + /** + * Pre-filled model mappings. The first entry becomes the default model. + * `model` holds the real model id; the alias is NOT hand-written here — + * presetToProviderAndModels() forces `${providerName}/${modelId}` so that + * handleDuplicateProvider's `alias.slice(name.length)` keeps working. + */ + models: Array<{ + /** Real model id sent to the provider API. */ + model: string; + /** Optional display name override; defaults to models.dev derivation. */ + displayName?: string; + /** Optional context override; defaults to the priority chain below. */ + maxContextSize?: number; + /** Defaults to ["thinking"]. */ + capabilities?: string[]; + }>; + /** + * Billing/usage query kinds; omitted means "do not query". + * Persisted to SQLite settings by the Rust side on save, never to + * config.toml. + */ + usageKinds?: ReadonlyArray; +} + +export const providerPresets: ProviderPreset[] = [ + { + id: "anthropic", + name: "Anthropic", + nameKey: "presetNameAnthropic", + websiteUrl: "https://www.anthropic.com", + category: "official", + providerType: "anthropic", + // No default base_url; Kimi Code automatically targets api.anthropic.com. + baseUrl: null, + icon: "anthropic", + models: [ + { model: "claude-opus-4-5" }, + { model: "claude-sonnet-4-5" }, + { model: "claude-haiku-4-5" }, + ], + }, + { + id: "kimi-coding", + name: "Kimi For Coding", + nameKey: "presetNameKimiCoding", + websiteUrl: "https://www.kimi.com/code", + category: "official", + providerType: "kimi", + baseUrl: "https://api.kimi.com/coding/v1", + icon: "kimi", + models: [ + // kimi-for-coding is not in the models.dev snapshot; context comes + // from the regex fallback in model-defaults (262144). + { model: "kimi-for-coding", displayName: "Kimi For Coding" }, + { model: "kimi-k2.7-code" }, + ], + usageKinds: ["plan:kimi_coding"], + }, + { + id: "moonshot", + name: "Moonshot AI", + nameKey: "presetNameMoonshot", + websiteUrl: "https://platform.moonshot.ai", + category: "cn_official", + providerType: "kimi", + baseUrl: "https://api.moonshot.ai/v1", + icon: "kimi", + models: [ + { model: "kimi-k2.7-code" }, + { model: "kimi-k2-thinking-turbo" }, + { model: "kimi-k2.6" }, + ], + }, + { + id: "deepseek", + name: "DeepSeek", + nameKey: "presetNameDeepseek", + websiteUrl: "https://platform.deepseek.com", + category: "cn_official", + providerType: "openai", + baseUrl: "https://api.deepseek.com/v1", + icon: "deepseek", + models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }], + usageKinds: ["balance:deepseek"], + }, + { + id: "zhipu", + name: "Zhipu GLM", + nameKey: "presetNameZhipu", + websiteUrl: "https://open.bigmodel.cn", + category: "cn_official", + providerType: "openai", + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + icon: "zhipu", + models: [ + { model: "glm-4.7" }, + { model: "glm-4.6" }, + { model: "glm-4.5-air" }, + ], + usageKinds: ["plan:zhipu"], + }, + { + id: "zai", + name: "z.ai", + nameKey: "presetNameZai", + websiteUrl: "https://z.ai", + category: "third_party", + providerType: "openai", + baseUrl: "https://api.z.ai/api/paas/v4", + icon: "zhipu", + models: [{ model: "glm-4.7" }, { model: "glm-4.6" }], + usageKinds: ["plan:zhipu"], + }, + { + id: "bailian", + name: "Alibaba Bailian", + nameKey: "presetNameBailian", + websiteUrl: "https://bailian.console.aliyun.com", + category: "cn_official", + providerType: "openai", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + icon: "bailian", + models: [ + { model: "qwen3-max" }, + { model: "qwen3-coder-plus" }, + { model: "qwen3.5-plus" }, + ], + }, + { + id: "minimax", + name: "MiniMax", + nameKey: "presetNameMinimax", + websiteUrl: "https://platform.minimaxi.com", + category: "cn_official", + providerType: "openai", + baseUrl: "https://api.minimaxi.com/v1", + icon: "minimax", + models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }], + usageKinds: ["plan:minimax"], + }, + { + id: "stepfun", + name: "StepFun", + nameKey: "presetNameStepfun", + websiteUrl: "https://platform.stepfun.com", + category: "cn_official", + providerType: "openai", + baseUrl: "https://api.stepfun.com/v1", + icon: "stepfun", + models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }], + usageKinds: ["balance:stepfun"], + }, + { + id: "siliconflow", + name: "SiliconFlow", + nameKey: "presetNameSiliconflow", + websiteUrl: "https://siliconflow.cn", + category: "cn_official", + providerType: "openai", + baseUrl: "https://api.siliconflow.cn/v1", + icon: "siliconflow", + // The extracted icon has no metadata defaultColor; set it explicitly. + iconColor: "#6E29F6", + models: [ + { model: "Qwen/Qwen3-235B-A22B" }, + { model: "moonshotai/Kimi-K2.5" }, + // Not covered by the snapshot; fill context/displayName explicitly. + { + model: "deepseek-ai/DeepSeek-V3.2", + displayName: "DeepSeek V3.2", + maxContextSize: 163840, + }, + ], + usageKinds: ["balance:siliconflow"], + }, + { + id: "novita", + name: "Novita AI", + nameKey: "presetNameNovita", + websiteUrl: "https://novita.ai", + category: "third_party", + providerType: "openai", + // Note: /v3, not /v1. + baseUrl: "https://api.novita.ai/v3", + icon: "novita", + models: [ + // Not covered by the snapshot; fill context/displayName explicitly. + { + model: "deepseek/deepseek-v3.2", + displayName: "DeepSeek V3.2", + maxContextSize: 163840, + }, + { model: "moonshotai/kimi-k2-thinking" }, + { model: "zai-org/glm-4.6" }, + ], + usageKinds: ["balance:novita"], + }, + { + id: "openrouter", + name: "OpenRouter", + nameKey: "presetNameOpenrouter", + websiteUrl: "https://openrouter.ai", + category: "aggregator", + providerType: "openai", + baseUrl: "https://openrouter.ai/api/v1", + icon: "openrouter", + models: [ + // OpenRouter uses dotted versions which the snapshot does not index; + // fill context/displayName explicitly for this one. + { + model: "anthropic/claude-opus-4.5", + displayName: "Claude Opus 4.5", + maxContextSize: 200000, + }, + { model: "openai/gpt-5.2" }, + { model: "google/gemini-3-pro-preview" }, + ], + usageKinds: ["balance:openrouter"], + }, + { + id: "openai", + name: "OpenAI", + nameKey: "presetNameOpenai", + websiteUrl: "https://platform.openai.com", + category: "official", + providerType: "openai", + baseUrl: "https://api.openai.com/v1", + icon: "openai", + models: [ + { model: "gpt-5.2" }, + { model: "gpt-5.1-codex-max" }, + { model: "gpt-5-mini" }, + ], + }, + { + id: "google-genai", + name: "Google AI Studio", + nameKey: "presetNameGoogleGenai", + websiteUrl: "https://aistudio.google.com", + category: "official", + providerType: "google-genai", + baseUrl: "https://generativelanguage.googleapis.com", + icon: "google", + models: [ + { model: "gemini-3-pro-preview" }, + { model: "gemini-3-flash-preview" }, + { model: "gemini-2.5-flash" }, + ], + }, + { + id: "volcengine", + name: "Volcengine Ark", + nameKey: "presetNameVolcengine", + websiteUrl: "https://www.volcengine.com/product/ark", + category: "cn_official", + providerType: "openai", + baseUrl: "https://ark.cn-beijing.volces.com/api/v3", + icon: "huoshan", + // v1: inference only — plan queries need separate AK/SK (P2). + models: [ + { + model: "doubao-seed-1-6-250615", + displayName: "Doubao Seed 1.6", + }, + { model: "kimi-k2-250905", displayName: "Kimi K2" }, + ], + }, +]; + +export interface PresetConversionResult { + provider: Provider; + models: Model[]; + /** Alias to write into config.default_model ("" when no models). */ + defaultModel: string; + usageKinds: ReadonlyArray | undefined; +} + +/** + * Convert a preset into concrete Provider + Model entries. + * + * - Provider name collisions get a "-2" / "-3" suffix. + * - Model aliases are forced to `${name}/${modelId}` and deduped one by one + * against existing aliases (also with "-2" / "-3" suffixes). + * - max_context_size priority: preset override → models.dev snapshot → + * regex rules → DEFAULT_MAX_CONTEXT_SIZE (the last two are both handled + * inside getDefaultMaxContextSize). + */ +export function presetToProviderAndModels( + preset: ProviderPreset, + options: { + existingProviderNames: Set; + existingModelAliases: Set; + }, +): PresetConversionResult { + let name = preset.id; + let n = 2; + while (options.existingProviderNames.has(name)) { + name = `${preset.id}-${n}`; + n++; + } + + const models: Model[] = preset.models.map((m) => { + let alias = `${name}/${m.model}`; + let k = 2; + while (options.existingModelAliases.has(alias)) { + alias = `${name}/${m.model}-${k}`; + k++; + } + + const ref = getModelRef(m.model); + const refCaps = ref ? capabilitiesFromRef(ref) : []; + const maxContextSize = + m.maxContextSize ?? ref?.context ?? getDefaultMaxContextSize(m.model); + + return { + alias, + provider: name, + model: m.model, + max_context_size: maxContextSize, + display_name: m.displayName ?? ref?.name ?? alias, + capabilities: m.capabilities ?? (refCaps.length > 0 ? refCaps : ["thinking"]), + supports_1m: maxContextSize >= 1_000_000, + raw_other: {}, + }; + }); + + const provider: Provider = { + name, + provider_type: preset.providerType, + base_url: preset.baseUrl, + api_key: null, + env: {}, + note: null, + official_url: preset.websiteUrl ?? null, + managed: false, + enabled: true, + icon: preset.icon ?? null, + icon_color: preset.iconColor ?? null, + usageKinds: preset.usageKinds ? [...preset.usageKinds] : undefined, + raw_other: {}, + }; + + return { + provider, + models, + defaultModel: models[0]?.alias ?? "", + usageKinds: preset.usageKinds, + }; +} + +// Dev-only cross-end drift check: assert that every usage kind referenced by +// any preset is one the Rust `UsageKind` enum knows how to handle. The +// runtime filter in `query_provider_usage` silently drops unknown kinds, +// so without this guard a typo or schema drift would only surface as a +// missing usage footer with no error message. Gated by Vite's DEV flag so +// it costs nothing in production bundles. +if (import.meta.env.DEV) { + for (const preset of providerPresets) { + if (!preset.usageKinds) continue; + for (const kind of preset.usageKinds) { + if (!SUPPORTED_USAGE_KINDS.has(kind)) { + // eslint-disable-next-line no-console + console.error( + `[providerPresets] preset "${preset.id}" references unknown usage kind "${kind}". ` + + `Add it to src-tauri/src/services/mod.rs UsageKind + SUPPORTED_USAGE_KINDS, or remove it from the preset.`, + ); + } + } + } +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1354106..d527329 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -11,6 +11,12 @@ export const enTranslations: Record = { reloadConfig: "Reload Config", openConfigDir: "Open Config Dir", unsavedConfirm: "You have unsaved changes. Are you sure you want to reload?", + validationNoApiKey: "Missing API key", + validationNoBaseUrl: "Missing base URL", + validationNoModels: "No models configured", + validationBlankModels: "Model entries with empty model id: {aliases}", + saveValidationConfirm: + "The following providers are not fully configured. Save anyway?\n\n{details}\n\n(Click OK to save to config.toml as-is; click Cancel to keep editing.)", providerNotFound: "Provider not found", backToList: "Back to list", syncToOmp: "Sync to OMP", @@ -316,4 +322,45 @@ export const enTranslations: Record = { // Sessions — preview previewCollapseChars: "Collapse · {n} chars", previewExpandChars: "Expand · {n} chars", + + // Provider presets + presetPickerTitle: "Choose a provider preset", + presetSearchPlaceholder: "Search presets...", + presetSortOriginal: "Default", + presetSortAlpha: "A-Z", + presetNoResults: "No matching presets", + presetCustomConfig: "+ Custom configuration", + presetFooterHint: "Presets only pre-fill the form; every field stays editable afterwards.", + presetCategoryOfficial: "Official", + presetCategoryCnOfficial: "China", + presetCategoryThirdParty: "Third-party", + presetCategoryAggregator: "Aggregator", + presetNameAnthropic: "Anthropic", + presetNameKimiCoding: "Kimi For Coding", + presetNameMoonshot: "Moonshot Platform", + presetNameDeepseek: "DeepSeek", + presetNameZhipu: "Zhipu GLM", + presetNameZai: "z.ai (Zhipu Global)", + presetNameBailian: "Alibaba Bailian", + presetNameMinimax: "MiniMax", + presetNameStepfun: "StepFun", + presetNameSiliconflow: "SiliconFlow", + presetNameNovita: "Novita AI", + presetNameOpenrouter: "OpenRouter", + presetNameOpenai: "OpenAI", + presetNameGoogleGenai: "Google AI Studio", + presetNameVolcengine: "Volcengine Ark", + + // Provider usage + usageBalance: "Balance", + usageUsed: "used", + usageRemaining: "left", + usageRefresh: "Refresh", + usageRetry: "Retry", + usageQueryFailed: "Query failed", + usageNetworkError: "Network error", + usageInvalidKey: "Invalid API key", + usageResetIn: "resets in {time}", + usageResetDone: "reset", + usageLoading: "Querying…", }; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 23b3830..e400227 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -9,6 +9,12 @@ export const zhTranslations = { reloadConfig: "读取配置", openConfigDir: "打开配置目录", unsavedConfirm: "有未保存修改,确定要重新读取配置吗?", + validationNoApiKey: "缺少 API Key", + validationNoBaseUrl: "缺少 Base URL", + validationNoModels: "未配置模型", + validationBlankModels: "存在未填写 model id 的模型:{aliases}", + saveValidationConfirm: + "以下供应商尚未完成配置,仍要保存吗?\n\n{details}\n\n(点击「确定」仍会保存到 config.toml;点击「取消」可继续编辑)", providerNotFound: "供应商不存在", backToList: "返回列表", syncToOmp: "同步到 OMP", @@ -311,6 +317,47 @@ export const zhTranslations = { // Sessions — preview previewCollapseChars: "收起 · {n} 字符", previewExpandChars: "展开全文 · {n} 字符", + + // Provider presets + presetPickerTitle: "选择预设供应商", + presetSearchPlaceholder: "搜索预设供应商...", + presetSortOriginal: "原始", + presetSortAlpha: "A-Z", + presetNoResults: "没有匹配的预设", + presetCustomConfig: "+ 自定义配置", + presetFooterHint: "预设只是一键填表,后续可继续修改所有字段。", + presetCategoryOfficial: "官方", + presetCategoryCnOfficial: "国产", + presetCategoryThirdParty: "第三方", + presetCategoryAggregator: "聚合", + presetNameAnthropic: "Anthropic 官方", + presetNameKimiCoding: "Kimi For Coding", + presetNameMoonshot: "Moonshot 平台", + presetNameDeepseek: "DeepSeek", + presetNameZhipu: "智谱 GLM", + presetNameZai: "z.ai(智谱海外)", + presetNameBailian: "阿里百炼", + presetNameMinimax: "MiniMax", + presetNameStepfun: "阶跃星辰", + presetNameSiliconflow: "硅基流动", + presetNameNovita: "Novita AI", + presetNameOpenrouter: "OpenRouter", + presetNameOpenai: "OpenAI 官方", + presetNameGoogleGenai: "Google AI Studio", + presetNameVolcengine: "火山方舟", + + // Provider usage + usageBalance: "余额", + usageUsed: "已使用", + usageRemaining: "剩余", + usageRefresh: "刷新", + usageRetry: "重试", + usageQueryFailed: "查询失败", + usageNetworkError: "网络异常", + usageInvalidKey: "API Key 无效", + usageResetIn: "{time} 后重置", + usageResetDone: "已重置", + usageLoading: "查询中…", } as const; export type TranslationKey = keyof typeof zhTranslations; diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..8a2340c --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,86 @@ +import type { Config, Model, ProviderType } from "../types"; + +/** + * Provider types that must have a base_url set in order to function. + * Anthropic and Vertexai intentionally have no default — they read the + * agent's CLI environment / SDK defaults, so leaving base_url empty is + * a valid (and the only) state. + */ +const TYPES_REQUIRING_BASE_URL: ReadonlySet = new Set([ + "openai", + "openai_responses", + "kimi", + "google-genai", +]); + +export interface ProviderIssue { + name: string; + reasons: string[]; +} + +/** + * Inspect a config and report any enabled providers that are not ready to + * use. Used by the save flow to warn the user before persisting an + * incomplete state to config.toml / SQLite. + * + * "Incomplete" means any of: + * - missing API key (and not a managed OAuth provider) + * - missing base_url for provider types that need one + * - zero configured models + * + * Disabled providers are skipped — the user explicitly turned them off. + */ +export function validateProviders( + config: Config, + modelIndex: Record, + // The i18n `t` from useTranslation() is strictly typed to its known key + // union; a generic (key: string) signature is the right shape for a + // library-style helper that doesn't want to import the project's i18n + // module directly. + t: (key: string, vars?: Record) => string, + // When set, only inspect this single provider. Used when activating a + // specific provider: sibling providers merely listed in the config + // must not block the switch. + target?: string, +): ProviderIssue[] { + const issues: ProviderIssue[] = []; + for (const provider of Object.values(config.providers)) { + if (target && provider.name !== target) continue; + if (!provider.enabled) continue; + const reasons: string[] = []; + + const apiKey = provider.api_key?.trim(); + if (!apiKey && !provider.managed) { + reasons.push(t("validationNoApiKey")); + } + + const baseUrl = provider.base_url?.trim(); + if (!baseUrl && TYPES_REQUIRING_BASE_URL.has(provider.provider_type)) { + reasons.push(t("validationNoBaseUrl")); + } + + const providerModels = Object.values(modelIndex).filter( + (m) => m.provider === provider.name, + ); + if (providerModels.length === 0) { + reasons.push(t("validationNoModels")); + } else { + // A model entry exists but its `model` field may be blank (e.g. the + // user hit "+ 添加模型" in the edit page and saved before filling + // in the upstream model id). Those still break the provider. + const blankAliases = providerModels + .filter((m) => !m.model || !m.model.trim()) + .map((m) => m.alias); + if (blankAliases.length > 0) { + reasons.push( + t("validationBlankModels", { aliases: blankAliases.join(", ") }), + ); + } + } + + if (reasons.length > 0) { + issues.push({ name: provider.name, reasons }); + } + } + return issues; +} diff --git a/src/types/index.ts b/src/types/index.ts index ac7cd1b..9edf999 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,6 +28,12 @@ export interface Provider { icon?: string | null; /** Custom icon color; falls back to icon metadata defaultColor. */ icon_color?: string | null; + /** + * Usage query kinds (e.g. "balance:deepseek", "plan:kimi_coding"). + * Merged in by the Rust side on load (SQLite settings / base_url detect) + * and persisted back to SQLite on save; never written to config.toml. + */ + usageKinds?: string[]; /** Extra agent-specific provider fields preserved across edits. */ raw_other?: unknown; }