diff --git a/docs/HANDOVER-usage-billing.md b/docs/HANDOVER-usage-billing.md new file mode 100644 index 0000000..6cfe004 --- /dev/null +++ b/docs/HANDOVER-usage-billing.md @@ -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:`);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 `——`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`;`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) diff --git a/docs/USAGE-QUERY-ADAPTATION.md b/docs/USAGE-QUERY-ADAPTATION.md index 3306cf1..822404e 100644 --- a/docs/USAGE-QUERY-ADAPTATION.md +++ b/docs/USAGE-QUERY-ADAPTATION.md @@ -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:`,避免污染 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:`),不进 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/ diff --git a/package.json b/package.json index 525a60d..a3029d8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.6.3", + "version": "0.6.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e459fb9..c4476cc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 44448e3..841be4c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3c67e70..b3a118c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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::(&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 = 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 = provider .usage_kinds .as_ref() @@ -660,7 +721,7 @@ pub async fn query_provider_usage( let mut transient: Vec = Vec::new(); let mut any_success = false; for kind in kinds { - match services::query_kind(kind, &base_url, &api_key).await { + 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 { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 150f27d..a20f721 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -159,6 +159,7 @@ pub fn load_config(agent: &Agent) -> DbResult { .unwrap_or(Value::Null), // Merged from the settings table by load_agent_config_command. usage_kinds: None, + usage_config: None, }) })?; diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs index 7b75d23..097fccb 100644 --- a/src-tauri/src/kimi_code_io.rs +++ b/src-tauri/src/kimi_code_io.rs @@ -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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce1e1d1..2c763b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a910a9c..8697da2 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -96,9 +96,17 @@ pub struct Provider { /// `usage_kinds:`, NOT in the agent's config.toml — both /// export paths (kimi_code_io manual TOML, pi_io PiProvider struct) are /// explicit and never serialize this field, while the IPC payload to the - /// frontend does carry it. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// 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>, + /// Usage query configuration (template, credentials, auto interval), + /// edited via the "配置用量查询" panel. Same persistence strategy as + /// `usage_kinds`: SQLite settings `usage_config:`, never + /// config.toml; IPC carries it as camelCase. + #[serde(default, skip_serializing_if = "Option::is_none", rename = "usageConfig")] + pub usage_config: Option, } 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, + /// NewAPI web-console access token (NOT the sk- inference key). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_token: Option, + /// NewAPI user id, sent as the `New-Api-User` header. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + /// Auto query interval in minutes; 0/None = manual refresh only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_query_interval_minutes: Option, + /// Per-request timeout in seconds; 0/None = default (8s). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, +} + +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") diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs new file mode 100644 index 0000000..75e4d12 --- /dev/null +++ b/src-tauri/src/oauth.rs @@ -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, + pub expires_at: Option, + #[allow(dead_code)] + pub scope: Option, + #[allow(dead_code)] + pub token_type: Option, + #[allow(dead_code)] + pub expires_in: Option, +} + +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 { + 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, + expires_in: Option, + scope: Option, + token_type: Option, +} + +/// 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> = 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 { + 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 { + 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::(¤t) { + if !latest.is_expired() && latest.access_token != creds.access_token { + return Ok(latest.access_token); + } + } + + let merged = merge_token_response(¤t, &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"); + } +} diff --git a/src-tauri/src/pi_io.rs b/src-tauri/src/pi_io.rs index 2f75f85..2433583 100644 --- a/src-tauri/src/pi_io.rs +++ b/src-tauri/src/pi_io.rs @@ -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() { diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs index 61b3c2e..a39fa66 100644 --- a/src-tauri/src/services/balance.rs +++ b/src-tauri/src/services/balance.rs @@ -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 `;智谱套餐接口不加前缀(见 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 { 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 Result { - 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 { + 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 { // GET https://api.stepfun.com/v1/accounts // Response: { object, type, balance, total_cash_balance, total_voucher_balance } -pub async fn query_stepfun(api_key: &str) -> Result { - match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer).await? { +pub async fn query_stepfun(api_key: &str, timeout: Duration) -> Result { + 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 { // 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 { +pub async fn query_siliconflow(api_key: &str, is_cn: bool, timeout: Duration) -> Result { let domain = if is_cn { "api.siliconflow.cn" } else { "api.siliconflow.com" }; let url = format!("https://{domain}/v1/user/info"); - match get_json(&url, api_key, AuthStyle::Bearer).await? { + 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 Result { +pub async fn query_openrouter(api_key: &str, timeout: Duration) -> Result { 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 { // GET https://api.novita.ai/v3/user/balance // Response: { availableBalance, ... };金额单位 0.0001 USD,需 /10000。 -pub async fn query_novita(api_key: &str) -> Result { +pub async fn query_novita(api_key: &str, timeout: Duration) -> Result { 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 { }] } +// ── 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 { + 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, 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,非 sk- key) +// New-Api-User: +// 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::sync::OnceLock::new(); + +fn quota_cache() -> &'static std::sync::Mutex< + std::collections::HashMap, +> { + 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::(&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 { + 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, 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)); + } } diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 67f6418..fd00607 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -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 { // Response: { limits: [{ detail: { limit, remaining, resetTime } }], // usage: { limit, remaining, resetTime } } -pub async fn query_kimi_coding(api_key: &str) -> Result { +pub async fn query_kimi_coding(api_key: &str, timeout: Duration) -> Result { 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 { +pub async fn query_zhipu(base_url: &str, api_key: &str, timeout: Duration) -> Result { 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 Result { +pub async fn query_minimax(api_key: &str, is_cn: bool, timeout: Duration) -> Result { let domain = if is_cn { "api.minimaxi.com" } else { "api.minimax.io" }; let url = format!("https://{domain}/v1/api/openplatform/coding_plan/remains"); - match get_json(&url, api_key, AuthStyle::Bearer).await? { + 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, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 9eca9ad..837116a 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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 { 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 { /// 按 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 { 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()); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e047937..e355f30 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.6.3", + "version": "0.6.4", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 357a7fc..40b8c96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [showSettings, setShowSettings] = useState(false); const [showPresetPicker, setShowPresetPicker] = useState(false); + /** Provider whose usage-config panel is open; null = closed. */ + const [usageConfigProvider, setUsageConfigProvider] = useState(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() { @@ -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] && ( + setUsageConfigProvider(null)} + onSave={handleSaveUsageConfig} + /> + )} ); } diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index a763470..22bbe95 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -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>({}); + // 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({ ) : (
- {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` : "✕"} )} + {/* compact usage summary — sits left of the switch button, + mirroring cc-switch's card layout (usage → action buttons) */} + {(provider.usageKinds?.length ?? 0) > 0 && ( + + )} +
); diff --git a/src/components/UsageConfigModal.tsx b/src/components/UsageConfigModal.tsx new file mode 100644 index 0000000..ca609ec --- /dev/null +++ b/src/components/UsageConfigModal.tsx @@ -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; +} + +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(() => defaultConfig(provider)); + const [showToken, setShowToken] = useState(false); + const [showApiKey, setShowApiKey] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [testOk, setTestOk] = useState(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("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( +
+ {/* header */} +
+ +

+ {t("usageConfigTitle", { name: provider.name })} +

+
+ + {/* body */} +
+
+ {/* enable toggle */} +
+ {t("usageEnable")} + +
+ + {cfg.enabled && ( + <> + {/* template picker */} +
+ + {isManaged ? ( +
+ {t("usageOAuthManagedHint")} +
+ ) : ( + <> +
+ {TEMPLATES.map((tpl) => ( + + ))} +
+

+ {cfg.templateType === "newapi" + ? t("usageNewapiHint") + : t("usageAutoDetectHint")} +

+ + )} + + {/* supported variables (read-only, cc-switch 风格) */} +
+ {t("usageSupportedVars")} +
+ {"{{baseUrl}}"} + = + + {cfg.templateType === "newapi" + ? cfg.baseUrl || provider.base_url || "-" + : provider.base_url || "-"} + +
+
+ {"{{apiKey}}"} + = + + {provider.api_key + ? showApiKey + ? provider.api_key + : "••••••••" + : "-"} + + {provider.api_key && ( + + )} +
+
+ + {/* auto-detect: show resolved kinds as read-only chips */} + {!isManaged && cfg.templateType === "auto" && ( +
+ + {detectedKinds.length === 0 ? ( +
+

{t("usageNoKindsHint")}

+ +
+ ) : ( +
+ {detectedKinds.map((k) => ( + + {k} + + ))} +
+ )} +
+ )} + + {/* newapi creds */} + {!isManaged && cfg.templateType === "newapi" && ( +
+
+ + setCfg({ ...cfg, baseUrl: e.target.value || undefined })} + /> +
+
+ +
+ setCfg({ ...cfg, accessToken: e.target.value || undefined })} + /> + +
+

{t("usageAccessTokenHint")}

+
+
+ + setCfg({ ...cfg, userId: e.target.value || undefined })} + /> +

{t("usageUserIdHint")}

+
+
+ )} +
+ + {/* timeout + auto query interval */} +
+
+ + { + const n = Math.max(0, parseInt(e.target.value, 10) || 0); + setCfg({ ...cfg, timeoutSeconds: n }); + }} + /> +

{t("usageTimeoutHint")}

+
+
+ + { + const n = Math.max(0, parseInt(e.target.value, 10) || 0); + setCfg({ ...cfg, autoQueryIntervalMinutes: n }); + }} + /> +

{t("usageAutoIntervalHint")}

+
+
+ + )} + + {/* test result - sticky bottom inside scroll container so it's always visible above the footer */} +
+ {testResult && ( +
+
+
+ {testResult} +
+
+
+ )} +
+ + {/* footer */} +
+
+ + + +
+
+
, + document.body + ); +} diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index cac346e..4918e78 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -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(); @@ -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([]); /** undefined = no error; null = network error; string = Rust error text. */ const [error, setError] = useState(undefined); + const [updatedAt, setUpdatedAt] = useState(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 = ( ); + /** 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
; + } + if (status === "error" && data.length === 0) { + return ( + + + {error == null ? t("usageNetworkError") : localizeUsageError(error, t)} + + {refreshBtn} + + ); + } + const s = compactSummary(); + const ghost = status !== "success"; + const ago = formatAgo(updatedAt); + return ( + + {s && ( + <> + + {s.text} + + )} + {ago && {ago}} + {refreshBtn} + + ); + } + + // ── 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 (
@@ -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 (
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 && ( {resetText} )} - {i === data.length - 1 && refreshBtn}
); })} {data.length === 0 && (
— - {refreshBtn}
)}
diff --git a/src/config/providerPresets.ts b/src/config/providerPresets.ts index 9efe3f1..c04a76f 100644 --- a/src/config/providerPresets.ts +++ b/src/config/providerPresets.ts @@ -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 = new Set([ "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", diff --git a/src/hooks/useConfig.ts b/src/hooks/useConfig.ts index 7b53d13..099a048 100644 --- a/src/hooks/useConfig.ts +++ b/src/hooks/useConfig.ts @@ -8,8 +8,8 @@ interface UseConfigReturn { dirty: boolean; error: string | null; loading: boolean; - refresh: () => Promise; - save: () => Promise; + refresh: (opts?: { silent?: boolean }) => Promise; + save: (opts?: { silent?: boolean }) => Promise; 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("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]); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3a4fe38..4337a08 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -372,6 +372,47 @@ export const enTranslations: Record = { 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", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a80d6bc..84f2c48 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -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: "套餐", diff --git a/src/lib/usage-display.ts b/src/lib/usage-display.ts new file mode 100644 index 0000000..5909438 --- /dev/null +++ b/src/lib/usage-display.ts @@ -0,0 +1,44 @@ +import type { TranslationKey } from "../i18n/zh"; + +type TranslateFn = ( + key: TranslationKey, + vars?: Record +) => 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; +} diff --git a/src/types/index.ts b/src/types/index.ts index 9edf999..7add3ec 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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;