feat: 新增部分套餐账单查询(v0.6.4)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s

- 账单/用量查询:balance:kimi(Moonshot 开放平台余额)、NewAPI 中转站余额(accessToken+userId)、Kimi/智谱/MiniMax 套餐档位(5小时/7天)
- managed:kimi-code 走 Kimi Code OAuth 登录凭据查询订阅用量,access token 过期自动 refresh 并写回凭据(单 flight 锁 + 写回前重读防 CLI 竞态)
- 全屏「配置用量查询」页面:启用开关 / 自动检测 vs NewAPI 模板 / 超时与自动查询间隔 / 测试查询
- 供应商卡片紧凑用量行(切换使用左侧)+ 多档明细行;managed 供应商列表置顶(仅展示层)
- 用量显示中英文适配(five_hour→5小时、weekly_limit→7天,Rust 错误串本地化映射)
- 修复:usage_kinds serde rename 导致账单入口不显示;测试查询因 save 触发 loading 翻页卸载面板导致结果丢失(silent save)
This commit is contained in:
KimiSwitch Dev committed 2026-08-01 09:44:36 +08:00
1 parent 12dcb17771
commit ace9842556
26 files changed
+1822 -60

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
# 工作交接:账单查询功能(2026-08-01)
> 新会话续作入口:先读本文件 + `docs/USAGE-QUERY-ADAPTATION.md`(调研文档),然后按「二、下一步」继续。
## 一、当前进度总览
**已提交(1 个 commit,分支 `Dev_20260801`)**:
- `12dcb17` — docs: 账单查询适配调研 + OpenCode Zen 预设补推荐链接
**工作区未提交改动**——用户硬约束:**实现完成不提交,等用户逐批测试确认后才提交**。
`git status` 实测清单(注意:`src-tauri/Cargo.toml` 现含**真实改动**——批次 4 新增 tokio 依赖行,另混有用户自己的行尾差异,提交时只保留 tokio 行):
```
M docs/USAGE-QUERY-ADAPTATION.md M src/App.tsx
M src-tauri/Cargo.toml(含 tokio 真实改动) M src/components/ProviderList.tsx
M src-tauri/src/commands.rs M src/components/UsageFooter.tsx
M src-tauri/src/db.rs M src/config/providerPresets.ts
M src-tauri/src/kimi_code_io.rs M src/hooks/useConfig.ts
M src-tauri/src/lib.rs M src/i18n/en.ts
M src-tauri/src/models.rs M src/i18n/zh.ts
M src-tauri/src/pi_io.rs M src/types/index.ts
M src-tauri/src/services/balance.rs / coding_plan.rs / mod.rs
?? docs/HANDOVER-usage-billing.md(本文件)
?? src-tauri/src/oauth.rs
?? src/components/UsageConfigModal.tsx
?? src/lib/usage-display.ts
```
### 批次 1:P0 `balance:kimi` + usageKinds 字段修复
- `src-tauri/src/services/balance.rs` — `query_kimi`/`parse_kimi`(`api.moonshot.cn|ai/v1/users/me/balance`,CNY/USD 消歧)+ 5 单测
- `src-tauri/src/services/mod.rs` — `UsageKind::BalanceKimi`("balance:kimi")、detect_provider 加 `api.moonshot.cn/.ai`
- `src/config/providerPresets.ts` — union/SUPPORTED 加 `balance:kimi`,moonshot 预设挂 usageKinds
- `src-tauri/src/models.rs` — **`usage_kinds` 加 `#[serde(rename = "usageKinds")]`**(修复历史 bug:Rust 序列化 snake_case、前端读 camelCase 导致所有账单入口不显示)
### 批次 2:A/B/C 三阶段(OAuth + 配置面板 + NewAPI)
Rust:
- `src-tauri/src/oauth.rs`(新增)— 读 `~/.kimi-code/credentials/kimi-code.json` access_token,30s 过期缓冲,**不做 refresh**(避免与 CLI 的 refresh_token 轮换竞态)
- `src-tauri/src/models.rs` — `UsageConfig` struct(camelCase)+ `Provider.usage_config`(rename="usageConfig")
- `src-tauri/src/services/balance.rs` — `query_newapi`/`parse_newapi`/`fetch_newapi_status`(NewAPI 中转站:`{base}/api/user/self` + Bearer accessToken + `New-Api-User` userId;`/api/status` 拉 quota_per_unit/货币符号,进程内缓存 5min;quota=0 视为无限额度)
- `src-tauri/src/services/mod.rs` — `BalanceNewapi`("balance:newapi",ALL=10),`query_kind` 签名加 `usage_config: Option<&UsageConfig>`
- `src-tauri/src/commands.rs` — `usage_config_key` + merge_usage_kinds 同时 merge usage_config(SQLite `usage_config:<name>`);save 时持久化;`query_provider_usage`:usageConfig.enabled=false 拒绝、templateType=="newapi" 走 NewAPI 分支、managed 供应商读 OAuth token(api_key 优先,OAuth 兜底)
- `src-tauri/src/lib.rs` — 注册 `pub mod oauth;`
- `src-tauri/src/db.rs` / `kimi_code_io.rs` / `pi_io.rs` — Provider 构造点补 `usage_config: None`(8 处)
前端:
- `src/types/index.ts` — `UsageConfig` 接口 + `Provider.usageConfig`
- `src/components/UsageConfigModal.tsx`(新增)— 配置面板(批次 3 已改全屏)
- `src/components/UsageFooter.tsx` — 拆 compact(卡片右侧:主摘要 + `x 分钟前` + 刷新,含自动查询 interval)/ detail(底部:多档明细 + 错误/重试);模块级缓存 + 3 并发信号量
- `src/components/ProviderList.tsx` — BarChart3 配置入口按钮;紧凑用量行嵌入按钮组、位于「切换使用」左侧(cc-switch 布局)
- `src/App.tsx` — `usageConfigProvider` 状态 + `handleSaveUsageConfig` + modal 挂载
- `src/i18n/zh.ts` / `en.ts` — 新增约 34 个 key
### 批次 3:全屏配置页面 + 超时配置
- `src/components/UsageConfigModal.tsx` — **居中弹窗 → 全屏页面**(参考 cc-switch FullScreenPanel):`fixed inset-0 flex flex-col bg-app`,header 64px(ArrowLeft 返回 + 标题),内容 `max-w-3xl` 居中滚动,footer 固定底部;ESC 关闭但输入框聚焦时不关(isTextEditableTarget)
- 新增页面元素:**支持的变量区**(`{{baseUrl}}`/`{{apiKey}}` 脱敏 + 眼睛切换明文)、**超时时间输入**(与自动查询间隔并排两列)
- 超时接线:`UsageConfig.timeout_seconds`(models.rs camelCase)+ `types/index.ts timeoutSeconds`;`balance.rs::get_json` 及全部 `query_*`、`fetch_newapi_status`、`coding_plan` 3 函数加 `timeout: Duration` 参数;`query_kind` 从 usage_config 计算(0/None 回退 8s),`commands.rs` 无改动
- i18n:`usageTimeout`/`usageTimeoutHint`/`usageSupportedVars`/`usageBack`(zh+en)
- **测试查询体验修复**(2026-08-01 用户反馈「测试查询没有生效」):
- 测试结果框改为 **sticky bottom-0**(在 body 滚动容器内),始终浮在 footer 上方,点了立即可见
- 「自动检测 + 无识别类型」短路:handleTest 前置检查 detectedKinds.length===0,直接展示 `usageUnsupportedProvider` 中文提示,不发无效请求
- 「未识别查询类型」区改为带「切换到 NewAPI 模板」快捷按钮的 amber 提示框(替换原灰色单行)
- 新增 i18n:`usageNoKindsHint`/`usageSwitchToNewapi`/`usageUnsupportedProvider`
**验证(三批累计)**:`cargo test --lib` 46 全过、`cargo check` 干净、`tsc --noEmit` 零错误。
**最新打包产物**(批次 3 + 体验修复 已交付用户测试):
- `D:\AIGC\KimiSwitch\src-tauri\target\release\bundle\nsis\Kimi Switch_0.6.3_x64-setup.exe`
- `D:\AIGC\KimiSwitch\src-tauri\target\release\bundle\msi\Kimi Switch_0.6.3_x64_en-US.msi`
### 批次 4(2026-08-01):OAuth refresh + 测试查询短路修复
- **背景**:用户反馈「Kimi login 又失效」——根因是 access token 15 分钟过期且 refresh 未实现(§三.3 已拍板实现);refresh 端点与 client_id 已从 kimi.exe 二进制实测确认(`POST https://auth.kimi.com/api/oauth/token`,client_id `17e5f671-d194-4dfb-9706-5516cb48c098`,form-encoded grant_type=refresh_token)
- `src-tauri/src/oauth.rs` — `get_valid_access_token()`:过期 → `tokio::sync::Mutex` 单 flight 锁内重读文件(CLI/其他等待者可能已刷新则直接采用)→ `refresh_credentials()` 调 token 端点 → 写回前再重读(CLI 若已轮换则采纳其 token,避免覆盖 CLI 的新 refresh_token)→ `merge_token_response()` 纯函数合并写回(保留未知字段)+ 3 个新单测
- `src-tauri/src/commands.rs` — managed 分支改为 `get_valid_access_token().await`,错误信息直接用 refresh 的具体失败原因(含 invalid_grant 时提示重新 `kimi login`)
- `src-tauri/Cargo.toml` — 新增 `tokio = { version = "1", features = ["sync"] }`(项目本无 tokio 直接依赖)
- `src/components/UsageConfigModal.tsx` — 「无识别类型短路」加 `!isManaged` 守卫(managed 走 OAuth 与 detectedKinds 无关,防误伤)
- **验证**:cargo test 48 过(+2 merge 用例)、tsc 零错误
### 批次 5(2026-08-01):用量显示本地化 + 按钮顺序调整
- `src/lib/usage-display.ts`(新增)— `planLabel()`(five_hour→"5小时"/"5-hour"、weekly_limit→"7天"/"Weekly",其余专名透传)+ `localizeUsageError()`(Rust 英文错误串按前缀/正则映射到 i18n,未知错误透传保留诊断信息)
- `src/components/UsageFooter.tsx` — compact/detail 的 tier 标签与错误显示接入两个 helper
- `src/components/UsageConfigModal.tsx` — 测试结果的 planName/错误同样本地化;catch(瞬时失败)统一显示 usageNetworkError
- `src/components/ProviderList.tsx` — BarChart3(配置用量查询)按钮从图标组首位移到 Activity(连通测试)与 Trash(删除)之间
- i18n 新增:`usageTier5h`/`usageTierWeekly`/`usageErrNoKey`/`usageErrDisabled`/`usageErrLoginExpired`/`usageErrNoOauth`/`usageErrNewapiCreds`(zh+en)
- **验证**:tsc 零错误(纯前端改动,Rust 未动)
### 批次 6(2026-08-01):测试查询「无结果」根因修复
- **根因**:`App.tsx` 顶层 `if (loading || !config) return <loading 页>`——`handleTest` 先 `onSave` → `void save()` → `setLoading(true)` → App 整树换成 loading 页 → **modal 被卸载**;查询结果回来后 setState 打到已卸载实例(无效),save 结束 modal 全新重挂载(testResult=null)。自配置面板引入即存在,任何「先保存再查询」的路径都中招。
- `src/hooks/useConfig.ts` — `refresh`/`save` 加 `{ silent?: boolean }` 选项:silent 时不翻 loading 页(不 setLoading)
- `src/App.tsx` — `handleSaveUsageConfig` 改为 `return save({ silent: true })`;error 分支重试按钮 `onClick={refresh}` 改箭头包装(MouseEvent 会当 opts 传入)
- `src/components/UsageConfigModal.tsx` — `onSave` 类型改 `Promise<void> | void`;`handleTest` **await onSave 完成后再 invoke 查询**(保证后端读到同一份配置)
- `src/components/ProviderList.tsx` — **managed(OAuth 登录托管)供应商置顶**:组件内 `[...providers].sort()` 稳定排序,仅展示层,不改 config.toml/SQLite 存储顺序
- **验证**:tsc 零错误
## 二、下一步
1. **等用户测批次 3 的包**(重点:卡片图表图标 → 全屏配置页 → 变量区/超时输入 → 测试查询 → 保存后紧凑行刷新)。用户反馈优先处理。
2. **用户确认后才 `git commit`**(建议拆 3 个 commit 对应批次 1/2/3,提交信息中文 + scope 前缀;排除 `src-tauri/Cargo.toml` 行尾改动)。
3. 后续候选(均待用户拍板,见 §三):OAuth refresh、NewAPI 面板自动探测、自定义 JS 提取器模板(P3)。
## 三、悬而未决(用户已知情,待决定)
1. **OpenCode Go 账单查询**:已实测全部端点 404(`/zen/go/v1/usages`、`/api/usage`、`/api/billing` 等 12+ 路径),cc-switch 源码也不支持 → **结论:无公开 API,不可查**。文档已记录。
2. **Kimi login 配 API key**:可以,代码已支持(api_key 优先于 OAuth)。但 managed 供应商编辑页隐藏 key 输入框(`ProviderEdit.tsx:301` `{!provider.managed && ...}`)。选项:① 用户新建 kimi-coding 预设供应商填 key;② 改 UI 让 managed 显示可选 key 输入。
3. **OAuth refresh**:**已实现**(批次 4)。过期自动用 refresh_token 换新并写回凭据文件;单 flight 锁 + 写回前重读防 CLI 竞态。已知边界:若 access token 未过期但被服务端吊销(罕见),401 不会触发重读重试——暂不处理。
4. **NewAPI 面板自动探测**:打开配置面板时探测 `{base}/api/status` 含 `quota_per_unit` → 自动预选 NewAPI 模板。已提议,未确认。
## 四、关键环境信息
- 项目:`D:\AIGC\KimiSwitch`(Tauri 2 + React 18 + TS + Tailwind + Rust/rusqlite/reqwest),分支 `Dev_20260801`
- 参考项目:`D:\AIGC\cc-switch`(MIT,账单查询功能源头;全屏面板参考其 `src/components/common/FullScreenPanel.tsx`)
- 构建:`npm run tauri-build`(前后端一起,产出 msi+nsis);`cargo test --lib`(src-tauri 下);`npx tsc --noEmit`
- 提交信息风格:中文 + scope 前缀(`docs:`/`build:`/`fix:`/`feat:`)
- 用户 NewAPI 站点:`https://ai.codingplan.site`(quota_per_unit=500000,符号 ¤);Access Token 需网页后台生成(sk- 推理 key 查不了 `/api/user/self`,实测 401)
+49 -2
View File
@@ -212,8 +212,48 @@ KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCo
| `bailian`(阿里云百炼) | pay_as_you_go | `usageKinds` 为空 | 百炼 2026-07 上线 Coding Plan(专属 key 格式 `sk-sp-`,按**调用次数**扣额度,与 token 无关);官方文档仅提供"控制台 Coding Plan 页面查看",**未发现公开查询 API**。预留为待跟踪项(套餐上线早期,API 可能随后开放) |
| `moonshot`(Kimi 开放平台) | pay_as_you_go | `usageKinds` 为空 | → 即 P0 的 `balance:kimi`,见 §4.3 |
### 4.7 NewAPI / OneAPI 类中转站账单查询(本次重点调研)
**背景**:用户使用自建 NewAPI 中转站(实测 `https://ai.codingplan.site`,`sk-` API Key 验证有效)。结论先行:
1. **`sk-` API Key 查不了余额**(实测 401)。NewAPI 站点的**管理接口只认「登录 Access Token」**,不认推理令牌。cc-switch 也是这个结论——它的 NEW_API 模板填的不是 `apiKey`,而是 `accessToken` + `userId` 两个独立字段。
2. **参考实现(cc-switch,完整链路)**:
- 模板代码(`UsageScriptModal.tsx:90`):`GET {{baseUrl}}/api/user/self`,头 `Authorization: Bearer {{accessToken}}` + **`New-Api-User: {{userId}}`**;extractor 取 `data.quota`、`data.used_quota`,余额 = `quota ÷ 500000`,已用 = `used_quota ÷ 500000`(单位按站点 `quota_per_unit`,实测本站为 500000;`custom_currency_symbol` 决定是 ¥ 还是 $)。
- 凭据模型(`types.ts UsageScript`):`accessToken?` / `userId?`,与 `apiKey`、`baseUrl` 并列,存供应商 `meta.usage_script`。
- 后端占位符替换(`usage_script.rs:413-416`):`{{accessToken}}` / `{{userId}}` 仅在脚本含对应占位符时替换;查询凭证由 `resolve_script_credentials` 决定(显式值优先,回退供应商配置)。
- 安全校验(`usage_script.rs`):**非 custom 模板强制 HTTPS + 与 base_url 同源(host+port)**;custom 模板放开(可任意 HTTPS 域名/HTTP)。
- 前端表单(`UsageScriptModal.tsx:1154-1230`):NEW_API 模板下显示 Base URL / Access Token(密码框带显隐)/ User ID 三个输入;模板切换联动清理多余字段。
3. **本站在 cc-switch 下的配置方式**:新建 Claude/Codex 供应商 → 开启用量查询 → 选 NEW_API 模板 → 填 `https://ai.codingplan.site`(Base URL)+ 网页后台 Access Token + 数字用户 ID。实测确认该站 `/api/status` 返回 `quota_per_unit: 500000`、`display_in_currency: true`、`custom_currency_symbol: ¤`(金额符号需按站确认)。
### 4.8 NewAPI 适配 KimiSwitch 的落地方案
KimiSwitch 现有 `usageKinds` 枚举是**每家中转站一个 Rust 函数**的路子,不适合任意 NewAPI 站。对照 cc-switch,推荐**声明式配置(不引入 JS 引擎)**:
- **存储**:`Provider` 增加可选 `usageConfig?: { accessToken?: string; userId?: string }`(不写进 TOML,与 `usageKinds` 一样存 SQLite settings `usage_config:<provider_name>`,避免污染 Kimi Code 原生配置)。
- **查询**:新增一个 `UsageKind::BalanceNewApi`(`"balance:newapi"`),Rust 侧 `query_kind` 分支:
- 端点:`{base_url}/api/user/self`(同源,复用 provider 的 base_url);
- 头:`Authorization: Bearer {accessToken}` + `New-Api-User: {userId}`;
- 解析:`data.quota / data.used_quota` ÷ `quota_per_unit`(从 `/api/status` 拉取,避免硬编码 500000;失败时回退 500000);
- 金额符号:`custom_currency_symbol`(未取到默认 ¥)。
- **错误通道**:遵循现有约定(`bytes()` 再解析、瞬时/确定性分离)。
- **前端**:`UsageFooter` 无需改;`ProviderEdit` 在启用 `usageKinds` 含 `balance:newapi` 时显示 Access Token / User ID 输入(参考 cc-switch 表单),或做成独立小弹窗。
- **风险**:`sk-` 用户拿不到 Access Token 时无解(需登录网页后台);同源校验天然防误查其他站。
## 5. 新增能力实现清单(改动点)
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
> 待办:用户确认后 `cargo test` + `tsc` 验证,验证通过再提交。
> 遗留风险:国际站 `api.moonshot.ai/v1/users/me/balance` 端点与币种(按 USD 处理)未实测,见 §4.3。
> 实施状态更新(2026-07-31 晚):**A + B + C 已全部实现(打包验证中,未提交)**。
> - **A(OAuth)**:新增 `src-tauri/src/oauth.rs`(读 `~/.kimi-code/credentials/kimi-code.json` access_token,过期 30s 缓冲,不做 refresh),`query_provider_usage` 的 managed 分支从"报 API key 错误"改为读 OAuth 凭据走 `plan:kimi_coding` 查询;`managed:kimi-code` 卡片不再显示"API Key 无效"。
> - **B(NewAPI)**:`services/balance.rs` 加 `query_newapi`(`{base_url}/api/user/self` + Bearer accessToken + `New-Api-User` userId,`/api/status` 运行态拉 `quota_per_unit` 与 `custom_currency_symbol`,进程内缓存);`UsageKind` 加 `BalanceNewApi`(`"balance:newapi"`,ALL 变 10);`query_kind` 签名加 `usage_config` 参数。
> - **C(配置面板 + 布局)**:新增 `UsageConfigModal.tsx`(启用开关 / 自动检测 vs NewAPI 模板 / 凭据输入 / 自动查询间隔 / 测试按钮,参考 cc-switch UsageScriptModal 但裁剪);`UsageFooter` 拆分 compact(卡片右上:主摘要 + `x 前` + 刷新)与 detail(底部:多档明细 + 错误/重试);`ProviderList` 加 BarChart3 配置入口按钮;`usageConfig` 存 SQLite settings(`usage_config:<provider_name>`),不进 config.toml。
> - **验证**:`cargo test` 46 通过(新增 oauth 凭据解析/过期、newapi 解析 5 个用例、UsageKind roundtrip 10 项)、`tsc --noEmit` 零错误;打包供用户测试。
> - **改动文件**:Rust 6 个(oauth.rs 新增、models.rs、commands.rs、services/mod.rs、services/balance.rs、lib.rs)+ 前端 7 个(types/index.ts、UsageConfigModal.tsx 新增、UsageFooter.tsx、ProviderList.tsx、App.tsx、i18n/zh.ts、i18n/en.ts)。
以 **P0:Kimi 开放平台余额(`balance:kimi`)** 为例,完整改动点如下(严格套用现有四步模式,前端展示零改动):
1. **Rust** `src-tauri/src/services/balance.rs`
@@ -238,8 +278,10 @@ KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCo
| **P0** | Kimi 开放平台余额 `balance:kimi` | balance.rs + mod.rs + providerPresets.ts | 小(~1 天) | 低;EN 端点待实测 |
| P1 | ZenMux 套餐(照搬 cc-switch) | coding_plan.rs + mod.rs + 预设 | 小 | 低 |
| P1 | 火山方舟 Coding Plan(AK/SK 签名 V4) | coding_plan.rs + 凭据存储 + 表单 | 中 | 签名细节易错,需单测 |
| P2 | 智谱团队版 / 中转站声明式配置(new-api 类) | 架构决策后实施 | 中-大 | 需先定方案 |
| P3 | JS 脚本引擎 / 官方 OAuth 订阅 / 托盘用量 | — | 大 | 与目标 CLI 不匹配,建议不做 |
| P2 | 智谱团队版 | coding_plan.rs + 凭据存储 + 表单 | 中 | 需扩展 Provider 凭据 |
| **P1** | **NewAPI 中转站余额 `balance:newapi`**(§4.8 声明式方案:`usageConfig{accessToken,userId}` + `/api/user/self`) | balance.rs + mod.rs + db 存储 + ProviderEdit 表单 | 中 | 需用户提供 Access Token;站点 quota_per_unit/符号需运行态拉取 |
| P3 | JS 脚本引擎(custom/general) | usage_script.rs 移植(rquickjs) | 大 | 覆盖面广但工程量大;被 `balance:newapi` 声明式方案覆盖主场景后可暂缓 |
| P3 | 官方 OAuth 订阅 / 托盘用量 | — | 大 | 与目标 CLI 不匹配,建议不做 |
| P3 | 查询请求走代理(见 §6.1) | balance.rs 共用 Client 改造 | 小-中 | 依赖全局代理设置是否已存在 |
### 6.1 遗漏盘点(首轮调研未覆盖,本次补查确认)
@@ -262,6 +304,8 @@ KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCo
4. **响应读体与解析分离**:所有查询先 `bytes()` 再 parse,保持瞬时/确定性错误通道不混淆(已统一在 `balance.rs::get_json`)。
5. **金额单位**:Novita ×0.0001 USD;Kimi 余额为 CNY;Go 套餐按 USD 计。
6. **TS/Rust 枚举漂移**:新增 kind 必须同时改 `UsageKind` union + `SUPPORTED_USAGE_KINDS` + Rust 枚举三处,否则 dev 断言报错。
7. **NewAPI 特有点(§4.7 实测)**:`sk-` API Key 与 Access Token 是两套凭据,前者**查不了** `/api/user/self`;`New-Api-User` 头必须带;余额 = `quota/used_quota ÷ quota_per_unit`(本站 500000);`quota` 为 0 常表示该令牌"无限额度"(后台设置),不要显示成余额为 0。
8. **NewAPI 同源**:`/api/user/self` 与推理同源(同一 base_url host),天然可复用车基地址;若经代理访问站点,`/api/status` 也要走代理(见 §6.1 代理改造)。
---
@@ -289,6 +333,9 @@ KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCo
- Kimi Code 文档(服务地址 / 环境变量 / 错误参考):https://www.kimi.com/code/docs/ 、 https://www.kimi.com/code/docs/kimi-code-cli/configuration/env-vars.html
- Kimi 帮助中心(余额与用量、会员权益):https://www.kimi.com/zh-cn/help/kimi-api/api-balance-and-usage 、 https://www.kimi.com/zh-cn/help/kimi-code/membership-guide
- 智谱 GLM Coding Plan 额度 API(cc-switch issue 社区确认):https://github.com/farion1231/cc-switch/issues/1588 、 https://github.com/seakee/CPA-Manager-Plus/issues/379
- NewAPI / OneAPI `/api/user/self` 接口(社区文档):https://juejin.cn/post/7493007002832551988 、 https://allinone.apifox.cn/399558528e0 、 https://docs.laozhang.ai/faq/balance-query-api
- NewAPI 用户管理文档:https://www.newapi.ai/zh/docs/guide/feature-guide/admin/user
- 实测站点:`https://ai.codingplan.site`(`/api/status`:quota_per_unit=500000、display_in_currency=true)
- Kimi 余额查询(cc-switch issue,官方 API 引入):https://github.com/farion1231/cc-switch/issues/4455
- OpenCode Go 套餐额度:https://opencode.ai/docs/go/
- OpenCode providers / 配置:https://opencode.ai/docs/providers/
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.6.3",
"version": "0.6.4",
"type": "module",
"scripts": {
"dev": "vite",
+2 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.6.3"
version = "0.6.4"
dependencies = [
"anyhow",
"chrono",
@@ -1975,6 +1975,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tokio",
"toml 0.8.23",
"walkdir",
]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.6.3"
version = "0.6.4"
description = "Kimi Switch - model config manager"
authors = ["you"]
edition = "2021"
@@ -25,6 +25,7 @@ futures-util = "0.3"
dirs = "5.0"
toml = "0.8"
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
tokio = { version = "1", features = ["sync"] }
[lib]
name = "kimiswitch_lib"
+66 -5
View File
@@ -1,5 +1,5 @@
use crate::db;
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType};
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType, UsageConfig};
use crate::pi_io;
use crate::services::{self, UsageKind, UsageResult};
use indexmap::IndexMap;
@@ -27,6 +27,10 @@ fn usage_kinds_key(provider_name: &str) -> String {
format!("usage_kinds:{provider_name}")
}
fn usage_config_key(provider_name: &str) -> String {
format!("usage_config:{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.
@@ -45,6 +49,11 @@ fn merge_usage_kinds(config: &mut Config) {
Some(kinds.iter().map(|k| k.as_str().to_string()).collect())
}
});
// Same pattern for the panel-edited usage config.
p.usage_config = db::get_setting_pub(&usage_config_key(&p.name))
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<UsageConfig>(&s).ok());
}
}
@@ -158,6 +167,14 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
}
_ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?,
}
let cfg_key = usage_config_key(&provider.name);
match &provider.usage_config {
Some(cfg) => {
let json = serde_json::to_string(cfg).map_err(|e| e.to_string())?;
db::set_setting_pub(&cfg_key, &json).map_err(fmt_anyhow)?;
}
None => db::delete_setting_pub(&cfg_key).map_err(fmt_anyhow)?,
}
}
Ok(())
}
@@ -614,9 +631,20 @@ pub async fn query_provider_usage(
)));
};
// Panel toggle: user disabled usage queries for this provider.
if let Some(cfg) = &provider.usage_config {
if !cfg.enabled {
return Ok(UsageResult::failure(
"usage query disabled in config panel".to_string(),
));
}
}
// The api_key only ever goes into request headers — never into logs,
// error messages, or the cache key.
let api_key = provider
// error messages, or the cache key. Managed (OAuth-login) providers have
// no static key; their credential comes from the Kimi Code OAuth session
// file (refreshed on demand when the 15-min access token expires).
let mut api_key = provider
.api_key
.clone()
.filter(|s| !s.trim().is_empty())
@@ -627,15 +655,48 @@ pub async fn query_provider_usage(
.cloned()
.filter(|s| !s.is_empty())
});
let mut oauth_err: Option<String> = None;
if api_key.is_none() && provider.managed {
match crate::oauth::get_valid_access_token().await {
Ok(token) => api_key = Some(token),
Err(e) => oauth_err = Some(e),
}
}
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()
oauth_err.unwrap_or_else(|| {
"no Kimi Code OAuth credentials found; run `kimi login` first".to_string()
})
} else {
"no API key configured".to_string()
}));
};
let base_url = resolve_base_url(provider);
// NewAPI template: query the gateway's own /api/user/self with the
// web-console access token, bypassing usage_kinds entirely.
if let Some(cfg) = &provider.usage_config {
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
// Err = transient (network) → propagate for retry, same semantics
// as the kinds loop below. Config errors surface as Ok(failure).
let result = services::query_kind(
UsageKind::BalanceNewapi,
&base_url,
&api_key,
Some(cfg),
)
.await?;
if result.success {
usage_cache()
.lock()
.unwrap()
.insert(cache_key, (Instant::now(), result.clone()));
}
return Ok(result);
}
}
let kinds: Vec<UsageKind> = provider
.usage_kinds
.as_ref()
@@ -660,7 +721,7 @@ pub async fn query_provider_usage(
let mut transient: Vec<String> = Vec::new();
let mut any_success = false;
for kind in kinds {
match services::query_kind(kind, &base_url, &api_key).await {
match services::query_kind(kind, &base_url, &api_key, provider.usage_config.as_ref()).await {
Ok(result) if result.success => {
any_success = true;
if let Some(d) = result.data {
+1
View File
@@ -159,6 +159,7 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
.unwrap_or(Value::Null),
// Merged from the settings table by load_agent_config_command.
usage_kinds: None,
usage_config: None,
})
})?;
+6
View File
@@ -210,6 +210,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
icon_color,
raw_other,
usage_kinds: None,
usage_config: None,
},
);
}
@@ -508,6 +509,7 @@ api_key = ""
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
usage_config: None,
},
);
let mut models = IndexMap::new();
@@ -570,6 +572,7 @@ api_key = ""
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
usage_config: None,
},
);
let mut models = IndexMap::new();
@@ -676,6 +679,7 @@ max_context_size = 1048576
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
usage_config: None,
},
);
providers.insert(
@@ -695,6 +699,7 @@ max_context_size = 1048576
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
usage_config: None,
},
);
let config = Config {
@@ -734,6 +739,7 @@ max_context_size = 1048576
icon_color: None,
raw_other: serde_json::json!({"default_model": "some-alias"}),
usage_kinds: None,
usage_config: None,
},
);
let config = Config {
+1
View File
@@ -4,6 +4,7 @@ pub mod dashboard;
pub mod db;
pub mod kimi_code_io;
pub mod models;
pub mod oauth;
pub mod pi_io;
pub mod services;
+46 -2
View File
@@ -96,9 +96,17 @@ pub struct Provider {
/// `usage_kinds:<provider_name>`, 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")]
/// frontend does carry it. Renamed to camelCase to match the TS
/// `Provider.usageKinds` field — without the rename the frontend reads
/// `undefined` and no usage footer ever renders.
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageKinds")]
pub usage_kinds: Option<Vec<String>>,
/// Usage query configuration (template, credentials, auto interval),
/// edited via the "配置用量查询" panel. Same persistence strategy as
/// `usage_kinds`: SQLite settings `usage_config:<provider_name>`, never
/// config.toml; IPC carries it as camelCase.
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageConfig")]
pub usage_config: Option<UsageConfig>,
}
impl PartialEq for Provider {
@@ -117,6 +125,7 @@ impl PartialEq for Provider {
&& self.icon_color == other.icon_color
&& self.raw_other == other.raw_other
&& self.usage_kinds == other.usage_kinds
&& self.usage_config == other.usage_config
}
}
@@ -207,6 +216,41 @@ impl PartialEq for Config {
impl Eq for Config {}
/// Usage query configuration edited via the "配置用量查询" panel.
/// Mirrors cc-switch's `UsageScript` but trimmed to what Kimi Switch supports:
/// auto-detected kinds plus the NewAPI/OneAPI template. Serialized camelCase
/// to match the TS `UsageConfig` interface; persisted in SQLite settings, never
/// in the agent's config.toml.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageConfig {
/// Whether usage queries run for this provider at all.
pub enabled: bool,
/// "auto" = query the kinds in `usage_kinds` / host detection;
/// "newapi" = query a NewAPI/OneAPI gateway with accessToken + userId.
pub template_type: String,
/// NewAPI query base URL (falls back to the provider's base_url).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// NewAPI web-console access token (NOT the sk- inference key).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
/// NewAPI user id, sent as the `New-Api-User` header.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
/// Auto query interval in minutes; 0/None = manual refresh only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_query_interval_minutes: Option<u32>,
/// Per-request timeout in seconds; 0/None = default (8s).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
}
impl UsageConfig {
pub const TEMPLATE_AUTO: &'static str = "auto";
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
+333
View File
@@ -0,0 +1,333 @@
//! Kimi Code OAuth credentials (login session) for usage queries.
//!
//! The official `kimi` CLI stores its OAuth session in
//! `~/.kimi-code/credentials/kimi-code.json` after `kimi login`:
//! ```json
//! {
//! "access_token": "eyJ...", // JWT, 15 min TTL (expires_in 900)
//! "refresh_token": "eyJ...", // 30 day TTL, rotated on refresh
//! "expires_at": 1785510484, // unix seconds
//! "scope": "kimi-code",
//! "token_type": "Bearer",
//! "expires_in": 900
//! }
//! ```
//!
//! v2 refreshes expired tokens via the OAuth refresh_token grant and writes
//! the rotated tokens back to the credentials file. Concurrency safety:
//! - a process-wide single-flight mutex serializes refreshes, and the file is
//! re-read under the lock so a refresh done by the running `kimi` CLI (or
//! another waiter) is adopted instead of duplicated;
//! - right before writing back, the file is re-read once more — if the CLI
//! refreshed meanwhile its newer tokens win (refresh tokens rotate on use,
//! so clobbering the CLI's write would break its next refresh).
use serde::Deserialize;
use crate::kimi_code_io::kimi_code_config_dir;
/// Seconds of leeway when checking expiry, so a token that is about to die
/// mid-request counts as expired.
const EXPIRY_LEEWAY_SECS: i64 = 30;
/// OAuth token endpoint (confirmed in the official kimi.exe binary).
const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token";
/// Public OAuth client id used by the official CLI (from kimi.exe).
const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
/// Refresh request timeout; refresh is rare, a bit more headroom than the 8s
/// query default is fine.
const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Debug, Clone, Deserialize)]
pub struct OAuthCredentials {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<i64>,
#[allow(dead_code)]
pub scope: Option<String>,
#[allow(dead_code)]
pub token_type: Option<String>,
#[allow(dead_code)]
pub expires_in: Option<i64>,
}
impl OAuthCredentials {
/// True when the token is expired or expires within the leeway window.
/// Missing `expires_at` is treated as valid (fail open to the API call,
/// which returns a definitive 401 if the token is bad).
pub fn is_expired(&self) -> bool {
let Some(expires_at) = self.expires_at else {
return false;
};
let now = chrono::Utc::now().timestamp();
now >= expires_at - EXPIRY_LEEWAY_SECS
}
}
fn credentials_path() -> std::path::PathBuf {
kimi_code_config_dir()
.join("credentials")
.join("kimi-code.json")
}
/// Load the Kimi Code OAuth session. Errors are deterministic (missing file /
/// unreadable JSON), never transient — the caller turns them into
/// `Ok(success:false)`.
pub fn load_kimi_code_credentials() -> Result<OAuthCredentials, String> {
let path = credentials_path();
let content = std::fs::read_to_string(&path).map_err(|e| {
format!(
"Kimi Code OAuth credentials not found at {}: {e}. Run `kimi login` first.",
path.display()
)
})?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse Kimi Code OAuth credentials: {e}"))
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<i64>,
scope: Option<String>,
token_type: Option<String>,
}
/// Process-wide single-flight lock for token refresh. Refresh tokens rotate
/// on use, so two concurrent refreshes would invalidate one of them.
fn refresh_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
/// Return a usable access token, refreshing via the refresh_token grant when
/// the stored one is expired. Errors are deterministic (missing/dead session)
/// — the caller surfaces them as `Ok(success:false)`.
pub async fn get_valid_access_token() -> Result<String, String> {
let creds = load_kimi_code_credentials()?;
if !creds.is_expired() {
return Ok(creds.access_token);
}
let _guard = refresh_lock().lock().await;
// Re-read under the lock: the CLI or a previous waiter may have refreshed
// while we were waiting.
let creds = load_kimi_code_credentials()?;
if !creds.is_expired() {
return Ok(creds.access_token);
}
refresh_credentials(&creds).await
}
/// Merge a token endpoint response into the existing credentials JSON,
/// preserving unrelated fields. Pure function for testability.
fn merge_token_response(current_json: &str, resp: &TokenResponse) -> String {
let mut merged: serde_json::Value =
serde_json::from_str(current_json).unwrap_or_else(|_| serde_json::json!({}));
let obj = match merged.as_object_mut() {
Some(o) => o,
None => return current_json.to_string(),
};
obj.insert(
"access_token".to_string(),
serde_json::Value::String(resp.access_token.clone()),
);
if let Some(rt) = &resp.refresh_token {
obj.insert(
"refresh_token".to_string(),
serde_json::Value::String(rt.clone()),
);
}
if let Some(expires_in) = resp.expires_in {
obj.insert(
"expires_in".to_string(),
serde_json::Value::Number(expires_in.into()),
);
obj.insert(
"expires_at".to_string(),
serde_json::Value::Number((chrono::Utc::now().timestamp() + expires_in).into()),
);
}
if let Some(scope) = &resp.scope {
obj.insert(
"scope".to_string(),
serde_json::Value::String(scope.clone()),
);
}
if let Some(token_type) = &resp.token_type {
obj.insert(
"token_type".to_string(),
serde_json::Value::String(token_type.clone()),
);
}
serde_json::to_string_pretty(&merged).unwrap_or_else(|_| current_json.to_string())
}
/// Call the token endpoint with the refresh_token grant and persist the
/// rotated tokens. Caller must hold [`refresh_lock`].
async fn refresh_credentials(creds: &OAuthCredentials) -> Result<String, String> {
let refresh_token = creds
.refresh_token
.clone()
.filter(|t| !t.is_empty())
.ok_or_else(|| {
"Kimi Code session has no refresh token; run `kimi login`".to_string()
})?;
let client = reqwest::Client::builder()
.timeout(REFRESH_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
// Tokens only ever go into the request body — never into logs or errors.
let resp = client
.post(TOKEN_ENDPOINT)
.form(&[
("grant_type", "refresh_token"),
("client_id", CLIENT_ID),
("refresh_token", refresh_token.as_str()),
])
.send()
.await
.map_err(|e| format!("Token refresh request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
// OAuth error bodies are tiny ({"error":"invalid_grant", ...}); keep
// the first 200 chars for diagnosability. They never contain tokens.
let body = resp.text().await.unwrap_or_default();
let body: String = body.chars().take(200).collect();
return Err(format!(
"Kimi Code login expired and refresh failed (HTTP {status}): {body}. Run `kimi login` to sign in again"
));
}
let token: TokenResponse = resp
.json()
.await
.map_err(|e| format!("Failed to parse token refresh response: {e}"))?;
// Re-read right before writing: if the CLI refreshed meanwhile, its newer
// (rotated) tokens win — overwriting them would kill its next refresh.
let path = credentials_path();
let current = std::fs::read_to_string(&path).unwrap_or_default();
if let Ok(latest) = serde_json::from_str::<OAuthCredentials>(&current) {
if !latest.is_expired() && latest.access_token != creds.access_token {
return Ok(latest.access_token);
}
}
let merged = merge_token_response(&current, &token);
if let Err(e) = std::fs::write(&path, merged) {
// Non-fatal: the new access token is still usable for this query; the
// next one will just refresh again.
eprintln!("[oauth] failed to write refreshed credentials: {e}");
}
Ok(token.access_token)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expired_token_detected_with_leeway() {
let past = chrono::Utc::now().timestamp() - 60;
let creds = OAuthCredentials {
access_token: "t".to_string(),
refresh_token: None,
expires_at: Some(past),
scope: None,
token_type: None,
expires_in: None,
};
assert!(creds.is_expired());
}
#[test]
fn valid_token_not_expired() {
let future = chrono::Utc::now().timestamp() + 3600;
let creds = OAuthCredentials {
access_token: "t".to_string(),
refresh_token: None,
expires_at: Some(future),
scope: None,
token_type: None,
expires_in: None,
};
assert!(!creds.is_expired());
}
#[test]
fn missing_expires_at_fails_open() {
let creds = OAuthCredentials {
access_token: "t".to_string(),
refresh_token: None,
expires_at: None,
scope: None,
token_type: None,
expires_in: None,
};
assert!(!creds.is_expired());
}
#[test]
fn credentials_file_shape_parses() {
let json = serde_json::json!({
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"refresh_token": "eyJhbGciOiJFUzI1NiIs...",
"expires_at": 1785510484i64,
"scope": "kimi-code",
"token_type": "Bearer",
"expires_in": 900
});
let creds: OAuthCredentials = serde_json::from_value(json).unwrap();
assert_eq!(creds.access_token, "eyJhbGciOiJFUzI1NiIs...");
assert_eq!(creds.expires_at, Some(1785510484));
}
#[test]
fn merge_preserves_unrelated_fields_and_rotates() {
let current = r#"{
"access_token": "old-access",
"refresh_token": "old-refresh",
"expires_at": 1,
"scope": "kimi-code",
"token_type": "Bearer",
"expires_in": 900,
"custom_field": "keep-me"
}"#;
let resp = TokenResponse {
access_token: "new-access".to_string(),
refresh_token: Some("new-refresh".to_string()),
expires_in: Some(900),
scope: None,
token_type: None,
};
let merged = merge_token_response(current, &resp);
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
assert_eq!(v["access_token"], "new-access");
assert_eq!(v["refresh_token"], "new-refresh");
assert_eq!(v["custom_field"], "keep-me");
assert_eq!(v["scope"], "kimi-code"); // resp.scope None → 原值保留
let now = chrono::Utc::now().timestamp();
let expires_at = v["expires_at"].as_i64().unwrap();
assert!(expires_at > now + 800 && expires_at <= now + 900);
}
#[test]
fn merge_without_rotated_refresh_keeps_old_refresh_token() {
let current = r#"{"access_token": "old", "refresh_token": "old-refresh", "expires_at": 1}"#;
let resp = TokenResponse {
access_token: "new-access".to_string(),
refresh_token: None,
expires_in: Some(900),
scope: None,
token_type: None,
};
let merged = merge_token_response(current, &resp);
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
assert_eq!(v["refresh_token"], "old-refresh");
}
}
+1
View File
@@ -340,6 +340,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
icon_color: None,
raw_other: provider_raw,
usage_kinds: None,
usage_config: None,
};
for (idx, pi_model) in pi_provider.models.iter().enumerate() {
+356 -10
View File
@@ -16,7 +16,7 @@
use super::usage_types::{UsageData, UsageResult};
use std::time::Duration;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
/// 鉴权头形式:绝大多数供应商用 `Bearer <key>`;智谱套餐接口不加前缀(见 coding_plan)。
pub(crate) enum AuthStyle {
@@ -41,9 +41,10 @@ pub(crate) async fn get_json(
url: &str,
api_key: &str,
auth: AuthStyle,
timeout: Duration,
) -> Result<Fetched, String> {
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
@@ -96,8 +97,8 @@ pub(crate) fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f6
// GET https://api.deepseek.com/user/balance
// Response: { balance_infos: [{ currency, total_balance, ... }], is_available }
pub async fn query_deepseek(api_key: &str) -> Result<UsageResult, String> {
match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer).await? {
pub async fn query_deepseek(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(UsageResult::ok(parse_deepseek(&body))),
Fetched::Failed(err) => Ok(err),
}
@@ -132,8 +133,8 @@ fn parse_deepseek(body: &serde_json::Value) -> Vec<UsageData> {
// 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<UsageResult, String> {
match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer).await? {
pub async fn query_stepfun(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(UsageResult::ok(parse_stepfun(&body))),
Fetched::Failed(err) => Ok(err),
}
@@ -153,14 +154,14 @@ fn parse_stepfun(body: &serde_json::Value) -> Vec<UsageData> {
// 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<UsageResult, String> {
pub async fn query_siliconflow(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
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? {
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(match parse_siliconflow(&body, is_cn) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
@@ -196,11 +197,12 @@ fn parse_siliconflow(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageD
// GET https://openrouter.ai/api/v1/credits
// Response: { data: { total_credits, total_usage } }
pub async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
pub async fn query_openrouter(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
match get_json(
"https://openrouter.ai/api/v1/credits",
api_key,
AuthStyle::Bearer,
timeout,
)
.await?
{
@@ -230,11 +232,12 @@ fn parse_openrouter(body: &serde_json::Value) -> Vec<UsageData> {
// GET https://api.novita.ai/v3/user/balance
// Response: { availableBalance, ... };金额单位 0.0001 USD,需 /10000。
pub async fn query_novita(api_key: &str) -> Result<UsageResult, String> {
pub async fn query_novita(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
match get_json(
"https://api.novita.ai/v3/user/balance",
api_key,
AuthStyle::Bearer,
timeout,
)
.await?
{
@@ -254,6 +257,251 @@ fn parse_novita(body: &serde_json::Value) -> Vec<UsageData> {
}]
}
// ── Kimi (Moonshot) 开放平台 ─────────────────────────────────
// GET https://api.moonshot.cn/v1/users/me/balance(国内站;国际站 api.moonshot.ai)
// 官方文档:https://platform.kimi.com/docs/api/balance
// Response: { code, data: { available_balance, voucher_balance, cash_balance }, scode, status }
// code != 0 或 status == false 为业务失败(确定性);available_balance 单位人民币元。
// 注:国际站货币单位未见于官方文档,先按 USD 处理,待实测确认(与 SiliconFlow 做法一致)。
pub async fn query_kimi(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
let domain = if is_cn {
"api.moonshot.cn"
} else {
"api.moonshot.ai"
};
let url = format!("https://{domain}/v1/users/me/balance");
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(match parse_kimi(&body, is_cn) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
}),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_kimi(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageData>, UsageResult> {
// 业务级失败:code != 0 / status == false
if body.get("code").and_then(|v| v.as_i64()) != Some(0)
|| body.get("status").and_then(|v| v.as_bool()) == Some(false)
{
let msg = body
.get("scode")
.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(),
))
}
};
let available = parse_f64_field(data, "available_balance").unwrap_or(0.0);
Ok(vec![UsageData {
plan_name: Some("Kimi".to_string()),
remaining: Some(available),
unit: Some(if is_cn {
"CNY".to_string()
} else {
"USD".to_string()
}),
is_valid: Some(available > 0.0),
..Default::default()
}])
}
// ── NewAPI / OneAPI 中转站 ────────────────────────────────────
// GET {base_url}/api/user/self
// 头:Authorization: Bearer <access_token>(网页后台 Access Token,非 sk- key)
// New-Api-User: <user_id>
// Response: { success, message, data: { quota, used_quota, username, ... } }
// 金额换算:quota / quota_per_unit(从 {base_url}/api/status 拉取,站点可配,
// 典型 500000 = 1 货币单位)。display_in_currency=true 时单位按
// custom_currency_symbol(¥/$/¤),此处统一记 "credit" 由前端按 symbol 显示。
//
// 坑位(cc-switch 实测 + 本站验证):
// - sk- 推理令牌不能查本接口(401 invalid access token),必须网页 Access Token。
// - quota == 0 常表示"无限额度"(后台设置),不能显示成余额 0。
const DEFAULT_QUOTA_PER_UNIT: f64 = 500_000.0;
/// /api/status 的进程内缓存(key = base_url)。站点配置几乎不变,TTL 与
/// usage 缓存一致(5 分钟)足够。
static QUOTA_PER_UNIT_CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, f64, String)>>,
> = std::sync::OnceLock::new();
fn quota_cache() -> &'static std::sync::Mutex<
std::collections::HashMap<String, (std::time::Instant, f64, String)>,
> {
QUOTA_PER_UNIT_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
/// 拉取站点的 quota_per_unit 与货币符号;失败回退默认值。
async fn fetch_newapi_status(base_url: &str, timeout: Duration) -> (f64, String) {
let base = base_url.trim_end_matches('/');
{
let cache = quota_cache().lock().unwrap();
if let Some((ts, qpu, symbol)) = cache.get(base) {
if ts.elapsed() < std::time::Duration::from_secs(300) {
return (*qpu, symbol.clone());
}
}
}
let client = match reqwest::Client::builder()
.timeout(timeout)
.build()
{
Ok(c) => c,
Err(_) => return (DEFAULT_QUOTA_PER_UNIT, "USD".to_string()),
};
let url = format!("{base}/api/status");
let fallback = || (DEFAULT_QUOTA_PER_UNIT, "USD".to_string());
let result = match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(raw) => match serde_json::from_slice::<serde_json::Value>(&raw) {
Ok(v) => {
let qpu = v
.get("data")
.and_then(|d| d.get("quota_per_unit"))
.and_then(|v| v.as_f64())
.filter(|&n| n > 0.0)
.unwrap_or(DEFAULT_QUOTA_PER_UNIT);
let symbol = v
.get("data")
.and_then(|d| d.get("custom_currency_symbol"))
.and_then(|v| v.as_str())
.unwrap_or("$")
.to_string();
(qpu, symbol)
}
Err(_) => fallback(),
},
Err(_) => fallback(),
},
_ => fallback(),
};
let mut cache = quota_cache().lock().unwrap();
cache.insert(
base.to_string(),
(std::time::Instant::now(), result.0, result.1.clone()),
);
result
}
pub async fn query_newapi(
base_url: &str,
access_token: &str,
user_id: &str,
timeout: Duration,
) -> Result<UsageResult, String> {
let base = base_url.trim_end_matches('/');
let url = format!("{base}/api/user/self");
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let resp = match client
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {access_token}"))
.header("New-Api-User", user_id)
.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(UsageResult::failure(format!(
"Authentication failed (HTTP {status})"
)));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Ok(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}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => {
return Ok(UsageResult::failure(format!(
"Failed to parse response: {e}"
)))
}
};
let (quota_per_unit, symbol) = fetch_newapi_status(base_url, timeout).await;
Ok(match parse_newapi(&body, quota_per_unit, &symbol) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
})
}
fn parse_newapi(
body: &serde_json::Value,
quota_per_unit: f64,
symbol: &str,
) -> Result<Vec<UsageData>, UsageResult> {
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
let msg = body
.get("message")
.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(),
))
}
};
let quota_raw = parse_f64_field(data, "quota").unwrap_or(0.0);
let used_raw = parse_f64_field(data, "used_quota").unwrap_or(0.0);
// quota == 0 && used == 0:站点把该令牌设为"无限额度",没有数值可显示。
if quota_raw == 0.0 && used_raw == 0.0 {
return Ok(vec![UsageData {
plan_name: Some("NewAPI".to_string()),
unit: Some(symbol.to_string()),
is_valid: Some(true),
..Default::default()
}]);
}
let remaining = quota_raw / quota_per_unit;
let used = used_raw / quota_per_unit;
let total = remaining + used;
Ok(vec![UsageData {
plan_name: Some("NewAPI".to_string()),
remaining: Some(remaining),
total: Some(total),
used: Some(used),
unit: Some(symbol.to_string()),
is_valid: Some(remaining > 0.0),
..Default::default()
}])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -323,4 +571,102 @@ mod tests {
let zero = parse_novita(&json!({ "availableBalance": 0 }));
assert_eq!(zero[0].is_valid, Some(false));
}
#[test]
fn kimi_parses_balance() {
let body = json!({
"code": 0,
"data": {
"available_balance": 49.58894,
"voucher_balance": 46.58893,
"cash_balance": 3.00001
},
"scode": "0x0",
"status": true
});
let data = parse_kimi(&body, true).unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0].plan_name.as_deref(), Some("Kimi"));
assert_eq!(data[0].remaining, Some(49.58894));
assert_eq!(data[0].unit.as_deref(), Some("CNY"));
assert_eq!(data[0].is_valid, Some(true));
}
#[test]
fn kimi_en_uses_usd_unit() {
let body = json!({ "code": 0, "data": { "available_balance": 12.5 }, "status": true });
let data = parse_kimi(&body, false).unwrap();
assert_eq!(data[0].unit.as_deref(), Some("USD"));
}
#[test]
fn kimi_business_error_is_deterministic_failure() {
let body = json!({ "code": 401, "scode": "0x191", "status": false });
let err = parse_kimi(&body, true).unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("0x191"));
}
#[test]
fn kimi_missing_data_is_deterministic_failure() {
let body = json!({ "code": 0, "status": true });
let err = parse_kimi(&body, true).unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("Missing 'data'"));
}
#[test]
fn kimi_zero_balance_is_invalid() {
let body = json!({ "code": 0, "data": { "available_balance": 0.0 }, "status": true });
let data = parse_kimi(&body, true).unwrap();
assert_eq!(data[0].is_valid, Some(false));
}
#[test]
fn newapi_parses_quota_with_unit_division() {
let body = json!({
"success": true,
"data": { "quota": 36_125_000, "used_quota": 2_500_000, "username": "alice" }
});
let data = parse_newapi(&body, 500_000.0, "¥").unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0].plan_name.as_deref(), Some("NewAPI"));
assert_eq!(data[0].remaining, Some(72.25));
assert_eq!(data[0].used, Some(5.0));
assert_eq!(data[0].total, Some(77.25));
assert_eq!(data[0].unit.as_deref(), Some("¥"));
assert_eq!(data[0].is_valid, Some(true));
}
#[test]
fn newapi_business_error_is_deterministic_failure() {
let body = json!({ "success": false, "message": "Unauthorized, invalid access token" });
let err = parse_newapi(&body, 500_000.0, "¥").unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("invalid access token"));
}
#[test]
fn newapi_missing_data_is_deterministic_failure() {
let body = json!({ "success": true });
let err = parse_newapi(&body, 500_000.0, "¥").unwrap_err();
assert!(err.error.unwrap().contains("Missing 'data'"));
}
#[test]
fn newapi_zero_quota_means_unlimited() {
let body = json!({ "success": true, "data": { "quota": 0, "used_quota": 0 } });
let data = parse_newapi(&body, 500_000.0, "$").unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0].remaining, None);
assert_eq!(data[0].is_valid, Some(true));
}
#[test]
fn newapi_accepts_string_numbers() {
let body = json!({ "success": true, "data": { "quota": "500000", "used_quota": "100000" } });
let data = parse_newapi(&body, 500_000.0, "¥").unwrap();
assert_eq!(data[0].remaining, Some(1.0));
assert_eq!(data[0].used, Some(0.2));
}
}
+10 -5
View File
@@ -13,7 +13,11 @@
use super::balance::{get_json, AuthStyle, Fetched};
use super::usage_types::{UsageData, UsageResult};
use std::time::Duration;
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax 及未来新增)
// 都只用这两个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖此约定
// 做本地化映射——新增 tier id 时必须同步加映射。
const TIER_FIVE_HOUR: &str = "five_hour";
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
@@ -66,11 +70,12 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } }
pub async fn query_kimi_coding(api_key: &str) -> Result<UsageResult, String> {
pub async fn query_kimi_coding(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
match get_json(
"https://api.kimi.com/coding/v1/usages",
api_key,
AuthStyle::Bearer,
timeout,
)
.await?
{
@@ -196,12 +201,12 @@ fn zhipu_quota_base(base_url: &str) -> &'static str {
}
}
pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
pub async fn query_zhipu(base_url: &str, api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
let url = format!(
"{}/api/monitor/usage/quota/limit",
zhipu_quota_base(base_url)
);
match get_json(&url, api_key, AuthStyle::Raw).await? {
match get_json(&url, api_key, AuthStyle::Raw, timeout).await? {
Fetched::Body(body) => Ok(match parse_zhipu(&body) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
@@ -215,14 +220,14 @@ pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, S
// (海外站 api.minimax.io)
// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。
pub async fn query_minimax(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
pub async fn query_minimax(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
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? {
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(match parse_minimax(&body) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
+54 -11
View File
@@ -21,6 +21,8 @@ pub enum UsageKind {
BalanceOpenrouter,
BalanceStepfun,
BalanceNovita,
BalanceKimi,
BalanceNewapi,
PlanKimiCoding,
PlanZhipu,
PlanMinimax,
@@ -35,18 +37,22 @@ impl UsageKind {
UsageKind::BalanceOpenrouter => "balance:openrouter",
UsageKind::BalanceStepfun => "balance:stepfun",
UsageKind::BalanceNovita => "balance:novita",
UsageKind::BalanceKimi => "balance:kimi",
UsageKind::BalanceNewapi => "balance:newapi",
UsageKind::PlanKimiCoding => "plan:kimi_coding",
UsageKind::PlanZhipu => "plan:zhipu",
UsageKind::PlanMinimax => "plan:minimax",
}
}
pub const ALL: [UsageKind; 8] = [
pub const ALL: [UsageKind; 10] = [
UsageKind::BalanceDeepseek,
UsageKind::BalanceSiliconflow,
UsageKind::BalanceOpenrouter,
UsageKind::BalanceStepfun,
UsageKind::BalanceNovita,
UsageKind::BalanceKimi,
UsageKind::BalanceNewapi,
UsageKind::PlanKimiCoding,
UsageKind::PlanZhipu,
UsageKind::PlanMinimax,
@@ -63,6 +69,8 @@ impl std::str::FromStr for UsageKind {
"balance:openrouter" => UsageKind::BalanceOpenrouter,
"balance:stepfun" => UsageKind::BalanceStepfun,
"balance:novita" => UsageKind::BalanceNovita,
"balance:kimi" => UsageKind::BalanceKimi,
"balance:newapi" => UsageKind::BalanceNewapi,
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
"plan:zhipu" => UsageKind::PlanZhipu,
"plan:minimax" => UsageKind::PlanMinimax,
@@ -91,6 +99,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("api.novita.ai") {
kinds.push(UsageKind::BalanceNovita);
}
// Kimi 开放平台(Moonshot):国内站 api.moonshot.cn / 国际站 api.moonshot.ai。
// 注意别与 api.kimi.com(Kimi Code 官方端点)混淆:后者只有 /coding 路径命中套餐。
if url.contains("api.moonshot.cn") || url.contains("api.moonshot.ai") {
kinds.push(UsageKind::BalanceKimi);
}
if url.contains("api.kimi.com") && url.contains("/coding") {
kinds.push(UsageKind::PlanKimiCoding);
}
@@ -105,24 +118,53 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
/// 其余分支忽略;调用方在 templateType=="newapi" 时保证其存在。
pub async fn query_kind(
kind: UsageKind,
base_url: &str,
api_key: &str,
usage_config: Option<&crate::models::UsageConfig>,
) -> Result<UsageResult, String> {
let lower = base_url.to_lowercase();
// 用户配置的超时(秒);0/未配置回退默认 8s。
let timeout = usage_config
.and_then(|c| c.timeout_seconds)
.filter(|&s| s > 0)
.map(std::time::Duration::from_secs)
.unwrap_or(balance::REQUEST_TIMEOUT);
match kind {
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key).await,
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key, timeout).await,
UsageKind::BalanceSiliconflow => {
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com")).await
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com"), timeout).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::BalanceOpenrouter => balance::query_openrouter(api_key, timeout).await,
UsageKind::BalanceStepfun => balance::query_stepfun(api_key, timeout).await,
UsageKind::BalanceNovita => balance::query_novita(api_key, timeout).await,
UsageKind::BalanceKimi => {
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
}
UsageKind::BalanceNewapi => {
let (token, uid) = usage_config
.and_then(|c| {
c.access_token
.as_deref()
.zip(c.user_id.as_deref())
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
})
.ok_or_else(|| {
"newapi template requires accessToken and userId".to_string()
})?;
let url = usage_config
.and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(base_url);
balance::query_newapi(url, token, uid, timeout).await
}
UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key, timeout).await,
UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key, timeout).await,
UsageKind::PlanMinimax => {
coding_plan::query_minimax(api_key, !lower.contains("minimax.io")).await
coding_plan::query_minimax(api_key, !lower.contains("minimax.io"), timeout).await
}
}
}
@@ -133,12 +175,13 @@ mod tests {
#[test]
fn detect_provider_maps_known_hosts() {
let cases: [(&str, UsageKind); 8] = [
let cases: [(&str, UsageKind); 9] = [
("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.moonshot.cn/v1", UsageKind::BalanceKimi),
("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding),
(
"https://open.bigmodel.cn/api/paas/v4",
@@ -165,7 +208,7 @@ mod tests {
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 路径 → 不命中套餐查询
// api.kimi.com 但无 /coding 路径 → 不命中套餐查询,也不命中 Moonshot 余额
assert!(detect_provider("https://api.kimi.com/v1").is_empty());
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.6.3",
"version": "0.6.4",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+34 -2
View File
@@ -8,6 +8,7 @@ import { ProviderEdit } from "./components/ProviderEdit";
import { DashboardPage } from "./components/dashboard/DashboardPage";
import { SessionsPage } from "./components/sessions/SessionsPage";
import { SettingsModal } from "./components/SettingsModal";
import { UsageConfigModal } from "./components/UsageConfigModal";
import { PresetPickerModal } from "./components/PresetPickerModal";
import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults";
@@ -17,7 +18,7 @@ import {
type ProviderPreset,
} from "./config/providerPresets";
import { validateProviders } from "./lib/validation";
import type { Agent, Model, Provider } from "./types";
import type { Agent, Model, Provider, UsageConfig } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
@@ -75,6 +76,8 @@ export default function App() {
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showPresetPicker, setShowPresetPicker] = useState(false);
/** Provider whose usage-config panel is open; null = closed. */
const [usageConfigProvider, setUsageConfigProvider] = useState<string | null>(null);
// 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
@@ -306,6 +309,24 @@ export default function App() {
}
};
const handleSaveUsageConfig = (name: string, usageConfig: UsageConfig) => {
updateConfig((cfg) => {
const target = cfg.providers[name];
if (!target) return cfg;
return {
...cfg,
providers: {
...cfg.providers,
[name]: { ...target, usageConfig },
},
};
});
// Silent save: a normal save flips App to its loading screen, which
// unmounts this panel and swallows the in-flight test query's result.
// Returned so the panel's test can await persistence before querying.
return save({ silent: true });
};
const handleDuplicateProvider = async (name: string) => {
updateConfig((cfg) => {
const src = cfg.providers[name];
@@ -524,7 +545,7 @@ export default function App() {
<button
type="button"
className="mt-2 px-2 py-1 border border-red-400/30 rounded hover:bg-red-900/30"
onClick={refresh}
onClick={() => refresh()}
>
{t("retry")}
</button>
@@ -633,6 +654,7 @@ export default function App() {
onDuplicate={handleDuplicateProvider}
onAdd={handleAddProvider}
onSwitchProvider={handleSwitchProvider}
onConfigureUsage={(name) => setUsageConfigProvider(name)}
agent={agent}
/>
) : currentProvider ? (
@@ -802,6 +824,16 @@ export default function App() {
onSelect={handleSelectPreset}
onCustom={handleAddCustomProvider}
/>
{usageConfigProvider && config.providers[usageConfigProvider] && (
<UsageConfigModal
open={usageConfigProvider != null}
agent={agent}
provider={config.providers[usageConfigProvider]}
onClose={() => setUsageConfigProvider(null)}
onSave={handleSaveUsageConfig}
/>
)}
</div>
);
}
+35 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter";
@@ -28,6 +28,7 @@ interface ProviderListProps {
onDuplicate: (name: string) => void;
onAdd: () => void;
onSwitchProvider: (name: string) => void;
onConfigureUsage: (name: string) => void;
agent: Agent;
}
@@ -43,11 +44,17 @@ export function ProviderList({
onDuplicate,
onAdd,
onSwitchProvider,
onConfigureUsage,
agent,
}: ProviderListProps) {
const { t } = useTranslation();
const [testState, setTestState] = useState<Record<string, TestState>>({});
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
const sortedProviders = [...providers].sort(
(a, b) => Number(b.managed === true) - Number(a.managed === true)
);
const handleTest = async (provider: Provider) => {
setTestState((s) => ({ ...s, [provider.name]: { status: "testing" } }));
try {
@@ -132,7 +139,7 @@ export function ProviderList({
</div>
) : (
<div className="grid grid-cols-1 gap-3 w-full">
{providers.map((provider) => {
{sortedProviders.map((provider) => {
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
);
@@ -217,6 +224,19 @@ export function ProviderList({
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
</span>
)}
{/* compact usage summary — sits left of the switch button,
mirroring cc-switch's card layout (usage → action buttons) */}
{(provider.usageKinds?.length ?? 0) > 0 && (
<UsageFooter
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
variant="compact"
autoIntervalMinutes={
provider.usageConfig?.autoQueryIntervalMinutes
}
/>
)}
<button
type="button"
onClick={(e) => {
@@ -283,6 +303,18 @@ export function ProviderList({
<Activity className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onConfigureUsage(provider.name);
}}
title={t("usageConfigBtn")}
className={iconBtn}
aria-label={t("usageConfigBtn")}
>
<BarChart3 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
@@ -301,6 +333,7 @@ export function ProviderList({
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
variant="detail"
/>
</div>
);
+433
View File
@@ -0,0 +1,433 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError, planLabel } from "../lib/usage-display";
import type { Agent, Provider, UsageConfig } from "../types";
interface UsageResult {
success: boolean;
data?: Array<{
planName?: string | null;
remaining?: number | null;
total?: number | null;
used?: number | null;
unit?: string | null;
}> | null;
error?: string | null;
}
interface UsageConfigModalProps {
open: boolean;
agent: Agent;
provider: Provider;
onClose: () => void;
/** Persist the edited config (goes through updateConfig → silent save).
* Awaited by the panel's test query so the backend sees the same config. */
onSave: (providerName: string, config: UsageConfig) => Promise<void> | void;
}
const TEMPLATES: ReadonlyArray<{
id: "auto" | "newapi";
labelKey: TranslationKey;
hintKey: TranslationKey;
}> = [
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
];
function defaultConfig(provider: Provider): UsageConfig {
return (
provider.usageConfig ?? {
enabled: true,
templateType: "auto",
baseUrl: provider.base_url ?? undefined,
autoQueryIntervalMinutes: 0,
}
);
}
/** cc-switch FullScreenPanel 行为:焦点在输入框/文本域时 ESC 不关闭面板。 */
function isTextEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable
);
}
export function UsageConfigModal({ open, agent, provider, onClose, onSave }: UsageConfigModalProps) {
const { t } = useTranslation();
const [cfg, setCfg] = useState<UsageConfig>(() => defaultConfig(provider));
const [showToken, setShowToken] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<string | null>(null);
const [testOk, setTestOk] = useState<boolean | null>(null);
// Re-seed when switching provider or reopening.
useEffect(() => {
if (open) {
setCfg(defaultConfig(provider));
setTestResult(null);
setTestOk(null);
setShowToken(false);
setShowApiKey(false);
}
}, [open, provider.name]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isTextEditableTarget(e.target)) onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
const isManaged = provider.managed === true;
const detectedKinds = useMemo(() => provider.usageKinds ?? [], [provider.usageKinds]);
if (!open) return null;
const handleSave = () => {
void onSave(provider.name, {
...cfg,
templateType: isManaged ? "auto" : cfg.templateType,
});
onClose();
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
setTestOk(null);
// 短路:自动模板 + 无识别类型 → 不发请求,直接给出本地化提示 + 切换入口。
// managed 供应商除外:它们走 OAuth 查询,与 detectedKinds 无关。
if (!isManaged && cfg.templateType === "auto" && detectedKinds.length === 0) {
setTestOk(false);
setTestResult(t("usageUnsupportedProvider"));
setTesting(false);
return;
}
try {
// Persist first (awaited) so the backend query sees the same config
// the user is testing.
await onSave(provider.name, cfg);
const result = await invoke<UsageResult>("query_provider_usage", {
agent,
providerName: provider.name,
forceRefresh: true,
});
if (result.success) {
setTestOk(true);
const summary = (result.data ?? [])
.map((d) => {
const parts: string[] = [];
if (d.planName) parts.push(planLabel(d.planName, t));
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`);
if (d.used != null && d.total != null && d.total > 0)
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
return parts.join(" ");
})
.filter(Boolean)
.join(", ");
setTestResult(summary || t("usageQueryOk"));
} else {
setTestOk(false);
setTestResult(result.error ? localizeUsageError(result.error, t) : t("usageQueryFailed"));
}
} catch {
setTestOk(false);
// Transient failure (network/timeout) — same label as the card footer.
setTestResult(t("usageNetworkError"));
} finally {
setTesting(false);
}
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex flex-col bg-app text-content-primary">
{/* header */}
<div className="h-16 shrink-0 flex items-center gap-4 px-6 border-b border-border">
<button
type="button"
onClick={onClose}
aria-label={t("usageBack")}
className="w-8 h-8 flex items-center justify-center rounded-lg text-content-muted hover:text-content-primary hover:bg-hover-2 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h2 className="text-lg font-semibold">
{t("usageConfigTitle", { name: provider.name })}
</h2>
</div>
{/* body */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6 space-y-5">
{/* enable toggle */}
<div className="flex items-center justify-between rounded-lg border border-border bg-hover/40 px-4 py-3">
<span className="text-sm text-content-primary">{t("usageEnable")}</span>
<button
type="button"
role="switch"
aria-checked={cfg.enabled}
onClick={() => setCfg({ ...cfg, enabled: !cfg.enabled })}
className={`relative w-10 h-5 rounded-full transition-colors ${
cfg.enabled ? "bg-blue-600" : "bg-hover-2"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
cfg.enabled ? "left-5" : "left-0.5"
}`}
/>
</button>
</div>
{cfg.enabled && (
<>
{/* template picker */}
<div className="rounded-lg border border-border px-4 py-4 space-y-3">
<label className="block text-sm font-medium text-content-primary">{t("usageTemplate")}</label>
{isManaged ? (
<div className="text-sm text-content-muted rounded-lg border border-border bg-hover/40 px-4 py-3">
{t("usageOAuthManagedHint")}
</div>
) : (
<>
<div className="flex gap-2 flex-wrap">
{TEMPLATES.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => setCfg({ ...cfg, templateType: tpl.id })}
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
cfg.templateType === tpl.id
? "bg-blue-600 text-white border-blue-600"
: "border-border text-content-primary hover:bg-hover-2"
}`}
>
{t(tpl.labelKey)}
</button>
))}
</div>
<p className="text-xs text-content-muted">
{cfg.templateType === "newapi"
? t("usageNewapiHint")
: t("usageAutoDetectHint")}
</p>
</>
)}
{/* supported variables (read-only, cc-switch 风格) */}
<div className="pt-1 space-y-1.5">
<span className="block text-xs text-content-muted">{t("usageSupportedVars")}</span>
<div className="flex items-center gap-2 text-xs font-mono">
<span className="text-blue-500">{"{{baseUrl}}"}</span>
<span className="text-content-muted">=</span>
<span className="text-content-primary break-all">
{cfg.templateType === "newapi"
? cfg.baseUrl || provider.base_url || "-"
: provider.base_url || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-xs font-mono">
<span className="text-blue-500">{"{{apiKey}}"}</span>
<span className="text-content-muted">=</span>
<span className="text-content-primary break-all">
{provider.api_key
? showApiKey
? provider.api_key
: "••••••••"
: "-"}
</span>
{provider.api_key && (
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="text-content-muted hover:text-content-primary"
>
{showApiKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
</div>
</div>
{/* auto-detect: show resolved kinds as read-only chips */}
{!isManaged && cfg.templateType === "auto" && (
<div className="space-y-2">
<label className="block text-sm text-content-muted">{t("usageDetectedKinds")}</label>
{detectedKinds.length === 0 ? (
<div className="rounded-lg border border-amber-300/40 bg-amber-50/40 dark:bg-amber-500/10 px-3 py-2.5 space-y-2">
<p className="text-xs text-content-primary">{t("usageNoKindsHint")}</p>
<button
type="button"
onClick={() => setCfg({ ...cfg, templateType: "newapi" })}
className="text-xs px-2.5 py-1 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
{t("usageSwitchToNewapi")}
</button>
</div>
) : (
<div className="flex gap-2 flex-wrap">
{detectedKinds.map((k) => (
<span
key={k}
className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border font-mono"
>
{k}
</span>
))}
</div>
)}
</div>
)}
{/* newapi creds */}
{!isManaged && cfg.templateType === "newapi" && (
<div className="space-y-3">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
<input
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.baseUrl ?? ""}
placeholder={provider.base_url ?? "https://your-newapi-site.com"}
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">
{t("usageAccessToken")} <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showToken ? "text" : "password"}
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.accessToken ?? ""}
placeholder={t("usageAccessTokenPlaceholder")}
onChange={(e) => setCfg({ ...cfg, accessToken: e.target.value || undefined })}
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary"
>
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="mt-1 text-xs text-content-muted">{t("usageAccessTokenHint")}</p>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">
{t("usageUserId")} <span className="text-red-500">*</span>
</label>
<input
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.userId ?? ""}
placeholder={t("usageUserIdPlaceholder")}
onChange={(e) => setCfg({ ...cfg, userId: e.target.value || undefined })}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageUserIdHint")}</p>
</div>
</div>
)}
</div>
{/* timeout + auto query interval */}
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
<input
type="number"
min={0}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.timeoutSeconds ?? 0}
onChange={(e) => {
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
setCfg({ ...cfg, timeoutSeconds: n });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageTimeoutHint")}</p>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageAutoInterval")}</label>
<input
type="number"
min={0}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.autoQueryIntervalMinutes ?? 0}
onChange={(e) => {
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
setCfg({ ...cfg, autoQueryIntervalMinutes: n });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
</div>
</div>
</>
)}
{/* test result - sticky bottom inside scroll container so it's always visible above the footer */}
</div>
{testResult && (
<div className="sticky bottom-0">
<div className="max-w-3xl mx-auto px-6 pb-6">
<div
className={`text-sm rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm ${
testOk
? "border-green-300 dark:border-green-500/30 bg-green-50/95 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "border-red-300 dark:border-red-500/30 bg-red-50/95 dark:bg-red-900/30 text-red-600 dark:text-red-300"
}`}
>
{testResult}
</div>
</div>
</div>
)}
</div>
{/* footer */}
<div className="shrink-0 border-t border-border">
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleTest}
disabled={testing || !cfg.enabled}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
>
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
{t("usageTestQuery")}
</button>
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 transition-colors"
>
{t("cancel")}
</button>
<button
type="button"
onClick={handleSave}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
{t("saveConfig")}
</button>
</div>
</div>
</div>,
document.body
);
}
+112 -8
View File
@@ -2,6 +2,7 @@ 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 { localizeUsageError, planLabel } from "../lib/usage-display";
import type { Agent } from "../types";
interface UsageData {
@@ -31,7 +32,8 @@ interface CacheEntry {
}
// Module-level cache shared across mounts: re-entering the list within the
// stale TTL shows the last result without firing new requests.
// stale TTL shows the last result without firing new requests. Both the
// compact (card header) and detail (footer) variants read this same cache.
const STALE_TTL_MS = 5 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
@@ -54,9 +56,21 @@ interface UsageFooterProps {
agent: Agent;
providerName: string;
usageKinds?: string[];
/** "detail" (default) renders the multi-line footer; "compact" renders a
* one-line summary + last-updated + refresh button for the card header. */
variant?: "detail" | "compact";
/** Auto query interval in minutes; only wired on the compact variant so
* both variants do not double-fire. 0/undefined = manual only. */
autoIntervalMinutes?: number;
}
export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProps) {
export function UsageFooter({
agent,
providerName,
usageKinds,
variant = "detail",
autoIntervalMinutes,
}: UsageFooterProps) {
const { t } = useTranslation();
const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider
@@ -67,6 +81,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
const [data, setData] = useState<UsageData[]>([]);
/** undefined = no error; null = network error; string = Rust error text. */
const [error, setError] = useState<string | null | undefined>(undefined);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now());
// Generation counter: stale responses (unmounted / superseded query) are ignored.
const genRef = useRef(0);
@@ -82,6 +97,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
setStatus(entry.status);
setData(entry.data);
setError(entry.error);
setUpdatedAt(entry.updatedAt);
};
try {
await acquireSlot();
@@ -133,6 +149,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
setStatus(cached.status);
setData(cached.data);
setError(cached.error);
setUpdatedAt(cached.updatedAt);
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
}
void runQuery(false);
@@ -145,7 +162,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
};
}, []);
// Tick the reset countdown once a minute while showing data.
// Auto query interval (compact variant only, to avoid double-firing).
useEffect(() => {
if (variant !== "compact" || !supported) return;
const mins = autoIntervalMinutes ?? 0;
if (mins <= 0) return;
const id = setInterval(() => void runQuery(false), mins * 60_000);
return () => clearInterval(id);
}, [variant, supported, autoIntervalMinutes, runQuery]);
// Tick relative times once a minute while showing data.
useEffect(() => {
if (status !== "success") return;
const id = setInterval(() => setNow(Date.now()), 60_000);
@@ -185,6 +211,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
return t("usageResetIn", { time: `${Math.floor(hours / 24)}d` });
};
const formatAgo = (ts: number | null): string | null => {
if (ts == null) return null;
const mins = Math.floor((now - ts) / 60_000);
if (mins < 1) return t("usageUpdatedAgo", { time: "1m" });
if (mins < 60) return t("usageMinAgo", { n: mins });
const hours = Math.floor(mins / 60);
if (hours < 24) return t("usageHourAgo", { n: hours });
return t("usageDayAgo", { n: Math.floor(hours / 24) });
};
const refreshBtn = (
<button
type="button"
@@ -195,7 +231,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
disabled={status === "loading"}
title={t("usageRefresh")}
aria-label={t("usageRefresh")}
className="ml-auto w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
className="w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
>
{status === "loading" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -205,6 +241,73 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
</button>
);
/** One-line summary of the primary entry (first tier / balance). */
const compactSummary = (): { icon: string; text: string; color: string } | null => {
const d = data[0];
if (!d) return null;
const isPlan = !!d.planName && d.planName !== "NewAPI";
if (d.isValid === false) {
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
}
if (d.planName === "NewAPI" && d.remaining == null && d.total == null) {
return { icon: "💰", text: `${t("usageBalance")} ∞`, color: "text-green-600 dark:text-green-400" };
}
if (isPlan) {
const pct = percentOf(d);
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 label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} ` : "";
return {
icon: "⚡",
text: pct != null ? `${label}${Math.round(pct)}%` : `${label}—`,
color,
};
}
return {
icon: "💰",
text: formatAmount(d.remaining, d.unit),
color: "text-content-primary",
};
};
if (variant === "compact") {
if (status === "loading" && data.length === 0) {
return <div className="h-3.5 w-20 rounded bg-hover-2 animate-pulse" />;
}
if (status === "error" && data.length === 0) {
return (
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400">
<span className="truncate max-w-[120px]" title={error ?? t("usageNetworkError")}>
{error == null ? t("usageNetworkError") : localizeUsageError(error, t)}
</span>
{refreshBtn}
</span>
);
}
const s = compactSummary();
const ghost = status !== "success";
const ago = formatAgo(updatedAt);
return (
<span className={`flex items-center gap-1.5 text-xs tabular-nums ${ghost ? "opacity-50" : ""}`}>
{s && (
<>
<span aria-hidden="true">{s.icon}</span>
<span className={`font-medium ${s.color}`}>{s.text}</span>
</>
)}
{ago && <span className="text-content-muted">{ago}</span>}
{refreshBtn}
</span>
);
}
// ── detail variant ──
// Loading with no prior data: skeleton bar.
if (status === "loading" && data.length === 0) {
return (
@@ -224,7 +327,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
: /401|api[ -]?key|invalid key/i.test(error)
? t("usageInvalidKey")
: error
? `${t("usageQueryFailed")} · ${error}`
? `${t("usageQueryFailed")} · ${localizeUsageError(error, t)}`
: t("usageQueryFailed");
return (
<div className="w-full border-t border-border pt-2 mt-1 flex items-center gap-2 text-xs">
@@ -246,6 +349,9 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
}
// success, or error/loading with last-good data (ghost).
// Compact header already shows the primary entry; detail only adds value
// when there are multiple tiers (e.g. 5h + weekly) or on error ghost.
if (data.length <= 1 && status === "success") return null;
const ghost = status !== "success";
return (
<div
@@ -270,7 +376,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
if (d.isValid === false) {
main = t("usageInvalidKey");
} else if (isPlan) {
const label = data.length > 1 && d.planName ? `${d.planName} · ` : "";
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} · ` : "";
main =
pct != null
? `${label}${Math.round(pct)}% ${t("usageUsed")}`
@@ -295,14 +401,12 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
{resetText && (
<span className="text-content-muted shrink-0">{resetText}</span>
)}
{i === data.length - 1 && refreshBtn}
</div>
);
})}
{data.length === 0 && (
<div className="flex items-center gap-1.5">
<span className="text-content-muted">—</span>
{refreshBtn}
</div>
)}
</div>
+5
View File
@@ -13,6 +13,7 @@ export type UsageKind =
| "balance:openrouter"
| "balance:stepfun"
| "balance:novita"
| "balance:kimi"
| "plan:kimi_coding"
| "plan:zhipu"
| "plan:minimax";
@@ -27,6 +28,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:openrouter",
"balance:stepfun",
"balance:novita",
"balance:kimi",
"plan:kimi_coding",
"plan:zhipu",
"plan:minimax",
@@ -141,6 +143,9 @@ export const providerPresets: ProviderPreset[] = [
{ model: "kimi-k2.6" },
],
billingMode: "pay_as_you_go",
// Kimi 开放平台余额:国内站 api.moonshot.cn / 国际站 api.moonshot.ai,
// Rust detect 按 host 消歧(api.moonshot.ai → USD,api.moonshot.cn → CNY)。
usageKinds: ["balance:kimi"],
},
{
id: "deepseek",
+12 -9
View File
@@ -8,8 +8,8 @@ interface UseConfigReturn {
dirty: boolean;
error: string | null;
loading: boolean;
refresh: () => Promise<void>;
save: () => Promise<void>;
refresh: (opts?: { silent?: boolean }) => Promise<void>;
save: (opts?: { silent?: boolean }) => Promise<void>;
updateConfig: (updater: (config: Config) => Config) => void;
}
@@ -25,8 +25,11 @@ export function useConfig(agent: Agent): UseConfigReturn {
configRef.current = config;
}, [config]);
const refresh = useCallback(async () => {
setLoading(true);
const refresh = useCallback(async (opts?: { silent?: boolean }) => {
// silent: skip the loading flip — App returns a bare loading screen while
// `loading` is true, which would unmount any open panel (and swallow its
// in-flight async results).
if (!opts?.silent) setLoading(true);
setError(null);
try {
const loadedConfig = await invoke<Config>("load_agent_config_command", {
@@ -39,7 +42,7 @@ export function useConfig(agent: Agent): UseConfigReturn {
setError(message);
console.error("useConfig refresh failed:", message);
} finally {
setLoading(false);
if (!opts?.silent) setLoading(false);
}
}, [agent]);
@@ -61,19 +64,19 @@ export function useConfig(agent: Agent): UseConfigReturn {
setDirty(true);
}, []);
const save = useCallback(async () => {
const save = useCallback(async (opts?: { silent?: boolean }) => {
const current = configRef.current;
if (!current) return;
setLoading(true);
if (!opts?.silent) setLoading(true);
setError(null);
try {
await invoke("save_agent_config_command", { agent, config: current });
await refresh();
await refresh({ silent: opts?.silent });
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
setError(raw);
} finally {
setLoading(false);
if (!opts?.silent) setLoading(false);
}
}, [refresh, t, agent]);
+41
View File
@@ -372,6 +372,47 @@ export const enTranslations: Record<TranslationKey, string> = {
usageResetIn: "resets in {time}",
usageResetDone: "reset",
usageLoading: "Querying…",
usageQueryOk: "Query succeeded",
// Usage config panel (cc-switch style)
usageConfigTitle: "Configure Usage Query - {name}",
usageConfigBtn: "Configure usage query",
usageEnable: "Enable usage query",
usageTemplate: "Query template",
usageTemplateAuto: "Auto-detect",
usageTemplateNewapi: "NewAPI gateway",
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
usageDetectedKinds: "Detected query kinds",
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
usageBaseUrl: "Query URL (Base URL)",
usageAccessToken: "Access Token",
usageAccessTokenPlaceholder: "Access Token from the web console",
usageAccessTokenHint: "Generated in the NewAPI web console (Personal Settings → Access Token) — NOT the sk- inference key",
usageUserId: "User ID",
usageUserIdPlaceholder: "NewAPI user id (numeric)",
usageUserIdHint: "User id from the web console profile page, sent as the New-Api-User header",
usageAutoInterval: "Auto query interval (minutes)",
usageAutoIntervalHint: "0 = query only on page open or manual refresh",
usageTimeout: "Timeout (seconds)",
usageTimeoutHint: "0 = use the default (8 seconds)",
usageSupportedVars: "Supported variables",
usageBack: "Back",
usageNoKindsHint: "No auto-detected query type for this provider. Switch to the NewAPI template to configure manually, or add a custom query script (coming soon).",
usageSwitchToNewapi: "Switch to NewAPI template",
usageUnsupportedProvider: "This provider is not supported by auto query",
usageTier5h: "5-hour",
usageTierWeekly: "Weekly",
usageErrNoKey: "No API key configured",
usageErrDisabled: "Usage query is disabled in the config panel",
usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again",
usageErrNoOauth: "No Kimi Code OAuth credentials found; run `kimi login` first",
usageErrNewapiCreds: "The NewAPI template requires Access Token and User ID",
usageTestQuery: "Test query",
usageUpdatedAgo: "{time} ago",
usageMinAgo: "{n} min ago",
usageHourAgo: "{n} h ago",
usageDayAgo: "{n} d ago",
// Billing mode (subscription / pay-as-you-go)
billingTabSubscription: "Subscription",
+41
View File
@@ -367,6 +367,47 @@ export const zhTranslations = {
usageResetIn: "{time} 后重置",
usageResetDone: "已重置",
usageLoading: "查询中…",
usageQueryOk: "查询成功",
// Usage config panel (cc-switch style)
usageConfigTitle: "配置用量查询 - {name}",
usageConfigBtn: "配置用量查询",
usageEnable: "启用用量查询",
usageTemplate: "查询模板",
usageTemplateAuto: "自动检测",
usageTemplateNewapi: "NewAPI 中转站",
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
usageDetectedKinds: "检测到的查询类型",
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
usageBaseUrl: "查询地址 (Base URL)",
usageAccessToken: "Access Token",
usageAccessTokenPlaceholder: "网页后台生成的 Access Token",
usageAccessTokenHint: "在 NewAPI 站点网页后台「个人设置 → Access Token」生成,不是 sk- 开头的推理 Key",
usageUserId: "User ID",
usageUserIdPlaceholder: "NewAPI 用户 ID(数字)",
usageUserIdHint: "网页后台「个人中心」页面的用户 ID,用于 New-Api-User 请求头",
usageAutoInterval: "自动查询间隔(分钟)",
usageAutoIntervalHint: "0 表示不自动查询,仅在打开页面或手动刷新时查询",
usageTimeout: "超时时间(秒)",
usageTimeoutHint: "0 表示使用默认值(8 秒)",
usageSupportedVars: "支持的变量",
usageBack: "返回",
usageNoKindsHint: "未识别此供应商的自动查询类型,请切换到 NewAPI 模板手动配置,或添加自定义查询脚本(即将支持)",
usageSwitchToNewapi: "切换到 NewAPI 模板",
usageUnsupportedProvider: "此供应商暂不支持自动查询",
usageTier5h: "5小时",
usageTierWeekly: "7天",
usageErrNoKey: "未配置 API Key",
usageErrDisabled: "用量查询已在配置面板中停用",
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
usageErrNoOauth: "未找到 Kimi Code 登录凭据,请先运行 `kimi login`",
usageErrNewapiCreds: "NewAPI 模板需要填写 Access Token 和 User ID",
usageTestQuery: "测试查询",
usageUpdatedAgo: "{time}前",
usageMinAgo: "{n} 分钟前",
usageHourAgo: "{n} 小时前",
usageDayAgo: "{n} 天前",
// Billing mode (套餐 / 按量)
billingTabSubscription: "套餐",
+44
View File
@@ -0,0 +1,44 @@
import type { TranslationKey } from "../i18n/zh";
type TranslateFn = (
key: TranslationKey,
vars?: Record<string, string | number>
) => string;
/**
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
*
* Coverage note: ALL plan-type (套餐) queries flow through
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT — so this mapping covers every
* current and future plan provider automatically. If a new tier id is ever
* added there, add a case here too.
*/
export function planLabel(name: string, t: TranslateFn): string {
switch (name) {
case "five_hour":
return t("usageTier5h");
case "weekly_limit":
return t("usageTierWeekly");
default:
return name;
}
}
/**
* Map known Rust error strings to localized text. Unknown errors pass
* through unchanged (they are diagnostic details like HTTP status + body).
* Keep patterns aligned with the exact prefixes emitted in
* src-tauri/src/commands.rs and src-tauri/src/services/*.
*/
export function localizeUsageError(error: string, t: TranslateFn): string {
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
if (error.startsWith("no API key configured")) return t("usageErrNoKey");
if (error.startsWith("usage query disabled")) return t("usageErrDisabled");
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired");
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
if (error.startsWith("Authentication failed")) return t("usageInvalidKey");
return error;
}
+24
View File
@@ -34,6 +34,11 @@ export interface Provider {
* and persisted back to SQLite on save; never written to config.toml.
*/
usageKinds?: string[];
/**
* Usage query configuration edited via the "配置用量查询" panel.
* Same persistence as usageKinds (SQLite settings, never config.toml).
*/
usageConfig?: UsageConfig;
/** Extra agent-specific provider fields preserved across edits. */
raw_other?: unknown;
}
@@ -59,6 +64,25 @@ export interface Config {
raw_other?: unknown;
}
/**
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
* NewAPI/OneAPI gateway with accessToken + userId.
*/
export interface UsageConfig {
enabled: boolean;
templateType: "auto" | "newapi";
baseUrl?: string;
/** NewAPI web-console access token (NOT the sk- inference key). */
accessToken?: string;
/** NewAPI user id, sent as the New-Api-User header. */
userId?: string;
/** Auto query interval in minutes; 0/undefined = manual refresh only. */
autoQueryIntervalMinutes?: number;
/** Per-request timeout in seconds; 0/undefined = default (8s). */
timeoutSeconds?: number;
}
/** A model discovered from a provider's API endpoint. */
export interface DiscoveredModel {
id: string;