dsh-router-core 0.4.6 → 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.
Files changed (63) hide show
  1. package/README.md +152 -12
  2. package/docs/ext.md +1 -1
  3. package/docs/protocols.md +198 -0
  4. package/lib/client-registry.js +146 -106
  5. package/lib/client.js +146 -106
  6. package/lib/ext-prompt/index.js +182 -17
  7. package/lib/index.js +2732 -55
  8. package/lib/types/ext/contract.d.ts +20 -1
  9. package/lib/types/ext-prompt/content.d.ts +7 -1
  10. package/lib/types/ext-prompt/mount.d.ts +12 -1
  11. package/lib/types/ext-prompt/plugin.d.ts +10 -0
  12. package/lib/types/ext-prompt/render.d.ts +14 -3
  13. package/lib/types/ext-prompt/sections.d.ts +95 -0
  14. package/lib/types/protocol/anthropic.d.ts +35 -0
  15. package/lib/types/protocol/chat.d.ts +25 -0
  16. package/lib/types/protocol/entry.d.ts +137 -0
  17. package/lib/types/protocol/gemini.d.ts +57 -0
  18. package/lib/types/protocol/index.d.ts +13 -0
  19. package/lib/types/protocol/responses.d.ts +41 -0
  20. package/lib/types/protocol/sse.d.ts +41 -0
  21. package/lib/types/protocol/stream.d.ts +92 -0
  22. package/lib/types/protocol/types.d.ts +224 -0
  23. package/lib/types/router/index.d.ts +42 -1
  24. package/lib/types/router/types.d.ts +19 -0
  25. package/lib/types/shared.d.ts +11 -0
  26. package/lib/types/suppliers/contract.d.ts +8 -0
  27. package/package.json +6 -6
  28. package/src/client/ExtDetail.tsx +25 -5
  29. package/src/client/group-controls.test.ts +53 -0
  30. package/src/client/group-controls.ts +43 -0
  31. package/src/client/router.css +20 -1
  32. package/src/ext/contract.ts +20 -1
  33. package/src/ext-prompt/assembly.test.ts +63 -0
  34. package/src/ext-prompt/content.ts +8 -10
  35. package/src/ext-prompt/ext-prompt.test.ts +38 -28
  36. package/src/ext-prompt/index.ts +50 -2
  37. package/src/ext-prompt/mount.test.ts +18 -1
  38. package/src/ext-prompt/mount.ts +22 -7
  39. package/src/ext-prompt/plugin.ts +64 -1
  40. package/src/ext-prompt/render.test.ts +1 -1
  41. package/src/ext-prompt/render.ts +17 -6
  42. package/src/ext-prompt/section-controls.test.ts +148 -0
  43. package/src/ext-prompt/section-filter.test.ts +140 -0
  44. package/src/ext-prompt/sections.test.ts +123 -0
  45. package/src/ext-prompt/sections.ts +193 -0
  46. package/src/ext-route.test.ts +16 -0
  47. package/src/index.ts +109 -1
  48. package/src/protocol/anthropic.ts +662 -0
  49. package/src/protocol/chat.ts +272 -0
  50. package/src/protocol/entry.ts +440 -0
  51. package/src/protocol/gemini.ts +548 -0
  52. package/src/protocol/index.ts +31 -0
  53. package/src/protocol/protocol.test.ts +728 -0
  54. package/src/protocol/responses.ts +551 -0
  55. package/src/protocol/sse.ts +94 -0
  56. package/src/protocol/stream.ts +195 -0
  57. package/src/protocol/types.ts +249 -0
  58. package/src/protocol/wire-e2e.test.ts +208 -0
  59. package/src/protocol/wiring.test.ts +225 -0
  60. package/src/router/index.ts +142 -23
  61. package/src/router/types.ts +19 -0
  62. package/src/shared.ts +11 -0
  63. package/src/suppliers/contract.ts +8 -0
