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
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 流的协议转码 —— 把「一种协议的 SSE 字节流」翻译成「另一种协议的 SSE 字节流」。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 为什么拆成 transform + state 两层
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 逐帧翻译需要一个**有状态**的累加器(usage 分散在多帧、工具名要先于参数、
8
+ * 收尾帧要在流末补),直接写成一个 200 行的 async generator 会把「状态」和
9
+ * 「翻译」搅在一起。所以:
10
+ * - `StreamState`:一次流的有状态累加(纯内存,可单测,无 IO)。
11
+ * - `translateStream`:把它接到字节流上(只管分帧/拼帧/生命周期)。
12
+ *
13
+ * 同一条流里**同时只有一个转码器**(上游协议固定),但出站协议由客户端决定 ——
14
+ * 所以是「一个 decode + 一个 encode」,而不是各自独立的两趟。
15
+ *
16
+ * ⚠️ **不缓冲、不聚合**:每个事件到立刻吐对应帧。首字节延迟与上游一致,
17
+ * 不会因为转码多等一个 chunk(Gemini 的工具调用除外:它的参数是一次性整块,
18
+ * 天然没有增量可言)。
19
+ */
20
+ import type { ProtocolId } from './types.ts';
21
+ /** 一次流式转码的有状态累加。 */
22
+ export declare class StreamState {
23
+ /** 上游协议(解码用)。 */
24
+ readonly from: ProtocolId;
25
+ /** 客户端协议(编码用)。 */
26
+ readonly to: ProtocolId;
27
+ /** 上游 usage(跨帧合并,字段级 max —— 见 mergeUsage 的注释)。 */
28
+ private usage;
29
+ /** 已经吐过起始帧没有(起始帧只能在流最前发一次)。 */
30
+ private started;
31
+ /** 收尾原因(用于补 endFrames;缺省 stop)。 */
32
+ private finish;
33
+ /** 模型名(回显进 startFrames/endFrames,如 Anthropic 的 message_start)。 */
34
+ private model;
35
+ /**
36
+ * 解码侧会话与编码侧会话,各一份。
37
+ *
38
+ * **必须每帧复用同一个**:Gemini 的工具名↔index 映射就存在这里;每帧新建
39
+ * 会让 `tool-args` 找不到 `tool-start` 给的名字(参数被丢),而放模块级会
40
+ * 跨请求串名。会话在 `StreamState` 构造时建、随流结束丢弃 —— 生命周期与
41
+ * 「一条流」严格对齐。
42
+ */
43
+ private readonly decodeSession;
44
+ private readonly encodeSession;
45
+ constructor(from: ProtocolId, to: ProtocolId, model?: string);
46
+ /** 当前累计的用量。 */
47
+ currentUsage(): {
48
+ promptTokens: number;
49
+ completionTokens: number;
50
+ cachedTokens: number;
51
+ };
52
+ /** 把上游一帧原文(`data:` 载荷)翻成出站帧原文列表。 */
53
+ translateFrame(frame: string, event?: string): string[];
54
+ /**
55
+ * 上游流结束:补发收尾帧。
56
+ *
57
+ * **顺序固定**:finish → usage → endFrames。各协议的收尾帧都要求这个次序
58
+ * (Anthropic 的 message_delta 必须在 message_stop 前,Responses 的
59
+ * `response.completed` 是最后一件且自带 usage)。
60
+ *
61
+ * `endFrames` 拿得到 finish 与 usage —— 有的协议(Responses)只有收尾帧
62
+ * 里才有地方放 usage 与「是否被截断」。
63
+ */
64
+ finishStream(): string[];
65
+ /**
66
+ * 带当前用量派生的编码会话。
67
+ *
68
+ * 不能只在 `encodeSession` 存在时才派生:`createSession` 是**可选**的
69
+ * (只有 Gemini 这类有跨帧映射的协议实现它),Anthropic 没有 —— 按
70
+ * `encodeSession === undefined` 就返回 undefined 会让 usage 永远送不进去
71
+ * (2026-10-21 实测:output_tokens 恒为 0)。所以无论如何都造一个,
72
+ * 有 state 就带上。
73
+ */
74
+ private finishSessionWithUsage;
75
+ /**
76
+ * usage 合并:字段级 max。
77
+ *
78
+ * 与 `router/usage-tokens.ts` 的 `mergeUsage` 同规则(那里照 9router):
79
+ * Claude 把输入用量放 `message_start`、输出放 `message_delta`,两帧都给
80
+ * `usage` 字段,**覆盖**会让后一帧把前面的输入清零。
81
+ */
82
+ private mergeUsage;
83
+ }
84
+ /**
85
+ * 把上游 SSE 字节流转成客户端协议的 SSE 字节流。
86
+ *
87
+ * 生命周期:上游正常读完 / 上游 error / 消费方取消,三条路径都要**释放上游
88
+ * reader** 并把取消传下去 —— 否则上游 fetch body 永不 cancel,长跑会漏连接
89
+ * (`router/index.ts` 的 `normalizeSSEStream` 里同一个坑,判据在
90
+ * `router/disconnect-leak.test.ts`)。
91
+ */
92
+ export declare function translateStream(upstream: ReadableStream<Uint8Array>, state: StreamState): ReadableStream<Uint8Array>;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * 协议矩阵的**规范形态**(canonical form)与编解码契约。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 为什么内部只留一种形态
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 四种协议两两互转是 4×3 = 12 条路径,逐条实现必然长出一打互相不一致的
8
+ * 实现。收敛成「全部先转成同一个规范形态、再从规范形态转出去」之后:
9
+ *
10
+ * 外来请求 ──decode──┐ ┌──encode── 外来响应
11
+ * ├─ 规范形态 ────┤
12
+ * 供应商请求 ◀─encode─┘ └─decode──▶ 供应商响应
13
+ *
14
+ * 只需要 N 个协议模块(N=4),而不是 N² 条翻译路径;新增第五种协议只是再加
15
+ * 一个模块,已有路径一行不改。
16
+ *
17
+ * 规范形态选 **OpenAI Chat Completions**(请求体)与**一组扁平事件**(响应流),
18
+ * 不是随便挑的:供应商契约(`suppliers/contract.ts` 的 `chatOnce`)今天就是
19
+ * 「吃 Chat Completions 请求体、吐 OpenAI SSE」,选别的形态等于把每个已装
20
+ * 供应商插件都改一遍。
21
+ *
22
+ * 天花板:规范形态只覆盖**能表达的部分**。各家私有的、没有对应概念的东西
23
+ * (Anthropic 的 `cache_control`、Responses 的 `previous_response_id`、
24
+ * Gemini 的 `safetySettings`)在跨协议时会被**丢掉**,不是被翻译。每条丢弃
25
+ * 在对应模块的注释里点名。
26
+ */
27
+ /** 四种协议。`chat` 是规范形态本身,因此它的编解码是恒等变换。 */
28
+ export type ProtocolId = 'chat' | 'anthropic' | 'responses' | 'gemini';
29
+ /** 所有协议 id(顺序 = 文档与面板展示顺序)。 */
30
+ export declare const PROTOCOL_IDS: readonly ProtocolId[];
31
+ /** 宽松 JSON 对象(外来请求体一律不可信,逐字段校验)。 */
32
+ export type JsonObject = Record<string, unknown>;
33
+ /** 编解码时需要知道的上下文。 */
34
+ export interface CodecContext {
35
+ /**
36
+ * 请求里声明的模型名(已剥掉本插件的 `alias/` 前缀)。
37
+ *
38
+ * 有些协议把模型名放在 URL 路径里(Gemini 的
39
+ * `/v1beta/models/<model>:generateContent`),有些放在请求体里;模块统一从
40
+ * 这里取,不各自去猜。
41
+ */
42
+ model: string;
43
+ /** 客户端是否要流式。 */
44
+ stream: boolean;
45
+ /**
46
+ * 收尾原因(仅 `endFrames` 会读)。
47
+ *
48
+ * 有些协议的收尾帧要**同时**带状态与用量(Responses 的 `response.completed`
49
+ * 里 `status: 'completed' | 'incomplete'` 决定 Codex 是否认为被截断),
50
+ * 而 `finish` 事件本身在这些协议里没有独立帧可发 —— 于是原因必须通过
51
+ * 上下文传进 `endFrames`,不能只靠「收到过 finish 事件」这个事实。
52
+ */
53
+ finish?: FinishReason;
54
+ /** 累计用量(仅 `endFrames` 会读,同上:Responses 只在收尾帧给 usage)。 */
55
+ usage?: {
56
+ promptTokens: number;
57
+ completionTokens: number;
58
+ cachedTokens: number;
59
+ };
60
+ }
61
+ /**
62
+ * 规范形态的**流式事件**。
63
+ *
64
+ * 刻意做得很小:只要四家能无损落进来的都在这,落不进来的都不在。工具调用用
65
+ * `tool-start` + `tool-args` 两段表达(各家都是「先给名字、再给参数增量」,
66
+ * 只有 Gemini 把参数整块给一次 —— 它在 decode 时拆成一段即可)。
67
+ */
68
+ export type ChatEvent =
69
+ /** 可见文本增量。 */
70
+ {
71
+ type: 'text';
72
+ text: string;
73
+ }
74
+ /** 推理过程增量(`reasoning_content` / `thinking` / `thought`)。 */
75
+ | {
76
+ type: 'reasoning';
77
+ text: string;
78
+ }
79
+ /** 一个工具调用的开始(名字先到,参数随后)。同一 index 只发一次。 */
80
+ | {
81
+ type: 'tool-start';
82
+ index: number;
83
+ id?: string;
84
+ name?: string;
85
+ }
86
+ /** 工具调用的参数**增量**(JSON 片段,逐段拼接后才是完整参数)。 */
87
+ | {
88
+ type: 'tool-args';
89
+ index: number;
90
+ delta: string;
91
+ }
92
+ /** 收尾原因。一条流最多一个,且必须是最后一个内容事件。 */
93
+ | {
94
+ type: 'finish';
95
+ reason: FinishReason;
96
+ }
97
+ /** 用量。多数上游在最后一帧才给。 */
98
+ | {
99
+ type: 'usage';
100
+ promptTokens: number;
101
+ completionTokens: number;
102
+ cachedTokens: number;
103
+ }
104
+ /** 上游在流内报的错(HTTP 200 + 流内 error 是真实存在的形态)。 */
105
+ | {
106
+ type: 'error';
107
+ message: string;
108
+ };
109
+ /** 收尾原因(规范形态)。 */
110
+ export type FinishReason = 'stop' | 'length' | 'tool-calls' | 'content-filter' | 'error';
111
+ /**
112
+ * 一条流里的编解码会话状态。
113
+ *
114
+ * 刻意做成**不透明的空接口 + 可选**:绝大多数协议(chat / Anthropic /
115
+ * Responses)逐帧无状态,不需要它;只有 Gemini 这类「用名字而非 index 指代
116
+ * 工具」的协议需要跨帧记住名字↔index。让它可选,是为了不给无状态协议强加
117
+ * 一个空对象参数。
118
+ */
119
+ export interface CodecSession {
120
+ /** 会话内的临时槽位(各协议自己的形状,核心不解读)。 */
121
+ readonly state?: unknown;
122
+ /**
123
+ * 本流已累计的用量。
124
+ *
125
+ * 为什么编码器需要它:有的协议把「收尾原因」与「输出用量」绑在**同一帧**
126
+ * (Anthropic 的 `message_delta`),而 finish 事件本身不带 usage。不给这个
127
+ * 槽位,收尾帧只能写 `output_tokens: 0` 或者再多发一帧 —— 后者会让客户端
128
+ * 取到第一个(占位)值,把用量读成 0(2026-10-21 在 3080 实测到)。
129
+ */
130
+ readonly usage?: {
131
+ promptTokens: number;
132
+ completionTokens: number;
133
+ cachedTokens: number;
134
+ };
135
+ }
136
+ /**
137
+ * 一个协议的编解码器。
138
+ *
139
+ * 四个方法分成两组,两组都必须实现 —— 少了任何一组就只能单向,而「双向互转」
140
+ * 正是这个模块存在的理由:
141
+ * - `toChat*`:**入站**用。客户端说这个协议,我们得听懂它、并用它回答。
142
+ * - `fromChat*`:**出站**用。供应商说这个协议,我们得用它的语言请求、并把
143
+ * 它的回答翻回规范形态。
144
+ */
145
+ export interface ProtocolCodec {
146
+ readonly id: ProtocolId;
147
+ /**
148
+ * 一条流的编解码会话。
149
+ *
150
+ * 部分协议是**有状态**的:Gemini 的 `functionCall` 靠工具名认,而规范形态
151
+ * 靠 index,跨帧必须有映射;映射若放模块级就会串请求(并发两条流各自的
152
+ * `ls` 抢同一个 index,或上一条流的残留映射漏到下一条)。
153
+ *
154
+ * 所以状态由调用方在**请求边界**建一次、在整条流里传给每一帧:
155
+ * - `translateStream` / `StreamState` 在一次转码里建一个;
156
+ * - 单帧转换(测试、非流式一次性)可以不传,此时每帧各建一份 —— 结果
157
+ * 等价于无状态,代价只有一次 Map 分配。
158
+ */
159
+ createSession?(): CodecSession;
160
+ /** 外来请求体 → 规范请求体。`chat` 是恒等。 */
161
+ toChat(body: JsonObject, ctx: CodecContext): JsonObject;
162
+ /** 规范请求体 → 外来请求体。`chat` 是恒等。 */
163
+ fromChat(body: JsonObject, ctx: CodecContext): JsonObject;
164
+ /**
165
+ * 外来响应流的**单帧**解码:一帧 SSE 载荷(已剥掉 `data:` 前缀)→ 0..n 个
166
+ * 规范事件。`[DONE]` 与无法识别的帧返回空数组,**不抛**:一条坏帧不该让
167
+ * 整轮响应作废(上游发半截 JSON 是常态,见 `llm/adapter.ts` 的同类处理)。
168
+ *
169
+ * @param frame SSE 的 `data:` 载荷原文;`[DONE]` 由调用方过滤,不会传进来。
170
+ * @param event 该帧的 `event:` 名(Anthropic 用它区分事件类型;其余协议为空)。
171
+ */
172
+ decodeFrame(frame: string, event?: string, session?: CodecSession): ChatEvent[];
173
+ /**
174
+ * 规范事件 → 0..n 个外来 SSE 帧。
175
+ *
176
+ * 返回的是**完整帧**(含 `event:`/`data:` 行与结尾空行),调用方只管拼字节。
177
+ * 做成整帧而不是载荷,是因为 Anthropic 这类协议每个事件都要带 `event:` 行,
178
+ * 让编码器自己拼比让调用方猜事件名干净。
179
+ */
180
+ encodeFrame(event: ChatEvent, session?: CodecSession): string[];
181
+ /**
182
+ * 非流式响应体的解码:整个 JSON body → 规范事件列表。
183
+ *
184
+ * 与 `decodeFrame` 同构(同样返回事件列表),区别只在输入是**一个完整的
185
+ * 响应对象**而不是一帧增量。两种都必须实现:客户端可能发 `stream:false`,
186
+ * 供应商也可能只给 JSON(有些上游对短请求直接回非流式)。
187
+ */
188
+ decodeJson(body: JsonObject): ChatEvent[];
189
+ /**
190
+ * 规范事件 → 非流式响应体。
191
+ *
192
+ * 与 `encodeFrame` 同构:把攒起来的事件折成一个完整响应对象。
193
+ * `events` 里最多一个 `finish`、一个 `usage`,其余是内容。
194
+ */
195
+ encodeJson(events: ChatEvent[], ctx: CodecContext): JsonObject;
196
+ /**
197
+ * 流开始时的一次性帧(Anthropic 的 `message_start`、Responses 的
198
+ * `response.created`)。恒等协议返回空数组。
199
+ */
200
+ startFrames(ctx: CodecContext): string[];
201
+ /**
202
+ * 流结束时的一次性帧(`message_stop`、`response.completed`、`[DONE]`)。
203
+ * 与 `startFrames` 对称;`finish` 事件本身走 `encodeFrame`。
204
+ *
205
+ * 带上 `session` 是因为有的协议**必须**在流末才发得出东西:Gemini 的
206
+ * `functionCall` 是「名字+完整参数」一个 part,而上游逐帧发参数增量,
207
+ * 只能攒到流末(见 gemini.ts 的 endFrames)。
208
+ */
209
+ endFrames(ctx: CodecContext, session?: CodecSession): string[];
210
+ }
211
+ /** 是否为已知协议 id。 */
212
+ export declare function isProtocolId(v: unknown): v is ProtocolId;
213
+ /** 取对象字段(不是对象则 undefined)。 */
214
+ export declare function obj(v: unknown): JsonObject | undefined;
215
+ /** 取数组字段(不是数组则 undefined)。 */
216
+ export declare function arr(v: unknown): unknown[] | undefined;
217
+ /** 取非空字符串字段。 */
218
+ export declare function str(v: unknown): string | undefined;
219
+ /** 取有限数字字段。 */
220
+ export declare function num(v: unknown): number | undefined;
221
+ /** 取布尔字段。 */
222
+ export declare function bool(v: unknown): boolean | undefined;
223
+ /** 把 `finish` 原因在各协议间对齐(各家词汇不同,这里是唯一的对照表)。 */
224
+ export declare function finishOf(v: unknown): FinishReason;
@@ -12,6 +12,31 @@ import { UsageStore } from './usage-store.ts';
12
12
  * 谁也没法再改结果)。核心也是所有供应商唯一的收口点,套一次全都受益。
