dsh-router-core 0.3.20 → 0.3.21
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/lib/index.js +15 -6
- package/package.json +1 -1
- package/src/llm/adapter.test.ts +54 -1
- package/src/llm/adapter.ts +15 -3
- package/src/router/disconnect-leak.test.ts +50 -0
- package/src/router/index.ts +16 -3
package/lib/index.js
CHANGED
|
@@ -1573,7 +1573,16 @@ function normalizeSSEStream(stream) {
|
|
|
1573
1573
|
function isDoneFrame(frame) {
|
|
1574
1574
|
return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame);
|
|
1575
1575
|
}
|
|
1576
|
-
/**
|
|
1576
|
+
/**
|
|
1577
|
+
* 修一帧 SSE:剥掉**空的**(`''` 或 `null`)name/id,出任何问题都原样返回。
|
|
1578
|
+
*
|
|
1579
|
+
* 为什么 `null` 也算空:上游(OpenCode/Zen)在后续 delta 里会把首帧已给过的
|
|
1580
|
+
* id/name 显式重发成 `null`——`{"id":null,"function":{"name":null,"arguments":"{"}}`。
|
|
1581
|
+
* 下游判「有没有新值」用的是 `name !== undefined`(dsh 的 BlockAssembler 就是),
|
|
1582
|
+
* null 过不了这个判断、却也不该覆盖首帧:客户端会把已拿到的 "bash" 冲成 null,
|
|
1583
|
+
* 工具名丢失(表现为 `unknown tool ""`)。空串早就剥了,null 语义上同样是
|
|
1584
|
+
* 「本帧没给新值」,一并剥掉。
|
|
1585
|
+
*/
|
|
1577
1586
|
function fixFrame(frame) {
|
|
1578
1587
|
if (!frame.includes("tool_calls")) return frame;
|
|
1579
1588
|
return frame.split("\n").map((line) => {
|
|
@@ -1589,12 +1598,12 @@ function fixFrame(frame) {
|
|
|
1589
1598
|
let changed = false;
|
|
1590
1599
|
const tcs = (obj.choices?.[0]?.delta)?.tool_calls;
|
|
1591
1600
|
if (Array.isArray(tcs)) for (const tc of tcs) {
|
|
1592
|
-
if (tc.id === "") {
|
|
1601
|
+
if (tc.id === "" || tc.id === null) {
|
|
1593
1602
|
delete tc.id;
|
|
1594
1603
|
changed = true;
|
|
1595
1604
|
}
|
|
1596
1605
|
const fn = tc.function;
|
|
1597
|
-
if (fn !== null && typeof fn === "object" && fn.name === "") {
|
|
1606
|
+
if (fn !== null && typeof fn === "object" && (fn.name === "" || fn.name === null)) {
|
|
1598
1607
|
delete fn.name;
|
|
1599
1608
|
changed = true;
|
|
1600
1609
|
}
|
|
@@ -2733,9 +2742,9 @@ async function* translateSse(payloads) {
|
|
|
2733
2742
|
blockType: "tool-call"
|
|
2734
2743
|
};
|
|
2735
2744
|
}
|
|
2736
|
-
if (call.id !==
|
|
2737
|
-
if (call.function?.name !==
|
|
2738
|
-
const fragment = call.function?.arguments
|
|
2745
|
+
if (typeof call.id === "string" && call.id !== "") block.callId = call.id;
|
|
2746
|
+
if (typeof call.function?.name === "string" && call.function.name !== "") block.name = call.function.name;
|
|
2747
|
+
const fragment = typeof call.function?.arguments === "string" ? call.function.arguments : "";
|
|
2739
2748
|
block.text += fragment;
|
|
2740
2749
|
yield {
|
|
2741
2750
|
type: "tool-call-delta",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
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": {
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from 'node:test'
|
|
13
13
|
import assert from 'node:assert/strict'
|
|
14
|
-
import { EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import { AssistantStreamAccumulator, EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
|
|
15
15
|
import { toTokenUsage } from '../router/usage-tokens.ts'
|
|
16
16
|
import { RouterAdapter, translateSse } from './adapter.ts'
|
|
17
17
|
|
|
@@ -191,6 +191,59 @@ async function toolCallsOf(...payloads: string[]): Promise<Array<{ name?: string
|
|
|
191
191
|
return out
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
test('工具调用:后续 delta 把 id/name 重发成 null 时,不能冲掉首帧也不许发 null', async () => {
|
|
195
|
+
// 真实事故(2026-09-22,OpenCode/Zen):上游首帧给 id+name,后续分片帧把
|
|
196
|
+
// 它们**显式重发成 null**(不是省略、不是空串)。用对象构造、交给 frame() 序列化,
|
|
197
|
+
// 免得在字符串字面量里跟引号打架。
|
|
198
|
+
const tcDelta = (tc: unknown): string => frame({ choices: [{ delta: { tool_calls: [tc] } }] })
|
|
199
|
+
const payloads = [
|
|
200
|
+
tcDelta({ index: 0, id: 'call_abc', type: 'function', function: { name: 'bash', arguments: '' } }),
|
|
201
|
+
tcDelta({ index: 0, id: null, type: 'function', function: { name: null, arguments: '{' } }),
|
|
202
|
+
tcDelta({ index: 0, id: null, type: 'function', function: { name: null, arguments: '"command": "ls"}' } }),
|
|
203
|
+
frame({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
204
|
+
'[DONE]',
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
// 旧判据是 `!== undefined`,null 通不过它却被当成真值:既把 "bash" 冲成 null,
|
|
208
|
+
// 又把 `name: null` 发进 StreamChunk。而真实运行路径
|
|
209
|
+
// (dsh-llm 的 AssistantStreamAccumulator.push)判据是
|
|
210
|
+
// `Object.hasOwn(chunk,'name') && typeof chunk.name !== 'string'` → 抛
|
|
211
|
+
// `TypeError: tool-call-delta name must be a string`(用户看到的
|
|
212
|
+
// 「本轮运行失败 tool-call-delta name must be a string」)。
|
|
213
|
+
//
|
|
214
|
+
// 用**真实的那个累加器**验收,而不是自己复述规则:它就是运行时会抛的那一段。
|
|
215
|
+
const acc = new AssistantStreamAccumulator()
|
|
216
|
+
let n = 0
|
|
217
|
+
for await (const chunk of translateSse(payloads)) {
|
|
218
|
+
acc.push({ time: n++, chunk }) // 这里就是线上抛错的那一步
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 逐条再按原判据过一遍,失败时能直接指向违规的那条 delta
|
|
222
|
+
for await (const chunk of translateSse(payloads)) {
|
|
223
|
+
if (chunk.type !== 'tool-call-delta') continue
|
|
224
|
+
if (Object.hasOwn(chunk, 'name')) assert.equal(typeof chunk.name, 'string', 'name 一旦出现就必须是字符串')
|
|
225
|
+
assert.equal(typeof chunk.id, 'string', 'id 必须是字符串')
|
|
226
|
+
assert.equal(typeof chunk.argumentsDelta, 'string', 'argumentsDelta 必须是字符串')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 首帧给的 name/id 必须在后续 null 帧里**保持**
|
|
230
|
+
const deltas: Array<Record<string, unknown>> = []
|
|
231
|
+
for await (const chunk of translateSse(payloads)) {
|
|
232
|
+
if (chunk.type === 'tool-call-delta') deltas.push(chunk as unknown as Record<string, unknown>)
|
|
233
|
+
}
|
|
234
|
+
assert.ok(deltas.length >= 3, '三个分片帧都该产出 delta')
|
|
235
|
+
for (const d of deltas) {
|
|
236
|
+
assert.equal(d.name, 'bash', 'name 被后续 null 帧冲掉了(工具名会变成空)')
|
|
237
|
+
assert.equal(d.id, 'call_abc', 'id 被后续 null 帧冲掉了')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 收尾后的完整调用也要正确
|
|
241
|
+
const calls = await toolCallsOf(...payloads)
|
|
242
|
+
assert.equal(calls.length, 1)
|
|
243
|
+
assert.equal(calls[0]?.name, 'bash', '工具名必须保住')
|
|
244
|
+
assert.equal(calls[0]?.arguments, '{"command": "ls"}')
|
|
245
|
+
})
|
|
246
|
+
|
|
194
247
|
test('工具调用:参数分片发完才收尾,完整 JSON 原样保留', async () => {
|
|
195
248
|
const calls = await toolCallsOf(
|
|
196
249
|
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'bash', arguments: '' } }] } }] }),
|
package/src/llm/adapter.ts
CHANGED
|
@@ -439,9 +439,21 @@ export async function* translateSse(payloads: AsyncIterable<string> | Iterable<s
|
|
|
439
439
|
toolBlocks.set(key, block)
|
|
440
440
|
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
|
441
441
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
442
|
+
// 身份字段只认**非空字符串**。上游(OpenCode/Zen)在后续 delta 里会把
|
|
443
|
+
// 首帧已给过的 id/name 显式重发成 `null`(不是省略、也不是空串)——
|
|
444
|
+
// 实测帧形状:
|
|
445
|
+
// {"index":0,"id":"call_abc","function":{"name":"bash","arguments":""}}
|
|
446
|
+
// {"index":0,"id":null, "function":{"name":null, "arguments":"{"}}
|
|
447
|
+
// 用 `!== undefined` 挡不住 null:它既会把首帧的 "bash"/"call_abc" **冲成
|
|
448
|
+
// null**(工具名丢失),又会把 `name: null` 原样发进 StreamChunk,而
|
|
449
|
+
// dsh-llm 的校验是 `typeof chunk.name !== 'string'`(Object.hasOwn 先判存在)
|
|
450
|
+
// → 抛 `TypeError: tool-call-delta name must be a string` →「本轮运行失败」。
|
|
451
|
+
// 判据与核心 aggregateSSE(非流式聚合路径)保持一致,两条路不能有分歧。
|
|
452
|
+
if (typeof call.id === 'string' && call.id !== '') block.callId = call.id
|
|
453
|
+
if (typeof call.function?.name === 'string' && call.function.name !== '') block.name = call.function.name
|
|
454
|
+
// arguments 同理只认字符串:`?? ''` 挡得住 null/undefined,但挡不住
|
|
455
|
+
// 上游偶发把参数发成对象/数字——那会撞 dsh-llm 的同一条校验。
|
|
456
|
+
const fragment = typeof call.function?.arguments === 'string' ? call.function.arguments : ''
|
|
445
457
|
block.text += fragment
|
|
446
458
|
yield {
|
|
447
459
|
type: 'tool-call-delta',
|
|
@@ -198,3 +198,53 @@ test('聚合失败:reader 必须被解绑(否则连接还活着时上层无
|
|
|
198
198
|
assert.equal(releaseLocked, true, '聚合失败后必须 releaseLock,否则流锁死、上层无法回收')
|
|
199
199
|
assert.equal(failing.locked, false, '流必须处于未锁定状态(可被 cancel / 已被回收)')
|
|
200
200
|
})
|
|
201
|
+
|
|
202
|
+
test('fixFrame:把后续 delta 里重发成 null 的 id/name 剥掉(否则外部 OpenAI 客户端会丢工具名)', async () => {
|
|
203
|
+
// 为什么这一层也得修(adapter 的修复保护不到它):`/v1/chat/completions` 是
|
|
204
|
+
// **对外**的 OpenAI 兼容端点,外部客户端(Cline/Cursor/其它工具)直接读这条流。
|
|
205
|
+
// 上游 OpenCode/Zen 在工具调用的后续分片帧里会把首帧已给过的 id/name 显式重发成
|
|
206
|
+
// null——外部客户端普遍按 `name !== undefined` 判「有没有新值」,于是把已经拿到的
|
|
207
|
+
// "bash" 冲成 null,表现为 `unknown tool ""`。fixFrame 是核心的**线上归一化层**,
|
|
208
|
+
// 职责就是抹掉这类不合规的空值(空串早就抹了,null 语义相同)。
|
|
209
|
+
const enc = new TextEncoder()
|
|
210
|
+
const sse =
|
|
211
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"bash","arguments":""}}]}}]}\n\n' +
|
|
212
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":null,"type":"function","function":{"name":null,"arguments":"{"}}]}}]}\n\n' +
|
|
213
|
+
'data: [DONE]\n\n'
|
|
214
|
+
const upstream = new ReadableStream<Uint8Array>({
|
|
215
|
+
start(ctrl) { ctrl.enqueue(enc.encode(sse)); ctrl.close() },
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const router = new Router('')
|
|
219
|
+
router.add(supplierWith(upstream) as never)
|
|
220
|
+
|
|
221
|
+
const server = http.createServer((req, res) => {
|
|
222
|
+
let body = ''
|
|
223
|
+
req.on('data', (c) => { body += c })
|
|
224
|
+
req.on('end', () => {
|
|
225
|
+
void router.chatCompletions(
|
|
226
|
+
{ model: 'sup,m1', stream: true, rawBody: body } as never,
|
|
227
|
+
res as never,
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
|
|
232
|
+
const port = (server.address() as { port: number }).port
|
|
233
|
+
try {
|
|
234
|
+
const resp = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({ model: 'sup,m1', messages: [{ role: 'user', content: 'hi' }], stream: true }),
|
|
238
|
+
})
|
|
239
|
+
const text = await resp.text()
|
|
240
|
+
// 第一帧给的 "bash"/"call_abc" 必须还在
|
|
241
|
+
assert.ok(text.includes('"name":"bash"'), `首帧的工具名丢了:${text.slice(0, 200)}`)
|
|
242
|
+
assert.ok(text.includes('"id":"call_abc"'), `首帧的 id 丢了:${text.slice(0, 200)}`)
|
|
243
|
+
// 后续帧的 null 必须被剥掉(不能出现在线上字节里)
|
|
244
|
+
assert.equal(/"name":null/.test(text), false, `null 的 name 泄漏到了线上:${text.slice(0, 200)}`)
|
|
245
|
+
assert.equal(/"id":null/.test(text), false, `null 的 id 泄漏到了线上:${text.slice(0, 200)}`)
|
|
246
|
+
} finally {
|
|
247
|
+
server.closeAllConnections?.()
|
|
248
|
+
server.close()
|
|
249
|
+
}
|
|
250
|
+
})
|
package/src/router/index.ts
CHANGED
|
@@ -296,7 +296,16 @@ function isDoneFrame(frame: string): boolean {
|
|
|
296
296
|
return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame)
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* 修一帧 SSE:剥掉**空的**(`''` 或 `null`)name/id,出任何问题都原样返回。
|
|
301
|
+
*
|
|
302
|
+
* 为什么 `null` 也算空:上游(OpenCode/Zen)在后续 delta 里会把首帧已给过的
|
|
303
|
+
* id/name 显式重发成 `null`——`{"id":null,"function":{"name":null,"arguments":"{"}}`。
|
|
304
|
+
* 下游判「有没有新值」用的是 `name !== undefined`(dsh 的 BlockAssembler 就是),
|
|
305
|
+
* null 过不了这个判断、却也不该覆盖首帧:客户端会把已拿到的 "bash" 冲成 null,
|
|
306
|
+
* 工具名丢失(表现为 `unknown tool ""`)。空串早就剥了,null 语义上同样是
|
|
307
|
+
* 「本帧没给新值」,一并剥掉。
|
|
308
|
+
*/
|
|
300
309
|
function fixFrame(frame: string): string {
|
|
301
310
|
// 绝大多数帧没有 tool_calls,先廉价字符串筛查,避免无谓的 JSON 解析
|
|
302
311
|
if (!frame.includes('tool_calls')) return frame
|
|
@@ -314,9 +323,13 @@ function fixFrame(frame: string): string {
|
|
|
314
323
|
const tcs = delta?.tool_calls as Array<Record<string, unknown>> | undefined
|
|
315
324
|
if (Array.isArray(tcs)) {
|
|
316
325
|
for (const tc of tcs) {
|
|
317
|
-
|
|
326
|
+
// 空串与 null 都是「本帧没给新值」,剥掉让下游沿用首帧那个
|
|
327
|
+
if (tc.id === '' || tc.id === null) { delete tc.id; changed = true }
|
|
318
328
|
const fn = tc.function as Record<string, unknown> | undefined
|
|
319
|
-
if (fn !== null && typeof fn === 'object' && fn.name === ''
|
|
329
|
+
if (fn !== null && typeof fn === 'object' && (fn.name === '' || fn.name === null)) {
|
|
330
|
+
delete fn.name
|
|
331
|
+
changed = true
|
|
332
|
+
}
|
|
320
333
|
}
|
|
321
334
|
}
|
|
322
335
|
return changed ? `data: ${JSON.stringify(obj)}` : line
|