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.
@@ -15,7 +15,13 @@ export interface RouterAdapterSource {
15
15
  }>>;
16
16
  }
17
17
  /** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
18
- export declare function translateSse(payloads: Iterable<string>): AsyncGenerator<StreamChunk>;
18
+ /**
19
+ * 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
20
+ *
21
+ * 吃 `AsyncIterable`(真流式:边收边吐)或 `Iterable`(测试用数组)。
22
+ * 用 `for await` 是因为它天然兼容两者 —— 不必为流式再写一份。
23
+ */
24
+ export declare function translateSse(payloads: AsyncIterable<string> | Iterable<string>): AsyncGenerator<StreamChunk>;
19
25
  /**
20
26
  * DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
21
27
  * /v1/chat/completions(组合路由在 /v1 内完成)。
@@ -1,37 +1,72 @@
1
1
  /**
2
- * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、错误累计、遍历回退。
2
+ * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、遍历回退。
3
3
  *
4
- * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里:
5
- * - 按 poolOrder 排序(未配置的按插件报告顺序追加)
6
- * - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
7
- * - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
4
+ * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里。
8
5
  *
9
- * 天花板:冷却/禁用/错误计数都是**内存态**,重启归零(与旧插件行为一致,
10
- * 插件的持久化只存凭证与积分,不存健康)。要跨重启保留得落盘,届时加一个
11
- * stateFile 即可,接口不用变。
6
+ * ## 冷却颗粒度 = (供应商, 模型, 连接) 三元组
7
+ *
8
+ * 冷却**不按连接整体记**,而是按「哪家供应商 × 哪个模型 × 哪个连接」单独记。
9
+ * 理由:一个连接可以服务多个模型。若某连接调模型 A 时被限流/瞬时抖动而把
10
+ * **整个连接**冷掉,那它调模型 B 也被殃及——一个付费连接会因一个模型的瞬时
11
+ * 失败而对其它模型也不可用,这是错的。
12
+ *
13
+ * - 冷却/限流退避/瞬时 → 键 = (supplierId, modelId, uid),只冷「这个号 × 这个模型」
14
+ * - 禁用(session_dead) → 键 = uid(登录态问题,该号所有模型都不可用)
15
+ * - 手动冷却(面板) → 键 = uid(管理员整连接暂停)
16
+ *
17
+ * 为什么键里要带 supplierId:不同供应商**会有同名的模型 id**(如 traework 与
18
+ * codebuddy 都有 `deepseek-v4-flash`),它们是不同的实体,单靠 (modelId, uid)
19
+ * 会把跨供应商的同名模型串到一起。尽管本池实例当前是单供应商(loader 每供应商
20
+ * 一个 AccountPool),键仍显式带 supplierId,语义自明且将来若池合并也安全。
21
+ *
22
+ * 天花板:冷却/禁用都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
23
+ * 得落盘,届时加 stateFile 即可,接口不用变。
12
24
  */
13
25
  import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts';
14
26
  export declare class AccountPool {
15
- private entries;
27
+ /** 本池所属供应商(loader 每供应商一个实例;键里带上,跨供应商同名模型不串)。 */
28
+ private supplierId;
29
+ /** (supplier, model, uid) → 冷却/退避。 */
30
+ private cooldowns;
31
+ /** uid → 连接级禁用 / 手动暂停。 */
32
+ private byUid;
16
33
  private rrCursor;
34
+ constructor(supplierId?: string);
35
+ private key;
17
36
  private entry;
18
- /** 是否可服务:未禁用且不在冷却中。 */
37
+ /** 某连接整体状态(禁用/手动暂停)。 */
38
+ private uidState;
39
+ /** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
40
+ private uidBlocked;
41
+ /**
42
+ * 给定要试的模型,该连接是否可服务:
43
+ * - 未被连接级禁用/手动暂停(跨模型);
44
+ * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
45
+ */
19
46
  private healthy;
20
47
  /**
21
- * 按策略选一个健康账号。
48
+ * 按策略为「某个模型」选一个健康账号。
22
49
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
23
50
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
51
+ * @param strategy fallback 取第一个健康 / round-robin 轮转健康号
52
+ * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
24
53
  * @returns 选中的 uid;无健康账号返回 undefined
25
54
  */
26
- pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string): string | undefined;
27
- /** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
28
- noteFailure(uid: string, state: AccountState, message: string): void;
29
- /** 记录一次成功:清零连续错误。 */
30
- noteSuccess(uid: string): void;
31
- /** 手动冷却(面板/管理用途)。 */
55
+ pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined;
56
+ /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
57
+ noteFailure(uid: string, modelId: string, state: AccountState, message: string): void;
58
+ /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
59
+ noteSuccess(uid: string, modelId: string): void;
60
+ /** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
32
61
  cooldown(uid: string, ms: number, reason: string): void;
33
- /** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
34
- decorate(accounts: SupplierAccountNow[]): Array<SupplierAccountNow & {
62
+ /** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
63
+ *
64
+ * 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
65
+ * 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
66
+ * (reason 带上具体模型),并在 `until` 给最长者。真实路由用 `pick` 的
67
+ * 模型级判定,不受此展示聚合影响。传 modelId 可只按该模型判定。
68
+ */
69
+ decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
35
70
  cooling: boolean;
