dsh-router-core 0.3.2 → 0.3.3
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 +5 -1
- package/docs/suppliers.md +19 -3
- package/lib/client-registry.js +13 -8
- package/lib/client.js +13 -8
- package/lib/index.js +611 -215
- package/lib/types/llm/adapter.d.ts +7 -1
- package/lib/types/router/account-pool.d.ts +54 -19
- package/lib/types/router/index.d.ts +5 -0
- package/lib/types/router/usage-store.d.ts +20 -3
- package/lib/types/shared.d.ts +2 -0
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +8 -8
- package/src/llm/adapter.test.ts +404 -1
- package/src/llm/adapter.ts +242 -34
- package/src/router/account-pool.test.ts +163 -61
- package/src/router/account-pool.ts +200 -68
- package/src/router/index.ts +137 -38
- package/src/router/models-cache.test.ts +87 -4
- package/src/router/precise-dispatch.test.ts +110 -7
- package/src/router/usage-store.ts +174 -74
- package/src/router/usage.test.ts +115 -0
- package/src/shared.ts +2 -0
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/registry.test.ts +23 -3
- package/src/suppliers/registry.ts +7 -2
package/src/llm/adapter.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
EMPTY_RESPONSE_CODE,
|
|
12
12
|
LlmAdapter,
|
|
13
13
|
LlmError,
|
|
14
|
+
attributionHeaders,
|
|
14
15
|
type GenerateOptions,
|
|
15
16
|
type LlmModelInfo,
|
|
16
17
|
type StreamChunk,
|
|
@@ -22,29 +23,23 @@ export interface RouterAdapterSource {
|
|
|
22
23
|
comboModels: () => Promise<Array<{ id: string; name?: string }>>
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (line === '' && data.length > 0) {
|
|
36
|
-
yield data
|
|
37
|
-
data = ''
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
if (data.length > 0) yield data
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。 */
|
|
44
|
-
function wireMessages(options: GenerateOptions): Array<Record<string, unknown>> {
|
|
26
|
+
/**
|
|
27
|
+
* 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
|
|
28
|
+
*
|
|
29
|
+
* `system` 必须由调用方从 `options.system` 传进来:agent-loop 把系统提示词
|
|
30
|
+
* 放在 **options.system 这个独立槽位**,不放进 messages(见 dsh-llm
|
|
31
|
+
* types.d.ts 的 `system?: string` —— 注释原话 "adapters map to the
|
|
32
|
+
* provider's system slot")。不读这个槽位,模型每一轮都拿不到身份/规则/
|
|
33
|
+
* 工具用法约束,且**不会报错**,只是行为悄悄降级。
|
|
34
|
+
*/
|
|
35
|
+
function wireMessages(options: GenerateOptions, system?: string): Array<Record<string, unknown>> {
|
|
45
36
|
const out: Array<Record<string, unknown>> = []
|
|
37
|
+
if (typeof system === 'string' && system !== '') {
|
|
38
|
+
out.push({ role: 'system', content: system })
|
|
39
|
+
}
|
|
46
40
|
for (const message of options.messages) {
|
|
47
41
|
if (message.role === 'system') {
|
|
42
|
+
// 手写调用也可能把 system 塞进 messages(one-shot 场景),照旧支持
|
|
48
43
|
out.push({ role: 'system', content: flattenText(message.content) })
|
|
49
44
|
continue
|
|
50
45
|
}
|
|
@@ -70,7 +65,9 @@ function wireMessages(options: GenerateOptions): Array<Record<string, unknown>>
|
|
|
70
65
|
const toolResults = message.content.filter((b) => b.type === 'tool-result')
|
|
71
66
|
if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
|
|
72
67
|
for (const result of toolResults) {
|
|
73
|
-
|
|
68
|
+
// 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
|
|
69
|
+
// 以为工具真的打印了那句话(9router 也是补 content: "")。
|
|
70
|
+
out.push({ role: 'tool', tool_call_id: result.toolCallId, content: flattenText(result.content) })
|
|
74
71
|
}
|
|
75
72
|
}
|
|
76
73
|
return out
|
|
@@ -78,7 +75,7 @@ function wireMessages(options: GenerateOptions): Array<Record<string, unknown>>
|
|
|
78
75
|
|
|
79
76
|
/** 组装 wire 请求体。 */
|
|
80
77
|
function wireRequest(options: GenerateOptions): Record<string, unknown> {
|
|
81
|
-
const messages = wireMessages(options)
|
|
78
|
+
const messages = wireMessages(options, options.system)
|
|
82
79
|
const body: Record<string, unknown> = {
|
|
83
80
|
model: options.model,
|
|
84
81
|
messages,
|
|
@@ -93,9 +90,33 @@ function wireRequest(options: GenerateOptions): Record<string, unknown> {
|
|
|
93
90
|
if (options.temperature !== undefined) body.temperature = options.temperature
|
|
94
91
|
if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens
|
|
95
92
|
if (options.stop !== undefined) body.stop = options.stop
|
|
93
|
+
applyReasoning(body, options.reasoningEffort)
|
|
96
94
|
return body
|
|
97
95
|
}
|
|
98
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 推理强度 → CodeBuddy 的 OpenAI 风格参数。
|
|
99
|
+
*
|
|
100
|
+
* 照 9router `executors/codebuddy-cn.js` 的处理:CodeBuddy 只有同时收到
|
|
101
|
+
* `reasoning_effort` + `reasoning_summary:"auto"` 才吐推理内容,而 harness
|
|
102
|
+
* 只给 `reasoning_effort`,从不下发 `reasoning_summary`。
|
|
103
|
+
*
|
|
104
|
+
* 关键陷阱(9router 注释里的 #2071):**不能无条件加**。对没要推理的普通
|
|
105
|
+
* 请求强行加 `reasoning_effort:"medium"` + `reasoning_summary`,会让
|
|
106
|
+
* CodeBuddy 触发内容过滤直接报错。`none`/`off` 也必须是**删字段**而不是传
|
|
107
|
+
* `"none"` —— 网关没有这个值。
|
|
108
|
+
*/
|
|
109
|
+
function applyReasoning(body: Record<string, unknown>, effort: string | undefined): void {
|
|
110
|
+
if (effort === undefined || effort === '') return
|
|
111
|
+
if (effort === 'none' || effort === 'off') {
|
|
112
|
+
delete body.reasoning_effort
|
|
113
|
+
delete body.reasoning_summary
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
body.reasoning_effort = effort
|
|
117
|
+
body.reasoning_summary = 'auto'
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
function flattenText(blocks: readonly { type: string; text?: string }[]): string {
|
|
100
121
|
return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
|
|
101
122
|
}
|
|
@@ -109,7 +130,13 @@ interface OpenBlock {
|
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
112
|
-
|
|
133
|
+
/**
|
|
134
|
+
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
|
135
|
+
*
|
|
136
|
+
* 吃 `AsyncIterable`(真流式:边收边吐)或 `Iterable`(测试用数组)。
|
|
137
|
+
* 用 `for await` 是因为它天然兼容两者 —— 不必为流式再写一份。
|
|
138
|
+
*/
|
|
139
|
+
export async function* translateSse(payloads: AsyncIterable<string> | Iterable<string>): AsyncGenerator<StreamChunk> {
|
|
113
140
|
let nextIndex = 0
|
|
114
141
|
let textBlock: OpenBlock | undefined
|
|
115
142
|
let reasoningBlock: OpenBlock | undefined
|
|
@@ -118,18 +145,63 @@ export async function* translateSse(payloads: Iterable<string>): AsyncGenerator<
|
|
|
118
145
|
let pendingFinish: { kind: string; failure?: unknown } | undefined
|
|
119
146
|
/** 攒上游各帧的 usage(归一形态),[DONE] 时统一转成 DSH 契约。 */
|
|
120
147
|
let accumulated: UsageTokens | null = null
|
|
148
|
+
/** 解析失败的帧数(坏帧跳过,不硬抛;全是坏帧才当断流处理)。 */
|
|
149
|
+
let malformed = 0
|
|
150
|
+
/** 有没有产出过任何内容块(content/reasoning/tool-call)。 */
|
|
151
|
+
let produced = false
|
|
121
152
|
|
|
122
153
|
const open = (kind: OpenBlock['kind']): OpenBlock => {
|
|
123
154
|
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
|
|
124
155
|
order.push(block)
|
|
156
|
+
produced = true
|
|
125
157
|
return block
|
|
126
158
|
}
|
|
127
159
|
|
|
128
|
-
for (const payload of payloads) {
|
|
160
|
+
for await (const payload of payloads) {
|
|
129
161
|
if (payload === '[DONE]') {
|
|
130
|
-
|
|
162
|
+
// 收尾前先筛掉参数没发完的工具调用。
|
|
163
|
+
//
|
|
164
|
+
// 为什么必须筛:上游(CodeBuddy 网关)在长上下文/高压时会在 tool_call
|
|
165
|
+
// 的 arguments **分片没发完**就直接发 `[DONE]`。无条件收尾会把半成品
|
|
166
|
+
// JSON(如 `{"command": "cd`)当完整参数交给 harness,工具侧校验报
|
|
167
|
+
// `missing required property "command"` / `"arguments" must be an object`,
|
|
168
|
+
// 表现为「bash 调用大面积失败」。实测一次会话 29 次工具调用里 27 次
|
|
169
|
+
// 参数残缺。残缺的调用**不能执行**——拿半条命令去跑比报错危险得多。
|
|
170
|
+
const complete = order.filter((block) => block.kind !== 'tool-call' || completeJson(block.text))
|
|
171
|
+
const dropped = order.length - complete.length
|
|
172
|
+
for (const block of complete) {
|
|
131
173
|
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
|
132
174
|
}
|
|
175
|
+
// 残缺的块**必须补发一个安全的 block-end**,光不发是不够的:
|
|
176
|
+
// dsh-llm 的 BlockAssembler 对缺 block-end 的 index 会用累积的 delta
|
|
177
|
+
// 兜底重建(lib/types/assembler.js),照样组装出 `{"command": "rm -rf /`
|
|
178
|
+
// 这种半成品;而 assembled() 只在 finish.kind === 'max-tokens' 时过滤
|
|
179
|
+
// tool-call —— EMPTY_RESPONSE 的 error finish 不过滤。
|
|
180
|
+
// 补一个空名 + `{}` 的收尾,让它**覆盖**掉 delta 累积值。
|
|
181
|
+
for (const block of order) {
|
|
182
|
+
if (block.kind !== 'tool-call' || completeJson(block.text)) continue
|
|
183
|
+
yield {
|
|
184
|
+
type: 'block-end',
|
|
185
|
+
index: block.index,
|
|
186
|
+
block: { type: 'tool-call', id: CallId(block.callId ?? ''), name: '', arguments: '{}' },
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (dropped > 0) {
|
|
190
|
+
// 整轮判 error:让 agent-loop 重试(拿残参执行是错的,静默装作成功更错)
|
|
191
|
+
//
|
|
192
|
+
// code 必须用 EMPTY_RESPONSE:只有 dsh-llm 默认可重试码白名单里的
|
|
193
|
+
// 码才会触发重试(["EMPTY_RESPONSE","RATE_LIMIT","SERVER","TIMEOUT",
|
|
194
|
+
// "TRANSPORT"],maxRetries 5)。造一个新码(如 INCOMPLETE_TOOL_ARGS)
|
|
195
|
+
// 看着更精确,但不在白名单里 → **一次都不重试**,直接抛给用户,
|
|
196
|
+
// 正好毁掉这里重试的本意。语义也对得上:上游没产出可用的调用。
|
|
197
|
+
pendingFinish = {
|
|
198
|
+
kind: 'error',
|
|
199
|
+
failure: {
|
|
200
|
+
message: `upstream closed the stream before ${dropped} tool call argument${dropped > 1 ? 's were' : ' was'} complete`,
|
|
201
|
+
code: EMPTY_RESPONSE_CODE,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
133
205
|
if (accumulated !== null) {
|
|
134
206
|
// 转成 DSH 的 TokenUsage:**必须**转,不能把上游的 OpenAI 形态
|
|
135
207
|
// (prompt_tokens,且含缓存)原样透传。字段名和 DISJOINT 口径都
|
|
@@ -153,7 +225,14 @@ export async function* translateSse(payloads: Iterable<string>): AsyncGenerator<
|
|
|
153
225
|
try {
|
|
154
226
|
chunk = JSON.parse(payload) as typeof chunk
|
|
155
227
|
} catch {
|
|
156
|
-
|
|
228
|
+
// 坏帧**跳过**,不要抛:一帧坏 JSON 毁掉整轮,已流式吐出的内容全废
|
|
229
|
+
// (9router 也是 catch 后继续,见 mitm/handlers/base.js 的
|
|
230
|
+
// "Skip unparseable lines")。上游断流时最后一帧常常就是半截的,
|
|
231
|
+
// 硬抛等于把可恢复的失败变成必失败 —— 而且 MALFORMED_RESPONSE 不在
|
|
232
|
+
// dsh-llm 的可重试白名单里,抛了也一次都不重试。
|
|
233
|
+
// 真的一帧都没解析成功时,流末按断流处理(见下方)。
|
|
234
|
+
malformed += 1
|
|
235
|
+
continue
|
|
157
236
|
}
|
|
158
237
|
for (const choice of chunk.choices ?? []) {
|
|
159
238
|
const delta = choice.delta ?? {}
|
|
@@ -196,21 +275,45 @@ export async function* translateSse(payloads: Iterable<string>): AsyncGenerator<
|
|
|
196
275
|
argumentsDelta: fragment,
|
|
197
276
|
}
|
|
198
277
|
}
|
|
199
|
-
if (typeof choice.finish_reason === 'string')
|
|
278
|
+
if (typeof choice.finish_reason === 'string') {
|
|
279
|
+
// 只认真的终止原因:上游(CodeBuddy)会在中间帧发 `finish_reason: ""`,
|
|
280
|
+
// 它是「还没结束」而不是「以空原因结束」。当真原因收进来会被 mapFinishReason
|
|
281
|
+
// 的 default 分支变成 code:"" 的 error finish,agent-loop 拿它重建
|
|
282
|
+
// LlmError 时 dsh-llm 直接抛 `LlmError code must be a non-empty string`
|
|
283
|
+
// —— 界面就是「本轮运行失败 … UNKNOWN」。空的不收,也不许它冲掉真原因。
|
|
284
|
+
const mapped = mapFinishReason(choice.finish_reason)
|
|
285
|
+
if (mapped !== undefined) pendingFinish = mapped
|
|
286
|
+
}
|
|
200
287
|
}
|
|
201
288
|
// 上游可能把 usage 拆在多个帧里(Claude 系:先给输入、后给输出),
|
|
202
289
|
// 所以是字段级 max 合并,不是覆盖。攒着,[DONE] 时统一转契约。
|
|
203
290
|
if (chunk.usage !== undefined) accumulated = mergeUsage(accumulated, normalizeUsage(chunk.usage))
|
|
204
291
|
}
|
|
205
|
-
|
|
292
|
+
// 码必须是 TRANSPORT(不是自造的 STREAM_CLOSED / MALFORMED_RESPONSE):
|
|
293
|
+
// 只有 dsh-llm 默认可重试白名单里的码才会被重试(EMPTY_RESPONSE /
|
|
294
|
+
// RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT)。自造码一次都不重试,
|
|
295
|
+
// 断流就直接抛给用户 —— 而断流恰恰是**最该重试**的一类故障。
|
|
296
|
+
const detail = malformed > 0 ? ` (${malformed} frame(s) unparseable)` : ''
|
|
297
|
+
throw new LlmError(`SSE payload stream ended without [DONE]${detail}`, 'TRANSPORT')
|
|
206
298
|
}
|
|
207
299
|
|
|
208
|
-
|
|
300
|
+
/** 上游 finish_reason → DSH finish reason;空串(「未终止」)返回 undefined。 */
|
|
301
|
+
function mapFinishReason(reason: string): { kind: string; failure?: unknown } | undefined {
|
|
209
302
|
switch (reason) {
|
|
303
|
+
case '': return undefined
|
|
210
304
|
case 'stop': return { kind: 'stop' }
|
|
211
305
|
case 'tool_calls': return { kind: 'tool-calls' }
|
|
212
306
|
case 'length': return { kind: 'max-tokens' }
|
|
213
|
-
|
|
307
|
+
// 未知原因也要能过 dsh-llm 的 `code` 非空校验:万一上游给的是空白或
|
|
308
|
+
// 纯符号(toUpperCase 后仍可能是怪东西),兜一个稳定码,别把空串交给
|
|
309
|
+
// agent-loop —— 它拿 code 重建 LlmError,空码 = 抛「本轮运行失败」。
|
|
310
|
+
default: {
|
|
311
|
+
const code = reason.trim().toUpperCase()
|
|
312
|
+
return {
|
|
313
|
+
kind: 'error',
|
|
314
|
+
failure: { message: `model stopped: ${reason}`, code: code === '' ? 'UNKNOWN_FINISH_REASON' : code },
|
|
315
|
+
}
|
|
316
|
+
}
|
|
214
317
|
}
|
|
215
318
|
}
|
|
216
319
|
|
|
@@ -222,11 +325,30 @@ function closeBlock(block: OpenBlock): never {
|
|
|
222
325
|
type: 'tool-call',
|
|
223
326
|
id: CallId(block.callId ?? ''),
|
|
224
327
|
name: block.name ?? '',
|
|
225
|
-
|
|
328
|
+
// 空串 = 无参工具,合法(上游很多工具不吃参数);补成 `{}` 让下游
|
|
329
|
+
// 不必分支。能走到这里的必然是完整 JSON(残缺的已在 [DONE] 处拦掉)。
|
|
330
|
+
arguments: block.text === '' ? '{}' : block.text,
|
|
226
331
|
} as never
|
|
227
332
|
}
|
|
228
333
|
}
|
|
229
334
|
|
|
335
|
+
/**
|
|
336
|
+
* 工具调用的 arguments 是否已经收完整(能解析成一个 JSON 对象)。
|
|
337
|
+
*
|
|
338
|
+
* 判断口径刻意简单:只看能不能 `JSON.parse` 出对象。不校验 schema——
|
|
339
|
+
* 那是工具自己的活,adapter 只负责「别把半成品交出去」。
|
|
340
|
+
* 空串视为完整(无参工具),因为上游不发参数分片是合法的。
|
|
341
|
+
*/
|
|
342
|
+
function completeJson(text: string): boolean {
|
|
343
|
+
if (text.trim() === '') return true
|
|
344
|
+
try {
|
|
345
|
+
const v: unknown = JSON.parse(text)
|
|
346
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
347
|
+
} catch {
|
|
348
|
+
return false // 分片没发完,JSON 必然解析失败
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
230
352
|
/**
|
|
231
353
|
* DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
|
|
232
354
|
* /v1/chat/completions(组合路由在 /v1 内完成)。
|
|
@@ -266,7 +388,14 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
266
388
|
try {
|
|
267
389
|
resp = await fetch(`${this.baseURL}/chat/completions`, {
|
|
268
390
|
method: 'POST',
|
|
269
|
-
|
|
391
|
+
// attributionHeaders() 是 dsh-llm 对 adapter 的硬契约:每个 provider
|
|
392
|
+
// 请求都必须带(LlmAdapter 类注释原话)。它给出
|
|
393
|
+
// `user-agent: deepseek-harness/<ver> (+url)`。
|
|
394
|
+
headers: {
|
|
395
|
+
...attributionHeaders(),
|
|
396
|
+
'content-type': 'application/json',
|
|
397
|
+
accept: 'text/event-stream',
|
|
398
|
+
},
|
|
270
399
|
body: JSON.stringify(body),
|
|
271
400
|
signal: controller.signal,
|
|
272
401
|
})
|
|
@@ -279,7 +408,86 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
279
408
|
const detail = await resp.text().catch(() => '')
|
|
280
409
|
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
|
|
281
410
|
}
|
|
282
|
-
|
|
283
|
-
yield* translateSse(
|
|
411
|
+
if (resp.body === null) return
|
|
412
|
+
yield* translateSse(ssePayloads(resp.body))
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 已攒的 payload 是否已经是完整的(`[DONE]` 或一个能解析的 JSON 对象)。
|
|
418
|
+
*
|
|
419
|
+
* 只用于「缺空行分隔」时的前瞻回退,不校验业务结构 —— 那是 translateSse 的事。
|
|
420
|
+
*/
|
|
421
|
+
function looksComplete(payload: string): boolean {
|
|
422
|
+
if (payload === '[DONE]') return true
|
|
423
|
+
try {
|
|
424
|
+
JSON.parse(payload)
|
|
425
|
+
return true
|
|
426
|
+
} catch {
|
|
427
|
+
return false
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 从上游响应体**增量**产出 SSE 的 `data:` 载荷。
|
|
433
|
+
*
|
|
434
|
+
* 为什么必须逐块读:曾经这里写 `await resp.text()` 攒完整串再解析,等于
|
|
435
|
+
* 把流式降级成批处理 —— 首字节延迟等于整个响应耗时;更糟的是中途断流时
|
|
436
|
+
* resp.text() 只拿到半截文本,最后一帧是半截 JSON(`{"command": "cd`),
|
|
437
|
+
* 表现为「工具参数残缺」。短响应连接不断,所以本地抓包复现不了。
|
|
438
|
+
*
|
|
439
|
+
* 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
|
|
440
|
+
* 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
|
|
441
|
+
*/
|
|
442
|
+
async function* ssePayloads(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
443
|
+
const reader = body.getReader()
|
|
444
|
+
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
445
|
+
let buffer = ''
|
|
446
|
+
/** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
|
|
447
|
+
* 状态 —— 放模块级会让并发请求互相串数据。 */
|
|
448
|
+
let payload = ''
|
|
449
|
+
try {
|
|
450
|
+
for (;;) {
|
|
451
|
+
const { done, value } = await reader.read()
|
|
452
|
+
if (done) break
|
|
453
|
+
buffer += decoder.decode(value, { stream: true })
|
|
454
|
+
const lines = buffer.split('\n')
|
|
455
|
+
buffer = lines.pop() ?? '' // 最后一段可能没换行,留给下一块
|
|
456
|
+
for (const line of lines) {
|
|
457
|
+
const t = line.trim()
|
|
458
|
+
if (t.startsWith('data:')) {
|
|
459
|
+
// 前瞻回退:部分上游不发空行分隔,直接连着发下一帧。这时若已攒的
|
|
460
|
+
// payload 本身是个完整 JSON,就先把它交出去 —— 否则两帧会被拼成
|
|
461
|
+
// `{...}\n{...}` 而 parse 失败,整轮报废(9router 逐行取,天然
|
|
462
|
+
// 没这问题;我们按空行分帧,就得补这个回退)。
|
|
463
|
+
if (payload.length > 0 && looksComplete(payload)) {
|
|
464
|
+
yield payload
|
|
465
|
+
payload = ''
|
|
466
|
+
}
|
|
467
|
+
payload += (payload.length > 0 ? '\n' : '') + t.slice(5).trimStart()
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
// 空行 = 一帧结束
|
|
471
|
+
if (t === '' && payload.length > 0) {
|
|
472
|
+
yield payload
|
|
473
|
+
payload = ''
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// 流正常结束:末尾没换行的内容也要收(可能有最后一帧)
|
|
478
|
+
if (buffer.length > 0) {
|
|
479
|
+
const t = buffer.trim()
|
|
480
|
+
if (t.startsWith('data:')) payload += (payload.length > 0 ? '\n' : '') + t.slice(5).trimStart()
|
|
481
|
+
}
|
|
482
|
+
if (payload.length > 0) yield payload
|
|
483
|
+
} finally {
|
|
484
|
+
// 释放锁**之后还必须 cancel**:releaseLock() 只解除 JS 侧的 reader 绑定,
|
|
485
|
+
// 不通知传输层 —— 上游那条 TCP 连接和 in-flight 请求会原样挂着。
|
|
486
|
+
// dsh-llm 在消费者提前退出时必走 `iterator.return()`(用户取消 /
|
|
487
|
+
// agent-loop 收敛 / 异常),于是每次中断泄漏一条到本地路由器的连接;
|
|
488
|
+
// 长会话下 fd 耗尽 → ECONNRESET / 端口耗尽。
|
|
489
|
+
// 顺序不能反:cancel() 要求没有活跃 reader,所以先 releaseLock。
|
|
490
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
491
|
+
await body.cancel().catch(() => {})
|
|
284
492
|
}
|
|
285
493
|
}
|