dsh-router-core 0.3.21 → 0.4.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 +238 -63
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +28 -5
- package/docs/pool-sticky-block.md +158 -0
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +1300 -549
- package/lib/client.js +1300 -549
- 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 +1169 -254
- 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 -7
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm/adapter.d.ts +35 -4
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +65 -15
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +25 -21
- package/lib/types/supplier-config.d.ts +15 -3
- package/lib/types/suppliers/contract.d.ts +37 -4
- 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 -2
- package/package.json +50 -16
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +32 -196
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- 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 +86 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +2592 -1635
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -7
- 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-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +165 -68
- package/src/llm/adapter.test.ts +175 -4
- package/src/llm/adapter.ts +109 -16
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +276 -62
- package/src/router/models-cache.test.ts +54 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +25 -24
- package/src/supplier-config.ts +33 -13
- package/src/suppliers/contract.ts +56 -4
- package/src/suppliers/loader.test.ts +38 -4
- 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 +103 -23
- package/src/types.d.ts +2 -1
package/src/router/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
|
|
3
|
-
* 仿 9router:组合 = 一组模型,请求 model
|
|
4
|
-
*
|
|
3
|
+
* 仿 9router:组合 = 一组模型,请求 model 命中组合名时,作为降级链
|
|
4
|
+
* 按序尝试选中一个模型路由(无策略旋钮,见 docs/pool-sticky-block.md §10)。
|
|
5
5
|
*/
|
|
6
6
|
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
7
7
|
import { dirname, join } from 'node:path'
|
|
@@ -24,6 +24,8 @@ interface ChatTrace {
|
|
|
24
24
|
lastError?: string
|
|
25
25
|
/** 最后一次失败的状态(组合据此判断要不要「喘口气」再降级)。 */
|
|
26
26
|
lastState?: AccountState
|
|
27
|
+
/** 本次选号的理由(诊断「为什么没粘住原号」)。 */
|
|
28
|
+
pickWhy?: string
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -79,6 +81,103 @@ function isTransient(state: AccountState): boolean {
|
|
|
79
81
|
return state === 'transport' || state === 'unavailable'
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* **每条降级腿的首字节预算**:从进入这条腿起,多久内上游必须吐出第一个字节。
|
|
86
|
+
*
|
|
87
|
+
* 为什么必须有(2026-09-24 夜里 money 组合实测):上游(codebuddy 系)额度用完 /
|
|
88
|
+
* 过载时,连接照常建立、HTTP 200 响应头照常回来,**body 却一个字节都不吐**。
|
|
89
|
+
* 这既不是成功也不是失败,于是
|
|
90
|
+
* - `chatOnce` 的 120s 只守「连接+响应头」,`fetch` 一返回就撤表 → 守不到 body
|
|
91
|
+
* - 核心写响应的路径也没有任何计时器 → 组合链卡在这一条腿上不动
|
|
92
|
+
* - 客户端(dsh-router 自己的 adapter)有 300s 流空闲超时,但那是「整条请求」
|
|
93
|
+
* 的兜底,与降级无关
|
|
94
|
+
* 实测用户干等 200 多秒手动暂停:ttfb 84s / 122s / 151s / 244s / 246s / 250s /
|
|
95
|
+
* 254s / 255s,8 次全部 `ok: true`——**全都最终成功了**,只是晚了四分钟。同一批
|
|
96
|
+
* 账号因为「没失败」也不冷却,下一次请求继续挑它(cb-8 连吃 248s + 155s)。
|
|
97
|
+
*
|
|
98
|
+
* 60s 的来历(本机 500 条真实请求的 ttfb 分布):p50 4.1s / p95 14.9s,
|
|
99
|
+
* 最大的**非停滞**值 47.7s,停滞簇从 84.5s 起 —— 60s 正落在两簇之间的空档,
|
|
100
|
+
* 砍得动停滞、砍不到真实慢请求。首个字节之后**不再计时**:长生成可以跑很久。
|
|
101
|
+
*
|
|
102
|
+
* 为什么按「腿」而不是按「账号」计:停滞是上游级的(同一供应商的所有号一起
|
|
103
|
+
* 挂)。按账号计的话,codebuddy 有 11 个号,一次请求能 11×60s = 11 分钟才轮到
|
|
104
|
+
* 下一个模型;按腿计,最坏 3 条腿 × 60s ≈ 3 分钟,且期间一定会走到下一个模型。
|
|
105
|
+
*/
|
|
106
|
+
const FIRST_BYTE_BUDGET_MS = 60_000
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 给上游流套一层**首字节截止**:截止前一个字节都没来,就把这条流判失败
|
|
110
|
+
* (error 出去,调用方看到「一个字节都没写」→ 换号/换模型)。
|
|
111
|
+
* 第一个字节到手后立刻撤表,之后多久都不管。
|
|
112
|
+
*
|
|
113
|
+
* 为什么套在核心而不是插件:首字节迟迟不来时,**只有核心**能做「还没提交响应
|
|
114
|
+
* → 换一条腿」的决定(插件在 `fetch` 拿到响应头那一刻就把流交出去了,
|
|
115
|
+
* 谁也没法再改结果)。核心也是所有供应商唯一的收口点,套一次全都受益。
|
|
116
|
+
*/
|
|
117
|
+
export function withFirstByteDeadline(stream: ReadableStream<Uint8Array>, ms: number): ReadableStream<Uint8Array> {
|
|
118
|
+
const reader = stream.getReader()
|
|
119
|
+
let first = true
|
|
120
|
+
/** 消费方已取消:这之后任何 ctrl.* 都会抛(流已死),必须全部跳过。 */
|
|
121
|
+
let cancelled = false
|
|
122
|
+
/**
|
|
123
|
+
* 上游 reader 只解绑一次。wrapper 替核心持有了这个 reader,所以**必须由
|
|
124
|
+
* wrapper 负责收尾**:上游正常读完 / 上游自己 error / 被取消时都解锁,
|
|
125
|
+
* 否则流永久锁着,上层想 cancel 会抛 `Invalid state: ReadableStream is locked`
|
|
126
|
+
* (disconnect-leak 那条判据守的就是这个;历史上由 aggregateSSE 的 finally 解锁,
|
|
127
|
+
* 现在读的是 wrapper,解绑责任跟着搬过来)。
|
|
128
|
+
*/
|
|
129
|
+
let released = false
|
|
130
|
+
const release = (): void => {
|
|
131
|
+
if (released) return
|
|
132
|
+
released = true
|
|
133
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
134
|
+
}
|
|
135
|
+
/** 取消要传到底下的上游(cancel 本身会顺带解锁)。 */
|
|
136
|
+
const cancelUpstream = (reason: unknown): Promise<void> => {
|
|
137
|
+
if (released) return Promise.resolve()
|
|
138
|
+
released = true
|
|
139
|
+
return reader.cancel(reason).then(() => {}, () => {})
|
|
140
|
+
}
|
|
141
|
+
return new ReadableStream<Uint8Array>({
|
|
142
|
+
async pull(ctrl) {
|
|
143
|
+
// 超时标志由计时器置位:置位后**不再碰 ctrl**(流已经 error 掉了,
|
|
144
|
+
// 再 close/enqueue 会抛 ERR_INVALID_STATE,把真正的失败原因盖掉)。
|
|
145
|
+
let expired = false
|
|
146
|
+
const timer = first
|
|
147
|
+
? setTimeout(() => {
|
|
148
|
+
expired = true
|
|
149
|
+
void cancelUpstream(new Error('first byte timeout'))
|
|
150
|
+
try { ctrl.error(new Error(`no first byte within ${ms}ms`)) } catch { /* 已被取消 */ }
|
|
151
|
+
}, ms)
|
|
152
|
+
: undefined
|
|
153
|
+
try {
|
|
154
|
+
const { done, value } = await reader.read()
|
|
155
|
+
if (expired || cancelled) return
|
|
156
|
+
if (done) {
|
|
157
|
+
release()
|
|
158
|
+
ctrl.close()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
first = false
|
|
162
|
+
ctrl.enqueue(value)
|
|
163
|
+
} catch (e) {
|
|
164
|
+
release()
|
|
165
|
+
if (!expired && !cancelled) {
|
|
166
|
+
try { ctrl.error(e) } catch { /* 已被取消 */ }
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
// 消费方取消必须穿过这层 wrapper 传到上游(与 normalizeSSEStream 同理,
|
|
173
|
+
// 不实现就等于每次客户端放弃都漏一条上游连接)。
|
|
174
|
+
cancel: (reason) => {
|
|
175
|
+
cancelled = true
|
|
176
|
+
return cancelUpstream(reason)
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
82
181
|
/**
|
|
83
182
|
* 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
|
|
84
183
|
* undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
|
|
@@ -353,8 +452,18 @@ function fixFrame(frame: string): string {
|
|
|
353
452
|
*
|
|
354
453
|
* 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
|
|
355
454
|
* 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
|
|
455
|
+
*
|
|
456
|
+
* @param firstByteTimeoutMs 上游多久内必须吐出第一个字节(见 FIRST_BYTE_BUDGET_MS)。
|
|
457
|
+
* 超时按「一个字节都没写」返回 false,调用方照常换号/换模型。
|
|
356
458
|
*/
|
|
357
|
-
async function writeChatResult(
|
|
459
|
+
async function writeChatResult(
|
|
460
|
+
res: ServerResponse,
|
|
461
|
+
r: ChatOnceResult,
|
|
462
|
+
wantsStream: boolean,
|
|
463
|
+
probe?: UsageProbe,
|
|
464
|
+
startedAt = 0,
|
|
465
|
+
firstByteTimeoutMs = FIRST_BYTE_BUDGET_MS,
|
|
466
|
+
): Promise<boolean> {
|
|
358
467
|
if (!r.ok) return false // 失败不该走到这里(核心先判 ok 才写)
|
|
359
468
|
if (!('stream' in r)) {
|
|
360
469
|
if (probe !== undefined) probe.tokens = usageFromJsonBody(r.body)
|
|
@@ -362,19 +471,21 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
362
471
|
res.end(r.body)
|
|
363
472
|
return true
|
|
364
473
|
}
|
|
474
|
+
// 唯一收口:两条路径(聚合 / 透传)读的都是这层「套了首字节截止」的流。
|
|
475
|
+
const src = withFirstByteDeadline(r.stream, firstByteTimeoutMs)
|
|
365
476
|
// 客户端要 JSON 但供应商只给流(如 codebuddy 强制 stream:true)→ 聚合成
|
|
366
477
|
// 一次非流式响应。核心独占响应写入权,这个协议错配必须由核心吸收,
|
|
367
478
|
// 否则客户端按 JSON 解析会在 SSE 的 delta 里找不到工具名。
|
|
368
479
|
if (!wantsStream) {
|
|
369
480
|
let body: string
|
|
370
481
|
try {
|
|
371
|
-
body = await aggregateSSE(
|
|
482
|
+
body = await aggregateSSE(src)
|
|
372
483
|
} catch {
|
|
373
484
|
// 聚合失败 = 一个字节都没写,调用方可以换号重试。
|
|
374
485
|
// **但底层流必须 cancel**:aggregateSSE 内部 getReader() 消费到一半抛错时,
|
|
375
486
|
// reader 只是 releaseLock(),传输层毫无察觉 —— 那条上游连接会一直挂着。
|
|
376
487
|
// 不 cancel 就退化成「每次聚合失败漏一条连接」(issue #6 的路径 1)。
|
|
377
|
-
await
|
|
488
|
+
await src.cancel().catch(() => {})
|
|
378
489
|
return false
|
|
379
490
|
}
|
|
380
491
|
if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
|
|
@@ -384,7 +495,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
384
495
|
}
|
|
385
496
|
|
|
386
497
|
// 流式:边透传边统计(统计在 tee 之外的旁路做,首字节延迟不受影响)
|
|
387
|
-
const tapped = probe === undefined ? undefined : tapStreamUsage(
|
|
498
|
+
const tapped = probe === undefined ? undefined : tapStreamUsage(src, startedAt)
|
|
388
499
|
let wroteAny = false
|
|
389
500
|
/** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
|
|
390
501
|
let dead = false
|
|
@@ -447,7 +558,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
447
558
|
abort.abort()
|
|
448
559
|
}
|
|
449
560
|
res.once('close', onClose)
|
|
450
|
-
const norm = normalizeSSEStream(tapped?.stream ??
|
|
561
|
+
const norm = normalizeSSEStream(tapped?.stream ?? src)
|
|
451
562
|
try {
|
|
452
563
|
// signal 让 abort 时 pipeTo 立刻收尾(而不是等 drain);其收尾路径会
|
|
453
564
|
// cancel 源流,顺着 wrapper 的 cancel 一路传到上游 fetch body。
|
|
@@ -524,12 +635,16 @@ interface UsageProbe {
|
|
|
524
635
|
|
|
525
636
|
/** 路由器。 */
|
|
526
637
|
export class Router {
|
|
638
|
+
/**
|
|
639
|
+
* **已装载**的全部供应商(含被开关关掉的)。关掉的仍留在列表里 —— 否则用户关掉
|
|
640
|
+
* 之后它在 `/health`、面板、插件页里一起消失,开关就没有可指的对象、也没法再开。
|
|
641
|
+
*
|
|
642
|
+
* 真正参与路由的只有 `active` 那一份。
|
|
643
|
+
*/
|
|
527
644
|
private suppliers: Supplier[] = []
|
|
528
645
|
private combosFp = ''
|
|
529
646
|
private customCombos: Combo[] = []
|
|
530
|
-
/**
|
|
531
|
-
private rrCursors = new Map<string, number>()
|
|
532
|
-
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
647
|
+
/** 通用供应商配置(连接池顺序、模型启用、别名)。 */
|
|
533
648
|
private store: SupplierConfigStore
|
|
534
649
|
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
535
650
|
private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
|
|
@@ -540,13 +655,21 @@ export class Router {
|
|
|
540
655
|
private modelsInflight = new Map<string, Promise<ModelWithEnabled[]>>()
|
|
541
656
|
/** 请求日志出口(面板/宿主 logger)。 */
|
|
542
657
|
private log: (msg: string) => void
|
|
658
|
+
/** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
|
|
659
|
+
private firstByteBudgetMs: number
|
|
543
660
|
/** 用量统计(概览看板)。 */
|
|
544
661
|
usage: UsageStore
|
|
545
662
|
|
|
546
|
-
constructor(
|
|
663
|
+
constructor(
|
|
664
|
+
stateFile = '',
|
|
665
|
+
store?: SupplierConfigStore,
|
|
666
|
+
log?: (msg: string) => void,
|
|
667
|
+
opts?: { firstByteBudgetMs?: number },
|
|
668
|
+
) {
|
|
547
669
|
this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
|
|
548
670
|
this.store = store ?? new SupplierConfigStore(stateFile)
|
|
549
671
|
this.log = log ?? ((): void => {})
|
|
672
|
+
this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS
|
|
550
673
|
this.usage = new UsageStore(stateFile)
|
|
551
674
|
this.loadCombos()
|
|
552
675
|
}
|
|
@@ -565,7 +688,7 @@ export class Router {
|
|
|
565
688
|
try {
|
|
566
689
|
const f = JSON.parse(readFileSync(this.combosFp, 'utf8')) as { combos?: Combo[] }
|
|
567
690
|
if (Array.isArray(f.combos)) {
|
|
568
|
-
// 兼容旧格式:steps(供应商) → models(模型)
|
|
691
|
+
// 兼容旧格式:steps(供应商) → models(模型);旧 strategy 字段静默忽略。
|
|
569
692
|
this.customCombos = f.combos
|
|
570
693
|
.filter((c) => typeof c.id === 'string' && c.id !== '')
|
|
571
694
|
.map((c) => {
|
|
@@ -589,13 +712,12 @@ export class Router {
|
|
|
589
712
|
if (slash <= 0) return m // 裸 id:保持,路由降级为遍历
|
|
590
713
|
const alias = m.slice(0, slash)
|
|
591
714
|
const modelId = m.slice(slash + 1)
|
|
592
|
-
const s = this.
|
|
715
|
+
const s = this.active.find((x) => x.getAlias() === alias)
|
|
593
716
|
return s === undefined ? modelId : `${s.id},${modelId}`
|
|
594
717
|
})
|
|
595
718
|
return {
|
|
596
719
|
id: c.id,
|
|
597
720
|
name: typeof c.name === 'string' ? c.name : c.id,
|
|
598
|
-
strategy: c.strategy === 'round-robin' ? 'round-robin' as const : 'fallback' as const,
|
|
599
721
|
models,
|
|
600
722
|
}
|
|
601
723
|
})
|
|
@@ -627,6 +749,22 @@ export class Router {
|
|
|
627
749
|
this.store.sync(this.suppliers.map((s) => s.id))
|
|
628
750
|
}
|
|
629
751
|
|
|
752
|
+
/**
|
|
753
|
+
* **参与路由的供应商** —— 装载清单里开关为开的那一份,按 priority 排好。
|
|
754
|
+
*
|
|
755
|
+
* 这是「供应商开关」唯一的收口点:请求分发、别名反查、模型列表、组合、面板状态
|
|
756
|
+
* 全部读它。关掉一个供应商 = 它从这里消失,而不是在每个调用点各写一遍判断 ——
|
|
757
|
+
* 后者一定会漏(本次实测路由有 15 处读 `this.suppliers`)。
|
|
758
|
+
*/
|
|
759
|
+
private get active(): Supplier[] {
|
|
760
|
+
return this.suppliers.filter((s) => this.store.isEnabled(s.id))
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** 某供应商当前是否参与路由(面板与 API 用)。 */
|
|
764
|
+
isEnabled(supplierId: string): boolean {
|
|
765
|
+
return this.store.isEnabled(supplierId)
|
|
766
|
+
}
|
|
767
|
+
|
|
630
768
|
/** 移除供应商(外部插件卸载时注销)。 */
|
|
631
769
|
removeSupplier(id: string): boolean {
|
|
632
770
|
const i = this.suppliers.findIndex((s) => s.id === id)
|
|
@@ -637,14 +775,20 @@ export class Router {
|
|
|
637
775
|
return true
|
|
638
776
|
}
|
|
639
777
|
|
|
640
|
-
/**
|
|
641
|
-
|
|
642
|
-
|
|
778
|
+
/**
|
|
779
|
+
* 返回全部供应商状态(面板用)。**含被开关关掉的** —— 面板要能显示它们
|
|
780
|
+
* (「已关闭」),否则用户在面板上就再也看不到自己装过的供应商。
|
|
781
|
+
* 开关状态由 `enabled` 字段带出,调用方自己决定要不要只留开着的。
|
|
782
|
+
*/
|
|
783
|
+
status(): { suppliers: (SupplierStatus & { enabled: boolean })[] } {
|
|
784
|
+
return {
|
|
785
|
+
suppliers: this.suppliers.map((s) => ({ ...s.status(), enabled: this.store.isEnabled(s.id) })),
|
|
786
|
+
}
|
|
643
787
|
}
|
|
644
788
|
|
|
645
|
-
/** 供应商前缀信息(组合模型全名 = alias/id
|
|
789
|
+
/** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。只含开着的。 */
|
|
646
790
|
aliases(): Array<{ id: string; name: string; alias: string }> {
|
|
647
|
-
return this.
|
|
791
|
+
return this.active.map((s) => ({ id: s.id, name: s.name, alias: s.getAlias() }))
|
|
648
792
|
}
|
|
649
793
|
|
|
650
794
|
/**
|
|
@@ -661,7 +805,7 @@ export class Router {
|
|
|
661
805
|
out.push({ id: c.name })
|
|
662
806
|
}
|
|
663
807
|
}
|
|
664
|
-
for (const s of this.
|
|
808
|
+
for (const s of this.active) {
|
|
665
809
|
try {
|
|
666
810
|
const ids = s.customModelIds?.()
|
|
667
811
|
for (const id of ids ?? []) {
|
|
@@ -730,7 +874,7 @@ export class Router {
|
|
|
730
874
|
}
|
|
731
875
|
|
|
732
876
|
async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
|
|
733
|
-
const s = this.
|
|
877
|
+
const s = this.active.find((x) => x.id === supplierId)
|
|
734
878
|
if (s === undefined) return []
|
|
735
879
|
const hit = this.modelsCache.get(supplierId)
|
|
736
880
|
const fresh = hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS
|
|
@@ -765,7 +909,14 @@ export class Router {
|
|
|
765
909
|
enabled: !disabled.has(mm.id),
|
|
766
910
|
custom: custom.has(mm.id) ? true : undefined,
|
|
767
911
|
}))
|
|
768
|
-
// 自定义模型(listModels
|
|
912
|
+
// 自定义模型(listModels 之外的)并入显示。
|
|
913
|
+
//
|
|
914
|
+
// 注意这里**刻意不**把「用户禁用过的模型」也补回来:插件的 listModels 只该报
|
|
915
|
+
// 「模型来源」,启用状态由核心合并(见 loader.ts 的 modelsWithEnabled)。
|
|
916
|
+
// 插件若在 listModels 里就把自己禁用的过滤掉,那是它违约 —— 该修的是插件,
|
|
917
|
+
// 核心不该反过来替它兜着。兜着的后果比表面症状更糟:面板看着正常了,
|
|
918
|
+
// 而体检的越权检测(probe.ts 的 hiddenDisabledIds)会因为模型已被补全而
|
|
919
|
+
// 永远报 OK,那个 bug 就再也没人发现了。
|
|
769
920
|
const seen = new Set(models.map((mm) => mm.id))
|
|
770
921
|
for (const id of custom) {
|
|
771
922
|
if (!seen.has(id)) {
|
|
@@ -802,7 +953,7 @@ export class Router {
|
|
|
802
953
|
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
803
954
|
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
804
955
|
async supplierModels(): Promise<Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }>> {
|
|
805
|
-
const groups = await Promise.all(this.
|
|
956
|
+
const groups = await Promise.all(this.active.map(async (s) => {
|
|
806
957
|
try {
|
|
807
958
|
const models = (await this.modelsOf(s.id)).filter((m) => m.enabled)
|
|
808
959
|
return { supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models }
|
|
@@ -819,10 +970,6 @@ export class Router {
|
|
|
819
970
|
typeof m === 'string' && m !== '')
|
|
820
971
|
}
|
|
821
972
|
|
|
822
|
-
private validStrategy(strategy: string | undefined): strategy is 'fallback' | 'round-robin' {
|
|
823
|
-
return strategy === 'fallback' || strategy === 'round-robin'
|
|
824
|
-
}
|
|
825
|
-
|
|
826
973
|
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
827
974
|
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
828
975
|
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
@@ -844,27 +991,26 @@ export class Router {
|
|
|
844
991
|
if (slash <= 0) return m
|
|
845
992
|
const alias = m.slice(0, slash)
|
|
846
993
|
const modelId = m.slice(slash + 1)
|
|
847
|
-
const s = this.
|
|
994
|
+
const s = this.active.find((x) => x.getAlias() === alias)
|
|
848
995
|
return s === undefined ? modelId : `${s.id},${modelId}`
|
|
849
996
|
})
|
|
850
997
|
}
|
|
851
998
|
|
|
852
|
-
/** 创建组合(name 唯一,非 default
|
|
853
|
-
createCombo(name: string,
|
|
999
|
+
/** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
|
|
1000
|
+
createCombo(name: string, models: string[]): { ok: boolean; error?: string; combo?: Combo } {
|
|
854
1001
|
const clean = name.trim()
|
|
855
1002
|
if (clean === '' || clean === 'default') return { ok: false, error: '组合名无效' }
|
|
856
1003
|
if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
|
|
857
1004
|
if (this.customCombos.some((c) => c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
|
|
858
1005
|
if (!this.validModels(models)) return { ok: false, error: '至少需要一个模型' }
|
|
859
|
-
|
|
860
|
-
const combo: Combo = { id: clean, name: clean, strategy, models: this.normalizeModelIds(models) }
|
|
1006
|
+
const combo: Combo = { id: clean, name: clean, models: this.normalizeModelIds(models) }
|
|
861
1007
|
this.customCombos.push(combo)
|
|
862
1008
|
this.saveCombos()
|
|
863
1009
|
return { ok: true, combo }
|
|
864
1010
|
}
|
|
865
1011
|
|
|
866
1012
|
/** 更新组合(按 id)。 */
|
|
867
|
-
updateCombo(id: string, name: string,
|
|
1013
|
+
updateCombo(id: string, name: string, models: string[]): { ok: boolean; error?: string } {
|
|
868
1014
|
const target = this.customCombos.find((c) => c.id === id)
|
|
869
1015
|
if (!target) return { ok: false, error: '组合不存在' }
|
|
870
1016
|
const clean = name.trim()
|
|
@@ -872,9 +1018,7 @@ export class Router {
|
|
|
872
1018
|
if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
|
|
873
1019
|
if (this.customCombos.some((c) => c.id !== id && c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
|
|
874
1020
|
if (!this.validModels(models)) return { ok: false, error: '至少需要一个模型' }
|
|
875
|
-
if (!this.validStrategy(strategy)) return { ok: false, error: '策略无效' }
|
|
876
1021
|
target.name = clean
|
|
877
|
-
target.strategy = strategy
|
|
878
1022
|
target.models = this.normalizeModelIds(models)
|
|
879
1023
|
this.saveCombos()
|
|
880
1024
|
return { ok: true }
|
|
@@ -929,22 +1073,24 @@ export class Router {
|
|
|
929
1073
|
ttfbMs: probe.ttfbMs,
|
|
930
1074
|
// 只有真被服务过才记 uid:失败请求的 uid 可能是空或上一次的残留
|
|
931
1075
|
...(ok && probe.uid !== '' ? { uid: probe.uid } : {}),
|
|
1076
|
+
...(req.session === undefined ? {} : { session: req.session }),
|
|
932
1077
|
...(error === undefined ? {} : { error }),
|
|
933
1078
|
}, usage)
|
|
934
1079
|
}
|
|
935
1080
|
|
|
936
1081
|
const combo = this.comboByName(req.model)
|
|
937
1082
|
if (combo) {
|
|
938
|
-
//
|
|
939
|
-
const start = combo.strategy === 'round-robin'
|
|
940
|
-
? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length
|
|
941
|
-
: 0
|
|
942
|
-
if (combo.strategy === 'round-robin') this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1)
|
|
1083
|
+
// 组合 = 降级链:从第一个模型起顺序尝试,谁先服务成功就停。
|
|
943
1084
|
for (let i = 0; i < combo.models.length; i++) {
|
|
944
|
-
const model = combo.models[
|
|
1085
|
+
const model = combo.models[i]
|
|
945
1086
|
if (model === undefined) continue
|
|
946
1087
|
const trace: ChatTrace = { attempts: 0 }
|
|
947
|
-
|
|
1088
|
+
// 每条腿**独立**一份首字节预算:上一条腿已经等满 60s 也不该让下一条腿
|
|
1089
|
+
// 只剩 0ms(否则组合越多越没法用),但同一条腿内的多个号共享一份
|
|
1090
|
+
// (停滞是上游级的,按号各等一份会 11×60s)。
|
|
1091
|
+
const served = await this.chatWithModel(
|
|
1092
|
+
req, res, model, probe, trace, Date.now() + this.firstByteBudgetMs,
|
|
1093
|
+
)
|
|
948
1094
|
if (served) {
|
|
949
1095
|
settle(true)
|
|
950
1096
|
return
|
|
@@ -1003,8 +1149,18 @@ export class Router {
|
|
|
1003
1149
|
* @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
|
|
1004
1150
|
* 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
|
|
1005
1151
|
* 失败状态没有单一归属)。
|
|
1152
|
+
* @param firstByteDeadlineAt 这条腿的首字节截止时刻(绝对 ms)。同一条腿内的
|
|
1153
|
+
* 多个账号共享它——停滞是上游级的,按号各给一份会 11×60s 才轮到下一个模型。
|
|
1154
|
+
* 缺省 = 此刻起一份全新预算(直接调用路径只有这一条腿)。
|
|
1006
1155
|
*/
|
|
1007
|
-
private async chatWithModel(
|
|
1156
|
+
private async chatWithModel(
|
|
1157
|
+
req: ChatRequest,
|
|
1158
|
+
res: ServerResponse,
|
|
1159
|
+
model: string,
|
|
1160
|
+
probe?: UsageProbe,
|
|
1161
|
+
outTrace?: ChatTrace,
|
|
1162
|
+
firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs,
|
|
1163
|
+
): Promise<boolean> {
|
|
1008
1164
|
const comma = model.indexOf(',')
|
|
1009
1165
|
const supplierId = comma > 0 ? model.slice(0, comma) : undefined
|
|
1010
1166
|
// 组合存的是供应商 id,但插件认的是自己的模型 id
|
|
@@ -1028,28 +1184,33 @@ export class Router {
|
|
|
1028
1184
|
return false
|
|
1029
1185
|
}
|
|
1030
1186
|
// 精准:只调这一个供应商。查不到就是配置错了,直接失败(不遍历兜底)
|
|
1031
|
-
const s = this.
|
|
1187
|
+
const s = this.active.find((x) => x.id === supplierId)
|
|
1032
1188
|
if (s === undefined) {
|
|
1033
|
-
|
|
1189
|
+
// 「被开关关掉」与「不存在」对请求是同一件事:都没有可用的上游。分开说
|
|
1190
|
+
// 是为了日志能直接看出是用户关了它。
|
|
1191
|
+
const reason = this.suppliers.some((x) => x.id === supplierId)
|
|
1192
|
+
? `supplier ${JSON.stringify(supplierId)} 已关闭`
|
|
1193
|
+
: `supplier ${JSON.stringify(supplierId)} 不存在`
|
|
1194
|
+
this.logChat(req.model, reason, 0)
|
|
1034
1195
|
return false
|
|
1035
1196
|
}
|
|
1036
1197
|
const t0 = Date.now()
|
|
1037
1198
|
const trace: ChatTrace = { attempts: 0 }
|
|
1038
|
-
const served = await this.chatWithSupplier(s, clone, res, trace, probe)
|
|
1199
|
+
const served = await this.chatWithSupplier(s, clone, res, trace, probe, firstByteDeadlineAt)
|
|
1039
1200
|
if (outTrace !== undefined) {
|
|
1040
1201
|
outTrace.attempts = trace.attempts
|
|
1041
1202
|
outTrace.lastError = trace.lastError
|
|
1042
1203
|
outTrace.lastState = trace.lastState
|
|
1043
1204
|
}
|
|
1044
1205
|
this.logChat(req.model, served
|
|
1045
|
-
? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}`
|
|
1206
|
+
? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}${trace.pickWhy !== undefined ? ` [选号: ${trace.pickWhy}]` : ''}`
|
|
1046
1207
|
: `${s.id}/${modelId} 失败 ${trace.lastError ?? 'no account'}`, Date.now() - t0)
|
|
1047
1208
|
return served
|
|
1048
1209
|
}
|
|
1049
1210
|
|
|
1050
1211
|
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
1051
1212
|
supplierByAlias(alias: string): Supplier | undefined {
|
|
1052
|
-
return this.
|
|
1213
|
+
return this.active.find((s) => s.getAlias() === alias)
|
|
1053
1214
|
}
|
|
1054
1215
|
|
|
1055
1216
|
/**
|
|
@@ -1060,8 +1221,24 @@ export class Router {
|
|
|
1060
1221
|
* 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
|
|
1061
1222
|
* 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
|
|
1062
1223
|
* 延迟,目前未做。
|
|
1224
|
+
*
|
|
1225
|
+
* @param firstByteDeadlineAt 这条腿的首字节截止时刻(见 chatWithModel)。
|
|
1226
|
+
* 预算耗尽后不再调任何号:剩下的号大概率同样停滞(上游级故障),
|
|
1227
|
+
* 挨个等下去只会把 60s 乘以号数,组合永远轮不到下一个模型。
|
|
1063
1228
|
*/
|
|
1064
|
-
|
|
1229
|
+
/**
|
|
1230
|
+
* @param onlyUid - 只试这一个连接,**不做账号池回退**。给面板「指定连接」那档用
|
|
1231
|
+
* (诊断单个链接到底通不通);正常请求与普通模型测试都不传,走账号池。
|
|
1232
|
+
*/
|
|
1233
|
+
private async chatWithSupplier(
|
|
1234
|
+
s: Supplier,
|
|
1235
|
+
req: ChatRequest,
|
|
1236
|
+
res: ServerResponse,
|
|
1237
|
+
trace?: ChatTrace,
|
|
1238
|
+
probe?: UsageProbe,
|
|
1239
|
+
firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs,
|
|
1240
|
+
onlyUid?: string,
|
|
1241
|
+
): Promise<boolean> {
|
|
1065
1242
|
const pool = s.pool
|
|
1066
1243
|
const cfg = this.store.get(s.id)
|
|
1067
1244
|
// 无账号供应商:直接调一次(uid 传空,插件忽略)
|
|
@@ -1085,7 +1262,9 @@ export class Router {
|
|
|
1085
1262
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
|
|
1086
1263
|
probe.uid = ''
|
|
1087
1264
|
}
|
|
1088
|
-
return await writeChatResult(
|
|
1265
|
+
return await writeChatResult(
|
|
1266
|
+
res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now(),
|
|
1267
|
+
)
|
|
1089
1268
|
}
|
|
1090
1269
|
// 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
|
|
1091
1270
|
// 不排除试过的就会原地打转——死循环等于整个服务挂住。
|
|
@@ -1093,9 +1272,35 @@ export class Router {
|
|
|
1093
1272
|
// 否则同一会话的两次尝试会绑到不同的号上)。
|
|
1094
1273
|
const messages = reqMessages(req.rawBody)
|
|
1095
1274
|
const tried = new Set<string>()
|
|
1275
|
+
let pickWhy = ''
|
|
1096
1276
|
for (;;) {
|
|
1097
|
-
|
|
1098
|
-
|
|
1277
|
+
// 指定连接:只试它。选不中就直接失败并说明原因(连接已不存在/不在池里),
|
|
1278
|
+
// **不回退到别的号** —— 否则「测这个连接」测出来的是另一个号的结果,
|
|
1279
|
+
// 诊断就变成了假话。
|
|
1280
|
+
const uid = onlyUid !== undefined
|
|
1281
|
+
? (s.accounts().some((a) => a.uid === onlyUid) && !tried.has(onlyUid) ? onlyUid : undefined)
|
|
1282
|
+
: pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session)
|
|
1283
|
+
if (uid === undefined) {
|
|
1284
|
+
if (onlyUid !== undefined && trace !== undefined) {
|
|
1285
|
+
// 不给 AccountState 加「连接不存在」这个值:那是给**账号的失败**分类用的
|
|
1286
|
+
// (决定要不要冷却),而链接不存在不是任何账号的错,混进去会让所有分类逻辑
|
|
1287
|
+
// 都要认识它。具体原因放 lastError 就够。
|
|
1288
|
+
trace.lastState = 'unknown'
|
|
1289
|
+
trace.lastError = `连接 ${JSON.stringify(onlyUid)} 不存在(可能已被删除)`
|
|
1290
|
+
}
|
|
1291
|
+
return false
|
|
1292
|
+
}
|
|
1293
|
+
// 腿的首字节预算已经耗尽:不再调下一个号。停滞是上游级的,剩下那些号
|
|
1294
|
+
// 挨个等下去 = 60s × 号数,组合永远轮不到下一个模型(2026-09-24 实测)。
|
|
1295
|
+
const left = firstByteDeadlineAt - Date.now()
|
|
1296
|
+
if (left <= 0) {
|
|
1297
|
+
if (trace !== undefined) {
|
|
1298
|
+
trace.lastState = 'transport'
|
|
1299
|
+
trace.lastError = 'first-byte budget exhausted'
|
|
1300
|
+
}
|
|
1301
|
+
return false
|
|
1302
|
+
}
|
|
1303
|
+
pickWhy = pool.lastWhy
|
|
1099
1304
|
tried.add(uid)
|
|
1100
1305
|
const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
|
|
1101
1306
|
if (!r.ok) {
|
|
@@ -1121,7 +1326,10 @@ export class Router {
|
|
|
1121
1326
|
continue
|
|
1122
1327
|
}
|
|
1123
1328
|
pool.noteSuccess(uid, req.model)
|
|
1124
|
-
if (trace !== undefined)
|
|
1329
|
+
if (trace !== undefined) {
|
|
1330
|
+
trace.uid = uid
|
|
1331
|
+
trace.pickWhy = pickWhy
|
|
1332
|
+
}
|
|
1125
1333
|
if (probe !== undefined) {
|
|
1126
1334
|
probe.supplier = s.id
|
|
1127
1335
|
// 记对外全名 alias/model:与直接调用路径一致。组合里存的是裸
|
|
@@ -1130,13 +1338,13 @@ export class Router {
|
|
|
1130
1338
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
|
|
1131
1339
|
probe.uid = uid
|
|
1132
1340
|
}
|
|
1133
|
-
const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
|
|
1341
|
+
const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now())
|
|
1134
1342
|
if (committed) {
|
|
1135
1343
|
// 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
|
|
1136
|
-
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0)
|
|
1344
|
+
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session)
|
|
1137
1345
|
return true
|
|
1138
1346
|
}
|
|
1139
|
-
//
|
|
1347
|
+
// 一个字节都没写(上游刚连上就断 / 首字节超时)→ 这个号不算数,换下一个重试
|
|
1140
1348
|
pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
|
|
1141
1349
|
if (trace !== undefined) {
|
|
1142
1350
|
trace.attempts += 1
|
|
@@ -1146,11 +1354,17 @@ export class Router {
|
|
|
1146
1354
|
}
|
|
1147
1355
|
}
|
|
1148
1356
|
|
|
1149
|
-
/**
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
|
|
1153
|
-
|
|
1357
|
+
/**
|
|
1358
|
+
* 测试某供应商的某模型是否可用。
|
|
1359
|
+
*
|
|
1360
|
+
* 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
|
|
1361
|
+
* 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。
|
|
1362
|
+
*
|
|
1363
|
+
* @param onlyUid - 限定只测这一个连接(诊断单个链接),**不做账号池回退**。
|
|
1364
|
+
* 不传 = 与面板「测试」按钮完全同一条路径(池内任选可用号,失败自动换号)。
|
|
1365
|
+
*/
|
|
1366
|
+
async testModel(supplierId: string, model: string, onlyUid?: string): Promise<{ ok: boolean; error?: string }> {
|
|
1367
|
+
const s = this.active.find((x) => x.id === supplierId)
|
|
1154
1368
|
if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
|
|
1155
1369
|
const sink = sinkRes()
|
|
1156
1370
|
const req: ChatRequest = {
|
|
@@ -1161,7 +1375,7 @@ export class Router {
|
|
|
1161
1375
|
let served = false
|
|
1162
1376
|
const trace: ChatTrace = { attempts: 0 }
|
|
1163
1377
|
try {
|
|
1164
|
-
served = await this.chatWithSupplier(s, req, sink, trace)
|
|
1378
|
+
served = await this.chatWithSupplier(s, req, sink, trace, undefined, Date.now() + this.firstByteBudgetMs, onlyUid)
|
|
1165
1379
|
} catch (err) {
|
|
1166
1380
|
return { ok: false, error: (err as Error).message }
|
|
1167
1381
|
}
|