dsh-router-core 0.4.0 → 0.4.2
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 +174 -38
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +29 -6
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +939 -132
- package/lib/client.js +939 -132
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +838 -89
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -0
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-base-url.d.ts +31 -0
- package/lib/types/llm/adapter.d.ts +40 -4
- package/lib/types/router/index.d.ts +61 -8
- package/lib/types/shared.d.ts +12 -0
- package/lib/types/supplier-config.d.ts +15 -0
- package/lib/types/suppliers/contract.d.ts +35 -2
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -1
- package/package.json +39 -4
- package/src/client/ExtDetail.tsx +21 -22
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +41 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +490 -9
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -0
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-base-url.test.ts +129 -0
- package/src/host-base-url.ts +43 -0
- package/src/index.ts +46 -11
- package/src/llm/adapter.test.ts +101 -4
- package/src/llm/adapter.ts +89 -20
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +255 -35
- package/src/router/models-cache.test.ts +45 -0
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/shared.ts +12 -0
- package/src/supplier-config.ts +32 -1
- package/src/suppliers/contract.ts +54 -2
- package/src/suppliers/loader.test.ts +36 -2
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +102 -3
package/src/llm/adapter.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
|
|
9
9
|
* 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
|
|
10
10
|
* 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
|
|
11
|
-
* 图片字节从 `ctx.attachments`
|
|
11
|
+
* 图片字节从 `ctx.attachments` 读取。读不到时按稳定占位文本降级(不让整条消息失败),
|
|
12
|
+
* 但**必须留痕**:降级原因走 adapter 的 `log` 出口打到宿主 logger。曾经这里是
|
|
13
|
+
* 一个空 `catch {}`,字段改名导致的读图失败静默了跨两个内测版本(issue #8)。
|
|
12
14
|
*/
|
|
13
15
|
import {
|
|
14
16
|
EMPTY_RESPONSE_CODE,
|
|
@@ -31,15 +33,58 @@ import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../r
|
|
|
31
33
|
* 这一行为,不引入对 dsh-attachment 包编译期/运行期的依赖(它是宿主注入的
|
|
32
34
|
* devDependency)。本地声明与原包 `AttachmentStore.readImageRequest` 形状一致,
|
|
33
35
|
* 宿主注入真实 store 时天然满足。
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ **第二个参数是 `ImageRequestTarget`(`width` + `height` + `maxBytes`),
|
|
38
|
+
* 不是 0.1.5 时代的 `ImageRequestPolicy`(`maxPixels` + `maxBytes`)**。宿主从
|
|
39
|
+
* 0.1.6 起在 `validateTarget()` 里校验这三个字段,缺一个就抛
|
|
40
|
+
* `Image request width must be a positive integer`,读图整体失败(issue #8)。
|
|
41
|
+
* 这两个字段不是摆设:宿主的 `pipeline()` 按 **target 的长边**把图缩到该尺寸,
|
|
42
|
+
* 短边由它自己推 —— 所以这里传投影后的尺寸,就是「按预算给我这张图」。
|
|
34
43
|
*/
|
|
35
44
|
export interface RouterAttachmentStore {
|
|
36
45
|
readImageRequest(
|
|
37
46
|
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
38
|
-
|
|
47
|
+
target: { width: number; height: number; maxBytes: number },
|
|
39
48
|
signal?: AbortSignal,
|
|
40
49
|
): Promise<{ mediaType: string; data: Uint8Array }>
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** 送上游的请求图的像素预算(宽 × 高),沿用 0.1.5 时代 `maxPixels` 的意图。 */
|
|
53
|
+
const MAX_REQUEST_PIXELS = 64e4
|
|
54
|
+
/** 送上游的请求图的字节上限。 */
|
|
55
|
+
const MAX_REQUEST_BYTES = 1024 * 1024
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 把源图尺寸投影到「总像素 ≤ maxPixels」的请求目标(等比、只缩不放)。
|
|
59
|
+
*
|
|
60
|
+
* 为什么需要它:宿主 0.1.6 起要的是 `ImageRequestTarget`(width/height/maxBytes),
|
|
61
|
+
* 而「别把一张 12MP 手机照原样塞进对话」这个意图原本靠 `maxPixels` 表达。
|
|
62
|
+
* 宿主自己**只按 target 的长边**缩放、短边由它推,所以我们要做的只是给出一组
|
|
63
|
+
* 落在预算内的正整数;短边取 floor 即可(它只参与校验与缓存键,不决定出图尺寸)。
|
|
64
|
+
*
|
|
65
|
+
* 没有直接复用官方的 `requestImageDimensions`(`@deepseek-ai/dsh-attachment` 有导出):
|
|
66
|
+
* 本文件刻意不依赖那个包(见 `RouterAttachmentStore` 的说明 —— 它是宿主注入的
|
|
67
|
+
* devDependency,引入它等于给插件加一条编译期/运行期依赖)。这 8 行是纯函数、
|
|
68
|
+
* 有测试钉住;真要换成官方实现时,语义等价。
|
|
69
|
+
*/
|
|
70
|
+
export function projectImageDimensions(
|
|
71
|
+
width: number,
|
|
72
|
+
height: number,
|
|
73
|
+
maxPixels: number,
|
|
74
|
+
): { width: number; height: number } {
|
|
75
|
+
const pixels = width * height
|
|
76
|
+
// 尺寸缺失/非法时不投影:让宿主自己的校验去报错,别在这里造一个假尺寸掩盖问题
|
|
77
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
78
|
+
return { width, height }
|
|
79
|
+
}
|
|
80
|
+
if (pixels <= maxPixels) return { width, height }
|
|
81
|
+
const scale = Math.sqrt(maxPixels / pixels)
|
|
82
|
+
return {
|
|
83
|
+
width: Math.max(1, Math.floor(width * scale)),
|
|
84
|
+
height: Math.max(1, Math.floor(height * scale)),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
43
88
|
/** 一张 image 块的 attachment ref 的最小形状(value 类型,够用即可)。 */
|
|
44
89
|
interface RouterImgLike {
|
|
45
90
|
attachmentId: string
|
|
@@ -96,7 +141,7 @@ const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
|
|
|
96
141
|
*/
|
|
97
142
|
const STREAM_IDLE_TIMEOUT_MS = 300_000
|
|
98
143
|
|
|
99
|
-
async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
|
|
144
|
+
async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined, log?: (msg: string) => void): Promise<Array<Record<string, unknown>>> {
|
|
100
145
|
const out: Array<Record<string, unknown>> = []
|
|
101
146
|
/**
|
|
102
147
|
* 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
|
|
@@ -157,7 +202,7 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
|
|
|
157
202
|
// 图片不能放进 tool 消息(实测模型会把两张不同图认成同一张),也不能插在两条 tool
|
|
158
203
|
// 之间(同样 400),所以先攒进 pending,等整串 tool 发完再补一条 user(图)。
|
|
159
204
|
const toolCallId = (message as { toolCallId?: unknown }).toolCallId
|
|
160
|
-
const parts = await contentParts(message.content, attachments, options.signal)
|
|
205
|
+
const parts = await contentParts(message.content, attachments, options.signal, log)
|
|
161
206
|
const imageParts = parts.filter((p) => p.type !== 'text')
|
|
162
207
|
const text = parts
|
|
163
208
|
.filter((p) => p.type === 'text')
|
|
@@ -176,6 +221,7 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
|
|
|
176
221
|
message.content.filter((b) => b.type !== 'tool-result'),
|
|
177
222
|
attachments,
|
|
178
223
|
options.signal,
|
|
224
|
+
log,
|
|
179
225
|
)
|
|
180
226
|
// 有实质 user 内容(或压根没有 tool 结果)就先发这条 user 消息;
|
|
181
227
|
// 只有 tool 结果的「纯工具」消息不发空 user,图片留到 pending 里合并。
|
|
@@ -189,7 +235,7 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
|
|
|
189
235
|
// 11148「tool calls and tool results do not match」。
|
|
190
236
|
// 图片不能放进 tool 消息(实测模型会把两张不同的图认成同一张),
|
|
191
237
|
// 也不能插在两条 tool 之间(同样 400),所以先攒起来、发完整串再补一条 user。
|
|
192
|
-
const parts = await contentParts(result.content, attachments, options.signal)
|
|
238
|
+
const parts = await contentParts(result.content, attachments, options.signal, log)
|
|
193
239
|
const imageParts = parts.filter((p) => p.type !== 'text')
|
|
194
240
|
const text = parts.filter((p) => p.type === 'text').map((p) => (p as { text?: string }).text ?? '').join('')
|
|
195
241
|
// 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
|
|
@@ -224,6 +270,7 @@ async function contentParts(
|
|
|
224
270
|
blocks: ReadonlyArray<{ type: string }>,
|
|
225
271
|
attachments: RouterAttachmentStore | undefined,
|
|
226
272
|
signal: AbortSignal | undefined,
|
|
273
|
+
log?: (msg: string) => void,
|
|
227
274
|
): Promise<Array<Record<string, unknown>>> {
|
|
228
275
|
const parts: Array<Record<string, unknown>> = []
|
|
229
276
|
for (const block of blocks) {
|
|
@@ -234,11 +281,11 @@ async function contentParts(
|
|
|
234
281
|
}
|
|
235
282
|
if (block.type === 'image') {
|
|
236
283
|
const ref = (block as unknown as { attachment: RouterImgLike }).attachment
|
|
237
|
-
parts.push(...(await imageParts(ref, attachments, signal)))
|
|
284
|
+
parts.push(...(await imageParts(ref, attachments, signal, log)))
|
|
238
285
|
continue
|
|
239
286
|
}
|
|
240
287
|
if (block.type === 'tool-result') {
|
|
241
|
-
parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal)))
|
|
288
|
+
parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal, log)))
|
|
242
289
|
}
|
|
243
290
|
// 其它块(reasoning/tool-call)不属于 user content,忽略
|
|
244
291
|
}
|
|
@@ -250,16 +297,23 @@ async function imageParts(
|
|
|
250
297
|
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
251
298
|
attachments: RouterAttachmentStore | undefined,
|
|
252
299
|
signal: AbortSignal | undefined,
|
|
300
|
+
log?: (msg: string) => void,
|
|
253
301
|
): Promise<Array<Record<string, unknown>>> {
|
|
254
|
-
if (attachments === undefined)
|
|
302
|
+
if (attachments === undefined) {
|
|
303
|
+
log?.('图片附件 store 不可用(ctx.attachments 未注入),该图降级为占位文本')
|
|
304
|
+
return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
|
|
305
|
+
}
|
|
306
|
+
const ref0 = { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }
|
|
307
|
+
// 目标 = 源尺寸按 64e4 像素预算等比投影后的宽高 + 字节上限(0.1.6+ 的 target 形状)
|
|
308
|
+
const target = { ...projectImageDimensions(ref.width, ref.height, MAX_REQUEST_PIXELS), maxBytes: MAX_REQUEST_BYTES }
|
|
255
309
|
try {
|
|
256
|
-
const img = await attachments.readImageRequest(
|
|
257
|
-
{ attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height },
|
|
258
|
-
{ maxPixels: 64e4, maxBytes: 1024 * 1024 },
|
|
259
|
-
signal,
|
|
260
|
-
)
|
|
310
|
+
const img = await attachments.readImageRequest(ref0, target, signal)
|
|
261
311
|
return [{ type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` } }]
|
|
262
|
-
} catch {
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// 曾经这里是空 catch:宿主把请求目标从 maxPixels 换成 width/height 之后读图
|
|
314
|
+
// 全失败,图被静默换成占位文本,跨两个内测版本没被发现(issue #8)。降级仍然
|
|
315
|
+
// 保留(不能让整条消息失败),但原因必须出口。
|
|
316
|
+
log?.(`读取图片 ${ref.attachmentId} 失败,已降级为占位文本:${(err as Error).message}`)
|
|
263
317
|
return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
|
|
264
318
|
}
|
|
265
319
|
}
|
|
@@ -280,8 +334,8 @@ function imagePlaceholder(attachmentId: string): string {
|
|
|
280
334
|
}
|
|
281
335
|
|
|
282
336
|
/** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
|
|
283
|
-
async function wireRequest(options: GenerateOptions, attachments: RouterAttachmentStore | undefined): Promise<Record<string, unknown>> {
|
|
284
|
-
const messages = await wireMessages(options, options.system, attachments)
|
|
337
|
+
async function wireRequest(options: GenerateOptions, attachments: RouterAttachmentStore | undefined, log?: (msg: string) => void): Promise<Record<string, unknown>> {
|
|
338
|
+
const messages = await wireMessages(options, options.system, attachments, log)
|
|
285
339
|
const body: Record<string, unknown> = {
|
|
286
340
|
model: options.model,
|
|
287
341
|
messages,
|
|
@@ -580,27 +634,38 @@ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
|
|
|
580
634
|
]
|
|
581
635
|
|
|
582
636
|
export class RouterAdapter extends LlmAdapter {
|
|
583
|
-
|
|
637
|
+
/**
|
|
638
|
+
* 网关端点。**函数形态 = 每次请求现算**:端口要等宿主 listen 完才有值的场景
|
|
639
|
+
* 必须传函数(写死 3080 在桌面端等非 3080 宿主上全线 `fetch failed`,
|
|
640
|
+
* issue #9),见 `host-base-url.ts`。
|
|
641
|
+
*/
|
|
642
|
+
private readonly baseURL: string | (() => string)
|
|
584
643
|
private readonly source: RouterAdapterSource
|
|
585
644
|
private readonly resolveAttachments: () => RouterAttachmentStore | undefined
|
|
586
645
|
/** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
|
|
587
646
|
private readonly idleTimeoutMs: number
|
|
647
|
+
/** 降级/失败诊断的出口(宿主接 ctx.logger.warn)。缺省丢弃 —— 仅为测试可注入。 */
|
|
648
|
+
private readonly log: ((msg: string) => void) | undefined
|
|
588
649
|
|
|
589
650
|
/**
|
|
590
651
|
* @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
|
|
591
652
|
* 可注入只为测试能压到几百毫秒,生产不传。
|
|
653
|
+
* @param log 降级诊断出口。图片读不到时会降级成占位文本,原因走这里 ——
|
|
654
|
+
* 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
|
|
592
655
|
*/
|
|
593
656
|
constructor(
|
|
594
|
-
baseURL: string,
|
|
657
|
+
baseURL: string | (() => string),
|
|
595
658
|
source: RouterAdapterSource,
|
|
596
659
|
resolveAttachments?: () => RouterAttachmentStore | undefined,
|
|
597
660
|
idleTimeoutMs: number = STREAM_IDLE_TIMEOUT_MS,
|
|
661
|
+
log?: (msg: string) => void,
|
|
598
662
|
) {
|
|
599
663
|
super()
|
|
600
664
|
this.baseURL = baseURL
|
|
601
665
|
this.source = source
|
|
602
666
|
this.resolveAttachments = resolveAttachments ?? (() => undefined)
|
|
603
667
|
this.idleTimeoutMs = idleTimeoutMs
|
|
668
|
+
this.log = log
|
|
604
669
|
}
|
|
605
670
|
|
|
606
671
|
providerInfo(provider: string): { id: string; name: string } {
|
|
@@ -647,13 +712,17 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
647
712
|
}
|
|
648
713
|
|
|
649
714
|
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
650
|
-
const body = await wireRequest(options, this.resolveAttachments())
|
|
715
|
+
const body = await wireRequest(options, this.resolveAttachments(), this.log)
|
|
651
716
|
const controller = new AbortController()
|
|
652
717
|
const onAbort = (): void => controller.abort()
|
|
653
718
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
654
719
|
let resp: Response
|
|
655
720
|
try {
|
|
656
|
-
|
|
721
|
+
// 端点现算:宿主监听端口在 listen 之后才有值、也会随宿主重启变(issue #9)。
|
|
722
|
+
// 解析器自己抛错时也落进下面这个 catch,于是「端口拿不到」是以
|
|
723
|
+
// LlmError 消息报出来的,而不是一句无从查证的 `fetch failed`。
|
|
724
|
+
const base = typeof this.baseURL === 'function' ? this.baseURL() : this.baseURL
|
|
725
|
+
resp = await fetch(`${base}/chat/completions`, {
|
|
657
726
|
method: 'POST',
|
|
658
727
|
// attributionHeaders() 是 dsh-llm 对 adapter 的硬契约:每个 provider
|
|
659
728
|
// 请求都必须带(LlmAdapter 类注释原话)。它给出
|
|
@@ -158,28 +158,24 @@ test('客户端断开:上游流 cancel 链要穿过 tapStreamUsage / normalize
|
|
|
158
158
|
assert.equal(leak.cancelled(), true, '取消必须穿过两层 wrapper 到达上游')
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
-
test('
|
|
161
|
+
test('聚合失败:上游流必须被回收(解锁,不能永久锁死)', async () => {
|
|
162
162
|
// 这一条测的**不是**「cancel 回调有没有触发」——那测不出东西:
|
|
163
163
|
// 实测结论(两个都验过):
|
|
164
164
|
// - 流 error 后调 cancel(),只会以同一错误 reject,**不触发** cancel 回调
|
|
165
165
|
// (`pull()` 抛错会把流置为 errored;上游拆连接则流直接 error)
|
|
166
166
|
// - 这两种情况下 undici **已经自己关了连接**,不需要我们 cancel
|
|
167
|
-
// 所以聚合失败路径真正要保证的是
|
|
167
|
+
// 所以聚合失败路径真正要保证的是 **上游流不再被锁着**:锁着的话上层
|
|
168
168
|
// (writeChatResult 的失败路径 / 未来的取消逻辑)想 cancel 都会抛
|
|
169
169
|
// `Invalid state: ReadableStream is locked`,连接就永久漏了。
|
|
170
|
-
|
|
170
|
+
//
|
|
171
|
+
// 注意拓扑:核心现在隔着「首字节截止」wrapper(withFirstByteDeadline)读上游,
|
|
172
|
+
// 所以判据只能是**最终状态**(locked=false),不能再断言核心直接 releaseLock
|
|
173
|
+
// 上游的 reader —— 中间隔了一层,核心只会解锁 wrapper 那个 reader。
|
|
171
174
|
const enc = new TextEncoder()
|
|
172
175
|
const failing = new ReadableStream<Uint8Array>({
|
|
173
176
|
start(c) { c.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"a"}}]}\n\n')) },
|
|
174
177
|
pull() { throw new Error('aggregate read failed') },
|
|
175
178
|
})
|
|
176
|
-
const origGetReader = failing.getReader.bind(failing)
|
|
177
|
-
;(failing as unknown as { getReader: () => unknown }).getReader = () => {
|
|
178
|
-
const r = origGetReader()
|
|
179
|
-
const origRelease = r.releaseLock.bind(r)
|
|
180
|
-
r.releaseLock = () => { releaseLocked = true; origRelease() }
|
|
181
|
-
return r
|
|
182
|
-
}
|
|
183
179
|
|
|
184
180
|
const router = new Router('')
|
|
185
181
|
router.add(supplierWith(failing) as never)
|
|
@@ -195,8 +191,7 @@ test('聚合失败:reader 必须被解绑(否则连接还活着时上层无
|
|
|
195
191
|
)
|
|
196
192
|
await new Promise((r) => setTimeout(r, 150))
|
|
197
193
|
|
|
198
|
-
assert.equal(
|
|
199
|
-
assert.equal(failing.locked, false, '流必须处于未锁定状态(可被 cancel / 已被回收)')
|
|
194
|
+
assert.equal(failing.locked, false, '上游流必须处于未锁定状态(可被 cancel / 已被回收)')
|
|
200
195
|
})
|
|
201
196
|
|
|
202
197
|
test('fixFrame:把后续 delta 里重发成 null 的 id/name 剥掉(否则外部 OpenAI 客户端会丢工具名)', async () => {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首字节预算测试 —— 组合的一条腿「连上了、200 了、就是不吐字节」时必须降级。
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-24 夜里的现场(usage.json / 三个 session 的 step 时间线互相对齐):
|
|
5
|
+
* money 组合第一腿 codebuddy-en 卡住,ttfb 分别 84s / 122s / 151s / 244s / 246s /
|
|
6
|
+
* 250s / 254s / 255s,**8 次全部 ok: true**——全都最终成功了,只是晚了四分钟。
|
|
7
|
+
* 用户干等 200 多秒手动暂停。
|
|
8
|
+
*
|
|
9
|
+
* 为什么以前不会降级:停滞既不是成功也不是失败,所以
|
|
10
|
+
* - 插件的 120s 只守「连接 + 响应头」(fetch 一返回就撤表),守不到 body
|
|
11
|
+
* - 核心写响应的路径没有计时器,组合链卡在这一条腿上一动不动
|
|
12
|
+
* - 该号也不会被冷却(没失败 → noteFailure 不触发)→ 下次还挑它
|
|
13
|
+
*
|
|
14
|
+
* 这里守的是修复后的语义:**预算到就判失败,链往前走**。没装这个闸门时
|
|
15
|
+
* 下面两条测试都会在 5s 的看门狗里超时变红(而不是把测试进程挂死)。
|
|
16
|
+
*/
|
|
17
|
+
import { test } from 'node:test'
|
|
18
|
+
import assert from 'node:assert/strict'
|
|
19
|
+
import type { ServerResponse } from 'node:http'
|
|
20
|
+
import { Router, withFirstByteDeadline } from './index.ts'
|
|
21
|
+
import { AccountPool } from './account-pool.ts'
|
|
22
|
+
import type { ModelInfo } from './types.ts'
|
|
23
|
+
import type { AccountPool as Pool } from './account-pool.ts'
|
|
24
|
+
import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
|
|
25
|
+
|
|
26
|
+
const enc = new TextEncoder()
|
|
27
|
+
|
|
28
|
+
/** 永不吐字节的流(模拟「200 响应头到手,body 停摆」),记录有没有被 cancel。 */
|
|
29
|
+
function stallingStream(): { stream: ReadableStream<Uint8Array>; cancelled: () => boolean } {
|
|
30
|
+
let cancelled = false
|
|
31
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
32
|
+
cancel() { cancelled = true },
|
|
33
|
+
})
|
|
34
|
+
return { stream, cancelled: () => cancelled }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 立刻吐一帧 + [DONE] 的正常流。 */
|
|
38
|
+
function healthyStream(text: string): ReadableStream<Uint8Array> {
|
|
39
|
+
return new ReadableStream<Uint8Array>({
|
|
40
|
+
start(ctrl) {
|
|
41
|
+
ctrl.enqueue(enc.encode(`data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`))
|
|
42
|
+
ctrl.enqueue(enc.encode('data: [DONE]\n\n'))
|
|
43
|
+
ctrl.close()
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function supplier(id: string, uids: string[], chatOnce: (uid: string) => Promise<ChatOnceResult>) {
|
|
49
|
+
const accounts: SupplierAccountNow[] = uids.map((uid) => ({ uid, credits: 0, state: 'ok' as const }))
|
|
50
|
+
return {
|
|
51
|
+
id, name: id, priority: 0, pool: new AccountPool(id) as Pool,
|
|
52
|
+
status: (): SupplierStatusNow => ({ id, name: id, accounts }),
|
|
53
|
+
listModels: async (): Promise<ModelInfo[]> => [{ id: 'm1' }],
|
|
54
|
+
getAlias: () => id,
|
|
55
|
+
accounts: () => accounts,
|
|
56
|
+
chatOnce,
|
|
57
|
+
dispose: () => {},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function captureRes(): { res: ServerResponse; body: () => string } {
|
|
62
|
+
const chunks: string[] = []
|
|
63
|
+
const text = (chunk: unknown): string =>
|
|
64
|
+
chunk instanceof Uint8Array ? new TextDecoder().decode(chunk) : String(chunk)
|
|
65
|
+
const res = {
|
|
66
|
+
writableEnded: false,
|
|
67
|
+
destroyed: false,
|
|
68
|
+
writeHead: (): unknown => undefined,
|
|
69
|
+
write: (chunk?: unknown): boolean => { chunks.push(text(chunk)); return true },
|
|
70
|
+
end: (chunk?: unknown): unknown => { if (chunk !== undefined) chunks.push(text(chunk)); return undefined },
|
|
71
|
+
once: (): unknown => undefined,
|
|
72
|
+
removeListener: (): unknown => undefined,
|
|
73
|
+
destroy: (): unknown => undefined,
|
|
74
|
+
} as unknown as ServerResponse
|
|
75
|
+
return { res, body: () => chunks.join('') }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const req = {
|
|
79
|
+
model: 'c',
|
|
80
|
+
stream: true,
|
|
81
|
+
rawBody: JSON.stringify({ model: 'c', messages: [{ role: 'user', content: 'hi' }], stream: true }),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 给等待加看门狗:闸门没装时这里是 5s 超时(红),而不是把测试挂死。 */
|
|
85
|
+
async function within<T>(ms: number, run: () => Promise<T>): Promise<T> {
|
|
86
|
+
return await Promise.race([
|
|
87
|
+
run(),
|
|
88
|
+
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`组合 ${ms}ms 内没有降级`)), ms)),
|
|
89
|
+
])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
test('第一腿停滞:预算到就判失败,落到下一条腿(而不是把请求挂到客户端自己放弃)', async () => {
|
|
93
|
+
const stall = stallingStream()
|
|
94
|
+
const router = new Router('', undefined, undefined, { firstByteBudgetMs: 150 })
|
|
95
|
+
router.add(supplier('supA', ['a1'], async () => ({ ok: true, stream: stall.stream })) as never)
|
|
96
|
+
router.add(supplier('supB', ['b1'], async () => ({ ok: true, stream: healthyStream('ok-from-B') })) as never)
|
|
97
|
+
assert.equal(router.createCombo('c', ['supA,m1', 'supB,m1']).ok, true)
|
|
98
|
+
|
|
99
|
+
const { res, body } = captureRes()
|
|
100
|
+
const t0 = Date.now()
|
|
101
|
+
await within(5_000, () => router.chatCompletions(req, res))
|
|
102
|
+
const elapsed = Date.now() - t0
|
|
103
|
+
|
|
104
|
+
assert.match(body(), /ok-from-B/, '必须由第二条腿服务(第一腿 200 了但不吐字节)')
|
|
105
|
+
assert.equal(stall.cancelled(), true, '停滞的上游流必须被 cancel,否则连接原样挂着')
|
|
106
|
+
// 2s 是组合「喘口气」等待(TRANSIENT_SETTLE_MS)——停滞按 transport 归类,
|
|
107
|
+
// 该等就得等;这里断言它确实走了「失败→降级」这条路,而不是瞬间成功。
|
|
108
|
+
assert.ok(elapsed >= 2_000, `应先等喘口气窗口再降级,实际 ${elapsed}ms`)
|
|
109
|
+
assert.ok(elapsed < 5_000, `降级必须发生在预算+喘口气附近,实际 ${elapsed}ms`)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('预算是「每条腿」一份:腿内第二个号不再各等一次(否则 60s × 号数)', async () => {
|
|
113
|
+
const stallA1 = stallingStream()
|
|
114
|
+
const stallA2 = stallingStream()
|
|
115
|
+
let a1 = 0
|
|
116
|
+
let a2 = 0
|
|
117
|
+
const router = new Router('', undefined, undefined, { firstByteBudgetMs: 150 })
|
|
118
|
+
router.add(supplier('supA', ['a1', 'a2'], async (uid) => {
|
|
119
|
+
if (uid === 'a1') { a1 += 1; return { ok: true, stream: stallA1.stream } }
|
|
120
|
+
a2 += 1
|
|
121
|
+
return { ok: true, stream: stallA2.stream }
|
|
122
|
+
}) as never)
|
|
123
|
+
router.add(supplier('supB', ['b1'], async () => ({ ok: true, stream: healthyStream('ok-from-B') })) as never)
|
|
124
|
+
assert.equal(router.createCombo('c', ['supA,m1', 'supB,m1']).ok, true)
|
|
125
|
+
|
|
126
|
+
const { res, body } = captureRes()
|
|
127
|
+
const t0 = Date.now()
|
|
128
|
+
await within(5_000, () => router.chatCompletions(req, res))
|
|
129
|
+
const elapsed = Date.now() - t0
|
|
130
|
+
|
|
131
|
+
assert.match(body(), /ok-from-B/)
|
|
132
|
+
assert.equal(a1, 1, '第一个号被调了一次')
|
|
133
|
+
assert.equal(a2, 0, '预算已耗尽:同一个号池里的第二个号不该再各等 150ms(停滞是上游级的)')
|
|
134
|
+
assert.ok(elapsed < 5_000, `不能按号数翻倍地等,实际 ${elapsed}ms`)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('wrapper:第一个字节到手后不再计时(长生成不能被砍)', async () => {
|
|
138
|
+
let ctrlRef: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
139
|
+
const src = new ReadableStream<Uint8Array>({ start: (c) => { ctrlRef = c } })
|
|
140
|
+
const reader = withFirstByteDeadline(src, 60).getReader()
|
|
141
|
+
|
|
142
|
+
ctrlRef?.enqueue(enc.encode('first'))
|
|
143
|
+
assert.equal(dec((await reader.read()).value!), 'first')
|
|
144
|
+
// 停 3 倍预算才吐第二块:过了截止点也必须原样透传
|
|
145
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
146
|
+
ctrlRef?.enqueue(enc.encode('second'))
|
|
147
|
+
assert.equal(dec((await reader.read()).value!), 'second')
|
|
148
|
+
await reader.cancel()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('wrapper:源流一个字节都不吐就到期 → 流判失败(上层据此换号/换模型)', async () => {
|
|
152
|
+
const { stream, cancelled } = stallingStream()
|
|
153
|
+
const reader = withFirstByteDeadline(stream, 60).getReader()
|
|
154
|
+
await assert.rejects(() => reader.read(), /no first byte within 60ms/)
|
|
155
|
+
assert.equal(cancelled(), true, '到期时必须把取消传给源流(否则上游连接原样挂着)')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
function dec(v: Uint8Array): string {
|
|
159
|
+
return new TextDecoder().decode(v)
|
|
160
|
+
}
|