Compare commits
14
Commits
v0.6.0-rc1
...
v0.6.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6789a941 | ||
|
|
2464500ef5 | ||
|
|
f60305fbeb | ||
|
|
8d734a6507 | ||
|
|
0a36cd0ec4 | ||
|
|
6b867ff6fc | ||
|
|
c6da421d2f | ||
|
|
ace9842556 | ||
|
|
12dcb17771 | ||
|
|
02555daff4 | ||
|
|
b9ea6426d8 | ||
|
|
0889eeba24 | ||
|
|
f35924e15c | ||
|
|
bee981aefa |
No files matched your search
@@ -46,7 +46,10 @@ jobs:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- uses: actions/setup-rust@v1
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
cache: true
|
||||
|
||||
@@ -56,7 +59,6 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
build-essential \
|
||||
@@ -69,6 +71,8 @@ jobs:
|
||||
|
||||
- name: Build and upload release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: ${{ github.ref_name }}
|
||||
|
||||
@@ -14,3 +14,6 @@ Snipaste_*.png
|
||||
# 内部设计规范与实施计划,仅供本地查阅,不入版本库
|
||||
docs/superpowers/
|
||||
.release*
|
||||
|
||||
# 本地 models.dev 上次成功快照备份(不入库;src/lib/models-dev.json 本身即版本化兜底)
|
||||
src/lib/models-dev.last-good.json
|
||||
@@ -179,7 +179,7 @@
|
||||
- **`config.toml` 是 Kimi Code 的权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)。切换时只改 `default_model`,新增的供应商会被自动提升到列表最前,不会被覆盖
|
||||
- **SQLite 只存 Kimi Switch 专有元数据**:备注、官网、每个 Agent 记住的默认模型(`settings` 表)。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不在 `~/.kimi-switch` 下。`config.toml` 不完整时 SQLite 兜底
|
||||
- **`raw_other` 透传未知字段**:含 `[oauth]` 段,前后往返不丢字段
|
||||
- **models.dev 快照**:来源于 `https://models.dev/api.json`,本地 JSON 缓存 → `capabilitiesFromRef` 推导 `thinking/image_in/video_in/tool_use`,`getModelRef` 推导 `max_context_size/display_name`
|
||||
- **models.dev 快照**:来源于 `https://models.dev/api.json`,本地 JSON 缓存 → `capabilitiesFromRef` 推导 `thinking/image_in/video_in/tool_use`,`getModelRef` 推导 `max_context_size/display_name`;仪表盘成本计算以快照 `cost`($/M tokens)为准,缺失时回退内置 Kimi 价格表(`pretauri` 钩子保证打包前自动同步)
|
||||
- **启动版本与配置目录**:`KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` 覆盖 Kimi Code / Pi 的目录;Kimi Switch 自己的数据目录固定为 `~/.kimi-switch`,暂无环境变量覆盖(详见 [数据存储位置](#数据存储位置))
|
||||
|
||||
## 功能详情
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
13
|
||||
@@ -0,0 +1,125 @@
|
||||
# 工作交接:账单查询功能(2026-08-01)
|
||||
|
||||
> 新会话续作入口:先读本文件 + `docs/USAGE-QUERY-ADAPTATION.md`(调研文档),然后按「二、下一步」继续。
|
||||
|
||||
## 一、当前进度总览
|
||||
|
||||
**已提交并发布 v0.6.4**:
|
||||
- `ace9842` — feat: 新增部分套餐账单查询(v0.6.4)(26 文件 +1822/-60,含以下全部 6 个批次 + 版本号三处 0.6.4)
|
||||
- 已推送 origin(git.codingplan.site):分支 `Dev_20260801` + tag `v0.6.4`
|
||||
- 已推送 GitHub(billowliu2/KimiSwitch):tag `v0.6.4`(走 127.0.0.1:7897 代理;git 全局配置 `http.proxy` 指向它,代理关闭时 push 会报 schannel SSL 握手错误,可用 `git -c http.proxy= push` 绕过硬连 origin)
|
||||
- GitHub Actions Release workflow 已触发(三平台构建 → Release draft 自动上传 msi/dmg/AppImage/deb/rpm)
|
||||
- 本地 0.6.4 安装包:`src-tauri/target/release/bundle/{nsis,msi}/`
|
||||
|
||||
**已提交并发布 v0.6.5**:
|
||||
- `feat: 优化更新检查逻辑 + 修复按量余额显示(v0.6.5)`
|
||||
- 检查更新优先私有库(git.codingplan.site),8s 超时自动回退 GitHub
|
||||
- 设置-关于:合并移植声明为一句、新增 cc-switch 链接(openUrl + window.open 兜底)、移除仪表盘/会话页脚重复声明
|
||||
- 修复按量余额不显示:UsageFooter isPlan 判定增加配额字段(total/used)条件——余额条目(DeepSeek/StepFun/SiliconFlow/Novita/Kimi 开放平台)现在正确显示 `💰 ¥x.xx`,套餐类(有 total/used)不受影响。实测 DeepSeek 官方接口返回 200 + balance_infos
|
||||
- 两仓库 main + Dev_20260801 + tag v0.6.5 已同步
|
||||
|
||||
**历史提交**:`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)
|
||||
@@ -0,0 +1,343 @@
|
||||
# 账单查询(用量查询)适配调研报告
|
||||
|
||||
> 调研日期:2026-07-31
|
||||
> 调研范围:`D:\AIGC\cc-switch`(参考实现)、`D:\AIGC\KimiSwitch`(当前项目)、OpenCode(sst/opencode)订阅套餐定义、Kimi / 智谱官方用量查询 API
|
||||
> 状态:调研完成,未动代码
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论速览
|
||||
|
||||
- **KimiSwitch 的账单查询不是空白**:已移植 cc-switch 的「余额查询(Balance)+ 套餐查询(Coding Plan)」两大块,共 **8 种 `usageKinds`**,Rust 侧闭环 + `UsageFooter` 展示,错误通道、keep-last-good、缓存、`detect_provider` 自动识别等核心设计均与 cc-switch 对齐。
|
||||
- **OpenCode 本身不提供账单/额度查询**。它只负责"定义"供应商与订阅套餐的接入方式(provider id、base URL、认证、模型清单,数据源为 models.dev),"套餐还剩下多少"需要查各家官方 API——这正是 cc-switch(及 KimiSwitch 已移植部分)做的事。
|
||||
- **本次调研发现 2 个值得补的能力缺口**:
|
||||
1. **Kimi 开放平台余额查询**(`GET https://api.moonshot.cn/v1/users/me/balance`,Kimi 官方 2026-07 新增公开 API,cc-switch 尚未实现)→ **KimiSwitch 也缺**。
|
||||
2. **OpenCode Go 订阅套餐额度**(5h / 周 / 月 三窗口)→ 无公开查询 API,只有控制台,暂不可程序化。
|
||||
- 其余缺口(ZenMux / 火山方舟 / 智谱团队版 / JS 脚本引擎 / 官方 OAuth 订阅)为 cc-switch 独有能力,与"做好现有账单查询"的目标匹配度分层,见 §6 路线。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状盘点:KimiSwitch 已实现的账单查询
|
||||
|
||||
### 2.1 架构
|
||||
|
||||
```
|
||||
ProviderList (前端)
|
||||
└─ UsageFooter ── invoke("query_provider_usage", { agent, providerName, forceRefresh })
|
||||
└─ Rust commands.rs:590 ── services::query_kind(kind, base_url, api_key)
|
||||
├─ balance.rs (余额,5 家)
|
||||
└─ coding_plan.rs (套餐,3 家)
|
||||
```
|
||||
|
||||
- 前端**永不持有 API key、不直连 HTTP**;所有请求在 Rust 侧用 reqwest 完成(无浏览器 CORS 问题)。
|
||||
- `Provider.usageKinds`(前端 `src/config/providerPresets.ts` 预设字段)→ 保存时写入 SQLite settings `usage_kinds:<provider_name>`(`commands.rs:152`)→ 加载时 `merge_usage_kinds`(`commands.rs:33`),缺失时按 base_url host 自动 `detect_provider`。
|
||||
- TS/Rust 双端枚举防漂移:`SUPPORTED_USAGE_KINDS` 运行时断言(`providerPresets.ts:563`)检查预设的 `usageKinds` 都在 Rust `UsageKind` 覆盖内。
|
||||
|
||||
### 2.2 已有能力对照表
|
||||
|
||||
| UsageKind | 供应商 | 端点 | 认证 | 实现 |
|
||||
|---|---|---|---|---|
|
||||
| `balance:deepseek` | DeepSeek | `GET https://api.deepseek.com/user/balance` | Bearer | ✅ |
|
||||
| `balance:siliconflow` | SiliconFlow (.cn/.com) | `GET https://api.siliconflow.cn\|.com/v1/user/info` | Bearer | ✅ |
|
||||
| `balance:openrouter` | OpenRouter | `GET https://openrouter.ai/api/v1/credits` | Bearer | ✅ |
|
||||
| `balance:stepfun` | StepFun | `GET https://api.stepfun.com/v1/accounts` | Bearer | ✅ |
|
||||
| `balance:novita` | Novita AI | `GET https://api.novita.ai/v3/user/balance`(÷10000 转 USD) | Bearer | ✅ |
|
||||
| `plan:kimi_coding` | **Kimi For Coding** | `GET https://api.kimi.com/coding/v1/usages` | Bearer | ✅ |
|
||||
| `plan:zhipu` | **GLM Coding Plan**(bigmodel.cn / z.ai) | `GET {open.bigmodel.cn\|api.z.ai}/api/monitor/usage/quota/limit` | **Raw key(无 Bearer)** | ✅ |
|
||||
| `plan:minimax` | MiniMax Token Plan (.com/.io) | `GET https://api.minimaxi.com\|.io/v1/api/openplatform/coding_plan/remains` | Bearer | ✅ |
|
||||
|
||||
已有预设(`providerPresets.ts`):`kimi-coding`(`plan:kimi_coding`)、`zhipu-coding` / `zai-coding`(`plan:zhipu`)、`minimax` / `minimax-token-plan`(`plan:minimax`)、`deepseek` / `stepfun` / `siliconflow` / `novita` / `openrouter`(余额)。
|
||||
|
||||
### 2.3 统一返回契约(Rust → 前端)
|
||||
|
||||
`src-tauri/src/services/usage_types.rs`(camelCase serde):
|
||||
|
||||
```ts
|
||||
interface UsageData {
|
||||
planName?: string | null; // 套餐名:five_hour / weekly_limit
|
||||
remaining?: number | null; // 余额:金额(balance)或剩余百分比(plan)
|
||||
total?: number | null; // 总量(plan 恒为 100)
|
||||
used?: number | null; // 已用百分比 0-100(plan)
|
||||
unit?: string | null; // CNY / USD / %
|
||||
isValid?: boolean | null;
|
||||
resetsAt?: string | null; // ISO 8601 重置时间
|
||||
}
|
||||
interface UsageResult { success: boolean; data?: UsageData[] | null; error?: string | null; }
|
||||
```
|
||||
|
||||
错误通道语义(与 cc-switch 一致):`Err`(invoke reject)= 瞬时失败(网络/超时,前端重试 + 保留 last-good);`Ok(success:false)` = 确定性失败(无 key / 401 / 非 2xx / 坏 JSON,立即透出并清 last-good)。实现要点:**先 `bytes()` 再 `serde_json::from_slice`** 以区分读体失败(瞬时)与解析失败(确定性)。
|
||||
|
||||
---
|
||||
|
||||
## 3. OpenCode 如何"定义" Kimi / 智谱订阅套餐(重点)
|
||||
|
||||
### 3.1 OpenCode 的 provider 体系
|
||||
|
||||
OpenCode(sst/opencode)通过 `~/.config/opencode/opencode.json` 的 `provider` 段注册供应商,用 AI SDK + **Models.dev** 提供 75+ 提供商。一个"订阅套餐"在 OpenCode 里不是一个独立概念,而是**一组 provider 定义 + 模型引用**:
|
||||
|
||||
| provider id | 对应产品 | 默认 base URL(API 端点) | 说明 |
|
||||
|---|---|---|---|
|
||||
| `zhipuai` | **GLM Coding Plan**(订阅) | `https://open.bigmodel.cn/api/coding/paas/v4` | 套餐专属端点,与按量 PaaS 端点不同 |
|
||||
| `zai` | z.ai(按量) | `https://api.z.ai/api/paas/v4` | |
|
||||
| `kimi` | **Kimi For Coding**(订阅) | `https://api.kimi.com/coding/v1` | OpenAI 兼容;Anthropic 兼容为 `https://api.kimi.com/coding/` |
|
||||
| `moonshot` | Moonshot / Kimi 开放平台(按量) | `https://api.moonshot.ai/v1`(国际) | 国内站 `api.moonshot.cn/v1` |
|
||||
| `opencode-zen` | OpenCode Zen(充值余额) | `https://opencode.ai/zen/v1` | 按量 |
|
||||
| `opencode-go` | **OpenCode Go**($10/月订阅) | `https://opencode.ai/zen/go/v1` | 5h/周/月 额度 |
|
||||
|
||||
关键事实:
|
||||
- **套餐与按量是不同 provider id、不同 base URL**(`zhipuai` vs `zai`;`kimi` vs `moonshot`)。`/connect` 时按选择的 provider 存 key。
|
||||
- 认证均为 API Key(多数 `Authorization: Bearer`);智谱 Coding Plan 的额度查询**不含 Bearer**(见 §4.2)。
|
||||
- **模型清单来自 Models.dev**(OpenCode 编译内置),模型 id 形如 `zhipuai/glm-5.2`、`moonshotai/kimi-k2.7-code`;OpenCode Go 的模型走独立命名空间 `opencode-go/<model>`(如 `opencode-go/kimi-k3`),其额度不消耗各家套餐,只消耗 Go 的 5h/周/月窗口。
|
||||
|
||||
### 3.2 OpenCode Go 套餐额度(当前 opencode.ai 官方数据)
|
||||
|
||||
来源:[opencode.ai/docs/go](https://opencode.ai/docs/go/)
|
||||
|
||||
| 窗口 | 额度 |
|
||||
|---|---|
|
||||
| 5 小时 | $12 用量 |
|
||||
| 每周 | $30 用量 |
|
||||
| 每月 | $60 用量 |
|
||||
|
||||
额度按美元计费价值计算(不同模型折算请求数不同)。超出限额后可选"Use balance"回退到 Zen 余额。
|
||||
|
||||
**查询方式:仅控制台(console),无公开 REST API**。OpenCode 项目本身不做用量查询(无 usage/billing 命令),社区工具(如 cc-switch 的 `usage_script`)也无法覆盖 Go 套餐——这是目前的技术边界,适配时只能提示用户"网页控制台查看"或把 Go 归入"不可查询"类。
|
||||
|
||||
### 3.3 OpenCode 定义 → KimiSwitch 预设的对应关系
|
||||
|
||||
KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCode 一致):
|
||||
|
||||
| KimiSwitch 预设 | baseUrl | billingMode | usageKinds | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `kimi-coding` | `https://api.kimi.com/coding/v1` | subscription | `plan:kimi_coding` | ✅ 可查套餐 |
|
||||
| `zhipu-coding` | `https://open.bigmodel.cn/api/coding/paas/v4` | subscription | `plan:zhipu` | ✅ 可查套餐 |
|
||||
| `zai-coding` | `https://api.z.ai/api/coding/paas/v4` | subscription | `plan:zhipu` | ✅ 可查套餐 |
|
||||
| `opencode-go` | `https://opencode.ai/zen/go/v1` | subscription | **(空)** | ❌ 不可查,见 §4.4 |
|
||||
| `opencode-zen` | `https://opencode.ai/zen/v1` | pay_as_you_go | **(空)** | ❌ 不可查 |
|
||||
| `moonshot` | `https://api.moonshot.ai/v1` | pay_as_you_go | **(空)** | ❌ 未挂余额查询,见 §4.3 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 各订阅 / 余额查询 API 详细方案
|
||||
|
||||
### 4.1 Kimi For Coding 套餐(✅ 已实现,无需改动)
|
||||
|
||||
- **端点**:`GET https://api.kimi.com/coding/v1/usages`
|
||||
- **认证**:`Authorization: Bearer <kimi-code-api-key>`(Kimi Code 控制台签发,与开放平台 key 不互通)
|
||||
- **响应**(cc-switch 实测 + 社区验证):
|
||||
|
||||
```json
|
||||
{
|
||||
"limits": [
|
||||
{ "detail": { "limit": 100, "remaining": 40, "resetTime": 1754000000000 } }
|
||||
],
|
||||
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
|
||||
}
|
||||
```
|
||||
|
||||
- **解析**(`coding_plan.rs::parse_kimi_coding`):`limits[].detail` → 5 小时窗口(5h 滚动,`resetTime` 毫秒);`usage` → 周限额。`used = limit - remaining`,换算百分比。
|
||||
- 官方端点确认:Kimi Code 文档服务地址 `https://api.kimi.com/coding/v1`(OpenAI 协议),模型 ID `kimi-for-coding`。
|
||||
|
||||
### 4.2 GLM Coding Plan 套餐(✅ 已实现,无需改动)
|
||||
|
||||
- **端点**:`GET https://open.bigmodel.cn/api/monitor/usage/quota/limit`(国内)/ `GET https://api.z.ai/api/monitor/usage/quota/limit`(海外)
|
||||
- **认证**:`Authorization: <api-key>` —— **裸 key,不带 `Bearer` 前缀**(cc-switch 实测;智谱官方 `glm-plan-usage` 插件 `query-usage.mjs` 同样如此)
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"level": "pro",
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 35, "unit": 3, "nextResetTime": 1754000000000 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 80, "unit": 6, "nextResetTime": 1754500000000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **解析**(`coding_plan.rs::parse_zhipu`):只取 `type == "TOKENS_LIMIT"` 的条目;`unit: 3` → 5 小时窗口,`unit: 6` → 每周窗口(缺失时按 `nextResetTime` 升序启发式兜底);`percentage` 即已用百分比。
|
||||
- **套餐等级**(Lite / Pro 等)在 `data.level`,当前实现未透出(`UsageData` 无该字段,可后续扩展)。
|
||||
|
||||
### 4.3 Kimi 开放平台余额(❌ 未实现 —— 建议新增,优先级 P0)
|
||||
|
||||
**这是本次调研最重要的新发现**。Kimi 官方 2026-07 新增公开余额 API([platform.kimi.com/docs/api/balance](https://platform.kimi.com/docs/api/balance)),cc-switch 也尚未实现(issue [#4455](https://github.com/farion1231/cc-switch/issues/4455) 提议中)。
|
||||
|
||||
- **端点**:`GET https://api.moonshot.cn/v1/users/me/balance`
|
||||
- **认证**:`Authorization: Bearer <MOONSHOT_API_KEY>`
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"available_balance": 49.58894, // 可用余额 CNY(= 现金 + 代金券)
|
||||
"voucher_balance": 46.58893, // 代金券余额
|
||||
"cash_balance": 3.00001 // 现金余额(可为负,欠费)
|
||||
},
|
||||
"scode": "0x0",
|
||||
"status": true
|
||||
}
|
||||
```
|
||||
|
||||
- 业务错误:`code != 0` 或 `status == false` → 确定性失败。
|
||||
- **适配点**:`moonshot` 预设 baseUrl 为 `https://api.moonshot.ai/v1`(国际站),而该端点固定 `api.moonshot.cn`(国内站)。需按 base_url 消歧:host 含 `moonshot.cn` → 查 CN 端点;`moonshot.ai` → 查 `https://api.moonshot.ai/v1/users/me/balance`(国际站对应端点,待实测确认;官方文档仅给出 CN 示例)。
|
||||
- 新增 `balance:kimi` kind 即可复用现有 balance 路径,前端 `UsageFooter` 自动生效(余额形态显示 `💰 余额 ¥49.59`)。
|
||||
|
||||
### 4.4 OpenCode Go / Zen(❌ 未实现 —— 无公开 API,建议标注"不可查")
|
||||
|
||||
- **Go 套餐**:额度窗口见 §3.2,仅控制台可看;`opencode.ai/zen/go/v1` 只有推理端点(`/chat/completions`、`/responses`、`/models`),**无 usage/balance 端点**。
|
||||
- **Zen 余额**:充值制,同样仅控制台。
|
||||
- **可选替代(不推荐投入)**:通过调用推理端点时服务端返回的 402/429/额度错误头做被动感知,无法拿到数值,价值低。
|
||||
- **适配结论**:`opencode-go` / `opencode-zen` 预设维持 `usageKinds` 为空;可在预设 `note`/UI 上提示"额度请在 opencode.ai 控制台查看"。
|
||||
|
||||
### 4.5 其他 cc-switch 独有、KimiSwitch 未移植的能力(按目标匹配度排序)
|
||||
|
||||
| 能力 | cc-switch 实现 | KimiSwitch | 匹配度与建议 |
|
||||
|---|---|---|---|
|
||||
| 通用 JS 脚本引擎(custom/general/newapi) | `usage_script.rs`(rquickjs)+ `UsageScriptModal` | ❌ | 覆盖"任意中转站",工程量大(新增 rquickjs 依赖 + ~1000 行引擎);若"现有账单查询"只服务已知供应商则非必须。**若要支持 new-api/one-api 类中转站,可优先用声明式配置方案替代**(见 §6 P2) |
|
||||
| ZenMux 套餐 | `coding_plan.rs::query_zenmux`(base_url 即查询端点,Bearer) | ❌ | 简单可搬(~50 行 + 测试),P1 |
|
||||
| 火山方舟 Coding Plan | `coding_plan.rs::query_volcengine`(AK/SK 签名 V4 变体,控制面网关) | ❌ | 中等(~200 行签名逻辑,照搬注释坑位),P1 |
|
||||
| 智谱团队版 | `query_zhipu_team`(`bigmodel-organization`/`bigmodel-project` header) | ❌ | 需前端表单存组织/项目 ID + Rust 凭据扩展,P2 |
|
||||
| 官方订阅额度(Claude/Codex/Gemini/Grok OAuth) | `subscription.rs` / `subscription_grok.rs`(读 CLI 凭据文件) | ❌ | 与 KimiSwitch 目标 CLI(Kimi Code / Pi)不匹配,**不建议** |
|
||||
| GitHub Copilot / Codex-OAuth / xAI-OAuth 配额 | 自管 OAuth | ❌ | 同上,**不建议** |
|
||||
| 托盘用量摘要 + UsageCache 事件桥 | `tray.rs` + `usage_cache.rs` | ❌ | KimiSwitch 托盘无用量入口,低价值 |
|
||||
|
||||
### 4.6 盘点中新发现的「订阅预设但不可查」项
|
||||
|
||||
| 预设 | billingMode | 现状 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `stepfun-plan`(StepFun Plan) | subscription | `usageKinds` 为空 | StepFun Plan 走 `https://api.stepfun.com/step_plan/v1` 推理端点;**未发现公开的套餐用量查询 API**(cc-switch 也无)。`balance:stepfun` 查的是按量账户余额(`/v1/accounts`),两者不互通。标注"不可查"即可 |
|
||||
| `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`
|
||||
- 新增 `pub async fn query_kimi(api_key: &str, is_cn: bool) -> Result<UsageResult, String>`:
|
||||
- 端点:`https://api.moonshot.cn/v1/users/me/balance`(CN)/ `https://api.moonshot.ai/v1/users/me/balance`(EN,待实测)
|
||||
- 复用 `get_json(url, key, AuthStyle::Bearer)`;新增 `parse_kimi`:`code == 0 && status == true` 时取 `data.available_balance`,`unit: "CNY"`;`code != 0` 走确定性失败。
|
||||
- 单元测试:正常解析 / `code!=0` / 缺字段。
|
||||
2. **Rust** `src-tauri/src/services/mod.rs`
|
||||
- `UsageKind` 加 `BalanceKimi`;`as_str` → `"balance:kimi"`;`ALL` 变 9 项;`FromStr` 匹配;`query_kind` 路由按 host 消歧(`moonshot.cn` → CN,`moonshot.ai` → EN);`detect_provider` 加 `api.moonshot.cn` / `api.moonshot.ai` host 匹配 + 测试。
|
||||
3. **前端** `src/config/providerPresets.ts`
|
||||
- `UsageKind` union 与 `SUPPORTED_USAGE_KINDS` 各加 `"balance:kimi"`;`moonshot` 预设加 `usageKinds: ["balance:kimi"]`(防漂移断言会强制两侧同步)。
|
||||
4. **i18n**:无需新 key(`usageBalance` 等已存在)。
|
||||
|
||||
若同时做 P1 的 ZenMux / 火山,则除第 3 步外再加预设(`zenmux`、火山套餐基 URL 或 AK/SK 凭据存储)。注意:**火山 / 智谱团队需要 Provider 模型新增凭据字段**(AK/SK、组织/项目 ID),会触及 `models.rs`、`db.rs` 的 SQLite 表结构与前端 `ProviderEdit` 表单,改动面明显更大,建议单独排期。
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议路线
|
||||
|
||||
| 优先级 | 事项 | 改动面 | 工作量 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| **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 | 智谱团队版 | 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 遗漏盘点(首轮调研未覆盖,本次补查确认)
|
||||
|
||||
1. **代理支持缺失(建议 P1-P2)**:KimiSwitch 的 `balance.rs::get_json` 每次新建 `reqwest::Client`,**没有配置代理**;cc-switch 的 `crate::proxy::http_client::get()` 继承全局代理。用户若经代理访问海外站(SiliconFlow.com / z.ai / OpenRouter / Novita / api.moonshot.ai),账单查询会直连失败。适配时建议把 Client 构造提升为共用函数并支持代理(项目已依赖 reqwest,无新增依赖)。
|
||||
2. **无自动轮询(P3 可选)**:cc-switch 有 `autoQueryInterval` + react-query 轮询 + 托盘摘要;KimiSwitch 的 `UsageFooter` 仅在挂载时查询 + 手动刷新。低价值(窗口额度重置倒计时可手动刷新),不做不影响正确性。
|
||||
3. **智谱套餐等级未透出(P3 可选)**:`data.level`(Lite/Pro)已在响应中但 `UsageData` 无字段承接;如需展示需扩契约。
|
||||
4. **多 agent 已覆盖**:`UsageFooter` 挂在两个 agent(Kimi Code / Pi)的 `ProviderList`,cacheKey 含 agent,无遗漏。
|
||||
5. **相关文档衔接**:项目已有 `docs/PROPOSAL-presets-and-usage.md`、`docs/VERIFICATION-CHECKLIST.md` 随功能迭代更新;本文档为独立调研稿,实施时按项目约定把改动项回写上述文档。
|
||||
|
||||
> 说明:以上为调研结论与建议,尚未实现任何代码;若确认方向,可先落 P0。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与坑位备忘
|
||||
|
||||
1. **智谱裸 key**:`monitor/usage/quota/limit` 的 `Authorization` 不带 `Bearer`,且要带 `Content-Type: application/json`、`Accept-Language`(cc-switch 实测)。
|
||||
2. **Kimi 三套凭据不互通**:Kimi 开放平台(开放平台 key)、Kimi Code(`api.kimi.com/coding` 专属 key)、Kimi 会员是三个独立产品,余额/权益/key 均不通用(官方 FAQ 确认)——UI 文案需区分"开放平台余额"与"For Coding 套餐"。
|
||||
3. **base_url 消歧**:SiliconFlow `.cn/.com`、MiniMax `.com/.io`、智谱 `bigmodel.cn/z.ai`、Kimi 开放平台 `moonshot.cn/.ai` 均靠 host 子串区分;`detect_provider` 命中即注入,注意别把 `api.kimi.com`(非 `/coding` 路径)误判为套餐。
|
||||
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 代理改造)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 附:OpenCode 预设"获取 API Key"推荐链接检查(2026-07-31)
|
||||
|
||||
用户反馈:OpenCode 预设的"获取 API Key"链接没有走自己的推荐链接。排查结果如下:
|
||||
|
||||
| 预设 | 字段 | 当前值 | 是否带推荐码 |
|
||||
|---|---|---|---|
|
||||
| `opencode-go` | `websiteUrl` | `https://opencode.ai/go?ref=DFCNADQCEM` | ✅ 带 `ref=DFCNADQCEM` |
|
||||
| `opencode-go` | `apiKeyUrl` | `https://opencode.ai/go?ref=DFCNADQCEM` | ✅ 带 `ref=DFCNADQCEM` |
|
||||
| `opencode-zen` | `websiteUrl` | `https://opencode.ai/zen/` | ❌ **不带 ref** |
|
||||
| `opencode-zen` | `apiKeyUrl` | `https://opencode.ai/zen/` | ❌ **不带 ref** |
|
||||
|
||||
- 位置:`src/config/providerPresets.ts:417-458`(`opencode-go` / `opencode-zen` 两个预设)。
|
||||
- 渲染链路:`src/components/ProviderEdit.tsx:325` —— 编辑页"获取 API Key"按钮 = `preset?.apiKeyUrl ?? provider.official_url`;预设选择后即带出该 URL。
|
||||
- 历史:`ref=DFCNADQCEM` 于 v0.6.2(提交 `bee981a` "OpenCode Go/Zen 预设")加入 `opencode-go`;`opencode-zen` 自始未带 ref。
|
||||
- 结论:推荐链接确认为 `https://opencode.ai/go?ref=DFCNADQCEM`(用户确认)。`opencode-go` 预设两处均已带该 ref,无需改动;**`opencode-zen` 的 `apiKeyUrl` 已修复**为推荐链接(`websiteUrl` 保持 `https://opencode.ai/zen/` 官网展示不变),这样两个 OpenCode 预设的"获取 API Key"入口都走推荐链接。
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考资料
|
||||
|
||||
- Kimi 开放平台余额 API:https://platform.kimi.com/docs/api/balance
|
||||
- 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/
|
||||
- cc-switch 源码(MIT):`src-tauri/src/services/balance.rs`、`src-tauri/src/services/coding_plan.rs`、`src-tauri/src/usage_script.rs`
|
||||
- KimiSwitch 现有实现:`src-tauri/src/services/{mod,balance,coding_plan,usage_types}.rs`、`src/config/providerPresets.ts`、`src/components/UsageFooter.tsx`
|
||||
@@ -0,0 +1,144 @@
|
||||
# 用户自定义预设方案(User Presets)
|
||||
|
||||
## 动机
|
||||
|
||||
当前内置预设(`providerPresets.ts`)硬编码在源码中,用户无法自行添加自定义供应商预设或修改已有预设的 `apiKeyUrl`(获取 Key / 推广链接)。每次新增或修改都需要修改源码、重新编译、重新打包,缺乏灵活性。
|
||||
|
||||
## 目标
|
||||
|
||||
用户可通过外部配置文件**零代码**添加/修改预设供应商,包含:
|
||||
- 自定义供应商预设(名称、Base URL、API 格式、图标、套餐类型)
|
||||
- 自定义获取 API Key 入口 / 推广链接(`apiKeyUrl`,可直接放带 ref 的链接)
|
||||
- 自定义模型列表(模型 ID、显示名称、上下文大小、能力)
|
||||
- 覆盖内置预设(同一 `id` 时用户预设优先)
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 1. 配置文件位置
|
||||
|
||||
```
|
||||
Windows: %APPDATA%/KimiSwitch/user-presets.json
|
||||
macOS: ~/Library/Application Support/KimiSwitch/user-presets.json
|
||||
Linux: ~/.config/KimiSwitch/user-presets.json
|
||||
```
|
||||
|
||||
统一使用应用数据目录(与 `app_config_dir` 一致),不扫描项目根目录——发行版是编译后的二进制,终端用户没有"项目根目录"。
|
||||
|
||||
首次启动时若文件不存在,自动创建一个空 `[]` 模板文件,方便用户直接编辑。
|
||||
PresetPickerModal / 设置页提供一个"打开预设文件夹"按钮,让用户能找到文件位置。
|
||||
|
||||
### 2. 文件格式
|
||||
|
||||
> 支持 `//` 行注释(jsonc 风格):加载时先剥离 `//` 注释再 `JSON.parse`,方便用户写备注。
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
// 唯一标识,与内置预设 id 重名则覆盖内置预设
|
||||
"id": "my-custom-vendor",
|
||||
// 显示名称(fallback 英文)
|
||||
"name": "My Custom Vendor",
|
||||
// i18n key(可选,有则优先用 t(nameKey))
|
||||
"nameKey": "presetNameMyCustom",
|
||||
// 官网链接
|
||||
"websiteUrl": "https://example.com",
|
||||
// 获取 API Key 入口 / 推广链接(可直接放带 ref 的链接,如 ?ref=CODE)
|
||||
"apiKeyUrl": "https://example.com?ref=MYCODE",
|
||||
// 分类:official | cn_official | third_party | aggregator | custom
|
||||
"category": "third_party",
|
||||
// API 格式:openai | anthropic | google-genai | vertexai | kimi | openai_responses
|
||||
"providerType": "openai",
|
||||
// Base URL(null 表示使用默认)
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
// 图标 key(从 src/icons/extracted 中选,或空字符串用首字母)
|
||||
"icon": "",
|
||||
// 图标颜色(可选,hex 格式)
|
||||
"iconColor": "",
|
||||
// 计费模式:subscription | pay_as_you_go
|
||||
"billingMode": "pay_as_you_go",
|
||||
// 用量查询类型(可选,参考内置预设)
|
||||
"usageKinds": [],
|
||||
// 模型列表
|
||||
"models": [
|
||||
{
|
||||
"model": "my-model-1",
|
||||
"displayName": "My Model 1",
|
||||
"maxContextSize": 128000,
|
||||
"capabilities": ["thinking"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 加载逻辑(伪代码)
|
||||
|
||||
```
|
||||
function loadAllPresets(): ProviderPreset[] {
|
||||
const builtin = providerPresets; // 内置预设
|
||||
const user = readUserPresetsFile(); // 读取用户预设文件
|
||||
if (!user || user.length === 0) return builtin;
|
||||
|
||||
const merged = new Map<string, ProviderPreset>();
|
||||
for (const p of builtin) merged.set(p.id, p); // 内置先进
|
||||
for (const p of user) merged.set(p.id, p); // 用户覆盖
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 需要修改的代码文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `src/config/providerPresets.ts` | 导出 `loadAllPresets()` 函数替代直接使用 `providerPresets` 数组 |
|
||||
| `src/config/userPresets.ts`(新建) | 实现 `readUserPresetsFile()`:通过 Tauri 命令(Rust 侧 `read_text_file`)读取 + 剥离注释 + `JSON.parse` + 校验结构。用 Rust 命令而非前端 FS 插件,避免 scope 配置且跨平台路径由 Rust `app_config_dir` 统一解析 |
|
||||
| `src/components/PresetPickerModal.tsx` | 将 `providerPresets` 引用改为 `loadAllPresets()` |
|
||||
| `src/config/providerPresets.ts`(`findPresetForProvider`) | 同样需要从合并后的预设中查找 |
|
||||
|
||||
### 5. 校验逻辑
|
||||
|
||||
用户 JSON 文件可能写错,需要做基础校验:
|
||||
|
||||
```typescript
|
||||
interface UserPresetValidation {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
presets: ProviderPreset[];
|
||||
}
|
||||
|
||||
function validateUserPreset(raw: unknown): UserPresetValidation {
|
||||
// 1. 必须是数组
|
||||
// 2. 每项必须有 id、name、providerType、billingMode
|
||||
// 3. providerType 必须是合法值之一(openai | openai_responses | anthropic | google-genai | vertexai | kimi)
|
||||
// 4. billingMode 必须是 "subscription" | "pay_as_you_go"
|
||||
// 5. models 数组每项必须有 model 字段
|
||||
// 6. usageKinds 若提供,每项必须在 SUPPORTED_USAGE_KINDS 内,否则丢弃并警告
|
||||
// (Rust 侧运行时也会静默丢弃未知 kind,这里提前拦截给用户明确反馈)
|
||||
// 7. 忽略未知字段不报错(仅警告)
|
||||
// 8. 单条严重错误时跳过该条,不阻塞其他预设
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 用户交互
|
||||
|
||||
- PresetPickerModal 中用户预设顶部显示,与内置预设分栏展示(或加"自定义"标签)
|
||||
- 若 JSON 文件解析失败,界面上显示一个黄色警告条 + 错误详情
|
||||
- 提供一个"刷新用户预设"按钮,不必重启应用
|
||||
|
||||
### 7. 安全问题
|
||||
|
||||
- 不读取系统敏感路径,仅限于应用数据目录
|
||||
- 不执行 JSON 中的任何代码;`JSON.parse` 产出的是纯对象,无原型污染风险,校验只需关注字段类型与取值
|
||||
- 对 URL 字段(websiteUrl / apiKeyUrl)做基本 `http(s)://` 协议校验,`openUrl` 调用前过滤非法 scheme
|
||||
|
||||
### 8. 向后兼容
|
||||
|
||||
- 没有 `user-presets.json` 时行为完全不变
|
||||
- 用户预设只影响 PresetPickerModal 中的选项,不影响已保存的 Provider
|
||||
- 已保存的 Provider 不受用户预设删除影响
|
||||
|
||||
## 未解决的问题
|
||||
|
||||
1. **图标来源**:用户用内置图标集不存在的 key(含空字符串/null)时,统一降级为首字母显示。是否支持用户自定义 SVG 图标?—— 当前首字母降级足够,先不做。
|
||||
2. **热加载**:文件修改后是否需要自动检测并刷新?—— 初期手动刷新按钮即可,后续可加 `fs::watch`。
|
||||
3. **i18n**:用户预设的 `nameKey` 如果对应内置 i18n key 则显示翻译,否则 fallback 到 `name`。用户不能添加自定义 i18n key(涉及翻译文件修改,违背零代码原则)。
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,6 +10,7 @@
|
||||
"fetch-models-dev": "node scripts/fetch-models-dev.mjs",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"pretauri": "node scripts/fetch-models-dev.mjs || echo fetch-models-dev failed, using existing snapshot",
|
||||
"tauri-dev": "tauri dev",
|
||||
"tauri-build": "tauri build"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,11 @@ fi
|
||||
|
||||
# Strip leading 'v' for comparison (we expect semver without v)
|
||||
EXPECTED_BARE="${EXPECTED#v}"
|
||||
# Strip common prerelease suffixes (-rc1, -beta.2, -alpha.3, etc.) so a
|
||||
# v0.6.0-rc1 tag can reuse the existing 0.6.0 versions in the project files.
|
||||
# If the project does want a separate prerelease version, it must be
|
||||
# committed explicitly to the three version files.
|
||||
EXPECTED_BARE="${EXPECTED_BARE%%-*}"
|
||||
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
CARGO_VERSION=$(grep -E '^version\s*=' src-tauri/Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
|
||||
+194
-45
@@ -1,57 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetch the latest model reference data from models.dev and write a compact
|
||||
* snapshot to src/lib/models-dev.json.
|
||||
* Fetch the latest model reference data from models.dev and write:
|
||||
* 1. src/lib/models-dev.json — compact per-model snapshot (frontend)
|
||||
* 2. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
|
||||
* 3. src/lib/models-dev.last-good.json — local backup of the last success
|
||||
*
|
||||
* The data source is https://models.dev/api.json (176 providers; each model
|
||||
* carries `cost` = { input, output, cache_read?, cache_write? } in $/M tokens).
|
||||
* Note: https://models.dev/models.json does NOT include pricing — use api.json.
|
||||
*
|
||||
* Offline behaviour: when models.dev is unreachable, the script keeps using
|
||||
* the local snapshot (the committed src/lib/models-dev.json) so builds always
|
||||
* carry the last known prices. If that file is missing, it restores from the
|
||||
* last-good backup.
|
||||
*
|
||||
* Run manually (`npm run fetch-models-dev`) or automatically before builds
|
||||
* (`prebuild`). Requires network access to models.dev.
|
||||
* (`prebuild` / `pretauri`). Requires network access to models.dev.
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SOURCE_URL = "https://models.dev/models.json";
|
||||
// ── proxy bootstrap ─────────────────────────────────────────────────────────
|
||||
// Node's fetch only honors HTTP(S)_PROXY when NODE_USE_ENV_PROXY=1 is set at
|
||||
// startup. On proxied networks (e.g. git http.proxy), re-exec ourselves with
|
||||
// the flag so `npm run fetch-models-dev` works without manual env.
|
||||
const proxyEnv =
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy;
|
||||
let proxy = proxyEnv;
|
||||
if (!proxy) {
|
||||
try {
|
||||
const g = spawnSync("git", ["config", "--get", "http.proxy"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (g.status === 0 && g.stdout.trim()) proxy = g.stdout.trim();
|
||||
} catch {
|
||||
/* no git / no proxy — direct connection */
|
||||
}
|
||||
}
|
||||
if (proxy && process.env.NODE_USE_ENV_PROXY !== "1") {
|
||||
const r = spawnSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(import.meta.url), ...process.argv.slice(2)],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
HTTPS_PROXY: proxy,
|
||||
HTTP_PROXY: proxy,
|
||||
},
|
||||
},
|
||||
);
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
|
||||
const SOURCE_URL = "https://models.dev/api.json";
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const OUTPUT = join(ROOT, "src", "lib", "models-dev.json");
|
||||
const SNAPSHOT = join(ROOT, "src", "lib", "models-dev.json");
|
||||
const FULL = join(ROOT, "src", "lib", "models-dev-full.json");
|
||||
// Local backup of the last successful snapshot. Not committed to git (see
|
||||
// .gitignore); the committed models-dev.json itself is the versioned fallback.
|
||||
const BACKUP = join(ROOT, "src", "lib", "models-dev.last-good.json");
|
||||
|
||||
const res = await fetch(SOURCE_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
/** Keep only the numeric cost fields the app bills with. */
|
||||
function pickCost(cost) {
|
||||
if (!cost || typeof cost !== "object") return undefined;
|
||||
const out = {};
|
||||
for (const k of ["input", "output", "cache_read", "cache_write"]) {
|
||||
if (typeof cost[k] === "number") out[k] = cost[k];
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const res = await fetch(SOURCE_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
const raw = await res.json();
|
||||
|
||||
// ── snapshot: "<provider>/<model>" → compact fields (frontend) ────────────
|
||||
// Keeps the shape callers already consume (getModelRef / capabilitiesFromRef)
|
||||
// and adds `cost`. A single `last_updated` key is appended for freshness.
|
||||
const snapshot = { last_updated: new Date().toISOString().slice(0, 10) };
|
||||
|
||||
// ── full list: provider → { name, models: [...] } incl. pricing ───────────
|
||||
// Human-readable complete inventory for price benchmarking / debugging.
|
||||
const full = { last_updated: snapshot.last_updated, providers: {} };
|
||||
|
||||
let modelCount = 0;
|
||||
for (const [providerId, provider] of Object.entries(raw)) {
|
||||
if (!provider || typeof provider !== "object") continue;
|
||||
const models = provider.models;
|
||||
if (!models || typeof models !== "object") continue;
|
||||
|
||||
const providerEntry = {
|
||||
id: providerId,
|
||||
name: typeof provider.name === "string" ? provider.name : providerId,
|
||||
models: [],
|
||||
};
|
||||
|
||||
for (const [modelId, m] of Object.entries(models)) {
|
||||
if (!m || typeof m !== "object") continue;
|
||||
const key = `${providerId}/${modelId}`;
|
||||
|
||||
// snapshot entry
|
||||
const entry = {};
|
||||
if (typeof m.name === "string") entry.name = m.name;
|
||||
// context 0 means "not applicable" (image/audio models) — treat as missing
|
||||
// so callers fall back to regex defaults instead of storing 0.
|
||||
if (typeof m.limit?.context === "number" && m.limit.context > 0) {
|
||||
entry.context = m.limit.context;
|
||||
}
|
||||
if (m.reasoning === true) entry.reasoning = true;
|
||||
if (m.tool_call === true) entry.tool_call = true;
|
||||
if (m.structured_output === true) entry.structured_output = true;
|
||||
const input = m.modalities?.input;
|
||||
if (Array.isArray(input)) {
|
||||
if (input.includes("image")) entry.image = true;
|
||||
if (input.includes("video")) entry.video = true;
|
||||
}
|
||||
const cost = pickCost(m.cost);
|
||||
if (cost) entry.cost = cost;
|
||||
snapshot[key] = entry;
|
||||
|
||||
// full-list entry
|
||||
providerEntry.models.push({
|
||||
id: modelId,
|
||||
name: typeof m.name === "string" ? m.name : modelId,
|
||||
...(cost ? { cost } : {}),
|
||||
...(typeof m.limit?.context === "number" && m.limit.context > 0
|
||||
? { context: m.limit.context }
|
||||
: {}),
|
||||
...(typeof m.limit?.input === "number" && m.limit.input > 0
|
||||
? { input_limit: m.limit.input }
|
||||
: {}),
|
||||
...(typeof m.limit?.output === "number" && m.limit.output > 0
|
||||
? { output_limit: m.limit.output }
|
||||
: {}),
|
||||
...(m.reasoning === true ? { reasoning: true } : {}),
|
||||
...(m.tool_call === true ? { tool_call: true } : {}),
|
||||
...(m.structured_output === true ? { structured_output: true } : {}),
|
||||
...(Array.isArray(input) && input.includes("image")
|
||||
? { image: true }
|
||||
: {}),
|
||||
...(Array.isArray(input) && input.includes("video")
|
||||
? { video: true }
|
||||
: {}),
|
||||
});
|
||||
modelCount += 1;
|
||||
}
|
||||
|
||||
if (providerEntry.models.length > 0) {
|
||||
full.providers[providerId] = providerEntry;
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(dirname(SNAPSHOT), { recursive: true });
|
||||
const json = JSON.stringify(snapshot, null, 2) + "\n";
|
||||
await writeFile(SNAPSHOT, json, "utf8");
|
||||
await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8");
|
||||
await writeFile(BACKUP, json, "utf8");
|
||||
|
||||
console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`);
|
||||
console.log(
|
||||
`models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
// Offline fallback: builds must keep using the last known prices.
|
||||
try {
|
||||
const existing = await readFile(SNAPSHOT, "utf8");
|
||||
const stamp = JSON.parse(existing).last_updated ?? "unknown";
|
||||
console.warn(
|
||||
`[fetch-models-dev] models.dev unreachable (${err.message}); ` +
|
||||
`using local snapshot (last synced ${stamp})`,
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
const backup = await readFile(BACKUP, "utf8");
|
||||
await writeFile(SNAPSHOT, backup, "utf8");
|
||||
console.warn(
|
||||
"[fetch-models-dev] models.dev unreachable; restored models-dev.json from last-good backup",
|
||||
);
|
||||
} catch {
|
||||
console.error(
|
||||
"[fetch-models-dev] models.dev unreachable and no local snapshot found; " +
|
||||
"run online once to generate src/lib/models-dev.json",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET ${SOURCE_URL} returned HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
const raw = await res.json();
|
||||
|
||||
// Keep only the fields the app needs; the raw file carries benchmarks,
|
||||
// descriptions, etc. that would bloat the bundle.
|
||||
const snapshot = {};
|
||||
for (const [key, m] of Object.entries(raw)) {
|
||||
if (!m || typeof m !== "object") continue;
|
||||
const entry = {};
|
||||
if (typeof m.name === "string") entry.name = m.name;
|
||||
// context 0 means "not applicable" (image/audio models) — treat as missing
|
||||
// so callers fall back to regex defaults instead of storing 0.
|
||||
if (typeof m.limit?.context === "number" && m.limit.context > 0) {
|
||||
entry.context = m.limit.context;
|
||||
}
|
||||
if (m.reasoning === true) entry.reasoning = true;
|
||||
if (m.tool_call === true) entry.tool_call = true;
|
||||
if (m.structured_output === true) entry.structured_output = true;
|
||||
const input = m.modalities?.input;
|
||||
if (Array.isArray(input)) {
|
||||
if (input.includes("image")) entry.image = true;
|
||||
if (input.includes("video")) entry.video = true;
|
||||
}
|
||||
snapshot[key] = entry;
|
||||
}
|
||||
|
||||
await mkdir(dirname(OUTPUT), { recursive: true });
|
||||
await writeFile(OUTPUT, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
||||
|
||||
console.log(
|
||||
`models-dev snapshot: ${Object.keys(snapshot).length} models -> ${OUTPUT}`,
|
||||
);
|
||||
Generated
+2
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.0"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1975,6 +1975,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.0"
|
||||
version = "0.6.6"
|
||||
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"
|
||||
|
||||
+118
-26
@@ -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 {
|
||||
@@ -740,25 +801,55 @@ fn version_lt(current: &str, latest: &str) -> bool {
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update() -> Result<UpdateInfo, String> {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
// GitHub Releases API for billowliu2/KimiSwitch. Historical versions
|
||||
// (v0.5.x and earlier) live on git.codingplan.site and are not exposed
|
||||
// here — only v0.6.0+ is published via GitHub.
|
||||
let url = "https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1";
|
||||
// Prefer the private Gitea repo (primary release channel, all versions
|
||||
// since v0.1.0). Fall back to GitHub Releases when it is unreachable
|
||||
// (e.g. off the LAN/VPN) — v0.6.0+ is mirrored there.
|
||||
let sources = [
|
||||
"https://git.codingplan.site/api/v1/repos/admin/KimiCodeSwitch/releases?limit=1",
|
||||
"https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1",
|
||||
];
|
||||
|
||||
// GitHub returns 403 without a User-Agent header. Bundle the app version
|
||||
// so the source is identifiable in any rate-limit / abuse reports.
|
||||
// Both hosts require a User-Agent header (GitHub returns 403 without one).
|
||||
// Bundle the app version so the source is identifiable in any rate-limit
|
||||
// / abuse reports. A short timeout keeps the fallback responsive when the
|
||||
// private server is unreachable.
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.map_err(|e| format!("client build failed: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
for url in sources {
|
||||
match fetch_latest_release(&client, url).await {
|
||||
Ok((latest, release_url, download_url)) => {
|
||||
let update_available = version_lt(¤t, &latest);
|
||||
return Ok(UpdateInfo {
|
||||
current,
|
||||
latest,
|
||||
update_available,
|
||||
release_url,
|
||||
download_url,
|
||||
});
|
||||
}
|
||||
Err(e) => errors.push(format!("{url}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("all update sources failed: {}", errors.join("; ")))
|
||||
}
|
||||
|
||||
/// GET a releases API (Gitea or GitHub, same array shape) and extract the
|
||||
/// latest tag, release page URL and the installer asset for the current OS.
|
||||
async fn fetch_latest_release(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<(String, String, Option<String>), String> {
|
||||
let releases: serde_json::Value = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
|
||||
let releases: serde_json::Value = resp
|
||||
.map_err(|e| format!("request failed: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))?;
|
||||
@@ -785,15 +876,7 @@ pub async fn check_for_update() -> Result<UpdateInfo, String> {
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|a| pick_asset_for_current_os(a));
|
||||
|
||||
let update_available = version_lt(¤t, &latest);
|
||||
|
||||
Ok(UpdateInfo {
|
||||
current,
|
||||
latest,
|
||||
update_available,
|
||||
release_url,
|
||||
download_url,
|
||||
})
|
||||
Ok((latest, release_url, download_url))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -866,8 +949,6 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
|
||||
.open_path(&path, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Pick the GitHub release asset matching the given OS by file extension.
|
||||
/// Preference for Linux is AppImage (portable, no install). If the preferred
|
||||
/// extension is not present, returns None so the UI can fall back to
|
||||
/// "open release page" instead of guessing a less-preferred format.
|
||||
@@ -964,4 +1045,15 @@ mod asset_picker_tests {
|
||||
let url = pick_asset_for_os(&assets, "freebsd");
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an external https/http URL in the system default browser.
|
||||
/// Uses the Rust opener directly (bypassing the JS plugin scope) so it
|
||||
/// works regardless of capability scope configuration.
|
||||
#[tauri::command]
|
||||
pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_url(url, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
+180
-2
@@ -5,7 +5,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -479,11 +479,118 @@ fn list_prices() -> Vec<PriceRow> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Per-model price from the models.dev snapshot (all values in $/M tokens).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ModelsDevCost {
|
||||
input: f64,
|
||||
output: f64,
|
||||
cache_read: f64,
|
||||
/// None = models.dev has no cache_write field → caller falls back to input.
|
||||
cache_write: Option<f64>,
|
||||
}
|
||||
|
||||
/// Compiled-in models.dev snapshot (`src/lib/models-dev.json`, generated by
|
||||
/// scripts/fetch-models-dev.mjs before every build via the `pretauri` hook).
|
||||
/// Keyed by "<provider>/<model>", lowercased.
|
||||
const MODELS_DEV_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
|
||||
|
||||
fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
|
||||
static INDEX: OnceLock<HashMap<String, ModelsDevCost>> = OnceLock::new();
|
||||
INDEX.get_or_init(|| {
|
||||
let mut map = HashMap::new();
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(MODELS_DEV_SNAPSHOT) else {
|
||||
return map;
|
||||
};
|
||||
let Some(obj) = v.as_object() else {
|
||||
return map;
|
||||
};
|
||||
for (key, entry) in obj {
|
||||
// Skip the "last_updated" metadata key and entries without cost.
|
||||
if !entry.is_object() || entry.get("cost").is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(cost) = entry.get("cost").and_then(|c| c.as_object()) else {
|
||||
continue;
|
||||
};
|
||||
let num = |k: &str| cost.get(k).and_then(|x| x.as_f64());
|
||||
let (Some(input), Some(output)) = (num("input"), num("output")) else {
|
||||
continue;
|
||||
};
|
||||
map.insert(
|
||||
key.to_ascii_lowercase(),
|
||||
ModelsDevCost {
|
||||
input,
|
||||
output,
|
||||
cache_read: num("cache_read").unwrap_or(0.0),
|
||||
cache_write: num("cache_write"),
|
||||
},
|
||||
);
|
||||
}
|
||||
map
|
||||
})
|
||||
}
|
||||
|
||||
/// Official providers take precedence over resellers when the same model id
|
||||
/// ships under multiple providers and no provider prefix disambiguates.
|
||||
const OFFICIAL_PROVIDERS: &[&str] = &[
|
||||
"openai", "anthropic", "google", "deepseek", "moonshotai", "zhipuai",
|
||||
"minimax", "x-ai", "meta", "mistral", "qwen", "doubao", "volcengine",
|
||||
"baidu", "tencent", "nvidia",
|
||||
];
|
||||
|
||||
fn provider_rank(key: &str) -> usize {
|
||||
// key = "<provider>/<model>"
|
||||
let provider = key.split('/').next().unwrap_or("");
|
||||
OFFICIAL_PROVIDERS
|
||||
.iter()
|
||||
.position(|p| *p == provider)
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Look up a model's price in the models.dev snapshot. Resolution order:
|
||||
/// 1. exact key match (e.g. "moonshotai/kimi-k2.5" passed verbatim),
|
||||
/// 2. suffix match on the bare model id, preferring an exact provider prefix,
|
||||
/// then an official provider, then lexicographically smallest key
|
||||
/// (deterministic tie-break). Returns None when nothing matches.
|
||||
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
||||
let lower = model_name.to_ascii_lowercase();
|
||||
if let Some(cost) = models_dev_cost_index().get(&lower) {
|
||||
return Some((lower, *cost));
|
||||
}
|
||||
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
||||
let bare_l = bare.to_ascii_lowercase();
|
||||
// Match on the model part after the last '/', not the full key: aliases
|
||||
// strip the family prefix (record "k2.5" vs models.dev "kimi-k2.5").
|
||||
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
key.rsplit_once('/')
|
||||
.map(|(_, model)| model.ends_with(&bare_l))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
matches.sort_by(|(a, _), (b, _)| {
|
||||
provider_rank(a)
|
||||
.cmp(&provider_rank(b))
|
||||
.then_with(|| a.cmp(b))
|
||||
});
|
||||
let (key, cost) = matches[0];
|
||||
Some((key.clone(), *cost))
|
||||
}
|
||||
|
||||
fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||
let bare = match model_name.rsplit_once('/') {
|
||||
Some((_, b)) => b,
|
||||
None => model_name,
|
||||
};
|
||||
// 1) models.dev snapshot first (authoritative, all providers).
|
||||
if let Some((id, c)) = models_dev_lookup(model_name) {
|
||||
return (id, c.cache_read, c.input, c.output, false);
|
||||
}
|
||||
// 2) legacy Kimi table (kept as a fallback for names not in models.dev).
|
||||
let bare_l = bare.to_ascii_lowercase();
|
||||
for p in &list_prices() {
|
||||
let id_l = p.id.to_ascii_lowercase();
|
||||
@@ -491,14 +598,20 @@ fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||
return (p.id.clone(), p.cache_hit, p.input, p.output, false);
|
||||
}
|
||||
}
|
||||
// 3) last-resort estimate.
|
||||
("kimi-k2.6".into(), 0.16, 0.95, 4.00, true)
|
||||
}
|
||||
|
||||
fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create: u64, model: &str) -> (f64, bool) {
|
||||
let (_price_id, cache_hit, input_price, output_price, est) = match_price(model);
|
||||
// models.dev reports a separate cache_write price; fall back to input when
|
||||
// absent (models without a cache_write field bill cache creation at input).
|
||||
let cache_write_price = models_dev_lookup(model)
|
||||
.and_then(|(_, c)| c.cache_write)
|
||||
.unwrap_or(input_price);
|
||||
let cost = (input_other as f64 / 1e6) * input_price
|
||||
+ (cache_read as f64 / 1e6) * cache_hit
|
||||
+ (cache_create as f64 / 1e6) * input_price
|
||||
+ (cache_create as f64 / 1e6) * cache_write_price
|
||||
+ (output as f64 / 1e6) * output_price;
|
||||
(cost, est)
|
||||
}
|
||||
@@ -1417,3 +1530,68 @@ pub fn get_session_preview(home_override: Option<String>, workspace_id: String,
|
||||
let home = resolve_kimi_home(home_override);
|
||||
get_session_preview_cmd(&home, &workspace_id, &session_id, status.as_deref())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn models_dev_snapshot_has_pricing() {
|
||||
let idx = models_dev_cost_index();
|
||||
// The compiled-in snapshot must carry real models.dev prices.
|
||||
assert!(idx.len() > 1000, "expected >1000 priced models, got {}", idx.len());
|
||||
let kimi = idx.get("moonshotai/kimi-k2.5").copied().expect("kimi-k2.5 present");
|
||||
assert_eq!(kimi.input, 0.6);
|
||||
assert_eq!(kimi.output, 3.0);
|
||||
assert_eq!(kimi.cache_read, 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_price_prefers_models_dev() {
|
||||
// Bare ids and prefixed ids both resolve via the suffix index.
|
||||
let (id, ch, input, output, est) = match_price("glm-4.6");
|
||||
assert!(!est);
|
||||
assert_eq!(id, "zhipuai/glm-4.6");
|
||||
assert_eq!(input, 0.6);
|
||||
assert_eq!(output, 2.2);
|
||||
|
||||
let (id, _, _, _, est) = match_price("kimi/k2.5");
|
||||
assert!(!est);
|
||||
assert_eq!(id, "moonshotai/kimi-k2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_price_falls_back_to_legacy_table() {
|
||||
// kimi-k3 resolves via models.dev (moonshotai); a made-up id hits the
|
||||
// last-resort estimate.
|
||||
let (id, _, _, _, est) = match_price("kimi-k3");
|
||||
assert!(!est);
|
||||
assert_eq!(id, "moonshotai/kimi-k3");
|
||||
|
||||
let (_, _, _, _, est) = match_price("totally-unknown-model");
|
||||
assert!(est, "unknown models must be flagged as estimated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_ids_prefer_official_provider() {
|
||||
// glm-4.6 ships under resellers too (e.g. 302ai); the official zhipuai
|
||||
// entry must win.
|
||||
let (id, _, input, _, _) = match_price("glm-4.6");
|
||||
assert_eq!(id, "zhipuai/glm-4.6");
|
||||
assert_eq!(input, 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_write_price_preferred_when_present() {
|
||||
// glm-4.6 has cache_write: 0 → cache creation billed at 0, not input.
|
||||
let (_id, ch, input, _output, _est) = match_price("glm-4.6");
|
||||
let cw = models_dev_lookup("glm-4.6").and_then(|(_, c)| c.cache_write);
|
||||
assert_eq!(cw, Some(0.0));
|
||||
// 1M input tokens + 1M cache-creation tokens, no output: cache_write 0
|
||||
// → total is just the input price.
|
||||
let cost = cost_for_usage(1_000_000, 0, 0, 1_000_000, "glm-4.6");
|
||||
assert_eq!(cost.0, input);
|
||||
assert_eq!(cost.1, false);
|
||||
assert_eq!(ch, 0.11);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -104,6 +105,7 @@ pub fn run() {
|
||||
commands::check_for_update,
|
||||
commands::download_update,
|
||||
commands::open_installer,
|
||||
commands::open_external_url,
|
||||
dashboard::get_paths,
|
||||
dashboard::get_prices,
|
||||
dashboard::get_summary,
|
||||
|
||||
+46
-2
@@ -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")
|
||||
|
||||
@@ -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>(¤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");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.6",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["msi", "nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+39
-2
@@ -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 ? (
|
||||
@@ -703,6 +725,11 @@ export default function App() {
|
||||
updateConfig((cfg) => {
|
||||
const updated = { ...cfg.models };
|
||||
for (const m of models) {
|
||||
// Skip if this model id already exists for the same provider
|
||||
const dup = Object.values(updated).some(
|
||||
(mm) => mm.provider === m.provider && mm.model === m.model && m.model !== ""
|
||||
);
|
||||
if (dup) continue;
|
||||
let alias = m.alias;
|
||||
let n = 1;
|
||||
while (alias in updated) {
|
||||
@@ -797,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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets";
|
||||
import {
|
||||
providerPresets,
|
||||
type BillingMode,
|
||||
type PresetCategory,
|
||||
type ProviderPreset,
|
||||
} from "../config/providerPresets";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
interface PresetPickerModalProps {
|
||||
@@ -26,12 +31,26 @@ const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey>
|
||||
aggregator: "presetCategoryAggregator",
|
||||
};
|
||||
|
||||
const BILLING_BADGE: Record<BillingMode, string> = {
|
||||
subscription: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400",
|
||||
pay_as_you_go: "bg-slate-200 text-slate-600 dark:bg-slate-500/15 dark:text-slate-400",
|
||||
};
|
||||
|
||||
const BILLING_LABEL: Record<BillingMode, TranslationKey> = {
|
||||
subscription: "billingBadgeSubscription",
|
||||
pay_as_you_go: "billingBadgePayAsYouGo",
|
||||
};
|
||||
|
||||
type SortMode = "original" | "alpha";
|
||||
type BillingTab = "all" | BillingMode;
|
||||
|
||||
const BILLING_TAB_ORDER: BillingTab[] = ["subscription", "pay_as_you_go", "all"];
|
||||
|
||||
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortMode>("original");
|
||||
const [billingTab, setBillingTab] = useState<BillingTab>("all");
|
||||
|
||||
// Esc to close
|
||||
useEffect(() => {
|
||||
@@ -58,15 +77,31 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setSort("original");
|
||||
setBillingTab("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Counts per billing tab — shown in the tab labels so users see how many
|
||||
// presets are in each bucket without having to click through.
|
||||
const billingCounts = useMemo(() => {
|
||||
let sub = 0;
|
||||
let payg = 0;
|
||||
for (const p of providerPresets) {
|
||||
if (p.billingMode === "subscription") sub++;
|
||||
else payg++;
|
||||
}
|
||||
return { subscription: sub, pay_as_you_go: payg, all: sub + payg };
|
||||
}, []);
|
||||
|
||||
const presetName = (p: ProviderPreset) =>
|
||||
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
let list = providerPresets;
|
||||
if (billingTab !== "all") {
|
||||
list = list.filter((p) => p.billingMode === billingTab);
|
||||
}
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
|
||||
@@ -77,7 +112,7 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
}
|
||||
return list;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, sort, t]);
|
||||
}, [query, sort, billingTab, t]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -110,39 +145,67 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + sort */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("presetSearchPlaceholder")}
|
||||
autoFocus
|
||||
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex items-center rounded border border-border bg-input p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("original")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "original"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortOriginal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("alpha")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "alpha"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortAlpha")}
|
||||
</button>
|
||||
{/* Billing-mode tabs + search + sort */}
|
||||
<div className="flex flex-col gap-2 border-b border-border px-5 py-3">
|
||||
<div className="flex items-center bg-input border border-border rounded p-0.5 self-start">
|
||||
{BILLING_TAB_ORDER.map((tab) => {
|
||||
const labelKey: TranslationKey =
|
||||
tab === "all"
|
||||
? "billingTabAll"
|
||||
: tab === "subscription"
|
||||
? "billingTabSubscription"
|
||||
: "billingTabPayAsYouGo";
|
||||
const count = billingCounts[tab];
|
||||
const active = billingTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setBillingTab(tab)}
|
||||
className={`px-2.5 py-1 text-xs rounded transition-colors ${
|
||||
active
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t(labelKey)} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("presetSearchPlaceholder")}
|
||||
autoFocus
|
||||
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex items-center rounded border border-border bg-input p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("original")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "original"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortOriginal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("alpha")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "alpha"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortAlpha")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,13 +231,20 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
<div className="w-full truncate text-sm font-medium text-content-primary">
|
||||
{presetName(p)}
|
||||
</div>
|
||||
{p.category !== "custom" && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.category !== "custom" && (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
|
||||
>
|
||||
{t(CATEGORY_LABEL[p.category])}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${BILLING_BADGE[p.billingMode]}`}
|
||||
>
|
||||
{t(CATEGORY_LABEL[p.category])}
|
||||
{t(BILLING_LABEL[p.billingMode])}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { findPresetForProvider } from "../config/providerPresets";
|
||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
||||
import { getIconMetadata } from "../icons/extracted/metadata";
|
||||
@@ -117,6 +118,10 @@ export function ProviderEdit({
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
|
||||
// Preset this provider was created from (if any) — provides the
|
||||
// "Get API Key" / referral links shown under the key input.
|
||||
const preset = findPresetForProvider(provider);
|
||||
|
||||
useEffect(() => {
|
||||
const def = defaultBaseUrl(agent, provider.provider_type);
|
||||
if (!def) return;
|
||||
@@ -215,7 +220,7 @@ export function ProviderEdit({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("providerName")}
|
||||
{t("providerName")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
@@ -296,7 +301,7 @@ export function ProviderEdit({
|
||||
{!provider.managed && (
|
||||
<div>
|
||||
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("apiKey")}
|
||||
{t("apiKey")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -316,12 +321,29 @@ export function ProviderEdit({
|
||||
{showApiKey ? t("hide") : t("show")}
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
|
||||
if (!linkUrl) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex items-center gap-4 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
invoke("open_external_url", { url: linkUrl }).catch(() => {})
|
||||
}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{preset?.apiKeyUrl ? t("getApiKeyLink") : t("officialUrl")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("requestUrl")}
|
||||
{t("requestUrl")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={baseUrlId}
|
||||
@@ -459,6 +481,9 @@ function ModelMapping({
|
||||
provider,
|
||||
});
|
||||
setDiscovered(result);
|
||||
// Auto-select models that already exist in the current provider's list
|
||||
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||
setSelected(new Set(result.filter((dm) => existingIds.has(dm.id)).map((dm) => dm.id)));
|
||||
} catch (err) {
|
||||
setDiscoverError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -477,9 +502,11 @@ function ModelMapping({
|
||||
|
||||
const handleAddSelected = () => {
|
||||
if (!discovered || selected.size === 0) return;
|
||||
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||
const toAdd: Model[] = [];
|
||||
for (const dm of discovered) {
|
||||
if (!selected.has(dm.id)) continue;
|
||||
if (existingIds.has(dm.id)) continue; // skip models already in this provider
|
||||
const safeProvider = provider.name.replace(/\//g, "-");
|
||||
const safeModelId = dm.id.replace(/\//g, "-");
|
||||
const alias = `${safeProvider}/${safeModelId}`;
|
||||
@@ -555,7 +582,9 @@ function ModelMapping({
|
||||
{t("fetchEnableThinking")}
|
||||
</label>
|
||||
<div className="max-h-48 overflow-auto space-y-1">
|
||||
{discovered.map((m) => (
|
||||
{discovered.map((m) => {
|
||||
const exists = models.some((mm) => mm.model === m.id);
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
|
||||
@@ -569,8 +598,12 @@ function ModelMapping({
|
||||
{m.display_name && (
|
||||
<span className="text-content-muted">({m.display_name})</span>
|
||||
)}
|
||||
{exists && (
|
||||
<span className="ml-auto text-[10px] text-emerald-500 shrink-0">{t("alreadyAdded")}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -110,6 +110,15 @@ export function SettingsModal({
|
||||
invoke("open_installer", { path: downloadedPath }).catch(() => {});
|
||||
};
|
||||
|
||||
// Open an external link in the system browser. Falls back to a new tab
|
||||
// when the opener plugin call fails, so a broken click never stays silent.
|
||||
const openExternal = (url: string) => {
|
||||
openUrl(url).catch(() => {
|
||||
const w = window.open(url, "_blank");
|
||||
if (!w) console.error(`failed to open ${url}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [
|
||||
@@ -253,26 +262,66 @@ export function SettingsModal({
|
||||
type="button"
|
||||
onClick={onCheckUpdate}
|
||||
disabled={checking}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={checking ? "animate-spin" : ""}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
{checking ? t("checking") : t("checkUpdate")}
|
||||
</button>
|
||||
{updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors"
|
||||
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{t("downloadUpdate")} ↓
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{t("downloadUpdate")}
|
||||
</button>
|
||||
)}
|
||||
{downloadedPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{t("install")} ⏎
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
{t("install")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -323,18 +372,43 @@ export function SettingsModal({
|
||||
<div className="font-medium text-content-primary">Kimi Switch</div>
|
||||
<div>{t("appDescription")}</div>
|
||||
<div className="text-xs text-content-muted">v{appVersion} · MIT License</div>
|
||||
<div className="pt-2">
|
||||
<span className="text-content-muted">{t("referenceProject")}: </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openUrl("https://github.com/JochenYang/kimicode-dashboard")
|
||||
}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>
|
||||
<span className="text-content-muted"> (MIT, © JochenYang)</span>
|
||||
<div className="pt-2 space-y-1">
|
||||
<div>
|
||||
{t("attributionPorted")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openExternal("https://github.com/JochenYang/kimicode-dashboard")
|
||||
}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer font-semibold"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>
|
||||
<span className="text-content-muted"> {t("attributionSuffix")}</span>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
{t("modelDataSourcePrefix")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal("https://models.dev/")}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
models.dev
|
||||
</button>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<span className="text-content-muted">{t("referenceProject")}: </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openExternal("https://github.com/farion1231/cc-switch")
|
||||
}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
cc-switch
|
||||
</button>
|
||||
<span className="text-content-muted"> (MIT, © Jason Young)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,79 @@ 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;
|
||||
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
|
||||
// entries carry only remaining + unit — their plan_name is a currency /
|
||||
// brand label and must render as an amount, not a percentage.
|
||||
const isPlan =
|
||||
!!d.planName &&
|
||||
d.planName !== "NewAPI" &&
|
||||
(d.total != null || d.used != null);
|
||||
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 +333,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 +355,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
|
||||
@@ -254,7 +366,12 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
}`}
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const isPlan = !!d.planName;
|
||||
// Same plan-vs-balance rule as compactSummary: balance rows have no
|
||||
// quota fields (total/used), so they render as an amount.
|
||||
const isPlan =
|
||||
!!d.planName &&
|
||||
d.planName !== "NewAPI" &&
|
||||
(d.total != null || d.used != null);
|
||||
const pct = isPlan ? percentOf(d) : null;
|
||||
const color =
|
||||
pct == null
|
||||
@@ -270,7 +387,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 +412,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>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { TrendLineChart } from "./TrendLineChart";
|
||||
|
||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||
|
||||
@@ -12,18 +12,20 @@ function Card({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
<div className={`flex flex-col rounded-xl border border-border bg-panel ${className ?? ""}`}>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
||||
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
||||
</div>
|
||||
<div className="flex-1 p-4">{children}</div>
|
||||
<div className="flex flex-1 flex-col p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function DashboardPage() {
|
||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [trendTab, setTrendTab] = useState<"model" | "provider">("model");
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||
|
||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||
today: t("rangeToday"),
|
||||
@@ -187,18 +189,29 @@ export function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap — full-year strip, fixed-size cells, centered */}
|
||||
{/* Heatmap — full row */}
|
||||
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
|
||||
<Heatmap heatmap={heatmap} />
|
||||
</Card>
|
||||
|
||||
{/* Charts + Model side table */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
{/* Usage-trend tabs + Model table side by side */}
|
||||
<div className="grid items-stretch gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
{/* Usage-trend tab container */}
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
{/* Tab bar header */}
|
||||
<div className="flex items-end justify-between border-b border-border px-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("daily")}
|
||||
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||
trendTab === "daily"
|
||||
? "border-blue-500 text-content-primary"
|
||||
: "border-transparent text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("tabDailyTrend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("model")}
|
||||
@@ -223,21 +236,29 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
<span className="pb-2 text-xs text-content-muted">
|
||||
{daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
{trendTab === "daily"
|
||||
? t("trendLineSub", { n: trendDaily.length })
|
||||
: daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Active tab content */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={trendDaily}
|
||||
dimension={trendTab}
|
||||
names={trendTab === "model" ? modelNames : providerNames}
|
||||
unknownProviderLabel={t("providerUnknown")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
{trendTab === "daily" ? (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<TrendLineChart daily={trendDaily} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={trendDaily}
|
||||
dimension={trendTab}
|
||||
names={trendTab === "model" ? modelNames : providerNames}
|
||||
unknownProviderLabel={t("providerUnknown")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +267,7 @@ export function DashboardPage() {
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
|
||||
@@ -260,7 +281,7 @@ export function DashboardPage() {
|
||||
{visibleModels.map((m) => (
|
||||
<tr
|
||||
key={m.model}
|
||||
className="border-b border-border/50 hover:bg-hover"
|
||||
className="border-b border-border hover:bg-hover"
|
||||
>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -273,10 +294,10 @@ export function DashboardPage() {
|
||||
: "#6b7280",
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span>
|
||||
{m.costEstimated && (
|
||||
<span className="text-[10px] text-yellow-600">{t("estimate")}</span>
|
||||
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,8 +337,8 @@ export function DashboardPage() {
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-auto max-h-[860px]">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
|
||||
@@ -330,7 +351,7 @@ export function DashboardPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentPageRows.map((r, i) => (
|
||||
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover">
|
||||
<tr key={recentStart + i} className="border-b border-border hover:bg-hover">
|
||||
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
|
||||
{fmtTime(r.time)}
|
||||
</td>
|
||||
@@ -398,20 +419,12 @@ export function DashboardPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Attribution footer */}
|
||||
{/* Footer — meta at the end of the content flow */}
|
||||
<div className="text-xs text-content-muted pb-2">
|
||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||
{data.meta.home}
|
||||
<br />
|
||||
{t("dashboardAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
{t("attributionSuffix")}
|
||||
<div>
|
||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||
{data.meta.home}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ const LEVEL_BG = [
|
||||
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const CELL = 12; // px
|
||||
const CELL = 16; // px
|
||||
const GAP = 3; // px
|
||||
const LABEL_W = 28; // dow label column width
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useMemo, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtTokens } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
|
||||
interface TrendLineChartProps {
|
||||
/** Per-day totals, already capped to the desired window by the caller. */
|
||||
daily: DailyRow[];
|
||||
}
|
||||
|
||||
const W = 800;
|
||||
const H = 130;
|
||||
const PAD_L = 56;
|
||||
const PAD_R = 56;
|
||||
const PAD_T = 10;
|
||||
const PAD_B = 22;
|
||||
const PLOT_W = W - PAD_L - PAD_R;
|
||||
const PLOT_H = H - PAD_T - PAD_B;
|
||||
|
||||
const COLOR_TOKENS = "#3b82f6"; // blue
|
||||
const COLOR_CACHE = "#22c55e"; // green
|
||||
const COLOR_REQUESTS = "#f97316"; // orange
|
||||
|
||||
/** Human-friendly token count for axis labels (K / M / B). */
|
||||
function fmtTokenAxis(value: number): string {
|
||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
function fmtInt(n: number): string {
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
function niceMax(value: number): number {
|
||||
if (value <= 0) return 1;
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const base = Math.pow(10, exp);
|
||||
const norm = value / base;
|
||||
let nice: number;
|
||||
if (norm <= 1) nice = 1;
|
||||
else if (norm <= 2) nice = 2;
|
||||
else if (norm <= 5) nice = 5;
|
||||
else nice = 10;
|
||||
return nice * base;
|
||||
}
|
||||
|
||||
export function TrendLineChart({ daily }: TrendLineChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showRequests, setShowRequests] = useState(true);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const maxTokens = useMemo(() => {
|
||||
if (!daily?.length) return 1;
|
||||
return niceMax(Math.max(...daily.map((d) => d.totalTokens || 0), 1));
|
||||
}, [daily]);
|
||||
|
||||
const maxRequests = useMemo(() => {
|
||||
if (!daily?.length) return 1;
|
||||
return niceMax(Math.max(...daily.map((d) => d.requests || 0), 1));
|
||||
}, [daily]);
|
||||
|
||||
const n = daily?.length ?? 0;
|
||||
const stepX = n > 1 ? PLOT_W / (n - 1) : PLOT_W;
|
||||
|
||||
const pointX = (i: number) => (n > 1 ? PAD_L + i * stepX : PAD_L + PLOT_W / 2);
|
||||
const pointYTokens = (v: number) => PAD_T + PLOT_H - (v / maxTokens) * PLOT_H;
|
||||
// Cache hit rate is a fraction 0-1 (see fmtPct); map onto the right 0-100% axis.
|
||||
const pointYCache = (v: number) => PAD_T + PLOT_H - Math.max(0, Math.min(1, v)) * PLOT_H;
|
||||
const pointYReq = (v: number) => PAD_T + PLOT_H - (v / maxRequests) * PLOT_H;
|
||||
|
||||
const pathTokens = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYTokens(d.totalTokens || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily, maxTokens]);
|
||||
|
||||
const pathCache = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYCache(d.cacheHitRate || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily]);
|
||||
|
||||
const pathRequests = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYReq(d.requests || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily, maxRequests]);
|
||||
|
||||
const areaTokens = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
const lastX = pointX(daily.length - 1);
|
||||
return (
|
||||
pathTokens +
|
||||
` L ${lastX} ${PAD_T + PLOT_H} L ${PAD_L} ${PAD_T + PLOT_H} Z`
|
||||
);
|
||||
}, [pathTokens, daily]);
|
||||
|
||||
// X-axis ticks: show every ~5 days (plus the first and last) to avoid overlap.
|
||||
const xTicks = useMemo(() => {
|
||||
if (!daily?.length) return [];
|
||||
if (n <= 6) return daily.map((_, i) => i);
|
||||
const step = Math.max(1, Math.round(n / 6));
|
||||
const set = new Set<number>([0, n - 1]);
|
||||
for (let i = step; i < n - 1; i += step) set.add(i);
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
}, [daily, n]);
|
||||
|
||||
// Hover handling
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
if (!daily?.length) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const scale = W / rect.width;
|
||||
const x = (e.clientX - rect.left) * scale - PAD_L;
|
||||
if (x < 0 || x > PLOT_W) {
|
||||
setHoverIndex(null);
|
||||
return;
|
||||
}
|
||||
const idx = Math.max(0, Math.min(n - 1, Math.round(x / stepX)));
|
||||
setHoverIndex(idx);
|
||||
};
|
||||
|
||||
const onLeave = () => setHoverIndex(null);
|
||||
|
||||
if (!daily?.length) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-content-muted">
|
||||
{t("trendLineNoData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hover = hoverIndex != null ? daily[hoverIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
{/* Legend + toggle */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-content-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_TOKENS }} />
|
||||
{t("trendLineLegendTokens")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_CACHE }} />
|
||||
{t("trendLineLegendCacheHit")}
|
||||
</span>
|
||||
{showRequests && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_REQUESTS }} />
|
||||
{t("trendLineLegendRequests")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-content-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRequests}
|
||||
onChange={(e) => setShowRequests(e.target.checked)}
|
||||
className="h-3 w-3 accent-blue-500"
|
||||
/>
|
||||
{t("trendLineShowRequests")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
{/* Gradient definitions */}
|
||||
<defs>
|
||||
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={COLOR_TOKENS} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={COLOR_TOKENS} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Y-axis grid lines only (labels rendered as HTML overlay below) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||
const y = PAD_T + PLOT_H * (1 - p);
|
||||
return (
|
||||
<line
|
||||
key={p}
|
||||
x1={PAD_L}
|
||||
y1={y}
|
||||
x2={W - PAD_R}
|
||||
y2={y}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={p === 0 ? "" : "2 3"}
|
||||
opacity={p === 0 ? 0.9 : 0.4}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area fill for tokens */}
|
||||
<path d={areaTokens} fill="url(#tokenAreaGrad)" />
|
||||
|
||||
{/* Tokens line (area + line) */}
|
||||
<path
|
||||
d={pathTokens}
|
||||
fill="none"
|
||||
stroke={COLOR_TOKENS}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Cache hit line */}
|
||||
<path
|
||||
d={pathCache}
|
||||
fill="none"
|
||||
stroke={COLOR_CACHE}
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Requests line (optional) */}
|
||||
{showRequests && (
|
||||
<path
|
||||
d={pathRequests}
|
||||
fill="none"
|
||||
stroke={COLOR_REQUESTS}
|
||||
strokeWidth={1.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover crosshair + dot */}
|
||||
{hover && (
|
||||
<g pointerEvents="none">
|
||||
<line
|
||||
x1={pointX(hoverIndex!)}
|
||||
y1={PAD_T}
|
||||
x2={pointX(hoverIndex!)}
|
||||
y2={PAD_T + PLOT_H}
|
||||
stroke="currentColor"
|
||||
className="text-content-muted"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 2"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYTokens(hover.totalTokens || 0)}
|
||||
r={3.5}
|
||||
fill={COLOR_TOKENS}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYCache(hover.cacheHitRate || 0)}
|
||||
r={3}
|
||||
fill={COLOR_CACHE}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{showRequests && (
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYReq(hover.requests || 0)}
|
||||
r={2.5}
|
||||
fill={COLOR_REQUESTS}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Axis labels as HTML overlay (avoids SVG preserveAspectRatio="none" font distortion) */}
|
||||
{/* Left Y-axis (tokens) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||
return (
|
||||
<span
|
||||
key={`yl-${p}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
left: 0,
|
||||
top: `${top}%`,
|
||||
width: `${((PAD_L - 4) / W) * 100}%`,
|
||||
textAlign: "right",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{fmtTokenAxis(maxTokens * p)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* Right Y-axis (cache hit %) */}
|
||||
{[0, 0.5, 1].map((p) => {
|
||||
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||
return (
|
||||
<span
|
||||
key={`yr-${p}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
right: 0,
|
||||
top: `${top}%`,
|
||||
width: `${((PAD_R - 4) / W) * 100}%`,
|
||||
textAlign: "left",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{Math.round(p * 100)}%
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* X-axis labels */}
|
||||
{xTicks.map((i) => (
|
||||
<span
|
||||
key={`x-${i}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
left: `${(pointX(i) / W) * 100}%`,
|
||||
bottom: 0,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{daily[i].date.slice(5)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Tooltip (HTML overlay for readability) */}
|
||||
{hover && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-md border border-border bg-panel px-2.5 py-1.5 text-[11px] text-content-primary shadow-xl"
|
||||
style={{
|
||||
left: `${(pointX(hoverIndex!) / W) * 100}%`,
|
||||
top: 4,
|
||||
transform: hoverIndex! > n / 2 ? "translateX(-100%)" : "translateX(8px)",
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{hover.date}</div>
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendTokens")}: <span className="tabular-nums">{fmtTokens(hover.totalTokens)}</span>
|
||||
</div>
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendCacheHit")}: <span className="tabular-nums">{(hover.cacheHitRate * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
{showRequests && (
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendRequests")}: <span className="tabular-nums">{fmtInt(hover.requests)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useSessions } from "../../hooks/useSessions";
|
||||
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
||||
import { useTranslation } from "../../i18n";
|
||||
@@ -547,16 +546,6 @@ export function SessionsPage() {
|
||||
{/* Attribution */}
|
||||
<div className="text-xs text-content-muted pb-2">
|
||||
{data?.home && <>{data.home}</>}
|
||||
<br />
|
||||
{t("sessionsAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
{t("attributionSuffix")}
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
|
||||
@@ -13,10 +13,14 @@ export type UsageKind =
|
||||
| "balance:openrouter"
|
||||
| "balance:stepfun"
|
||||
| "balance:novita"
|
||||
| "balance:kimi"
|
||||
| "plan:kimi_coding"
|
||||
| "plan:zhipu"
|
||||
| "plan:minimax";
|
||||
|
||||
/** Billing model for a preset. Drives the tab filter in PresetPickerModal. */
|
||||
export type BillingMode = "subscription" | "pay_as_you_go";
|
||||
|
||||
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
|
||||
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||
"balance:deepseek",
|
||||
@@ -24,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",
|
||||
@@ -45,6 +50,7 @@ export interface ProviderPreset {
|
||||
nameKey?: string;
|
||||
/** Official website / console URL. */
|
||||
websiteUrl?: string;
|
||||
/** Where to create / fetch an API key; shown as a link under the key input. */
|
||||
apiKeyUrl?: string;
|
||||
/** Category: sorting & badge. */
|
||||
category: PresetCategory;
|
||||
@@ -71,6 +77,12 @@ export interface ProviderPreset {
|
||||
/** Defaults to ["thinking"]. */
|
||||
capabilities?: string[];
|
||||
}>;
|
||||
/**
|
||||
* Billing model — drives the 套餐/按量 tab in PresetPickerModal.
|
||||
* - "subscription": coding-plan / token-plan style subscription (quota-based)
|
||||
* - "pay_as_you_go": per-token API billing
|
||||
*/
|
||||
billingMode: BillingMode;
|
||||
/**
|
||||
* Billing/usage query kinds; omitted means "do not query".
|
||||
* Persisted to SQLite settings by the Rust side on save, never to
|
||||
@@ -95,12 +107,14 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "claude-sonnet-4-5" },
|
||||
{ model: "claude-haiku-4-5" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
name: "Kimi For Coding",
|
||||
nameKey: "presetNameKimiCoding",
|
||||
websiteUrl: "https://www.kimi.com/code",
|
||||
apiKeyUrl: "https://kimi-bot.com/activities/zh-cn/invite/share?scenario=invite&from=share_poster&invitation_code=6UJX7J",
|
||||
category: "official",
|
||||
providerType: "kimi",
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
@@ -111,6 +125,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
|
||||
{ model: "kimi-k2.7-code" },
|
||||
],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:kimi_coding"],
|
||||
},
|
||||
{
|
||||
@@ -127,6 +142,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "kimi-k2-thinking-turbo" },
|
||||
{ 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",
|
||||
@@ -138,12 +157,13 @@ export const providerPresets: ProviderPreset[] = [
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
icon: "deepseek",
|
||||
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
usageKinds: ["balance:deepseek"],
|
||||
},
|
||||
{
|
||||
id: "zhipu",
|
||||
name: "Zhipu GLM",
|
||||
nameKey: "presetNameZhipu",
|
||||
id: "zhipu-api",
|
||||
name: "Zhipu GLM (API)",
|
||||
nameKey: "presetNameZhipuApi",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
@@ -154,18 +174,51 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "glm-4.6" },
|
||||
{ model: "glm-4.5-air" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "zhipu-coding",
|
||||
name: "Zhipu GLM Coding Plan",
|
||||
nameKey: "presetNameZhipuCoding",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Coding Plan endpoint is separate from the pay-as-you-go PaaS API.
|
||||
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [
|
||||
{ model: "glm-4.7" },
|
||||
{ model: "glm-4.6" },
|
||||
{ model: "glm-4.5-air" },
|
||||
],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
id: "zai",
|
||||
name: "z.ai",
|
||||
nameKey: "presetNameZai",
|
||||
id: "zai-api",
|
||||
name: "z.ai (API)",
|
||||
nameKey: "presetNameZaiApi",
|
||||
websiteUrl: "https://z.ai",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "zai-coding",
|
||||
name: "z.ai Coding Plan",
|
||||
nameKey: "presetNameZaiCoding",
|
||||
websiteUrl: "https://z.ai",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
// Coding Plan endpoint (mirror of Zhipu GLM Coding Plan). If the path
|
||||
// differs on z.ai, fall back to a known mirror.
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
@@ -182,6 +235,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "qwen3-coder-plus" },
|
||||
{ model: "qwen3.5-plus" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
@@ -193,6 +247,26 @@ export const providerPresets: ProviderPreset[] = [
|
||||
baseUrl: "https://api.minimaxi.com/v1",
|
||||
icon: "minimax",
|
||||
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
usageKinds: ["plan:minimax"],
|
||||
},
|
||||
{
|
||||
id: "minimax-token-plan",
|
||||
name: "MiniMax Token Plan",
|
||||
nameKey: "presetNameMinimaxTokenPlan",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
apiKeyUrl: "https://platform.minimaxi.com/subscribe/token-plan?code=GmmZA629b5&source=link",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Token Plan is sold against the same platform endpoint as the
|
||||
// pay-as-you-go API — only the API key differs. The Rust detect
|
||||
// routes api.minimaxi.com to plan:minimax, so this preset picks up
|
||||
// the same query path; the `subscription` tag helps the user pick
|
||||
// the right entry based on which key they hold.
|
||||
baseUrl: "https://api.minimaxi.com/v1",
|
||||
icon: "minimax",
|
||||
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:minimax"],
|
||||
},
|
||||
{
|
||||
@@ -205,8 +279,24 @@ export const providerPresets: ProviderPreset[] = [
|
||||
baseUrl: "https://api.stepfun.com/v1",
|
||||
icon: "stepfun",
|
||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
usageKinds: ["balance:stepfun"],
|
||||
},
|
||||
{
|
||||
id: "stepfun-plan",
|
||||
name: "StepFun Plan",
|
||||
nameKey: "presetNameStepfunPlan",
|
||||
websiteUrl: "https://platform.stepfun.com/step-plan",
|
||||
apiKeyUrl: "https://platform.stepfun.com/?invite_code=HSIGHTPS",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Step Plan endpoint — same host, different path prefix than the
|
||||
// pay-as-you-go API (/v1). Serves both OpenAI and Anthropic protocols.
|
||||
baseUrl: "https://api.stepfun.com/step_plan/v1",
|
||||
icon: "stepfun",
|
||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||
billingMode: "subscription",
|
||||
},
|
||||
{
|
||||
id: "siliconflow",
|
||||
name: "SiliconFlow",
|
||||
@@ -229,6 +319,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
],
|
||||
usageKinds: ["balance:siliconflow"],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "novita",
|
||||
@@ -251,6 +342,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "zai-org/glm-4.6" },
|
||||
],
|
||||
usageKinds: ["balance:novita"],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
@@ -273,6 +365,36 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "google/gemini-3-pro-preview" },
|
||||
],
|
||||
usageKinds: ["balance:openrouter"],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "tokenrhythm",
|
||||
name: "TokenRhythm",
|
||||
nameKey: "presetNameTokenRhythm",
|
||||
websiteUrl: "https://tokenrhythm.studio",
|
||||
// Referral link for creating an API key on the console.
|
||||
apiKeyUrl: "https://tokenrhythm.studio/i/rf_tr_v0QXuBTzyl3-bJOS2uFFPg6x",
|
||||
category: "aggregator",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://tokenrhythm.studio/v1",
|
||||
icon: "tokenrhythm",
|
||||
models: [
|
||||
{ model: "deepseek-v4-flash" },
|
||||
{ model: "deepseek-v4-pro" },
|
||||
{ model: "glm-5" },
|
||||
{ model: "glm-5.1" },
|
||||
{ model: "glm-5.2" },
|
||||
{ model: "kimi-k2.5" },
|
||||
{ model: "kimi-k2.6" },
|
||||
{ model: "kimi-k2.7-code" },
|
||||
{ model: "minimax-m2.5" },
|
||||
{ model: "minimax-m2.7" },
|
||||
{ model: "mimo-v2.5-pro" },
|
||||
{ model: "qwen3.7-max" },
|
||||
],
|
||||
// No public billing endpoint: the API key only authorizes the OpenAI /
|
||||
// Anthropic / Embeddings proxy. Console usage requires a login session.
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
@@ -288,6 +410,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "gpt-5.1-codex-max" },
|
||||
{ model: "gpt-5-mini" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "google-genai",
|
||||
@@ -303,6 +426,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "gemini-3-flash-preview" },
|
||||
{ model: "gemini-2.5-flash" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "volcengine",
|
||||
@@ -321,6 +445,53 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
nameKey: "presetNameOpencodeGo",
|
||||
// Referral link — supports the OpenCode project
|
||||
websiteUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
icon: "opencode",
|
||||
models: [
|
||||
{ model: "grok-4.5", displayName: "Grok 4.5" },
|
||||
{ model: "glm-5.2", displayName: "GLM-5.2" },
|
||||
{ model: "glm-5.1", displayName: "GLM-5.1" },
|
||||
{ model: "kimi-k3", displayName: "Kimi K3" },
|
||||
{ model: "kimi-k2.7-code", displayName: "Kimi K2.7 Code" },
|
||||
{ model: "kimi-k2.6", displayName: "Kimi K2.6" },
|
||||
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
|
||||
{ model: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash" },
|
||||
],
|
||||
billingMode: "subscription",
|
||||
},
|
||||
{
|
||||
id: "opencode-zen",
|
||||
name: "OpenCode Zen",
|
||||
nameKey: "presetNameOpencodeZen",
|
||||
websiteUrl: "https://opencode.ai/zen/",
|
||||
// Referral program only exists on the /go landing page; the Zen console
|
||||
// page itself carries no ref parameter, so point the "get API key" action
|
||||
// at the referral link.
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
icon: "opencode",
|
||||
models: [
|
||||
{ model: "claude-opus-5", displayName: "Claude Opus 5" },
|
||||
{ model: "claude-sonnet-5", displayName: "Claude Sonnet 5" },
|
||||
{ model: "gpt-5.5", displayName: "GPT 5.5" },
|
||||
{ model: "gemini-3.6-flash", displayName: "Gemini 3.6 Flash" },
|
||||
{ model: "grok-4.5", displayName: "Grok 4.5" },
|
||||
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -405,6 +576,21 @@ export function presetToProviderAndModels(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort reverse lookup: which preset (if any) a Provider was created
|
||||
* from. Matches the provider name with any "-2"/"-3" dedup suffix stripped,
|
||||
* falling back to an exact official_url match. Used to surface preset-only
|
||||
* metadata (apiKeyUrl / referralUrl) in the edit form without persisting
|
||||
* extra fields on Provider.
|
||||
*/
|
||||
export function findPresetForProvider(provider: Provider): ProviderPreset | undefined {
|
||||
const base = provider.name.replace(/-\d+$/, "");
|
||||
return (
|
||||
providerPresets.find((p) => p.id === base) ??
|
||||
providerPresets.find((p) => p.websiteUrl != null && p.websiteUrl === provider.official_url)
|
||||
);
|
||||
}
|
||||
|
||||
// Dev-only cross-end drift check: assert that every usage kind referenced by
|
||||
// any preset is one the Rust `UsageKind` enum knows how to handle. The
|
||||
// runtime filter in `query_provider_usage` silently drops unknown kinds,
|
||||
|
||||
+12
-9
@@ -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]);
|
||||
|
||||
|
||||
+72
-4
@@ -60,6 +60,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
apiFormat: "API Format",
|
||||
authField: "Auth Field",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "Get API Key",
|
||||
show: "Show",
|
||||
hide: "Hide",
|
||||
requestUrl: "Request URL",
|
||||
@@ -73,6 +74,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
fetchingModels: "Fetching...",
|
||||
discoveredModels: "Discovered {count} models",
|
||||
addSelected: "Add selected",
|
||||
alreadyAdded: "Added",
|
||||
displayName: "Display name",
|
||||
actualModel: "Actual model",
|
||||
contextSize: "Context size",
|
||||
@@ -283,6 +285,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||
tabModelTrend: "Model usage trend",
|
||||
tabProviderTrend: "Provider–model usage trend",
|
||||
tabDailyTrend: "Daily usage trend",
|
||||
providerUnknown: "Unknown provider",
|
||||
cardDailyTrendSub: "{n} days",
|
||||
cardModelUsage: "Model usage",
|
||||
@@ -315,9 +318,9 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "Scanned {n} files · {m} records",
|
||||
dashboardAttribution: "Dashboard ported from",
|
||||
sessionsAttribution: "Session manager ported from",
|
||||
attributionPorted: "Dashboard & session manager ported from",
|
||||
attributionSuffix: "(MIT, © JochenYang)",
|
||||
modelDataSourcePrefix: "Model & pricing data referenced from",
|
||||
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "Collapse · {n} chars",
|
||||
@@ -339,17 +342,24 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
presetNameKimiCoding: "Kimi For Coding",
|
||||
presetNameMoonshot: "Moonshot Platform",
|
||||
presetNameDeepseek: "DeepSeek",
|
||||
presetNameZhipu: "Zhipu GLM",
|
||||
presetNameZai: "z.ai (Zhipu Global)",
|
||||
presetNameZhipuApi: "Zhipu GLM (Pay-as-you-go)",
|
||||
presetNameZhipuCoding: "Zhipu GLM Coding Plan",
|
||||
presetNameZaiApi: "z.ai (Pay-as-you-go)",
|
||||
presetNameZaiCoding: "z.ai Coding Plan",
|
||||
presetNameBailian: "Alibaba Bailian",
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||
presetNameStepfun: "StepFun",
|
||||
presetNameStepfunPlan: "StepFun Plan",
|
||||
presetNameSiliconflow: "SiliconFlow",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
presetNameTokenRhythm: "TokenRhythm",
|
||||
presetNameOpenai: "OpenAI",
|
||||
presetNameGoogleGenai: "Google AI Studio",
|
||||
presetNameVolcengine: "Volcengine Ark",
|
||||
presetNameOpencodeGo: "OpenCode Go",
|
||||
presetNameOpencodeZen: "OpenCode Zen",
|
||||
|
||||
// Provider usage
|
||||
usageBalance: "Balance",
|
||||
@@ -363,4 +373,62 @@ 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",
|
||||
billingTabPayAsYouGo: "Pay-as-you-go",
|
||||
billingTabAll: "All",
|
||||
billingBadgeSubscription: "Plan",
|
||||
billingBadgePayAsYouGo: "PAYG",
|
||||
|
||||
// Usage trend line chart
|
||||
trendLineTitle: "Usage Trend",
|
||||
trendLineSub: "last {n} days",
|
||||
trendLineLegendTokens: "Token usage",
|
||||
trendLineLegendCacheHit: "Cache hit rate",
|
||||
trendLineLegendRequests: "Requests",
|
||||
trendLineShowRequests: "Show requests",
|
||||
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
|
||||
trendLineNoData: "No data",
|
||||
};
|
||||
+73
-5
@@ -58,6 +58,7 @@ export const zhTranslations = {
|
||||
apiFormat: "API 格式",
|
||||
authField: "认证字段",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "获取 API Key",
|
||||
show: "显示",
|
||||
hide: "隐藏",
|
||||
requestUrl: "请求地址",
|
||||
@@ -71,6 +72,7 @@ export const zhTranslations = {
|
||||
fetchingModels: "获取中...",
|
||||
discoveredModels: "发现 {count} 个模型",
|
||||
addSelected: "添加选中的",
|
||||
alreadyAdded: "已添加",
|
||||
displayName: "显示名称",
|
||||
actualModel: "实际请求模型",
|
||||
contextSize: "上下文长度",
|
||||
@@ -278,6 +280,7 @@ export const zhTranslations = {
|
||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||
tabModelTrend: "模型用量趋势",
|
||||
tabProviderTrend: "供应商模型用量趋势",
|
||||
tabDailyTrend: "每日用量趋势",
|
||||
providerUnknown: "未知供应商",
|
||||
cardDailyTrendSub: "共 {n} 天",
|
||||
cardModelUsage: "模型用量",
|
||||
@@ -310,9 +313,9 @@ export const zhTranslations = {
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "扫描 {n} 个文件 · {m} 条记录",
|
||||
dashboardAttribution: "仪表盘功能基于",
|
||||
sessionsAttribution: "会话管理功能基于",
|
||||
attributionSuffix: "(MIT,© JochenYang)移植",
|
||||
attributionPorted: "仪表盘与会话管理功能基于",
|
||||
attributionSuffix: "(MIT,© JochenYang)",
|
||||
modelDataSourcePrefix: "新增模型与价格参考来自于",
|
||||
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "收起 · {n} 字符",
|
||||
@@ -334,17 +337,24 @@ export const zhTranslations = {
|
||||
presetNameKimiCoding: "Kimi For Coding",
|
||||
presetNameMoonshot: "Moonshot 平台",
|
||||
presetNameDeepseek: "DeepSeek",
|
||||
presetNameZhipu: "智谱 GLM",
|
||||
presetNameZai: "z.ai(智谱海外)",
|
||||
presetNameZhipuApi: "智谱 GLM(按量)",
|
||||
presetNameZhipuCoding: "智谱 GLM Coding Plan",
|
||||
presetNameZaiApi: "z.ai(按量)",
|
||||
presetNameZaiCoding: "z.ai Coding Plan",
|
||||
presetNameBailian: "阿里百炼",
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||
presetNameStepfun: "阶跃星辰",
|
||||
presetNameStepfunPlan: "阶跃星辰 Step Plan",
|
||||
presetNameSiliconflow: "硅基流动",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
presetNameTokenRhythm: "基元律动",
|
||||
presetNameOpenai: "OpenAI 官方",
|
||||
presetNameGoogleGenai: "Google AI Studio",
|
||||
presetNameVolcengine: "火山方舟",
|
||||
presetNameOpencodeGo: "OpenCode Go",
|
||||
presetNameOpencodeZen: "OpenCode Zen",
|
||||
|
||||
// Provider usage
|
||||
usageBalance: "余额",
|
||||
@@ -358,6 +368,64 @@ 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: "套餐",
|
||||
billingTabPayAsYouGo: "按量",
|
||||
billingTabAll: "全部",
|
||||
billingBadgeSubscription: "套餐",
|
||||
billingBadgePayAsYouGo: "按量",
|
||||
|
||||
// Usage trend line chart
|
||||
trendLineTitle: "用量趋势",
|
||||
trendLineSub: "最近 {n} 天",
|
||||
trendLineLegendTokens: "Token 用量",
|
||||
trendLineLegendCacheHit: "缓存命中率",
|
||||
trendLineLegendRequests: "请求数",
|
||||
trendLineShowRequests: "显示请求数",
|
||||
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
|
||||
trendLineNoData: "暂无数据",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof zhTranslations;
|
||||
@@ -23,6 +23,7 @@ import _relaxcode from "./relaxcode.png";
|
||||
import _runapi from "./runapi.jpg";
|
||||
import _shengsuanyun from "./shengsuanyun.svg?url";
|
||||
import _subrouter from "./subrouter.svg?url";
|
||||
import _tokenrhythm from "./tokenrhythm.svg?url";
|
||||
import _sudocode from "./sudocode.png";
|
||||
import _sudocodeUs from "./sudocode-us.png";
|
||||
import _teamorouter from "./TeamoRouter-icon-dark.png";
|
||||
@@ -128,6 +129,7 @@ export const iconUrls: Record<string, string> = {
|
||||
sudocode: _sudocode,
|
||||
"sudocode-us": _sudocodeUs,
|
||||
teamorouter: _teamorouter,
|
||||
tokenrhythm: _tokenrhythm,
|
||||
unity2: _unity2,
|
||||
zetaapi: _zetaapi,
|
||||
};
|
||||
|
||||
@@ -696,6 +696,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["stepfun", "step", "jieyue", "阶跃星辰"],
|
||||
defaultColor: "#005AFF",
|
||||
},
|
||||
tokenrhythm: {
|
||||
name: "tokenrhythm",
|
||||
displayName: "TokenRhythm",
|
||||
category: "ai-provider",
|
||||
keywords: ["tokenrhythm", "token rhythm", "基元律动", "aggregator", "relay", "gateway", "opensquilla"],
|
||||
defaultColor: "#0D0D0C",
|
||||
},
|
||||
};
|
||||
|
||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 189 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="基元律动logo0630 2 [Vectorized]" opacity="0.75">
|
||||
<path id="Vector" d="M28.2869 15.3313L52.3032 15.3318C56.6495 15.332 61.1409 15.3885 65.4782 15.3038C62.4476 17.8251 58.7745 21.2659 55.8259 23.9557C54.451 24.0375 52.5027 23.9839 51.0859 23.9842C48.459 23.9946 45.832 23.9874 43.2052 23.9626C44.8543 25.4287 47.4485 28.1941 49.1119 29.8604L61.0355 41.7999L68.4647 49.2348C68.9123 49.6831 71.0764 51.7891 71.2735 52.1321L71.1537 52.1688C67.1352 52.0344 62.9706 52.1522 58.9688 51.9728L45.811 38.8082C42.8897 35.885 39.8219 32.8966 36.9616 29.9257L36.9693 44.1437C34.1378 46.911 31.1624 49.6754 28.2845 52.4082L28.2869 15.3313Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_2" d="M98.7525 30.6261L100.21 30.6376L100.194 32.1506L103.548 32.1483L103.537 34.4298L100.204 34.4441C100.223 36.862 100.217 39.28 100.188 41.6978C101.416 41.6652 102.754 41.6897 103.991 41.6874C103.956 42.3315 103.988 43.3183 103.992 43.9834C102.257 43.9898 100.451 44.0225 98.723 43.9532C100.293 45.3913 102.261 45.9617 104.247 46.4987C104.03 47.4093 103.842 48.4144 103.65 49.3374C102.178 48.9282 99.9596 48.0446 98.7359 47.1392L98.7303 48.6004C97.2215 48.6082 95.7126 48.6069 94.2028 48.5966V50.1824C97.2187 50.1304 100.388 50.1795 103.416 50.1805C103.358 50.8001 103.384 51.7645 103.382 52.4095L85.6626 52.41L82.2408 52.4069C82.1845 51.6493 82.0959 50.9327 82.0033 50.1798L91.2904 50.1778L91.3044 48.6046L86.8554 48.6069L86.7349 47.176C84.9932 48.2367 83.7603 48.7314 81.8149 49.3328C81.5976 48.432 81.4066 47.4129 81.2142 46.4981C83.4621 45.9232 84.8957 45.4246 86.7564 43.9544C85.2596 44.0446 83.2197 43.9867 81.6781 43.9816C81.6749 43.3384 81.5832 42.3121 81.4779 41.6911L85.2003 41.6896L85.202 34.4349L82.4254 34.4319C82.3711 33.6655 82.326 32.9125 82.2188 32.1509L85.1936 32.1508L85.1881 30.652C86.1541 30.6266 87.1534 30.6348 88.1224 30.631L88.0896 32.1462L97.2732 32.1504L97.2824 30.6572L98.7525 30.6261ZM91.2808 44.7054L94.2028 44.7065L94.2038 46.2956C94.5083 46.5151 96.9954 46.3764 97.6672 46.4214C96.8117 45.5574 96.2802 45.0332 95.6194 43.9894C93.6999 44.0104 91.7815 44.0086 89.8628 43.984C89.1274 45.024 88.7137 45.528 87.7696 46.42C88.9413 46.3968 90.1131 46.3965 91.2848 46.419L91.2808 44.7054ZM97.2824 35.4409L97.2852 34.439C94.5175 34.4424 90.8129 34.5608 88.1224 34.4435L88.0839 35.4132L94.0229 35.4172C94.9817 35.4175 96.3485 35.3769 97.2824 35.4409ZM97.2741 41.6939C97.2824 41.4051 97.3359 40.9901 97.1892 40.7622C96.3189 40.6695 94.8212 40.7158 93.9066 40.7186L88.1216 40.7332C88.112 41.0237 88.088 41.3984 88.0937 41.6812C91.1174 41.683 94.2601 41.6422 97.2741 41.6939ZM88.1146 37.5911C88.1072 37.8571 88.0611 38.2777 88.1768 38.4975C88.6462 38.6161 90.4817 38.56 91.0733 38.5629C93.1342 38.5734 95.2069 38.5231 97.2695 38.5662C97.2759 38.3047 97.3248 37.8151 97.2058 37.6159C94.4695 37.475 90.9224 37.6123 88.1146 37.5911Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_3" d="M143.557 30.6421C144.519 30.6365 145.481 30.6369 146.444 30.6431L146.416 32.0838C147.712 32.0952 150.587 31.7416 151.532 32.6334C152.238 33.2995 152.073 34.9116 152.037 35.836L153.455 35.8341L153.452 38.2574L152.061 38.2665C152.068 38.6574 152.073 39.0484 152.074 39.4393C152.077 40.817 151.714 41.9762 150.071 42.0028C148.863 42.0222 147.644 41.9835 146.435 41.9641L146.417 43.4188L152.471 43.4164C152.45 44.172 152.462 44.9907 152.458 45.7513C150.451 45.767 148.443 45.7646 146.434 45.7444L146.421 47.307L153.455 47.3061L153.448 49.7244L146.433 49.7289C146.427 50.7883 146.426 51.8478 146.432 52.9071C145.466 52.9302 144.528 52.9131 143.562 52.8971L143.57 52.0394L143.562 49.7232C141.674 49.7332 139.786 49.7317 137.898 49.7187L137.652 47.305C139.595 47.2752 141.613 47.3037 143.563 47.3043V45.7518L138.841 45.7562C138.807 44.9585 138.72 44.2115 138.634 43.4192L143.567 43.4181L143.557 41.9638C142.283 41.9729 141.01 41.9746 139.735 41.9686C139.676 41.1971 139.589 40.4064 139.512 39.6348L143.557 39.6422L143.567 38.2609C142.025 38.2767 140.451 38.2581 138.907 38.2555C138.812 37.4492 138.73 36.6414 138.659 35.8325C140.283 35.8153 141.935 35.8318 143.561 35.8329L143.563 34.4377L139.734 34.4393C139.706 33.6753 139.59 32.8396 139.5 32.0747C140.831 32.1063 142.213 32.0809 143.561 32.1059L143.557 30.6421ZM146.418 35.8352C147.335 35.8344 148.302 35.817 149.215 35.8342C149.226 35.4685 149.29 35.001 149.104 34.7061C148.57 34.1891 147.223 34.4624 146.434 34.4293L146.418 35.8352ZM146.415 39.6429C147.046 39.6429 148.618 39.6765 149.164 39.578C149.198 39.1228 149.226 38.7194 149.231 38.263C148.344 38.2616 147.306 38.287 146.433 38.2546L146.415 39.6429Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_4" d="M106.528 38.04C113.768 38.1537 121.294 38.0359 128.569 38.0446L128.562 40.5572L122.09 40.5609C122.088 41.6707 121.918 49.1898 122.298 49.5794C122.677 49.9669 123.385 49.993 123.896 49.9976C124.384 50.0018 125.079 50.0346 125.443 49.658C126.096 48.9854 125.794 46.7277 125.787 45.8074C126.773 45.9027 127.712 46.0629 128.733 46.1458C128.717 47.4305 128.81 48.9848 128.679 50.2335C128.37 53.174 123.248 52.7047 121.193 52.341C118.598 51.6953 119.124 49.0866 119.126 46.9824L119.136 40.5538L115.088 40.5599C115.11 42.4962 115.153 44.316 114.498 46.1768C113.261 49.6902 110.052 51.4181 106.92 52.9188L106.102 49.9209C107.583 49.2936 109.128 48.4728 110.259 47.3244C112.25 45.3015 112.152 43.1551 112.126 40.5521L106.708 40.5516C106.715 39.8733 106.589 38.7478 106.528 38.04Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_5" d="M66.8075 16.3432C66.9854 16.5409 66.8988 26.5212 66.8939 27.8531C64.2096 30.6301 61.1162 33.5951 58.3448 36.3093L55.4318 33.3274C54.3728 32.2341 53.3067 31.1479 52.2334 30.0688L66.8075 16.3432Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_6" d="M168.959 30.6309C169.884 30.6563 170.931 30.6308 171.866 30.6301L171.852 34.4054C173.207 34.3801 175.717 34.1215 176.702 34.9053C177.766 36.6628 177.13 47.7169 177.116 50.1618C177.1 53.3678 173.527 52.8804 171.243 52.5637L170.593 50.0131C171.16 50.153 171.784 50.2022 172.368 50.2301C172.828 50.2521 173.663 50.2678 173.997 49.902C174.784 49.0385 174.376 39.5355 174.327 38.0864C174.322 37.7564 174.344 37.497 174.196 37.1993C173.703 36.7025 172.784 36.9975 171.856 36.8896C171.872 43.0678 171.482 47.246 168.791 52.9199L165.627 52.9227C168.446 46.8398 168.968 43.577 168.974 36.942L166.948 36.9497C166.846 36.141 166.779 35.2157 166.701 34.3979L168.97 34.3965L168.959 30.6309Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_7" d="M20.3634 15.2565C22.2816 15.2079 24.3745 15.2491 26.3065 15.2484C26.241 18.0694 26.2922 21.1045 26.2817 23.9411L16.4729 23.9386C14.5285 23.9342 12.3458 23.8846 10.4213 23.964C13.7597 21.1535 17.0176 18.0347 20.3634 15.2565Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_8" d="M155.77 38.0545L166.671 38.0592L166.66 40.4752C165.053 40.4843 163.446 40.4852 161.84 40.4778C161.528 43.4002 160.536 45.7653 159.075 48.266C160.563 48.2528 162.053 48.2562 163.542 48.2759C163.189 46.7011 162.709 45.1377 162.374 43.5428L162.451 43.4572L163.137 43.4397L165.225 43.4383C165.596 44.9311 165.967 46.4514 166.333 47.9353C166.75 49.6312 166.649 50.8135 164.561 50.8055C163.779 50.8026 162.918 50.8113 162.128 50.8104L155.98 50.8004C155.944 50.318 155.704 48.6508 155.865 48.2955L156.187 48.2595C156.442 48.0579 156.735 47.3129 156.883 46.9886C157.864 44.8345 158.443 42.8159 158.779 40.4811L155.978 40.4739L155.77 38.0545Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_9" d="M135.661 36.5098L138.529 36.5075C137.667 39.5604 136.341 41.2684 133.853 43.125C135.823 43.0944 136.683 42.9556 136.651 45.3019C136.642 45.9935 136.648 46.7679 136.646 47.4658L136.62 52.91C135.725 52.9391 134.714 52.9162 133.81 52.9155L133.813 44.8194L131.69 44.8038C131.463 43.9161 131.164 42.9683 130.903 42.0839C133.129 40.7958 134.759 38.9216 135.661 36.5098Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_10" d="M108.577 31.6073C114.355 31.739 120.686 31.6114 126.507 31.6117L126.501 34.1803C120.771 34.0572 114.56 34.1742 108.794 34.1751C108.692 33.3151 108.638 32.4706 108.577 31.6073Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_11" d="M119.479 15.71C120.798 15.693 123.164 15.5224 124.308 16.0558C126.299 16.9837 126.076 20.1731 123.91 20.9264L126.019 24.5984L124.235 24.5917C123.99 24.2657 122.699 21.6603 122.451 21.1619L121.062 21.1583C121.058 22.307 121.06 23.4556 121.068 24.6042L119.473 24.5985C119.566 21.8236 119.51 18.5035 119.479 15.71ZM121.051 19.7961C121.945 19.8033 123.018 19.9234 123.753 19.3913C124.995 17.011 122.631 17.0745 121.058 17.0804L121.051 19.7961Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_12" d="M155.37 18.0461C155.609 18.0631 156.076 18.0928 156.278 18.1888C156.662 18.3716 156.969 18.8524 157.439 18.8598C157.913 18.7376 157.723 18.2102 158.869 18.0577C162.365 17.5926 161.518 22.3556 161.493 24.6266C161.146 24.5826 160.371 24.5993 159.991 24.5975C160.008 23.7007 160.273 20.0376 159.597 19.6128C157.02 17.9932 157.607 22.7534 157.616 23.9524C157.627 24.1804 157.666 24.3672 157.523 24.5406C157.209 24.6959 156.557 24.606 156.131 24.6033C156.14 23.7629 156.368 20.0944 155.742 19.655C153.081 17.7873 153.733 23.3044 153.748 24.6107L152.219 24.596L152.224 18.1901L153.622 18.1893C153.646 18.4011 153.643 18.5972 153.772 18.7625C154.361 18.5998 154.068 18.2095 155.37 18.0461Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_13" d="M91.1429 18.0522C94.3302 18.122 94.6993 21.3866 93.4369 23.7784C93.088 24.4389 92.238 24.6456 91.5331 24.7415C89.621 24.7439 88.6302 23.9073 88.4773 21.9881C88.3105 19.8932 88.8263 18.2411 91.1429 18.0522ZM91.5428 23.479C92.8444 22.7222 93.0262 19.9867 91.5137 19.3544C91.3955 19.305 91.0173 19.3148 90.8734 19.3189C89.5574 20.1837 89.4669 23.4529 91.5428 23.479Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_14" d="M145.02 15.4482L146.555 15.456C146.534 16.612 146.558 17.8251 146.565 18.9856C146.847 18.6568 147.078 18.4626 147.403 18.1796C151.439 17.4693 150.425 21.8162 150.425 24.6083C149.943 24.5952 149.421 24.6041 148.934 24.6027L148.939 21.4117C148.948 20.7508 149.021 20.2024 148.532 19.6668C148.255 19.2434 147.363 18.9955 147.009 19.481C146.226 20.5554 146.536 23.3853 146.553 24.6374C146.158 24.5887 145.445 24.6029 145.024 24.5998C145.105 21.8847 145.105 18.1604 145.02 15.4482Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_15" d="M127.242 15.4528L128.754 15.4523L128.731 19.0125C129.054 18.6436 129.23 18.4927 129.609 18.1793C133.617 17.4115 132.634 21.915 132.617 24.626C132.225 24.5865 131.519 24.6004 131.104 24.5962C131.138 23.6667 131.118 22.6056 131.122 21.6658C131.127 21.2822 131.185 19.819 130.796 19.5937C128.046 18.0022 128.721 23.0514 128.732 24.6298C128.356 24.5837 127.656 24.6016 127.253 24.6009C127.299 21.6611 127.302 18.3882 127.242 15.4528Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_16" d="M104.703 18.0539C107.273 18.0208 107.522 19.7472 107.536 21.7736C107.485 21.7663 107.282 21.7393 107.24 21.7437C106.531 21.8174 103.885 21.4971 103.447 21.9005C103.203 22.6803 103.891 23.0805 104.486 23.4144C105.403 23.9286 105.93 23.2584 106.671 23.0228L107.281 23.9938C105.121 25.5435 102.096 24.8569 101.965 21.8757C101.873 19.7994 102.385 18.2445 104.703 18.0539ZM105.228 20.7215C105.635 20.7127 105.722 20.7826 106.002 20.5611C106.26 19.6405 105.264 19.2138 104.479 19.2263C103.984 19.3874 103.424 19.9315 103.375 20.4601C103.611 20.9109 104.71 20.7275 105.228 20.7215Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_17" d="M95.613 15.4501L97.1495 15.4595C97.0489 16.8341 97.132 19.5081 97.1476 20.9768C97.8592 20.0398 98.5873 19.1154 99.332 18.204L101.148 18.1842C100.305 19.0863 99.3081 20.3303 98.4959 21.2937C99.0164 21.8404 99.6947 22.698 100.195 23.2939C100.563 23.7308 100.935 24.1646 101.311 24.5952L99.44 24.5937C98.6621 23.6374 97.8536 22.6688 97.1255 21.6764C97.1273 22.6237 97.1089 23.6683 97.1532 24.6062C96.6299 24.6007 96.1288 24.6149 95.6056 24.6313C95.6683 21.6443 95.6314 18.4428 95.613 15.4501Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_18" d="M138.155 30.6314L138.216 30.6632C138.225 30.6912 138.25 30.7914 138.242 30.8154C137.368 33.7396 135.557 36.4317 132.96 38.0842C132.725 38.2333 132.282 38.5592 132.026 38.5579L131.893 38.3232L131.215 36.0397C133.275 34.5304 134.685 33.1812 135.475 30.6529C136.336 30.6234 137.286 30.6371 138.155 30.6314Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_19" d="M138.031 18.1923L139.634 18.187C139.113 19.4098 138.609 20.91 138.147 22.1743C137.664 23.3967 137.181 25.3724 136.413 26.3367C135.914 26.9644 135.196 26.9582 134.462 26.9886C134.433 25.9751 134.301 25.8813 135.281 25.5299C135.564 25.4284 135.723 24.84 135.83 24.5486L133.541 18.1904L135.218 18.1897C135.53 19.4879 136.212 21.5023 136.617 22.8346L138.031 18.1923Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_20" d="M112.102 18.046C115.346 18.1544 114.465 22.2111 114.441 24.6366C114.084 24.5819 113.334 24.6005 112.944 24.6C112.968 23.2884 113.132 21.3365 112.826 20.0538C112.623 19.2009 111.715 19.2205 110.994 19.5094C110.308 20.3316 110.553 23.3734 110.563 24.6263C110.172 24.5862 109.502 24.601 109.088 24.5984L109.092 18.1938C109.399 18.1918 110.308 18.1335 110.496 18.2688L110.576 18.8185C110.925 18.811 110.951 18.218 112.102 18.046Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_21" d="M157.03 32.3117L165.63 32.3144C165.624 33.1227 165.608 33.931 165.584 34.7389C165.222 34.8005 163.328 34.7749 162.89 34.7741L157.199 34.7598C157.159 33.9511 157.089 33.1214 157.03 32.3117Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_22" d="M81.1911 15.7075L87.5055 15.7099L87.4955 17.1429C86.7222 17.1164 85.9486 17.1026 85.1749 17.1016L85.1705 22.3787L85.1727 24.6281C84.799 24.5835 84.0131 24.599 83.6075 24.596L83.6191 17.1192C82.8556 17.1088 82.092 17.115 81.3288 17.1376C81.3098 16.6005 81.267 16.2407 81.1911 15.7075Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
<path id="Vector_23" d="M140.921 16.2497L142.45 16.2545C142.414 16.8395 142.427 17.6034 142.421 18.2023L143.71 18.1906L143.703 19.4062L142.427 19.3996C142.426 20.1072 142.287 22.5398 142.611 23.1728C142.719 23.383 143.27 23.3172 143.532 23.3359C143.762 23.5842 143.697 23.5858 143.741 23.9739L143.686 24.4864L143.561 24.5789C139.822 24.8423 141.248 21.9367 140.879 19.4935C140.535 19.4014 139.948 19.3017 139.914 18.8681C139.846 18.0247 140.318 18.2036 140.926 18.1833L140.921 16.2497Z" fill="var(--fill-0, #0D0D0C)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
File diff suppressed because it is too large.
Load diff
+62716
-596
File diff suppressed because it is too large.
Load diff
+18
-1
@@ -4,6 +4,13 @@ import snapshot from "./models-dev.json";
|
||||
* Compact per-model reference data from models.dev
|
||||
* (see scripts/fetch-models-dev.mjs).
|
||||
*/
|
||||
export interface ModelCost {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
}
|
||||
|
||||
export interface ModelRef {
|
||||
name?: string;
|
||||
context?: number;
|
||||
@@ -12,9 +19,19 @@ export interface ModelRef {
|
||||
structured_output?: boolean;
|
||||
image?: boolean;
|
||||
video?: boolean;
|
||||
/** $/M tokens from models.dev (added by fetch-models-dev.mjs). */
|
||||
cost?: ModelCost;
|
||||
}
|
||||
|
||||
const data: Record<string, ModelRef> = snapshot;
|
||||
const raw = snapshot as Record<string, unknown>;
|
||||
|
||||
// Drop the "last_updated" metadata key; the rest is "<provider>/<model>".
|
||||
const data: Record<string, ModelRef> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key !== "last_updated" && value && typeof value === "object") {
|
||||
data[key] = value as ModelRef;
|
||||
}
|
||||
}
|
||||
|
||||
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
|
||||
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user