# 架构
## 整体结构 (v0.1.0)
```
┌─────────────────────────────────────────────────────────────┐
│ DSH webview (浏览器) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ conversation.composer.bar (DSH ship 容器) │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ [+][Full access]│[musage inline]│[model][↗] │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ modes (DSH flex) ↑↑↑ 我们注册到 .trailing │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲ │
│ 同源 fetch (JSON) │ webServer 路由
│ GET /musage/quota │ (后台轮询 / 缓存)
│ ?provider=minimax|deepseek|...
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ DSH host (Node.js) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ musage-minimax-quota Plugin (host) │ │
│ │ - 定时器 5min 预热 → 走 PROVIDERS[provider] 查表 │ │
│ │ - 30s TTL 缓存, 错误指数退避 (5s base, 30min cap) │ │
│ │ - 6 个 provider 通用路径 (minimax / deepseek / │ │
│ │ kimi / openrouter / zhipu / dashscope), │ │
│ │ 每个独立 schema 解析 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ │ subprocess.spawn(curl -K -, 鉴权 header 走 stdin) │
│ ▼ │
│ DSH subprocess Service (subprocess-local) │
│ │ HTTPS │
│ ▼ │
│ api.minimaxi.com / api.deepseek.com / api.kimi.com / │
│ openrouter.ai / open.bigmodel.cn / │
│ bailian.console.aliyun.com (百炼 Coding Plan) │
└─────────────────────────────────────────────────────────────┘
Client 端:
┌─────────────────────────────────────────────────────────────┐
│ InlineReadout (React) │
│ - useEffect subscribe ctx.modelDirectories.directoryFor(sessionId)│
│ - 状态变化 → setProvider(route → mapped) │
│ - useEffect 依赖 [provider, timer] → fetch('/musage/quota
│ ?provider=
') │
│ - renderDisplay 按 provider 分发 5h/7d 双窗 或 余额 │
└─────────────────────────────────────────────────────────────┘
```
## 关键设计
### 1. 复用 DSH 已配 key (零凭证输入)
- 不读本地 `keys.json`, 走 DSH `credentials.resolve(ref)`
- ref 名按 DSH 客户端设置规则: `${PROVIDER_UPPER}_API_KEY`
- `minimax-cn` → `MINIMAX_CN_API_KEY`
- `deepseek` → `DEEPSEEK_API_KEY`
- `kimi-coding` → `KIMI_CODING_API_KEY`
- `zai-coding-cn` → `ZAI_CODING_CN_API_KEY`
- `openrouter` → `OPENROUTER_API_KEY`
- 用户在 DSH 模型设置里配的 key 直接被用, plugin 完全不碰密钥存储
### 2. 多 provider 通用 host
- `PROVIDERS` 注册表: 每个 entry 含 `refs / urls / parse / authHeaders? / method? / body? / fallbackUrl?`
- `fetchProviderQuota(provider)` 查表, 走同一段 curl + 解析流程
- 6 个 parser 各自独立 (从 Musage 各 rs 文件抄 schema):
- `parseMinimaxResponse`: 2026-06-01 双 schema (percent-based / count-based)
- `parseDeepseekBalance`: `balance_infos[].total_balance` (v0.0.20 修对, 不是老 ccswitch `balance[]`)
- `parseKimiResponse`: `limits[].detail.{limit,remaining,resetTime}` + `usage.{...}`
- `parseOpenrouterResponse`: `total_credits - total_usage` = 余额
- `parseZhipuResponse`: `unit=3` (5h) + `unit=6` (周) 双窗口, **智谱特殊 `Authorization: ` 不加 Bearer**
- 智谱 `authHeaders` 不加 Bearer 前缀; 百炼 dashscope 是唯一 POST + `{}` body 端点, 且国内/国际双宿主 (ConsoleNeedLogin 回退)
- 鉴权 header 一律经 stdin curl 配置 (`curl -K -`) 下发, 不进 argv —— argv 里的 key 会被本机任意用户 `ps` 看到
### 3. 跟 DSH 模型自动切换
- client 端 `inject: ['timer', 'modelDirectories']`
- `models.directoryFor(sessionId).store.subscribe(callback)` 监听当前 model 切换
- `PROVIDER_ALIASES` 映射 DSH provider route id → 我方 PROVIDERS key:
- `minimax-cn` / `minimax-en` / `minimax` → `minimax`
- `deepseek` / `deepseek-official` → `deepseek` (DSH 实际用 `deepseek-official` 带 -official 后缀)
- `kimi-coding` → `kimi`
- `openrouter` → `openrouter`
- `zai-coding-cn` → `zhipu`
- model 变化 → setProvider(mapped) → 立刻重 fetch 对应 provider
### 4. Slot 位置
注册到 `conversation.input.right` (list kind, scope session):
- DSH 渲染: `[rightItems, renderSlot("input.model"), ContextMeter, primary]`
- 我的 entry 紧邻 `model select` 左侧 (`.trailing` flex 容器内)
- `display: inline-flex; margin-left: auto` → 推自己到容器最右 → 贴 model select
- v0.0.8 之前用 `conversation.composer.dock` 错位 (`Full access` 旁边, 不挨 model select), 已弃
### 5. 错误退避
- 健康: 60s 轮询 + 30s 内存缓存
- 错误: 5s → 10s → 20s → ... → 30min cap
- 错误恢复: 立即回到正常 60s
- 失败时 client 显示 `Provider ⚠` (黄), hover 看具体错误 (`HTTP 401 · 余额 字段为空` 等), 不刷屏
### 6. 调试方法 (核心经验)
`dsh/index.js` (host 半边) 的 `console.log` 打 `/private/tmp/dsh.log` (DSH 进程 stdout). 加 8 处诊断点 (`resolveExecutable / spawn / done / stderr / statusCode / body / parsed`), 15 步踩坑在 `docs/cordis-pitfalls.md` 沉淀.
## 完整 package 演进
| 版本 | 关键变更 |
|---|---|
| v0.0.1 | PoC, `composer.dock` slot, `setInterval` (v0.0.2 修) |
| v0.0.2 | 改 `ctx.timer.interval` (动态 client half 禁用 setInterval) |
| v0.0.3 | 弃 `web.fetch` 改 `subprocess + curl` (DSH 部署无 fetch provider) |
| v0.0.4 | stdio 改 `{maxBytes: N}` 对象 (原 `'collect'` 字符串抛错) |
| v0.0.5 | Slot 改 `input.dock` |
| v0.0.6 | 拆 sidebar 按钮 + input.right (位置错) |
| v0.0.7-v0.0.8 | 改 `input.left` (但 `marginLeft:auto` 不工作) |
| v0.0.9 | 试 `marginLeft: auto` (无效) |
| v0.0.10 | 改 `input.dock` + `text-align: right` (flex 容器无效) |
| v0.0.11-v0.0.12 | 试 `shell.overlay` 浮窗 (估算 minimax 位置错) |
| v0.0.13 | **回到 `input.left` + `width:100% + text-align:right`**, 位置终于对 |
| v0.0.14 | 加全链路 `console.log` 诊断 |
| v0.0.15 | **stdio 改对象形式 `{stdin,stdout,stderr}`** (之前数组错) |
| v0.0.16 | **Slot 改 `input.right`** (DSH ship `.trailing` flex 容器, 紧贴 model select 左侧) |
| v0.0.17 | 通用化 host (PROVIDERS map, 走 `quota:fetch` handler) + client 跟 DSH model 切换 |
| v0.0.18 | 修 inject 名字 `models` → `modelDirectories` (DSH `super(ctx, "modelDirectories")` 实际名) |
| v0.0.19 | 修 `PROVIDER_ALIASES` 漏 `deepseek-official` (DSH 实际 provider id 带后缀) |
| v0.0.20 | 修 DeepSeek 解析走 Musage 真实 schema (`balance_infos[].total_balance`, 不是老 ccswitch `balance[]`) |
| v0.0.21 | 扩 5 个 provider (kimi / openrouter / zhipu) |
| v0.1.0 | **转 bundle 形态**: `dsh plugin add` 可装, client→host 从 `host.call` 改同源 fetch `/musage/quota` 路由, host 服务访问从 `ctx.get()` 改 inject 属性访问 |
| v0.1.3 | 扩 6 个 provider (dashscope 百炼 Coding Plan); 鉴权 header 移入 stdin |
| v0.1.4 | **切套餐现场取数**: client 切 provider 带 `force=1` 真拉 + 失败立刻 force 补拉 (失败退避缓存不再把 ⚠ 钉屏最长 30min); host 轮询懒预热 (只拉被请求过的 provider); zhipu 业务码检查对齐上游 (code-only 失败体 / 401xx); refs 补 `ZAI_CN_API_KEY` / `GLM_API_KEY` |