dsh-router-core 0.4.3 → 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/index.js +2 -2
- 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 +106 -17
- 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/ext-prompt/content.d.ts +10 -3
- 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 +14 -7
- 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 +56 -0
- package/src/suppliers/registry.ts +40 -10
- package/src/types.d.ts +0 -0
|
@@ -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
|
|
@@ -218,3 +218,94 @@ test('指定不存在的连接:明确报「连接不存在」,而不是静
|
|
|
218
218
|
assert.equal(a.calls, 0, '一个号都不该被试')
|
|
219
219
|
assert.match(r.error ?? '', /不存在/)
|
|
220
220
|
})
|
|
221
|
+
|
|
222
|
+
test('指定连接失败:报上游真实原因,不许说成「连接不存在」', async () => {
|
|
223
|
+
// 2026-10-14 实测:0 积分号(uid 在池里、面板上看得见)测出来是
|
|
224
|
+
// 「连接 "4135677434275728" 不存在(可能已被删除)」——真话是额度用完。
|
|
225
|
+
// 机制:失败后 `continue`,下一轮 onlyUid 被 `tried` 挡掉 → uid undefined,
|
|
226
|
+
// 旧代码在那里无条件覆盖 lastError,把上游原因冲掉。假诊断比没有诊断更坏:
|
|
227
|
+
// 用户会去删链接、重新登录,而正解是充值。
|
|
228
|
+
const { router } = rig()
|
|
229
|
+
const a = spy('supA', 'a-model')
|
|
230
|
+
a.s.accounts = () => [
|
|
231
|
+
{ uid: 'broke', credits: 0, state: 'ok' },
|
|
232
|
+
{ uid: 'rich', credits: 100, state: 'ok' },
|
|
233
|
+
]
|
|
234
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
235
|
+
const seen: string[] = []
|
|
236
|
+
a.s.chatOnce = async (uid) => {
|
|
237
|
+
seen.push(uid)
|
|
238
|
+
return { ok: false, state: 'quota', message: 'Your requests have exceeded the quota.' }
|
|
239
|
+
}
|
|
240
|
+
add(router, a.s)
|
|
241
|
+
|
|
242
|
+
const r = await router.testModel('supA', 'a-model', 'broke')
|
|
243
|
+
assert.equal(r.ok, false)
|
|
244
|
+
assert.deepEqual(seen, ['broke'], '钉死单连接,失败也不换号')
|
|
245
|
+
assert.match(r.error ?? '', /quota|exceeded/, `要报上游真因,实际: ${r.error}`)
|
|
246
|
+
assert.doesNotMatch(r.error ?? '', /不存在/, 'uid 在池里,绝不是「不存在」')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// ---- 积分用完的号必须退出路由(插件报 quota → 核心不选它) ----
|
|
250
|
+
// 端到端串起来:traework 的 pool.list() 在 credits===0 时报 quota,核心 pick()
|
|
251
|
+
// 据此跳过该号。这条走真实入口 chatCompletions,钉住「报出的事实真的改变了路由」,
|
|
252
|
+
// 而不只是「状态字符串好看」。
|
|
253
|
+
|
|
254
|
+
test('插件报 quota 的号:请求直接落到还有额度的号,不去撞死号', async () => {
|
|
255
|
+
const { router } = rig()
|
|
256
|
+
const a = spy('supA', 'a-model')
|
|
257
|
+
const tried: string[] = []
|
|
258
|
+
a.s.accounts = () => [
|
|
259
|
+
{ uid: 'broke', credits: 0, state: 'quota' },
|
|
260
|
+
{ uid: 'rich', credits: 500, state: 'ok' },
|
|
261
|
+
]
|
|
262
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
263
|
+
a.s.chatOnce = async (uid) => {
|
|
264
|
+
tried.push(uid)
|
|
265
|
+
return { ok: true, status: 200, body: '{"ok":1}' }
|
|
266
|
+
}
|
|
267
|
+
add(router, a.s)
|
|
268
|
+
|
|
269
|
+
await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(tried, ['rich'], '一次都不该撞到积分用完的号')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('积分用完的号恢复(充值后报 ok):立刻重新参与路由,无需重启', async () => {
|
|
275
|
+
const { router } = rig()
|
|
276
|
+
const a = spy('supA', 'a-model')
|
|
277
|
+
let broke = true
|
|
278
|
+
const tried: string[] = []
|
|
279
|
+
a.s.accounts = () => (broke
|
|
280
|
+
? [{ uid: 'was-broke', credits: 0, state: 'quota' }, { uid: 'other', credits: 10, state: 'ok' }]
|
|
281
|
+
: [{ uid: 'was-broke', credits: 900, state: 'ok' }])
|
|
282
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
283
|
+
a.s.chatOnce = async (uid) => {
|
|
284
|
+
tried.push(uid)
|
|
285
|
+
return { ok: true, status: 200, body: '{"ok":1}' }
|
|
286
|
+
}
|
|
287
|
+
add(router, a.s)
|
|
288
|
+
|
|
289
|
+
await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
|
|
290
|
+
assert.deepEqual(tried, ['other'], '没额度时不选它')
|
|
291
|
+
|
|
292
|
+
broke = false
|
|
293
|
+
await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
|
|
294
|
+
assert.equal(tried[1], 'was-broke', '充值后立刻回到候选')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('全池都没额度:如实失败,不假装成功也不无限转圈', async () => {
|
|
298
|
+
const { router } = rig()
|
|
299
|
+
const a = spy('supA', 'a-model')
|
|
300
|
+
a.s.accounts = () => [
|
|
301
|
+
{ uid: 'b1', credits: 0, state: 'quota' },
|
|
302
|
+
{ uid: 'b2', credits: 0, state: 'quota' },
|
|
303
|
+
]
|
|
304
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
305
|
+
a.s.chatOnce = async () => ({ ok: true, status: 200, body: '{"ok":1}' })
|
|
306
|
+
add(router, a.s)
|
|
307
|
+
|
|
308
|
+
const r = await router.testModel('supA', 'a-model')
|
|
309
|
+
assert.equal(r.ok, false, '没号可选就是失败')
|
|
310
|
+
assert.equal(a.calls, 0, '不该去撞任何一个已知没额度的号')
|
|
311
|
+
})
|
package/src/router/types.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
|
|
4
4
|
* 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
|
|
5
5
|
*/
|
|
6
|
-
import type { ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
6
|
+
import type { AccountState, ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
7
7
|
import type { AccountPool } from './account-pool.ts'
|
|
8
8
|
|
|
9
9
|
/** 面板展示的账号状态(脱敏)。 */
|
|
@@ -11,6 +11,14 @@ export interface SupplierAccount {
|
|
|
11
11
|
uid: string
|
|
12
12
|
nickname?: string
|
|
13
13
|
credits: number
|
|
14
|
+
/**
|
|
15
|
+
* 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
|
|
16
|
+
*
|
|
17
|
+
* `decorate()` 一直原样带出插件报的状态,核心选号也读它(`quota`/`session_dead`
|
|
18
|
+
* 的号不进候选)——面板类型里缺了这一格就会自相矛盾:号明明已被排除在路由之外,
|
|
19
|
+
* 界面上却只看得到「0 积分」,看不出它为什么不参与。
|
|
20
|
+
*/
|
|
21
|
+
state?: AccountState
|
|
14
22
|
cooling: boolean
|
|
15
23
|
until?: string
|
|
16
24
|
reason?: string
|
|
File without changes
|
|
File without changes
|
package/src/router/usage.test.ts
CHANGED
|
File without changes
|
package/src/shared.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface RouterAccount {
|
|
|
25
25
|
uid: string
|
|
26
26
|
nickname?: string
|
|
27
27
|
credits: number
|
|
28
|
+
/**
|
|
29
|
+
* 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
|
|
30
|
+
* 核心选号会跳过这些号,所以面板必须显示出来——否则界面写着「健康」,而路由
|
|
31
|
+
* 永远不选它,用户无从知道为什么。
|
|
32
|
+
*/
|
|
33
|
+
state?: string
|
|
28
34
|
cooling: boolean
|
|
29
35
|
until?: string
|
|
30
36
|
reason?: string
|
|
File without changes
|
package/src/supplier-config.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -215,3 +215,56 @@ test('用户目录只加载 *.js,且同 id 后者覆盖前者(按文件名
|
|
|
215
215
|
assert.equal(suppliers[0]?.supplier.name, 'Second')
|
|
216
216
|
assert.equal(suppliers[0]?.source, 'user')
|
|
217
217
|
})
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// 插件报的「额度用完」要真的进路由(end-to-end 的那一段线)
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 造一个报 quota 的插件(模拟 traework 的 pool.list():credits===0 → state quota)。
|
|
225
|
+
* 这条钉住 loader 的 `accounts()` 是**原样透传**插件状态(不带 decorate/hydrate):
|
|
226
|
+
* 核心 pick() 读的就是它。哪天有人给它加一层加工,把 quota 抹成 ok,路由又会
|
|
227
|
+
* 去撞没额度的号,而面板还显示正常——所以这里必须红。
|
|
228
|
+
*/
|
|
229
|
+
function quotaPlugin(id: string): SupplierModule {
|
|
230
|
+
const accounts: SupplierAccountNow[] = [
|
|
231
|
+
{ uid: 'u-broke', credits: 0, state: 'quota' },
|
|
232
|
+
{ uid: 'u-rich', credits: 500, state: 'ok' },
|
|
233
|
+
]
|
|
234
|
+
return {
|
|
235
|
+
id,
|
|
236
|
+
name: id,
|
|
237
|
+
status: () => ({ id, name: id, accounts }),
|
|
238
|
+
listModels: () => [],
|
|
239
|
+
getAlias: () => id,
|
|
240
|
+
chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
|
|
241
|
+
dispose: () => {},
|
|
242
|
+
} as unknown as SupplierModule
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
test('插件报的 quota 原样到达核心 → 没额度的号不进选号', () => {
|
|
246
|
+
const { env: e } = env()
|
|
247
|
+
const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
|
|
248
|
+
assert.equal(
|
|
249
|
+
supplier.accounts().find((a) => a.uid === 'u-broke')?.state,
|
|
250
|
+
'quota',
|
|
251
|
+
'accounts() 必须原样带出插件状态(核心 pick 读的就是它)',
|
|
252
|
+
)
|
|
253
|
+
assert.equal(supplier.pool.pick(supplier.accounts(), [], 'm1'), 'u-rich', '额度用完的号被跳过')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('额度用完的号在面板上也能看出来(decorate 不吞掉插件报的 state)', () => {
|
|
257
|
+
const { env: e } = env()
|
|
258
|
+
const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
|
|
259
|
+
const shown = supplier.status().accounts.find((a) => a.uid === 'u-broke')
|
|
260
|
+
assert.equal(shown?.state, 'quota', '面板要能区分「没额度」与「正常」')
|
|
261
|
+
assert.equal(shown?.cooling, false, '还没发生过失败,就不该标成冷却')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('额度用完 ≠ -1:water 后积分真值 0 照旧落进核心缓存(不冲值、不误判)', () => {
|
|
265
|
+
const { env: e, store } = env()
|
|
266
|
+
const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
|
|
267
|
+
supplier.status() // 触发 hydrateCredits
|
|
268
|
+
assert.equal(store.get('traework').credits['u-broke'], 0, '0 是真值,要落盘')
|
|
269
|
+
assert.equal(store.get('traework').credits['u-rich'], 500)
|
|
270
|
+
})
|
package/src/suppliers/loader.ts
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
|
|
File without changes
|
|
File without changes
|
|
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/suppliers/probe.ts
CHANGED
|
File without changes
|
|
@@ -211,3 +211,59 @@ test('刷新:status() 抛错 → 500 带错误信息', async () => {
|
|
|
211
211
|
assert.equal(body.ok, false)
|
|
212
212
|
assert.match(body.error ?? '', /status boom/)
|
|
213
213
|
})
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 插件在 `listModels` 里抛错时,核心必须回 **JSON 500**。
|
|
217
|
+
*
|
|
218
|
+
* 为什么值得单锁:webServer 的兜底是 `res.writeHead(400); res.end()` —— **空
|
|
219
|
+
* body**。面板 `response.json()` 于是抛 "Unexpected end of JSON input",真实原因
|
|
220
|
+
* (上游 500 / token 失效…)全丢。插件契约要求「出错就抛」(docs/suppliers.md),
|
|
221
|
+
* 所以这层必须接住。traework 修掉 `catch {}` 之后这条才第一次真被走到。
|
|
222
|
+
*/
|
|
223
|
+
test('models 路由:插件 listModels 抛错 → 500 + JSON body(不是空 400)', async () => {
|
|
224
|
+
for (const path of ['models', 'models/fetch']) {
|
|
225
|
+
const supplier = {
|
|
226
|
+
id: 'fake',
|
|
227
|
+
name: 'fake',
|
|
228
|
+
status: () => ({ id: 'fake', name: 'fake', accounts: [] }),
|
|
229
|
+
}
|
|
230
|
+
const loaded = { supplier, capabilities: new Set(), source: 'builtin' }
|
|
231
|
+
// router.modelsOf 复刻核心语义:无旧值 → 把插件的错抛出来
|
|
232
|
+
const router = {
|
|
233
|
+
modelsOf: async (): Promise<never> => { throw new Error('models api returned empty list') },
|
|
234
|
+
invalidateModels: () => {},
|
|
235
|
+
}
|
|
236
|
+
const routes = supplierRoutes('/router/api', loaded as never, { get: () => ({ custom: [], disabled: [], poolOrder: [], credits: {} }) } as never, router as never)
|
|
237
|
+
const route = routes.find((r) => r.path === `/router/api/suppliers/fake/${path}`)
|
|
238
|
+
assert.ok(route, `${path} 路由应注册`)
|
|
239
|
+
const sink = fakeRes()
|
|
240
|
+
await route!.handler({} as never, sink.res)
|
|
241
|
+
const body = sink.body as { ok: boolean; error?: string }
|
|
242
|
+
assert.equal(sink.status, 500, `${path}: 抛错该回 500`)
|
|
243
|
+
assert.equal(body.ok, false, `${path}: 必须是 JSON body,不能是空 body`)
|
|
244
|
+
assert.match(String(body.error), /empty list/, `${path}: 真实原因不能丢`)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('models 路由:body 不是合法 JSON → 400(不是 500,也不是空 body)', async () => {
|
|
249
|
+
const supplier = { id: 'fake', name: 'fake', status: () => ({ id: 'fake', name: 'fake', accounts: [] }) }
|
|
250
|
+
const loaded = { supplier, capabilities: new Set(), source: 'builtin' }
|
|
251
|
+
const router = { modelsOf: async () => [], invalidateModels: () => {} }
|
|
252
|
+
const routes = supplierRoutes('/router/api', loaded as never, { get: () => ({ custom: [], disabled: [], poolOrder: [], credits: {} }) } as never, router as never)
|
|
253
|
+
const route = routes.find((r) => r.path === '/router/api/suppliers/fake/models/toggle')
|
|
254
|
+
assert.ok(route)
|
|
255
|
+
// 造一个会吐非法 JSON 的 req(readBody 读 data/end)
|
|
256
|
+
const req = {
|
|
257
|
+
on(event: string, cb: (chunk?: Buffer) => void) {
|
|
258
|
+
if (event === 'data') cb(Buffer.from('{ not json'))
|
|
259
|
+
if (event === 'end') cb()
|
|
260
|
+
return req
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
const sink = fakeRes()
|
|
264
|
+
await route!.handler(req as never, sink.res)
|
|
265
|
+
const body = sink.body as { ok: boolean; error?: string }
|
|
266
|
+
assert.equal(sink.status, 400, '客户端传坏 body 是 400,不是核心 500')
|
|
267
|
+
assert.equal(body.ok, false)
|
|
268
|
+
assert.ok(String(body.error).length > 0, '400 也要带原因,不能是空 body')
|
|
269
|
+
})
|
|
@@ -44,6 +44,36 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
|
44
44
|
res.end(payload)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 包住会调插件的 handler:插件抛错时回 **JSON 500**,别让异常裸奔到 webServer。
|
|
49
|
+
*
|
|
50
|
+
* 为什么必须有这一层:webServer 的兜底是 `res.writeHead(400); res.end()`——**空
|
|
51
|
+
* body**。面板拿到空 body,`response.json()` 抛 "Unexpected end of JSON input",
|
|
52
|
+
* 真实原因(上游 500 / token 失效…)全丢,用户只看到一句解析错误。
|
|
53
|
+
*
|
|
54
|
+
* 这条路径是插件契约「出错就抛」(docs/suppliers.md「模型统一策略」) 的下游:插件
|
|
55
|
+
* 老实抛了,核心就得老实接住并说清楚。以前 `listModels` 静默吞错,所以从没暴露过
|
|
56
|
+
* 这个缺口(traework 2026-10-10 「获取模型只有几个」修完才浮现)。
|
|
57
|
+
*/
|
|
58
|
+
function guard(handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>) {
|
|
59
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
60
|
+
try {
|
|
61
|
+
await handler(req, res)
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// headers 已发出就没法再改状态码了(writeJson 是原子的,这里基本不会发生)
|
|
64
|
+
if (res.headersSent) {
|
|
65
|
+
res.end()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
// body 不是合法 JSON 是客户端的事(400),不该报成核心 500。这些 handler 里
|
|
69
|
+
// 唯一的 SyntaxError 来源就是 `JSON.parse(await readBody(req))`。
|
|
70
|
+
// (改 guard 之前这种情况由 webServer 兜底成**空** 400,改后保住状态码又带上原因。)
|
|
71
|
+
const status = err instanceof SyntaxError ? 400 : 500
|
|
72
|
+
writeJson(res, status, { ok: false, error: (err as Error).message })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
async function readBody(req: IncomingMessage, limit = 64 << 10): Promise<string> {
|
|
48
78
|
const chunks: Buffer[] = []
|
|
49
79
|
let size = 0
|
|
@@ -109,10 +139,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
109
139
|
routes.push({
|
|
110
140
|
kind: 'exact',
|
|
111
141
|
path: `${p}/models`,
|
|
112
|
-
handler: async (_req, res) => {
|
|
142
|
+
handler: guard(async (_req, res) => {
|
|
113
143
|
const models = await router.modelsOf(s.id)
|
|
114
144
|
writeJson(res, 200, { ok: true, alias: s.getAlias(), models })
|
|
115
|
-
},
|
|
145
|
+
}),
|
|
116
146
|
})
|
|
117
147
|
|
|
118
148
|
// ---- 通用: 供应商开关 ----
|
|
@@ -235,7 +265,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
235
265
|
routes.push({
|
|
236
266
|
kind: 'exact',
|
|
237
267
|
path: `${p}/models/toggle`,
|
|
238
|
-
handler: async (req, res) => {
|
|
268
|
+
handler: guard(async (req, res) => {
|
|
239
269
|
const body = JSON.parse(await readBody(req)) as { id?: string; enabled?: boolean }
|
|
240
270
|
const cfg = store.get(s.id)
|
|
241
271
|
const models = await router.modelsOf(s.id)
|
|
@@ -246,14 +276,14 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
246
276
|
store.setModelEnabled(s.id, body.id, !!body.enabled)
|
|
247
277
|
router.invalidateModels(s.id)
|
|
248
278
|
writeJson(res, 200, { ok: true })
|
|
249
|
-
},
|
|
279
|
+
}),
|
|
250
280
|
})
|
|
251
281
|
|
|
252
282
|
// ---- 通用: models/add(自定义模型) ----
|
|
253
283
|
routes.push({
|
|
254
284
|
kind: 'exact',
|
|
255
285
|
path: `${p}/models/add`,
|
|
256
|
-
handler: async (req, res) => {
|
|
286
|
+
handler: guard(async (req, res) => {
|
|
257
287
|
const body = JSON.parse(await readBody(req)) as { id?: string }
|
|
258
288
|
const id = (body.id ?? '').trim()
|
|
259
289
|
if (id === '') {
|
|
@@ -268,7 +298,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
268
298
|
store.addCustomModel(s.id, id)
|
|
269
299
|
router.invalidateModels(s.id)
|
|
270
300
|
writeJson(res, 200, { ok: true })
|
|
271
|
-
},
|
|
301
|
+
}),
|
|
272
302
|
})
|
|
273
303
|
|
|
274
304
|
// ---- 通用: models/remove(自定义模型) ----
|
|
@@ -292,13 +322,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
292
322
|
routes.push({
|
|
293
323
|
kind: 'exact',
|
|
294
324
|
path: `${p}/models/bulk`,
|
|
295
|
-
handler: async (req, res) => {
|
|
325
|
+
handler: guard(async (req, res) => {
|
|
296
326
|
const body = JSON.parse(await readBody(req)) as { enabled?: boolean }
|
|
297
327
|
const models = await router.modelsOf(s.id)
|
|
298
328
|
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id))
|
|
299
329
|
router.invalidateModels(s.id)
|
|
300
330
|
writeJson(res, 200, { ok: true })
|
|
301
|
-
},
|
|
331
|
+
}),
|
|
302
332
|
})
|
|
303
333
|
|
|
304
334
|
// ---- 通用: pool/order ----
|
|
@@ -324,10 +354,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
324
354
|
routes.push({
|
|
325
355
|
kind: 'exact',
|
|
326
356
|
path: `${p}/models/fetch`,
|
|
327
|
-
handler: async (_req, res) => {
|
|
357
|
+
handler: guard(async (_req, res) => {
|
|
328
358
|
const models = await router.modelsOf(s.id, true)
|
|
329
359
|
writeJson(res, 200, { ok: true, models })
|
|
330
|
-
},
|
|
360
|
+
}),
|
|
331
361
|
})
|
|
332
362
|
|
|
333
363
|
// ---- 通用: 测试模型(核心统一走 chatOnce 路径,账号池回退/冷却自动生效) ----
|