@@ -65,7 +65,7 @@ export interface RouterExt {
65
65
  * 扩展自带的**子开关**(可选;不声明 = 只有总开关)。
66
66
  *
67
67
  * 为什么需要:有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如分层
68
- * 提示词有 14 个准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、
68
+ * 提示词的准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、
69
69
  * 要么全不给。
70
70
  *
71
71
  * ⚠️ **与总开关的关系**:子开关是**叠加**在总开关之上的,总开关关 ⇒ 全部子开关
@@ -178,6 +178,25 @@ export interface ExtControl {
178
178
  readonly custom?: boolean;
179
179
  /** 文本被用户改过(决定「还原」按钮是否可点)。 */
180
180
  readonly overridden?: boolean;
181
+ /**
182
+ * 分组名(可选;缺省 = 无组)—— 面板按它把控制条**分节**渲染,组间插一条
183
+ * 组标题行,组内顺序照旧。**无组的条目照旧平铺** ⇒ 没用这个字段的扩展
184
+ * (ext-test 等)面板行为一个像素都不变。
185
+ *
186
+ * 为什么要有:一条扩展同时管两类东西时(分层提示词 = 准则分类 + 宿主段落),
187
+ * 平铺成一长列会让"哪条是准则、哪条是宿主的段"无从分辨 —— 分组是**归属
188
+ * 信息**,不是样式。
189
+ */
190
+ readonly group?: string;
191
+ /**
192
+ * 这一条参与拖动排序(可选;缺省 = 参与,保持既有行为)。
193
+ *
194
+ * 为什么需要显式关掉:排序落盘只认扩展自己的条目 id(`setControlOrder`
195
+ * 过滤未知 id)—— 宿主段落的 id 不在其中,拖它只会被静默丢弃 ⇒ 看起来能拖、
196
+ * 松手弹回,是假交互。**由扩展逐条声明**,与 `editable` 同一理由:能力在
197
+ * 实现方手里,由它说哪条有。
198
+ */
199
+ readonly sortable?: boolean;
181
200
  }
182
201
  /** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
183
202
  export interface ExtInfo {
@@ -20,6 +20,12 @@
20
20
  * —— 它没有终止条件,每次收口都必然能再找出一件存量,等于无限循环。
21
21
  * ② 固化:由"每次用户可见损失都必须固化"降级为"只对数据丢失/安全这类真损失
22
22
  * 补检查",并删掉「不固化的教训等于决定再犯一次」这句无界义务。
23
+ *
24
+ * ⚠️ 2026-10-17 用户点名的两处改动:
25
+ * ① **删掉「固化」分类**(整条移除,id `hardening` 不复存在)。用户已存的
26
+ * `categories`/`text`/`order` 里残留的 `hardening` 键无须迁移 ——
27
+ * `resolveCategories` 规则 2 会忽略不认识的 id。
28
+ * ② 身份气质:「默认中文」→「回复、思考过程必须使用中文」。
23
29
  */
24
30
  /** 一个可独立开关的提示词分类。 */
25
31
  export interface PromptCategory {
@@ -43,7 +49,7 @@ export declare const PROMPT_CATEGORIES: readonly PromptCategory[];
43
49
  *
44
50
  * ⚠️ 拆分时差点丢它:原文是 `[…] ·身份气质:…`,标题挂在方括号里、
45
51
  * **不属于任何单条分类**,按分隔符切分时自然被丢掉
46
- * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到 14 条
52
+ * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到一堆
47
53
  * **没有名字**的规则,而"这套东西叫什么、为什么存在"恰恰是它需要的上下文。
48
54
  *
49
55
  * 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。
@@ -23,10 +23,21 @@ export interface SystemPromptFace {
23
23
  order: number;
24
24
  text: string | (() => string);
25
25
  }): () => void;
26
- getSectionOrder?(name: string): number;
26
+ /**
27
+ * 段落总控用(`index.ts` 的 waterfall 预热):宿主 `SystemPrompt.assemble()`。
28
+ * 只声明用得到的最小形状 —— 返回的 `sections` 已按 order/name 排好序。
29
+ */
30
+ assemble(context?: Record<string, unknown>): Promise<{
31
+ sections: {
32
+ name: string;
33
+ text: string;
34
+ }[];
35
+ }>;
27
36
  }
28
37
  /** 段落名(**稳定**):改名会让已注册的作用域 section 与旧名并存。 */
29
38
  export declare const PROMPT_SECTION_NAME = "dsh-router-ext-prompt:guideline";
39
+ /** 官方 `SECTION_ORDERS` 里最低的那档(第一方开场白),测试用它当"最前"的判据。 */
40
+ export declare const OFFICIAL_LOWEST_SECTION_ORDER = -1000;
30
41
  export interface MountPromptDeps {
31
42
  /** 取 `ctx.systemPrompt`(可能尚未就绪 ⇒ undefined)。 */
32
43
  getSystemPrompt: () => SystemPromptFace | undefined;
@@ -13,6 +13,7 @@
13
13
  * 真正的注入在 `mount.ts`。
14
14
  */
15
15
  import type { ExtStoreService, RouterExt } from '../ext/contract.ts';
16
+ import { type SectionEntry } from './sections.ts';
16
17
  /** 扩展开关表里的注册键。 */
17
18
  export declare const EXT_PROMPT_ID = "prompt";
18
19
  /**
@@ -29,4 +30,13 @@ export declare const EXT_PROMPT_ID = "prompt";
29
30
  export declare function createPromptExt(deps: {
30
31
  isSystemPromptReady: () => boolean;
31
32
  store?: ExtStoreService;
33
+ /**
34
+ * 当前已装配的段落清单(最近一次 `assemble` 的快照,来自 waterfall 缓存)。
35
+ *
36
+ * ⚠️ **必选,不给缺省**:缺省成 `() => []` 的话,`index.ts` 忘接这个参数
37
+ * 就是"段清单恒空"的静默失效(面板无段、过滤不生效,但一切照常跑)——
38
+ * 让类型红比让它静默强。**空清单是合法值**(还没预热完/宿主一段都没有),
39
+ * 与"没接线"的区分靠这里不给缺省,不靠运行时猜。
40
+ */
41
+ getSections: () => SectionEntry[];
32
42
  }): RouterExt;
