Files
KimiCodeSwitch/docs/PROPOSAL-presets-and-usage.md

36 KiB
Raw Permalink Blame History

提案:预设供应商 + 供应商账单查询

状态:v0.4-draft(v0.3-draft 已实施;P0 全部落地,新增 v0.4 修订) 作者:Kimi Switch 内部 目标版本:0.6.0 参考:cc-switch(MIT,© Jason Young)— 仅借鉴字段结构与 Rust 用量查询层模式 Kimi Code 官方文档:providers 配置、配置文件


一、背景与目标

当前 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 注释):

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:

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 重要补充):

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 全部错位):

#[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):

#[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 等公开信息)不构成版权客体。

文件头添加:

// Adapted from cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch

README 致谢段补一条:

供应商预设结构与余额/套餐查询实现参考自 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 文档) ✅
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。