dsh-router-core 0.2.0 → 0.3.1
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 +40 -11
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +302 -0
- package/lib/client-registry.js +1119 -213
- package/lib/client.js +1119 -213
- package/lib/index.js +1066 -50
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/index.d.ts +34 -5
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +86 -1
- package/lib/types/supplier-config.d.ts +15 -1
- package/package.json +21 -4
- package/src/client/CheckinCard.tsx +180 -0
- package/src/client/CombosTab.tsx +28 -18
- package/src/client/RouterView.tsx +44 -27
- package/src/client/StatsTab.tsx +531 -0
- package/src/client/index.tsx +61 -11
- package/src/client/router.css +688 -35
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/settings-section.tsx +21 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +51 -3
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/index.ts +461 -49
- package/src/router/models-cache.test.ts +269 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +90 -1
- package/src/supplier-config.ts +30 -2
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/nvidia/plugin.ts +2 -3
- package/src/suppliers/opencode/plugin.ts +2 -3
- package/src/suppliers/openrouter/plugin.ts +2 -3
- package/src/suppliers/registry.ts +7 -2
|
@@ -142,6 +142,219 @@ test('未知供应商返回空,不抛错', async () => {
|
|
|
142
142
|
assert.deepEqual(await router.modelsOf('nonexistent'), [])
|
|
143
143
|
})
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* 回归:客户端要非流式、供应商只给流(如 codebuddy 强制 stream:true)时,
|
|
147
|
+
* 核心必须聚合成 JSON——响应写入归核心,协议错配得核心吸收。
|
|
148
|
+
* 否则客户端按 JSON 解析会在 SSE delta 里找不到工具名,报 unknown tool ""。
|
|
149
|
+
*/
|
|
150
|
+
test('要 JSON 却拿到流 → 聚合成非流式响应,工具名不丢', async () => {
|
|
151
|
+
const router = new Router('')
|
|
152
|
+
const cb = supplier('cb', [{ id: 'hy4-preview' }])
|
|
153
|
+
// 模拟只支持流式的供应商:name 在首块,arguments 分两段
|
|
154
|
+
cb.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
155
|
+
ok: true,
|
|
156
|
+
stream: new ReadableStream<Uint8Array>({
|
|
157
|
+
start(c) {
|
|
158
|
+
const enc = new TextEncoder()
|
|
159
|
+
c.enqueue(enc.encode('data: {"id":"cmpl-1","model":"hy4-preview","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":""}}]}}]}\n\n'))
|
|
160
|
+
c.enqueue(enc.encode('data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"command\\": \\"ls -la\\"}"}}]}}]}\n\n'))
|
|
161
|
+
c.enqueue(enc.encode('data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"total_tokens":42}}\n\n'))
|
|
162
|
+
c.enqueue(enc.encode('data: [DONE]\n\n'))
|
|
163
|
+
c.close()
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
})
|
|
167
|
+
addSupplier(router, cb.s)
|
|
168
|
+
|
|
169
|
+
let ctype = ''
|
|
170
|
+
let out = ''
|
|
171
|
+
const res = {
|
|
172
|
+
writeHead: (_c: number, h?: Record<string, string>): void => { ctype = h?.['Content-Type'] ?? '' },
|
|
173
|
+
write: (b: Uint8Array | string): boolean => { out += Buffer.from(b).toString(); return true },
|
|
174
|
+
end: (b?: Uint8Array | string): void => { if (b !== undefined) out += Buffer.from(b).toString() },
|
|
175
|
+
once: (): void => {},
|
|
176
|
+
removeListener: (): void => {},
|
|
177
|
+
destroy: (): void => {},
|
|
178
|
+
} as unknown as ServerResponse
|
|
179
|
+
|
|
180
|
+
await router.chatCompletions({ model: 'cb/hy4-preview', stream: false, rawBody: JSON.stringify({ model: 'cb/hy4-preview', stream: false, messages: [] }) }, res)
|
|
181
|
+
|
|
182
|
+
assert.match(ctype, /application\/json/, `客户端要 JSON 就该给 JSON,实际 ${ctype}`)
|
|
183
|
+
const o = JSON.parse(out) as { choices?: Array<{ message?: { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> }; finish_reason?: string }>; usage?: unknown }
|
|
184
|
+
const tc = o.choices?.[0]?.message?.tool_calls?.[0]
|
|
185
|
+
assert.equal(tc?.function?.name, 'bash', '工具名必须保住(否则下游 unknown tool "")')
|
|
186
|
+
assert.deepEqual(JSON.parse(tc?.function?.arguments ?? ''), { command: 'ls -la' }, '分段的 arguments 要拼成合法 JSON')
|
|
187
|
+
assert.equal(o.choices?.[0]?.finish_reason, 'tool_calls')
|
|
188
|
+
assert.ok(o.usage !== undefined, 'usage 要带上')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('要流式就原样流式,不能被聚合(否则首字节延迟爆炸)', async () => {
|
|
192
|
+
const router = new Router('')
|
|
193
|
+
const cb = supplier('cb', [{ id: 'm' }])
|
|
194
|
+
cb.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
195
|
+
ok: true,
|
|
196
|
+
stream: new ReadableStream<Uint8Array>({
|
|
197
|
+
start(c) { c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n')); c.close() },
|
|
198
|
+
}),
|
|
199
|
+
})
|
|
200
|
+
addSupplier(router, cb.s)
|
|
201
|
+
|
|
202
|
+
let ctype = ''
|
|
203
|
+
let out = ''
|
|
204
|
+
const res = {
|
|
205
|
+
writeHead: (_c: number, h?: Record<string, string>): void => { ctype = h?.['Content-Type'] ?? '' },
|
|
206
|
+
write: (b: Uint8Array | string): boolean => { out += Buffer.from(b).toString(); return true },
|
|
207
|
+
end: (): void => {},
|
|
208
|
+
once: (): void => {},
|
|
209
|
+
removeListener: (): void => {},
|
|
210
|
+
destroy: (): void => {},
|
|
211
|
+
} as unknown as ServerResponse
|
|
212
|
+
|
|
213
|
+
await router.chatCompletions({ model: 'cb/m', stream: true, rawBody: JSON.stringify({ model: 'cb/m', stream: true, messages: [] }) }, res)
|
|
214
|
+
|
|
215
|
+
assert.match(ctype, /event-stream/, '要流式就该给 SSE')
|
|
216
|
+
assert.ok(out.includes('data: [DONE]'), '原始 SSE 帧要原样透传')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/** 请求日志要能回答「这个组合到底用的哪个模型和账号」——排障全靠它。 */
|
|
220
|
+
test('每次 chat 记一行:组合名 → 供应商/模型 (账号) 结果', async () => {
|
|
221
|
+
const lines: string[] = []
|
|
222
|
+
const router = new Router('', undefined, (m) => lines.push(m))
|
|
223
|
+
const ok = supplier('supA', [{ id: 'a-m' }])
|
|
224
|
+
addSupplier(router, ok.s)
|
|
225
|
+
assert.equal(router.createCombo('combo1', 'fallback', ['supA,a-m']).ok, true)
|
|
226
|
+
|
|
227
|
+
const res = {
|
|
228
|
+
writeHead: (): void => {},
|
|
229
|
+
write: (): boolean => true,
|
|
230
|
+
end: (): void => {},
|
|
231
|
+
once: (): void => {},
|
|
232
|
+
removeListener: (): void => {},
|
|
233
|
+
destroy: (): void => {},
|
|
234
|
+
} as unknown as ServerResponse
|
|
235
|
+
await router.chatCompletions({ model: 'combo1', stream: false, rawBody: JSON.stringify({ model: 'combo1', messages: [] }) }, res)
|
|
236
|
+
|
|
237
|
+
assert.equal(lines.length, 1)
|
|
238
|
+
assert.match(lines[0] ?? '', /^chat "combo1" → supA\/a-m \(u1\) ok \d+ms$/, `日志格式不对: ${lines[0]}`)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('失败的成员也要记,且带真实原因(不能笼统写 no account)', async () => {
|
|
242
|
+
const lines: string[] = []
|
|
243
|
+
const router = new Router('', undefined, (m) => lines.push(m))
|
|
244
|
+
const dead = supplier('dead', [{ id: 'dead-m' }])
|
|
245
|
+
dead.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
246
|
+
ok: true,
|
|
247
|
+
stream: new ReadableStream<Uint8Array>({ start(c) { c.error(new Error('刚连上就断')) } }),
|
|
248
|
+
})
|
|
249
|
+
const live = supplier('live', [{ id: 'live-m' }])
|
|
250
|
+
addSupplier(router, dead.s)
|
|
251
|
+
addSupplier(router, live.s)
|
|
252
|
+
assert.equal(router.createCombo('c1', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
|
|
253
|
+
|
|
254
|
+
const res = {
|
|
255
|
+
writeHead: (): void => {},
|
|
256
|
+
write: (): boolean => true,
|
|
257
|
+
end: (): void => {},
|
|
258
|
+
once: (): void => {},
|
|
259
|
+
removeListener: (): void => {},
|
|
260
|
+
destroy: (): void => {},
|
|
261
|
+
} as unknown as ServerResponse
|
|
262
|
+
await router.chatCompletions({ model: 'c1', stream: true, rawBody: JSON.stringify({ model: 'c1', messages: [] }) }, res)
|
|
263
|
+
|
|
264
|
+
assert.equal(lines.length, 2, '每个成员一行')
|
|
265
|
+
assert.ok((lines[0] ?? '').includes('dead/dead-m 失败'), `失败成员要记: ${lines[0]}`)
|
|
266
|
+
assert.ok((lines[0] ?? '').includes('stream failed before first byte'), `要带真实原因: ${lines[0]}`)
|
|
267
|
+
assert.ok((lines[1] ?? '').includes('ok'), `成功的要记: ${lines[1]}`)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 回归:上游「刚连上就断」时组合必须回退到下一个模型。
|
|
272
|
+
* 之前 writeChatResult 先写响应头再 pipe,且把 pipe 错误吞掉 —— 于是返回
|
|
273
|
+
* 「成功」但一个字节都没写,客户端拿到空/截断的 SSE,表现为工具调用名是
|
|
274
|
+
* 空串(Tool call Error: unknown tool "")。
|
|
275
|
+
*/
|
|
276
|
+
test('流在写出第一个字节前就断 → 组合回退到下一个模型', async () => {
|
|
277
|
+
const router = new Router('')
|
|
278
|
+
const dead = supplier('dead', [{ id: 'dead-m' }])
|
|
279
|
+
dead.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
280
|
+
ok: true,
|
|
281
|
+
stream: new ReadableStream<Uint8Array>({ start(c) { c.error(new Error('上游刚连上就断')) } }),
|
|
282
|
+
})
|
|
283
|
+
const live = supplier('live', [{ id: 'live-m' }])
|
|
284
|
+
// 兜底模型要返回真的 SSE 流,才贴近组合的真实场景
|
|
285
|
+
live.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
286
|
+
ok: true,
|
|
287
|
+
stream: new ReadableStream<Uint8Array>({
|
|
288
|
+
start(c) {
|
|
289
|
+
c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"兜底"}}]}\n\ndata: [DONE]\n\n'))
|
|
290
|
+
c.close()
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
})
|
|
294
|
+
addSupplier(router, dead.s)
|
|
295
|
+
addSupplier(router, live.s)
|
|
296
|
+
assert.equal(router.createCombo('c1', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
|
|
297
|
+
|
|
298
|
+
let out = ''
|
|
299
|
+
let heads = 0
|
|
300
|
+
const res = {
|
|
301
|
+
writeHead: (): void => { heads += 1 },
|
|
302
|
+
write: (b: Uint8Array | string): boolean => { out += Buffer.from(b).toString(); return true },
|
|
303
|
+
end: (): void => {},
|
|
304
|
+
once: (): void => {},
|
|
305
|
+
removeListener: (): void => {},
|
|
306
|
+
destroy: (): void => {},
|
|
307
|
+
} as unknown as ServerResponse
|
|
308
|
+
await router.chatCompletions({ model: 'c1', stream: true, rawBody: JSON.stringify({ model: 'c1', messages: [] }) }, res)
|
|
309
|
+
|
|
310
|
+
assert.ok(out.includes('[DONE]'), `应拿到完整响应,实际: ${JSON.stringify(out)}`)
|
|
311
|
+
assert.equal(heads, 1, '响应头只应提交一次')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('已写出字节后断流 → 诚实截断,不回退也不二次写头', async () => {
|
|
315
|
+
const router = new Router('')
|
|
316
|
+
const half = supplier('half', [{ id: 'half-m' }])
|
|
317
|
+
half.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
318
|
+
ok: true,
|
|
319
|
+
stream: new ReadableStream<Uint8Array>({
|
|
320
|
+
async start(c) {
|
|
321
|
+
c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"半截"}}]}\n\n'))
|
|
322
|
+
await new Promise((r) => setTimeout(r, 30)) // 确保字节已投递
|
|
323
|
+
c.error(new Error('上游中途断流'))
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
})
|
|
327
|
+
const live = supplier('live', [{ id: 'live-m' }])
|
|
328
|
+
live.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
329
|
+
ok: true,
|
|
330
|
+
stream: new ReadableStream<Uint8Array>({
|
|
331
|
+
start(c) {
|
|
332
|
+
c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"兜底"}}]}\n\ndata: [DONE]\n\n'))
|
|
333
|
+
c.close()
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
})
|
|
337
|
+
addSupplier(router, half.s)
|
|
338
|
+
addSupplier(router, live.s)
|
|
339
|
+
assert.equal(router.createCombo('c2', 'fallback', ['half,half-m', 'live,live-m']).ok, true)
|
|
340
|
+
|
|
341
|
+
let out = ''
|
|
342
|
+
let heads = 0
|
|
343
|
+
const res = {
|
|
344
|
+
writeHead: (): void => { heads += 1 },
|
|
345
|
+
write: (b: Uint8Array | string): boolean => { out += Buffer.from(b).toString(); return true },
|
|
346
|
+
end: (): void => {},
|
|
347
|
+
once: (): void => {},
|
|
348
|
+
removeListener: (): void => {},
|
|
349
|
+
destroy: (): void => {},
|
|
350
|
+
} as unknown as ServerResponse
|
|
351
|
+
await router.chatCompletions({ model: 'c2', stream: true, rawBody: JSON.stringify({ model: 'c2', messages: [] }) }, res)
|
|
352
|
+
|
|
353
|
+
assert.ok(out.includes('半截'), '已投递的字节要送达客户端')
|
|
354
|
+
assert.ok(!out.includes('[DONE]'), '已提交就不该再接第二个模型的内容(会串流)')
|
|
355
|
+
assert.equal(heads, 1, '响应头只能提交一次')
|
|
356
|
+
})
|
|
357
|
+
|
|
145
358
|
/**
|
|
146
359
|
* 对照组:插件若用 unavailable 表示「不是我的模型」就会污染账号——
|
|
147
360
|
* 这正是当初把 no_such_model 加进契约的原因(unavailable 在 RULES 里计数)。
|
|
@@ -184,3 +397,59 @@ test('组合请求不污染无关供应商的账号(no_such_model 不记账)
|
|
|
184
397
|
assert.equal(acc?.cooling, false, '不该被冷却')
|
|
185
398
|
assert.equal(acc?.disabled, false, '不该被禁用')
|
|
186
399
|
})
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 回归:CodeBuddy 上游的工具调用第二个 delta 会**显式发空串** name 和 id
|
|
403
|
+
* (OpenAI 规范里后续 delta 应当省略,客户端沿用首帧)。客户端(dsh)判空用
|
|
404
|
+
* `!== void 0`,空串过不了这个判断,首帧的 "bash" 就被 "" 冲掉 → 工具名变空,
|
|
405
|
+
* 下游报 `unknown tool ""`,而 arguments 独立累积所以「参数对、名字空」。
|
|
406
|
+
*
|
|
407
|
+
* 核心是流式响应的唯一写入方,必须在转发时把这些空字段剥掉。
|
|
408
|
+
* 帧数据取自真实上游(hy4-preview 工具调用)。
|
|
409
|
+
*/
|
|
410
|
+
test('流式:剥掉上游多发一次的空白 tool_call name/id', async () => {
|
|
411
|
+
const router = new Router('')
|
|
412
|
+
const cb = supplier('cb', [{ id: 'hy4-preview' }])
|
|
413
|
+
const REAL = [
|
|
414
|
+
'data: {"id":"cmb-1","model":"hy4-preview","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"","tool_calls":[{"id":"chatcmpl-tool-a7ee5a0d3c361c7f","type":"function","function":{"name":"bash","arguments":""},"index":0}]},"finish_reason":""}]}\n\n',
|
|
415
|
+
'data: {"id":"cmb-1","model":"hy4-preview","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"","tool_calls":[{"id":"","function":{"name":"","arguments":"{\\"command\\": \\"echo hello\\"}"},"index":0}]},"finish_reason":"tool_calls"}]}\n\n',
|
|
416
|
+
'data: [DONE]\n\n',
|
|
417
|
+
].join('')
|
|
418
|
+
cb.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
419
|
+
ok: true,
|
|
420
|
+
stream: new ReadableStream<Uint8Array>({
|
|
421
|
+
start(c) { c.enqueue(new TextEncoder().encode(REAL)); c.close() },
|
|
422
|
+
}),
|
|
423
|
+
})
|
|
424
|
+
addSupplier(router, cb.s)
|
|
425
|
+
|
|
426
|
+
let out = ''
|
|
427
|
+
const res = {
|
|
428
|
+
writeHead: (): void => {},
|
|
429
|
+
write: (b: Uint8Array | string): boolean => { out += Buffer.from(b).toString(); return true },
|
|
430
|
+
end: (): void => {},
|
|
431
|
+
once: (): void => {},
|
|
432
|
+
removeListener: (): void => {},
|
|
433
|
+
destroy: (): void => {},
|
|
434
|
+
} as unknown as ServerResponse
|
|
435
|
+
await router.chatCompletions({ model: 'cb/hy4-preview', stream: true, rawBody: JSON.stringify({ model: 'cb/hy4-preview', stream: true, messages: [] }) }, res)
|
|
436
|
+
|
|
437
|
+
// 按 dsh 的合并规则跑一遍:name !== void 0 就覆盖
|
|
438
|
+
let name: string | undefined
|
|
439
|
+
let callId: string | undefined
|
|
440
|
+
let args = ''
|
|
441
|
+
for (const line of out.split('\n')) {
|
|
442
|
+
if (!line.startsWith('data: ')) continue
|
|
443
|
+
const payload = line.slice(6).trim()
|
|
444
|
+
if (payload === '[DONE]') continue
|
|
445
|
+
const o = JSON.parse(payload) as { choices?: Array<{ delta?: { tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: string } }> } }> }
|
|
446
|
+
for (const tc of o.choices?.[0]?.delta?.tool_calls ?? []) {
|
|
447
|
+
if (tc.id !== undefined) callId = tc.id
|
|
448
|
+
if (tc.function?.name !== undefined) name = tc.function.name
|
|
449
|
+
args += tc.function?.arguments ?? ''
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
assert.equal(name, 'bash', `工具名必须保住(空了就会 unknown tool ""),实际 ${JSON.stringify(name)}`)
|
|
453
|
+
assert.equal(callId, 'chatcmpl-tool-a7ee5a0d3c361c7f', 'callId 也不能被空串冲掉')
|
|
454
|
+
assert.deepEqual(JSON.parse(args), { command: 'echo hello' }, 'arguments 正常累积')
|
|
455
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 精准调度测试 —— 组合调用不该挨个问供应商「这是不是你的模型」。
|
|
3
|
+
* 存储格式 = `supplierId,modelId`,对外展示/调用 = `alias/model`。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import type { ServerResponse } from 'node:http'
|
|
8
|
+
import { Router } from './index.ts'
|
|
9
|
+
import type { ModelInfo } from './types.ts'
|
|
10
|
+
import { AccountPool } from './account-pool.ts'
|
|
11
|
+
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
12
|
+
import type { AccountPool as Pool } from './account-pool.ts'
|
|
13
|
+
import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
|
|
14
|
+
|
|
15
|
+
interface Spy {
|
|
16
|
+
/** chatOnce 被调用的次数 */
|
|
17
|
+
calls: number
|
|
18
|
+
/** 每次被调用的模型名 */
|
|
19
|
+
seen: string[]
|
|
20
|
+
s: {
|
|
21
|
+
id: string
|
|
22
|
+
name: string
|
|
23
|
+
priority: number
|
|
24
|
+
pool: Pool
|
|
25
|
+
status: () => SupplierStatusNow
|
|
26
|
+
listModels: () => Promise<ModelInfo[]>
|
|
27
|
+
modelsWithEnabled: () => Promise<Array<ModelInfo & { enabled: boolean }>>
|
|
28
|
+
getAlias: () => string
|
|
29
|
+
accounts: () => SupplierAccountNow[]
|
|
30
|
+
chatOnce: (uid: string, req: { rawBody: string }) => Promise<ChatOnceResult>
|
|
31
|
+
dispose: () => void
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 带探针的供应商:只服务自己的模型,记录被问过什么。 */
|
|
36
|
+
function spy(id: string, modelId: string, alias = id): Spy {
|
|
37
|
+
const calls = { n: 0 }
|
|
38
|
+
const seen: string[] = []
|
|
39
|
+
return {
|
|
40
|
+
get calls() { return calls.n },
|
|
41
|
+
seen,
|
|
42
|
+
s: {
|
|
43
|
+
id, name: id, priority: 0, pool: new AccountPool(),
|
|
44
|
+
status: () => ({ id, name: id, accounts: [{ uid: `${id}-u1`, credits: 0, state: 'ok' }] }),
|
|
45
|
+
listModels: async () => [{ id: modelId }],
|
|
46
|
+
modelsWithEnabled: async () => [{ id: modelId, enabled: true }],
|
|
47
|
+
getAlias: () => alias,
|
|
48
|
+
accounts: () => [{ uid: `${id}-u1`, credits: 0, state: 'ok' }],
|
|
49
|
+
chatOnce: async (_uid, req) => {
|
|
50
|
+
calls.n += 1
|
|
51
|
+
const m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
|
|
52
|
+
seen.push(m)
|
|
53
|
+
if (m !== modelId) return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
|
|
54
|
+
return { ok: true, status: 200, body: '{"ok":1}' }
|
|
55
|
+
},
|
|
56
|
+
dispose: () => {},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 走真实注册路径(router.add 内部会 sync 已知 id,别名校验才能生效)。 */
|
|
62
|
+
function add(router: Router, s: unknown): void {
|
|
63
|
+
router.add(s as Parameters<Router['add']>[0])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fakeRes(): ServerResponse {
|
|
67
|
+
return {
|
|
68
|
+
writeHead: (): unknown => undefined,
|
|
69
|
+
end: (): unknown => undefined,
|
|
70
|
+
write: (): boolean => true,
|
|
71
|
+
once: (): unknown => undefined,
|
|
72
|
+
removeListener: (): unknown => undefined,
|
|
73
|
+
destroy: (): unknown => undefined,
|
|
74
|
+
} as unknown as ServerResponse
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function reqWith(model: string) {
|
|
78
|
+
return { model, stream: false, rawBody: JSON.stringify({ model, messages: [] }) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test('组合只调目标供应商,其它供应商一次都不会被问', async () => {
|
|
82
|
+
const router = new Router('')
|
|
83
|
+
const a = spy('supA', 'a-model')
|
|
84
|
+
const b = spy('supB', 'b-model')
|
|
85
|
+
const c = spy('supC', 'c-model')
|
|
86
|
+
add(router, a.s); add(router, b.s); add(router, c.s)
|
|
87
|
+
assert.equal(router.createCombo('my-combo', 'fallback', ['supB,b-model']).ok, true, '组合必须创建成功')
|
|
88
|
+
|
|
89
|
+
await router.chatCompletions(reqWith('my-combo'), fakeRes())
|
|
90
|
+
|
|
91
|
+
assert.equal(b.calls, 1, '目标供应商被调用')
|
|
92
|
+
assert.equal(a.calls, 0, '无关供应商不该被问')
|
|
93
|
+
assert.equal(c.calls, 0, '无关供应商不该被问')
|
|
94
|
+
assert.deepEqual(b.seen, ['b-model'], '插件收到的是自己的模型 id')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('直接调 alias/model:用别名反查供应商,精准命中', async () => {
|
|
98
|
+
const router = new Router('')
|
|
99
|
+
const a = spy('supA', 'a-model', 'aaa')
|
|
100
|
+
const b = spy('supB', 'b-model', 'bbb')
|
|
101
|
+
add(router, a.s); add(router, b.s)
|
|
102
|
+
|
|
103
|
+
await router.chatCompletions(reqWith('bbb/b-model'), fakeRes())
|
|
104
|
+
|
|
105
|
+
assert.equal(b.calls, 1)
|
|
106
|
+
assert.equal(a.calls, 0, '别名唯一,不该误命中别的供应商')
|
|
107
|
+
assert.deepEqual(b.seen, ['bbb/b-model'], '核心原样传全名,剥前缀是插件自己的事')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('同名模型不串台:两个供应商都有 gpt,各调各的', async () => {
|
|
111
|
+
const router = new Router('')
|
|
112
|
+
const a = spy('supA', 'gpt', 'aaa')
|
|
113
|
+
const b = spy('supB', 'gpt', 'bbb')
|
|
114
|
+
add(router, a.s); add(router, b.s)
|
|
115
|
+
|
|
116
|
+
await router.chatCompletions(reqWith('bbb/gpt'), fakeRes())
|
|
117
|
+
|
|
118
|
+
assert.equal(b.calls, 1)
|
|
119
|
+
assert.equal(a.calls, 0, 'supA 也有 gpt,但别名没指向它就不该被调用')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('模型 id 含斜杠时不被吃掉(如 deepseek-ai/xxx)', async () => {
|
|
123
|
+
const router = new Router('')
|
|
124
|
+
const a = spy('supA', 'deepseek-ai/deepseek-v3', 'aaa')
|
|
125
|
+
add(router, a.s)
|
|
126
|
+
|
|
127
|
+
await router.chatCompletions(reqWith('aaa/deepseek-ai/deepseek-v3'), fakeRes())
|
|
128
|
+
|
|
129
|
+
assert.equal(a.calls, 1)
|
|
130
|
+
// 核心原样传,插件只剥第一段(别名),命名空间保留
|
|
131
|
+
assert.deepEqual(a.seen, ['aaa/deepseek-ai/deepseek-v3'])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('旧数据裸模型 id 仍能工作(降级为遍历)', async () => {
|
|
135
|
+
const router = new Router('')
|
|
136
|
+
const a = spy('supA', 'a-model')
|
|
137
|
+
const b = spy('supB', 'b-model')
|
|
138
|
+
add(router, a.s); add(router, b.s)
|
|
139
|
+
// 模拟旧存储:裸 id,没有 supplierId 前缀
|
|
140
|
+
;(router as unknown as { customCombos: Array<{ id: string; name: string; strategy: 'fallback'; models: string[] }> })
|
|
141
|
+
.customCombos.push({ id: 'legacy', name: 'legacy', strategy: 'fallback', models: ['b-model'] })
|
|
142
|
+
|
|
143
|
+
await router.chatCompletions(reqWith('legacy'), fakeRes())
|
|
144
|
+
|
|
145
|
+
assert.equal(a.calls, 1, '降级路径会先问 supA(它报 no_such_model)')
|
|
146
|
+
assert.equal(b.calls, 1, '最终由 supB 服务')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('组合存了不存在的供应商 id → 调用失败,不静默落到别的供应商', async () => {
|
|
150
|
+
const router = new Router('')
|
|
151
|
+
const a = spy('supA', 'a-model')
|
|
152
|
+
add(router, a.s)
|
|
153
|
+
assert.equal(router.createCombo('broken', 'fallback', ['ghost,a-model']).ok, true, '组合必须创建成功')
|
|
154
|
+
|
|
155
|
+
const errs: unknown[] = []
|
|
156
|
+
const res = { ...fakeRes(), end: (b?: string): unknown => { if (b) errs.push(b); return undefined } } as unknown as ServerResponse
|
|
157
|
+
await router.chatCompletions(reqWith('broken'), res)
|
|
158
|
+
|
|
159
|
+
assert.equal(a.calls, 0, '不该兜底落到 supA')
|
|
160
|
+
assert.equal(errs.length, 1, '应返回失败响应')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('别名唯一性:冲突拒绝,自己改自己不冲突', () => {
|
|
164
|
+
const store = new SupplierConfigStore('') // 不落盘
|
|
165
|
+
const router = new Router('', store)
|
|
166
|
+
const a = spy('supA', 'a-model')
|
|
167
|
+
const b = spy('supB', 'b-model')
|
|
168
|
+
add(router, a.s); add(router, b.s) // add() 会 sync 已知 id
|
|
169
|
+
|
|
170
|
+
assert.equal(store.setAlias('supA', 'taken').ok, true)
|
|
171
|
+
assert.equal(store.setAlias('supB', 'taken').ok, false, '别名冲突应拒绝')
|
|
172
|
+
assert.equal(store.setAlias('supA', 'taken').ok, true, '自己改自己不算冲突')
|
|
173
|
+
// 空别名 = 用供应商 id(默认值),且同样参与唯一性
|
|
174
|
+
assert.equal(store.setAlias('supB', '').ok, true)
|
|
175
|
+
assert.equal(store.get('supB').alias, '')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('别名冲突时返回占用者,便于面板提示', () => {
|
|
179
|
+
const store = new SupplierConfigStore('')
|
|
180
|
+
const router = new Router('', store)
|
|
181
|
+
add(router, spy('supA', 'a-model').s)
|
|
182
|
+
add(router, spy('supB', 'b-model').s)
|
|
183
|
+
|
|
184
|
+
store.setAlias('supA', 'shared')
|
|
185
|
+
const r = store.setAlias('supB', 'shared')
|
|
186
|
+
assert.equal(r.ok, false)
|
|
187
|
+
assert.equal(r.conflictWith, 'supA')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('空别名 → 生效别名回落到供应商 id', () => {
|
|
191
|
+
const store = new SupplierConfigStore('')
|
|
192
|
+
const router = new Router('', store)
|
|
193
|
+
add(router, spy('supA', 'a-model').s)
|
|
194
|
+
|
|
195
|
+
assert.equal(store.effectiveAliases().get('supA'), 'supA', '没设别名时 = 供应商 id')
|
|
196
|
+
store.setAlias('supA', 'custom')
|
|
197
|
+
assert.equal(store.effectiveAliases().get('supA'), 'custom')
|
|
198
|
+
})
|