@@ -24,7 +24,7 @@ export interface PromptExtData {
24
24
  text?: Record<string, PromptCategoryOverride>;
25
25
  /**
26
26
  * 用户自建的准则条目。**与内置的 `PROMPT_CATEGORIES` 分开存** ——
27
- * 内置那 14 条是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
27
+ * 内置条目是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
28
28
  * 落盘里又有一条"的归属问题:升级时新加的内置条目会和用户的旧数据打架。
29
29
  *
30
30
  * 存的是**完整条目**(不是覆盖值):自建的东西没有"内置版本"可回退。
@@ -33,12 +33,23 @@ export interface PromptExtData {
33
33
  /**
34
34
  * 展示顺序:`[id, …]`,含内置与自建。
35
35
  *
36
- * ⚠️ **只存 id 顺序,不重排内置数组本身**:内置 14 条的次序是 `content.ts`
36
+ * ⚠️ **只存 id 顺序,不重排内置数组本身**:内置条目的次序是 `content.ts`
37
37
  * 里的代码顺序,它同时是"准则作为一份文档的阅读顺序";让用户拖动去改它,
38
38
  * 等于让代码顺序随某个用户的操作漂移 —— 别人拿到新版本看到的次序就变了。
39
39
  * 落盘只记"这次会话里按什么顺序显示",代码顺序永远是兜底。
40
40
  */
41
41
  order?: string[];
42
+ /**
43
+ * 段落级开关:`{ [sectionName]: boolean }`,**只存关闭项**(`false` = 关)。
44
+ *
45
+ * 缺项 = 开(默认全开)——与 `text` 的"只存覆盖值"同一纪律:宿主的段落随
46
+ * 宿主/别的插件增删,存全文表就得在升级后逐条对账;只记"用户主动关了谁"
47
+ * 让新增段落自然落回默认(开),用户的选择不会被静默迁移。
48
+ *
49
+ * key 是**段的注册名**(如 `tool:write`),不是控制条 id(`sec:` 前缀是
50
+ * 传输层编码,落盘用裸名,见 sections.ts)。
51
+ */
52
+ sections?: Record<string, boolean>;
42
53
  }
43
54
  /** 一条分类上用户改过的字段(只记改了的那些)。 */
