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/router/index.ts
CHANGED
|
@@ -81,6 +81,103 @@ function isTransient(state: AccountState): boolean {
|
|
|
81
81
|
return state === 'transport' || state === 'unavailable'
|
|
82
82
|
}
|
|
83
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
|
+
|
|
84
181
|
/**
|
|
85
182
|
* 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
|
|
86
183
|
* undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
|
|
@@ -355,8 +452,18 @@ function fixFrame(frame: string): string {
|
|
|
355
452
|
*
|
|
356
453
|
* 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
|
|
357
454
|
* 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
|
|
455
|
+
*
|
|
456
|
+
* @param firstByteTimeoutMs 上游多久内必须吐出第一个字节(见 FIRST_BYTE_BUDGET_MS)。
|
|
457
|
+
* 超时按「一个字节都没写」返回 false,调用方照常换号/换模型。
|
|
358
458
|
*/
|
|
359
|
-
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> {
|
|
360
467
|
if (!r.ok) return false // 失败不该走到这里(核心先判 ok 才写)
|
|
361
468
|
if (!('stream' in r)) {
|
|
362
469
|
if (probe !== undefined) probe.tokens = usageFromJsonBody(r.body)
|
|
@@ -364,19 +471,21 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
364
471
|
res.end(r.body)
|
|
365
472
|
return true
|
|
366
473
|
}
|
|
474
|
+
// 唯一收口:两条路径(聚合 / 透传)读的都是这层「套了首字节截止」的流。
|
|
475
|
+
const src = withFirstByteDeadline(r.stream, firstByteTimeoutMs)
|
|
367
476
|
// 客户端要 JSON 但供应商只给流(如 codebuddy 强制 stream:true)→ 聚合成
|
|
368
477
|
// 一次非流式响应。核心独占响应写入权,这个协议错配必须由核心吸收,
|
|
369
478
|
// 否则客户端按 JSON 解析会在 SSE 的 delta 里找不到工具名。
|
|
370
479
|
if (!wantsStream) {
|
|
371
480
|
let body: string
|
|
372
481
|
try {
|
|
373
|
-
body = await aggregateSSE(
|
|
482
|
+
body = await aggregateSSE(src)
|
|
374
483
|
} catch {
|
|
375
484
|
// 聚合失败 = 一个字节都没写,调用方可以换号重试。
|
|
376
485
|
// **但底层流必须 cancel**:aggregateSSE 内部 getReader() 消费到一半抛错时,
|
|
377
486
|
// reader 只是 releaseLock(),传输层毫无察觉 —— 那条上游连接会一直挂着。
|
|
378
487
|
// 不 cancel 就退化成「每次聚合失败漏一条连接」(issue #6 的路径 1)。
|
|
379
|
-
await
|
|
488
|
+
await src.cancel().catch(() => {})
|
|
380
489
|
return false
|
|
381
490
|
}
|
|
382
491
|
if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
|
|
@@ -386,7 +495,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
386
495
|
}
|
|
387
496
|
|
|
388
497
|
// 流式:边透传边统计(统计在 tee 之外的旁路做,首字节延迟不受影响)
|
|
389
|
-
const tapped = probe === undefined ? undefined : tapStreamUsage(
|
|
498
|
+
const tapped = probe === undefined ? undefined : tapStreamUsage(src, startedAt)
|
|
390
499
|
let wroteAny = false
|
|
391
500
|
/** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
|
|
392
501
|
let dead = false
|
|
@@ -449,7 +558,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
449
558
|
abort.abort()
|
|
450
559
|
}
|
|
451
560
|
res.once('close', onClose)
|
|
452
|
-
const norm = normalizeSSEStream(tapped?.stream ??
|
|
561
|
+
const norm = normalizeSSEStream(tapped?.stream ?? src)
|
|
453
562
|
try {
|
|
454
563
|
// signal 让 abort 时 pipeTo 立刻收尾(而不是等 drain);其收尾路径会
|
|
455
564
|
// cancel 源流,顺着 wrapper 的 cancel 一路传到上游 fetch body。
|
|
@@ -526,6 +635,12 @@ interface UsageProbe {
|
|
|
526
635
|
|
|
527
636
|
/** 路由器。 */
|
|
528
637
|
export class Router {
|
|
638
|
+
/**
|
|
639
|
+
* **已装载**的全部供应商(含被开关关掉的)。关掉的仍留在列表里 —— 否则用户关掉
|
|
640
|
+
* 之后它在 `/health`、面板、插件页里一起消失,开关就没有可指的对象、也没法再开。
|
|
641
|
+
*
|
|
642
|
+
* 真正参与路由的只有 `active` 那一份。
|
|
643
|
+
*/
|
|
529
644
|
private suppliers: Supplier[] = []
|
|
530
645
|
private combosFp = ''
|
|
531
646
|
private customCombos: Combo[] = []
|
|
@@ -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
|
}
|
|
@@ -589,7 +712,7 @@ 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 {
|
|
@@ -626,6 +749,22 @@ export class Router {
|
|
|
626
749
|
this.store.sync(this.suppliers.map((s) => s.id))
|
|
627
750
|
}
|
|
628
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
|
+
|
|
629
768
|
/** 移除供应商(外部插件卸载时注销)。 */
|
|
630
769
|
removeSupplier(id: string): boolean {
|
|
631
770
|
const i = this.suppliers.findIndex((s) => s.id === id)
|
|
@@ -636,14 +775,20 @@ export class Router {
|
|
|
636
775
|
return true
|
|
637
776
|
}
|
|
638
777
|
|
|
639
|
-
/**
|
|
640
|
-
|
|
641
|
-
|
|
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
|
+
}
|
|
642
787
|
}
|
|
643
788
|
|
|
644
|
-
/** 供应商前缀信息(组合模型全名 = alias/id
|
|
789
|
+
/** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。只含开着的。 */
|
|
645
790
|
aliases(): Array<{ id: string; name: string; alias: string }> {
|
|
646
|
-
return this.
|
|
791
|
+
return this.active.map((s) => ({ id: s.id, name: s.name, alias: s.getAlias() }))
|
|
647
792
|
}
|
|
648
793
|
|
|
649
794
|
/**
|
|
@@ -660,7 +805,7 @@ export class Router {
|
|
|
660
805
|
out.push({ id: c.name })
|
|
661
806
|
}
|
|
662
807
|
}
|
|
663
|
-
for (const s of this.
|
|
808
|
+
for (const s of this.active) {
|
|
664
809
|
try {
|
|
665
810
|
const ids = s.customModelIds?.()
|
|
666
811
|
for (const id of ids ?? []) {
|
|
@@ -729,7 +874,7 @@ export class Router {
|
|
|
729
874
|
}
|
|
730
875
|
|
|
731
876
|
async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
|
|
732
|
-
const s = this.
|
|
877
|
+
const s = this.active.find((x) => x.id === supplierId)
|
|
733
878
|
if (s === undefined) return []
|
|
734
879
|
const hit = this.modelsCache.get(supplierId)
|
|
735
880
|
const fresh = hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS
|
|
@@ -764,7 +909,14 @@ export class Router {
|
|
|
764
909
|
enabled: !disabled.has(mm.id),
|
|
765
910
|
custom: custom.has(mm.id) ? true : undefined,
|
|
766
911
|
}))
|
|
767
|
-
// 自定义模型(listModels
|
|
912
|
+
// 自定义模型(listModels 之外的)并入显示。
|
|
913
|
+
//
|
|
914
|
+
// 注意这里**刻意不**把「用户禁用过的模型」也补回来:插件的 listModels 只该报
|
|
915
|
+
// 「模型来源」,启用状态由核心合并(见 loader.ts 的 modelsWithEnabled)。
|
|
916
|
+
// 插件若在 listModels 里就把自己禁用的过滤掉,那是它违约 —— 该修的是插件,
|
|
917
|
+
// 核心不该反过来替它兜着。兜着的后果比表面症状更糟:面板看着正常了,
|
|
918
|
+
// 而体检的越权检测(probe.ts 的 hiddenDisabledIds)会因为模型已被补全而
|
|
919
|
+
// 永远报 OK,那个 bug 就再也没人发现了。
|
|
768
920
|
const seen = new Set(models.map((mm) => mm.id))
|
|
769
921
|
for (const id of custom) {
|
|
770
922
|
if (!seen.has(id)) {
|
|
@@ -801,7 +953,7 @@ export class Router {
|
|
|
801
953
|
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
802
954
|
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
803
955
|
async supplierModels(): Promise<Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }>> {
|
|
804
|
-
const groups = await Promise.all(this.
|
|
956
|
+
const groups = await Promise.all(this.active.map(async (s) => {
|
|
805
957
|
try {
|
|
806
958
|
const models = (await this.modelsOf(s.id)).filter((m) => m.enabled)
|
|
807
959
|
return { supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models }
|
|
@@ -839,7 +991,7 @@ export class Router {
|
|
|
839
991
|
if (slash <= 0) return m
|
|
840
992
|
const alias = m.slice(0, slash)
|
|
841
993
|
const modelId = m.slice(slash + 1)
|
|
842
|
-
const s = this.
|
|
994
|
+
const s = this.active.find((x) => x.getAlias() === alias)
|
|
843
995
|
return s === undefined ? modelId : `${s.id},${modelId}`
|
|
844
996
|
})
|
|
845
997
|
}
|
|
@@ -933,7 +1085,12 @@ export class Router {
|
|
|
933
1085
|
const model = combo.models[i]
|
|
934
1086
|
if (model === undefined) continue
|
|
935
1087
|
const trace: ChatTrace = { attempts: 0 }
|
|
936
|
-
|
|
1088
|
+
// 每条腿**独立**一份首字节预算:上一条腿已经等满 60s 也不该让下一条腿
|
|
1089
|
+
// 只剩 0ms(否则组合越多越没法用),但同一条腿内的多个号共享一份
|
|
1090
|
+
// (停滞是上游级的,按号各等一份会 11×60s)。
|
|
1091
|
+
const served = await this.chatWithModel(
|
|
1092
|
+
req, res, model, probe, trace, Date.now() + this.firstByteBudgetMs,
|
|
1093
|
+
)
|
|
937
1094
|
if (served) {
|
|
938
1095
|
settle(true)
|
|
939
1096
|
return
|
|
@@ -992,8 +1149,18 @@ export class Router {
|
|
|
992
1149
|
* @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
|
|
993
1150
|
* 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
|
|
994
1151
|
* 失败状态没有单一归属)。
|
|
1152
|
+
* @param firstByteDeadlineAt 这条腿的首字节截止时刻(绝对 ms)。同一条腿内的
|
|
1153
|
+
* 多个账号共享它——停滞是上游级的,按号各给一份会 11×60s 才轮到下一个模型。
|
|
1154
|
+
* 缺省 = 此刻起一份全新预算(直接调用路径只有这一条腿)。
|
|
995
1155
|
*/
|
|
996
|
-
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> {
|
|
997
1164
|
const comma = model.indexOf(',')
|
|
998
1165
|
const supplierId = comma > 0 ? model.slice(0, comma) : undefined
|
|
999
1166
|
// 组合存的是供应商 id,但插件认的是自己的模型 id
|
|
@@ -1017,14 +1184,19 @@ export class Router {
|
|
|
1017
1184
|
return false
|
|
1018
1185
|
}
|
|
1019
1186
|
// 精准:只调这一个供应商。查不到就是配置错了,直接失败(不遍历兜底)
|
|
1020
|
-
const s = this.
|
|
1187
|
+
const s = this.active.find((x) => x.id === supplierId)
|
|
1021
1188
|
if (s === undefined) {
|
|
1022
|
-
|
|
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)
|
|
1023
1195
|
return false
|
|
1024
1196
|
}
|
|
1025
1197
|
const t0 = Date.now()
|
|
1026
1198
|
const trace: ChatTrace = { attempts: 0 }
|
|
1027
|
-
const served = await this.chatWithSupplier(s, clone, res, trace, probe)
|
|
1199
|
+
const served = await this.chatWithSupplier(s, clone, res, trace, probe, firstByteDeadlineAt)
|
|
1028
1200
|
if (outTrace !== undefined) {
|
|
1029
1201
|
outTrace.attempts = trace.attempts
|
|
1030
1202
|
outTrace.lastError = trace.lastError
|
|
@@ -1038,7 +1210,7 @@ export class Router {
|
|
|
1038
1210
|
|
|
1039
1211
|
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
1040
1212
|
supplierByAlias(alias: string): Supplier | undefined {
|
|
1041
|
-
return this.
|
|
1213
|
+
return this.active.find((s) => s.getAlias() === alias)
|
|
1042
1214
|
}
|
|
1043
1215
|
|
|
1044
1216
|
/**
|
|
@@ -1049,8 +1221,24 @@ export class Router {
|
|
|
1049
1221
|
* 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
|
|
1050
1222
|
* 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
|
|
1051
1223
|
* 延迟,目前未做。
|
|
1224
|
+
*
|
|
1225
|
+
* @param firstByteDeadlineAt 这条腿的首字节截止时刻(见 chatWithModel)。
|
|
1226
|
+
* 预算耗尽后不再调任何号:剩下的号大概率同样停滞(上游级故障),
|
|
1227
|
+
* 挨个等下去只会把 60s 乘以号数,组合永远轮不到下一个模型。
|
|
1052
1228
|
*/
|
|
1053
|
-
|
|
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> {
|
|
1054
1242
|
const pool = s.pool
|
|
1055
1243
|
const cfg = this.store.get(s.id)
|
|
1056
1244
|
// 无账号供应商:直接调一次(uid 传空,插件忽略)
|
|
@@ -1074,7 +1262,9 @@ export class Router {
|
|
|
1074
1262
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
|
|
1075
1263
|
probe.uid = ''
|
|
1076
1264
|
}
|
|
1077
|
-
return await writeChatResult(
|
|
1265
|
+
return await writeChatResult(
|
|
1266
|
+
res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now(),
|
|
1267
|
+
)
|
|
1078
1268
|
}
|
|
1079
1269
|
// 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
|
|
1080
1270
|
// 不排除试过的就会原地打转——死循环等于整个服务挂住。
|
|
@@ -1084,8 +1274,32 @@ export class Router {
|
|
|
1084
1274
|
const tried = new Set<string>()
|
|
1085
1275
|
let pickWhy = ''
|
|
1086
1276
|
for (;;) {
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
+
}
|
|
1089
1303
|
pickWhy = pool.lastWhy
|
|
1090
1304
|
tried.add(uid)
|
|
1091
1305
|
const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
|
|
@@ -1124,13 +1338,13 @@ export class Router {
|
|
|
1124
1338
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
|
|
1125
1339
|
probe.uid = uid
|
|
1126
1340
|
}
|
|
1127
|
-
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())
|
|
1128
1342
|
if (committed) {
|
|
1129
1343
|
// 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
|
|
1130
1344
|
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session)
|
|
1131
1345
|
return true
|
|
1132
1346
|
}
|
|
1133
|
-
//
|
|
1347
|
+
// 一个字节都没写(上游刚连上就断 / 首字节超时)→ 这个号不算数,换下一个重试
|
|
1134
1348
|
pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
|
|
1135
1349
|
if (trace !== undefined) {
|
|
1136
1350
|
trace.attempts += 1
|
|
@@ -1140,11 +1354,17 @@ export class Router {
|
|
|
1140
1354
|
}
|
|
1141
1355
|
}
|
|
1142
1356
|
|
|
1143
|
-
/**
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
|
|
1147
|
-
|
|
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)
|
|
1148
1368
|
if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
|
|
1149
1369
|
const sink = sinkRes()
|
|
1150
1370
|
const req: ChatRequest = {
|
|
@@ -1155,7 +1375,7 @@ export class Router {
|
|
|
1155
1375
|
let served = false
|
|
1156
1376
|
const trace: ChatTrace = { attempts: 0 }
|
|
1157
1377
|
try {
|
|
1158
|
-
served = await this.chatWithSupplier(s, req, sink, trace)
|
|
1378
|
+
served = await this.chatWithSupplier(s, req, sink, trace, undefined, Date.now() + this.firstByteBudgetMs, onlyUid)
|
|
1159
1379
|
} catch (err) {
|
|
1160
1380
|
return { ok: false, error: (err as Error).message }
|
|
1161
1381
|
}
|
|
@@ -66,6 +66,12 @@ function addSupplier(router: Router, s: unknown): void {
|
|
|
66
66
|
;(router as unknown as { suppliers: unknown[] }).suppliers.push(s)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** 禁用某供应商的一个模型(store 是私有的,测试按同文件惯例断言访问)。 */
|
|
70
|
+
function disableModel(router: Router, supplierId: string, modelId: string): void {
|
|
71
|
+
;(router as unknown as { store: { setModelEnabled(id: string, m: string, enabled: boolean): void } })
|
|
72
|
+
.store.setModelEnabled(supplierId, modelId, false)
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
/**
|
|
70
76
|
* 把某供应商的缓存标记成「已过期」(TTL 现在 10 分钟,测试不能真等)。
|
|
71
77
|
* 白盒改 fetchedAt,模拟插件重启/TTL 到期的冷路径。
|
|
@@ -708,3 +714,42 @@ test('组合窗口:模型都没报 context_length → undefined', async () =>
|
|
|
708
714
|
'一家都没报就别编一个数(traework/nvidia 目前就是如此)',
|
|
709
715
|
)
|
|
710
716
|
})
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
/* ---- 核心不替违约插件兜底 ----
|
|
720
|
+
*
|
|
721
|
+
* 起因:插件在 listModels 里把用户禁用的模型过滤掉(越权 —— 启用状态归核心合并),
|
|
722
|
+
* 面板的「已禁用」列表于是空了。曾想过在核心按配置把缺的 id 补回来,**这是错的**:
|
|
723
|
+
*
|
|
724
|
+
* 1. 那是替有问题的插件擦屁股,插件永远不会被修;
|
|
725
|
+
* 2. 更糟的是**它会把体检的越权检测一起掩盖掉** —— 模型被补全之后,
|
|
726
|
+
* probe.ts 的 hiddenDisabledIds 找不到「少报的 id」,报告变成 OK,
|
|
727
|
+
* 第三方那个 bug 就再也没人知道了。
|
|
728
|
+
*
|
|
729
|
+
* 所以核心只负责:自己不合并不掩饰(插件少报就是少报),并让体检把违约报出来。
|
|
730
|
+
* 下面两条判据钉住这个方向,防止以后有人(我)再犯同样的错。
|
|
731
|
+
*/
|
|
732
|
+
|
|
733
|
+
test('核心不替违约插件补回已禁用的模型(要留着证据给体检)', async () => {
|
|
734
|
+
const router = new Router('')
|
|
735
|
+
const a = supplier('a', [{ id: 'a-1' }, { id: 'a-2' }])
|
|
736
|
+
// 越权实现:listModels 自己过滤掉了已禁用的
|
|
737
|
+
a.s.listModels = async (): Promise<ModelInfo[]> => [{ id: 'a-1' }]
|
|
738
|
+
addSupplier(router, a.s)
|
|
739
|
+
disableModel(router, 'a', 'a-2')
|
|
740
|
+
|
|
741
|
+
const models = await router.modelsOf('a')
|
|
742
|
+
assert.deepEqual(models.map((m) => m.id), ['a-1'],
|
|
743
|
+
'核心不该把插件藏掉的模型补回来 —— 补回来就等于替它兜底,越权检测也会失效')
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
test('正常供应商(listModels 全量返回):列表内容与启用状态照旧', async () => {
|
|
747
|
+
const router = new Router('')
|
|
748
|
+
const a = supplier('a', [{ id: 'a-1' }, { id: 'a-2' }])
|
|
749
|
+
addSupplier(router, a.s)
|
|
750
|
+
disableModel(router, 'a', 'a-2')
|
|
751
|
+
|
|
752
|
+
const models = await router.modelsOf('a')
|
|
753
|
+
assert.deepEqual(models.map((m) => m.id).sort(), ['a-1', 'a-2'])
|
|
754
|
+
assert.deepEqual(models.map((m) => m.enabled).sort(), [false, true], '启用状态仍由核心合并')
|
|
755
|
+
})
|