chore: init main branch
This commit is contained in:
commit
549c8b5b13
231 files changed
+27286
No files matched your search
@@ -0,0 +1,625 @@
|
||||
# 提案:预设供应商 + 供应商账单查询
|
||||
|
||||
> **状态**:v0.4-draft(v0.3-draft 已实施;P0 全部落地,新增 v0.4 修订)
|
||||
> **作者**:Kimi Switch 内部
|
||||
> **目标版本**:0.6.0
|
||||
> **参考**:cc-switch(MIT,© Jason Young)— 仅借鉴字段结构与 Rust 用量查询层模式
|
||||
> **Kimi Code 官方文档**:[providers 配置](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html)、[配置文件](https://www.kimi.com/code/docs/kimi-code-cli/configuration/config-files.html)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
当前 Kimi Switch 已有"添加供应商"入口(`src/App.tsx:141 handleAddProvider`)直接凭空造一个 `provider-N`,用户需要手填 base_url、provider_type、模型映射等十几个字段。新用户面对一份空白表单门槛很高。
|
||||
|
||||
同时,用量仪表盘(`src/components/dashboard/`)展示的是**本地会话日志**聚合出来的 token 消耗,与"供应商账户里还剩多少钱/套餐还剩多少"完全无关——后者才能告诉用户"今天是不是该充值了"。
|
||||
|
||||
**目标**:
|
||||
|
||||
1. **预设供应商**:用户点"添加"时弹出预设选择面板,选中后表单字段自动填好(保留任意编辑权);支持至少 12 条主流国内/海外供应商。
|
||||
2. **供应商账单查询**:在 `ProviderList` 卡片底部展示余额/套餐剩余,调用供应商鉴权 API 实时拉取。
|
||||
|
||||
**非目标**:
|
||||
|
||||
- 不做 OAuth 流程(Claude/Codex 官方订阅)—— 我们不接管 CLI 登录态。
|
||||
- 不做"用户自定义 JS 脚本"查询(rquickjs 依赖过重)。
|
||||
- 不做用量费用看板(已有本地仪表盘)。
|
||||
|
||||
---
|
||||
|
||||
## 二、参考:cc-switch 关键事实
|
||||
|
||||
### 2.1 预设供应商
|
||||
|
||||
- **位置**:纯前端 TS 数组,`src/config/*ProviderPresets.ts`,按目标 CLI 工具拆 8+1 个文件,每文件 22–73 条。
|
||||
- **数据契约**(`claudeProviderPresets.ts:25-74`):
|
||||
- `name / nameKey(i18n) / websiteUrl / apiKeyUrl`
|
||||
- `settingsConfig`(真正写进配置的对象,**结构因工具而异**:Claude 用 `ANTHROPIC_*` env、Codex 用 auth.json + TOML)
|
||||
- `category`: `official | cn_official | third_party | aggregator`
|
||||
- `isOfficial / isPartner / primePartner`
|
||||
- `apiKeyField`(Claude 特有)、`endpointCandidates[]`、modelsUrl
|
||||
- `theme / icon / iconColor / apiFormat`
|
||||
- **触发**:`ProviderForm` 表单顶部内嵌 `ProviderPresetSelector`(grid 按钮 + 搜索 + 排序);选中即 `form.reset` 灌字段。
|
||||
- **模型清单来源**:硬编码写进 `settingsConfig.env` 或 `modelCatalog[]`;**不**走 models.dev,**不**在预设里调 API。动态拉模型是另一个独立按钮。
|
||||
- **许可证**:MIT,可直接借鉴字段结构与预设数据。
|
||||
|
||||
### 2.2 账单/用量查询
|
||||
|
||||
cc-switch 在 Rust 端实现了 6 套并行的查询层(详见 `src-tauri/src/services/`),与本项目相关的有 2 套:
|
||||
|
||||
| 机制 | 文件 | 覆盖 | 关键端点 |
|
||||
|---|---|---|---|
|
||||
| **A. 余额查询** | `balance.rs` | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita | `GET {base_url}/user/balance` 等 |
|
||||
| **B. Token Plan 套餐** | `coding_plan.rs` | **Kimi For Coding** / 智谱 GLM / MiniMax / 火山 / ZenMux | `GET https://api.kimi.com/coding/v1/usages` |
|
||||
|
||||
**统一返回契约**(`balance.rs:1-10` 注释):
|
||||
|
||||
```rust
|
||||
struct UsageResult {
|
||||
success: bool,
|
||||
data: Option<Vec<UsageData>>,
|
||||
error: Option<String>,
|
||||
}
|
||||
struct UsageData {
|
||||
plan_name: Option<String>,
|
||||
remaining: Option<f64>,
|
||||
total: Option<f64>,
|
||||
used: Option<f64>,
|
||||
unit: Option<String>,
|
||||
is_valid: Option<bool>,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
**错误通道语义**(这套设计很成熟,建议照搬):
|
||||
|
||||
- `Err(_)` = 瞬时失败(网络/超时/读体中断)→ 前端 retry + keep-last-good。
|
||||
- `Ok(success:false)` = 确定性失败(空 key / 401 / 非 2xx / JSON 解析失败)→ 直接透出错误文案。
|
||||
|
||||
**鉴权**:统一 `Authorization: Bearer <api_key>`。
|
||||
|
||||
**路由**:`detect_provider(base_url)` 按子串匹配 → 调对应 `query_xxx`。
|
||||
|
||||
---
|
||||
|
||||
## 三、KimiSwitch 落点
|
||||
|
||||
| 现有结构 | 落点 |
|
||||
|---|---|
|
||||
| `src/App.tsx:141 handleAddProvider`(直接造空 `provider-N`)| 改为先打开 `PresetPickerModal` |
|
||||
| `src/components/ProviderEdit.tsx` basic tab | 顶部加"切换预设"下拉(v1 不做) |
|
||||
| `src-tauri/src/models.rs Provider` | 不动;预设转 Provider 由前端完成 |
|
||||
| `src-tauri/src/commands.rs` | 新增 `query_provider_usage(base_url, api_key, usage_kind)` |
|
||||
| `src-tauri/src/`(无 `services/`)| 新建 `services/{mod.rs, usage_types.rs, balance.rs, coding_plan.rs}` |
|
||||
| `src/components/ProviderList.tsx` 卡片 | 卡片底部新增 `<UsageFooter>` |
|
||||
| `src/i18n/{zh,en}.ts` | 加 i18n key(preset 名 / 账单相关) |
|
||||
| `src/lib/models-dev.ts` | 复用:补全预设 model 的 `maxContextSize` / `capabilities` |
|
||||
|
||||
**已有依赖**(已满足):
|
||||
|
||||
- `Cargo.toml`: `reqwest = { version = "0.12", features = ["json","rustls-tls","stream"] }` ✅
|
||||
- `Cargo.toml`: `chrono = { version = "0.4", features = ["serde"] }` ✅
|
||||
|
||||
**私有字段存储约定**(**重要变更:v0.3 修订**):
|
||||
|
||||
早前提议把 `usageKinds` 写进 `provider.raw_other["kimi-switch.usageKinds"]` 通过 `config.toml` 透传。**此方案否决**——`kimi-switch.` 前缀不是 Kimi Code 注册的 namespace,"安全"只是 Zod 非 strict + raw 透传的实现现状,无版本兼容承诺。
|
||||
|
||||
**v0.3 改为:**
|
||||
|
||||
- `usageKinds` 存进 **SQLite** 的 `settings` 表(已有 per-agent 设置表,key 形式 `usage_kinds:<provider_name>`,值为 JSON 数组字符串,如 `["balance:deepseek"]` 或 `["plan:kimi_coding","balance:custom"]`)
|
||||
- `load_agent_config_command` 在返回 config 时**自动合并** SQLite settings 中的 usageKinds 到每个 provider(前端消费时是无感的)
|
||||
- Rust 端导出 `config.toml` 时**完全不写** `usageKinds` 字段
|
||||
- 卸载 / 退出登录 / 备份 `config.toml` 都不影响 `usageKinds`(独立存储)
|
||||
|
||||
**优势**:
|
||||
|
||||
1. 不污染用户的 Kimi Code 配置(终审 A8 解决)
|
||||
2. 用户改 `provider_type` / `base_url` 后,Rust 端 `detect_provider(base_url)` 自动重新决定 `usageKinds`(B-D1 解决)
|
||||
3. 旧用户升级 0.6.0 时,所有匹配 `detect_provider` host 列表的 provider **自动获得** `usageKinds`(B-D2 解决)
|
||||
4. 数组形式让同一 provider 同时支持套餐 + 余额查询(如 SiliconFlow 未来加套餐)
|
||||
|
||||
---
|
||||
|
||||
## 四、方案 1:预设供应商
|
||||
|
||||
### 4.1 数据模型
|
||||
|
||||
新建 `src/config/providerPresets.ts`:
|
||||
|
||||
```ts
|
||||
export interface ProviderPreset {
|
||||
/** 唯一 key,作为默认 Provider name 使用,如 "deepseek" */
|
||||
id: string;
|
||||
/** 显示名(中文/英文) */
|
||||
name: string;
|
||||
/** i18n key,可选;存在时优先用 t(nameKey) */
|
||||
nameKey?: string;
|
||||
/** 官网/拿 key 的链接 */
|
||||
websiteUrl?: string;
|
||||
apiKeyUrl?: string;
|
||||
/** 分类:排序 & 标签 */
|
||||
category: "official" | "cn_official" | "third_party" | "aggregator" | "custom";
|
||||
/** 真正写进 Provider 的字段 */
|
||||
providerType: ProviderType;
|
||||
/** 预设 base_url;`null` 表示无默认值(如 Anthropic、CodingPlan.site)。
|
||||
* 当选中的 provider.type 对应的 `defaultBaseUrl()` 返回非空时,
|
||||
* PresetPicker 会**用预设值覆盖它**,保存后以 `provider.base_url` 为唯一真相。 */
|
||||
baseUrl: string | null;
|
||||
/** 复用现有 IconPicker 命名(无品牌图标时按 base_url 走首字母兜底) */
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
/** 预填的模型映射(alias → model id)。
|
||||
* 数组第一个 ⇒ 设 default_model。
|
||||
* 数组为空时,default_model 留空,由用户通过「拉取模型」补齐。
|
||||
*
|
||||
* **alias 强制规范**:`provider/${modelId}` 形式 —— 例如 `deepseek/deepseek-chat`。
|
||||
* 不能用裸 alias(如 `chat`),否则会破坏 `handleDuplicateProvider` 的
|
||||
* `alias.slice(name.length)` 推导:若 provider=`copy`,会得到空串,
|
||||
* 复制时所有模型都写到 `copy` 上,后者覆盖前者。 */
|
||||
models: Array<{
|
||||
alias: string;
|
||||
model: string;
|
||||
/** 可选:覆写显示名;缺省走 models.dev 推导 */
|
||||
displayName?: string;
|
||||
/** 可选:覆写 context;缺省走 models.dev [API→models.dev→正则] 三级 */
|
||||
maxContextSize?: number;
|
||||
/** 默认 ["thinking"] */
|
||||
capabilities?: string[];
|
||||
}>;
|
||||
/** 关联方案 2:账单查询类型;缺省时不查。
|
||||
* **v0.3 改为数组**:同一个供应商可能同时支持套餐 + 余额(如 SiliconFlow
|
||||
* 既有平台余额又有套餐),数组形式让两端都查、UI 都展示。
|
||||
* 注意:`plan:volcengine` **不在 v1 union** —— Volcengine 套餐需独立 AK/SK
|
||||
* (不是推理 api_key),需要额外的 `access_key_id / secret_access_key` 参数,
|
||||
* v1 不支持,留待 P2。 */
|
||||
usageKinds?: ReadonlyArray<
|
||||
| "balance:deepseek" | "balance:siliconflow" | "balance:openrouter"
|
||||
| "balance:stepfun" | "balance:novita"
|
||||
| "plan:kimi_coding" | "plan:zhipu" | "plan:minimax"
|
||||
>;
|
||||
}
|
||||
export const providerPresets: ProviderPreset[] = [ /* … */ ];
|
||||
```
|
||||
|
||||
**`presetToProviderAndModels()` 显式转换契约**(v0.3 重要补充):
|
||||
|
||||
```ts
|
||||
export function presetToProviderAndModels(preset: ProviderPreset, options: {
|
||||
existingProviderNames: Set<string>;
|
||||
existingModelAliases: Set<string>;
|
||||
}): {
|
||||
provider: Provider;
|
||||
models: Model[];
|
||||
defaultModel: string; // 用于写入 config.default_model
|
||||
usageKinds: ReadonlyArray<string> | undefined; // 用于写入 SQLite settings
|
||||
} {
|
||||
// Provider.name 冲突 → 追加 -2 / -3
|
||||
let name = preset.id;
|
||||
let n = 2;
|
||||
while (options.existingProviderNames.has(name)) name = `${preset.id}-${n++}`;
|
||||
|
||||
// 模型 alias 冲突 → 追加 -2 / -3
|
||||
const models: Model[] = preset.models.map((m, i) => {
|
||||
let alias = `${name}/${m.model}`; // 强制规范
|
||||
let k = 2;
|
||||
while (options.existingModelAliases.has(alias)) alias = `${name}/${m.model}-${k++}`;
|
||||
|
||||
// max_context_size 4 级优先级
|
||||
const ref = modelsDevRef(m.model);
|
||||
const maxContextSize =
|
||||
m.maxContextSize
|
||||
?? ref?.maxContextSize
|
||||
?? parseContextFromName(m.model)
|
||||
?? DEFAULT_MAX_CONTEXT_SIZE; // 256000
|
||||
|
||||
return {
|
||||
alias,
|
||||
provider: name,
|
||||
model: m.model,
|
||||
max_context_size: maxContextSize,
|
||||
display_name: m.displayName ?? ref?.displayName ?? alias,
|
||||
capabilities: m.capabilities ?? ref?.capabilities ?? ["thinking"],
|
||||
supports_1m: maxContextSize >= 1_000_000,
|
||||
raw_other: {},
|
||||
};
|
||||
});
|
||||
|
||||
const provider: Provider = {
|
||||
name,
|
||||
provider_type: preset.providerType,
|
||||
base_url: preset.baseUrl,
|
||||
api_key: null, // 用户补
|
||||
env: {},
|
||||
note: null,
|
||||
official_url: preset.websiteUrl ?? null,
|
||||
managed: false,
|
||||
enabled: true,
|
||||
icon: preset.icon ?? null,
|
||||
icon_color: preset.iconColor ?? null,
|
||||
raw_other: {},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
models,
|
||||
defaultModel: models[0]?.alias ?? "",
|
||||
usageKinds: preset.usageKinds,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**字段映射表**(明确每条字段从哪来):
|
||||
|
||||
| Preset 字段 | Provider/Model 字段 | 备注 |
|
||||
|---|---|---|
|
||||
| `providerType` | `provider.provider_type` | 直透 |
|
||||
| `baseUrl` | `provider.base_url` | `null` → 留空 |
|
||||
| `websiteUrl` | `provider.official_url` | |
|
||||
| `icon` | `provider.icon` | `null` → 走首字母兜底 |
|
||||
| `iconColor` | `provider.icon_color` | |
|
||||
| `models[].alias` | `models[].alias` | **强制 `${uniqueProviderName}/${modelId}`** |
|
||||
| `models[].model` | `models[].model` | 直透 |
|
||||
| `models[].displayName` | `models[].display_name` | 缺省走 models.dev |
|
||||
| `models[].maxContextSize` | `models[].max_context_size` | 优先级链 |
|
||||
| `models[].capabilities` | `models[].capabilities` | 缺省 `["thinking"]` |
|
||||
| `id` | `provider.name` | 冲突 → `-2` |
|
||||
| `usageKinds` | SQLite `settings` 表 | **不**写 `config.toml`;数组形式支持同一供应商多种查询 |
|
||||
|
||||
### 4.2 v1 预设清单(15 条)
|
||||
|
||||
| id | 供应商 | category | provider_type | base_url | icon | iconColor | usageKinds | 备注 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `anthropic` | Anthropic 官方 | official | anthropic | `null` | `anthropic` | — | — | 无默认 base_url;Kimi Code 自动走 `api.anthropic.com` |
|
||||
| `kimi-coding` | Kimi Code 托管服务(会员订阅)| official | **kimi** | `https://api.kimi.com/coding/v1` | `kimi` | — | `[plan:kimi_coding]` | Kimi Code 托管服务走 `kimi` 类型(OpenAI 兼容 + Kimi identity headers + 视频上传)。Base URL 同时兼容 Anthropic 协议 `https://api.kimi.com/coding/`,但 preset 用 OpenAI 兼容路径 |
|
||||
| `moonshot` | Moonshot Platform API(按量付费)| cn_official | **kimi** | `https://api.moonshot.ai/v1` | `kimi` | — | — | Moonshot 平台 API 密钥用户;官方文档明确 `kimi` 类型默认 base_url 即此(与 `.cn` 互为别名) |
|
||||
| `deepseek` | DeepSeek | cn_official | openai | `https://api.deepseek.com/v1` | `deepseek` | — | `[balance:deepseek]` | — |
|
||||
| `zhipu` | 智谱 GLM | cn_official | openai | `https://open.bigmodel.cn/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — |
|
||||
| `zai` | z.ai(智谱海外)| third_party | openai | `https://api.z.ai/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — |
|
||||
| `bailian` | 阿里百炼 | cn_official | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `bailian` | — | — | — |
|
||||
| `minimax` | MiniMax | cn_official | openai | `https://api.minimaxi.com/v1` | `minimax` | — | `[plan:minimax]` | — |
|
||||
| `stepfun` | StepFun(阶跃星辰)| cn_official | openai | `https://api.stepfun.com/v1` | `stepfun` | — | `[balance:stepfun]` | — |
|
||||
| `siliconflow` | 硅基流动 | cn_official | openai | `https://api.siliconflow.cn/v1` | `siliconflow` | 显式补色(默认灰色)| `[balance:siliconflow]` | `siliconflow` 在 `src/icons/extracted/index.ts:93` 有 SVG,但 `metadata.ts` 没有;需显式 `iconColor` |
|
||||
| `novita` | Novita AI | third_party | openai | `https://api.novita.ai/v3` | `novita` | — | `[balance:novita]` | 注意 `/v3`(非 `/v1`) |
|
||||
| `openrouter` | OpenRouter | third_party | openai | `https://openrouter.ai/api/v1` | `openrouter` | — | `[balance:openrouter]` | — |
|
||||
| `openai` | OpenAI 官方 | official | openai | `https://api.openai.com/v1` | `openai` | — | — | — |
|
||||
| `google-genai` | Google AI Studio | official | google-genai | `https://generativelanguage.googleapis.com` | `google` | — | — | — |
|
||||
| `volcengine` | 火山方舟(仅推理)| cn_official | openai | `https://ark.cn-beijing.volces.com/api/v3` | `huoshan` | — | — | **v1 不支持套餐查询**(需 AK/SK),仅作推理 fallback |
|
||||
|
||||
**`codingplan`(自定义中转)v1 不做 preset**——价值低(无 base_url、无 usageKinds),与预设"一键填表"目标冲突。改在 `PresetPickerModal` 底部保留独立的"**+ 自定义配置**"按钮(来自空表单),与 cc-switch `ProviderPresetSelector.tsx:392-404` 一致。
|
||||
|
||||
**预估 icon 字段值校核**:上面表的 `icon` 值均来自 `src/icons/extracted/index.ts` 真实存在的 key;不能使用 `brands.ts` 推断 key 作显式 icon(两者命名空间不同)。P0 实现时若发现某个 key 不存在,需在 `src/icons/extracted/index.ts` 补 entry。
|
||||
|
||||
**模型清单里的 `maxContextSize` / `capabilities` 优先级**(与拉取模型同一体系):
|
||||
|
||||
1. 预设内置值(`ProviderPreset.models[].maxContextSize / capabilities / displayName` — 若有)
|
||||
2. `src/lib/models-dev.ts` 快照查找(`getModelRef`)
|
||||
3. 正则兜底(`src/lib/model-defaults.ts`)
|
||||
4. 全部 miss → **`DEFAULT_MAX_CONTEXT_SIZE`**(`src/lib/model-defaults.ts:1,83-89` 实际为 **256000**,不是 128000)
|
||||
|
||||
> v0.3 修正:早期文档误写 128000;统一用 `DEFAULT_MAX_CONTEXT_SIZE` 常量,避免双源真相。
|
||||
|
||||
**预设里绝大多数模型只需放 `alias` + `model id`**;仅对 models-dev 快照无覆盖的新发模型(如国产首发)才填覆写字段。
|
||||
|
||||
### 4.3 UI 流程
|
||||
|
||||
**入口 A(v1 必做)`PresetPickerModal`**:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ 选择预设供应商 [X] │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ 🔍[搜索] [排序:原始│A-Z] │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ [K] Kimi Coding [官方] │
|
||||
│ [A] Anthropic [官方] │
|
||||
│ [D] DeepSeek [国产] │
|
||||
│ [Z] 智谱 GLM [国产] │
|
||||
│ …(grid 150px×n) │
|
||||
│ [+自定义配置] │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ 预设只是一键填表,后续可继续修改所有字段。 │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 触发:① "添加"按钮 ② 顶部"+ 添加供应商"
|
||||
- 选中预设后:
|
||||
1. 用预设 `id` 作为默认 `Provider.name`(冲突时尾部追加 `-2`、`-3`…)
|
||||
2. 把 `models[]` 转成 `Model` 加进 `config.models`,**每个 alias 逐一检测冲突**:alias 已存在 → 尾部追加 `-2` / `-3`;**第一个 model** 设为 `default_model`(经过去重后的 alias)
|
||||
3. 写入 `provider.raw_other["kimi-switch.usageKinds"] = usageKinds`(沿用 raw_other 私有字段约定)
|
||||
4. 跳到 `ProviderEdit` 让用户填 `api_key`(其余字段已预填)
|
||||
- 分类徽章:官方 = 蓝、国产 = 绿、第三方 = 灰、聚合 = 紫
|
||||
- 网格自适应:`repeat(auto-fill, minmax(140px, 1fr))`,参考 cc-switch `ProviderPresetSelector.tsx:392`
|
||||
|
||||
**入口 B(v2,可选)`ProviderEdit` 顶部"切换预设"下拉**:对已存在但想换预设的供应商也能用。
|
||||
|
||||
### 4.4 持久化约定
|
||||
|
||||
- **name 由预设 id 派生**,用户可后续在表单里改名(不要锁)。
|
||||
- **`usageKinds` 存进 SQLite settings 表**(key: `usage_kinds:<provider_name>`,值为 JSON 数组),不写进 `config.toml`。详见 §三"私有字段存储约定"。
|
||||
- **不使用** `raw_other` 存 usageKinds(v0.3 修订:避免污染 Kimi Code 配置 + 支持 Rust 端自动 host detect)。
|
||||
|
||||
---
|
||||
|
||||
## 五、方案 2:供应商账单查询
|
||||
|
||||
### 5.1 Rust 端
|
||||
|
||||
```
|
||||
src-tauri/src/
|
||||
├── services/
|
||||
│ ├── mod.rs // pub use + 统一入口
|
||||
│ ├── usage_types.rs // UsageResult / UsageData
|
||||
│ ├── balance.rs // 余额类(5 家)
|
||||
│ └── coding_plan.rs // 套餐类(5 家)
|
||||
└── commands.rs // +#[tauri::command] query_provider_usage
|
||||
```
|
||||
|
||||
**usage_types.rs**(**注意必须加 camelCase** —— 缺它前端 `resetsAt/planName/isValid` 全部错位):
|
||||
|
||||
```rust
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageData {
|
||||
pub plan_name: Option<String>,
|
||||
pub remaining: Option<f64>,
|
||||
pub total: Option<f64>,
|
||||
pub used: Option<f64>,
|
||||
pub unit: Option<String>,
|
||||
pub is_valid: Option<bool>,
|
||||
pub resets_at: Option<String>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageResult {
|
||||
pub success: bool,
|
||||
pub data: Option<Vec<UsageData>>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
> **与 cc-switch 真实结构的差异**(重要):cc-switch 的 `UsageData` 在 `provider.rs:282-314` 还含 `extra: Option<Value>` / `invalid_message: Option<String>`,**没有** `resets_at`(该字段在 `SubscriptionQuota.tiers[].resets_at`)。本方案**不"照抄"**,而是把套餐的 `tiers` 展平成数组 + 每项带 `resets_at` 字段,统一前端消费面。`SubscriptionQuota → UsageData[]` 的转换规则实现时需明确(每个 tier 一条 `UsageData`,`plan_name = tier.name`,`used/total/remaining/unit` 视情况映射)。
|
||||
|
||||
**balance.rs / coding_plan.rs**:直接 port cc-switch 实现的 5 家 + 5 家(~670 行 + ~2200 行)。重点实现:
|
||||
|
||||
- `detect_provider(base_url) -> Option<...>`:按子串匹配
|
||||
- `query_xxx(api_key) -> Result<UsageResult, String>`:分别处理 401、读体失败、解析失败
|
||||
- **读体失败和解析失败要区分**:先 `bytes().await` 拿到完整字节再 `serde_json::from_slice`(见 balance.rs:99-108 的注释,reqwest `.json()` 把读体错也包成 decode,会丢信息)
|
||||
|
||||
**command.rs 新增**(**v0.3 修订 + v0.4 落实**:传 `provider_name` 让 Rust 端从 config 加载 key 与 usageKinds,避免 IPC 序列化 key):
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn query_provider_usage(
|
||||
agent: Agent, // v0.4 落实:项目 config 按 agent 隔离,必须传
|
||||
provider_name: String,
|
||||
force_refresh: Option<bool>, // true = 跳过 5min 缓存
|
||||
) -> Result<UsageResult, String> {
|
||||
// 1. 从 config / SQLite 加载该 provider
|
||||
// 2. 如果 api_key 为空 → 自动 fallback 到 provider.env 里的
|
||||
// expected_api_key_key(provider_type) 对应 key(v0.4 落实)
|
||||
// 3. 读取 usageKinds: 优先 SQLite 显式设置(JSON 数组)→ fallback detect_provider(base_url) → success:false "unknown provider"
|
||||
// 4. 对每个 kind 路由到对应 query_xxx,合并所有结果到一个 UsageData[]
|
||||
// 5. 返回 UsageResult(data 含全部 kind 的条目)
|
||||
}
|
||||
```
|
||||
|
||||
**v0.4 重要**:Rust 端除 `provider.api_key` 外,**会兜底读取 `provider.env` 里的 `expected_api_key_key(provider_type)`**(如 Kimi 平台期望 `KIMI_API_KEY`)。这只读 env 用于实际查询,**不**写回 config.toml,给只配 env 不配 api_key 的用户兜底。
|
||||
|
||||
**重要**:前端**不**传 `api_key` / `usage_kind` / `base_url`。Rust 端统一从配置加载,杜绝:
|
||||
- API key 经 IPC 序列化(终审 D4)
|
||||
- 前端绕过 host 校验乱发请求(终审 D1)
|
||||
- Rust detect 与前端规则矛盾(终审 D2)
|
||||
|
||||
**Tauri 配置文件注册**(**v0.3 修订**:Rust reqwest 不走 Tauri HTTP plugin,不需 capability):
|
||||
|
||||
1. `commands.rs` 加 `#[tauri::command]` ✅
|
||||
2. `lib.rs` 的 `tauri::generate_handler![…]` 追加 `query_provider_usage`
|
||||
3. `capabilities/default.json` **无需改动**(现有 `test_connectivity` 同样用 Rust reqwest,不需 HTTP scope)
|
||||
|
||||
**Tauri config**:`reqwest::get` 是 Rust 端发出,**不**走 WebView 网络栈,**不**需要改 CSP。
|
||||
|
||||
### 5.2 前端
|
||||
|
||||
**ProviderList 卡片底部的 `<UsageFooter>`**:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ Kimi For Coding [使用中] │
|
||||
│ 默认: kimi-k2.7-code 3 models │
|
||||
├────────────────────────────────────────┤
|
||||
│ ⚡ 35% 已使用 · 5h 后重置 [刷新] │ ← 套餐类(green/orange/red)
|
||||
└────────────────────────────────────────┘
|
||||
|
||||
┌────────────────────────────────────────┐
|
||||
│ DeepSeek [使用中] │
|
||||
│ 默认: deepseek-chat 2 models │
|
||||
├────────────────────────────────────────┤
|
||||
│ 💰 余额 ¥12.34 [刷新] │ ← 余额类
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 配色:<70% 绿 / 70-89% 橙 / ≥90% 红(对齐 cc-switch)
|
||||
- **多 kind 渲染**:同一 provider 的 `usageKinds` 数组可能含多种(如 `[plan:kimi_coding, balance:custom]`);`UsageData[]` 每条独立渲染为一行,余额类显金额、套餐类显百分比+倒计时
|
||||
- 倒计时:前端 JS 算 `new Date(resetsAt) - Date.now()`,按区间显示"5h 后重置 / 3d 后重置"(绝对时间,Rust 端已转 ISO 8601,不依赖时区)
|
||||
- 缓存:**5min stale TTL**(`force_refresh` 跳过)+ Manual 刷新(**v0.3 修订**:与 cc-switch `queries.ts:245-277` 对齐)。进入 ProviderList 时对有 usageKinds 的供应商并发查一次,**最多同时 3 个请求**(`Promise.allSettled` + 简易信号量),单请求超时 8 秒
|
||||
- 错误:网络错"网络异常"、401"API Key 无效"、其他"查询失败" —— 不阻塞主界面
|
||||
- 没 `usageKinds` 字段(undefined 或空数组)→ **完全不显示 footer,不发起任何请求**
|
||||
|
||||
**状态机**(每个 provider 独立维护):
|
||||
|
||||
| 当前状态 | 触发 | 行为 |
|
||||
|---|---|---|
|
||||
| `idle` | 首次进入 ProviderList | → `loading`(并发 ≤3) |
|
||||
| `loading` | 响应到达 | `ok` → `success`;`err` → `error` |
|
||||
| `success` | 用户点「刷新」 | → `loading`,保留上一次 `data` 做 ghost 展示 |
|
||||
| `error` | 用户点「重试」 | → `loading` |
|
||||
| `success`/`error` | 缓存超 30s,重新进入列表 | 自动 → `loading` |
|
||||
| 任何 | 用户清空 `api_key` | → `idle`(不发起请求,见 §5.1 命令层检查) |
|
||||
|
||||
**渲染**:
|
||||
|
||||
- `loading`:骨架屏或上次数据的半透明 ghost
|
||||
- `success`:余额/百分比 + 配色 + 重置倒计时
|
||||
- `error`:红色错误提示 + 「重试」按钮
|
||||
- `idle`:无(不渲染 UsageFooter)
|
||||
|
||||
**触发与权限**(**v0.3 修订**):
|
||||
|
||||
- 启动时**不**预查
|
||||
- 进入 ProviderList 视图时并发查 1 次(≤3 并发,8s 超时)
|
||||
- 用户点"刷新"按钮才再次查(5min stale TTL)
|
||||
- 全局开关:设置面板加"启动账单查询"开关,关闭后 Rust 端拒绝查询(默认开启)
|
||||
- **前端不做 usageKinds 决策**:单纯 `invoke("query_provider_usage", { provider_name: "deepseek" })`,Rust 端返回 `success: false` 时客户端仅展示错误,不做 host 校验
|
||||
|
||||
### 5.3 与现有"用量仪表盘"的关系
|
||||
|
||||
- **现有仪表盘**:本地 SQLite 聚合的 token 计数 + 费用估算(基于 `models.dev` 价格)
|
||||
- **新增账单查询**:供应商侧的余额/套餐
|
||||
- **不重复**:仪表盘 chip 区域不显示余额,余额只在 ProviderList 卡片底部
|
||||
- **联动**:可选地(v2)在仪表盘 KPI 加一个"供应商侧配额"卡,但 v1 不做
|
||||
|
||||
---
|
||||
|
||||
## 六、里程碑
|
||||
|
||||
| 阶段 | 内容 | 优先级 |
|
||||
|---|---|---|
|
||||
| **P0** | 方案 1:15 条预设 + `PresetPickerModal` + App 入口接入 | P0 |
|
||||
| **P0** | 方案 2 骨架:`services/{usage_types,mod}.rs` + `query_provider_usage` 命令 + `UsageResult` + **camelCase 序列化** | P0 |
|
||||
| **P0** | `query_provider_usage` 两层注册(commands.rs + lib.rs;**无 capabilities 变更**) + Rust 端 mock 测试 | P0 |
|
||||
| **P0** | SQLite 改造:`usage_kinds:<provider>` 存 settings 表(JSON 数组);`load_agent_config_command` 合并回 config;export 时不写 usageKinds | P0 |
|
||||
| **P0** | `detect_provider(base_url)` 启发式:迁移期旧用户自动获得 usageKinds | P0 |
|
||||
| **P0** | 方案 2 前端:`<UsageFooter>` + 卡片接入 + 错误处理 + 配色 + 状态机 + 5min stale TTL | P0 |
|
||||
| **P0** | **套餐类 v1:Kimi For Coding(`api.kimi.com/coding/v1/usages`)**(项目主题,优先) | P0 |
|
||||
| **P0** | 余额类 v1:DeepSeek / SiliconFlow / OpenRouter(3 家) | P0 |
|
||||
| **P0** | 前端手工验证 checklist(见 §十一 #20) | P0 |
|
||||
| **P1** | 套餐类补全:智谱 GLM / MiniMax | P1 |
|
||||
| **P1** | 余额类补全:StepFun / Novita | P1 |
|
||||
| **P2** | `ProviderEdit` 顶部"切换预设"下拉(入口 B) | P2 |
|
||||
| **P2** | 设置面板"启动账单查询"开关 | P2 |
|
||||
| **P2** | 倒计时 i18n 友好格式化("5小时后" / "in 5h") | P2 |
|
||||
| **P2** | Volcengine 套餐查询(独立 AK/SK 支持 + UI) | P2 |
|
||||
| **P3** | 自定义 JS 脚本兜底(usage_script.rs + rquickjs) | P3 |
|
||||
| **P3** | 仪表盘 KPI 加"供应商侧配额"卡 | P3 |
|
||||
|
||||
---
|
||||
|
||||
## 七、许可证与引用
|
||||
|
||||
cc-switch 是 MIT(`D:/AIGC/cc-switch/LICENSE`),Rust 实现(`balance.rs` / `coding_plan.rs`)可直接移植;预设数据(base_url、模型 id 等公开信息)不构成版权客体。
|
||||
|
||||
**文件头添加**:
|
||||
|
||||
```rust
|
||||
// Adapted from cc-switch (MIT, © Jason Young)
|
||||
// https://github.com/farion1231/cc-switch
|
||||
```
|
||||
|
||||
**README 致谢段补一条**:
|
||||
|
||||
> 供应商预设结构与余额/套餐查询实现参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT,© Jason Young)。
|
||||
|
||||
**配置文件同步**:preset 名 i18n key 同样放 `src/i18n/{zh,en}.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 八、风险与待确认
|
||||
|
||||
| 风险 | 缓解措施 | 状态 |
|
||||
|---|---|---|
|
||||
| 预设的 `id` 与用户已存在的 `provider.name` 冲突 | `presetToProviderAndModels()` 内 `while` 循环追加 `-2` / `-3` | ✅ |
|
||||
| 模型 alias 冲突 | 强制 alias = `${name}/${modelId}`,冲突追加 `-2` / `-3` | ✅ |
|
||||
| 裸 alias 破坏 `handleDuplicateProvider` | **强制规范**(§4.1 转换契约);`src/App.tsx:271` `alias.slice` 改造为 `startsWith` 显式判断 + 完整 re-prefix fallback(v0.4 落实) | ✅ |
|
||||
| `usageKinds` 漂移(TS 端枚举与 Rust 端 enum 各自维护) | TS 端 enum + Rust 端 enum 各自声明;TS 端在 dev 模式加载 `providerPresets.ts` 时对每个 preset 的 usageKinds 做运行时断言(v0.4 新增 `SUPPORTED_USAGE_KINDS` Set + `import.meta.env.DEV` guard) | ✅(dev-only 断言)|
|
||||
| Zhipu/MiniMax 的套餐端点可能限频 | 5min stale TTL + 失败 keep-last-good;网络错误自动 retry | ✅ |
|
||||
| Kimi For Coding 套餐鉴权:官方文档已确认 `kimi` 类型 + `Authorization: Bearer <sk-...>` 即可,与 cc-switch 实现一致 | ✅ 已核验([官方 providers 文档](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html))| ✅ |
|
||||
| StepFun / Novita 的 base_url 匹配与现有 OpenAI 兼容克隆冲突 | `detect_provider` 在 `detect_provider_openai` 之前调用 | ✅ |
|
||||
| `defaultBaseUrl` 与预设 base_url 双方有值 | 预设的 baseUrl **覆盖** `default_base_url()`;保存时以 `provider.base_url` 为唯一真相 | ✅ |
|
||||
| provider 改 `base_url` / `provider_type` 后旧 usageKinds 错路由 | usageKinds 不显式存 provider 字段,Rust 端每次 `query_provider_usage` 重新 `detect_provider(base_url)` | ✅ |
|
||||
| 旧用户升级 0.6.0 后无 usageKinds | Rust 端 `detect_provider(base_url)` 启发式作为 fallback,自动识别 DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita / Kimi Coding / 智谱 / MiniMax | ✅ |
|
||||
| API key 暴露:当前 edit UI 已持有明文 key,IPC invoke 序列化 key 是"再次"暴露面 | command 改为 `provider_name`,Rust 端从 config 加载 key,**不**经 IPC 传 key。错误/调试日志中严禁输出 key | ✅ |
|
||||
| Tauri capability 是否需要 | Rust reqwest 不走 Tauri HTTP plugin,无 capability 变更需求 | ✅ |
|
||||
| `usageKinds` 写进 config.toml 污染 Kimi Code 配置 | 存 SQLite settings 表(JSON 数组),export 时不写 | ✅ |
|
||||
| 限额与套餐 UI 区分 | 按 `plan_name` 判断:`five_hour` / `weekly_limit` → 百分比+倒计时;其他 → 余额数值 | ✅ |
|
||||
| Pi 与 KimiCode 同名 provider 共享 UsageFooter 缓存造成串扰 | v0.4 落实:模块级 cache key 改为 `${agent}:${providerName}`(UsageFooter.tsx:36) | ✅ |
|
||||
| 用户用 env 配 key 但 `api_key` 字段为空 | v0.4 落实:Rust 端 `query_provider_usage` 兜底读 `provider.env[expected_api_key_key(provider_type)]` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 九、需要在开始前确认的决策点
|
||||
|
||||
**状态标签说明**:
|
||||
- ✅ `代码已核验` —— 已对照源码确认可行
|
||||
- 🟡 `产品决策` —— 已选,但落地细节随实现调整
|
||||
- 🟠 `待真实 API 实测` —— 需真实 key 实测后才能标完成
|
||||
- ⚪ `待用户确认` —— 文档中给了倾向,但需用户最终决定
|
||||
|
||||
1. ✅ **预设触发方式**:先做入口 A(独立弹窗)。入口 B 推迟到 P2。
|
||||
2. 🟡 **预设的模型 maxContextSize / capabilities**:靠 models.dev 推导 + 预设可选覆写字段(§4.1 已明确 4 级优先级)。
|
||||
3. 🟡 **账单查询触发**:首次进入自动查(≤3 并发)+ 5min stale TTL + 刷新/重试按钮。
|
||||
4. 🟡 **Kimi For Coding 套餐**:P1(先 P0 跑通后再做)。
|
||||
5. ✅ **自定义 JS 脚本**:不做(rquickjs 过重)。
|
||||
6. 🟡 **预设 v1 清单**:15 条(不含 volcengine 套餐 + codingplan),够用。
|
||||
7. ✅ **usageKinds 持久化**:SQLite settings 表(JSON 数组,v0.3 修订),不写 config.toml。
|
||||
8. ✅ **kimi-coding preset 的 `provider_type`**:用 **`type: "kimi"`** + `base_url: "https://api.kimi.com/coding/v1"`。**官方文档已确认**:`kimi` 类型 = "对接 Moonshot AI 的 OpenAI 兼容接口,包括 Kimi Code 托管服务 + Kimi Platform API 密钥"。`moonshot` preset 同样用 `kimi` 类型(之前误写 `openai`,已修)。
|
||||
9. ⚪ **Volcengine 套餐 v1 不做的取舍**:v1 仅保留 volcengine 推理预设,去掉套餐查询。是否可接受?
|
||||
|
||||
**用户实标 ✅ 决议后的项目**(待用户回复 8/9 后):
|
||||
|
||||
---
|
||||
|
||||
## 十、附录:cc-switch 借鉴到的关键代码位置
|
||||
|
||||
- `src/config/claudeProviderPresets.ts:25-74` — `ProviderPreset` 接口
|
||||
- `src/config/codexProviderPresets.ts:13-46` — `CodexProviderPreset` 接口(带 modelCatalog)
|
||||
- `src/components/providers/forms/ProviderPresetSelector.tsx:142-500` — Reference UI
|
||||
- `src/components/providers/forms/ProviderForm.tsx:1794-1899` — `handlePresetChange` 字段注入逻辑
|
||||
- `src-tauri/src/services/balance.rs:1-454` — 余额查询完整实现(5 家)
|
||||
- `src-tauri/src/services/coding_plan.rs:100-206` — Kimi For Coding 单家实现
|
||||
- `src-tauri/src/provider.rs` — `UsageResult` / `UsageData` 类型定义
|
||||
- `docs/user-manual/en/2-providers/2.5-usage-query.md` — 设计语义文档(中文版同路径)
|
||||
|
||||
---
|
||||
|
||||
## 十一、自审发现的问题(v0.1 → v0.2 已修正)
|
||||
|
||||
> 本节是第一轮自审发现的问题。v0.2 已全部修正或记录到正文 / §八风险表 / 对应章节。
|
||||
>
|
||||
> 第三轮审核额外发现 20 项(6 阻塞 + 7 类型/语义 + 7 逻辑/边界),已直接修入正文,不在此重复。关键修正:
|
||||
> - B1 「baseUrl: string → string | null」已修正(§4.1)
|
||||
> - B2 「category 缺少 custom」已补(§4.1)
|
||||
> - B3 「model alias 冲突检测」已补(§4.3 步骤 2)
|
||||
> - B5 「三层注册清单」已补(§5.1)
|
||||
> - B6 「并发控制 ≤3」已补(§5.2)
|
||||
> - L1 「icon 补齐」已在 §4.2 表为预设加 `icon` 字段(值由 P0 实现时从现有 IconPicker 名映射表中读取)
|
||||
> - L2 「models-dev 缺失回退 4 级优先级」已补(§4.2 底部)
|
||||
> - L3 「状态机」已补(§5.2)
|
||||
> - L5 「空 api_key 拒绝」已补(§5.1 命令层)
|
||||
> - S4 「models 数组为空」已补(§4.1 注释)
|
||||
|
||||
| # | 问题 | 状态 |
|
||||
|---|---|---|
|
||||
| 1 | **预设 `id` 与已有 provider.name 冲突** | ✅ §4.4 已解决 |
|
||||
| 2 | **usageKinds 写进 raw_other 会进 config.toml** | ✅ §三 私有字段持久化约定已明确 `kimi-switch.` 命名空间 |
|
||||
| 3 | **默认 base_url 来源双源** | ✅ §八·风险 #7 + §4.1 baseUrl 注释 |
|
||||
| 4 | **TS 端 usageKinds 与 Rust 端 enum 漂移** | ✅ §八·风险 #2 |
|
||||
| 5 | **Kimi For Coding 鉴权格式未实测** | ✅ §八·风险 #5:P1 前 curl 验证 |
|
||||
| 6 | **StepFun/Novita base_url 与 OpenAI clone 冲突** | ✅ §八·风险 #6 |
|
||||
| 7 | **倒计时 i18n 友好格式化** | → §六 P2 |
|
||||
| 8 | **账单查询全局开关** | → §六 P2 |
|
||||
| 9 | **入口 B 切换预设** | → §六 P2 |
|
||||
| 10 | **model 字段名 vs Kimi Code 识别** | 核验后确认:`[models.alias]` 块与 `[providers.name]` 块独立,无冲突风险 ✅ |
|
||||
| 11 | **图标名 → IconPicker 映射表** | → P0 实现时补 |
|
||||
| 12 | **设置面板账单查询开关分组** | → §六 P2:放"设置"Tab 同一分组 |
|
||||
| 13 | **模型 alias 命名规范** | ✅ §4.1 已明确 `provider/model` 形式 |
|
||||
| 14 | **Moonshot 无余额 API → 不显示 footer** | ✅ §5.2:无 usageKinds = 不渲染 |
|
||||
| 15 | **OpenAI 协议选择 `openai`** | ✅ 不暴露 `openai_responses` 让用户困惑 |
|
||||
| 16 | **多端点候选 v1 不做** | ✅ |
|
||||
| 17 | **extra_usage 字段简化** | ✅ §5.1 只留 `UsageData[]` |
|
||||
| 18 | **is_valid: false 透出** | ✅ §5.1 类型已含 |
|
||||
| 19 | **跨会话缓存 v1 不做** | ✅ 30s TTL 够用 |
|
||||
| 20 | **测试覆盖 P0 必须** | ✅ 已加回 §六里程碑 |
|
||||
|
||||
---
|
||||
|
||||
*文档结束 — v0.4-draft(v0.3 已实施 + v0.4 落实 4 项收尾:handleDuplicateProvider 兼容老 alias / UsageFooter cache 加 agent / dev 模式跨端断言 / env 兜底读 key)。配套人工回归清单见 [VERIFICATION-CHECKLIST.md](./VERIFICATION-CHECKLIST.md)。*
|
||||
Reference in new issue
Block a user