44
55
  export interface PromptCategoryOverride {
@@ -69,7 +80,7 @@ export declare function resolveEnabledCategories(masterOn: boolean, data: Prompt
69
80
  * 渲染成**一个**段落文本(多分类拼一起)。
70
81
  *
71
82
  * 为什么要拼成一段而不是一段一个:面板开关是"分类"粒度,而 prompt 读起来
72
- * 是一段连续的准则 —— 分成 14 个 section 会让 KV cache 每个分类各占一段、
83
+ * 是一段连续的准则 —— 分成多个 section 会让 KV cache 每个分类各占一段、
73
84
  * 增删任意一个分类都让其后全部位移。这里**注册一个 section,文本由开关拼装**。
74
85
  *
75
86
  * @returns 关闭的分类不进结果;全关 ⇒ 空串(上游 `renderPrompt` 会丢弃空段)。
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 系统提示词**段落级**总控的纯逻辑与清单缓存 —— 把整体系统提示词拆成
3
+ * 每一段(`harness:identity`、`tool:write`、`plan:policy` …),面板分组列出、
4
+ * 逐段开关,关掉的段在装配时被过滤掉(`index.ts` 的 waterfall)。
5
+ *
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 清单从哪来:`assemble()` 的真实产物,不写死宿主段名表
8
+ * ════════════════════════════════════════════════════════════════════
9
+ * 段名只有个别是宿主导出常量(`harness:identity`、`deployment:persona-*`),
10
+ * 其余(`tool:*`、`mcp:*`、第三方插件段)都埋在各自插件里 —— 写死清单必然漂移。
11
+ * 装配 waterfall(`system-prompt/assemble`)每轮都携带**全量已排序段落**,
12
+ * 顺路缓存一份就是面板的事实源;apply 时预热一次 assemble,没开始对话也有清单。
13
+ *
14
+ * ⚠️ 清单是**最近一次装配的快照**(近似):`assemble()` 不带 scope 时只含
15
+ * global 段,真实对话是 agent scope;且多会话并发时缓存归"最后一次装配"。
16
+ * 面板展示可接受,**开关判定不受影响** —— 过滤按段名,名字缺席时无害跳过。
17
+ *
18
+ * ════════════════════════════════════════════════════════════════════
19
+ * 开关的生效面:waterfall 删段,不碰别人的注册
20
+ * ════════════════════════════════════════════════════════════════════
21
+ * 宿主没有"禁用别人的段"的 API。两条路里选了 waterfall:`assemble()` 的
22
+ * 监听器返回值是权威结果(宿主源码 `return transformed`,complete 段除外),
23
+ * 删掉的段不会被恢复。同名覆盖(scoped section 返回 `''`)只适合"顶掉某一个
24
+ * 已知名字",对同层自己注册的段还会 throw —— 删段才是对全部段通用的开关。
25
+ */
26
+ import type { PromptExtData } from './render.ts';
27
+ /** 面板控制条 id 的段前缀(**传输层编码**;落盘用裸段名,不带前缀)。 */
28
+ export declare const SECTION_CONTROL_PREFIX = "sec:";
29
+ /**
30
+ * 自己的准则段:**不进清单、不被过滤**。
31
+ *
32
+ * 它已有独立的开关体系(扩展总开关 + 14 个分类开关)——再列一条"段开关"
33
+ * 就是同一件事的第二个开关,两边状态会漂移;过滤它还会让分类开关看起来
34
+ * 失效(段没了,分类怎么切都看不到效果)。
35
+ */
36
+ export declare const OWN_SECTION_NAME = "dsh-router-ext-prompt:guideline";
37
+ /** 一条已装配的段(面板清单用)。 */
38
+ export interface SectionEntry {
39
+ /** 段注册名(稳定;也是开关的落盘键)。 */
40
+ name: string;
41
+ /** 面板副行显示的原文(装配时 provider 现算的结果,**未做 `{{变量}}` 插值**)。 */
42
+ text: string;
43
+ }
44
+ /** `controlId`(`sec:…`)→ 段名;非段条目返回 null。 */
45
+ export declare function sectionNameOf(controlId: string): string | null;
46
+ /** 段名 → 面板控制条 id。 */
47
+ export declare function sectionControlId(name: string): string;
48
+ /**
49
+ * 段名的分组名。
50
+ *
51
+ * 查表顺序:**整名**(无冒号段名,如 `mcp-resource-servers`)→ **第一段前缀**
52
+ * (`tool:write` → `tool`)。两级都查不到 ⇒ 有冒号时用第一段原样当组名、
53
+ * 无冒号归「其他」。
54
+ */
55
+ export declare function groupLabelOf(name: string): string;
56
+ /** 面板条目标题:去掉组前缀的部分(`tool:write` → `write`;无冒号 = 全名)。 */
57
+ export declare function sectionTitleOf(name: string): string;
58
+ /**
59
+ * 当前被关掉的段名集合。**只认显式 `false`** —— 缺项是"没碰过"(默认开),
60
+ * 与 `resolveEnabledCategories` 对 `undefined` 的处置同一纪律。
61
+ */
62
+ export declare function disabledSectionNames(data: PromptExtData | undefined): Set<string>;
63
+ /**
64
+ * 过滤掉关闭的段(保序)。自己那段**恒放行**(理由见 `OWN_SECTION_NAME`)。
65
+ *
66
+ * ⚠️ 名字不在清单里的关闭项静默跳过:宿主段随版本增删,旧数据里的名字
67
+ * 失效是正常生命周期,报错只会把面板变成红字噪音。
68
+ */
69
+ export declare function filterSections<T extends {
70
+ name: string;
71
+ }>(sections: readonly T[], off: ReadonlySet<string>): T[];
72
+ /**
73
+ * 段清单缓存:waterfall 监听器是**唯一写入者**(装配路径顺路喂),
74
+ * 面板 controls getter 只读。分组与排序都在读取端做 —— 缓存保持
75
+ * 装配原序(`assemble` 已按 order/name 排好),组的次序自然等于 prompt 次序。
76
+ */
77
+ export declare function createSectionCache(): {
78
+ /** 用一次装配结果刷新(整表替换;自己的段不进清单)。 */
79
+ update: (sections: readonly {
80
+ name: string;
81
+ text: string;
82
+ }[]) => void;
83
+ /** 当前清单(拷贝,防调用方改缓存内部)。 */
84
+ list: () => SectionEntry[];
85
+ };
86
+ /** 一个分组(组名 + 组内条目,组内保持清单序)。 */
87
+ export interface SectionGroup {
88
+ label: string;
89
+ items: SectionEntry[];
90
+ }
91
+ /**
92
+ * 按段名前缀分组;**组的先后 = 首次出现序**(清单已按 prompt 序 ⇒ 组序即
93
+ * 段落在 prompt 里的先后)——不按字母重排,面板顺序与模型看到的顺序一致。
94
+ */
95
+ export declare function groupSections(list: readonly SectionEntry[]): SectionGroup[];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Anthropic Messages 协议。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 为什么第一个做它
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * Claude Code 只会说这一种协议,而它对 `ANTHROPIC_BASE_URL` 的支持是现成的:
8
+ * 把它指到我们的端点,就**直接**能用上账号池/组合/用量看板。其余三种协议
9
+ * (Responses/Gemini)没有这样的「一个客户端在等」的理由。
10
+ *
11
+ * ════════════════════════════════════════════════════════════════════
12
+ * 翻译表(唯一的事实来源)
13
+ * ════════════════════════════════════════════════════════════════════
14
+ * 请求 system → messages[0] 的 system-role 消息(字符串或块数组都要吃)
15
+ * tools[].input_schema → tools[].function.parameters
16
+ * tool_use / tool_result 块 → assistant.tool_calls / role:"tool"
17
+ * max_tokens / temperature / stop_sequences → 同名(下划线差异两处)
18
+ * 响应 message_start → usage(input_tokens 在这帧)
19
+ * content_block_start(text) → text 增量开始
20
+ * content_block_delta(text_delta) → text 增量
21
+ * content_block_delta(input_json_delta) → tool-args 增量
22
+ * content_block_delta(thinking_delta) → reasoning 增量
23
+ * content_block_stop → 该 index 结束(无对应规范事件,吃掉)
24
+ * message_delta → usage + finish_reason
25
+ * message_stop → 无对应事件(finish 已由 message_delta 给)
26
+ *
27
+ * 丢弃(明确不做翻译,见下):
28
+ * - `cache_control`:Anthropic 的提示缓存标记。跨到 Chat 后失去意义
29
+ * (Chat 的缓存是上游自动的),硬造一个 `prompt_cache_key` 反而是猜。
30
+ * - `thinking.budget_tokens`:只在响应侧保留为 reasoning 增量。
31
+ * - `mcp_servers` / `container` / `context_management`:客户端侧特性,
32
+ * 转发到只懂 Chat 的上游只会得到 400。
33
+ */
34
+ import { type ProtocolCodec } from './types.ts';
35
+ export declare const anthropicCodec: ProtocolCodec;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * OpenAI Chat Completions 协议 —— **规范形态本身**,所以编解码是恒等变换。
3
+ *
4
+ * 这个文件承担两件别处不做的事:
5
+ *
6
+ * 1. **请求侧恒等**:`toChat`/`fromChat` 原样返回。供应商契约
7
+ * (`suppliers/contract.ts` 的 `chatOnce`)吃的就是 Chat Completions 请求体,
8
+ * 多一层拷贝只会让「哪一层改的」变难查。
9
+ *
10
+ * 2. **响应侧的规范事件翻译**:OpenAI SSE → `ChatEvent`,以及反向。这是**所有
11
+ * 协议共用的脊柱** —— Anthropic/Gemini 的 `decodeFrame` 拿到自己的帧后,
12
+ * 也要靠这里的逻辑把内容映射成同一组事件,才能让「Anthropic 客户端问、
13
+ * Gemini 供应商答」这条路只有两个模块参与,而不是 12 条路径。
14
+ *
15
+ * ⚠️ 这里的映射规则与 `llm/adapter.ts` 的 `translateSse` 是**同一套语义的两种
16
+ * 落点**:那边翻译成 DSH 的 `StreamChunk`(给宿主 agent loop 用),这边翻译
17
+ * 成 `ChatEvent`(给协议矩阵用)。两处都留着,因为消费方不同、且
18
+ * `translateSse` 里那些踩过坑的处理(残缺 tool_call 收尾、空串 name 剥除)
19
+ * 只对 DSH 有意义;但**改任何一处规则都必须看另一处**,否则同一个上游在
20
+ * 两个入口表现不一致。
21
+ */
22
+ import { type ProtocolCodec, finishOf } from './types.ts';
23
+ export declare const chatCodec: ProtocolCodec;
24
+ /** 收尾原因 → 规范形态(在各协议模块里共用,从这再导出省一次 import)。 */
25
+ export { finishOf };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 入站协议的四个入口 —— 把四种协议的请求翻成规范请求、交给 Router,
3
+ * 再把 Router 的响应翻回客户端说的协议。
4
+ *
5
+ * ════════════════════════════════════════════════════════════════════
6
+ * 为什么 Router 不认识协议,翻译却全在这
7
+ * ════════════════════════════════════════════════════════════════════
8
+ * Router 的核心职责是「选号/冷却/回退/记账」,它不该知道世界上有几种协议。
9
+ * 所以这里只做三件与协议有关的事:认路径、翻请求体、翻响应体。剩下全部
10
+ * 委托给 `Router.chatCompletions` —— 于是四种协议**自动**获得账号池、
11
+ * 组合回退、首字节预算、断流补帧、用量统计,一个都不用重写。
12
+ *
13
+ * 挂载点见 `src/index.ts` 的 `route(...)` 调用处;新增第五种协议只改
14
+ * 这里 + `protocol/` 下加一个模块,`src/index.ts` 只多一行 route。
15
+ */
16
+ import type { IncomingMessage, ServerResponse } from 'node:http';
17
+ import { StreamState } from './stream.ts';
18
+ import type { JsonObject, ProtocolId } from './types.ts';
19
+ /** 一次入站请求解析后的结果。 */
20
+ export interface InboundRequest {
21
+ /** 规范请求体(Chat Completions 形状)。 */
22
+ body: JsonObject;
23
+ /** 客户端要的流式。 */
24
+ stream: boolean;
25
+ /** 模型名(已从路径或请求体取出)。 */
26
+ model: string;
27
+ /** 推理等级;缺省 'auto'。 */
28
+ lv: string;
29
+ /** 客户端协议。 */
30
+ protocol: ProtocolId;
31
+ }
32
+ /**
33
+ * 读请求体 + 按协议翻成规范请求。
34
+ *
35
+ * 模型名从哪来因协议而异:Chat/Anthropic/Responses 在 body 的 `model`,
36
+ * Gemini 在 URL 路径(`:generateContent` 之前那段)—— 由 `modelFromPath`
37
+ * 调用方先解析好传进来。
38
+ */
39
+ export declare function parseInbound(req: IncomingMessage, protocol: ProtocolId, opts: {
40
+ limitBytes: number;
41
+ modelFromPath?: string;
42
+ lvFromBody?: (body: JsonObject) => string;
43
+ /**
44
+ * 路径里已声明的流式意图。
45
+ *
46
+ * Gemini 是唯一把「流不流式」放在 **URL 路径**(`:streamGenerateContent`
47
+ * vs `:generateContent`)而不是 body 里的协议。不认它,Gemini 的流式请求
48
+ * 会被当成非流式处理 —— 客户端拿到一整个 JSON,而它按 SSE 解析 ⇒ 空响应
49
+ * (2026-10-21 在 3080 实测)。
50
+ *
51
+ * `undefined` = 路径没说,看 body;`true/false` = 路径已明确,body 不算数。
52
+ */
53
+ streamFromPath?: boolean;
54
+ }): Promise<InboundRequest | {
55
+ error: string;
56
+ status: number;
57
+ }>;
58
+ /**
59
+ * 给 Router 的**非 chat 出口**:收集 OpenAI 形态的响应,翻译成客户端协议。
60
+ *
61
+ * ════════════════════════════════════════════════════════════════════
62
+ * 两条路径分开处理,因为时序完全不同
63
+ * ════════════════════════════════════════════════════════════════════
64
+ * 非流式:Router 一次 `head(200) + end(json)`。整份 JSON 已在手里,
65
+ * `decodeJson` → `encodeJson` 一个来回就完。
66
+ * 流式: Router 分很多次 `write(bytes)`,且**第一个字节前可能失败换号**。
67
+ * 不能等收齐再翻(那就没有流式了);每收到一段就翻一段。
68
+ *
69
+ * 流式那条为什么插一层 sink 而不是让 Router 直接写翻译后的字节:Router 的
70
+ * `writeChatResult` 里有一整套「客户端断开就取消上游流」「断流补 `[DONE]`」
71
+ * 的逻辑,那些必须继续对真实的 `res` 生效。sink 只是**转发的中继**:
72
+ * 它翻译字节、写给真正的 res;断开时调用方取消本层(见 index.ts 挂载处),
73
+ * Router 那侧的取消链路原样传到上游。
74
+ */
75
+ export declare function outboundSink(protocol: ProtocolId, model: string, res: ServerResponse): ResponseSinkLike;
76
+ /** Router 的 ResponseSink 形状(本地声明,避免 protocol → router 的循环依赖)。 */
77
+ export interface ResponseSinkLike {
78
+ head?: (status: number, headers: Record<string, string>) => void;
79
+ write: (bytes: Uint8Array) => void;
80
+ end: () => void;
81
+ }
82
+ /**
83
+ * 一段上游 OpenAI SSE 文本的转码器 —— **有状态**。
84
+ *
85
+ * 上游一帧被拆在两个 TCP 段里是常态(Router 按拿到的字节原样转交),所以
86
+ * 分帧缓冲必须跨 `write()` 调用存活。这里用 `sse.ts` 的 `SseSplitter`(它内部
87
+ * 留着半个帧)而不是每次现切 —— 早先每次现切会把不完整帧当完整帧解析,
88
+ * 表现是「文本丢一半、工具参数截断」,而 HTTP 200,客户端看不到任何错误。
89
+ *
90
+ * 生命周期与 sink 一致:一个请求一个实例。
91
+ */
92
+ export declare class ChunkTranslator {
93
+ private readonly splitter;
94
+ private readonly state;
95
+ constructor(state: StreamState);
96
+ /** 喂一段文本,返回可写给客户端的帧。 */
97
+ push(text: string): string[];
98
+ /** 流结束:冲刷残留帧(上游不发尾空行是有的),返回收尾帧。 */
99
+ end(): string[];
100
+ private emit;
101
+ }
102
+ /**
103
+ * 单次调用版:把一段**完整**的 SSE 文本转成客户端帧。
104
+ *
105
+ * 只适合「手里已经有一整段完整数据」的场合(测试、非流式上游误发了 SSE)。
106
+ * 真正的流式路径必须用 `ChunkTranslator` 保持跨段状态。
107
+ */
108
+ export declare function translateChunk(state: StreamState, text: string): string[];
109
+ /**
110
+ * 从 Gemini 的 URL 路径里取模型名。
111
+ *
112
+ * 形如 `/v1beta/models/<model>:generateContent`(或 `:streamGenerateContent`),
113
+ * `<model>` 本身可以含斜杠,所以取 `models/` 之后到最后一个 `:` 之前那段。
114
+ *
115
+ * **必须先切掉 query**:Gemini CLI 默认带 `?alt=sse`,不切的话 `$` 锚点匹配
116
+ * 不上,模型名变空 —— 表现为「404 model not found」而不是参数错误,很难查。
117
+ * 顺便支持 `/v1beta/models/<model>` 这种带版本号但无方法的形态(走 GET 列表时
118
+ * 会撞上,这里取不到 `/` 就是取不到,返回 undefined 让上层报 400)。
119
+ */
120
+ export declare function geminiModelFromPath(pathname: string): string | undefined;
121
+ /**
122
+ * Gemini 的流式意图在**路径尾部**:`:streamGenerateContent` 是流式、
123
+ * `:generateContent` 是非流式。返回 `undefined` 表示路径没表态(不是 Gemini
124
+ * 形态,或路径不可识别),交给 body 的 `stream` 决定。
125
+ *
126
+ * 为什么不能只看 body:Gemini 的请求体里**没有** `stream` 字段 —— 它的官方
127
+ * 契约就是靠 URL 区分两种方法。不认这个,流式请求会被当成非流式,客户端按
128
+ * SSE 解析一整个 JSON ⇒ 空响应(2026-10-21 在 3080 实测到)。
129
+ */
130
+ export declare function geminiStreamFromPath(pathname: string): boolean | undefined;
131
+ /**
132
+ * 从请求体取推理等级(各协议字段名不同)。
133
+ *
134
+ * 只认「明确说了要多少思考」的形态,其余一律 'auto':把「有这个字段」当成
135
+ * 「要高档推理」会让所有带 thinking 块的请求都按 high 走,费用与延迟都变。
136
+ */
137
+ export declare function lvOf(body: JsonObject, protocol: ProtocolId): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Google Gemini 协议(`generateContent` / `streamGenerateContent`)。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 与其他三种的差异
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 1. **模型名在 URL 路径里**:`/v1beta/models/<model>:generateContent`,
8
+ * 请求体里没有 `model` 字段。
9
+ * 2. **角色名不同**:`user` / `model`(不是 assistant)。
10
+ * 3. **内容是 parts**:`{parts:[{text}]}`,不是字符串。
11
+ * 4. **工具调用是整块**:`functionCall` part 一次给全 `{name,args}`,
12
+ * 没有增量形态 —— 故 decode 时拆成 tool-start + tool-args 一段。
13
+ * 5. **鉴权走 query 参数 `?key=`** 或 `x-goog-api-key` 头(不是 Bearer)。
14
+ * 6. **流式响应是 JSON 数组分片**(`[{...},{...}]`),不是 SSE ——
15
+ * `streamGenerateContent?alt=sse` 才给 SSE。我们只支持 `alt=sse`,
16
+ * 非 SSE 形态明确不支持(见文件尾 `geminiStreamSse` 的说明)。
17
+ *
18
+ * 丢弃:`safetySettings` / `tools[].googleSearch` / `cachedContent` /
19
+ * `systemInstruction` 之外的 `generationConfig` 大部分字段(只留
20
+ * temperature / maxOutputTokens / stopSequences)。
21
+ */
22
+ import { type JsonObject, type ProtocolCodec } from './types.ts';
23
+ export declare const geminiCodec: ProtocolCodec;
24
+ /**
25
+ * 工具名 ↔ index 的映射,**每个请求一份**。
26
+ *
27
+ * Gemini 没有 index 概念(工具调用靠 name 认),而规范事件用 index —— 所以
28
+ * 跨帧必须有映射,而跨**请求**必须没有。模块级 Map 会串名(并发两条流各自
29
+ * 的 `ls` 抢同一个 index,或者上一条流的映射残留到下一条),所以这里做成
30
+ * 每个编解码会话一个实例,由 `StreamState` / `entry.ts` 在请求边界创建。
31
+ *
32
+ * 谁在用:`decodeFrameInto` / `encodeFrameWith` 拿到同一个 `GeminiToolMap`;
33
+ * 单次调用(测试、非流式一次性转换)走 `geminiBodyEvents` 的默认实例。
34
+ */
35
+ export interface GeminiToolMap {
36
+ /** 工具名 → 规范 index(同名复用同一个 index)。 */
37
+ indexOf(name: string): number;
38
+ /** 规范 index → 工具名(未知返回 '')。 */
39
+ nameOf(index: number): string;
40
+ /** 记下「规范 index → 工具名」(`tool-start` 带上游 index 时用)。 */
41
+ remember(index: number, name: string): void;
42
+ /**
43
+ * 把一段参数增量累到该 index 上,返回**截至目前**的完整 JSON。
44
+ *
45
+ * Gemini 的 `functionCall.args` 是整块对象,没有增量形态;而上游(OpenAI
46
+ * 系)会把一次调用拆成很多 `arguments` 增量帧。逐帧发 `functionCall` 会让
47
+ * 客户端看到 N 次调用、且前 N-1 次 args 是空对象或半截 JSON
48
+ * (2026-10-21 在 3080 实测:5 个 `args:{}`)。所以只能攒。
49
+ */
50
+ appendArgs(index: number, delta: string): string;
51
+ /** 该 index 上是否有**完整可解析**的参数(流末判定用)。 */
52
+ completeArgs(index: number): JsonObject | undefined;
53
+ /** 攒过参数的所有 index(流末按序发出)。 */
54
+ pendingIndexes(): number[];
55
+ }
56
+ /** 新建一份空的工具名映射。 */
57
+ export declare function newGeminiToolMap(): GeminiToolMap;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 协议注册表 —— 按 id 取编解码器。
3
+ *
4
+ * 刻意保持成一张**静态表**而不是注册机制:四种协议是编译期就知道的集合,
5
+ * 加第五种就是改这个文件 + 加一个模块,不需要运行时发现。做成可注册会让
6
+ * 「谁在什么时候注册了什么」变成运行时事实,而这里没有任何插件需要插进来
7
+ * (供应商 wire 是另一回事,见 `suppliers/contract.ts` 的 `wire`)。
8
+ */
9
+ import type { ProtocolCodec, ProtocolId } from './types.ts';
10
+ /** 取编解码器。id 已由 `isProtocolId` 校验过,这里不再兜底。 */
11
+ export declare function codecOf(id: ProtocolId): ProtocolCodec;
12
+ /** 全部编解码器(文档与测试用)。 */
13
+ export declare function allCodecs(): ProtocolCodec[];
@@ -0,0 +1,41 @@
1
+ /**
2
+ * OpenAI Responses 协议(Codex 用的是它)。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 与 Chat Completions 的关键差异
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 请求 `input` 是**消息数组**(与 messages 同构)而不是 messages 本身
8
+ * `instructions` 替代 `system` 消息
9
+ * `max_output_tokens` 替代 `max_tokens`
10
+ * `tools` 项是扁平的 `{type:'function', name, parameters}`,
11
+ * 没有 `function` 这一层包装
12
+ * 响应 事件名带语义:`response.output_text.delta` / `response.function_call_arguments.delta`
13
+ * / `response.reasoning_summary_text.delta`,全部带 `sequence_number`
14
+ * 收尾是 `response.completed`(带完整 response 对象),不是 finish_reason
15
+ *
16
+ * 丢弃(明确不做):
17
+ * - `previous_response_id` / `store`:服务端会话态。我们的供应商契约是
18
+ * 无状态的(`chatOnce` 一次一请求),转发过去只会拿到错误。
19
+ * - `reasoning.effort` 之外的 `reasoning.summary`:摘要粒度由上游定,
20
+ * 我们只透传 reasoning 增量。
21
+ * - `include` / `text.format`(结构化输出):跨协议没有对应物。
22
+ */
23
+ import { type ChatEvent, type ProtocolCodec } from './types.ts';
24
+ export declare const responsesCodec: ProtocolCodec;
25
+ /**
26
+ * 拼 `response.completed` 帧(收尾时由调用方带用量与收尾原因调)。
27
+ *
28
+ * `output` 里要写一条 `function_call` 占位项(当 finish 是 tool-calls 时)——
29
+ * Responses 没有 finish_reason 字段,客户端(以及我们自己的 `completedEvents`)
30
+ * 只能靠 `output` 里有没有 function_call 判断「这轮是调工具还是说完」。
31
+ * 少了这条,工具轮在往返后会被认成普通结束。
32
+ */
33
+ export declare function completedFrame(model: string, usage?: {
34
+ promptTokens: number;
35
+ completionTokens: number;
36
+ cachedTokens?: number;
37
+ }, finish?: string): string;
38
+ /** Responses 的 usage 帧(若调用方要单独发)。 */
39
+ export declare function usageFrame(event: Extract<ChatEvent, {
40
+ type: 'usage';
41
+ }>): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SSE 帧的编解码小工具 —— 四种协议共用。
3
+ *
4
+ * 只做三件事:把字节流按 SSE 规则切成「一帧」、把一帧拆成 `event:`/`data:`
5
+ * 两部分、把若干帧拼回字节流。**不**知道任何协议语义 —— 那在各协议的
6
+ * `ProtocolCodec` 里。
7
+ *
8
+ * 为什么不用现成库:宿主没有 SSE 依赖,而我们要的能力只有「分帧」这一件,
9
+ * 引入一个包换三十行代码不值;且分帧必须跨 chunk 边界缓冲(上游一帧被拆在
10
+ * 两个 TCP 段里是常态),现成库普遍假设整帧到达。
11
+ */
12
+ /** 一帧 SSE。 */
13
+ export interface SseFrame {
14
+ /** `event:` 行的值(没有该行时为空串)。 */
15
+ event: string;
16
+ /** `data:` 各行的拼接(多行 `data:` 用 `\n` 连接,SSE 规范如此)。 */
17
+ data: string;
18
+ }
19
+ /**
20
+ * 增量分帧器:喂字节进去,吐出完整帧。
21
+ *
22
+ * 用类而不是纯函数,是因为「半个帧」必须留在实例里 —— 每次调用都是无状态的
23
+ * 函数做不到这件事,而每个流都要一个独立缓冲。
24
+ */
25
+ export declare class SseSplitter {
26
+ private buf;
27
+ /** 喂入一块字节,返回其中包含的完整帧(可能 0..n 个)。 */
28
+ push(bytes: Uint8Array): SseFrame[];
29
+ /** 流结束时冲刷:把残留的、不以空行结尾的尾帧也吐出来(上游不发尾空行是有的)。 */
30
+ end(): SseFrame[];
31
+ /** 把已缓冲的完整帧全部切出来。 */
32
+ private drain;
33
+ }
34
+ /** 把一帧原文拆成 `event` + `data`。空 data 的帧(注释/keep-alive)返回空 data。 */
35
+ export declare function parseFrame(raw: string): SseFrame;
36
+ /** 拼一帧字节(`data:` 帧;不带 `event:`)。 */
37
+ export declare function dataFrame(data: string): string;
38
+ /** 拼一帧字节(带 `event:`)。 */
39
+ export declare function eventFrame(event: string, data: string): string;
40
+ /** 把若干帧原文拼成字节。 */
41
+ export declare function framesToBytes(frames: string[]): Uint8Array;