dsh-router-core 0.3.2 → 0.3.4
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 +6 -2
- package/docs/suppliers.md +87 -30
- package/lib/client-registry.js +20 -11
- package/lib/client.js +20 -11
- package/lib/index.js +751 -264
- 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 +28 -5
- package/lib/types/router/types.d.ts +0 -2
- package/lib/types/router/usage-store.d.ts +20 -3
- package/lib/types/shared.d.ts +2 -0
- package/lib/types/supplier-config.d.ts +23 -0
- package/lib/types/suppliers/contract.d.ts +9 -6
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +15 -10
- 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 +225 -66
- package/src/router/models-cache.test.ts +224 -6
- package/src/router/precise-dispatch.test.ts +110 -7
- package/src/router/types.ts +0 -2
- package/src/router/usage-store.ts +174 -74
- package/src/router/usage.test.ts +115 -0
- package/src/shared.ts +2 -0
- package/src/supplier-config.test.ts +98 -0
- package/src/supplier-config.ts +60 -1
- package/src/suppliers/contract.ts +9 -9
- package/src/suppliers/loader.test.ts +118 -0
- package/src/suppliers/loader.ts +21 -5
- package/src/suppliers/nvidia/plugin.ts +12 -15
- package/src/suppliers/opencode/plugin.ts +10 -13
- package/src/suppliers/openrouter/plugin.ts +12 -15
- package/src/suppliers/registry.test.ts +23 -3
- package/src/suppliers/registry.ts +11 -3
package/src/llm/adapter.test.ts
CHANGED
|
@@ -11,8 +11,21 @@
|
|
|
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
15
|
import { toTokenUsage } from '../router/usage-tokens.ts'
|
|
15
|
-
import { translateSse } from './adapter.ts'
|
|
16
|
+
import { RouterAdapter, translateSse } from './adapter.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* dsh-llm 默认可重试的错误码白名单(DEFAULT_RETRYABLE_CODES)。
|
|
20
|
+
*
|
|
21
|
+
* 这里**故意写死一份**而不是从依赖导入:白名单是重试能不能生效的判据,
|
|
22
|
+
* 从 dsh-llm 导入的话,依赖升级改了白名单,这条测试会跟着一起变绿,
|
|
23
|
+
* 而我们真正要锁的是「线上那份 policyKey 里的码」。
|
|
24
|
+
* 来源:线上会话日志 llm/retry 的 policyKey —— ["EMPTY_RESPONSE","RATE_LIMIT",
|
|
25
|
+
* "SERVER","TIMEOUT","TRANSPORT"]。若将来确实变了,同步改这里,并确认
|
|
26
|
+
* adapter 用的码还在新名单里。
|
|
27
|
+
*/
|
|
28
|
+
const RETRYABLE_CODES = ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']
|
|
16
29
|
|
|
17
30
|
/** 跑一遍 SSE 载荷序列,返回产出的 usage chunk(没有则 undefined)。 */
|
|
18
31
|
async function usageOf(...payloads: string[]): Promise<Record<string, unknown> | undefined> {
|
|
@@ -106,3 +119,393 @@ test('出口:上游压根不发 usage 时不硬凑 usage chunk', async () => {
|
|
|
106
119
|
const sse = frame({ choices: [{ delta: { content: 'x' }, finish_reason: 'stop' }] })
|
|
107
120
|
assert.equal(await usageOf(sse, '[DONE]'), undefined)
|
|
108
121
|
})
|
|
122
|
+
|
|
123
|
+
/* ---------------- finish reason ---------------- */
|
|
124
|
+
|
|
125
|
+
/** 跑一遍 SSE 载荷序列,返回 finish chunk 的 reason。 */
|
|
126
|
+
async function finishOf(...payloads: string[]): Promise<Record<string, unknown> | undefined> {
|
|
127
|
+
for await (const chunk of translateSse(payloads)) {
|
|
128
|
+
if (chunk.type === 'finish') return chunk.reason as unknown as Record<string, unknown>
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('出口:中间帧的 finish_reason:"" 不是终止原因,不能变成 error finish', async () => {
|
|
134
|
+
// CodeBuddy 上游真实形态:首帧就带 finish_reason:""(意思是还没有终止原因)。
|
|
135
|
+
// 当真原因收下会产出 code:"" 的 error finish,agent-loop 拿它重建 LlmError
|
|
136
|
+
// 时 dsh-llm 直接抛 `LlmError code must be a non-empty string`,用户看到
|
|
137
|
+
// 「本轮运行失败 … UNKNOWN」。空串必须被忽略,落到默认 stop。
|
|
138
|
+
const reason = await finishOf(frame({ choices: [{ delta: { content: 'hi' }, finish_reason: '' }] }), '[DONE]')
|
|
139
|
+
assert.deepEqual(reason, { kind: 'stop' })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('出口:空的 finish_reason 不许冲掉后面那个真终止原因', async () => {
|
|
143
|
+
// 同一条流里先 "" 后 "tool_calls"(models-cache.test.ts 里的真实帧序)
|
|
144
|
+
const reason = await finishOf(
|
|
145
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{}' } }] }, finish_reason: '' }] }),
|
|
146
|
+
frame({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
147
|
+
'[DONE]',
|
|
148
|
+
)
|
|
149
|
+
assert.deepEqual(reason, { kind: 'tool-calls' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('出口:未知终止原因必须带非空 code(agent-loop 会拿它重建 LlmError)', async () => {
|
|
153
|
+
const reason = await finishOf(frame({ choices: [{ delta: { content: 'x' }, finish_reason: 'content_filter' }] }), '[DONE]')
|
|
154
|
+
assert.equal(reason?.kind, 'error')
|
|
155
|
+
const failure = reason?.failure as { message: string; code: string }
|
|
156
|
+
assert.equal(failure.code, 'CONTENT_FILTER')
|
|
157
|
+
// 空白原因也必须有码 —— 空码会让 dsh-llm 抛「本轮运行失败」
|
|
158
|
+
const blank = await finishOf(frame({ choices: [{ delta: { content: 'x' }, finish_reason: ' ' }] }), '[DONE]')
|
|
159
|
+
const blankCode = (blank?.failure as { code: string }).code
|
|
160
|
+
assert.ok(typeof blankCode === 'string' && blankCode.length > 0, `code 必须非空,实际 ${JSON.stringify(blankCode)}`)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
/* ---------------- tool-call 参数完整性 ---------------- */
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 为什么要有这一组:上游(CodeBuddy 网关,长上下文/高压时)会在 tool_call
|
|
167
|
+
* 的 arguments 分片**还没发完**就直接发 `[DONE]`。adapter 原本在 `[DONE]`
|
|
168
|
+
* 时无条件收尾所有 block,把半成品 JSON(如 `{"command": "cd`)当完整参数
|
|
169
|
+
* 交给 harness —— 工具侧校验报 `missing required property "command"` /
|
|
170
|
+
* `"arguments" must be an object`,表现为「bash 调用大面积失败」。
|
|
171
|
+
* 实测一次会话里 29 次工具调用有 27 次参数是残缺的。
|
|
172
|
+
*
|
|
173
|
+
* 所以这里锁死:**残缺参数绝不能当有效调用交出去**。
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
/** 跑一遍 SSE,返回 finish chunk 的 reason。 */
|
|
177
|
+
async function toolCallsOf(...payloads: string[]): Promise<Array<{ name?: string; arguments?: string }>> {
|
|
178
|
+
const out: Array<{ name?: string; arguments?: string }> = []
|
|
179
|
+
for await (const chunk of translateSse(payloads)) {
|
|
180
|
+
if (chunk.type === 'block-end' && (chunk.block as { type?: string }).type === 'tool-call') {
|
|
181
|
+
out.push(chunk.block as { name?: string; arguments?: string })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
test('工具调用:参数分片发完才收尾,完整 JSON 原样保留', async () => {
|
|
188
|
+
const calls = await toolCallsOf(
|
|
189
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'bash', arguments: '' } }] } }] }),
|
|
190
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"command": "ls' } }] } }] }),
|
|
191
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ' -la"}' } }] } }] }),
|
|
192
|
+
frame({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
193
|
+
'[DONE]',
|
|
194
|
+
)
|
|
195
|
+
assert.equal(calls.length, 1)
|
|
196
|
+
assert.equal(calls[0]?.arguments, '{"command": "ls -la"}')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('工具调用:参数残缺时不产出可执行的调用(补发空收尾覆盖半成品)', async () => {
|
|
200
|
+
// 真实故障形态:首片开块,[DONE] 就来了,参数停在 `{"command": "cd`
|
|
201
|
+
const calls = await toolCallsOf(
|
|
202
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'bash', arguments: '{"command": "cd' } }] } }] }),
|
|
203
|
+
'[DONE]',
|
|
204
|
+
)
|
|
205
|
+
// 注意:产出的是**占位收尾**(空名 + `{}`),不是「不产出」。
|
|
206
|
+
// 只跳过 block-end 是拦不住的 —— BlockAssembler 会用累积 delta 重建出
|
|
207
|
+
// 半成品(见下面那条 BlockAssembler 级别的用例)。必须主动覆盖。
|
|
208
|
+
const dangerous = calls.filter((c) => {
|
|
209
|
+
try { JSON.parse(c.arguments ?? ''); return false } catch { return true }
|
|
210
|
+
})
|
|
211
|
+
assert.equal(dangerous.length, 0, `不能产出参数残缺的调用:${JSON.stringify(calls)}`)
|
|
212
|
+
assert.equal(calls[0]?.arguments, '{}', '残缺调用应被覆盖成空参数')
|
|
213
|
+
assert.equal(calls[0]?.name, '', '名字也要清空,避免被当成真调用')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('工具调用:参数残缺时整轮判 error(agent-loop 好重试,而不是拿着残参执行)', async () => {
|
|
217
|
+
const reason = await finishOf(
|
|
218
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'bash', arguments: '{"command": "cd' } }] } }] }),
|
|
219
|
+
'[DONE]',
|
|
220
|
+
)
|
|
221
|
+
assert.equal(reason?.kind, 'error')
|
|
222
|
+
const failure = reason?.failure as { message: string; code: string }
|
|
223
|
+
assert.ok(typeof failure.code === 'string' && failure.code.length > 0, 'error code 必须非空')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('工具调用:残缺的 error code 必须在默认可重试白名单里', async () => {
|
|
227
|
+
// 这是最容易踩的坑:造一个「看起来更精确」的新码(INCOMPLETE_TOOL_ARGS)
|
|
228
|
+
// 不在 dsh-llm 的默认 retryableCodes 里,结果**一次都不重试**,直接抛给
|
|
229
|
+
// 用户 —— 正好毁掉「判 error 好让 agent-loop 重试」的本意。
|
|
230
|
+
// 白名单来自线上会话日志的 policyKey;产出为空/不完整 → EMPTY_RESPONSE 最贴。
|
|
231
|
+
const reason = await finishOf(
|
|
232
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'bash', arguments: '{"command": "cd' } }] } }] }),
|
|
233
|
+
'[DONE]',
|
|
234
|
+
)
|
|
235
|
+
const code = (reason?.failure as { code: string }).code
|
|
236
|
+
assert.ok(RETRYABLE_CODES.includes(code), `code ${code} 不在可重试白名单 ${JSON.stringify(RETRYABLE_CODES)} 里,残缺参数将永不重试`)
|
|
237
|
+
assert.equal(code, EMPTY_RESPONSE_CODE)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('工具调用:整块没收到任何参数是空对象,不算残缺', async () => {
|
|
241
|
+
// 无参工具(如 checkinNow)合法:arguments 为空串,应补成 {}
|
|
242
|
+
const calls = await toolCallsOf(
|
|
243
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'ping', arguments: '' } }] } }] }),
|
|
244
|
+
frame({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
245
|
+
'[DONE]',
|
|
246
|
+
)
|
|
247
|
+
assert.equal(calls.length, 1)
|
|
248
|
+
assert.equal(calls[0]?.arguments, '{}')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
/* ---------------- 真流式:边收边吐,不攒完再放 ---------------- */
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 为什么要有这一组:stream() 曾用 `await resp.text()` 把整个响应**攒成
|
|
255
|
+
* 一个字符串**再解析,等于把流式降级成批处理:
|
|
256
|
+
*
|
|
257
|
+
* 1. 首字节延迟 = 整个响应耗时(CodeBuddy 长响应几十秒),用户干等;
|
|
258
|
+
* 2. 中途断流 → resp.text() 抛异常或只拿到半截文本,最后一帧是半截 JSON
|
|
259
|
+
* (`{"command": "cd`)——这正是「bash 参数残缺」的根因。本地抓包
|
|
260
|
+
* 永远复现不了,因为短响应连接不断;长响应才会断。
|
|
261
|
+
*
|
|
262
|
+
* 9router 的 copilot 通道只做字节级透传(pipeSSE,23 行,不解析 SSE),
|
|
263
|
+
* 上游发什么客户端收什么,天然没有这个问题。dsh-router 必须在中间做
|
|
264
|
+
* 协议转换,所以**必须自己保证是增量的**。
|
|
265
|
+
*
|
|
266
|
+
* 这里锁死:上游还在慢慢发时,下游必须已经收到前面的块。
|
|
267
|
+
*/
|
|
268
|
+
|
|
269
|
+
/** 造一个「先发一帧、再等 signal、再发 [DONE]」的 SSE 响应体。 */
|
|
270
|
+
function slowBody(): { stream: ReadableStream<Uint8Array>; resume: () => void; sent: () => boolean } {
|
|
271
|
+
const enc = new TextEncoder()
|
|
272
|
+
let resume = (): void => {}
|
|
273
|
+
const gate = new Promise<void>((r) => { resume = r })
|
|
274
|
+
let sent = false
|
|
275
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
276
|
+
async pull(controller) {
|
|
277
|
+
if (!sent) {
|
|
278
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: 'first' } }] })}\n\n`))
|
|
279
|
+
sent = true
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
await gate
|
|
283
|
+
controller.enqueue(enc.encode('data: [DONE]\n\n'))
|
|
284
|
+
controller.close()
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
return { stream, resume, sent: () => sent }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
test('流式:上游还没发完,下游必须已经拿到前面的块(不能攒完再放)', async () => {
|
|
291
|
+
const { stream, resume } = slowBody()
|
|
292
|
+
globalThis.fetch = (async () => new Response(stream, { status: 200 })) as typeof fetch
|
|
293
|
+
|
|
294
|
+
const adapter = new RouterAdapter('http://x', { comboModels: async () => [] })
|
|
295
|
+
const seen: Array<{ type: string }> = []
|
|
296
|
+
const gen = adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(5000) } as never)
|
|
297
|
+
// 只取第一块:如果实现是「await resp.text() 攒完再解析」,这里会一直
|
|
298
|
+
// 挂到上游关闭(gate 永不放行)→ 超时。真流式则立刻拿到 'first'。
|
|
299
|
+
for await (const chunk of gen) {
|
|
300
|
+
seen.push(chunk as { type: string })
|
|
301
|
+
if (seen.length >= 2) break // block-start + text-delta
|
|
302
|
+
}
|
|
303
|
+
resume()
|
|
304
|
+
assert.ok(
|
|
305
|
+
seen.some((c) => c.type === 'text-delta'),
|
|
306
|
+
`上游未发完就应收到 text-delta,实际收到:${JSON.stringify(seen.map((c) => c.type))}`,
|
|
307
|
+
)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('流式:断流(无 [DONE])抛的 code 必须在可重试白名单里', async () => {
|
|
311
|
+
// 同上一条白名单约束的另一处踩坑点:断流是**最该重试**的故障(半截响应、
|
|
312
|
+
// 连接被切),若用自造码(STREAM_CLOSED)不在白名单里就永不重试。
|
|
313
|
+
const sse = `data: ${JSON.stringify({ choices: [{ delta: { content: 'a' } }] })}\n\n`
|
|
314
|
+
globalThis.fetch = (async () => new Response(new TextEncoder().encode(sse), { status: 200 })) as typeof fetch
|
|
315
|
+
const adapter = new RouterAdapter('http://x', { comboModels: async () => [] })
|
|
316
|
+
let code = ''
|
|
317
|
+
try {
|
|
318
|
+
for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000) } as never)) void _c
|
|
319
|
+
} catch (e) {
|
|
320
|
+
code = (e as { code?: string }).code ?? ''
|
|
321
|
+
}
|
|
322
|
+
assert.ok(RETRYABLE_CODES.includes(code), `断流 code ${JSON.stringify(code)} 不在白名单 ${JSON.stringify(RETRYABLE_CODES)} 里`)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('流式:CRLF 换行也能正确收尾(不能把 \\r 当成帧内容)', async () => {
|
|
326
|
+
const sse = `data: ${JSON.stringify({ choices: [{ delta: { content: 'a' } }] })}\r\n\r\ndata: [DONE]\r\n\r\n`
|
|
327
|
+
globalThis.fetch = (async () => new Response(new TextEncoder().encode(sse), { status: 200 })) as typeof fetch
|
|
328
|
+
const adapter = new RouterAdapter('http://x', { comboModels: async () => [] })
|
|
329
|
+
const types: string[] = []
|
|
330
|
+
for await (const c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000) } as never)) {
|
|
331
|
+
types.push(c.type)
|
|
332
|
+
}
|
|
333
|
+
assert.ok(types.includes('finish'), `CRLF 流必须以 finish 收尾,实际 ${JSON.stringify(types)}`)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('流式:一帧横跨两次 read() 也能拼回来(半包)', async () => {
|
|
337
|
+
const enc = new TextEncoder()
|
|
338
|
+
const half = 'data: {"choices":[{"delta":{"content":"hel'
|
|
339
|
+
const rest = 'lo"}}]}\n\ndata: [DONE]\n\n'
|
|
340
|
+
let sent = false
|
|
341
|
+
globalThis.fetch = (async () =>
|
|
342
|
+
new Response(
|
|
343
|
+
new ReadableStream<Uint8Array>({
|
|
344
|
+
pull(c) {
|
|
345
|
+
if (!sent) { sent = true; c.enqueue(enc.encode(half)) }
|
|
346
|
+
else { c.enqueue(enc.encode(rest)); c.close() }
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
{ status: 200 },
|
|
350
|
+
)) as typeof fetch
|
|
351
|
+
const adapter = new RouterAdapter('http://x', { comboModels: async () => [] })
|
|
352
|
+
const texts: string[] = []
|
|
353
|
+
for await (const c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000) } as never)) {
|
|
354
|
+
if (c.type === 'text-delta') texts.push((c as { text: string }).text)
|
|
355
|
+
}
|
|
356
|
+
assert.equal(texts.join(''), 'hello', '跨块的半帧必须拼成完整内容')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
/* ---------------- wire 请求构造(对照 9router 查出的缺陷) ---------------- */
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 为什么要有这一组:dsh-router 是「从结构化 options 重建 wire body」,
|
|
363
|
+
* 9router 是「客户端 body 原样透传」——前者的字段漏带不会报错,只会
|
|
364
|
+
* 悄悄降级,所以必须逐条锁死。这几条都是对照 9router 查出来的。
|
|
365
|
+
*/
|
|
366
|
+
|
|
367
|
+
/** 拦下 stream() 发给上游的 body,返回解析后的对象。 */
|
|
368
|
+
async function captureBody(options: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
369
|
+
let captured: Record<string, unknown> = {}
|
|
370
|
+
globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
|
|
371
|
+
captured = JSON.parse(init?.body ?? '{}') as Record<string, unknown>
|
|
372
|
+
return new Response('data: [DONE]\n\n', { status: 200 })
|
|
373
|
+
}) as typeof fetch
|
|
374
|
+
const adapter = new RouterAdapter('http://x', { comboModels: async () => [] })
|
|
375
|
+
for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000), ...options } as never)) {
|
|
376
|
+
void _c
|
|
377
|
+
}
|
|
378
|
+
return captured
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
test('wire:options.system 必须发给上游(agent-loop 走 system 槽位,不放 messages)', async () => {
|
|
382
|
+
// dsh-llm 契约:system 是**独立槽位**(types.d.ts "adapters map to the
|
|
383
|
+
// provider's system slot"),agent-loop 把它放 options.system。不读它,
|
|
384
|
+
// 模型每轮都拿不到身份/规则/工具用法约束,且不报错——最难查的一类。
|
|
385
|
+
const body = await captureBody({ system: '你是一个助手', messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] })
|
|
386
|
+
const msgs = body.messages as Array<{ role: string; content: string }>
|
|
387
|
+
assert.equal(msgs[0]?.role, 'system')
|
|
388
|
+
assert.equal(msgs[0]?.content, '你是一个助手', 'system 必须是消息列表的第一条')
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test('wire:没给 system 时不塞空的 system 消息', async () => {
|
|
392
|
+
const body = await captureBody({ messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] })
|
|
393
|
+
const msgs = body.messages as Array<{ role: string }>
|
|
394
|
+
assert.equal(msgs.some((m) => m.role === 'system'), false)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('wire:reasoningEffort 要补 reasoning_summary:auto(CodeBuddy 只在两者齐备时吐推理)', async () => {
|
|
398
|
+
const body = await captureBody({ reasoningEffort: 'high', messages: [] })
|
|
399
|
+
assert.equal(body.reasoning_effort, 'high')
|
|
400
|
+
assert.equal(body.reasoning_summary, 'auto', '缺它 CodeBuddy 不吐推理内容')
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test('wire:reasoningEffort 为 none/off 时删字段,且无请求时不硬加(否则触发内容过滤)', async () => {
|
|
404
|
+
// 9router #2071:对普通请求强加 reasoning_effort+summary 会让 CodeBuddy
|
|
405
|
+
// 触发内容过滤报错;"none" 网关不认,必须是删字段。
|
|
406
|
+
const none = await captureBody({ reasoningEffort: 'none', messages: [] })
|
|
407
|
+
assert.equal('reasoning_effort' in none, false, 'none 要删字段,不能传字符串')
|
|
408
|
+
assert.equal('reasoning_summary' in none, false)
|
|
409
|
+
const plain = await captureBody({ messages: [] })
|
|
410
|
+
assert.equal('reasoning_effort' in plain, false, '没要推理就不能加')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('wire:空工具结果发空串,不伪造 (no output) 字面量', async () => {
|
|
414
|
+
// 模型会以为工具真打印了那句话 —— 对「bash 无输出」是误导。
|
|
415
|
+
const body = await captureBody({
|
|
416
|
+
messages: [{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [] }] }],
|
|
417
|
+
})
|
|
418
|
+
const tool = (body.messages as Array<{ role: string; content: string }>).find((m) => m.role === 'tool')
|
|
419
|
+
assert.equal(tool?.content, '', '空结果就该是空串')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 为什么要有这一条:只不发 `block-end` **并不能**拦住残缺调用。
|
|
424
|
+
*
|
|
425
|
+
* dsh-llm 的 BlockAssembler 对没有 block-end 的 index 会用累积的 delta
|
|
426
|
+
* **兜底重建**(lib/types/assembler.js)。实测:我们跳过了 block-end,
|
|
427
|
+
* 它照样组装出 `{"type":"tool-call","name":"bash","arguments":"{\"command\": \"rm -rf /"}`。
|
|
428
|
+
* 而 assembled() 只在 finish.kind === 'max-tokens' 时过滤 tool-call ——
|
|
429
|
+
* EMPTY_RESPONSE 的 error finish 不在过滤之列。
|
|
430
|
+
*
|
|
431
|
+
* 所以要拦住,必须**主动补发一个安全的 block-end**(空名 + `{}`),
|
|
432
|
+
* 让它覆盖掉 delta 累积出来的半成品。
|
|
433
|
+
*/
|
|
434
|
+
test('工具调用:残缺调用必须被 BlockAssembler 也判定为不可执行', async () => {
|
|
435
|
+
const { BlockAssembler } = await import('@deepseek-ai/dsh-llm')
|
|
436
|
+
const frames = [
|
|
437
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command": "rm -rf /' } }] } }] }),
|
|
438
|
+
'[DONE]',
|
|
439
|
+
]
|
|
440
|
+
const asm = new BlockAssembler()
|
|
441
|
+
for await (const c of translateSse(frames)) asm.push(c)
|
|
442
|
+
const assembled = (asm as unknown as { assembled: () => { blocks: Array<Record<string, unknown>> } }).assembled()
|
|
443
|
+
const blocks = assembled.blocks ?? []
|
|
444
|
+
const dangerous = blocks.filter((b) => {
|
|
445
|
+
if (b.type !== 'tool-call') return false
|
|
446
|
+
const args = typeof b.arguments === 'string' ? b.arguments : JSON.stringify(b.arguments)
|
|
447
|
+
try { JSON.parse(args); return false } catch { return true } // 解析不了 = 半成品
|
|
448
|
+
})
|
|
449
|
+
assert.equal(dangerous.length, 0, `不能组装出参数残缺的调用:${JSON.stringify(blocks)}`)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('流式:消费者提前退出必须 cancel 上游(releaseLock 不关 socket)', async () => {
|
|
453
|
+
// releaseLock() 只解除 JS 侧 reader 绑定,不通知传输层 —— 上游那条连接
|
|
454
|
+
// 会一直挂着。dsh-llm 在消费者提前退出时必走 iterator.return(),长会话
|
|
455
|
+
// 下每条中断泄漏一个 fd。
|
|
456
|
+
let cancelled = false
|
|
457
|
+
const body = new ReadableStream<Uint8Array>({
|
|
458
|
+
pull(c) { c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"a"}}]}\n\n')) },
|
|
459
|
+
cancel() { cancelled = true },
|
|
460
|
+
})
|
|
461
|
+
const { RouterAdapter: RA } = await import('./adapter.ts')
|
|
462
|
+
globalThis.fetch = (async () => new Response(body, { status: 200 })) as typeof fetch
|
|
463
|
+
const adapter = new RA('http://x', { comboModels: async () => [] })
|
|
464
|
+
for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000) } as never)) {
|
|
465
|
+
void _c
|
|
466
|
+
break // 提前退出
|
|
467
|
+
}
|
|
468
|
+
await new Promise((r) => setTimeout(r, 50)) // 等 finally 里的 cancel 落地
|
|
469
|
+
assert.equal(cancelled, true, '必须 cancel 上游,否则 socket 泄漏')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('流式:单个坏帧跳过,不毁掉整轮(对齐 9router 的容错)', async () => {
|
|
473
|
+
const enc = new TextEncoder()
|
|
474
|
+
const sse = `data: {"choices":[{"delta":{"content":"good"}}]}\n\ndata: {broken json\n\ndata: [DONE]\n\n`
|
|
475
|
+
globalThis.fetch = (async () => new Response(enc.encode(sse), { status: 200 })) as typeof fetch
|
|
476
|
+
const { RouterAdapter: RA } = await import('./adapter.ts')
|
|
477
|
+
const adapter = new RA('http://x', { comboModels: async () => [] })
|
|
478
|
+
const texts: string[] = []
|
|
479
|
+
for await (const c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000) } as never)) {
|
|
480
|
+
if (c.type === 'text-delta') texts.push((c as { text: string }).text)
|
|
481
|
+
}
|
|
482
|
+
assert.equal(texts.join(''), 'good', '好帧必须保住,不能因为一个坏帧全废')
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
test('流式:只有坏帧时才按断流处理,且 code 可重试', async () => {
|
|
486
|
+
const enc = new TextEncoder()
|
|
487
|
+
globalThis.fetch = (async () => new Response(enc.encode('data: {broken\n\n'), { status: 200 })) as typeof fetch
|
|
488
|
+
const { RouterAdapter: RA } = await import('./adapter.ts')
|
|
489
|
+
const adapter = new RA('http://x', { comboModels: async () => [] })
|
|
490
|
+
let code = ''
|
|
491
|
+
try {
|
|
492
|
+
for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000) } as never)) void _c
|
|
493
|
+
} catch (e) { code = (e as { code?: string }).code ?? '' }
|
|
494
|
+
assert.ok(RETRYABLE_CODES.includes(code), `code ${JSON.stringify(code)} 必须可重试`)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('流式:上游不发空行分隔时也要能分帧(前瞻完整 JSON)', async () => {
|
|
498
|
+
// 部分上游/中间件只发 \n 不发 \n\n。两帧会被拼成 `{...}\n{...}` 而 parse
|
|
499
|
+
// 失败。9router 逐行取天然没这问题,我们按空行分帧,就得补前瞻回退。
|
|
500
|
+
const enc = new TextEncoder()
|
|
501
|
+
const a = 'data: {"choices":[{"delta":{"content":"A"}}]}\n'
|
|
502
|
+
const b = 'data: {"choices":[{"delta":{"content":"B"}}]}\n\ndata: [DONE]\n\n'
|
|
503
|
+
globalThis.fetch = (async () => new Response(enc.encode(a + b), { status: 200 })) as typeof fetch
|
|
504
|
+
const { RouterAdapter: RA } = await import('./adapter.ts')
|
|
505
|
+
const adapter = new RA('http://x', { comboModels: async () => [] })
|
|
506
|
+
const texts: string[] = []
|
|
507
|
+
for await (const c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000) } as never)) {
|
|
508
|
+
if (c.type === 'text-delta') texts.push((c as { text: string }).text)
|
|
509
|
+
}
|
|
510
|
+
assert.equal(texts.join(''), 'AB', '两帧都要收到')
|
|
511
|
+
})
|