36
71
  disabled: boolean;
37
72
  err_count: number;
@@ -117,6 +117,11 @@ export declare class Router {
117
117
  * 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
118
118
  * 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
119
119
  */
120
+ /**
121
+ * @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
122
+ * 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
123
+ * 失败状态没有单一归属)。
124
+ */
120
125
  private chatWithModel;
121
126
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
122
127
  supplierByAlias(alias: string): Supplier | undefined;
@@ -41,11 +41,28 @@ export declare class UsageStore {
41
41
  ts?: number;
42
42
  }, usage: Usage): void;
43
43
  private bump;
44
- /** 窗口内的明细(today/24h 路径)。 */
45
- private inWindow;
44
+ /**
45
+ * 从 `first` 起的 24 个小时格:每格带整点时间戳、所属自然日、那天的数据。
46
+ *
47
+ * `today` 传今天 00:00,`24h` 传「当前整点往前 23 格」。
48
+ * 跨天一律靠 `localDateKey(start)` 现算,不用「减 N 小时」去猜天数:
49
+ * 本地时区(尤其 DST)下小时偏移和自然日不是一回事,算错就漏一整天。
50
+ */
51
+ private hourSlots;
52
+ /** 今天的小时格起点(自然日零点)。 */
53
+ private static dayStartOf;
54
+ /** 24h 滚动窗口的小时格起点(当前整点往前 23 格)。 */
55
+ private static rollingStartOf;
56
+ /**
57
+ * 一整天的小时桶是否齐(各格之和 >= 天桶计数)。
58
+ *
59
+ * 不齐 = 这天是升级前落的盘(没有小时粒度)→ 24h 窗口踩到它就只能整桶计入。
60
+ * 宁可多算窗口外那截,也不能报一个比「今日」还小的数。
61
+ */
62
+ private static hoursComplete;
46
63
  /** 汇总 + Top 榜。 */
47
64
  stats(period: Period, now?: number): StatsResult;
48
- /** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
65
+ /** 柱状图数据:today/24h = 24 个小时桶,7d/30d = 天桶。 */
49
66
  chart(period: Period, now?: number): ChartBucket[];
50
67
  /** 最近请求明细。 */
51
68
  recentList(limit?: number): UsageRecord[];
@@ -53,6 +53,8 @@ export interface RouterCheckinResponse {
53
53
  succeeded: number;
54
54
  /** 今日已签到的链接数(幂等成功)。 */
55
55
  already: number;
56
+ /** 失败/未开放的链接数。 */
57
+ failed: number;
56
58
  results?: Array<{
57
59
  uid: string;
58
60
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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": {
@@ -117,7 +117,7 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
117
117
  </div>
118
118
  <div className="dshr-supplierMeta">
119
119
  {supplierAccounts.length > 0 && (
120
- <span className="dshr-muted">{supplierAccounts.length}/{healthy} 健康</span>
120
+ <span className="dshr-muted">{healthy}/{supplierAccounts.length} 健康</span>
121
121
  )}
122
122
  <span className="dshr-chevron">›</span>
123
123
  </div>
@@ -184,20 +184,20 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
184
184
  setCheckingIn(true)
185
185
  try {
186
186
  const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/checkin`, { method: 'POST', cache: 'no-store' })
187
- const data = await response.json() as { ok: boolean; total: number; succeeded: number; already?: number; error?: string; results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }> }
187
+ const data = await response.json() as { ok: boolean; total: number; succeeded: number; already?: number; failed?: number; error?: string; results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }> }
188
+ // 核心口径(2026-09-02 修正):有任一链接失败 → ok:false + HTTP 400。
189
+ // 别再用「签到完成」打头——部分失败会被 UI 吞成成功。
188
190
  if (data.ok && data.total > 0) {
189
- // 真实语义:succeeded = 真正 claim 成功;already = 今日已签;其余 = 失败/未开放
190
- const failed = (data.results ?? []).filter(r => r.status !== 'ok' && r.status !== 'already')
191
191
  let msg = `签到完成:${data.succeeded}/${data.total} 成功`
192
192
  const already = data.already ?? 0
193
193
  if (already > 0) msg += ` · ${already} 今日已签`
194
- if (failed.length > 0) {
195
- const first = failed[0]
196
- msg += ` · ${first?.message ?? '有失败'}`
197
- }
198
194
  showToast(msg)
199
195
  } else {
200
- showToast(data.error ?? (data.total === 0 ? '没有可签到的链接' : '签到失败'))
196
+ const firstFail = (data.results ?? []).find(r => r.status !== 'ok' && r.status !== 'already')
197
+ const reason = firstFail ? `${firstFail.message ?? '有链接签到失败'}` : ''
198
+ const already = data.already ?? 0
199
+ const head = data.total > 0 ? `签到未完成:${data.succeeded}/${data.total} 成功` : '没有可签到的链接'
200
+ showToast(data.error ?? [head, already > 0 ? `${already} 今日已签` : '', reason].filter(Boolean).join(' · '))
201
201
  }
202
202
  onRefresh()
203
203
  } catch (err) {
@@ -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
+ })