dsh-router-core 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +156 -76
  2. package/cordis.patch.yml +2 -7
  3. package/docs/ext.md +4 -18
  4. package/docs/protocols.md +198 -0
  5. package/lib/client-registry.js +193 -668
  6. package/lib/client.js +193 -668
  7. package/lib/ext-prompt/index.js +202 -32
  8. package/lib/index.js +2805 -547
  9. package/lib/types/ext/contract.d.ts +20 -1
  10. package/lib/types/ext-prompt/content.d.ts +18 -1
  11. package/lib/types/ext-prompt/index.d.ts +7 -9
  12. package/lib/types/ext-prompt/mount.d.ts +14 -3
  13. package/lib/types/ext-prompt/plugin.d.ts +13 -5
  14. package/lib/types/ext-prompt/render.d.ts +14 -3
  15. package/lib/types/ext-prompt/sections.d.ts +95 -0
  16. package/lib/types/protocol/anthropic.d.ts +35 -0
  17. package/lib/types/protocol/chat.d.ts +25 -0
  18. package/lib/types/protocol/entry.d.ts +137 -0
  19. package/lib/types/protocol/gemini.d.ts +57 -0
  20. package/lib/types/protocol/index.d.ts +13 -0
  21. package/lib/types/protocol/responses.d.ts +41 -0
  22. package/lib/types/protocol/sse.d.ts +41 -0
  23. package/lib/types/protocol/stream.d.ts +92 -0
  24. package/lib/types/protocol/types.d.ts +224 -0
  25. package/lib/types/router/index.d.ts +42 -1
  26. package/lib/types/router/types.d.ts +19 -0
  27. package/lib/types/shared.d.ts +11 -0
  28. package/lib/types/suppliers/contract.d.ts +8 -0
  29. package/lib/types/suppliers/registry.d.ts +0 -1
  30. package/package.json +6 -15
  31. package/src/client/ExtDetail.tsx +26 -18
  32. package/src/client/group-controls.test.ts +53 -0
  33. package/src/client/group-controls.ts +43 -0
  34. package/src/client/index.tsx +0 -17
  35. package/src/client/router-components.test.ts +1 -1
  36. package/src/client/router.css +23 -355
  37. package/src/ext/contract.ts +20 -1
  38. package/src/ext-prompt/assembly.test.ts +63 -0
  39. package/src/ext-prompt/content.ts +25 -22
  40. package/src/ext-prompt/ext-prompt.test.ts +41 -31
  41. package/src/ext-prompt/index.ts +57 -11
  42. package/src/ext-prompt/mount.test.ts +18 -1
  43. package/src/ext-prompt/mount.ts +24 -9
  44. package/src/ext-prompt/plugin.ts +67 -6
  45. package/src/ext-prompt/render.test.ts +1 -1
  46. package/src/ext-prompt/render.ts +17 -6
  47. package/src/ext-prompt/section-controls.test.ts +148 -0
  48. package/src/ext-prompt/section-filter.test.ts +140 -0
  49. package/src/ext-prompt/sections.test.ts +123 -0
  50. package/src/ext-prompt/sections.ts +193 -0
  51. package/src/ext-route.test.ts +16 -0
  52. package/src/index.ts +109 -1
  53. package/src/protocol/anthropic.ts +662 -0
  54. package/src/protocol/chat.ts +272 -0
  55. package/src/protocol/entry.ts +440 -0
  56. package/src/protocol/gemini.ts +548 -0
  57. package/src/protocol/index.ts +31 -0
  58. package/src/protocol/protocol.test.ts +728 -0
  59. package/src/protocol/responses.ts +551 -0
  60. package/src/protocol/sse.ts +94 -0
  61. package/src/protocol/stream.ts +195 -0
  62. package/src/protocol/types.ts +249 -0
  63. package/src/protocol/wire-e2e.test.ts +208 -0
  64. package/src/protocol/wiring.test.ts +225 -0
  65. package/src/router/index.ts +144 -26
  66. package/src/router/models-cache.test.ts +5 -6
  67. package/src/router/supplier-switch.test.ts +1 -1
  68. package/src/router/types.ts +19 -0
  69. package/src/shared.ts +11 -0
  70. package/src/suppliers/contract.ts +8 -0
  71. package/src/suppliers/registry.ts +0 -71
  72. package/lib/contract-D4BWZ7Xe.js +0 -13
  73. package/lib/ext-test/index.js +0 -36
  74. package/lib/ext-test/locale/en.json +0 -6
  75. package/lib/ext-test/locale/zh.json +0 -6
  76. package/lib/ext-test/package.json +0 -5
  77. package/lib/types/ext-test/index.d.ts +0 -21
  78. package/lib/types/ext-test/plugin.d.ts +0 -23
  79. package/lib/types/suppliers/probe.d.ts +0 -160
  80. package/src/client/ExtTestPanel.tsx +0 -392
  81. package/src/client/comp-button-scope.test.ts +0 -58
  82. package/src/client/comp-panel-layout.test.ts +0 -58
  83. package/src/client/ext-panels.test.ts +0 -44
  84. package/src/client/ext-panels.ts +0 -49
  85. package/src/client/model-choice.test.ts +0 -41
  86. package/src/client/model-choice.ts +0 -37
  87. package/src/ext-test/ext-test.test.ts +0 -60
  88. package/src/ext-test/index.ts +0 -35
  89. package/src/ext-test/locale/en.json +0 -6
  90. package/src/ext-test/locale/zh.json +0 -6
  91. package/src/ext-test/plugin.ts +0 -34
  92. package/src/ext-test/row.json +0 -5
  93. package/src/suppliers/probe.test.ts +0 -266
  94. package/src/suppliers/probe.ts +0 -475
package/README.md CHANGED
@@ -18,12 +18,25 @@
18
18
  <a href="docs/ext.md">扩展开发</a>
19
19
  </p>
20
20
 
