# mydsh-auto-model-router

一个适配 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)（DSH）的混合式自动模型路由插件。通过 **L0–L3 四个能力层级**为每个 agent 请求选择模型——日常问答、代码测试、编写审查、复合多步任务——支持用户可配置的成本策略、可选的 LLM 任务分类器、以及失败降级链。

> 设计思路参考 [open-world-project/model-router](https://github.com/open-world-project/model-router)（Hermes Agent）与 [opencode-model-router](https://github.com/marco-jardim/opencode-model-router)（fast/medium/heavy 分层）：层级化路由 + 成本模式决定模糊时的偏移方向。

## 致谢

本插件的思路来自以下开源项目：

- [open-world-project/model-router](https://github.com/open-world-project/model-router) — Hermes Agent 的自动模型路由器，含关键词评分和成本感知分层选择
- [opencode-model-router](https://github.com/marco-jardim/opencode-model-router) — OpenCode 的 fast/medium/heavy 分层委托插件
- [openclaw-routing-yaml](https://github.com/glasshousehq-os/openclaw-routing-yaml) — OpenClaw 的声明式 YAML 按任务路由
- [openmarkai/openclaw-router](https://github.com/openmarkai/openclaw-router) — 基于基准测试驱动的模型路由
- [MoeAisaka/openclaw-model-policy-router](https://github.com/MoeAisaka/openclaw-model-policy-router) — OpenClaw 的策略驱动 fail-closed 路由
- [NanmiCoder/dsh-auto-mode](https://github.com/NanmiCoder/dsh-auto-mode) — DSH 的自动权限模式；客户端设置页实现参考
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — 本插件所适配的宿主平台

- [English](README.md)
- 中文（本文档）

## 工作原理

```
用户消息
  │
  ▼
① agent/pre-step         任务文本 + token 记账
  │
  ▼
② system-prompt/assemble 让 {{model}} 与路由结果保持一致
  │
  ▼
③ agent/request   ★ 决策点：
  │
  ├─ Tier 1 规则：        子串 / 正则匹配 → 层级（L0…L3）——永远优先
  ├─ Tier 2 成本：        预算耗尽 → 钉到最便宜层级；
  │                      未命中规则 → 模式决定默认层级
  ├─ Tier 3 分类器：      可选便宜 LLM → 层级；层级缺模型 → 按模式漂移
  └─ Tier 4 降级：        请求失败 → 沿链切换模型并重试
  │
  ▼
LLM 分发
```

### 四个层级

| 层级 | 典型任务 | 模型建议 |
| --- | --- | --- |
| `L0` | 日常问答、快速回答 | 最便宜/最快 |
| `L1` | 小改动、简单测试 | 普通 |
| `L2` | 写代码、审查、重构 | 较强 |
| `L3` | 复杂 BUG 修复、多步任务、汇报 | 最强 |

每个层级是一个用户自行指定的 provider/model 路由，**可选**附带
`reasoningEffort`：不写（或写 `null`）= 不发送该字段——GLM/Qwen flash 这类没有
思考模式的模型**必须**这样，否则宿主会拒绝请求；`off` = 显式关闭思考；`high`/`max`
= 思考强度。层级可以留空不配置；路由会落到最近的已配置层级（方向由成本模式决定）。

## 模型选择框中的 Auto 模式

插件注册了一个虚拟的 `auto` provider，模型选择框会出现 **Auto Router** 分组，
内含一个 **Auto** 模型：

```
Auto Router
  └─ Auto
deepseek-official
  ├─ deepseek-v4-flash
  └─ deepseek-v4-flash
```

- 选 **Auto** → 插件接管，按 L0–L3 层级自动路由。
- 选**具体模型** → 插件原样放行，你手选的模型优先，路由不干预。

### 设置界面页签（浏览器端）

浏览器端会在 DSH 设置面板（齿轮图标 → 侧栏）注册一个 **Auto Router**
页签，展示当前路由策略——即 host 在会话开始时注入的
`<mydsh-auto-model-router-status>` 状态报告——并附提示文案：如需修改配置，
直接在对话中告诉 AI（例如"把 L2 改成 deepseek-v4-flash 且 max 思考"或
"成本模式切换为 cost-first"），AI 会帮你编辑 `cordis.patch.yml`，
重启 DSH 后生效。

在模型选择框选 **Auto** 立即生效，不再有确认弹窗。

无需额外构建步骤：`client.js` 是本仓库维护的自包含 ModuleLoader bundle
（`pnpm run build:client` 可从 `client/` 源码重新生成）。

## 安装

```bash
dsh plugin --profile web add ./mydsh-auto-model-router
```

或通过已发布的 npm 包安装：

```bash
npm install @apinggithub/mydsh-auto-model-router
dsh plugin --profile web add @apinggithub/mydsh-auto-model-router
```

## 宿主兼容性

DSH 把 profile 的插件树当成一次组合：补丁失败的 bundle 可能让整个 Harness 起不来，
而抛错的插件条目会被标记为「did not activate」——路由器从会话里直接消失。
所以本插件的行为约定是**能挂载、能降级、能打日志**，而不是在接口变动时直接失败。

| 宿主版本线 | 状态 |
| --- | --- |
| 0.1.x – 0.3.x | 已实测（`0.2.1-rc.2` 为基准宿主） |
| 0.4.x – 1.x | 允许挂载，但在日志与 `GET /api/mydsh-auto-model-router/status` 中标记为「未实测」 |

挂载时 `compat.mjs` 会探测插件用到的每个接口（`llm.registerAdapter`、
`llm.listModels`、`llm.stream`、scoped `ctx.inject`、`webServer.register`、
可选的 `tokenMeter`），结果记录在状态接口的 `host.compat` 字段里。在未知宿主上：

- **`tokenMeter` 不再是硬依赖**——软查找，缺失时改用插件自带的估算器。
- **没有 `registerAdapter` 也能挂载**——只记一条警告，模型选择框里不出现
  `Auto`，路由逻辑本身不受影响。
- **HTTP 路由方法适配**——`webServer.register()`、`route()`、`addRoute()`
  都可接受；全都没有时设置页显示「暂无报告」，而不是报错。
- **事件负载容错**——既接受当前的 `{ agent }` / `(session, event)` 形状，也接受
  裸 agent / `{ session, event }` 形状；处理函数抛错时原样返回宿主自己的提案，
  路由器的 bug 不会再让用户请求失败。
- **不再使用进程级全局变量**——适配器按每次挂载创建并闭包引用该次挂载的
  router，同进程多 profile 不会互相覆盖。
- **版本报告**——当宿主愿意暴露版本（环境变量、app 服务或可解析的 manifest）时，
  超出 `>=0.1.0-0 <0.4.0` 会打一条同时含宿主版本与实测范围的警告。能力探测始终
  执行，因此即使宿主隐藏版本，也能从状态接口看到它实际提供了哪些接口。

peer 范围（`>=0.1.0-0 <1.0.0`）和 `engines.dsh` 故意比实测范围宽，避免宿主升级后
直接拒装；真正的提示是运行时那条警告——看到它就请提 issue。

## 配置

在 profile 的 `cordis.patch.yml` 中添加：

```yaml
- insert:
    - id: mydsh-auto-model-router
      name: '@deepseek-ai/cordis-plugin-group'
      group: true
      isolate:
        modelRouter: true
      config:
        - id: mydsh-auto-model-router-runtime
          name: '@apinggithub/mydsh-auto-model-router'
          config:
            # ── 四个层级（出厂默认）────────────────────────────────────
            # reasoningEffort：off = 无思考，medium/high/max = 思考强度
            levels:
              L0:                              # 日常问答
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: off
              L1:                              # 代码与测试
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: medium
              L2:                              # 编写与审查
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: high
              L3:                              # 复合多步任务
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: max

            # 关键词规则仅在全自动模式生效，默认为空
            rules: []

            # ── 模式A：字符统计锁（零延迟）────────────────────────────
            heuristic:
              windowSize: 3        # 统计最近 N 次用户输入
              counting: cjk2       # 中文计2字符/其他计1；可选 tokens
              thresholds:          # 宽度 → 层级（用户可改）
                - maxChars: 10
                  level: L0
                - maxChars: 100
                  level: L1        # 锁顶；超过 100 弹窗询问用户

            # ── 模式C：关键词积分（全自动）────────────────────────────
            scoring:
              weights:             # 每次命中积分（按层级）
                L1: 1
                L2: 2
                L3: 3
              bands:               # 积分 → 层级（用户可改）
                - maxScore: 0
                  level: L0
                - maxScore: 5
                  level: L1
                - maxScore: 15
                  level: L2
                - maxScore: null
                  level: L3

            # ── Tier 2：成本控制（用户可配置）─────────────────────────
            costControl:
              enabled: true
              mode: balanced        # cost-first | quality-first | balanced
              defaultLevel: L1      # 未命中规则时使用的层级
              tokenBudgetPerSession: 0   # 0 = 不设预算（默认）；
                                        # 设置正值即按会话封顶
              # 会话预算耗尽时的行为：
              #   silent  — 静默切到最便宜层级（不告知用户）
              #   notify  — 注入用户可见消息说明降级原因
              #   ask     — 弹窗询问；用户可选"继续用当前模型"
              #             （本会话豁免预算）
              downgradeBehavior: notify

            # ── LLM 分类器（全自动模式兜底；默认开启）───────────────────
            llmClassifier:
              enabled: true
              model:
                provider: deepseek-official
                model: deepseek-v4-flash
              requestTimeoutMs: 10000

            # ── Tier 3：免费额度优先（可选）──────────────────────────
            # 声明哪些路由是「免费额度」：优先走它们，额度用尽/报错自动换线。
            freeQuota:
              enabled: true          # 没有任何免费声明时自动失效
              routes:                # 不在 levels 里的额外免费路由
                - provider: glm
                  model: glm-4.7-flash
                  maxLevel: L1       # 这一档及以下的活可以交给它（含），默认 L1
                  reasoningEffort: off
              demoteOnError: true    # 该路由一报错即在本会话内降级到付费路由
              notify: true           # 降级时插入一次可见提示

            # 固定层级模式每 N 次用户输入重新弹窗询问
            askEveryInputs: 3

            # ── Tier 4：失败降级链（模型级）───────────────────────────
            fallbackChain:
              - provider: deepseek-official
                model: deepseek-v4-flash

            maxRetries: 1
```

### costControl 三种模式

| 模式 | 默认层级（未命中规则） | 模糊时偏移方向 |
| --- | --- | --- |
| `cost-first` | 最低已配置层级 | **向下**（更便宜） |
| `quality-first` | 最高已配置层级 | **向上**（更强） |
| `balanced` | `costControl.defaultLevel` | 不动 |

规则命中永远优先于成本模式：用户显式匹配到的层级不会被成本层降级。

### 预算降级可见

`tokenBudgetPerSession` **默认为 0（不设预算）**——成本层不会因预算耗尽而强制
降级，`downgradeBehavior` 此时不生效。设置一个正值即可开启按会话封顶：

- 例如 `tokenBudgetPerSession: 300000`：会话用满 30 万 token 后降级。
- 降级行为由 `downgradeBehavior` 控制（默认 `notify`）：
  - `notify`：注入一条用户可见消息，说明预算耗尽、剩余请求使用哪个模型、
    如何调高上限。
  - `ask`：弹窗询问"切到便宜模型还是继续用当前模型"；选"继续"则本会话
    豁免预算。
  - `silent`：静默切换（不推荐——用户会疑惑回答质量为何变化）。

### 免费额度优先（freeQuota）

很多 provider 会送免费额度（例如智谱的 `glm-4.7-flash` 长期免费），但免费通道
同时意味着**限流、额度耗尽、参数上限更严**。这一层让「免费」变成可声明的路由属性，
并在它不可用时自动换线，而不是把整轮对话做废。

声明方式有两种，可以混用：

```yaml
levels:
  L0: { provider: glm, model: glm-4.7-flash, reasoningEffort: off, freeQuota: true }
  L1: { provider: glm, model: glm-4.7-flash, reasoningEffort: low,  freeQuota: true }
  L2: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: high }
  L3: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: max }

# 或者：层级仍指向付费模型，另外声明免费路由来「抢」这些档位的活
freeQuota:
  routes:
    - { provider: glm, model: glm-4.7-flash, maxLevel: L1, reasoningEffort: off }
```

行为：

1. **优先**——决策落在 `maxLevel` 及以下的档位时，走声明的免费路由（免费路由
   总是带着显式 `reasoningEffort`，默认 `off`，不会掉进适配器的默认思考档）。
2. **失败即降级**——免费路由上任何一次请求失败（额度耗尽、429 限流、鉴权被拒、
   参数被拒……）都会把该路由在**本会话内**标记为已耗尽，请求立刻重试到下一跳
   可用路由（通常就是该档位原本的付费路由）。
3. **不再重试坏路由**——降级后，fallbackChain 的游走会跳过所有已耗尽的免费路由，
   同一个会话不会反复撞同一堵墙。
4. **可见**——`notify: true` 时插入一条说明「哪个免费路由、为什么、现在改用什么」
   的消息，每个会话最多一条。
5. **会话级**——降级只在当前会话内生效，下个会话重新尝试免费路由；`freeQuota`
   完全未声明时整层失效，老配置行为不变。

> 注意：免费的**视觉**模型通常有更小的输出上限（例如 `glm-4v-flash` 只允许
> `max_tokens ≤ 1024`）。这类限制要在 provider 的 `models` 条目上写 `maxTokens`
> 修正，路由器无法替你猜——遇到 `INVALID_REQUEST: max_tokens参数非法` 时按提示
> 给该模型加 `maxTokens` 即可。

### 加载时询问（无需客户端界面）

安装或热插拔插件后的**第一个会话**，DSH 会：

1. 注入一条状态汇报消息（当前层级→模型映射、成本模式、预算、降级链）。
   语言跟随 `settings.yaml` 的 `locale.preference`（`zh`/`en`，默认 `en`）。
2. 弹窗询问三个问题（每轮插件加载只问一次）：

| 问题 | 选项 |
| --- | --- |
| Q1 匹配规则 (T1) | 保持现有规则 / **查看现有规则**（注入规则清单） |
| Q2 成本模式 (T2) | 保持当前 / cost-first / balanced / quality-first（选择立即生效） |
| Q3 其他配置 | 跳过 / **检查某项配置**（输入 budget、fallbackChain、classifier 等，注入当前值） |

Q1/Q3 注入的清单与当前值让用户**决定**是否调整；持久修改仍落在
`cordis.patch.yml`（消息中会说明）。headless 或无可询问通道时自动降级为
只汇报、不询问，不会阻塞。

## 规则语法

- 纯文本：`match: 'refactor'` —— 大小写不敏感的子串匹配。
- 正则字面量：`match: '/\\bdebug\\b/'` —— 首尾斜杠界定正则，末尾可加标志（如 `/.../i`）。
- 规则**按顺序**求值，第一个命中生效。
- `match` 可匹配用户消息文本、工具调用结果文本，或会话 `cwd`（以 `cwd:<path>` 匹配）。

## 挂载点

| DSH 事件 | 作用 |
| --- | --- |
| `agent/session-start` | 重置每会话路由状态 |
| `agent/pre-step` | token 记账（预算跟踪） |
| `system-prompt/assemble` | 保持 `{{model}}` 提示词变量一致 |
| `agent/request` | **路由决策点** —— 替换 provider/model |
| `agent/request-error` | 降级链 → `{ kind: 'retry' }` |
| `session/event` | 从 assistant 消息统计 token 消耗 |

## 测试

```bash
npm test        # node --test *.test.mjs
```

无需外部服务——所有层级均以 fixture 做单元测试。

## 已知问题

- **全自动模式延迟**：开启 LLM 分类器后，每个新用户输入的第一步会增加一次分类器调用（LLM 请求）。虽然同一输入内的后续步骤已缓存、无额外开销，但多步任务的整体响应时间仍比直接选择模型慢。欢迎贡献优化方案——例如跨会话缓存分类结果，或用轻量启发式作为 LLM 分类的前置过滤。

## 配套插件：按档位拆账

`tier-cost/` 里是本仓库附带的第二个插件 **`@apinggithub/dsh-tier-cost`**：
它把会话日志按路由档位折叠成 L0–L3 的 token 与花费（金额向计费插件
`@kenz1117/dsh-ui-usage-billing` 借 per-turn 数据，本身不维护价目表），
在设置页多出一栏「档位花费」——因为四个档位常常共用同一个模型，计费面板
按模型分账时看不出是哪一档花的钱。

```bash
dsh plugin --profile desktop add ./tier-cost
```

细节见 [tier-cost/README.zh.md](tier-cost/README.zh.md)。

## License

MIT

### 上下文窗口守卫（contextGuard）

档位可声明其模型的 `contextWindow`。会话实际上下文（最近一次请求的输入侧
token，含缓存读写）超过 `window × headroom`（默认 0.85）时，**该档位被跳过**，
路由向上漂到最近的装得下的档位；没有任何声明窗口装得下时保持原档（fail-open），
让 provider 用真实错误说话、交给降级链处理。跳过每 (档位, 会话) 记录一次，
`/status` 的 `contextGuard` 字段可见。未声明任何窗口时整层不生效。

```yaml
levels:
  L0: { provider: bailian, model: qwen-max, contextWindow: 32768 }   # 小窗口模型的保护
contextGuard: { enabled: true, headroom: 0.85 }
```

## 部署到其它电脑

一台机器 = 三条命令（本仓库含两个插件）：

```powershell
# 1. 从 npm 装（推荐；peer 范围已放宽为宿主自适应，不需要 allow-version 豁免）
dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.3'

# 2. 可选：按档位拆账
dsh plugin --profile desktop add '@apinggithub/dsh-tier-cost@0.1.0'

# 3. 改完 cordis.patch.yml 后先校验（见下），再重启
node scripts/check-patch.mjs --profile desktop

# 4. 重启 DSH（bundle 只在启动时组合；配置改动用热加载即可）
```

### 改配置前必读：先校验，再重启

`cordis.patch.yml` 里一个少的换行就能让宿主**启动失败**：

```
DesktopHostFatalError: failed to parse overlay … YAMLException: bad indentation (454:44)
```

更麻烦的是崩溃对话框里有 **"Disable third-party plugins"** 按钮——点它会把所有第三方
bundle 从 profile 里摘掉，插件（含 Auto 入口）随之消失，需要手工恢复。

所以每次改完都跑一次（会用宿主自己的解析器校验，并清理临时 profile）：

```powershell
node scripts/check-patch.mjs --profile desktop
# PASS  profile "desktop" composes (194 top-level entries)
# FAIL  … 会打印宿主的原始错误
```

参数：`--home <DSH_HOME>`、`--profile <name>`、`--file <patch>`（只做静态检查）、`--keep`（保留临时 profile 便于排查）。


也可以从源码装：`git clone …` 后 `dsh plugin --profile desktop add ./mydsh-auto-model-router`。

### 每台机器必须自带档位块

插件的出厂默认档位是**历史值**（`deepseek-official/deepseek-flash`），这台机器上不一定可用。
装完在该机器 `profiles/<profile>/cordis.patch.yml` 末尾写自己的档位块，
**provider/model 必须是那条机器真实可用的路由，别照抄 id**：

```yaml
- id: mydsh-auto-model-router-runtime
  name: '@apinggithub/mydsh-auto-model-router'
  config:
    levels:
      # reasoningEffort: null = 该模型没有思考能力（Qwen/GLM flash 都是），必须写 null，
      # 否则宿主会以 UNSUPPORTED_REASONING_EFFORT 拒绝请求。
      L0: { provider: zhipu, model: glm-4.7-flash, reasoningEffort: null, freeQuota: true }
      L1: { provider: zhipu, model: glm-4.5-flash, reasoningEffort: null, freeQuota: true }
      L2: { provider: zhipu, model: glm-4-flash,   reasoningEffort: null }
      L3: { provider: deepseek-official, model: deepseek-flash, reasoningEffort: max }
    freeQuota:   { enabled: true, demoteOnError: true, notify: true }
    contextGuard: { enabled: true, headroom: 0.85 }
    quotaWatch:  { enabled: true, provider: bailian-dell-vostro, balanceFloorCny: 10 }
```

provider 本身要在 `cordis.patch.yml` 里先定义好（`llm-pi-ai` 的 `providers:` 段），
密钥通过 DSH 凭据库或 `apiKeyEnv` 提供。

**多机同账号的注意点**：额度与余额都在**服务端**，所以本机累计的 token 数不参与判断——
`quotaWatch` 读的是云端账单/余额。但**每台机器都要各自配置一次**（凭据库不跨机同步）。

## 额度看门狗（quotaWatch）

### 它解决什么问题

免费额度耗尽后，服务商**未必报错**——可能静默转按量计费。那时 `demoteOnError` 无错可捕，
路由会一直烧钱（见文末「静默计费陷阱」）。看门狗改读**账单**，一旦某个"免费"模型出现花费，
30 分钟内把它摘掉。

### 它能看见什么

| 数据源 | 能拿到 | 限制 |
| --- | --- | --- |
| `QueryAccountBalance` | 账户可用余额（¥） | 准实时 |
| `DescribeInstanceBill` | 按模型拆分的当期花费 | **T+1 延迟** |
| `QueryResourcePackageInstances` | 资源包余量 | 无资源包时为空 |
| DeepSeek `GET /user/balance` | 开放平台余额 | 准实时 |

**没有**"查询免费额度剩余"的公开接口——那个进度条只在服务商控制台里。
所以看门狗的语义是**发现即降级**，不是提前拦截；真正的超前拦截仍要靠服务商侧的「用完即停」。

### 配置

```yaml
quotaWatch:
  enabled: true
  provider: bailian-dell-vostro      # 账单描述的是哪个已配置 provider（要摘的路由）
  productCode: sfm                   # 百炼在阿里云账单里的商品码
  pollMs: 1800000                    # 轮询间隔，最小 60000
  balanceFloorCny: 10                # 阿里云余额低于此值 → 暂停所有付费档；0 = 关闭
  demoteOnSpend: true                # 某模型出现花费 → 摘掉它的路由
  deepseekBalanceFloor: 10           # DeepSeek 平台余额低于此值 → 只暂停 deepseek-official；0 = 关闭
  deepseekApiKeyRef: DEEPSEEK_API_KEY
  accessKeyIdRef: ALIBABA_CLOUD_ACCESS_KEY_ID
  accessKeySecretRef: ALIBABA_CLOUD_ACCESS_KEY_SECRET
  timeoutMs: 20000
```

### 阿里云 RAM 子账号怎么开（重要）

**不要用主账号 AccessKey。** 正确的做法：

1. 阿里云控制台 → **RAM 访问控制** → 用户 → 创建用户，勾选**只允许编程访问**；
2. 给它**只读账单权限**：直接附加系统策略 `AliyunBSSReadOnlyAccess`；
   想更小可用自定义策略，只允许这几个 Action：
   `bss:QueryAccountBalance`、`bss:DescribeInstanceBill`、`bss:QueryResourcePackageInstances`；
3. 创建 **AccessKey**，把 ID/Secret 存进 DSH 凭据库（见下），**不要再给它任何写权限**；
4. 验证（只读，不花钱）：

```powershell
$ak = (Resolve the ID);  $sk = (Resolve the secret)
# 期望返回 {"Success":true,"Data":{"AvailableAmount":"..."}}
```

> AccessKey **Secret 不要写进 `cordis.patch.yml`，也不要提交进仓库**——配置文件里只有
> `accessKeyIdRef` / `accessKeySecretRef` 这样的**引用名**。

### 凭据放哪里

看门狗通过 `ctx.credentials.resolve(ref)` 读取，所以密钥存在 **DSH 凭据库**
（`$DSH_HOME/.credentials.yaml`，与 provider 的 API key 同一个文件），形如：

```yaml
refs:
  ALIBABA_CLOUD_ACCESS_KEY_ID: LTAI...
  ALIBABA_CLOUD_ACCESS_KEY_SECRET: ...
  DEEPSEEK_API_KEY: sk-...
```

两种写入方式，任选：

- **GUI**：插件 → 内置插件设置 → Models / 凭据，按 API key 的方式存（键名用上面的 ref 名）；
- **命令行**（secret 不进 shell 历史，也不进对话）：

```powershell
$sec = Read-Host -AsSecureString 'Paste AccessKey Secret'
$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
  [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
# 写入 $DSH_HOME/.credentials.yaml 的 refs 段后，立即删除任何临时明文文件
```

⚠️ **多机部署**：每台机器都要各自配置一次凭据（凭据库不跨机同步）。
额度/余额在服务端，所以多机读到的数字一致，不存在方案 A 那种"各机各算、互不知情"的偏差。

### 状态接口

`GET /api/mydsh-auto-model-router/status` 的 `quotaWatch` 字段：

```json
{
  "enabled": true,
  "lastCheckAt": "2026-10-22T09:57:18Z",
  "lastError": null,
  "balanceCny": 28.21,
  "cycle": "2026-10",
  "spendByModel": { "glm-5.3": 62.5611, "qwen3.8-flash": 8.0478 },
  "totalCny": 70.67,
  "exhausted": [],
  "balanceLow": false,
  "deepseekBalanceCny": 158.25,
  "deepseekBlocked": false,
  "deepseekError": null
}
```

任何一环失败都只写进 `lastError` / `deepseekError`，**不会**影响路由本身。

## 发布到 npm

### 目录新鲜度守卫（modelsGuard）

模型版本升级后，provider 不再公布的 id 会变成「过期路由」。守卫在激活时与每
`refreshMs`（默认 10 分钟）用 `ctx.llm.listModels(provider)` 读取各 provider
实际公布的模型：过期档位的请求**自动漂移到最近的鲜活档位**（绝不静默打死），
状态报告列出过期档位与**同族候选 id**，改档位仍是一行编辑的事。目录读不到或
为空时一律 fail-open（不判过期）。未注册 listModels 的环境整层惰性。

```yaml
modelsGuard: { enabled: true, refreshMs: 600000 }
```

> 静态列表本身仍是配置（`cordis.patch.yml`）——这是刻意的：插件不会替你决定
> 换成哪个模型（窗口/单价/能力都变了），它只保证**坏档位不再被选中**并告诉你该换什么。

## 发布到 npm

两个插件同一仓库、各自独立发布（root 与本目录的 package.json 都可直接 publish）：

```bash
npm publish                 # @apinggithub/mydsh-auto-model-router（root）
cd tier-cost && npm publish # @apinggithub/dsh-tier-cost
```

0.2.0 起 peerDependencies 已放宽为宿主自适应范围，新机器安装**不再需要**
allow-version 豁免（豁免仅 0.1.1 及更早需要）。发布前各自 `npm login --scope=apinggithub`。

## 免费额度的静默计费陷阱（真实事故 · 守则）

本仓库作者机器上的实录事故：L1 指向百炼免费模型 `glm-5.3`（`freeQuota: true`），额度耗尽后
服务商**没有按「用完即停」返回 403**（批量开关未覆盖到该模型/生效延迟），调用照常成功——
于是 `demoteOnError` 无错可捕，**glm-5.3 静默转按量计费跑了一整天**，直到账单通知才被发现。

三条守则，缺一不可：

1. **先在服务商侧逐模型确认「用完即停」真的生效**（发一条超额调用看是否返回 403，别信批量开关）。
   路由器只能在**看得见错误**时换线；额度耗尽若被服务商静默转按量，插件无从得知。
2. **`costControl.defaultLevel` 指向稳定付费档**（本文各处示例的 `defaultLevel: L1` 若 L1 是免费档，
   正是本次事故形态）。免费档只放最低层（L0），或至少每个免费档都有明确的付费替代。
3. **用计费面板按档核账**：任何"免费"档的费用行持续大于 0 且不归零，立刻怀疑额度已尽、
   在按量扣费——把该档从免费路由摘下来。

> 另一个易错点：免费额度池按 **provider+model 独立**计数。同一个模型 id 出现在多个 provider
> （如 `qwen3.8-flash` 同时在 bailian 与 qianwen）是**不同的池子**，额度与计费互不相干。