13
13
  */
14
14
  export declare function withFirstByteDeadline(stream: ReadableStream<Uint8Array>, ms: number): ReadableStream<Uint8Array>;
15
+ /** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
16
+ /**
17
+ * 响应收集器:把 Router 本来要写给 `res` 的东西收进来,让调用方翻译后自己写。
18
+ *
19
+ * 只给**非 chat 协议**用(`/v1/messages`、`/v1/responses`、Gemini):Router 只会
20
+ * 写 OpenAI 形态,而客户端要的是别的协议,中间必须插一层翻译。插入点在
21
+ * `chatCompletions` 的入口 —— 那里是唯一能同时看到「响应还没开始写」与
22
+ * 「协议是什么」的地方。
23
+ *
24
+ * 三条路径都要接住,漏一条就是那个协议下的一种故障:
25
+ * - `head(status, headers)` —— 非流式的状态行(503 也走这里)
26
+ * - `write(bytes)` —— 流式的 SSE 字节(可能是多段)
27
+ * - `end()` —— 流结束
28
+ *
29
+ * 流式的 `writeHead` 是**延迟到第一个字节**才发生的(见 writeChatResult),
30
+ * 所以 `head` 在流式下可能一次都不来;调用方不能假设它来过。
31
+ */
32
+ export interface ResponseSink {
33
+ /** 状态行 + 响应头(流式下可能不来;来了就一次)。 */
34
+ head?: (status: number, headers: Record<string, string>) => void;
35
+ /** 一段响应体字节。 */
36
+ write: (bytes: Uint8Array) => void;
37
+ /** 响应体结束。 */
38
+ end: () => void;
39
+ }
15
40
  /** 路由器。 */