21
- **插件版的 9router** —— 不是另开一个网关服务,而是直接作为 DSH 插件嵌进 DSH web,
22
- 在宿主 Web 服务的 `/v1` 上原生暴露 OpenAI 兼容端点(`http://127.0.0.1:<宿主页端口>/v1`;`dsh web` 默认 3080,面板「端点与密钥」页显示的是实际地址),把请求路由到内部供应商。
23
- 管理界面在**设置 → 路由**(官方设置页座位,不是自己开的页面)。装好即用,
24
- 不用多开一个 9router、不用维护第二个端口、不用在网关和 DSH 之间搬配置。
21
+ 直接把 DSH 变成一个**多协议、多供应商的模型网关** —— 不是另开一个网关服务,
22
+ 而是作为插件嵌进 DSH web,在宿主 Web 服务上原生暴露四个端点,把请求路由到内部供应商。
23
+ 管理界面在**设置 → 路由**(官方设置页座位,不是自己开的页面)。装好即用:
24
+ 不另起进程、不占第二个端口、不用在网关和 DSH 之间搬配置。
25
25
 
26
- **原生支持 Agent Team / 多会话** —— 0.1.7 起每个 team 成员是独立会话,
26
+ **四种协议双向互转**(详见 [四种协议入口](#四种协议入口)):
27
+
28
+ | 端点 | 客户端 | 协议 |
29
+ | --- | --- | --- |
30
+ | `POST /v1/chat/completions` | OpenAI SDK / Cline / DSH 自身 | OpenAI Chat Completions |
31
+ | `POST /v1/messages` | **Claude Code** | Anthropic Messages |
32
+ | `POST /v1/responses` | **Codex** | OpenAI Responses |
33
+ | `POST /v1beta/models/<m>:generateContent` | **Gemini CLI** | Google Gemini |
34
+
35
+ 四个入口共享同一套鉴权、账号池、组合回退与用量统计。把 Claude Code 的
36
+ `ANTHROPIC_BASE_URL` 指过来,它用的就是你的账号池;Codex、Gemini CLI 同理。
37
+ 供应商侧也可以说这四种里的任意一种 —— 声明一下方言,核心负责翻译。
38
+
39
+ **原生支持 Agent Team / 多会话** —— team 的每个成员是独立会话,
27
40
  dsh-router 按**会话身份**亲和选号:同会话固定落同一个连接(前缀缓存只写一份),
28
41
  不同会话尽量铺开到不同连接;多会话共用同号时按会话隔离统计,**不会互相拖累**。
29
42
  详见 [Agent Team 支持](#agent-team-支持)。
@@ -57,6 +70,7 @@ dsh plugin --profile web add dsh-router-core
57
70
  | 能力 | 说明 |
58
71
  | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
59
72
  | 零额外进程 | 就是 DSH 插件,随 `dsh web` 启停,天然同源(`/router/api/*` 无 CORS、面板嵌在设置里)。 |
73
+ | 四协议互转 | Chat / Anthropic / Responses / Gemini 进站出站都认,Claude Code、Codex、Gemini CLI 直接接上来就能用账号池。供应商侧也只需声明方言,翻译在核心统一做。 |
60
74
  | 扩展即插即拔 | 扩展插件(如 [`dsh-router-ext-rtk`](https://github.com/CARVIN94/dsh-router-ext-rtk))经 `router.ext` 注册,在 bash 执行前改写命令(如加 `rtk` 前缀压缩输出)。开关在官方插件页(设置 → 插件 → dsh-router-core),带自检;面板「扩展」页只列已启用的、只读。 |
61
75
  | 供应商即插即拔 | 内置供应商是插件页「包含的组件」里的三行,可单独开关;更多供应商 = 装一个 DSH 插件(`dsh-router-*`)或放一个 js 文件到 `~/.dsh/profiles/web/suppliers/`(同名 js 覆盖内置)。 |
62
76
  | 模型不内置 | 供应商只实现差异化能力,模型拉取与缓存由核心统一管,不写死、不过时。 |
@@ -66,10 +80,6 @@ dsh plugin --profile web add dsh-router-core
66
80
  | 用量可观测 | 面板概览看板:周期切换、汇总卡、趋势折线、Top 榜、最近请求。 |
67
81
  | Team 友好 | 按**会话身份**亲和选号:同会话固定同一连接(前缀缓存只写一份),不同会话尽量铺开到不同连接;多会话共用同号时缓存质量**按会话隔离**统计,不会互相拖累。 |
68
82
 
69
- > 面板布局、组合 fallback、连接池/账号池、API key 管理都贴近
70
- > [9router](https://github.com/decolua/9router),但按 DSH「一切皆插件」的方式
71
- > 重组得更轻。
72
- >
73
83
  > 供应商开发与接入规范见 [`docs/suppliers.md`](docs/suppliers.md)
74
84
  > (契约 / 加载顺序 / 模型统一策略 / 内置供应商参考实现)。
75
85
  > 选号/亲和/team 的完整设计与实测见
@@ -77,7 +87,7 @@ dsh plugin --profile web add dsh-router-core
77
87
 
78
88
  ## Agent Team 支持
79
89
 
80
- DSH 0.1.7 的**智能体团队**把任务拆给多个成员,**每个成员是独立会话**,一轮
90
+ DSH 的**智能体团队**把任务拆给多个成员,**每个成员是独立会话**,一轮
81
91
  team 测试就是多个会话交错发请求。dsh-router 原生适配这个场景:
82
92
 
83
93
  - **真会话身份** —— 从宿主 `GenerateOptions.sessionId` 拿到权威会话 id
@@ -109,7 +119,7 @@ team 测试就是多个会话交错发请求。dsh-router 原生适配这个场
109
119
  第二处入口是**设置 → 插件 → dsh-router-core 的详情页**,那里分两层:
110
120
 
111
121
  - 宿主的原生**「包含的组件」**列 core 自己 patch 里声明的**行**:core 本体、三个
112
- 内置供应商、插件自检这个内置扩展。每行一个宿主管的开关,关一行 = loader 不
122
+ 内置供应商、分层提示词这个内置扩展。每行一个宿主管的开关,关一行 = loader 不
113
123
  import 它 = 它压根没注册。
114
124
  - 原生列表**下面**是本插件自绘的**「路由组件」**一节,列**外部供应商插件**与
115
125
  **扩展插件**,两组都带开关。
@@ -183,7 +193,57 @@ curl -X POST http://localhost:3080/v1/chat/completions \
183
193
  任何支持 OpenAI 兼容 API 的工具(Claude Code、Cline、DSH 设置-模型 等)都可以把
184
194
  `baseURL` 指向面板「端点与密钥」页里显示的那个端点(`dsh web` 下即 `http://localhost:3080/v1`)。
185
195
 
186
- **鉴权**:默认 `requireApiKey=false`,`/v1/*` 不要求鉴权(本地使用,与 9router 一致)。
196
+ ### 四种协议入口
197
+
198
+ 不止 OpenAI Chat。四种协议**双向互转**,进站认客户端说的方言,出站按供应商
199
+ 声明的方言请求,中间收敛成一种规范形态(OpenAI Chat Completions 请求体 + 一组
200
+ 扁平事件),所以是 4 个协议模块而不是 4×3 = 12 条翻译路径。
201
+
202
+ | 入口 | 客户端 | 说明 |
203
+ | --- | --- | --- |
204
+ | `POST /v1/chat/completions` | OpenAI Chat 系(Cline、DSH 设置-模型、任意 OpenAI SDK) | 规范形态本身,字节级透传 |
205
+ | `POST /v1/messages` | Claude Code | Anthropic Messages。`count_tokens` 返回 501(没有本地 tokenizer 就不编数字) |
206
+ | `POST /v1/responses` | Codex | OpenAI Responses |
207
+ | `POST /v1beta/models/<model>:generateContent`<br>`...:streamGenerateContent` | Gemini CLI | 模型名在**路径**里;`?alt=sse` 等 query 会先切掉再解析 |
208
+
209
+ 四个入口共享同一套鉴权(`Authorization: Bearer`)、同一个账号池与组合回退、
210
+ 同一份用量统计。换协议只是换入口,不会多一份账号池实现。
211
+
212
+ **把客户端接过来,只需改一个环境变量**(以 Claude Code 为例):
213
+
214
+ ```bash
215
+ export ANTHROPIC_BASE_URL=http://127.0.0.1:3080
216
+ export ANTHROPIC_AUTH_TOKEN=<面板「端点与密钥」里建的 key>
217
+ claude # 从此走 dsh-router 的账号池、组合回退与用量统计
218
+ ```
219
+
220
+ Codex 用 `base_url` 指向同一个地址 + `/v1`,Gemini CLI 用
221
+ `GOOGLE_GEMINI_BASE_URL`。三者都自动获得面板里配的供应商、冷却与失败转移 ——
222
+ 不需要为每个客户端各配一份 key。
223
+
224
+ **反方向也成立**:供应商可以在契约里声明自己说哪种协议(`wire`),核心负责把
225
+ 规范请求翻成它的方言、再把响应翻回来。所以接一个只会说 Anthropic 的上游,
226
+ 插件里只需要几行 `fetch`,不必自己写协议转换。
227
+
228
+ **跨协议会丢的东西**(明确不做翻译,各协议模块注释里逐条点名):Anthropic 的
229
+ `cache_control`、Responses 的 `previous_response_id` / `store`、Gemini 的
230
+ `safetySettings`。它们在目标协议里没有对应概念,硬造一个只是猜。
231
+
232
+ **实测过**(2026-10-21,真宿主 + 真实上游):四种协议的非流式与流式往返、
233
+ Anthropic 的工具调用(非流式 `tool_use` 与流式 `content_block_start` +
234
+ `input_json_delta` 增量)、用量从上游一路带到客户端。
235
+
236
+ 接线与语义层有七处坑是编解码器全绿也发现不了的:prefix 尾斜杠导致 404/405、
237
+ `chatCompletions` 造了 sink 却仍写 `res`、入站 tools 方向搞反、Gemini 的流式
238
+ 标志在 URL 不在 body、Anthropic 收尾帧的用量不能单独发、Gemini 的
239
+ `finishReason` 不能排在 `functionCall` 之前、纯工具调用流在收尾前一个字节都
240
+ 没有(收尾兜底判据得看「尾巴有没有内容」而不是「之前有没有写过」)。逐条记在
241
+ [`docs/protocols.md`](docs/protocols.md)。
242
+
243
+ 设计取舍、各协议的坑(工具调用的三种编码、收尾的四种词汇、用量分散在不同帧)、
244
+ 测试策略见 [`docs/protocols.md`](docs/protocols.md)。
245
+
246
+ **鉴权**:默认 `requireApiKey=false`,`/v1/*` 不要求鉴权(本地使用如此)。
187
247
  在「端点与密钥」页开启「要求 API Key」后,请求必须带
188
248
  `Authorization: Bearer <库内启用的 Key>`。
189
249
 
@@ -231,55 +291,6 @@ curl -X POST http://localhost:3080/v1/chat/completions \
231
291
  把它开回来。开关 UI 在**设置 → 插件 → dsh-router-core 详情页**下面的「路由组件」一节
232
292
  (「供应商」组 + 「扩展」组)。
233
293
 
234
- ## 插件自检(内置扩展 `dsh-router-ext-test`)
235
-
236
- 随核心分发的内置扩展,提供两块自检面板。它**不挂任何监听、不改写任何命令** ——
237
- 与 RTK 那类扩展的区别只在于它只提供面板。
238
-
239
- 1. **连接测试**:选**供应商 / 模型 / 连接**,跑一次真实的访问测试。
240
- 2. **契约体检**:选一个**外部供应商插件**,逐个契约成员报告「实现了吗 / 实跑通了吗 /
241
- 为什么没跑」。
242
-
243
- 它与三个内置供应商走同一套管道:是 `cordis.patch.yml` 里的一个**行**(子路径模块
244
- `dsh-router-core/ext-test`),因此在插件页原生「包含的组件」里占一行、开关由宿主管,
245
- 且**默认关闭**。行关闭时 loader 根本不 import 这个模块,扩展就不在 `router.ext` 表里 ——
246
- 「打开那一行才出现这张卡片」是天然的,不需要额外的状态位。
247
-
248
- 面板入口**只有一处**:设置 → 路由 → 扩展 → 点开「插件自检」。插件页那一行不挂详情页,
249
- 它只负责开关(理由见 [扩展插件](#扩展插件routerext) 一节)。
250
-
251
- 测试走核心已有的 `POST /suppliers/:id/models/test`,与面板「供应商」详情里的「测试」
252
- 按钮**同一条真实路径**(账号遍历 + chatOnce)。区别只在可以**钉死连接**:留空则由账号池
253
- 任选可用号(与那个按钮同义);指定连接则只测它、**失败不回退到别的号** —— 否则
254
- 「测这个连接」测出来的是另一个号的结果,诊断就成了假话。
255
-
256
- 面板的模型下拉**只列可用模型**:那个端点给的是全部模型(含用户在供应商详情里停用的),
257
- 把停用的也列出来,用户会挑一个自己明明关掉的模型去测。列表为空时还区分「一个模型都
258
- 没有」(该去拉取)与「全被停用」(该去开启)—— 共用一句「没有模型」会把用户往错的方向指。
259
-
260
- ### 契约体检:报告,不是代替你操作
261
-
262
- `POST /router/api/suppliers/:id/probe` 逐个成员给状态:`ok`(实现了且实跑通)/
263
- `fail`(必填成员缺失,或实跑抛错)/ `absent`(可选成员没实现)/ `skipped`(实现了但**故意
264
- 不跑**)。
265
-
266
- **有副作用的一律不自动执行**,只标出它实现了并说明为什么:
267
-
268
- | 成员 | 为什么不给自动跑 |
269
- | --- | --- |
270
- | `dispose` | 调用它就是把这个供应商卸载掉 |
271
- | `addApiKey` / `removeLink` | 写/删凭证 |
272
- | `generateLoginUrl` | 可能直接触发设备码 / OAuth 登录流 |
273
- | `completeLogin` | 需要一个真实回调 URL,空调没有意义 |
274
- | `checkinNow` | 替这个连接真的去签到 |
275
- | `chatOnce` | 会真发一次请求(消耗额度)——用上面的「跑一次访问测试」单独测 |
276
-
277
- 体检是给眼睛看的,不是给手用的:要触发上面这些,去供应商详情里点对应的按钮。
278
-
279
- 清单与契约**必须一起长**:`src/suppliers/probe.ts` 的成员表有一条判据直接解析
280
- `contract.ts` 源码里的 `SupplierModule` 成员名并断言全覆盖(编译期抓不到这种「两份
281
- 清单」),所以契约新增成员而体检漏检会当场红。
282
-
283
294
  ## 扩展插件(`router.ext`)
284
295
 
285
296
  > 完整契约、注册方式、自检与降级约定见 [`docs/ext.md`](docs/ext.md)。
@@ -312,17 +323,6 @@ curl -X POST http://localhost:3080/v1/chat/completions \
312
323
  (`PATCH /router/api/ext`)。开启时会**自检**:`getState().ready === false` 的拒绝开启
313
324
  (API 409),那一面的开关禁用并显示原因。
314
325
 
315
- ### 扩展可以自带详情面板
316
-
317
- 扩展详情页原本是一张写死的只读页(名字、id、状态、一段说明),扩展自己想放的交互
318
- (要选的模型、要跑的测试、要看的诊断)没地方放。客户端因此有一张按**扩展 id** 索引的
319
- 面板注册表(`src/client/ext-panels.ts` 的 `registerExtPanel`):登记了就用你的组件渲染
320
- 整个内容区,没登记才落回通用只读页。`插件自检` 就是这么用的。
321
-
322
- ⚠️ 这张注册表目前只在 dsh-router 自己的 client bundle 内,**外部插件包还引不到** ——
323
- `dsh-router-core/client` 是整个入口闭包、不导出它。要让外部插件也能自带面板需要加一个
324
- 真正的导出路径,详见 [`docs/ext.md`](docs/ext.md)。
325
-
326
326
  ### 内置扩展不在自绘节里重复列
327
327
 
328
328
  随核心分发的扩展(`source: 'builtin'`)已经是插件页原生「包含的组件」里的一行、自带
@@ -334,6 +334,81 @@ curl -X POST http://localhost:3080/v1/chat/completions \
334
334
  - `ctx.tools.get(name)` **必须带 agent scope**(`exec.agent`):bash 工具注册在
335
335
  agent scope,不带 scope 只查全局视图会查不到,静默走原样执行、从不改写。
336
336
  - 改写在一次已被审批授权的工具调用内发生,不绕过 sandbox / 审批。
337
+ - **段落清单要等一次装配**:扩展详情页的「系统提示词」各组来自宿主最近一次
338
+ `assemble()` 的快照,插件 apply 时会预热一次;刚装完/刚重载宿主那一瞬间
339
+ 可能还没有清单(组不显示),说一句话或重开面板即出现。
340
+
341
+ ## 分层提示词(`dsh-router-ext-prompt`)
342
+
343
+ 一个扩展,把**整个系统提示词**变成可看、可分组、可开关的清单。装在插件页
344
+ 「包含的组件」里,默认关闭。打开后入口在 **设置 → 路由 → 扩展 → 分层提示词**。
345
+
346
+ ### 两层,各管一件事
347
+
348
+ **第一层:准则(内置 12 条)**
349
+
350
+ 插件自带一套协作准则(`[准则 v5]`),按语义拆成 12 条,**每条独立开关、独立可改**:
351
+
352
+ | 分类 | 管什么 |
353
+ | --- | --- |
354
+ | 身份气质 / 视野 / 懒人梯子 | 怎么定位自己、动手前看什么、找最省的解法 |
355
+ | 领地 / 全局收口 | 工作边界、收尾复查 |
356
+ | 文档 | 只写权威内容(结论/约束/怎么用),不写流水账 |
357
+ | 修根因 / 取舍 / 底线 | 改共享函数而不是补单点、砍边角与升级路径、不可逆禁令 |
358
+ | 结构 / 交付 | 先结构后代码、交付口径 |
359
+ | 执行顺序 | 一次任务的固定流程 |
360
+
361
+ **还能自己加条目**:面板上「新增」写一条自己的准则,与内置的并列、同样能开关。
362
+ 自建的标 `custom`,可删;内置的不可删,只能关或改。改过的条目存
363
+ `router.extStore`,面板上有「还原」回到内置原文。整段准则用扩展的总开关控制
364
+ —— 关掉它,准则一个字都不注入。
365
+
366
+ **第二层:系统提示词段落(24 段)**
367
+
368
+ 把宿主**整体**系统提示词按段落列出来,逐段开关。实测 24 段,分组如下
369
+ (段名与前缀由宿主与本机装的插件决定,不同部署会不同):
370
+
371
+ | 分组 | 内容 |
372
+ | --- | --- |
373
+ | 部署人设 | `persona-prefix` / `persona-suffix` —— 这段部署的定位 |
374
+ | 工具说明 | `tool:bash` `tool:read` `tool:write` `tool:edit` `tool:glob` … 每个工具一段 |
375
+ | 计划模式 | `plan:policy` |
376
+ | 运行上下文 | `context:file-reference` |
377
+ | MCP 资源 / MCP 服务 | `mcp-resource-servers`、`mcp:rea` |
378
+ | 界面与交付 | `ui:deliverable-file-references` |
379
+ | harness / app | `harness:source`(运行环境说明)、`app:web-surface`(Web GUI) |
380
+
381
+ 关掉的段在 `system_prompt/assemble` 装配链里**真的不再注入**。
382
+
383
+ ### 为什么用 waterfall 删段,而不是同名覆盖
384
+
385
+ 同名覆盖(注册同名段返回 `''`)只能顶掉某一个**已知名字**,而且对**同层自己
386
+ 注册**的段还会 throw —— 它适合"替换宿主的某一句",不适合"关掉任意一段"。
387
+ `assemble()` 的监听器返回值是权威结果(宿主源码 `return transformed`),
388
+ 改 `sections` 就是真删段。它自己也在用同一手法做 agent preset 的 persona 替换,
389
+ 所以这条路是平台认可的,不是绕后门。
390
+
391
+ ### 段清单不写死
392
+
393
+ 段名只有个别是宿主导出的常量,其余(`tool:*` / `mcp:*` / 第三方插件注册的段)
394
+ 埋在各插件里 —— 写死必然漂移。做法:装配 waterfall 每轮携带**全量已排序段落**,
395
+ 顺路缓存一份;`apply` 时预热一次 `assemble`,所以没开口说话也有清单。
396
+
397
+ 分组映射**以真宿主实测段名为准**,不按"看起来像"的前缀脑补。实测纠正过两处:
398
+ `harness:source` 讲的是运行环境(不是开场身份)、`app:web-surface` 是 Web GUI
399
+ 说明(不是"界面与交付")⇒ 它们各自保留真实前缀当组名。映射表没收录的前缀
400
+ **原样显示**,条目照常能列能开关 —— 映射漂移不允许变成"段消失"。
401
+
402
+ ### 两条纪律
403
+
404
+ - **自己那段永不进段落清单**。准则已经有总开关 + 逐条分类开关,再列一条段开关
405
+ 就是同一件事的两个开关,状态必然漂移;过滤时它也恒放行。
406
+ - **落盘只存关闭项**(`data.sections[name] = false`),开回来就删键。宿主段随
407
+ 版本增删,存全表就得每次升级对账;只记"用户主动关了谁"让新段自然落回默认。
408
+
409
+ ⚠️ 段落清单是**最近一次装配的快照**:`assemble()` 不带 scope 时只含 global 段,
410
+ 真实对话在 agent scope;多会话并发时清单归"最后一次装配"。面板展示可接受,
411
+ **开关判定不受影响** —— 过滤按段名,名字缺席时无害跳过。
337
412
 
338
413
  ## 架构
339
414
 
@@ -345,20 +420,26 @@ curl -X POST http://localhost:3080/v1/chat/completions \
345
420
  │ │ ├─ StatsTab 概览:用量看板(周期按钮组 + 汇总卡 + 折线趋势 + Top 榜 + 最近请求)
346
421
  │ │ ├─ SupplierDetail 供应商详情:链接池 + 加链接 + 可用模型 + 模型测试
347
422
  │ │ ├─ CombosTab / EndpointTab
348
- │ │ └─ ExtTab → ExtDetail 扩展列表(只列已启用)与详情(优先渲染扩展自带的面板)
423
+ │ │ └─ ExtTab → ExtDetail 扩展列表(只列已启用)与详情(子开关 + 未就绪原因)
349
424
  │ └─ LastHitDock 「路由」徽章(composer.dock 座位;按会话显示最近命中)
350
425
  ├─ 设置 → 插件(官方 plugins.detail.section 座位)
351
426
  │ ├─ (原生「包含的组件」) core patch 里声明的行:core 本体 + 内置供应商 + 内置扩展
352
427
  │ │ 宿主管的开关,关一行 = loader 不 import 它
353
428
  │ └─ RouterComponentsSection dsh-router-core 详情页自绘的「路由组件」一节
354
429
  │ (外部供应商 + 扩展,两组都带开关)
355
- │ └─ ExtTestPanel 插件自检扩展的详情面板(按 ext id 登记到 ext-panels.ts)
356
430
  └─ fetch /router/api/* (同源,无 CORS)
357
431
  └─ host 半(src/index.ts)
358
432
  ├─ /v1/models + /v1/chat/completions (OpenAI 兼容, KeysStore 鉴权)
359
433
  │ └─ RouterAdapter(src/llm/adapter.ts) OpenAI SSE → DSH StreamChunk
360
434
  │ (usage 经 toTokenUsage 转 DSH 契约,见 docs/suppliers.md)
361
435
  │ (带 x-dsh-router-session 头:宿主 sessionId → 会话亲和)
436
+ ├─ 另外三个协议入口(src/protocol/,共享上面同一套 Router)
437
+ │ ├─ /v1/messages Anthropic Messages(Claude Code)
438
+ │ ├─ /v1/responses OpenAI Responses(Codex)
439
+ │ └─ /v1beta/models/* Gemini(generateContent / streamGenerateContent)
440
+ │ parseInbound → 规范请求 → Router.chatCompletions(原样复用)
441
+ │ → outboundSink 把 Router 写的 OpenAI 形态翻回客户端协议
442
+ │ (编解码器见 src/protocol/{chat,anthropic,responses,gemini}.ts)
362
443
  ├─ host-version(src/host-version.ts) 报宿主版本 + 守 0.2.0 版本地板(低于则告警)
363
444
  ├─ KeysStore(src/keys.ts) 密钥库 + requireApiKey
364
445
  └─ Router(路由器) → suppliers[]
@@ -368,7 +449,7 @@ curl -X POST http://localhost:3080/v1/chat/completions \
368
449
  └─ 外部插件供应商(经 router.suppliers service 注册)
369
450
  ↑ 内置三个是 core 自己 patch 里的**行**(子路径模块),与外部插件同一条通道
370
451
  └─ 扩展表 router.ext(core provide 空表,谁都能往里 append)
371
- ├─ 内置:插件自检(行 dsh-router-core/ext-test,默认关闭)
452
+ ├─ 内置:分层提示词(行 dsh-router-core/ext-prompt,默认关闭)
372
453
  └─ 外部:dsh-router-ext-rtk 等独立 bundle 经同一个 service 注册
373
454
  ```
374
455
 
@@ -425,7 +506,6 @@ pnpm test # node --test "src/**/*.test.ts"
425
506
 
426
507
  感谢以下项目给的灵感:
427
508
 
428
- - [decolua/9router](https://github.com/decolua/9router) —— 本地 AI 路由网关,面板/组合/连接池/凭证等思路的来源;
429
509
  - [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) —— DSH「一切皆插件」的宿主框架;
430
510
  - [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) —— DSH 插件形态与侧边栏入口的参考。
431
511
 
package/cordis.patch.yml CHANGED
@@ -30,13 +30,8 @@
30
30
  name: 'dsh-router-core/suppliers/openrouter'
31
31
  - id: dsh-router-nvidia
32
32
  name: 'dsh-router-core/suppliers/nvidia'
33
- # 连接自检扩展。**默认关闭**:行关闭时 loader 根本不 import 它,扩展就不在
34
- # `router.ext` 表里 —— 所以「打开才出现这张卡片」是天然的,不需要额外状态位。
35
- - id: dsh-router-ext-test
36
- name: 'dsh-router-core/ext-test'
37
- disabled: true
38
- # 分层提示词扩展。**默认关闭**(与 ext-test 同款):行关闭时 loader 根本不
39
- # import 它,扩展就不在 `router.ext` 表里 —— 「打开才出现这张卡片」是天然的。
33
+ # 分层提示词扩展。**默认关闭**:行关闭时 loader 根本不 import 它,扩展就不在
34
+ # `router.ext` 表里 —— 「打开才出现这张卡片」是天然的。
40
35
  #
41
36
  # ⚠️ 这里来回改过一次(2026-09-29):先改成默认开启(理由是"准则是基础设施"),
42
37
  # 随后又改回默认关闭 —— **决定权在用户**。判据见 ext-prompt.test.ts 的
package/docs/ext.md CHANGED
@@ -12,26 +12,12 @@ bash 命令。装好后出现在「设置 → 路由 → 扩展」,**开关不
12
12
  数据目录是 `<profile>/data/`,**不跟进程 cwd 跑**——从任何目录启动 `dsh web`
13
13
  读到的都是同一份配置(见 `src/data-dir.ts`)。
14
14
 
15
- ## 扩展详情页可以自带内容
16
-
17
- 扩展详情页默认是一张**写死的只读页**(名字、id、状态、一段说明)。扩展若想在详情里放
18
- 自己的交互(要选的模型、要跑的测试、要看的诊断),可以按**扩展 id** 登记一个面板组件
19
- (`src/client/ext-panels.ts` 的 `registerExtPanel`):登记后 `ExtDetail` 用你的组件渲染
20
- 整个内容区,没登记才落回那张通用只读页。组件**不收 props** —— 面板的渲染点只有详情页
21
- 一处。核心自带的 `插件自检` 就是这么做的。
22
-
23
- ⚠️ **目前这张注册表在 dsh-router 自己的 client bundle 内部,外部插件包还引不到它**:
24
- `dsh-router-core/client` 是整个 client 入口(一个注册座位的 CJS 闭包),并不导出
25
- `registerExtPanel`。要让外部插件也能自带面板,需要给它加一个真正的导出路径
26
- (如 `./client/panels`)。这是已知的升级路径,不是现在就能用的写法 —— 照着现有 exports
27
- 去 `import { registerExtPanel } from 'dsh-router-core/client'` 会拿到 undefined。
28
-
29
15
  参考实现:[dsh-router-ext-rtk](https://github.com/CARVIN94/dsh-router-ext-rtk)
30
16
  (把命令改写成 `rtk <cmd>` 压缩输出)。
31
17
 
32
18
  ## 子开关:一个扩展里有几件独立的事
33
19
 
34
- 有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如 `分层提示词` 有 14 个
20
+ 有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如 `分层提示词` 的
35
21
  准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、要么全不给。
36
22
 
37
23
  扩展可在 `RouterExt` 上声明 `controls`(子开关列表)与 `setControl`(改一个):
@@ -68,9 +54,9 @@ export function createPromptExt(deps): RouterExt {
68
54
  (隐藏会让用户以为它们不存在),重新打开总开关就恢复原样。
69
55
  3. **`control.id` 是持久化键**,改名会让用户已保存的选择丢失 —— 同 `ExtId`。
70
56
 
71
- ⚠️ **不适用于外部插件包(暂时)**:与下面的 `registerExtPanel` 一样,
72
- `controls` 的读写经 `dsh-router-core/client` 才能到达外部 bundle,而该入口不导出
73
- 这些能力。要让外部扩展用上,需要给它加一条真正的导出路径。这是已知的升级路径。
57
+ ⚠️ **不适用于外部插件包(暂时)**:`controls` 的读写经 `dsh-router-core/client`
58
+ 才能到达外部 bundle,而该入口不导出这些能力。要让外部扩展用上,需要给它加一条
59
+ 真正的导出路径。这是已知的升级路径。
74
60
 
75
61
  ## 分工:管理归核心,执行归插件
76
62
 
@@ -0,0 +1,198 @@
1
+ # 协议矩阵
2
+
3
+ dsh-router 的对外面不止 OpenAI Chat Completions。四种协议**双向互转**:
4
+
5
+ | 协议 | 入口 | 客户端 |
6
+ | --- | --- | --- |
7
+ | `chat` | `POST /v1/chat/completions` | OpenAI SDK / Cline / DSH 设置-模型 |
8
+ | `anthropic` | `POST /v1/messages` | Claude Code |
9
+ | `responses` | `POST /v1/responses` | Codex |
10
+ | `gemini` | `POST /v1beta/models/<model>:generateContent`<br>`POST /v1beta/models/<model>:streamGenerateContent` | Gemini CLI |
11
+
12
+ ## 为什么是「一个规范形态」而不是 12 条翻译路径
13
+
14
+ 四种协议两两互转是 4×3 = 12 条路径,逐条实现必然长出一打互相不一致的版本。
15
+ 收敛成「全部先转成规范形态、再从规范形态转出去」之后:
16
+
17
+ ```
18
+ 外来请求 ──decode──┐ ┌──encode── 外来响应
19
+ ├─ 规范形态 ────┤
20
+ 供应商请求 ◀─encode─┘ └─decode──▶ 供应商响应
21
+ ```
22
+
23
+ 只需要 N 个协议模块(N=4),而不是 N² 条路径;加第五种协议只是再加一个模块,
24
+ 已有路径一行不改。
25
+
26
+ **规范形态选 OpenAI Chat Completions(请求体)+ 一组扁平事件(响应流)**,
27
+ 不是随便挑的:供应商契约(`suppliers/contract.ts` 的 `chatOnce`)今天就是
28
+ 「吃 Chat Completions 请求体、吐 OpenAI SSE」。选别的形态等于把每个已装供应商
29
+ 插件都改一遍。
30
+
31
+ ## 模块
32
+
33
+ | 文件 | 职责 |
34
+ | --- | --- |
35
+ | `types.ts` | `ProtocolId`、`ChatEvent`、`ProtocolCodec` 契约、`finishOf` 对照表、不可信 JSON 取值工具 |
36
+ | `sse.ts` | 只做分帧(跨 chunk 缓冲)/拆 `event:`+`data:`/拼帧,**不懂任何协议语义** |
37
+ | `chat.ts` | 规范形态本身:编解码是恒等变换,外加事件映射(四种协议的共享脊柱) |
38
+ | `anthropic.ts` | `message_start` / `content_block_*` / `message_delta` / `message_stop` |
39
+ | `responses.ts` | `response.created` / `output_item.added` / `function_call_arguments.delta` / `response.completed` |
40
+ | `gemini.ts` | `contents` / `parts` / `functionDeclarations` / `usageMetadata` |
41
+ | `index.ts` | **静态**注册表(刻意不做运行时发现:四种协议是编译期就知道的集合) |
42
+ | `stream.ts` | `StreamState`(一次流的有状态累加)+ `translateStream`(接字节流) |
43
+ | `entry.ts` | 四个入站入口的薄壳:认路径、翻请求体、套响应流 |
44
+
45
+ ## 三条设计约束
46
+
47
+ ### 1. 核心不认识协议
48
+
49
+ `Router` 的职责是「选号/冷却/回退/记账」,它不该知道世界上有几种协议。
50
+ `protocol/entry.ts` 只做三件与协议有关的事:认路径、翻请求体、翻响应体,剩下
51
+ 全部委托给 `Router.chatCompletions` —— 于是四种协议**自动**获得账号池、组合
52
+ 回退、首字节预算、断流补帧、用量统计,一个都不用重写。
53
+
54
+ 响应侧因为 `chatCompletions` 直接往 `res` 写 SSE,所以传一个 `ResponseSink`
55
+ 进去:非 chat 协议用 `outboundSink` 中继翻译,而它只是转发器 —— Router 那套
56
+ 「客户端断开就取消上游流」的逻辑监听的是**真实 res 的 close**,照旧生效。
57
+
58
+ ### 2. 翻译层是纯函数,分帧状态按请求隔离
59
+
60
+ `toChat` / `fromChat` / `decodeFrame` / `encodeFrame` / `encodeJson` /
61
+ `decodeJson` 都不碰账号池、不碰 `res`,可以单测。
62
+
63
+ 需要跨帧记忆的部分(usage 分散在多帧、工具名先于参数、收尾帧要在流末补)
64
+ 放在 **`StreamState` / `ChunkTranslator`**,一个请求一个实例。早先 Gemini 的
65
+ 工具名映射放在模块级 Map 上靠「记得调 reset」来防串名 —— 那是错的:并发两条流
66
+ 各自的 `ls` 会抢同一个 index。现在由 `ProtocolCodec.createSession()` 在请求
67
+ 边界建一份,随流结束丢弃。
68
+
69
+ ### 3. 丢东西要写明,不要猜
70
+
71
+ 规范形态只覆盖**能表达的部分**。各家私有的、没有对应概念的东西在跨协议时
72
+ 会被**丢掉**,不是被翻译 —— 每条丢弃都在对应模块注释里点名:
73
+
74
+ - Anthropic 的 `cache_control`(Chat 的缓存是上游自动的,硬造一个 `prompt_cache_key` 是猜)
75
+ - Anthropic 的 `thinking.budget_tokens`(只保留为 reasoning 增量)
76
+ - Anthropic 的 `mcp_servers` / `container` / `context_management`(客户端侧特性)
77
+ - Responses 的 `previous_response_id` / `store`(我们的供应商契约是无状态的)
78
+ - Responses 的 `include` / `text.format`(跨协议没有对应物)
79
+ - Gemini 的 `safetySettings`
80
+
81
+ ## 各协议的坑
82
+
83
+ ### 工具调用:三种编码
84
+
85
+ | 协议 | 工具调用形态 |
86
+ | --- | --- |
87
+ | Chat | `tool_calls[].function.arguments` 是 **JSON 字符串增量** |
88
+ | Anthropic | `content_block_start` 给 `name`,后续 `input_json_delta.partial_json` 给增量 |
89
+ | Responses | `output_item.added` 给 `name`,后续 `function_call_arguments.delta` 给增量 |
90
+ | Gemini | `functionCall` 的 `args` 是**整块对象**,没有增量形态 |
91
+
92
+ Gemini 是唯一「名字和参数不同帧、参数还不可分」的:名字在 `functionCall.name`
93
+ 里,而规范形态的 `tool-args` 不带名字。所以 `gemini.ts` 需要 index↔name 映射,
94
+ 靠 `createSession()` 保证只在一条流内有效。
95
+
96
+ ### 收尾:四种词汇
97
+
98
+ | 协议 | 「说完了」怎么表达 |
99
+ | --- | --- |
100
+ | Chat | `finish_reason: "stop"` + `data: [DONE]` |
101
+ | Anthropic | `message_delta.stop_reason` + 独立的 `message_stop` **事件** |
102
+ | Responses | `response.completed`,里面 `status: "completed" | "incomplete"` |
103
+ | Gemini | 同帧的 `finishReason: "STOP" | "MAX_TOKENS"` |
104
+
105
+ `encodeFrame(finish)` 只发**原因那一帧**,`endFrames()` 发**终止帧**。
106
+ 两处都发会出现两个 `message_stop`,Anthropic 客户端会把后一个当成新一轮开始。
107
+
108
+ ### 用量:分散在不同帧
109
+
110
+ Anthropic 把输入用量放 `message_start`、输出放 `message_delta`;其余协议
111
+ 最后一帧才给。`StreamState` 的 `mergeUsage` 用**字段级 max**合并(与
112
+ `router/usage-tokens.ts` 的 `mergeUsage` 同规则)—— 覆盖会让后一帧把前面的
113
+ 输入清零。
114
+
115
+ ## 接线与语义上的七个坑(真宿主实测踩出来的)
116
+
117
+ 编解码器全绿、端点却不通 —— 这几条都属于**接线**而非翻译,症状离根因很远。
118
+
119
+ ### 1. prefix 路由不能带尾斜杠
120
+
121
+ 宿主 `Table.match()` 的判据是
122
+
123
+ ```js
124
+ pathname === prefix || pathname.startsWith(prefix + '/')
125
+ ```
126
+
127
+ 注册 `'/v1beta/models/'` 会让它去比 `'/v1beta/models//'`,**永不匹配** →
128
+ 落到宿主兜底,表现为 GET `404` / POST `405`。正确写法是不带尾斜杠的
129
+ `'/v1beta/models'`。
130
+
131
+ ### 2. `chatCompletions` 造了 sink 却没用它
132
+
133
+ `const out = sink === undefined ? res : sinkRes(sink)` 造完之后,方法体内仍
134
+ 一律写 `res` ⇒ 非 chat 客户端收到的还是 OpenAI 形态。`wiring.test.ts` 现在
135
+ **静态扫这一段**:方法体内除签名与 `out` 定义行外,出现任何 `res` 就红。
136
+
137
+ ### 3. 入站的 tools 方向搞反了
138
+
139
+ Anthropic 工具是扁平的 `{name, description, input_schema}`,Chat 要
140
+ `{type:'function', function:{name, parameters}}`。`toChat` 的 `convertTools`
141
+ 一度读的是 `tool.function.*`(出站方向的形状)⇒ **Claude Code 的所有工具被
142
+ 静默丢光**,模型回答「我没有这个工具」。出站方向当时有判据,入站一条都没有。
143
+
144
+ ### 4. 流式标志在 Gemini 的 URL 里,不在 body 里
145
+
146
+ Gemini 的官方契约是靠路径区分:`:streamGenerateContent` / `:generateContent`,
147
+ 请求体里**没有** `stream` 字段。只看 `body.stream` 会把流式请求当非流式,
148
+ 客户端按 SSE 解析一整个 JSON ⇒ 空响应。`parseInbound` 现在接受
149
+ `streamFromPath`(路径优先于 body)。
150
+
151
+ ### 5. 收尾帧的顺序按协议分治,不能统一
152
+
153
+ `StreamState.finishStream()` 原本固定发 `finish → usage → endFrames`。这对
154
+ Anthropic/Responses 是对的(收尾帧在最后),对 Gemini 是**致命**的:
155
+
156
+ Gemini 没有参数增量形态,`functionCall` 必须攒到流末才发(见下一条),而
157
+ `finishReason` 是「本轮说完了」的标记 —— 客户端看到它就收尾。若 `finishReason`
158
+ 先出,随后补发的 `functionCall` 被丢掉,表现为「工具永远调不动」。
159
+
160
+ 所以 `gemini.encodeFrame(finish)` 返回空,`finishReason` 改由 `endFrames` 在
161
+ 补发完 `functionCall` **之后**发出。判据直接断言
162
+ `indexOf('functionCall') < indexOf('finishReason')`。
163
+
164
+ ### 6. 收尾帧的用量不能单独发
165
+
166
+ Anthropic 的 `message_delta` 是**收尾帧**:`stop_reason` 与 `output_tokens`
167
+ 必须同帧。先发 finish(带占位 `output_tokens: 0`)再发 usage,会让流尾出现
168
+ 两个 `message_delta`,客户端取第一个就把用量读成 0。
169
+
170
+ 修法是给 `CodecSession` 加一个 `usage` 槽位,`finishStream` 派生一份带当前
171
+ 用量的会话来编收尾帧。注意 `createSession` 是**可选**的(只有 Gemini 实现它),
172
+ 所以派生时不能因 `encodeSession === undefined` 就跳过 —— 跳过等于用量永远送不进去。
173
+
174
+ ### 7. 纯工具调用流在收尾前一个字节都没有
175
+
176
+ `outboundSink.end()` 曾经用 `headSent` 判断「要不要给个空流兜底」。但 Gemini
177
+ 的 `functionCall` 攒到流末才发 ⇒ **纯工具调用轮次在收尾前没写过任何字节**,
178
+ `headSent` 是 false,于是收尾帧写完之后又被 `finishEmptyStream` 覆盖,两次
179
+ `writeHead` 打架,连接被掐断(2026-10-22 实测:请求 4.5 秒后 000)。
180
+
181
+ 判据必须改成「尾巴有没有内容」,而不是「之前有没有写过」。
182
+
183
+ ## 测试
184
+
185
+ 三条线,缺一不可:
186
+
187
+ - **`protocol.test.ts`** —— 编解码器两两往返(纯函数、无 IO)。基础 + 回归,
188
+ 每条回归对应一个真实修掉的 bug(含**入站**的 tools 方向)。
189
+ - **`wire-e2e.test.ts`** —— 从 `outboundSink` 到真实字节。守的是接线:状态行、
190
+ content-type、内容、收尾四件事同时搬对。
191
+ - **`wiring.test.ts`** —— 守**接线本身**:prefix 尾斜杠、`chatCompletions` 里
192
+ 有没有绕过 sink、四条入站路由是否都注册、Gemini 路径的流式意图与模型名是否
193
+ 真接进了 `parseInbound`、以及 Anthropic 收尾帧是否只发一次且带真用量。
194
+
195
+ 为什么都要:编解码器正确 ≠ 接线正确。`finishJson` 曾经把 Router 写的
196
+ **Chat 形态**用**客户端 codec** 去解(`choices` 字段根本不存在)—— 编解码器
197
+ 各自都是对的,但客户端拿到 200 + 空内容。静默错,只有端到端能抓到。上面五条
198
+ 接线坑则说明:**光有端到端也不够**,接线错误会让端到端测不到(路由根本没命中)。