dsh-router-core 0.4.5 → 0.4.7
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.
- package/README.md +152 -12
- package/docs/ext.md +1 -1
- package/docs/protocols.md +198 -0
- package/docs/suppliers.md +2 -2
- package/lib/client-registry.js +146 -106
- package/lib/client.js +146 -106
- package/lib/ext-prompt/index.js +182 -17
- package/lib/index.js +2764 -63
- package/lib/suppliers/opencode/index.js +58 -30
- package/lib/types/ext/contract.d.ts +20 -1
- package/lib/types/ext-prompt/content.d.ts +7 -1
- package/lib/types/ext-prompt/mount.d.ts +12 -1
- package/lib/types/ext-prompt/plugin.d.ts +10 -0
- package/lib/types/ext-prompt/render.d.ts +14 -3
- package/lib/types/ext-prompt/sections.d.ts +95 -0
- package/lib/types/protocol/anthropic.d.ts +35 -0
- package/lib/types/protocol/chat.d.ts +25 -0
- package/lib/types/protocol/entry.d.ts +137 -0
- package/lib/types/protocol/gemini.d.ts +57 -0
- package/lib/types/protocol/index.d.ts +13 -0
- package/lib/types/protocol/responses.d.ts +41 -0
- package/lib/types/protocol/sse.d.ts +41 -0
- package/lib/types/protocol/stream.d.ts +92 -0
- package/lib/types/protocol/types.d.ts +224 -0
- package/lib/types/router/account-pool.d.ts +7 -2
- package/lib/types/router/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +26 -3
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +24 -0
- package/lib/types/suppliers/contract.d.ts +15 -1
- package/package.json +6 -6
- package/src/client/ExtDetail.tsx +25 -5
- package/src/client/group-controls.test.ts +53 -0
- package/src/client/group-controls.ts +43 -0
- package/src/client/router.css +20 -1
- package/src/ext/contract.ts +20 -1
- package/src/ext-prompt/assembly.test.ts +63 -0
- package/src/ext-prompt/content.ts +8 -10
- package/src/ext-prompt/ext-prompt.test.ts +38 -28
- package/src/ext-prompt/index.ts +50 -2
- package/src/ext-prompt/mount.test.ts +18 -1
- package/src/ext-prompt/mount.ts +22 -7
- package/src/ext-prompt/plugin.ts +64 -1
- package/src/ext-prompt/render.test.ts +1 -1
- package/src/ext-prompt/render.ts +17 -6
- package/src/ext-prompt/section-controls.test.ts +148 -0
- package/src/ext-prompt/section-filter.test.ts +140 -0
- package/src/ext-prompt/sections.test.ts +123 -0
- package/src/ext-prompt/sections.ts +193 -0
- package/src/ext-route.test.ts +16 -0
- package/src/index.ts +117 -6
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- package/src/protocol/anthropic.ts +662 -0
- package/src/protocol/chat.ts +272 -0
- package/src/protocol/entry.ts +440 -0
- package/src/protocol/gemini.ts +548 -0
- package/src/protocol/index.ts +31 -0
- package/src/protocol/protocol.test.ts +728 -0
- package/src/protocol/responses.ts +551 -0
- package/src/protocol/sse.ts +94 -0
- package/src/protocol/stream.ts +195 -0
- package/src/protocol/types.ts +249 -0
- package/src/protocol/wire-e2e.test.ts +208 -0
- package/src/protocol/wiring.test.ts +225 -0
- package/src/router/account-pool.test.ts +35 -0
- package/src/router/account-pool.ts +15 -4
- package/src/router/index.ts +143 -24
- package/src/router/types.ts +26 -3
- package/src/router/usage-store.ts +4 -2
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +33 -0
- package/src/suppliers/contract.ts +15 -2
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- package/src/suppliers/opencode/plugin.ts +60 -29
package/README.md
CHANGED
|
@@ -18,12 +18,25 @@
|
|
|
18
18
|
<a href="docs/ext.md">扩展开发</a>
|
|
19
19
|
</p>
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
管理界面在**设置 → 路由**(官方设置页座位,不是自己开的页面)
|
|
24
|
-
|
|
21
|
+
直接把 DSH 变成一个**多协议、多供应商的模型网关** —— 不是另开一个网关服务,
|
|
22
|
+
而是作为插件嵌进 DSH web,在宿主 Web 服务上原生暴露四个端点,把请求路由到内部供应商。
|
|
23
|
+
管理界面在**设置 → 路由**(官方设置页座位,不是自己开的页面)。装好即用:
|
|
24
|
+
不另起进程、不占第二个端口、不用在网关和 DSH 之间搬配置。
|
|
25
25
|
|
|
26
|
-
|
|
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
|
|
90
|
+
DSH 的**智能体团队**把任务拆给多个成员,**每个成员是独立会话**,一轮
|
|
81
91
|
team 测试就是多个会话交错发请求。dsh-router 原生适配这个场景:
|
|
82
92
|
|
|
83
93
|
- **真会话身份** —— 从宿主 `GenerateOptions.sessionId` 拿到权威会话 id
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -334,6 +394,80 @@ curl -X POST http://localhost:3080/v1/chat/completions \
|
|
|
334
394
|
- `ctx.tools.get(name)` **必须带 agent scope**(`exec.agent`):bash 工具注册在
|
|
335
395
|
agent scope,不带 scope 只查全局视图会查不到,静默走原样执行、从不改写。
|
|
336
396
|
- 改写在一次已被审批授权的工具调用内发生,不绕过 sandbox / 审批。
|
|
397
|
+
- **段落清单要等一次装配**:扩展详情页的「系统提示词」各组来自宿主最近一次
|
|
398
|
+
`assemble()` 的快照,插件 apply 时会预热一次;刚装完/刚重载宿主那一瞬间
|
|
399
|
+
可能还没有清单(组不显示),说一句话或重开面板即出现。
|
|
400
|
+
|
|
401
|
+
## 分层提示词(`dsh-router-ext-prompt`)
|
|
402
|
+
|
|
403
|
+
一个扩展,把**整个系统提示词**变成可看、可分组、可开关的清单。装在插件页
|
|
404
|
+
「包含的组件」里,默认关闭。打开后入口在 **设置 → 路由 → 扩展 → 分层提示词**。
|
|
405
|
+
|
|
406
|
+
### 两层,各管一件事
|
|
407
|
+
|
|
408
|
+
**第一层:准则(内置 13 条)**
|
|
409
|
+
|
|
410
|
+
插件自带一套协作准则(`[准则 v5]`),按语义拆成 13 条,**每条独立开关、独立可改**:
|
|
411
|
+
|
|
412
|
+
| 分类 | 管什么 |
|
|
413
|
+
| --- | --- |
|
|
414
|
+
| 身份气质 / 视野 / 懒人梯子 | 怎么定位自己、动手前看什么、找最省的解法 |
|
|
415
|
+
| 领地 / 全局收口 / 可判定 | 工作边界、收尾复查、什么叫"做完了" |
|
|
416
|
+
| 修根因 / 取舍 / 底线 | 改共享函数而不是补单点、砍边角与升级路径、不可逆禁令 |
|
|
417
|
+
| 结构 / 绝不偷懒 / 交付 | 先结构后代码、校验与安全、交付口径 |
|
|
418
|
+
| 执行顺序 | 一次任务的固定流程 |
|
|
419
|
+
|
|
420
|
+
**还能自己加条目**:面板上「新增」写一条自己的准则,与内置的并列、同样能开关。
|
|
421
|
+
自建的标 `custom`,可删;内置的不可删,只能关或改。改过的条目存
|
|
422
|
+
`router.extStore`,面板上有「还原」回到内置原文。整段准则用扩展的总开关控制
|
|
423
|
+
—— 关掉它,准则一个字都不注入。
|
|
424
|
+
|
|
425
|
+
**第二层:系统提示词段落(24 段)**
|
|
426
|
+
|
|
427
|
+
把宿主**整体**系统提示词按段落列出来,逐段开关。实测 24 段,分组如下
|
|
428
|
+
(段名与前缀由宿主与本机装的插件决定,不同部署会不同):
|
|
429
|
+
|
|
430
|
+
| 分组 | 内容 |
|
|
431
|
+
| --- | --- |
|
|
432
|
+
| 部署人设 | `persona-prefix` / `persona-suffix` —— 这段部署的定位 |
|
|
433
|
+
| 工具说明 | `tool:bash` `tool:read` `tool:write` `tool:edit` `tool:glob` … 每个工具一段 |
|
|
434
|
+
| 计划模式 | `plan:policy` |
|
|
435
|
+
| 运行上下文 | `context:file-reference` |
|
|
436
|
+
| MCP 资源 / MCP 服务 | `mcp-resource-servers`、`mcp:rea` |
|
|
437
|
+
| 界面与交付 | `ui:deliverable-file-references` |
|
|
438
|
+
| harness / app | `harness:source`(运行环境说明)、`app:web-surface`(Web GUI) |
|
|
439
|
+
|
|
440
|
+
关掉的段在 `system_prompt/assemble` 装配链里**真的不再注入**。
|
|
441
|
+
|
|
442
|
+
### 为什么用 waterfall 删段,而不是同名覆盖
|
|
443
|
+
|
|
444
|
+
同名覆盖(注册同名段返回 `''`)只能顶掉某一个**已知名字**,而且对**同层自己
|
|
445
|
+
注册**的段还会 throw —— 它适合"替换宿主的某一句",不适合"关掉任意一段"。
|
|
446
|
+
`assemble()` 的监听器返回值是权威结果(宿主源码 `return transformed`),
|
|
447
|
+
改 `sections` 就是真删段。它自己也在用同一手法做 agent preset 的 persona 替换,
|
|
448
|
+
所以这条路是平台认可的,不是绕后门。
|
|
449
|
+
|
|
450
|
+
### 段清单不写死
|
|
451
|
+
|
|
452
|
+
段名只有个别是宿主导出的常量,其余(`tool:*` / `mcp:*` / 第三方插件注册的段)
|
|
453
|
+
埋在各插件里 —— 写死必然漂移。做法:装配 waterfall 每轮携带**全量已排序段落**,
|
|
454
|
+
顺路缓存一份;`apply` 时预热一次 `assemble`,所以没开口说话也有清单。
|
|
455
|
+
|
|
456
|
+
分组映射**以真宿主实测段名为准**,不按"看起来像"的前缀脑补。实测纠正过两处:
|
|
457
|
+
`harness:source` 讲的是运行环境(不是开场身份)、`app:web-surface` 是 Web GUI
|
|
458
|
+
说明(不是"界面与交付")⇒ 它们各自保留真实前缀当组名。映射表没收录的前缀
|
|
459
|
+
**原样显示**,条目照常能列能开关 —— 映射漂移不允许变成"段消失"。
|
|
460
|
+
|
|
461
|
+
### 两条纪律
|
|
462
|
+
|
|
463
|
+
- **自己那段永不进段落清单**。准则已经有总开关 + 14 条分类开关,再列一条段开关
|
|
464
|
+
就是同一件事的两个开关,状态必然漂移;过滤时它也恒放行。
|
|
465
|
+
- **落盘只存关闭项**(`data.sections[name] = false`),开回来就删键。宿主段随
|
|
466
|
+
版本增删,存全表就得每次升级对账;只记"用户主动关了谁"让新段自然落回默认。
|
|
467
|
+
|
|
468
|
+
⚠️ 段落清单是**最近一次装配的快照**:`assemble()` 不带 scope 时只含 global 段,
|
|
469
|
+
真实对话在 agent scope;多会话并发时清单归"最后一次装配"。面板展示可接受,
|
|
470
|
+
**开关判定不受影响** —— 过滤按段名,名字缺席时无害跳过。
|
|
337
471
|
|
|
338
472
|
## 架构
|
|
339
473
|
|
|
@@ -359,6 +493,13 @@ curl -X POST http://localhost:3080/v1/chat/completions \
|
|
|
359
493
|
│ └─ RouterAdapter(src/llm/adapter.ts) OpenAI SSE → DSH StreamChunk
|
|
360
494
|
│ (usage 经 toTokenUsage 转 DSH 契约,见 docs/suppliers.md)
|
|
361
495
|
│ (带 x-dsh-router-session 头:宿主 sessionId → 会话亲和)
|
|
496
|
+
├─ 另外三个协议入口(src/protocol/,共享上面同一套 Router)
|
|
497
|
+
│ ├─ /v1/messages Anthropic Messages(Claude Code)
|
|
498
|
+
│ ├─ /v1/responses OpenAI Responses(Codex)
|
|
499
|
+
│ └─ /v1beta/models/* Gemini(generateContent / streamGenerateContent)
|
|
500
|
+
│ parseInbound → 规范请求 → Router.chatCompletions(原样复用)
|
|
501
|
+
│ → outboundSink 把 Router 写的 OpenAI 形态翻回客户端协议
|
|
502
|
+
│ (编解码器见 src/protocol/{chat,anthropic,responses,gemini}.ts)
|
|
362
503
|
├─ host-version(src/host-version.ts) 报宿主版本 + 守 0.2.0 版本地板(低于则告警)
|
|
363
504
|
├─ KeysStore(src/keys.ts) 密钥库 + requireApiKey
|
|
364
505
|
└─ Router(路由器) → suppliers[]
|
|
@@ -425,7 +566,6 @@ pnpm test # node --test "src/**/*.test.ts"
|
|
|
425
566
|
|
|
426
567
|
感谢以下项目给的灵感:
|
|
427
568
|
|
|
428
|
-
- [decolua/9router](https://github.com/decolua/9router) —— 本地 AI 路由网关,面板/组合/连接池/凭证等思路的来源;
|
|
429
569
|
- [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) —— DSH「一切皆插件」的宿主框架;
|
|
430
570
|
- [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) —— DSH 插件形态与侧边栏入口的参考。
|
|
431
571
|
|
package/docs/ext.md
CHANGED
|
@@ -31,7 +31,7 @@ bash 命令。装好后出现在「设置 → 路由 → 扩展」,**开关不
|
|
|
31
31
|
|
|
32
32
|
## 子开关:一个扩展里有几件独立的事
|
|
33
33
|
|
|
34
|
-
有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如 `分层提示词`
|
|
34
|
+
有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如 `分层提示词` 的
|
|
35
35
|
准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、要么全不给。
|
|
36
36
|
|
|
37
37
|
扩展可在 `RouterExt` 上声明 `controls`(子开关列表)与 `setControl`(改一个):
|
|
@@ -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
|
+
接线坑则说明:**光有端到端也不够**,接线错误会让端到端测不到(路由根本没命中)。
|
package/docs/suppliers.md
CHANGED
|
@@ -138,7 +138,7 @@ js 只要知道这一条:**报 `rate_limit`/`transport`/`unknown` 只影响「
|
|
|
138
138
|
type ChatOnceResult =
|
|
139
139
|
| { ok: true; stream: ReadableStream<Uint8Array> } // 流式
|
|
140
140
|
| { ok: true; status: number; body: string } // 非流式
|
|
141
|
-
| { ok: false; state: AccountState; message: string } // 失败
|
|
141
|
+
| { ok: false; state: AccountState; message: string; resetAt?: number } // 失败
|
|
142
142
|
```
|
|
143
143
|
|
|
144
144
|
要点:
|
|
@@ -151,7 +151,7 @@ type ChatOnceResult =
|
|
|
151
151
|
|
|
152
152
|
| state | 核心处置 |
|
|
153
153
|
|---|---|
|
|
154
|
-
| `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5
|
|
154
|
+
| `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零。失败项带 `resetAt`(上游声明的重置时刻,epoch ms)时**冷到该点**——频率配额的重置是小时级的,退避上限只会到点回来再撞 |
|
|
155
155
|
| `quota` | 冷却 10 分钟 |
|
|
156
156
|
| `session_dead` | **连接级冷却 30 分钟**(该号所有模型都停用,到期自愈;没有永久禁用——403 分不清凭证死活与风控误伤) |
|
|
157
157
|
| `unavailable` / `transport` / `unknown` | 瞬时短冷却 30s(**每次都冷**,不攒次数) |
|