# IMPLEMENTATION — 实施过程档案
> **这份文件是什么**:`CHANGELOG.md` 记「变了什么」,本文件记**「为什么这么变」**——每一轮的
> 病灶、取证、判据、以及那些「看起来能省但不能省」的取舍。发版时压缩过程进CHANGELOG 会把
> 它们埋进 git历史,而其中不少是**踩过的坑**:照着做能绕开,不知道就再踩一遍。
>
> **与 `PROVIDER-M4.md` 的分工**:`PROVIDER-M4.md` 是 **provider 怎么接入**的契约(目录→descriptor
> 映射、发布状态机、快照形状)——写代码前照它做。本文件是**接入过程中发生了什么**——写完
> 代码后回头看它,才知道哪些地方看着多余却不能删。
>
> **按时间倒序**:最上面一节是 0.2.0 发布前的十二轮收口(`CHANGELOG` 压缩后搬进来的全文),
> 往下是 0.1.0 的每节,每节标注对应的 CHANGELOG 条目。
## 0.2.0 的十二轮收口(2026-10-05)
0.1.0 发出去之后的十二轮修复,全文记在这一节;`CHANGELOG` 只留结论。轮次编号沿用当时的叫法。
### 第十二轮:cordis.patch.yml 自己的「env 是回退」,与 maxEvents 的补票
继续排查过时内容,命中两处「灯下黑」:
- **§7 的清单不含 cordis.patch.yml 本身**。`cordis.patch.yml:7-9` 一直写着
「env MODELSCOPE_API_KEY 是回退」——第十轮建 §7 时 surfaces 只列了 client 与
README,唯独漏掉**操作者改配置时唯一在手边的文件**。改为陈述真实分层
(启动时读一次、优先级高于引用值、设了就别用面板存),并把 cordis.patch.yml
纳入 §7 清单;变异验证(旧文案塞回 → 门禁红)KILLED。
- **`maxEvents` 自创面以来从未进文档与测试钉子**(第一轮锐评点名的配置缺口)。
`CONFIG_DEFAULTS.maxEvents = 50`(host-config.ts:49,clamp 1..1000)此前在
patch 文件、README、config.test 钉子里全部缺席。补为 live 配置项 + 注释,
并加进 `test/config.test.mjs` 的 patch↔defaults 钉子清单;变异验证
(patch 改成 99 → 红)KILLED。
### 第十一轮:被真机推翻的旧预期,散落在四个文件里
用户要求继续排查过时描述。这轮扫的不是 key/数字,而是**「预测 → 真机回填」类
claim 的残留**——SPIKE.md 回填了新事实,旧预告却没跟着改掉。抓到四处:
- `src/host/ms-auth.ts:29-31`:早先写「缺 `messages` 的请求预期 401 先于 400」,
又写「尚待真机验证」(已于 2026-10-04 被真机推翻)——预测与预告并存在同一处。
SPIKE.md §结论 5 实测**缺 messages 返回 200 空壳而非 400**,判定规则实为
401/403=坏、200/400=好、429=限频中;`ms-auth.ts:153` 的「尚待真机验证后接入」同为历史预告,
已按回填事实更正。
- `src/host/routes/probe.ts:5`:**相邻两行自相矛盾**——第 5 行写「预期 401 先于 400」(早先的预测,
已被真机推翻),第 6 行写「语义已真机回填」。标题「测试一次调用」也与按钮实际文案
「验令牌(零额度)」(i18n `probe.validity`)脱节。
- `docs/ROADMAP.md:17`:「400-vs-401,验证后落地」(已落地、已回填)——探针早就落地,规则也已反转;
同格「env 回退」是 §7 抓过的同一族假话(§7 清单不含 docs,漏网)。
- 四处全部改写为回填后的事实,保留出处(SPIKE.md §结论 5 + 日期)。
**门禁长出新牙(§8 + 递归清单)**:新增 §8——已被真机推翻的旧预告(写「尚待真机验证 / 验证后落地 /
待真机回填」这类尚未回填的话)与已被真机推翻的形状预期(写「401 先于 400」这类曾为预测的话),
**行内**必须带历史标注(早先 / 曾 / 推翻 / 证伪 / 更正 / ~~),否则 fail。两条实现教训,都来自变异测试自己:
①安全语从**段落级收紧到行级**——段落级窗口下,60 行 JSDoc 任何角落有一个
「回填」就全段放行,孤立旧预期照样绿(实测 STILL-GREEN 后修的);②§3/§7/§8
的文件清单原本只 `readdirSync` 顶层目录——**`src/host/routes/` 整个子目录不在
射程内**,而事故现场 `probe.ts` 恰好住在那。加了 `tsFiles()` 递归枚举,全部
清单换用。两个变异(probe.ts 塞旧形状、ROADMAP 塞旧预告)复验均 KILLED。
### 第十轮:面板假文案与它的盲区
第九轮在 README 与 `src/host/ms-auth.ts` 里更正了「环境变量为回退」这个反了的
说法,但**面板上这句话的原件没改**——用户照 README 的指引操作仍被界面误导:
`src/client/i18n.ts` 的 `token.hint`(zh 与 en 两侧)都还写着「环境变量为回退」。
**改动**:`token.hint` 两侧改写为说清真话——保存即生效(写入凭据服务、不依赖
重启);`MODELSCOPE_API_KEY` 只在 DSH 启动时读一次,且**优先级高于**面板保存值,
设了 env 就别再用面板存。`token.ephemeral`(只在无凭据服务的 Host 上渲染,那时
建议 env 是对的)与 `source.env`(一个标签)不改。
**门禁补洞**:`test/docs.test.mjs` 的 §3 文件清单原本只扫 `docs/`、README、
`src/host/*.ts`——**恰好漏掉唯一直接面向用户的面**。把 `src/client/*.ts` 纳入扫描,
并新增 §7:任何用户可见行同时出现「环境变量/MODELSCOPE_API_KEY」与
「回退/fallback」类措辞、又没交代真实优先级 → fail。**§7 上线即抓到第九轮自己
漏改的第二处同源假话(README.md:25「环境变量作回退」)**——§3 抓不到它是因为
词表只钉 `dailyQuota*`;同源错误出现在另一个文件、用了另一套措辞,只有面向
「claim 类型」而非「具体 key」的门禁能拦住。变异验证:把旧假文案塞回
`i18n.ts:92` → §7 精确红在该行;恢复 → 全绿。
### 第九轮:真机探测证伪 `reasoning_effort` 的 400 理由,改述为实测障碍
起因是用户报告「Windows 环境变量改了插件不认」,查证过程中顺带把
`docs/PROVIDER-M4.md` §4 里那条从 M4 起就存在的理由**真机验证了一遍**。
**探测(`ZhipuAI/GLM-4.7-Flash`,`POST /v1/chat/completions`,实测 2026-10-05)**:
| 请求 | 结果 |
|---|---|
| 不带 `reasoning_effort` | 200(可能是空壳) |
| `reasoning_effort: "low"` | 200(真响应带 `reasoning_content`);**同一请求复测变成空壳 200** |
| `reasoning_effort: "bogus"` | **200**——参数名被接受,**值不被校验** |
**结论**:原理由「发错档位会整条请求 400」是**推测且已被证伪**。真实障碍更硬:
①无法区分「参数生效」与「参数被静默忽略」(两者都 200);②该端点在真响应与
空壳 200(`{"created":0,"choices":null,"usage":{全0}}`)之间摇摆,不稳定信道上做
A/B 探测得出的档位表不可信。
**改动**(`reasoning: false` 的代码**未动**——它本来也是无操作,模型不在 pi-ai
安装目录里,`base === undefined`,`resolveModelReasoning` 兜底成 `false`):
- `docs/PROVIDER-M4.md` §4:追加更正块,保留原文作契约的历史记录;
- `docs/ROADMAP.md`:`reasoning_effort` 档位表那条 backlog 加删除线,标「已探测,判定不可行」;
- `docs/IMPLEMENTATION.md`、`README.md`、`RELEASE-0.1.0.md`:四处同一错误声明的
传播点一并改述。
### 第八轮:读 peer 源码核实「重分类→重试」链路,更正「少记」的错误声明
起因是评「代码功能本身是否优秀」时,发现两处承重声明互相矛盾:
- `src/host/llm-retry.ts` 文件头称,重分类把误判的 QUOTA 拉回 `RATE_LIMIT`
**"so they reach this policy"**(即重分类让限频进入重试策略);
- `docs/IMPLEMENTATION.md` 称 "peer 的重试**在同一条流内部**重发,观察器只看到一条流"。
若后者成立,则重分类发生在流出口、而重试在流内部,**重分类对重试完全无效**——那会把
本插件最有价值的设计变成一句空话;同时 `llm-retry.ts` 的注释就在误导读者。两者必有一错。
`test/peer-contract.test.mjs` 需要运行时 peer、在离线门禁之外,故此前无人判定。
**读 peer 源码核实(`@deepseek-ai/dsh-llm-retry` / `dsh-agent-loop` / `dsh-llm` /
`dsh-llm-pi-ai`,均取本机 `~/.dsh/profiles/node_modules`)**,链路如下:
1. `dsh-agent-loop/lib/index.js:1116-1134` —— 流被**完整消费完**后,读 `live.finish`;
若 `kind === "error" | "aborted"`,以 `failure: finish.failure` 派发
`agent/request-error` 瀑布。`live` 是 `AssistantStreamAttempt`,`push(chunk)`
(`:396`)逐块消费**调用方拿到的流**——即经 `reclassifyStream` 包装后的流。
**故 `finish.failure.code` 是重分类之后的 code。**
2. `dsh-llm-retry/lib/index.js:175` —— `recover()` 挂在同一个 `agent/request-error` 上;
`:160` 判据是 `!policy.retryableCodes.includes(failure.code) → return next()`。
3. `dsh-llm/lib/index.js:181-183` 的 `isQuotaExceededError` 五条正则**没有一条**匹配
`rate limit`;而 `dsh-llm-pi-ai:1376-1387` 的 `classifyPiAiError` **先查 quota 后查
rate**。故魔搭那句 `"quota_exceeded_error"`(实为 rpm 语义)永远被判成 `QUOTA`,
而 `QUOTA` 被 `retryableCodes()` 刻意排除。
**结论:`llm-retry.ts` 是对的,「同一条流内部重发」是错的。** 重试不在流内部,它产生
**新的流**。这条链也顺带证实了 `llm-error-fix.ts` 的存在理由:**重分类不是事后分类,
它是让限频够得着自愈路径的抢救层**——改写必须发生在 agent-loop 消费流之前,所以观察层
套在重分类层**之外**的顺序不可反。
**更正四处**(同一错误声明的传播范围):`docs/IMPLEMENTATION.md`(记账口径段重写为
「按流计次」并附完整链路)、`README.md:54`「诚实声明」、`cordis.patch.yml`(操作者改配置时
唯一在手边的文档)、`src/host/usage-observer.ts` 文件头。另修
`src/host/llm-error-fix.ts:48` 把 RATE_LIMIT 的重试误归给 "peer retryPolicy **默认**"——
重试由本 provider **显式**的 `buildRetryPolicyConfig()` 提供,peer 默认并不含本 provider
的调参。CHANGELOG 第五轮那条历史条目保留原文并加删除线,注明第八轮更正。
**偏差方向的实质变化**:原声明「少记」(宁可少记也不虚增)在新事实下不成立——**每次
重试各记一次 call**。按「魔搭按次数计费」的口径这**反而是对的**(每次重发都真打了一次
上游),但它是多记而非少记,必须改写。
**新增承重契约测试**(`test/retry.test.mjs`,本插件 429 自愈价值的唯一接缝,此前零覆盖):
`error-fix.test.mjs` 钉「改写后的 code 是什么」、`retry.test.mjs` 钉「可重试码有哪些」,
但**「改写后的 code ∈ 可重试码」这一步没人测**——若有人把 RATE_LIMIT 移出 retryableCodes
或改掉重分类的目标码,两侧测试都会继续全绿,而线上表现为「限频永远快失败、退避从不
发生」。补两段:①rpm 体经 `reclassifyFinish` 改写出的 `RATE_LIMIT` 必须落在
`retryableCodes()` 内、且等于 `QUOTA_CODES.rateLimit`;②真耗尽体不得被纠正、且 `QUOTA`
必须留在可重试码之外。**变异测试验证**(`tools/dev/mutate.mjs`):移出 RATE_LIMIT、
塞入 QUOTA、改掉目标码字面量、关掉 rpm 纠正——四个变异全部 KILLED,且各由对应断言命中。
### 第七轮:删除 probe 的 `kind:"usage"` 分支,并修好变异测试工具自己
起因是第六轮遗留的一个「活的死代码」:`routes/probe.ts` 的 `kind:"usage"` 分支服务端
仍在、客户端**永远发不出**。查完代码后的判断不是「死代码该删」,而是**形态本身是反的**:
- 服务端的 `kind` 取值是 `body.kind === "validity" ? "validity" : "usage"`——**缺省即
usage**。于是任何裸 `POST {modelId}`(本机其他程序、忘记传参的脚本)都会触发一次
`max_tokens:1` 的**真实计费调用**并记进账本。面板只剩零额度探针之后,缺省不该指向
「花钱的那个」。
- 它服务的 UI(模型目录逐行「试调」)已随面板收敛删除,`ProbeKind` / `ProbeSuccess.usage`
/ `inference-client.probe()` 里整套 usage 分支(含 `usage.total_tokens` 解析)都只为它存在。
- 真调用的 token 数由 `usage-observer.ts` 在 harness 流出口取,**不经过这里**。
**删除**:`probe.ts` 的 kind 解析与 recordCall 调用、`inference-client.ts` 的 `ProbeKind`
类型与 `ProbeSuccess.usage` 字段与 usage 分支、`client/const.ts` 的 stale 注释、
`panel-page.ts` 的 `kind:"validity"`(客户端现在只传 `{modelId}`)。`recordCall`
的写入侧从此只有 `usage-observer.ts` 一个(两处文件头注释同步改写)。
`test/routes.test.mjs` 的 probe 段**重写**:删掉「usage 成功 → 本地计数 +1」一类断言
(那是已删行为的断言),改为断言反向事实——成功**不计**用量、不带 `messages`/`max_tokens`、
不回 `usage`;429 分诊、401 分诊、Retry-After 解析、事件流写入全部保留(分诊逻辑在
validity 路径上同样执行);新增 400「先鉴权后校验 → 令牌仍好」一条。
#### 变异测试工具自己有两个缺陷,都是本轮才暴露的
用 `tools/dev/mutate.mjs` 验证新断言时,它先报「目标不存在」,再**把 7 个变异全记成
`KILLED (抓到)`**——而 detail 里每一行都是同一个错:
- **`ROOT` 上溯级数错了**:第六轮把脚本从 `tools/` 移到 `tools/dev/` 时,没有同步改
`resolve(dirname(fileURLToPath(import.meta.url)), "..")`。**六个脚本全中**(`mutate` +
`cov-*` + `find-fake-gates`),全部改成上溯两级。
- **`mutate.mjs` 把 `test/` 拼了两次**:`group.test` 一直是仓库相对路径
(README 里写的就是 `test/xxx.test.mjs`),而代码是 `join(ROOT, "test", t)` →
`test/test/xxx.test.mjs`。测试根本加载不了、进程非零退出,被下面「非超时即 KILLED」
一律记成**抓到了变异**。**这个缺陷在本次移动之前就存在**(`git show HEAD` 已如此),
意味着该项目**历史上每一次变异审计的报告都是假绿**——「杀死 N / 漏过 0」看起来完美,
实际一个变异都没跑起来。
- 修法有两层:按路径语义解析(含 `/` 当相对路径,裸文件名才补 `test/` 前缀);**并且**
新增 `BROKEN (测试没跑起来)` 判定——匹配 `Cannot find module` / `ERR_MODULE_NOT_FOUND`
时不算「抓到」,汇总里单列,且在有值时打印「上面的『杀死』数与检出率都不可信」。
**门禁工具的失效必须是响的**,不能是静默的。
- 修好后重跑:最开始的 `probe 不再校验 modelId` 由 `STILL-GREEN (漏!)` 变为
`KILLED`——因为它是真的**盲区**:`test/routes.test.mjs` 从未测过空 modelId 的 400 护栏。
已补该用例(含「护栏在发请求之前就挡下」与纯空白串)。最终 **7 杀死 / 0 漏过 / 0 没跑起来**。
### 第六轮:文档门禁与断言有效性
前五轮都在修**代码**;这一轮的问题是**文档在说谎,而没有东西会红**。审计逐条复现了
八处漂移,它们的共同形态是:文档里的「已实现 / 已删除 / 由某测试钉住」都是**声明**,
没有任何门禁核对过声明与磁盘是否一致。
- **新增 `test/docs.test.mjs`**(文档完整性门禁,6 段,静态文本检查、不联网、干净
checkout 可跑)。判据刻意是**机械可判**的,不试图理解语义:
- §1 README 里链到的每个仓库内文件必须存在(`docs/PROVIDER.md` 与
`docs/ARCHITECTURE.md` 都曾被指向而**不存在**);
- §2 反向:`docs/` 下每个文件都要在 README 文档地图里出现;
- §3 已删除的配置项(`dailyQuotaTotal` / `dailyQuotaPerModel`)不得再以现存配置的
口吻出现在 README / patch / docs / src。判定按**整段**而非单行——中文句子经常
折行,「已随之删除」落在下一行(`cordis.patch.yml` 就是这种写法);
- §4 `PROVIDER-M4.md` 不得再声称归档为 `docs/PROVIDER.md`,且那个文件不得存在;
- §5 文档说「某测试钉住 X」时,该测试必须真的提到 X;
- §6 的套件数量与 CI 存在性必须与磁盘一致(见下)。
每一条都用**变异测试**验证过它真的会红(注入 8 种违规,逐条确认变红后恢复)。
- **补齐 `panel.test.mjs` §6 的路由一致性,并新增 §6b 真交叉核对**。原清单只覆盖
`/snapshot`…`/probe` **五个**后缀——M4 加的**三条 provider 路由整条漏在门禁外**,
而它们正是 M4 的主功能。现在:
- §6 覆盖全部八条(区分 `paths.ts` 的字面量与 `routes/provider.ts` 的派生路由
`${PROVIDER_PATH}/roster`);
- §6b 把 client 模板与 Host 字面量/派生路由**都解析成完整路径**再逐一比对,
改错任何一边都会红。这正是 `PROVIDER-M4.md:244` 曾经**谎称**存在的那条断言
(原句说 `test/config.test.mjs` 钉住相等,而该文件零引用)。
已验证:改错 Host 字面量 / client 模板 / 删派生路由三种变异全被抓住。
- **修三处套件数量与 CI 存在性的漂移**:`RELEASING.md` 说「没有 `.github/workflows/`,
CI 也不存在」,而 `gate.yml` 就在版本库里且每次 push 都跑——**它正告诉维护者不要
期待 CI 检查**。同文件与 `docs/ROADMAP.md` 都写死了套件数量(15 与 16),`gate.yml` 注释
也写死,实际早已不止。现在一律不写死数字,且 §6 会盯着这类断言(当场就抓到
了两处我自己刚写的)。
- **修八处文档漂移**(审计逐条复现):
| 位置 | 原来 | 现在 |
|---|---|---|
| `PROVIDER-M4.md:19` | 「本地计数覆盖全部 DSH 魔搭调用」 | 保留原文 + **更正块**:该结论在 M4 当时是**错的**(适配器没挂钩子),`usage-observer` 接上后才成立 |
| `PROVIDER-M4.md:6`/`§14` | 承诺归档为 `docs/PROVIDER.md` | 明确**未执行且已决定不执行**(该文件继续承担契约职责) |
| `PROVIDER-M4.md:244` | 「两边由 `config.test.mjs` 钉住相等」 | 改为真实情况,并说明 §6b 是事后补的 |
| `README.md` 文档地图 | 指向本仓没有的 `docs/ARCHITECTURE.md` | 指向 `PROVIDER-M4.md` §14 与 `IMPLEMENTATION.md` |
| `README.md` 额度节 | 「额度常数…需手动更新配置」 | 明说这些数字**不参与任何计算**,面板不消费次数上限 |
| `IMPLEMENTATION.md` / `SPIKE.md` / `REFERENCES.md` | 三处把已删的 `dailyQuotaTotal`/`dailyQuotaPerModel` 描述为现存配置 | 标注删除,SPIKE 那条加删除线标「已作废」 |
| `src/host/usage-store.ts` 文件头 | 「魔搭没有余额查询接口」等**三重化石** | 重写为当前口径(官方给总额/本 store 给分布与事件流/写入侧是谁) |
| `src/host/usage-observer.ts` 文件头 | 「唯一调用者是 `routes/probe.ts`」 | 改为「常规生产者」(该分支第六轮已删除,见下) |
- **重组 `tools/`**:12 个无引用、无文档的临时脚本按「通用仪器 / 一次性探针」分流。
- 保留 6 个通用仪器进 `tools/dev/`(`mutate.mjs` 变异测试、`find-fake-gates.mjs`
假门禁扫描、`cov-{scan,merge,bundle,fn}.mjs` 覆盖率),并补
[tools/dev/README.md](tools/dev/README.md) 说明用法与「为什么变异测试是必要的」
——新断言第一次跑就绿**什么都不能证明**。
- 删掉 6 个绑定单次调查的一次性探针(`bypass6` / `leak-real` / `cmp-degraded` /
`mutate-state-store` / `cov-trace` / `cov-hook`)。它们验证过的结论已经写进
`test/credentials.test.mjs` 与 CHANGELOG。
- **保留** `tools/check-market-entry.mjs`(它是 `5631a3b` 提交的投稿校验工具,不是
临时脚本——初判为 scratch 是错的,核实 git 历史后纠正)。
- 清掉 `.audit/`、`.cov/`、`.cov-*.json` 等本轮调查残留。
- **重建 `lib/` 产物**:源码头两处改动需要重新 build,否则 `build-gate` 会红
(它红得对)。产物 chunk 文件名随内容哈希变化(`llm-adapter-CpcQ3gC5.js` →
`-CIXFWx9N.js`),`index.js` 的 import 同步更新,无外部引用。
两轮:先是发布前的漂移收口与门禁补强,再是一轮由双路深审挖出的**数据安全与红线修复**。
### 第五轮:红线收口
- **聚合层 `soft()` 补脱敏**(红线 1 的收口漏洞):`soft()` 用裸 `errMsg(error)`,而那个
字符串会一路流进 `shapeWarnings` / `balance.error` / `models.error`(面板可见)与快照
路由的响应体。它隐式依赖「下游每个 promise 都已自行脱敏」——**契约不该靠下游的自觉**。
实测 `errMsg` 会把 `upstream said ms-3f2a1b8c1111222233334444555566` 原样带出去。现在收口
处自己脱敏(`redactSecrets` 是幂等的,重复脱敏换掉「凭据可能外泄」这个尾部风险划算)。
- **另两处同类出口**:`resolveSettings` 的 `configError`(进 `ok:false` 响应体)与 `index.ts`
的路由注销警告(**进日志**——红线 1 同时管日志与面板)。都补上脱敏。
- **`test/credentials.test.mjs` 加一条静态不变量**:Host 侧不允许出现「裸 `errMsg` 进入
wire 字段或日志」,且点名三个具体收口。判据是静态的——构造真实的泄漏路径需要上游回显,
而离线套件刻意不联网。已双向验证(去掉任一脱敏,对应用例变红)。
### 第四轮:资源与一致性
- **原子写补崩溃一致性与临时文件清理**(`writeStateFile`,四个 store 共同的唯一写入口):
- `rename` 失败时(Windows 上目标文件被占用最常见)临时文件**留在原地**——名字是
`....tmp`,全仓原本无任何 `unlink`,于是
`state///` 会慢慢堆满垃圾。现在失败时删掉自己的临时文件再重抛
(清理失败不掩盖原始错误)。
- rename 前对临时文件 `fsync`。这是**崩溃一致性**(rename 本身只保证并发原子性):
没有它,断电/内核崩溃时 rename 可能把一个空或半截文件立到目标路径,而
`parsePayload` 对损坏文件是「读作未设置」**静默降级**——用户看到「清单和历史全没了」
而没有任何错误。代价是真机每次几毫秒,值得:`usage.json` 每次对话都写、
`provider.json` 每次点开关都写,都不是低频操作。
- **修未消费的 response body 占住连接**:`fetchTaskTags` 在 `!response.ok` 分支直接
`return []`、不读也不取消 body。undici 里未消费的 body 会**占住连接直到 GC**,而目录
刷新对 35 个模型并发打详情端点——任何一条走该分支,连接就一直挂着,连接池被吃满后
后续请求开始排队。`readErrorMessage` 的 catch 分支同理(`json()` 抛在半路时 body 未被
消费)。现在两条路径都显式 `void response.body?.cancel()`。
- **删 27 个零引用样式token**(83 → 56):`poolsGrid` / `quotas` / `quotaRemaining` /
`statHeadline` / `details*` / `primaryHover` / `primaryBusy` 等。它们连同解释其设计
决策的注释一起是「上一轮删模型目录表 / 次数口径推算条」的**化石**——注释里还在描述
「额度双栏」「grant chip」「details 折叠区」「twin card」,读代码的人会以为这些 UI 还在。
已连带清理 5 处引用已删 token 的注释。
- **删 4 个死 helper**:`when()`(注释声称「头部更新于必须能熬过隔天」,而 `panel-page`
实际用 `isoTime`,该设计从未落地)、`postJsonOrThrow`、`dictKey`,以及
`sortCatalogIds`(featured 置顶排序随「模型目录表」退役,`roster` 现按 Host 返回顺序
渲染,只保留 featured 徽标——排序规则连同它的 6 条性质断言一起删,不留「有契约无
实现」的孤儿)。`clockLong` 经核实**有 2 处真实调用**(`format.ts:24,42`),保留。
- **新增 `test/state-store.test.mjs`**:`state-store.ts` 是四个 store 共同的唯一写入口,
此前零直接测试。
### 第三轮:状态机与配置边界
派代理专门核实三个「需要真peer 才能判定」的疑点——它在DSH 发行版里找到了
`@deepseek-ai/dsh-llm@0.1.7-rc.2` 的**本机真实源码**,把三个疑点从推断升级为读代码判定。
- **修 dispose × 在途 publish 竞态(僵尸 provider 泄漏)**:`publishProviderOnce` 只在
**入口**查一次 `queue.isDisposed()`,而注册发生在最后一个 await 之后(动态 import
是最宽的窗口)。teardown 落在这段窗口里时闸门不重跑,于是 `registerAdapter` 把适配器
注册进一个**已撤下本插件的 Host**,而 teardown 早已跑完、release 永不调用(全仓只有
一个 `.release()` 调用点)。泄漏的对持有 `tokenStore` 与整个 Cordis `ctx` 的闭包 →
**僵尸 provider**:picker 里那个 provider 还在、还能路由,而面板路由已注销,用户没有
界面能关掉它。
peer 源码证实 fiber 兜底救不了:`registerAdapter` 用的是 **llm 服务自己的**
`ctx.effect`(`.bind(this)` 到 llm 服务),插件卸载不会 dispose 它。
修法:在 `swapRegistration` 前补第二次闸门检查——从那里到 `registerAdapter` 之间
**没有任何 await**,单线程下无法插入 teardown,两种时序都正确。
- **给 provider-store 加写串行化(并发丢数据)**:这个 store 曾**完全没有写串行化**
(`usage-store` 有 `writeChain`,这里没有),而 `patchPayload` 是读-改-写。面板开关
POST 与 roster POST 是两个独立请求,各自读到同一个 `current`,后写的赢——实测用户勾了
模型又开了开关,两次点击只活下来一次。文件头说「两个字段必须作为一个载荷读写」防住了
「不同字段互相覆盖」,但缺串行化后「两次都基于同一快照」的互相覆盖照样发生。现在整段
读-改-写排队,`writePayload` 的版本拒绝语义一起搬进临界区。**legacy 继承回填**(读路径
内触发的写)也走同一条链,否则它会覆盖用户刚保存的清单,且因只发生一次而永久生效。
- **给三个时长配置补上界**:`cacheSeconds` / `pollSeconds` / `inferenceTimeoutMs` 的
`clampInt` 只传了 min(max 默认 `Infinity`)。`pollSeconds × 1000` 超过 2^31-1 ms 时
Node 把 `setInterval` 延时**钳到 1ms**(实测 pollSeconds=3000000 → `_idleTimeout=1`),
插件变成每毫秒打一次魔搭目录并打穿单飞缓存;`inferenceTimeoutMs` 同理让每次请求立刻超时。
现在按语义夹到轮询 1 小时 / 缓存 10 分钟 / 超时 10 分钟。
- **新增 `test/provider-publish.test.mjs`**:`provider-publish.ts` 此前**零测试覆盖**
(ROADMAP 声称 `test/provider.test.mjs` 测「publish 三条语义」,实际上没有)——零覆盖
正是这个 P0 能藏住的原因。新套件覆盖 dispose 闸门(含 await 窗口竞态)、publish 队列
串行性、回滚恢复旧对、空转守卫(指标是 `registerAdapter` 次数而非工厂次数——守卫在工厂
之后,被跳过的 publish 也跑过工厂)、签名的确定性。
- **核实并排除两条疑点**(避免误改):`{...handle}` 展开丢私有字段**不是问题**——peer
的 `prepareCall` 返回 plain object literal(源码 `:1827-1833`),无原型方法无私有字段;
`resolveRegistrationService` 漏清 `state.built` **不构成 bug**——重新注册旧适配器是安全的
(无 DUPLICATE_ADAPTER、A 上无注册域状态、新 release 有记录),只是语义不一致。
### 第二轮:深审挖出的真问题(每条都有复现证据,不是推断)
- **修 usage-store 版本守卫(数据静默销毁)**:守卫检查写在 `enqueue` 开头,而
`readOnly` 的置位发生在其后的 `cache.read()` 内部——判据比真正的探测**晚了一
步**。实测:磁盘预置 version 2 含 110 次真实调用,一次 `recordCall` 后 version
降级成 1、历史归零,而日志还在说「recording disabled to avoid clobbering」——
旧构建正在降级覆写新构建的数据,日志与磁盘状态互相撒谎。同一处 `readOnly` 只
置位不复位,于是用户按 doctor 建议删掉 usage.json 后写入永久静默丢弃、面板
还在显示内存残留。现在版本探测由写路径自己做(判据与写入之间不再隔 await),
且「版本已知」时复位闩锁。回归用例见 `test/usage-store.test.mjs` §7b/§7c(已验证
它们在修复前确实变红)。
- **修信任围栏的前缀匹配**:`hostName` 对 `Host: [::1]evil.com` 读成 `[::1]` 并
**通过**白名单——白名单是精确集合,前缀匹配等于把它放宽成「以成员开头」。现在
校验 `]` 之后只允许空或 `:port`。
- **修面板缺键即白屏**:`interpretSnapshot` 对 `ok:true` 的 body 是裸 cast,
`SNAPSHOT_REQUIRED_KEYS` 只在测试里被引用、运行时零防线。缺 `models` / `trend` /
`token` 任一 → TypeError炸掉整个面板(那一行在**所有 tab 上都执行**),而只有
`provider` 有防御。现在按 `SNAPSHOT_REQUIRED_KEYS` 校验并把缺失块补成与 Host
降级同构的空形状,缺失名单作为 `missingKeys` 冒到面板上(不静默)。顺带收口同
族裸解引用(`data?.models.sample` 的 `?.` 没到底、`TokenForm` 的 `=== null` 漏
`undefined`、`LocalDailyCard` 裸解构)。
- **修 429 分诊把限频误判成额度耗尽**:`classifyRateLimit` 曾把 `"limit reached"` /
`"daily"` / `"次数"` 归进 quota 桶,于是「Rate limit reached, retry after 60s」
被判成耗尽 → `llm-retry` 刻意排除 QUOTA → **瞬时限频快失败且不重试**;面板事件
流还把 rpm 限频显示成「额度耗尽」,与 `llm-error-fix` 的纠正层自相矛盾。现在判据
**复用** `llm-error-fix` 的 `looksLikeRateLimit` + `hasHardQuotaWordingIn`(消除
第二份词表),并补上 `daily|monthly … limit` 这组硬额度措辞。
- **修 `internal_error` 没有引导**:聚合器兜底码曾不在 `CODE` 表里,于是 Client 的
`GUIDANCE_BY_CODE` 映射不了它,最需要人看的内部错误反而没有引导文案,且因为不
在 `FORM_EXCLUDED_CODES` 里被引导去「配令牌」——方向反了。现在
`CODE.INTERNAL_ERROR` 正式进表,两端同一份真源。
- **修令牌脱敏漏紧凑写法(红线 1)**:`redactSecrets` 的 `ms-` 正则硬编码 UUID 的
`8-4-4-4-12` 分组形状,紧凑写法 `ms-3f2a1b8c1111222233334444555566` 整条漏网;
而 `ms-auth.save()` 不做形状校验,非标准形状的令牌是**可达状态**,上游一旦回显
即进日志与面板响应。两头都堵:脱敏闸放宽到 `ms-[0-9a-f-]{16,}`,入口新增形状
校验拒收遮不住的短值。
- **修 `forget()` 谎报删除成功(红线 1)**:曾吞掉凭据服务的 `unset` 失败,让路由回
`ok: true`,而令牌**仍在凭据文件里且仍被读回来**。安全动作谎报成功比失败更糟,
现在失败向上传播(路由已有脱敏呈现路径)。
- **修 `state().ephemeral` 答错问题**:wire 定义是「这个值重启会不会丢」,实现却答
「有没有凭据服务」——于是 env 来源的值(重启不丢)被标成 ephemeral。现在按来源
答:只有 `memory` 才真 ephemeral。
- **修 `parseRetryAfterMs` 漏英文单位**:只认中文单位,上游文案换成
`retry after 5 seconds` 就静默退化成「无时长」。补英文单位时还抓到一个**新引入的
30 倍退避差**——单位写成了非捕获组 `(?:...)`,`en[2]` 恒为 undefined,`2 minutes`
会被当成 2 秒。
- **修 HTML 响应被塞进面板**:`use-snapshot-polling` 的裸 `await response.json()`
在反向代理/登录墙回 HTML 时抛 SyntaxError,而 `errorText` 对 Error 返回
`.message`,于是那段 HTML 源码会显示在面板正文(同仓 `http.ts` 早有 `.catch`,
这里是漏)。
- 新增两个套件:`test/codes.test.mjs`(`classifyRateLimit` 此前**零测试覆盖**,所以
它能悄悄漂移)、`test/credentials.test.mjs`(红线 1 的专属门禁,此前也没有)。
后者逐档比对入口与脱敏闸**同宽**——这是防「进得来却遮不住」的关键不变量。
### 第一轮:漂移收口与门禁补强
- **修配置面漂移(会误导用户的那一处)**:`cordis.patch.yml` 仍写着「本地计数只覆盖
probe、不覆盖 provider 接入后的对话推理调用」——该说法在 `usage-observer` 落地时就
已作废,而这份文件正是操作者改配置时唯一在手边的文档。改为准确描述:计数经流出口
观察器覆盖**全部 DSH 魔搭调用**,并写明偏差方向是**按流计次**(重试由流消费完后的
`agent/request-error` 瀑布决定,每次重试各记一次)。~~少记(peer 的重试在同一条流
内部重发)~~ 这一说法于第八轮读 peer 源码后更正——见文件末尾「第八轮」。
- **新增产物新鲜度门禁 `test/build-gate.mjs`**:`lib/` 与根 `client.js` 是故意入库的
产物(`github:` 安装源不跑 prepack),此前 `.gitignore` 里「后续接入」的 build-gate
并不存在——**改了`src/` 忘build,市场装到旧代码,而工作树干净、测试与typecheck 全绿,
没有任何东西会红**。判据是**内容哈希不是 mtime**(clone / 解压都会重写时间戳):重新
构建到系统临时目录,与入库产物逐字节比对,且**只读工作树**。已挂进 `npm test` 尾部。
- **删死配置 `dailyQuotaTotal` / `dailyQuotaPerModel`**:面板早已移除次数口径的推算条,
「M4 阈值提醒预留」的前提(M4)已落地且阈值提醒不存在。客户端对两者零消费,故连同
wire 的 `quota.daily.limit` / `remainingComputed` / `quota.perModelLimit` 一并收窄为
`quota.daily.usedLocal` 单字段。`test/config.test.mjs` 显式钉死「它们不回来」。
- **清悬空引用**:源码里 20+ 处引用本仓库不存在的 `PITFALLS §NN` / `docs/IMPROVEMENTS.md`、
以及一个本仓库没有的 Raccoon 插件的 bug 史。**保留知识、删掉死指针**——坑是什么仍写
在注释里,只是不再指向查不到的编号。
- **新增 CI**(`.github/workflows/gate.yml`):typecheck + 全部离线套件门禁。CI 是唯一能
抓到「提交了忘 build」的地方。`on` 加了引号(裸 `on` 被 YAML 1.1 解析成布尔 true,
工作流不报错但永不触发)。
---
## 截图接进发版链路(`release.test.mjs` 门禁)
**病灶**:三张截图长期挂在 `?? assets/`(未跟踪),`package.json` 的 `files` 白名单**既没有
`assets` 也没有 `screenshots.json`**(后者根本不存在),README 一张图都没引用。三者叠加,
市场投稿指南里那条「截图通过插件仓库自己的 `screenshots.json` 声明」对本仓**完全落空**:
清单不存在,市场页只能回退去抓 README 里的图,而README 里没有图。
姊妹插件 agnes 2026-10-01 在这件事上栽过一次:`assets/` 与 git 都换了新名,唯独
`screenshots.json` 还指着两个已不存在的文件——工作树干净、构建通过、其余检查全绿,
**没有任何东西在报错**,而市场按这份清单取图,推上去就是图裂。本仓比那次更早:不是清单
指空,是清单压根不存在。
**修法**:新建 `screenshots.json`(额度/ 模型两张 tab 截图);`files` 补 `assets` 与
`screenshots.json`;README「面板长什么样」一节上图,且**`assets/` 与清单必须同一次提交**
(改名就必须同步清单——这是那次事故的直接教训);新增 `test/release.test.mjs`。
**门禁为什么必须两段判据**:
| 段 | 抓什么 | 为什么单查磁盘抓不到 |
|---|---|---|
| SCREENSHOTS | 清单里的图在磁盘上存在、是真图、条目数 1–8 | —— |
| SHIPPED | 清单里的图**被 `files` 白名单打包** | 「磁盘有 + 清单有 + 包里没有」——本地一切正常,装到用户机器上的包缺图 |
**逆检查也补了**:`assets/` 存在而 `files` 不含它时直接报红。
(姊妹仓 sensenova 就有这个洞:磁盘有图、`files` 无 `assets`。)
**门禁自己必须验过会红**:把清单改成指向 `assets/does-not-exist.png` 后跑,`exit=1`、报
「清单指空,市场按它取图必然裂」;恢复后重新全绿。一条从没红过的门禁和没有门禁等价。
**踩坑:`npm pack --dry-run --json` 解析失败**。`prepack` 会跑 tsdown,构建日志
(`ℹ tsdown v0.23.0...`)混进 stdout,JSON 解析直接炸。加 `--ignore-scripts` 绕开。
**`icon-preview.png` 不进清单**:它是卡片头部的 icon + 标题条示意,不是面板 tab 截图。市场按
清单取图是给「AppStore 式大图」用的,混进去等于用一张装饰图占掉一个图位。仍随包发布。
---
## 本地计数层在接入 provider 之后没有任何生产者
**病灶**:`usageStore.recordCall` 全仓只有 `routes/probe.ts` 一处调用,且只在
`kind:"usage"` 分支。而面板的 usage 试调按钮已随目录表删除(client 只发 `kind:"validity"`,
validity 按设计不记调用),接入 provider 后 DSH 的真实对话走 pi-ai 适配器、**没有任何计数
钩子**。
> **后记(M4+ 第六轮)**:那条 `kind:"usage"` 分支后来**被删掉了**——它只服务已不存在的
> 目录表按钮,且服务端原有「缺省即 usage」的形状意味着任何人 POST 一个 modelId 都会触发
> 一次真实计费调用。删掉后 probe 只剩零额度鉴权一事,`ProbeKind` 类型与 `ProbeSuccess.usage`
> 一并消失,`recordCall` 的写入侧**只剩** `usage-observer.ts` 一个。本段保留原文,因为它记
> 录的是「当时为什么会缺生产者」这一判断依据。
于是「额度」tab 的三块面板——今日调用、单模型分布、趋势、429 事件流——**恒为 0 与空**。唯二
能往事件流里写东西的动作是手动点「验令牌」且恰好失败。插件叫 `-token-plan`,
`quota.note` 写着「本地口径:只统计经本插件的调用」,而经本插件的调用从未被计过。
`tools/doctor.mjs` 现场佐证:磁盘上 1 个天桶、0 条事件。
**这类病的形状**:「计数的生产者没了」不会报错——**恒为 0 是一种合法状态**,没有任何门禁会红。
`tools/doctor.mjs` 之所以能当场抓到,是因为它盘点的是磁盘真相而非内存状态。
**修法**:新增 `src/host/usage-observer.ts`(peer-free,纯函数 + 生成器,离线可测)观察
harness 流出口——`StreamChunk` 自带 `{type:'usage', usage: TokenUsage}` 与带
`reason.failure` 的 `finish` chunk,**一次遍历同时拿到 token 与失败分类**,不必碰provider
私有 API,也不必改 vendor peer。接线:`llm-adapter.ts` 组合观察层 → `provider-publish.ts` 的
deps 增加 `usage?: UsageSinks` 并透传给工厂 → `index.ts` 把上面那个 usage-store 的两条方法
接上(写**同一个** store 实例,所以面板读到的就是这里写的账,不会分叉到别的 profile 目录)。
**不重复造第二本账**:DSH 自己有 `@linxin666/dsh-usage`,按 `days→provider→model` 折 token,
但它只订阅 session 的 `assistant/message`——**失败调用一次都不记**。所以边界划在这里:成功
的 token 总量交给全局账本(插件不重复记),本插件只生产全局账本答不了的两件:①经本插件的
分布/趋势;②**429/错误事件流**(官方只给总额度余额,不给「哪个模型刚才在限频」)。
**顺序承重**:观察层套在 `llm-error-fix` 重分类层**之外**,事件分诊读的是改写后的 code。顺序
反了,被纠正成 `RATE_LIMIT` 的 rpm 限频会被记成 `quota`——面板说「额度耗尽」而退避策略在同
一刻按限频处理,两边说两套话。
**刻意不动 `llm-adapter-core.ts`**:那个文件是三族插件受控复制的共享层,往里加一个只有魔搭
需要的观测钩子,等于给三份副本之间再造一个漂移面(`patchPayload`、签名比对、三态口径都栽在
这类地方)。观察层独立成文件、由魔搭自己的 `llm-adapter.ts` 组合;**无 sinks 时不套 Proxy**,
那条路径零开销。
**记账口径**:每次流结束记一次 call(正常/用户中断/内层抛错都走同一个 `finally`),**含
失败**——魔搭按次数计费,限频掉的请求同样是一次上游调用;token 上游没给时是 `null` 而非0。
写 sinks 的任何失败(同步抛与异步拒)都在 `safe()` 里吞掉:**观测失败不是对话失败**。
**偏差方向是「按流计次」,重试会各记一次**(2026-10-05 读 peer 源码更正,此前本节写作「少
记」并把重试描述成「在同一条流内部重发」——**那是错的**)。真实链路在
`dsh-agent-loop/lib/index.js:1116-1134`:流被完整消费后,agent-loop 读 `live.finish`,以
`failure: finish.failure` 派发 `agent/request-error` 瀑布,`dsh-llm-retry` 在其中依
`retryableCodes.includes(failure.code)` 决定是否 `continue` **重新发起一次请求**。所以:
- 重试**不在流内部**,它产生**新的流**;观察层套在流出口上,于是**每次重试各记一次 call**。
一次用户请求在 8 次重试后可能记 8 次——按「魔搭按次数计费」的口径这甚至是**正确的**
(每次重发都真打了一次上游),但它**不是**「少记」,与本节原先的声明相反。
- 只有 `EMPTY_RESPONSE` 那类「一条流内部由 peer 自行重发」的情形才会漏记,而 peer 的重试
动作发生在 `dsh-llm-retry`、并非流内部,故该情形不存在。
- `tokens` 为 `null` 这一条不受影响:限频掉的流没有 usage chunk,累计值取最后一个。
**为何重分类必须套在流上**(同一链路顺带证实):`finish.failure.code` 被 agent-loop 原样
送进 `agent/request-error`,正是 `dsh-llm-retry` 的判据。魔搭把每分钟速率上限复用
`quota_exceeded_error` 这个名字,而 `dsh-llm` 的 `isQuotaExceededError` 先于
`classifyPiAiError` 的 `rate.?limit` 分支命中,故 peer 只会给出 `QUOTA`——而 `QUOTA` 被
`retryableCodes()` 刻意排除。`reclassifyStream` 在流出口把 code 改写成 `RATE_LIMIT`,重试
才够得着这个自愈路径:**重分类不是事后分类,它是让限频进入重试策略的抢救层**。改写必须发
生在 agent-loop 消费流之前,因此观察层套在重分类层**之外**的顺序不可反(反了事件流与重试
都会照 `QUOTA` 走)。
---
## 重启丢清单、轮询空转失效、计数与分类口径漂移
这一节五处修复**同源**:M4 单次提交 `8c9dc53` 内 doc/code 漂移——`docs/PROVIDER-M4.md` 写明
「开关/清单落盘在 provider-store」,但 `index.ts` 轮询从未迁移去读落盘值;签名比对在文档与
设计意图里存在,实现却漏接。
- **重启后允许清单静默丢失**:`index.ts` 的目录轮询原本读 `publisher.state.enabledIds`
(**纯内存,重启即空**)当允许清单,而 `provider-store` 其实**已落盘**该清单。改为
`runPoll()`:拉目录后从 `providerStore.enabledIds()` 读落盘清单(读不到回退内存),再
`publish`;挂载时立即 `runPoll()` 取代原来的空`publish([], [], [])` seed。
- **轮询签名空转从未接线**:`provider-publish.ts` 注释声称「按 `catalogSignature` 比对,未变则
空转」,但 `publishProviderOnce` 从不做比较——于是每 30s 轮询都**完整摘下再挂上同一个
provider 对**,期间在途请求可能被打断。修法:`catalogSignature` 扩展到覆盖 `name` /
`contextWindow` / `maxOutputLength`;注册/注销两条路径都在「offer 与失败状态都没变」时返回
`{ok:true, skipped:true}` 空转,并在每次成功 `publish` 后调`syncSignaturesAfterPublish(state)`
同步基准。
- **`allowed` 三态分类两处口径漂移**:`snapshot-aggregate.ts` 用
`includes(HIDE_ALL_MODELS)→"none"`,`routes/provider.ts` 用
`length===1 && [0]===sentinel →"none"`——`["__hide_all__","other"]` 会被后者错判成
`"list"`。抽出唯一口径 `resolveAllowedList(enabledIds)`(空=`"all"` / 含哨兵=`"none"` /
其余=`"list"`)放 `llm-models.ts`,两处统一调用。
- **`enabledCount` 两处算法不一致**:`routes/provider.ts` 用 `enabledIds.length`(含过期/哨兵
id 虚增计数),`snapshot-aggregate.ts` 用 `roster∩allowSet`。现两处都按「roster 中真正
命中的条数」计。
- **快照读取持久清单**:`snapshot-aggregate.ts` 原从内存读清单,与上面同样的重启丢失问题。
- **`cordis.patch.yml` 计数范围写错**:原文称注册 provider 后「本地计数同时覆盖全部 DSH 魔搭
调用」,但适配器路径没有挂计数钩子,本地计数只反映探测用量。已更正注释。
**幽灵测试引用**:`switch-precedence.ts` / `llm-retry.ts` / `llm-error-fix.ts` 注释引用的
`test/peer-contract.test.mjs` 依赖未vendored 进仓库的运行时 peer。改为说明该契约测试在
`npm test` 离线门禁之外,并补上三个**纯函数**离线套件钉死本模块自身行为
(`switch-precedence` / `retry` / `error-fix`)。
**通用教训**:注释与 doc 里的「已实现」**不构成实现**。签名比对、落盘读取这类「设计意图里存在
但没人接线」的东西,只有对照真实调用链逐条grep 才能发现。
---
## 开关值写不进磁盘、保存开关会抹掉清单
**两个 bug 同源**:`provider-store.ts` 的 `patchPayload` 带了个 `mergeEnabled` 参数,该参数对
`enabled` **无条件**走「保留磁盘现值」分支,于是 `save(value)` 转交的开关值被**静默丢弃**:
磁盘上没有 `enabled` 键时展开成 `{}`,写入的 payload 根本不含开关。同时 body 只在
`patch.enabledIds !== undefined` 时才带 `enabledIds`,而 `save()` 只传 `{enabled}`——所以
**每点一次开关都会把已保存的清单整个删掉**。
**症状的三个读数是同一件事**:点「保存清单」后开关变「未接入」、源标注「配置」、状态
「未注册」,但模型列表照旧完整可见。`enabled` 从未落盘 → `store.enabled()` 返回 `null` →
`resolveSwitchEnabled(null, false)` = false → 开关与源;`registered` 走 `registerWanted=false`
分支 → 「未注册」;而 roster 渲染**不读开关**(来自 `inference.fetchModels()` 的目录条目),
所以模型全在。面板当场显示正确值是因为 `cache.remember()` **绕过了磁盘**,30s 后轮询
`use-snapshot-polling.ts` 重读磁盘就把开关打回原样。
**修法**:`patchPayload(patch)` 改为一参,`enabled` 与 `enabledIds` **对称**三态——省略
(`undefined`)保留磁盘键、显式 `null` 清成「键不存在」(`forget()` 的语义,原先靠写
`"enabled": null` 侥幸工作)、其余值照存。三处调用点(`save` / `saveEnabledIds` / `forget`)
同步收敛为单参。
**测试为什么全盲**:`test/provider.test.mjs` 原先只经 `store.enabled()` 断言,而它读的是
`cache.remember()` 填的内存值——**「面板显示对了」不等于「磁盘写对了」**,所以整套断言对这两个
bug 完全无效。新增**冷读**断言:另开一个空缓存 store 读同一目录,并直接解析 `provider.json`
核对两个键。**已确认新断言在有 bug 的代码上失败**(`开关值必须落盘,不能只活在缓存里`)。
**通用教训**:带缓存的持久层,「读」要断言**冷读**——绕过缓存直读磁盘。断言经缓存读,等于
在断言「内存里的东西对」。
---
## 面板收敛:删目录表、推荐改小卡片
- **删掉「模型目录(免认证,不耗额度)」整张表**。`/models` 是免认证的裸 id 列表,而同一 tab
顶部的 provider roster 已经是「接入后实际提供哪些模型」(带可用性/ 额度耗尽 / 视觉标记)
——两份列表并排只会让人对着两个数字发愣。`MODELS_PATH` 不再被 client 读取,Host 路由保留
(无请求即无成本)。
- **逐行「试调」按钮一并删除**(usage probe)。它只服务那张表,且**不在 DSH 的调用路径上**
(真调用经 provider adapter);**零额度的 validity probe(验令牌)保留在「接入」tab**,
`POST /probe` 路由不动。相邻的 `runProbe` 状态机、试调结果/错误行、`probeBusy` 随之移除。
当时**只删了客户端**,服务端的 `kind:"usage"` 分支留着——第六轮把它一并删掉(见上文
「本地计数层在接入 provider 之后没有任何生产者」的**后记**):保留一条没有入口、却因
「缺省即 usage」而会消耗额度的服务端路径,是反的。
- **高亮降级为推荐卡片**:原先deepseek/glm/qwen 三家在目录表里**整行染色**(左边线 + 底色 +
圆角 + ★ + 品牌色加粗名),把「一批里的三个 owner」渲染成了「被选中的三行」。现在 roster
每行只挂一枚 **「推荐」pill**,与既有的「视觉」pill 同构(同尺寸/同底色,仅字色用品牌色)
——行背景、名字字号一律不动。
**词汇表随之收敛**:删 `section.catalog` / `models.count` / `models.fetched` /
`models.probeUsage` / `models.probeKept` / `probe.usage` / `probe.busy` / `probe.ok`
(zh/en 同步);新增 `provider.featured`。`catalogOwner` / `sortCatalogIds` /
`FEATURED_OWNERS` 仍是承重导出(roster 判定 + `catalog.test.mjs` 冻结的排序契约)。
---
## 能力分类器 `resolveModelCapability`
把二值「是不是 vision」升级为能力路由:返回
`ModelCapability = unknown|text|vision-input|image-to-text|image-to-image|text-to-image|text-to-video`;
`isVisionModel` 退化为它的布尔投影(`vision-input` / `image-to-text` 才算吃图,图出图 / 文生图
不算)。
**判定顺序(权威→兜底)**:结构化 `input_modalities` → 详情端点 `Tasks[].Name` 任务标签
(多标签按 `CAPABILITY_RANK` 取最具体)→ 策展清单 `KNOWN_VISION_IDS` → 名字启发;有标签但全
不认识 → `unknown`,**绝不猜**。
**词表真机核实(2026-10-04)**:api-inference 35 条目录只出现
`text-generation`(16) / `image-text-to-text`(14) / `image-to-image`(2) /
`text2text-generation`(1),**没有任何模型被标成正则扩展位里的词**,故 `VISION_INPUT_TASKS` 实际
只命中 `image-text-to-text`(扩展位保持防御性,未放宽到会误伤图出的词)。详情端点对文生图返
`text-to-image-synthesis`、文生视频 `text-to-video-synthesis`。
⚠️ **能力标签 ≠ 可用端点**:api-inference 目录里目前**没有**文生图模型。
`KNOWN_VISION_IDS` 由 2 条补全到全部 14 条实测吃图模型——其中 `MiniMax-M3` / `Intern-S1` 系列 /
`Step-3.7-Flash` **名字不含 vl/vision**,名字启发会漏,详情端点整段不可达时也要保住准确视觉
标签。(`early-access/EA-29B-A4B` 详情端点 404、`Step-3.5-Flash` 无 Tasks,均自动回退名字启发。)
---
## 正式接入 DSH 作为 LLM provider(M4)
**provider 注册**(id `modelscope-token-plan`,直连 apiBase,OpenAI 兼容)的承重文件清单与
设计契约见 [PROVIDER-M4.md](PROVIDER-M4.md),此处只记实施中额外查证到的三件事。
**vision 判定为什么不用 models.dev**:`/v1/models` 仍无模态字段,但详情端点
`Data.Tasks[].Name` 提供精确任务标签(`image-text-to-text`=图进文出=能吃图;
`image-to-image` / `text-to-image`=图出,排除)。`fetchModels` 拿到目录 id 后**并行拉取**(有界
并发 6、`Promise.allSettled`、单失败不影响整体)每个详情。免认证、零推理额度。
详见 [REFERENCES.md](REFERENCES.md) 与 [REFERENCE-modelsdev.md](REFERENCE-modelsdev.md)——后者
记录 `anomalyco/models.dev` 的编码方式及**为何不用作 drop-in 数据源**(它对魔搭仅收编 7 个纯
文本模型,不含 `DeepSeek-V4.1-Flash` 等视觉模型)。
**为什么魔粒余额是主数据源**:`GET /openapi/v1/magicubes/balance` 真实可用(见
[SPIKE.md](SPIKE.md)),而推理响应本身**不带任何额度头**。同日实测推翻了 Spike 的一半结论。
次数口径的「推算剩余 / 参考上限」因此**从面板移除**——官方改魔粒计费后两个单位并排是误导;
`dailyQuotaTotal` / `dailyQuotaPerModel` 两个社区快照常数**也从配置里一并删除**(面板不消费
它们,留着只会让人以为能算「还剩几次」)。真要加阈值提醒时,那应该是读官方余额的差值,
不是这两个数;`test/config.test.mjs` 已钉住它们不会回来。
**模型目录排序**:`/v1/models` 原始返回序把三家好用模型拆散(deepseek 在顶、Qwen 在中间、
glm/ZhipuAI 在底)。新增 `FEATURED_OWNERS`(`deepseek-ai` / `ZhipuAI` / `Qwen`)与
`sortCatalogIds`:featured 置顶分组、其余按 owner 字母序、同 owner 内按 id 字母序。
**429 响应形状未实测**(不值得烧额度去触发),解析按 OpenAI 惯例兼容,漂移时面板给
`shapeWarnings`。
---
## 仓库起步与审核修复
- **仓库起步**:清单 / 配置面 / 构建纪律(与姊妹插件同构)。`npm run build` 产物
(`lib/` + `client.js`)**入库**——`files` 白名单不含 `lib/`,不入库则 GitHub 直装的包没有
宿主入口。
- **`tools/doctor.mjs` 落地**(+`src/host/doctor.ts`):只读盘点 `$DSH_HOME/state` 下各
profile 的 usage.json(损坏/ 未知版本 → 症状)与 env 令牌在场性;**绝不写盘、绝不打印令牌值**。
它的真正价值是抓「恒为 0 / 恒为空」这类不会报错的状态。
- **快照失败线格式统一为 `{ok, code, error}`**:`wire.ts` 的 `SnapshotFailure` 此前声明
`message`,与路由写入、client 读取漂移。
- **单模型 tokens 改今日口径**(`dayTokens`,跨日清零):原「今日」行并排历史累计是误导;v1
加性字段,旧载荷缺失按 0 起,`STATE_VERSION` 不变。
- **`reasoning` 恒为 false 是保守默认**:魔搭 API-Inference 是多模型代理,是否吃
`reasoning_effort` 取决于背后那个模型;本插件无法离线得知每个 id 的档位表。
**后记(第九轮,2026-10-05 真机实测)**:原理由「发错档位会整条请求 400」**已被证伪**
——`reasoning_effort` 传 `"bogus"` 同样返回 200,值根本不被校验;真实障碍是无法区分
「生效」与「静默忽略」(都是 200),且端点会在真响应与空壳 200 之间摇摆。见
PROVIDER-M4.md §4 的更正块。
- 真机冒烟:魔粒余额 143、模型目录 35、零漂移警告。