dsh-router-core 0.4.4 → 0.4.5
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/LICENSE +0 -0
- package/README.md +0 -0
- package/cordis.patch.yml +0 -0
- package/docs/ext.md +0 -0
- package/docs/pool-sticky-block.md +59 -1
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +20 -1
- package/lib/client-registry.js +24 -14
- package/lib/client.js +24 -14
- package/lib/ext-prompt/locale/en.json +0 -0
- package/lib/ext-prompt/locale/zh.json +0 -0
- package/lib/ext-prompt/package.json +0 -0
- package/lib/ext-test/locale/en.json +0 -0
- package/lib/ext-test/locale/zh.json +0 -0
- package/lib/ext-test/package.json +0 -0
- package/lib/index.js +69 -7
- package/lib/suppliers/nvidia/locale/en.json +0 -0
- package/lib/suppliers/nvidia/locale/zh.json +0 -0
- package/lib/suppliers/nvidia/package.json +0 -0
- package/lib/suppliers/opencode/locale/en.json +0 -0
- package/lib/suppliers/opencode/locale/zh.json +0 -0
- package/lib/suppliers/opencode/package.json +0 -0
- package/lib/suppliers/openrouter/locale/en.json +0 -0
- package/lib/suppliers/openrouter/locale/zh.json +0 -0
- package/lib/suppliers/openrouter/package.json +0 -0
- package/lib/types/router/account-pool.d.ts +23 -1
- package/lib/types/router/index.d.ts +25 -0
- package/lib/types/router/types.d.ts +9 -1
- package/lib/types/shared.d.ts +6 -0
- package/package.json +1 -1
- package/src/client/CheckinCard.tsx +0 -0
- package/src/client/CombosTab.tsx +0 -0
- package/src/client/EndpointTab.tsx +0 -0
- package/src/client/ExtDetail.tsx +0 -0
- package/src/client/ExtTab.tsx +0 -0
- package/src/client/ExtTestPanel.tsx +0 -0
- package/src/client/LastHitDock.tsx +0 -0
- package/src/client/Modal.tsx +0 -0
- package/src/client/RouterComponentsSection.tsx +0 -0
- package/src/client/RouterView.tsx +0 -0
- package/src/client/StatsTab.tsx +0 -0
- package/src/client/SupplierDetail.tsx +1 -5
- package/src/client/account-badge.test.ts +37 -0
- package/src/client/account-badge.ts +24 -0
- package/src/client/comp-button-scope.test.ts +0 -0
- package/src/client/comp-panel-layout.test.ts +0 -0
- package/src/client/env.d.ts +0 -0
- package/src/client/ext-panels.test.ts +0 -0
- package/src/client/ext-panels.ts +0 -0
- package/src/client/index.tsx +0 -0
- package/src/client/last-hit-store.ts +0 -0
- package/src/client/model-choice.test.ts +0 -0
- package/src/client/model-choice.ts +0 -0
- package/src/client/model-hint-copy.test.ts +0 -0
- package/src/client/model-hint-copy.ts +0 -0
- package/src/client/router-components.test.ts +0 -0
- package/src/client/router-components.ts +0 -0
- package/src/client/router.css +0 -0
- package/src/client/settings-nav-icon.ts +0 -0
- package/src/client/settings-section.tsx +0 -0
- package/src/client/sidebar-entry.ts +0 -0
- package/src/client/supplier-visibility.test.ts +0 -0
- package/src/client/supplier-visibility.ts +0 -0
- package/src/client/workspace-controller.ts +0 -0
- package/src/client/workspace-mount.tsx +0 -0
- package/src/credential-store.ts +0 -0
- package/src/data-dir.test.ts +0 -0
- package/src/data-dir.ts +0 -0
- package/src/ext/contract.ts +0 -0
- package/src/ext/store.test.ts +0 -0
- package/src/ext/store.ts +0 -0
- package/src/ext-prompt/assembly.test.ts +0 -0
- package/src/ext-prompt/content.ts +0 -0
- package/src/ext-prompt/ext-prompt.test.ts +0 -0
- package/src/ext-prompt/index.ts +0 -0
- package/src/ext-prompt/locale/en.json +0 -0
- package/src/ext-prompt/locale/zh.json +0 -0
- package/src/ext-prompt/logo.svg +0 -0
- package/src/ext-prompt/logo.ts +0 -0
- package/src/ext-prompt/mount.test.ts +0 -0
- package/src/ext-prompt/mount.ts +0 -0
- package/src/ext-prompt/plugin.ts +0 -0
- package/src/ext-prompt/render.test.ts +0 -0
- package/src/ext-prompt/render.ts +0 -0
- package/src/ext-prompt/row.json +0 -0
- package/src/ext-route.test.ts +0 -0
- package/src/ext-test/ext-test.test.ts +0 -0
- package/src/ext-test/index.ts +0 -0
- package/src/ext-test/locale/en.json +0 -0
- package/src/ext-test/locale/zh.json +0 -0
- package/src/ext-test/plugin.ts +0 -0
- package/src/ext-test/row.json +0 -0
- package/src/host-base-url.test.ts +0 -0
- package/src/host-base-url.ts +0 -0
- package/src/host-version.test.ts +0 -0
- package/src/host-version.ts +0 -0
- package/src/index.ts +12 -1
- package/src/keys.ts +0 -0
- package/src/llm/adapter.test.ts +0 -0
- package/src/llm/adapter.ts +0 -0
- package/src/router/account-pool.test.ts +74 -0
- package/src/router/account-pool.ts +37 -5
- package/src/router/combo-change.test.ts +125 -0
- package/src/router/disconnect-leak.test.ts +0 -0
- package/src/router/first-byte-budget.test.ts +0 -0
- package/src/router/index.ts +43 -3
- package/src/router/models-cache.test.ts +0 -0
- package/src/router/precise-dispatch.test.ts +0 -0
- package/src/router/prefix-affinity.ts +0 -0
- package/src/router/supplier-switch.test.ts +91 -0
- package/src/router/types.ts +9 -1
- package/src/router/usage-store.ts +0 -0
- package/src/router/usage-tokens.ts +0 -0
- package/src/router/usage.test.ts +0 -0
- package/src/shared.ts +6 -0
- package/src/supplier-config.test.ts +0 -0
- package/src/supplier-config.ts +0 -0
- package/src/suppliers/contract.ts +0 -0
- package/src/suppliers/http-state.test.ts +0 -0
- package/src/suppliers/http-state.ts +0 -0
- package/src/suppliers/loader.test.ts +53 -0
- package/src/suppliers/loader.ts +0 -0
- package/src/suppliers/nvidia/index.ts +0 -0
- package/src/suppliers/nvidia/locale/en.json +0 -0
- package/src/suppliers/nvidia/locale/zh.json +0 -0
- package/src/suppliers/nvidia/plugin.ts +0 -0
- package/src/suppliers/nvidia/row.json +0 -0
- package/src/suppliers/opencode/index.ts +0 -0
- package/src/suppliers/opencode/locale/en.json +0 -0
- package/src/suppliers/opencode/locale/zh.json +0 -0
- package/src/suppliers/opencode/plugin.test.ts +0 -0
- package/src/suppliers/opencode/plugin.ts +0 -0
- package/src/suppliers/opencode/row.json +0 -0
- package/src/suppliers/openrouter/index.ts +0 -0
- package/src/suppliers/openrouter/locale/en.json +0 -0
- package/src/suppliers/openrouter/locale/zh.json +0 -0
- package/src/suppliers/openrouter/plugin.ts +0 -0
- package/src/suppliers/openrouter/row.json +0 -0
- package/src/suppliers/probe.test.ts +0 -0
- package/src/suppliers/probe.ts +0 -0
- package/src/suppliers/registry.test.ts +0 -0
- package/src/suppliers/registry.ts +0 -0
- package/src/types.d.ts +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号徽章闸门 —— 面板不许和路由说反话。
|
|
3
|
+
*
|
|
4
|
+
* 核心 `pick()` 会跳过插件报 `quota`/`session_dead` 的号。面板若把它们显示成
|
|
5
|
+
* 「健康」,用户看到的就是「一切正常但请求永远不走这个号」——没有线索可查。
|
|
6
|
+
* 这类不一致不会抛异常,只会让人误判,所以要有闸门。
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { statusBadge } from './account-badge.ts'
|
|
11
|
+
import type { RouterAccount } from '../shared.ts'
|
|
12
|
+
|
|
13
|
+
const acc = (over: Partial<RouterAccount> = {}): RouterAccount => ({
|
|
14
|
+
uid: 'u1', credits: 100, cooling: false, ...over,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('额度用完的号显示「额度用完」,不是「健康」', () => {
|
|
18
|
+
const b = statusBadge(acc({ credits: 0, state: 'quota' }))
|
|
19
|
+
assert.equal(b.text, '额度用完')
|
|
20
|
+
assert.notEqual(b.cls, 'dshr-badge-ok', '不能用健康色')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('凭证失效显示「凭证失效」', () => {
|
|
24
|
+
assert.equal(statusBadge(acc({ state: 'session_dead' })).text, '凭证失效')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('冷却优先于状态:冷却带 until/reason,用户更需要先看到', () => {
|
|
28
|
+
const b = statusBadge(acc({ state: 'quota', cooling: true, reason: 'quota: exceeded' }))
|
|
29
|
+
assert.equal(b.text, '冷却中')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('正常号仍显示「健康」(不误伤 openrouter/nvidia 那种 credits: 0 的号)', () => {
|
|
33
|
+
// 这两家没有积分概念,报的是 credits: 0 + state: 'ok' —— 不能因为 0 就报警。
|
|
34
|
+
assert.equal(statusBadge(acc({ credits: 0, state: 'ok' })).text, '健康')
|
|
35
|
+
assert.equal(statusBadge(acc({ credits: 0 })).text, '健康', 'state 缺省(老插件)也不报警')
|
|
36
|
+
assert.equal(statusBadge(acc({ credits: 500, state: 'ok' })).text, '健康')
|
|
37
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号行的状态徽章判据 —— 面板必须和路由说同一句话。
|
|
3
|
+
*
|
|
4
|
+
* 核心选号会跳过插件报 `quota`(额度/积分用完)和 `session_dead`(凭证失效)的号
|
|
5
|
+
* (见 `account-pool.servableByReport`)。如果面板把这些号显示成「健康」,用户
|
|
6
|
+
* 就会看到「界面说没问题、可是请求永远不走它」——查不出原因,只能去删链接重登,
|
|
7
|
+
* 而真正的解法是充值。
|
|
8
|
+
*
|
|
9
|
+
* 判据顺序(先冷却后状态)的理由:冷却带 `until` 和 `reason`,比静态状态更具体,
|
|
10
|
+
* 用户更需要先看到「什么时候会好」。
|
|
11
|
+
*/
|
|
12
|
+
import type { RouterAccount } from '../shared.ts'
|
|
13
|
+
|
|
14
|
+
export interface AccountBadge {
|
|
15
|
+
text: string
|
|
16
|
+
cls: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function statusBadge(account: RouterAccount): AccountBadge {
|
|
20
|
+
if (account.cooling) return { text: '冷却中', cls: 'dshr-badge-warn' }
|
|
21
|
+
if (account.state === 'quota') return { text: '额度用完', cls: 'dshr-badge-warn' }
|
|
22
|
+
if (account.state === 'session_dead') return { text: '凭证失效', cls: 'dshr-badge-warn' }
|
|
23
|
+
return { text: '健康', cls: 'dshr-badge-ok' }
|
|
24
|
+
}
|
|
File without changes
|
|
File without changes
|
package/src/client/env.d.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/client/ext-panels.ts
CHANGED
|
File without changes
|
package/src/client/index.tsx
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/client/router.css
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/credential-store.ts
CHANGED
|
File without changes
|
package/src/data-dir.test.ts
CHANGED
|
File without changes
|
package/src/data-dir.ts
CHANGED
|
File without changes
|
package/src/ext/contract.ts
CHANGED
|
File without changes
|
package/src/ext/store.test.ts
CHANGED
|
File without changes
|
package/src/ext/store.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/ext-prompt/index.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/ext-prompt/logo.svg
CHANGED
|
File without changes
|
package/src/ext-prompt/logo.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/ext-prompt/mount.ts
CHANGED
|
File without changes
|
package/src/ext-prompt/plugin.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/ext-prompt/render.ts
CHANGED
|
File without changes
|
package/src/ext-prompt/row.json
CHANGED
|
File without changes
|
package/src/ext-route.test.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/ext-test/index.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/ext-test/plugin.ts
CHANGED
|
File without changes
|
package/src/ext-test/row.json
CHANGED
|
File without changes
|
|
File without changes
|
package/src/host-base-url.ts
CHANGED
|
File without changes
|
package/src/host-version.test.ts
CHANGED
|
File without changes
|
package/src/host-version.ts
CHANGED
|
File without changes
|
package/src/index.ts
CHANGED
|
@@ -162,6 +162,11 @@ export function apply(rawContext: unknown): void {
|
|
|
162
162
|
get: (key: string) => unknown
|
|
163
163
|
on: (name: string, listener: (...args: unknown[]) => void) => unknown
|
|
164
164
|
provide: (name: string, value: unknown) => unknown
|
|
165
|
+
/**
|
|
166
|
+
* 广播宿主事件。组合增删改后必须用它发 `llm/adapters-updated`,
|
|
167
|
+
* 否则模型选择器一直拿着旧快照(见 `Router` 的 `onCombosChanged` 注释)。
|
|
168
|
+
*/
|
|
169
|
+
emit: (name: string, ...args: unknown[]) => unknown
|
|
165
170
|
webServer: WebServer
|
|
166
171
|
llm?: Llm
|
|
167
172
|
logger: { info: (message: string) => void; warn: (message: string) => void }
|
|
@@ -193,7 +198,13 @@ export function apply(rawContext: unknown): void {
|
|
|
193
198
|
}
|
|
194
199
|
const store = new SupplierConfigStore(stateFile)
|
|
195
200
|
const credentials = new CredentialStore(join(dataDir, 'auths'))
|
|
196
|
-
|
|
201
|
+
// 组合增删改后广播 `llm/adapters-updated`:模型选择器的目录是客户端缓存的,
|
|
202
|
+
// 只在这个(宿主已转发给客户端的)事件上失效重载。不发它就出现「面板里有、
|
|
203
|
+
// 选择器里找不到/切不过去」(2026-10-14 实测)。
|
|
204
|
+
// 该事件在宿主侧是纯通知(payload-free),监听方都是幂等的重读,重复发安全。
|
|
205
|
+
const router = new Router(stateFile, store, log, {
|
|
206
|
+
onCombosChanged: () => { ctx.emit('llm/adapters-updated') },
|
|
207
|
+
})
|
|
197
208
|
|
|
198
209
|
const keys = new KeysStore(stateFile)
|
|
199
210
|
|
package/src/keys.ts
CHANGED
|
File without changes
|
package/src/llm/adapter.test.ts
CHANGED
|
File without changes
|
package/src/llm/adapter.ts
CHANGED
|
File without changes
|
|
@@ -517,6 +517,80 @@ test('状态表:各 AccountState 的处置符合预期', () => {
|
|
|
517
517
|
assert.equal(p.pick(list, [], 'm1'), undefined, '四个号都不可服务')
|
|
518
518
|
})
|
|
519
519
|
|
|
520
|
+
// ---- 插件报的额度/凭证状态直接进选号 ----
|
|
521
|
+
// 分工:插件报「现在怎么了」,核心决定「怎么办」。积分用完(quota)/凭证失效
|
|
522
|
+
// (session_dead)要靠充值/重登才能恢复,选它必是白撞一次——而且 traework 的
|
|
523
|
+
// 积分耗尽失败发生在流已提交之后(HTTP 200 + 流内 4008),这次请求救不回来。
|
|
524
|
+
// 所以必须在**选号阶段**就排除,不能等失败后记账。
|
|
525
|
+
|
|
526
|
+
test('报 quota 的号不进选号(额度用完,选它必白撞)', () => {
|
|
527
|
+
const p = pool()
|
|
528
|
+
const list: SupplierAccountNow[] = [
|
|
529
|
+
{ uid: 'broke', credits: 0, state: 'quota' },
|
|
530
|
+
{ uid: 'rich', credits: 100, state: 'ok' },
|
|
531
|
+
]
|
|
532
|
+
assert.equal(p.pick(list, ['broke', 'rich'], 'm1'), 'rich', '池顺序在前也没用,用不了的号跳过')
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('报 session_dead 的号不进选号(凭证失效,要重登)', () => {
|
|
536
|
+
const p = pool()
|
|
537
|
+
const list: SupplierAccountNow[] = [
|
|
538
|
+
{ uid: 'dead', credits: 100, state: 'session_dead' },
|
|
539
|
+
{ uid: 'alive', credits: 1, state: 'ok' },
|
|
540
|
+
]
|
|
541
|
+
assert.equal(p.pick(list, ['dead', 'alive'], 'm1'), 'alive')
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
test('全池都报 quota → 无号可选,且说明原因不是「都在冷却」', () => {
|
|
545
|
+
const p = pool()
|
|
546
|
+
const list: SupplierAccountNow[] = [
|
|
547
|
+
{ uid: 'a', credits: 0, state: 'quota' },
|
|
548
|
+
{ uid: 'b', credits: 0, state: 'quota' },
|
|
549
|
+
]
|
|
550
|
+
assert.equal(p.pick(list, [], 'm1'), undefined)
|
|
551
|
+
assert.match(p.lastWhy, /额度|凭证/, '原因要能区分「没额度」和「冷却中」')
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
test('报 quota 的号恢复成 ok 后立刻回到候选(充值即自愈,无需重启)', () => {
|
|
555
|
+
const p = pool()
|
|
556
|
+
assert.equal(p.pick([{ uid: 'u', credits: 0, state: 'quota' }], [], 'm1'), undefined)
|
|
557
|
+
assert.equal(p.pick([{ uid: 'u', credits: 50, state: 'ok' }], [], 'm1'), 'u')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('积分 0 但状态是 ok 的号照常可选(0 不等于「没额度」)', () => {
|
|
561
|
+
const p = pool()
|
|
562
|
+
// openrouter/nvidia 报的就是 credits: 0 + state: 'ok'(没有积分这个概念)。
|
|
563
|
+
// 核心不能自己拿 credits===0 当「用完了」,否则这些供应商会被整池排除。
|
|
564
|
+
const list: SupplierAccountNow[] = [{ uid: 'api-key', credits: 0, state: 'ok' }]
|
|
565
|
+
assert.equal(p.pick(list, [], 'm1'), 'api-key')
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
test('瞬时状态(transport/unknown/unavailable)不排除号:那是采样抖动,不是账号事实', () => {
|
|
569
|
+
const p = pool()
|
|
570
|
+
for (const state of ['transport', 'unknown', 'unavailable'] as const) {
|
|
571
|
+
const list: SupplierAccountNow[] = [{ uid: 'u', credits: 5, state }]
|
|
572
|
+
assert.equal(p.pick(list, [], 'm1'), 'u', `${state} 不该让号退出候选`)
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test('会话绑定撞上报 quota 的号 → 换号,且 lastWhy 指出是额度问题', () => {
|
|
577
|
+
const p = pool()
|
|
578
|
+
const msgs = [{ role: 'user', content: 'hello' }]
|
|
579
|
+
const okList: SupplierAccountNow[] = [
|
|
580
|
+
{ uid: 'bound', credits: 10, state: 'ok' },
|
|
581
|
+
{ uid: 'other', credits: 10, state: 'ok' },
|
|
582
|
+
]
|
|
583
|
+
assert.equal(p.pick(okList, [], 'm1', msgs), 'bound', '先绑到 bound')
|
|
584
|
+
// 该号积分耗尽后:同一会话必须换到别的号,不能继续钉在死号上
|
|
585
|
+
const brokeList: SupplierAccountNow[] = [
|
|
586
|
+
{ uid: 'bound', credits: 0, state: 'quota' },
|
|
587
|
+
{ uid: 'other', credits: 10, state: 'ok' },
|
|
588
|
+
]
|
|
589
|
+
assert.equal(p.pick(brokeList, [], 'm1', msgs), 'other')
|
|
590
|
+
assert.match(p.lastWhy, /bound/, '要说清为什么没用原绑定')
|
|
591
|
+
assert.match(p.lastWhy, /额度|凭证/, '原因要指向额度/凭证,不是「冷却」')
|
|
592
|
+
})
|
|
593
|
+
|
|
520
594
|
/**
|
|
521
595
|
* 处置对齐 9router:未知/瞬时错误**每次**短冷却(不再攒够 3 次)。
|
|
522
596
|
*/
|
|
@@ -252,6 +252,25 @@ export class AccountPool {
|
|
|
252
252
|
return c.until <= now
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* 插件报的「现在状态」是否意味着这个号此刻不该被选中。
|
|
257
|
+
*
|
|
258
|
+
* 只认两种**账号级持久事实**(要靠外部动作才能恢复,不是抖动):
|
|
259
|
+
* - `quota` —— 额度/积分用完,充值才能恢复(traework 积分归零时报它)
|
|
260
|
+
* - `session_dead` —— 凭证失效,重新登录才能恢复
|
|
261
|
+
*
|
|
262
|
+
* 其余一律不看:`transport`/`unknown`/`unavailable` 是插件某一次采样的瞬时
|
|
263
|
+
* 观察,拿它排除等于「一次抖动就踢号」,而且没有冷却计时器兜底;`rate_limit`
|
|
264
|
+
* 的指数退避由核心的冷却单元管,比状态采样准。
|
|
265
|
+
*
|
|
266
|
+
* 这是「插件只报现在怎么了、核心决定怎么办」的分工落地:插件报出它观察到
|
|
267
|
+
* 的事实,核心决定此刻不选它。恢复同样自动——充值/重登后插件报回 `ok`,
|
|
268
|
+
* 该号立刻回到候选(没有额外状态要清)。
|
|
269
|
+
*/
|
|
270
|
+
private servableByReport(a: SupplierAccountNow | undefined): boolean {
|
|
271
|
+
return a?.state !== 'quota' && a?.state !== 'session_dead'
|
|
272
|
+
}
|
|
273
|
+
|
|
255
274
|
/**
|
|
256
275
|
* 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
|
|
257
276
|
*
|
|
@@ -267,11 +286,17 @@ export class AccountPool {
|
|
|
267
286
|
* "策略",是无会话身份时的替身。
|
|
268
287
|
* 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
|
|
269
288
|
* 降权该号(缓存无效,可恢复)。
|
|
289
|
+
* 5. 插件报的 `quota`/`session_dead`(额度用完 / 凭证失效)→ **直接不选它**:
|
|
290
|
+
* 这类失败要靠充值/重登才能恢复,选它必是白撞一次(还有的供应商失败在
|
|
291
|
+
* 流已提交之后,这次请求根本救不回来)。见 `servableByReport`。
|
|
270
292
|
*
|
|
271
293
|
* 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
|
|
272
294
|
* (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
|
|
273
295
|
*
|
|
274
|
-
* @param accounts
|
|
296
|
+
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)。
|
|
297
|
+
* `state` 会被读:`quota`/`session_dead` 的号不进候选(见 `servableByReport`)。
|
|
298
|
+
* `credits` **不读** —— 不同供应商的 0 含义不同(openrouter/nvidia 的 0 是
|
|
299
|
+
* 「没有积分这个概念」),是否已用完由插件解读成 `state` 后报上来。
|
|
275
300
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
276
301
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
277
302
|
* @param messages 请求体的 messages(算亲和指纹的内容兜底)
|
|
@@ -286,9 +311,11 @@ export class AccountPool {
|
|
|
286
311
|
...poolOrder.filter((uid) => byUid.has(uid)),
|
|
287
312
|
...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid)),
|
|
288
313
|
]
|
|
289
|
-
const healthy = ordered.filter(
|
|
314
|
+
const healthy = ordered.filter(
|
|
315
|
+
(uid) => this.healthy(uid, modelId, now) && this.servableByReport(byUid.get(uid)),
|
|
316
|
+
)
|
|
290
317
|
if (healthy.length === 0) {
|
|
291
|
-
this.lastWhy = '
|
|
318
|
+
this.lastWhy = '全池无健康号(都在冷却,或插件报的额度/凭证已不可用)'
|
|
292
319
|
return undefined
|
|
293
320
|
}
|
|
294
321
|
// 缓存无效降权:该号缓存质量样本长期不达标 → 跳过它(可到期恢复)
|
|
@@ -309,8 +336,13 @@ export class AccountPool {
|
|
|
309
336
|
why.push('该指纹无绑定(会话首次出现)')
|
|
310
337
|
} else {
|
|
311
338
|
if (!healthy.includes(bound)) {
|
|
312
|
-
const
|
|
313
|
-
|
|
339
|
+
const ba = byUid.get(bound)
|
|
340
|
+
if (!this.servableByReport(ba)) {
|
|
341
|
+
why.push(`原绑定 ${bound} 插件报额度/凭证不可用(${ba?.state})`)
|
|
342
|
+
} else {
|
|
343
|
+
const c = this.cooldowns.get(this.key(modelId, bound))
|
|
344
|
+
why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ''}`)
|
|
345
|
+
}
|
|
314
346
|
} else if (!usable.includes(bound)) {
|
|
315
347
|
why.push(`原绑定 ${bound} 被降权(本会话样本不达标)`)
|
|
316
348
|
} else {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组合变更必须通知拓扑观察者 —— `onCombosChanged` 的出口判据。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 为什么需要它(2026-10-14 实测的「面板里有,模型选择器里找不到」):
|
|
5
|
+
* 模型选择器(`ui-model-selection`)的模型目录是**客户端缓存**的:每个浏览器
|
|
6
|
+
* 连接只 `load()` 一次,之后只在宿主转发的事件上失效重载
|
|
7
|
+
* (`llm/adapters-updated` / `settings/document-updated` / 凭据事件 / 重连)。
|
|
8
|
+
* 而组合只是本插件 `combos.json` 里的一份状态:
|
|
9
|
+
* - `createCombo` 建了新组合 → 选择器永远看不到它
|
|
10
|
+
* - `updateCombo` 改了名字 → 选择器还拿旧名字,用户切不过去
|
|
11
|
+
* - `removeCombo` 删了组合 → 选择器里还留着,选中必然失败
|
|
12
|
+
* 实测现场:`combos.json` 里是 `buddy-models-flash`(22:19 改的名),
|
|
13
|
+
* 而选择器里显示的是**早已删除的** `NVIDIA-models` —— 整张表都过期了。
|
|
14
|
+
*
|
|
15
|
+
* 判据形态:直接守「提交成功后有没有发出通知」,而不是守客户端有没有刷新 ——
|
|
16
|
+
* 客户端刷新要靠宿主转发,单测里没有宿主。通知是这里唯一能守住的那一环。
|
|
17
|
+
*/
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { Router } from './index.ts'
|
|
24
|
+
|
|
25
|
+
/** 造一个空 stateFile 的临时目录(组合落在同目录的 combos.json)。 */
|
|
26
|
+
function fixture(): { dir: string; stateFile: string } {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-router-combo-'))
|
|
28
|
+
return { dir, stateFile: join(dir, 'state.json') }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('★ createCombo 成功后通知一次(否则新组合在选择器里永不出现)', () => {
|
|
32
|
+
const { stateFile } = fixture()
|
|
33
|
+
let notified = 0
|
|
34
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
35
|
+
assert.equal(router.createCombo('c1', ['supA,a-model']).ok, true)
|
|
36
|
+
assert.equal(notified, 1, '新建组合后没有通知 ⇒ 模型选择器看不到它')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('★ updateCombo 成功后通知(否则选择器还拿旧名字,切不过去)', () => {
|
|
40
|
+
const { stateFile } = fixture()
|
|
41
|
+
let notified = 0
|
|
42
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
43
|
+
router.createCombo('old-name', ['supA,a-model'])
|
|
44
|
+
notified = 0
|
|
45
|
+
assert.equal(router.updateCombo('old-name', 'new-name', ['supA,a-model']).ok, true)
|
|
46
|
+
assert.equal(notified, 1, '改名后没有通知 ⇒ 选择器里还留着旧名字')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('★ removeCombo 成功后通知(否则选择器里留着已删的组合,选中必失败)', () => {
|
|
50
|
+
const { stateFile } = fixture()
|
|
51
|
+
let notified = 0
|
|
52
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
53
|
+
router.createCombo('gone', ['supA,a-model'])
|
|
54
|
+
notified = 0
|
|
55
|
+
assert.equal(router.removeCombo('gone').ok, true)
|
|
56
|
+
assert.equal(notified, 1, '删除后没有通知 ⇒ 选择器里还留着它')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('被拒绝的变更不通知(校验失败时组合根本没变)', () => {
|
|
60
|
+
const { stateFile } = fixture()
|
|
61
|
+
let notified = 0
|
|
62
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
63
|
+
router.createCombo('keep', ['supA,a-model'])
|
|
64
|
+
notified = 0
|
|
65
|
+
// 空模型列表 / 非法名字 / 不存在的 id:三条都得原地不动
|
|
66
|
+
assert.equal(router.createCombo('bad-chars!', ['supA,a-model']).ok, false)
|
|
67
|
+
assert.equal(router.createCombo('no-models', []).ok, false)
|
|
68
|
+
assert.equal(router.updateCombo('missing-id', 'x', ['supA,a-model']).ok, false)
|
|
69
|
+
assert.equal(router.removeCombo('missing-id').ok, false)
|
|
70
|
+
assert.equal(notified, 0, '失败的变更不该通知(会让客户端做一次无意义的全量重拉)')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('通知出口抛错不能把已提交的变更报成失败', async () => {
|
|
74
|
+
const { stateFile } = fixture()
|
|
75
|
+
const router = new Router(stateFile, undefined, undefined, {
|
|
76
|
+
// 监听方(宿主事件总线)出问题:这是通知不是事务参与者
|
|
77
|
+
onCombosChanged: () => { throw new Error('listener exploded') },
|
|
78
|
+
})
|
|
79
|
+
const result = router.createCombo('survivor', ['supA,a-model'])
|
|
80
|
+
assert.equal(result.ok, true, '通知失败不该让 createCombo 变红 —— 组合其实已经提交')
|
|
81
|
+
assert.equal((await router.combos()).some((c) => c.name === 'survivor'), true, '内存里的组合必须还在')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('没装出口时不抛(单测/独立运行时)', () => {
|
|
85
|
+
const { stateFile } = fixture()
|
|
86
|
+
const router = new Router(stateFile)
|
|
87
|
+
assert.doesNotThrow(() => { router.createCombo('c1', ['supA,a-model']) })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('通知发生在落盘之后 —— 监听方重读时能读到新组合', () => {
|
|
91
|
+
const { stateFile } = fixture()
|
|
92
|
+
let seen: string[] = []
|
|
93
|
+
const router = new Router(stateFile, undefined, undefined, {
|
|
94
|
+
onCombosChanged: () => {
|
|
95
|
+
// 监听方(宿主)会重新读一遍组合去刷客户端,此刻必须已经能读到新的
|
|
96
|
+
const onDisk = JSON.parse(readFileSync(join(stateFile, '..', 'combos.json'), 'utf8')) as {
|
|
97
|
+
combos: Array<{ name: string }>
|
|
98
|
+
}
|
|
99
|
+
seen = onDisk.combos.map((c) => c.name)
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
router.createCombo('persisted', ['supA,a-model'])
|
|
103
|
+
assert.deepEqual(seen, ['persisted'], '通知时组合还没落盘 ⇒ 监听方重读会拿到旧状态')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('重复写同一个组合名不触发第二次落盘语义变化(幂等读取安全)', () => {
|
|
107
|
+
const { stateFile } = fixture()
|
|
108
|
+
let notified = 0
|
|
109
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
110
|
+
router.createCombo('c1', ['supA,a-model'])
|
|
111
|
+
// 同 id 的 update 到等价内容仍然是合法提交(不是 no-op 语义),必须通知
|
|
112
|
+
assert.equal(router.updateCombo('c1', 'c1', ['supA,a-model']).ok, true)
|
|
113
|
+
assert.equal(notified, 2)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('★ 注入:拿掉通知 ⇒ 上面几条会红(反向自检)', () => {
|
|
117
|
+
const { stateFile } = fixture()
|
|
118
|
+
// 等价于修复前的实现:没有 onCombosChanged
|
|
119
|
+
const router = new Router(stateFile)
|
|
120
|
+
router.createCombo('c1', ['supA,a-model'])
|
|
121
|
+
const combosFile = join(stateFile, '..', 'combos.json')
|
|
122
|
+
// 落盘仍然发生(这条路径没被改坏),但没有任何通知出口可发
|
|
123
|
+
const onDisk = JSON.parse(readFileSync(combosFile, 'utf8')) as { combos: unknown[] }
|
|
124
|
+
assert.equal(onDisk.combos.length, 1, '落盘行为必须保持')
|
|
125
|
+
})
|
|
File without changes
|
|
File without changes
|
package/src/router/index.ts
CHANGED
|
@@ -657,6 +657,21 @@ export class Router {
|
|
|
657
657
|
private log: (msg: string) => void
|
|
658
658
|
/** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
|
|
659
659
|
private firstByteBudgetMs: number
|
|
660
|
+
/**
|
|
661
|
+
* 组合增删改**成功提交后**的通知出口。
|
|
662
|
+
*
|
|
663
|
+
* 为什么必须有:模型选择器(`ui-model-selection`)的目录是**客户端缓存**的,
|
|
664
|
+
* 只在宿主转发过来的事件上失效重载(`llm/adapters-updated` /
|
|
665
|
+
* `settings/document-updated` / 凭据事件 / 重连)。而组合只是本插件
|
|
666
|
+
* `combos.json` 里的一份状态:改完名字若不发通知,选择器会一直拿着旧快照 ——
|
|
667
|
+
* 用户能看到的症状是「面板里明明有这个名字,模型选择器里却找不到、切不过去」,
|
|
668
|
+
* 且新建的组合永远不出现(2026-10-14 实测)。
|
|
669
|
+
*
|
|
670
|
+
* 通知与落盘分开({@link saveCombos} 先 {@link commitCombos} 落盘、再发通知):
|
|
671
|
+
* 落盘失败、或 `stateFile` 为空(单测/独立运行时)时内存已经改了,客户端的
|
|
672
|
+
* 状态同样过期,所以这两条路径都必须照发。
|
|
673
|
+
*/
|
|
674
|
+
private onCombosChanged: (() => void) | undefined
|
|
660
675
|
/** 用量统计(概览看板)。 */
|
|
661
676
|
usage: UsageStore
|
|
662
677
|
|
|
@@ -664,12 +679,13 @@ export class Router {
|
|
|
664
679
|
stateFile = '',
|
|
665
680
|
store?: SupplierConfigStore,
|
|
666
681
|
log?: (msg: string) => void,
|
|
667
|
-
opts?: { firstByteBudgetMs?: number },
|
|
682
|
+
opts?: { firstByteBudgetMs?: number; onCombosChanged?: () => void },
|
|
668
683
|
) {
|
|
669
684
|
this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
|
|
670
685
|
this.store = store ?? new SupplierConfigStore(stateFile)
|
|
671
686
|
this.log = log ?? ((): void => {})
|
|
672
687
|
this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS
|
|
688
|
+
this.onCombosChanged = opts?.onCombosChanged
|
|
673
689
|
this.usage = new UsageStore(stateFile)
|
|
674
690
|
this.loadCombos()
|
|
675
691
|
}
|
|
@@ -728,7 +744,24 @@ export class Router {
|
|
|
728
744
|
}
|
|
729
745
|
}
|
|
730
746
|
|
|
747
|
+
/**
|
|
748
|
+
* 提交组合变更:落盘 + 通知拓扑观察者。
|
|
749
|
+
*
|
|
750
|
+
* `onCombosChanged` 的失败必须被吞掉:它是**通知**不是事务参与者,
|
|
751
|
+
* 监听方(宿主事件总线)出问题不能把一次已经生效的增删改报成失败 ——
|
|
752
|
+
* 那会让用户重试一个其实已经成功的操作(同 `schedule/changed` 的口径)。
|
|
753
|
+
*/
|
|
731
754
|
private saveCombos(): void {
|
|
755
|
+
this.commitCombos()
|
|
756
|
+
try {
|
|
757
|
+
this.onCombosChanged?.()
|
|
758
|
+
} catch (err) {
|
|
759
|
+
this.log(`combo change notification failed: ${(err as Error).message}`)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** 组合落盘(通知由 {@link saveCombos} 单独发,落盘失败不阻断它)。 */
|
|
764
|
+
private commitCombos(): void {
|
|
732
765
|
if (this.combosFp === '') return
|
|
733
766
|
try {
|
|
734
767
|
const dir = dirname(this.combosFp)
|
|
@@ -1276,12 +1309,19 @@ export class Router {
|
|
|
1276
1309
|
for (;;) {
|
|
1277
1310
|
// 指定连接:只试它。选不中就直接失败并说明原因(连接已不存在/不在池里),
|
|
1278
1311
|
// **不回退到别的号** —— 否则「测这个连接」测出来的是另一个号的结果,
|
|
1279
|
-
//
|
|
1312
|
+
// 诊断就变成了假话。注意「选不中」要区分「不在池里」和「这一轮试过了」:
|
|
1313
|
+
// 后者是失败重试的收尾,必须保留上游给的真实错误(见下方 uid === undefined)。
|
|
1280
1314
|
const uid = onlyUid !== undefined
|
|
1281
1315
|
? (s.accounts().some((a) => a.uid === onlyUid) && !tried.has(onlyUid) ? onlyUid : undefined)
|
|
1282
1316
|
: pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session)
|
|
1283
1317
|
if (uid === undefined) {
|
|
1284
|
-
|
|
1318
|
+
// 指定连接却选不出来,有两种完全不同的原因,混为一谈就是假诊断:
|
|
1319
|
+
// - 这个 uid 根本不在账号池里 → 真「连接不存在(可能已被删除)」
|
|
1320
|
+
// - uid 在池里,只是这一轮被 `tried` 挡掉 → 它**刚才已经试过并失败**了,
|
|
1321
|
+
// trace 里正躺着上游给的真实原因(如 quota / 4008「额度已用完」)。
|
|
1322
|
+
// 此处若照旧覆盖,会把「积分用完」说成「连接不存在」——2026-10-14
|
|
1323
|
+
// 实测:0 积分号测出「连接不存在(可能已被删除)」,而它明明在面板上。
|
|
1324
|
+
if (onlyUid !== undefined && !tried.has(onlyUid) && trace !== undefined) {
|
|
1285
1325
|
// 不给 AccountState 加「连接不存在」这个值:那是给**账号的失败**分类用的
|
|
1286
1326
|
// (决定要不要冷却),而链接不存在不是任何账号的错,混进去会让所有分类逻辑
|
|
1287
1327
|
// 都要认识它。具体原因放 lastError 就够。
|
|
File without changes
|
|
File without changes
|
|
File without changes
|