16
41
  export declare class Router {
17
42
  /**
@@ -108,6 +133,13 @@ export declare class Router {
108
133
  * pi-ai 等 DSH provider 通过它发现 Router 的模型目录。
109
134
  */
110
135
  listModels(): Promise<ModelInfo[]>;
136
+ /**
137
+ * 该供应商的 `chatOnce`,已按它声明的 wire 包好协议翻译。
138
+ *
139
+ * 每次调用都现取(而不是构造时缓存):供应商会被开关/热重载替换,
140
+ * 缓存会把旧实例的翻译绑在新实例上。
141
+ */
142
+ private wireOf;
111
143
  /** 组合列表(面板用):用户自定义组合。 */
112
144
  combos(): Promise<Combo[]>;
113
145
  /**
@@ -196,7 +228,16 @@ export declare class Router {
196
228
  * - model 命中组合名 → 在组合模型里按策略选一个,交给供应商;失败按回退顺序尝试剩余模型。
197
229
  * - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
198
230
  */
199
- chatCompletions(req: ChatRequest, res: ServerResponse): Promise<void>;
231
+ /**
232
+ * 规范请求入口。
233
+ *
234
+ * @param res 真实的客户端响应。协议非 chat 时**不直接用它**,而是经
235
+ * `sink` 先收集再翻译(见 `protocol/entry.ts`)—— 因为 Router 只知道
236
+ * 怎么写 OpenAI 形态,而客户端说的可能是 Anthropic/Gemini。
237
+ * @param sink 可选的响应收集器:给了就把「写给 res 的东西」改成写给 sink,
238
+ * 由调用方翻译后自己写真正的 res。只给非 chat 协议用。
239
+ */
240
+ chatCompletions(req: ChatRequest, res: ServerResponse, sink?: ResponseSink): Promise<void>;
200
241
  /** 调指定供应商。
201
242
  * 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
202
243
  * (如 traework 会剥第一段)。核心在这剥就会剥两次,把模型 id 里的
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { AccountState, ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts';
7
7
  import type { AccountPool } from './account-pool.ts';
8
+ import type { ProtocolId } from '../protocol/types.ts';
8
9
  /** 面板展示的账号状态(脱敏)。 */
9
10
  export interface SupplierAccount {
10
11
  uid: string;
@@ -64,6 +65,15 @@ export interface ChatRequest {
64
65
  * 能反查是哪个客户端在打)。缺省 = 匿名外部客户端。
65
66
  */
66
67
  session?: string;
68
+ /**
69
+ * 客户端说的协议(入站)。
70
+ *
71
+ * 缺省 `'chat'`(今天所有入口的现状)。四种协议(chat / anthropic /
72
+ * responses / gemini)在入站路由上就被翻成规范形态,所以**核心与供应商
73
+ * 只认 chat**;这个字段存在的理由是让用量记录与日志能说出「客户端原本
74
+ * 说的哪种协议」,排查「Claude Code 打过来为什么不通」时省一次猜。
75
+ */
76
+ protocol?: ProtocolId;
67
77
  }
68
78
  /**
69
79
  * 供应商接口。核心负责账号池(选号/冷却/禁用/错误累计/遍历回退),
@@ -90,6 +100,15 @@ export interface Supplier {
90
100
  accounts(): SupplierAccountNow[];
91
101
  /** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
92
102
  chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult>;
103
+ /**
104
+ * 上游说哪种协议(缺省 `'chat'`)。
105
+ *
106
+ * 声明成别种时,核心在 `chatOnce` 前后各做一次翻译:请求体翻成该协议、
107
+ * 响应流翻回 OpenAI SSE。于是**插件实现里永远只看见 OpenAI 形态**,
108
+ * 契约不变 —— 一个只会说 Anthropic 的上游可以接一个只写了几行 fetch 的
109
+ * 插件,路由/选号/冷却/用量全都照旧。
110
+ */
111
+ readonly wire?: ProtocolId;
93
112
  /** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
94
113
  removeLink?(uid: string): Promise<boolean>;
95
114
  dispose(): void;
@@ -185,6 +185,17 @@ export interface ExtControlItem {
185
185
  body?: string;
186
186
  /** 文本可编辑(缺省 = 只读;见 `ExtControl.editable`)。 */
187
187
  editable?: boolean;
188
+ /**
189
+ * 分组名(可选;缺省 = 无组)—— 详情页按它分节渲染,组间插组标题行;
190
+ * 无组照旧平铺(见 `ExtControl.group`)。
191
+ */
192
+ group?: string;
193
+ /**
194
+ * 参与拖动排序(可选;缺省 = 参与)—— `false` 的条目不给拖把手
195
+ * (见 `ExtControl.sortable`)。⚠️ 服务端**带值透传**,`false` 也会收到;
196
+ * 判断必须写 `sortable !== false`,`!sortable` 会把缺省(参与)当不参与。
197
+ */
198
+ sortable?: boolean;
188
199
  /**
189
200
  * 自建条目(可删)还是内置(不可删、只有开关与还原)。
190
201
  *
@@ -13,6 +13,7 @@ import type { Context } from '@deepseek-ai/cordis';
13
13
  import type { ChatRequest, ModelInfo, SupplierAccount } from '../router/types.ts';
14
14
  import type { SupplierConfigStore } from '../supplier-config.ts';
15
15
  import type { CredentialStore } from '../credential-store.ts';
16
+ import type { ProtocolId } from '../protocol/types.ts';
16
17
  /** 供应商配置:loader 注入的运行时环境。 */
17
18
  export interface SupplierEnv {
18
19
  /** 数据目录(state.json / keys.json 所在目录)。 */
@@ -45,9 +46,16 @@ export interface SupplierEnv {
45
46
  * `source` 是**可选标签**,声明这个供应商是从哪儿来的。核心用它把 `/health` 的
46
47
  * `source` 报准(面板按它分「内置 / 插件」两组,面板和插件页都看这个值)。
47
48
  * 不声明 = `external`(独立安装的供应商插件的现状,一个字段都不用改)。
49
+ *
50
+ * `wire` 是**协议标签**(可选):这个供应商的上游说哪种协议。缺省
51
+ * `'chat'`(= OpenAI Chat Completions,今天所有供应商的现状,一个都不用改)。
52
+ * 声明成别种时,核心在把 `req` 交给插件前把规范请求体翻成它的协议,
53
+ * 并把它的响应流翻回规范形态 —— 插件看到的仍是「OpenAI SSE」,
54
+ * 于是**契约不变、已有插件全都不动**。详见 `protocol/`。
48
55
  */
49
56
  export type SupplierFactory = ((env: SupplierEnv) => SupplierModule) & {
50
57
  source?: 'builtin';
58
+ wire?: ProtocolId;
51
59
  };
52
60
  /**
53
61
  * 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -105,7 +105,7 @@
105
105
  },
106
106
  "license": "MIT",
107
107
  "dependencies": {
108
- "@deepseek-ai/schemastery": "^3.18.4"
108
+ "@deepseek-ai/schemastery": "^3.18.5"
109
109
  },
110
110
  "peerDependencies": {
111
111
  "@deepseek-ai/cordis": "^4.0.4",
@@ -113,8 +113,8 @@
113
113
  "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
114
114
  "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
115
115
  "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
116
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
117
116
  "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
117
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
118
118
  "cordis": "^4.0.0-rc.7",
119
119
  "react": "^18.2.0",
120
120
  "react-dom": "^18.2.0"
@@ -134,13 +134,13 @@
134
134
  }
135
135
  },
136
136
  "devDependencies": {
137
- "@types/node": "^26.6.2",
137
+ "@types/node": "^26.6.5",
138
138
  "@types/react": "~19.3.0",
139
139
  "@types/react-dom": "~19.3.0",
140
140
  "cordis": "4.0.0-rc.10",
141
- "react": "^19.3.0",
141
+ "react": "^19.3.1",
142
142
  "react-dom": "19.3.0",
143
- "tsdown": "^0.23.0",
143
+ "tsdown": "^0.23.1",
144
144
  "typescript": "^7.0.2",
145
145
  "vite": "8.3.0",
146
146
  "vite-plugin-css-injected-by-js": "^5.0.2"
@@ -16,10 +16,11 @@
16
16
  * 名称/副行,下面跟扩展说明卡与未就绪横幅。有 `api()` 支持的自定义扩展可在未来
17
17
  * 按需加独立面板,此处不内置任何具体扩展的专属视图。
18
18
  */
19
- import { useEffect, useState } from 'react'
19
+ import { Fragment, useEffect, useState } from 'react'
20
20
  import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
21
21
  import { ROUTER_API_BASE, type ExtControlItem, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
22
22
  import { extPanel } from './ext-panels.ts'
23
+ import { groupControlRows } from './group-controls.ts'
23
24
  import { Modal } from './Modal.tsx'
24
25
 
25
26
  /**
@@ -436,14 +437,30 @@ function ExtControls({ item }: { item: RouterExtItem }): JSX.Element {
436
437
  它只剩两个字,却仍占一行 —— 页面进来第一眼是「子开关」这三个字而不是
437
438
  那些规则本身。列表自带每条的标题与开关,不需要再套一层说明。 */}
438
439
  <ul className="dshr-compRows">
439
- {controls.map((c, index) => (
440
+ {/* 分组渲染(`ExtControl.group`):无组节(准则分类)平铺在前 ——
441
+ 没用 group 的扩展拿到的就是原来的平铺列表,行为不变;有组的
442
+ 条目(宿主段落)每组前插一行组标题。组的先后与组内顺序都来自
443
+ 服务端 `rows`(= prompt 序),这里不重排。 */}
444
+ {groupControlRows(controls).map((g) => (
445
+ <Fragment key={g.label ?? '__plain__'}>
446
+ {g.label !== undefined && (
447
+ <li className="dshr-extGroupTitle">{g.label}</li>
448
+ )}
449
+ {g.items.map((c) => {
450
+ // 下标永远对 `controls`(= 服务端 rows)取:分组只影响**渲染
451
+ // 顺序**,拖动落盘的 ids 必须基于原列表 —— 段落行不可拖(见下),
452
+ // 可拖的准则全在无组节,原下标与视图位置天然一致。
453
+ const index = controls.indexOf(c)
454
+ const sortable = c.sortable !== false
455
+ return (
440
456
  <li
441
457
  key={c.id}
442
458
  className={`dshr-compRow${dragIndex === index ? ' dshr-linkRowDragging' : ''}`}
443
- draggable
444
- onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
445
- onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
459
+ draggable={sortable}
460
+ onDragStart={(e) => { if (!sortable) return; setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
461
+ onDragOver={(e) => { if (!sortable) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
446
462
  onDrop={(e) => {
463
+ if (!sortable) { setDragIndex(null); return }
447
464
  e.preventDefault()
448
465
  if (dragIndex === null || dragIndex === index) { setDragIndex(null); return }
449
466
  const next = [...controls]
@@ -511,6 +528,9 @@ function ExtControls({ item }: { item: RouterExtItem }): JSX.Element {
511
528
  onChange={(next) => { void toggle(c.id, next) }}
512
529
  />
513
530
  </li>
531
+ )
532
+ })}
533
+ </Fragment>
514
534
  ))}
515
535
  </ul>
516
536
  {withBody.length === 0 && (
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 详情页 controls 分组的判据(`group-controls.ts`)。
3
+ *
4
+ * 关键是**向后兼容不可退**:没用 `group` 字段的扩展(ext-test 等)必须
5
+ * 拿到"一个无组节、顺序不变"的结果 —— 分组是新能力的**叠加**,不是重排。
6
+ */
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { groupControlRows } from './group-controls.ts'
10
+ import type { ExtControlItem } from '../shared.ts'
11
+
12
+ function row(id: string, extra: Partial<ExtControlItem> = {}): ExtControlItem {
13
+ return { id, title: id, on: true, custom: false, overridden: false, ...extra }
14
+ }
15
+
16
+ test('★ 全部无组 ⇒ 单一个无组节(旧扩展的面板展示与改动前一致)', () => {
17
+ const groups = groupControlRows([row('a'), row('b')])
18
+ assert.equal(groups.length, 1)
19
+ assert.equal(groups[0]!.label, undefined)
20
+ assert.deepEqual(groups[0]!.items.map((i) => i.id), ['a', 'b'], '无组条目顺序被改了')
21
+ })
22
+
23
+ test('★ 无组节恒排最前,且不产出空的无组节', () => {
24
+ const groups = groupControlRows([row('a'), row('t1', { group: '工具说明' })])
25
+ assert.deepEqual(groups.map((g) => g.label), [undefined, '工具说明'])
26
+ const onlyGrouped = groupControlRows([row('t1', { group: '工具说明' })])
27
+ assert.deepEqual(onlyGrouped.map((g) => g.label), ['工具说明'], '只有分组条目时不该冒出一个空的无组节')
28
+ })
29
+
30
+ test('组的先后 = 首次出现序(不按字母重排)', () => {
31
+ const groups = groupControlRows([
32
+ row('h', { group: '开场身份' }),
33
+ row('w', { group: '工具说明' }),
34
+ row('e', { group: '工具说明' }),
35
+ row('p', { group: '计划模式' }),
36
+ ])
37
+ assert.deepEqual(groups.map((g) => g.label), ['开场身份', '工具说明', '计划模式'])
38
+ assert.deepEqual(groups[1]!.items.map((i) => i.id), ['w', 'e'], '组内顺序被重排 ⇒ 面板序与 prompt 序脱钩')
39
+ })
40
+
41
+ test('同名组跨位置仍归一组(不因中间夹了别的组而拆开)', () => {
42
+ const groups = groupControlRows([
43
+ row('w1', { group: '工具说明' }),
44
+ row('h', { group: '开场身份' }),
45
+ row('w2', { group: '工具说明' }),
46
+ ])
47
+ const tool = groups.find((g) => g.label === '工具说明')!
48
+ assert.deepEqual(tool.items.map((i) => i.id), ['w1', 'w2'])
49
+ })
50
+
51
+ test('空输入 ⇒ 空数组', () => {
52
+ assert.deepEqual(groupControlRows([]), [])
53
+ })
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 扩展详情页 controls 的**分组**纯逻辑(客户端,可单测)。
3
+ *
4
+ * 分组是**归属信息**,不是样式:一条扩展同时管两类东西时(分层提示词 =
5
+ * 准则分类 + 宿主系统提示词段落),平铺成一长列无从分辨哪条是谁的。
6
+ *
7
+ * 两条规则:
8
+ * ① **无组节恒排最前** —— 没用 `group` 字段的扩展(ext-test 等)拿到的
9
+ * 分组结果就是原来的平铺列表(一个无名节),面板行为一个像素不变;
10
+ * ② 组的先后 = 组名在 `rows` 里的**首次出现序**,组内保持原顺序 ——
11
+ * 不按字母重排:`rows` 的顺序来自服务端(段落按 prompt 序),重排
12
+ * 就会把"面板顺序 ≈ 模型看到的顺序"这条对应关系打断。
13
+ */
14
+ import type { ExtControlItem } from '../shared.ts'
15
+
16
+ /** 一个分组:`label === undefined` 表示"无组节"(平铺条目)。 */
17
+ export interface ControlGroup {
18
+ label?: string
19
+ items: ExtControlItem[]
20
+ }
21
+
22
+ /** 按 `group` 分组;空输入返回空数组(不产出空的无组节)。 */
23
+ export function groupControlRows(rows: readonly ExtControlItem[]): ControlGroup[] {
24
+ const plain: ExtControlItem[] = []
25
+ const groups: ControlGroup[] = []
26
+ const byLabel = new Map<string, ExtControlItem[]>()
27
+ for (const c of rows) {
28
+ if (c.group === undefined) {
29
+ plain.push(c)
30
+ continue
31
+ }
32
+ let items = byLabel.get(c.group)
33
+ if (!items) {
34
+ items = []
35
+ byLabel.set(c.group, items)
36
+ groups.push({ label: c.group, items })
37
+ }
38
+ items.push(c)
39
+ }
40
+ const out: ControlGroup[] = []
41
+ if (plain.length > 0) out.push({ items: plain })
42
+ return [...out, ...groups]
43
+ }