dsh-router-core 0.3.21 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -63
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +28 -5
- package/docs/pool-sticky-block.md +158 -0
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +1300 -549
- package/lib/client.js +1300 -549
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +1169 -254
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -7
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm/adapter.d.ts +35 -4
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +65 -15
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +25 -21
- package/lib/types/supplier-config.d.ts +15 -3
- package/lib/types/suppliers/contract.d.ts +37 -4
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -2
- package/package.json +50 -16
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +32 -196
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +86 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +2592 -1635
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -7
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +165 -68
- package/src/llm/adapter.test.ts +175 -4
- package/src/llm/adapter.ts +109 -16
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +276 -62
- package/src/router/models-cache.test.ts +54 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +25 -24
- package/src/supplier-config.ts +33 -13
- package/src/suppliers/contract.ts +56 -4
- package/src/suppliers/loader.test.ts +38 -4
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +103 -23
- package/src/types.d.ts +2 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 契约体检的判据测试。三条,缺一条这个功能就不该存在:
|
|
3
|
+
*
|
|
4
|
+
* 1. **清单跟契约一起长** —— 契约加了成员而清单没加,体检就静默漏检。编译期类型
|
|
5
|
+
* 抓不到(清单是运行时数组,契约成员是类型),只能解析契约源码来钉。
|
|
6
|
+
* 2. **会毁掉体检前提的成员绝不被调用** —— `dispose` 卸载自己、`addApiKey` 写凭证等,
|
|
7
|
+
* 一律移出清单(见 PROBE_EXCLUDED_MEMBERS),既不出现也不被调。
|
|
8
|
+
* 3. **必填成员缺失要报错**(fail),可选成员缺失只是 absent。
|
|
9
|
+
* 4. **「全部启用/全部禁用」真跑**(并还原)—— 它是唯一能抓出「插件在 listModels
|
|
10
|
+
* 里过滤已禁用模型」越权的路径:全部禁用之后违规插件把模型全藏起来,核心就再也
|
|
11
|
+
* 拉不到列表、用户「全部启用」时没有 id 可传。
|
|
12
|
+
*/
|
|
13
|
+
import test from 'node:test'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { probeSupplier, SUPPLIER_CONTRACT_MEMBERS, PROBE_EXCLUDED_MEMBERS } from './probe.ts'
|
|
19
|
+
import type { LoadedSupplier } from './loader.ts'
|
|
20
|
+
|
|
21
|
+
// 本文件在 src/suppliers/ 下,所以 '../..' 才是仓库根
|
|
22
|
+
const root = fileURLToPath(new URL('../..', import.meta.url))
|
|
23
|
+
|
|
24
|
+
/** 从契约源码里抠出 `SupplierModule` 的成员名(编译期不存在的形状,只能这么拿)。 */
|
|
25
|
+
function contractMemberNames(): string[] {
|
|
26
|
+
const src = readFileSync(join(root, 'src', 'suppliers', 'contract.ts'), 'utf8')
|
|
27
|
+
const body = src.slice(src.indexOf('export interface SupplierModule'), src.indexOf('\n}', src.indexOf('export interface SupplierModule')))
|
|
28
|
+
const names = new Set<string>()
|
|
29
|
+
for (const m of body.matchAll(/^\s{2}(?:readonly\s+)?([a-zA-Z][a-zA-Z0-9]*)\??\s*[(:]/gm)) names.add(m[1]!)
|
|
30
|
+
return [...names]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('清单覆盖契约的全部成员,除了有意排除的那几个(契约加了成员而清单没加 = 体检漏检)', () => {
|
|
34
|
+
const declared = new Set([...SUPPLIER_CONTRACT_MEMBERS.map((m) => m.key), ...Object.keys(PROBE_EXCLUDED_MEMBERS)])
|
|
35
|
+
const missing = contractMemberNames().filter((k) => !declared.has(k))
|
|
36
|
+
assert.deepEqual(missing, [], `契约里有、清单里和排除名单里都没有的成员:${missing.join('、')}`)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('有意排除的成员都写了排除理由(不能无声消失)', () => {
|
|
40
|
+
for (const [key, why] of Object.entries(PROBE_EXCLUDED_MEMBERS)) {
|
|
41
|
+
assert.ok(why.length > 0, `${key} 必须写清为什么不测`)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('不测的成员不出现在报告清单里(不占篇幅)', () => {
|
|
46
|
+
const keys = new Set(SUPPLIER_CONTRACT_MEMBERS.map((m) => m.key))
|
|
47
|
+
for (const key of Object.keys(PROBE_EXCLUDED_MEMBERS)) {
|
|
48
|
+
assert.equal(keys.has(key), false, `${key} 说好不测,就不该出现在清单里`)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('清单里没有契约里不存在的成员(写错名字 = 永远探不到,且不报错)', () => {
|
|
53
|
+
const inContract = new Set(contractMemberNames())
|
|
54
|
+
const extra = SUPPLIER_CONTRACT_MEMBERS.map((m) => m.key).filter((k) => !inContract.has(k))
|
|
55
|
+
assert.deepEqual(extra, [], `清单里写了契约中不存在的成员:${extra.join('、')}`)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** 会记账的假模块:任何被调用的成员都会被记下来。 */
|
|
59
|
+
function spyModule(implemented: readonly string[]): { m: Record<string, unknown>; called: string[] } {
|
|
60
|
+
const called: string[] = []
|
|
61
|
+
const record = <T>(key: string, value: T) => (): T => { called.push(key); return value }
|
|
62
|
+
const m: Record<string, unknown> = { id: record('id', 'spy'), name: record('name', 'Spy') }
|
|
63
|
+
for (const key of implemented) {
|
|
64
|
+
if (key === 'status') m.status = record('status', { id: 'spy', name: 'Spy', accounts: [{ uid: 'u1', credits: 0, state: 'ok' }] })
|
|
65
|
+
else if (key === 'listModels') m.listModels = record('listModels', [{ id: 'mm' }])
|
|
66
|
+
else if (key === 'chatOnce') m.chatOnce = record('chatOnce', { ok: true })
|
|
67
|
+
else if (key === 'dispose') m.dispose = record('dispose', undefined)
|
|
68
|
+
else if (key === 'generateLoginUrl') m.generateLoginUrl = record('generateLoginUrl', 'https://x')
|
|
69
|
+
else if (key === 'completeLogin') m.completeLogin = record('completeLogin', { uid: 'u', nickname: 'n' })
|
|
70
|
+
else if (key === 'addApiKey') m.addApiKey = record('addApiKey', { ok: true })
|
|
71
|
+
else if (key === 'removeLink') m.removeLink = record('removeLink', true)
|
|
72
|
+
else if (key === 'pollLogin') m.pollLogin = record('pollLogin', true)
|
|
73
|
+
else if (key === 'checkinNow') m.checkinNow = record('checkinNow', { ok: true, status: 'ok' })
|
|
74
|
+
else m[key] = record(key, 'v')
|
|
75
|
+
}
|
|
76
|
+
return { m, called }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function loaded(m: Record<string, unknown>): LoadedSupplier {
|
|
80
|
+
return {
|
|
81
|
+
supplier: { id: 'spy', name: 'Spy', __module: m },
|
|
82
|
+
capabilities: new Set<string>(),
|
|
83
|
+
source: 'external',
|
|
84
|
+
} as unknown as LoadedSupplier
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('留在清单里的成员都有明确的 probe 方式', () => {
|
|
88
|
+
for (const m of SUPPLIER_CONTRACT_MEMBERS) {
|
|
89
|
+
assert.ok(m.probe === 'safe' || m.probe === 'skip', `${m.key} 的 probe 类型不明确`)
|
|
90
|
+
if (m.probe === 'skip') {
|
|
91
|
+
assert.ok((m.skipReason ?? '').length > 0, `${m.key} 标了 skip 必须写清为什么不自动跑`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('probe 输入的成员会实跑(单档全自动:能安全试的就是要试)', async () => {
|
|
97
|
+
const { m, called } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
98
|
+
await probeSupplier(loaded(m), { models: [{ id: 'm1', enabled: true }] })
|
|
99
|
+
// 清单里的成员该跑的都要跑(需要真实连接的用连接池的 token)
|
|
100
|
+
assert.equal(called.includes('checkinNow'), true, 'checkinNow 该对真实连接实跑')
|
|
101
|
+
assert.ok(called.includes('status'), 'safe 成员确实跑了')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('dispose 不出现在报告里(调用它就是卸载自己,体检没法在自己身上验自己)', async () => {
|
|
105
|
+
const { m } = spyModule([...SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key), 'dispose'])
|
|
106
|
+
const report = await probeSupplier(loaded(m), { models: [{ id: 'a', enabled: true }] })
|
|
107
|
+
assert.equal(
|
|
108
|
+
report.members.some((x) => x.key === 'dispose'), false,
|
|
109
|
+
'dispose 已移出清单:占一行只为说「没验」,是用篇幅淹掉真问题',
|
|
110
|
+
)
|
|
111
|
+
assert.ok(PROBE_EXCLUDED_MEMBERS.dispose, '要留在「有意不测」名单里,并写清理由')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('必填成员缺失 = fail(插件不完整),可选成员缺失 = absent', async () => {
|
|
115
|
+
const { m } = spyModule(['status', 'listModels']) // 缺 chatOnce/dispose,也缺全部可选
|
|
116
|
+
const report = await probeSupplier(loaded(m))
|
|
117
|
+
const chatOnce = report.members.find((x) => x.key === 'chatOnce')
|
|
118
|
+
assert.equal(chatOnce?.state, 'fail', '必填成员缺失要报 fail')
|
|
119
|
+
assert.equal(report.members.find((x) => x.key === 'checkinNow')?.state, 'absent', '可选成员缺失只是 absent')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('safe 成员抛错只让那一条 fail,不连坐', async () => {
|
|
123
|
+
const { m } = spyModule(['status', 'listModels'])
|
|
124
|
+
m.listModels = () => { throw new Error('boom') }
|
|
125
|
+
const report = await probeSupplier(loaded(m))
|
|
126
|
+
const listModels = report.members.find((x) => x.key === 'listModels')
|
|
127
|
+
const status = report.members.find((x) => x.key === 'status')
|
|
128
|
+
assert.equal(listModels?.state, 'fail')
|
|
129
|
+
assert.match(listModels?.detail ?? '', /boom/)
|
|
130
|
+
assert.equal(status?.state, 'ok')
|
|
131
|
+
assert.equal(report.summary.fail, report.members.filter((x) => x.state === 'fail').length, 'summary 与明细一致')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/* ---------------- 核心侧:模型启用/禁用只「报」不「改」 ---------------- */
|
|
135
|
+
|
|
136
|
+
test('核心区:「全部启用/全部禁用」实跑,并如实写「拿不到」而不是冒充 0', async () => {
|
|
137
|
+
const { m } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
138
|
+
const withModels = await probeSupplier(loaded(m), { models: [
|
|
139
|
+
{ id: 'a', enabled: true }, { id: 'b', enabled: true }, { id: 'c', enabled: false },
|
|
140
|
+
] })
|
|
141
|
+
assert.deepEqual(withModels.core.models, { total: 3, enabled: 2, disabled: 1 })
|
|
142
|
+
|
|
143
|
+
const without = await probeSupplier(loaded(m))
|
|
144
|
+
assert.equal(without.core.models.total, null, '拿不到就是 null,不能当成 0 个模型')
|
|
145
|
+
assert.match(without.core.models.note ?? '', /拿不到|不可用/)
|
|
146
|
+
assert.equal(without.core.operations[0]?.ran, false, '拿不到模型时这一项不跑')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('核心区:全部启用/禁用是真跑的(不是只报可用性)', async () => {
|
|
150
|
+
const { m } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
151
|
+
const calls: string[] = []
|
|
152
|
+
const report = await probeSupplier(loaded(m), {
|
|
153
|
+
models: [{ id: 'a', enabled: true }, { id: 'b', enabled: true }],
|
|
154
|
+
runBulkToggleRoundTrip: async () => { calls.push('bulk'); return { ok: true, detail: '全部禁用后 2 个模型都在' } },
|
|
155
|
+
})
|
|
156
|
+
assert.deepEqual(calls, ['bulk'], '这条必须真跑 —— 它是唯一能抓出越权的路径')
|
|
157
|
+
const bulk = report.core.operations.find((o) => o.key === 'models.bulk')
|
|
158
|
+
assert.equal(bulk?.ran, true)
|
|
159
|
+
assert.equal(bulk?.ok, true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('核心区:全部启用/禁用实跑发现问题 = 报出来(Loomy 那种)', async () => {
|
|
163
|
+
const { m } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
164
|
+
const report = await probeSupplier(loaded(m), {
|
|
165
|
+
models: [{ id: 'a', enabled: true }],
|
|
166
|
+
runBulkToggleRoundTrip: async () => ({ ok: false, detail: '全部禁用之后只剩 0/1 个模型 —— 插件把已禁用的藏起来了' }),
|
|
167
|
+
})
|
|
168
|
+
const bulk = report.core.operations.find((o) => o.key === 'models.bulk')
|
|
169
|
+
assert.equal(bulk?.ok, false, '必须报出问题')
|
|
170
|
+
assert.match(bulk?.detail ?? '', /藏起来了/)
|
|
171
|
+
assert.equal(report.summary.bulk, 'fail', '核心区实跑失败要能被数字看见(不是只藏在 detail 里)')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('status 的账号摘要给真实数量与状态分布(链接全过期正是要靠它看出来)', async () => {
|
|
175
|
+
const { m } = spyModule(['status'])
|
|
176
|
+
m.status = () => ({ id: 'spy', name: 'Spy', accounts: [
|
|
177
|
+
{ uid: 'a', credits: 0, state: 'ok' },
|
|
178
|
+
{ uid: 'b', credits: 0, state: 'ok' },
|
|
179
|
+
{ uid: 'c', credits: 0, state: 'session_dead' },
|
|
180
|
+
] })
|
|
181
|
+
const report = await probeSupplier(loaded(m))
|
|
182
|
+
const status = report.members.find((x) => x.key === 'status')
|
|
183
|
+
assert.equal(status?.state, 'ok')
|
|
184
|
+
assert.match(status?.detail ?? '', /3 个账号/, '要给真实数量,不是「≥1」')
|
|
185
|
+
assert.match(status?.detail ?? '', /session_dead 1/, '要把失效的号点出来')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
/* ---------------- 单档全自动:能跑的都跑,跑不了的写清为什么 ---------------- */
|
|
189
|
+
|
|
190
|
+
test('不测的成员不会被调用(就算插件实现了)', async () => {
|
|
191
|
+
const { m, called } = spyModule([...SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key), ...Object.keys(PROBE_EXCLUDED_MEMBERS)])
|
|
192
|
+
await probeSupplier(loaded(m), { models: [{ id: 'a', enabled: true }] })
|
|
193
|
+
for (const key of Object.keys(PROBE_EXCLUDED_MEMBERS)) {
|
|
194
|
+
assert.equal(called.includes(key), false, `${key} 说好不测,就不该被调用`)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('单档全自动:chatOnce 真跑一次(前提是调用方给了 runChatOnce)', async () => {
|
|
199
|
+
const { m, called } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
200
|
+
const tried: string[] = []
|
|
201
|
+
const report = await probeSupplier(loaded(m), {
|
|
202
|
+
models: [{ id: 'm1', enabled: true }, { id: 'm2', enabled: true }],
|
|
203
|
+
runChatOnce: async (model) => { tried.push(model); return { ok: true, detail: '通了' } },
|
|
204
|
+
})
|
|
205
|
+
assert.deepEqual(tried, ['m1'], '用第一个已启用的模型真跑')
|
|
206
|
+
const chat = report.members.find((x) => x.key === 'chatOnce')
|
|
207
|
+
assert.equal(chat?.executed, 'ran')
|
|
208
|
+
assert.equal(chat?.state, 'ok')
|
|
209
|
+
assert.match(chat?.detail ?? '', /真实调用 m1/)
|
|
210
|
+
assert.equal(called.includes('chatOnce'), false, '走的是核心 testModel 那条真实路径,不该直调插件的 chatOnce')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('chatOnce 跑不通就是 fail(出厂体检最该抓住的就是这个)', async () => {
|
|
214
|
+
const { m } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
215
|
+
const report = await probeSupplier(loaded(m), {
|
|
216
|
+
models: [{ id: 'm1', enabled: true }],
|
|
217
|
+
runChatOnce: async () => ({ ok: false, detail: '401 unauthorized' }),
|
|
218
|
+
})
|
|
219
|
+
const chat = report.members.find((x) => x.key === 'chatOnce')
|
|
220
|
+
assert.equal(chat?.state, 'fail')
|
|
221
|
+
assert.ok(report.summary.fail > 0, 'listModels 越权要计入失败数')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('单档全自动:checkinNow 对连接池里的真实连接真签到', async () => {
|
|
225
|
+
const seen: string[] = []
|
|
226
|
+
const { m } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
227
|
+
m.status = () => ({ id: 'spy', name: 'Spy', accounts: [{ uid: 'u1', credits: 0, state: 'ok' }] })
|
|
228
|
+
m.checkinNow = (uid: string) => { seen.push(uid); return Promise.resolve({ ok: true, status: 'ok' }) }
|
|
229
|
+
const report = await probeSupplier(loaded(m), { models: [{ id: 'm1', enabled: true }] })
|
|
230
|
+
assert.deepEqual(seen, ['u1'], '用连接池里第一个真实连接')
|
|
231
|
+
assert.equal(report.members.find((x) => x.key === 'checkinNow')?.executed, 'ran')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('dispose 一次都不被调用(哪怕它实现了)', async () => {
|
|
235
|
+
const { m, called } = spyModule([...SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key), 'dispose'])
|
|
236
|
+
await probeSupplier(loaded(m), { models: [{ id: 'a', enabled: true }] })
|
|
237
|
+
assert.equal(called.includes('dispose'), false, '调用 dispose 就是把这个供应商卸载掉')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('没有 token / 没有模型时,数字要如实说「未验」而不是「通过」', async () => {
|
|
241
|
+
const full = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
242
|
+
full.m.checkinNow = (uid: string) => Promise.resolve({ ok: true, status: 'ok' })
|
|
243
|
+
// 假模块没有 runChatOnce → chatOnce 无法验证 → 记 unverified
|
|
244
|
+
const noAccount = await probeSupplier(loaded(full.m), { models: [{ id: 'm1', enabled: true }] })
|
|
245
|
+
assert.equal(noAccount.summary.unverified, 1, '没条件验就是未验,不能混进通过里')
|
|
246
|
+
assert.equal(noAccount.members.find((x) => x.key === 'chatOnce')?.state, 'unverified')
|
|
247
|
+
|
|
248
|
+
const withToken = await probeSupplier(loaded(full.m), {
|
|
249
|
+
models: [{ id: 'm1', enabled: true }],
|
|
250
|
+
runChatOnce: async () => ({ ok: true, detail: '通了' }),
|
|
251
|
+
})
|
|
252
|
+
assert.equal(withToken.summary.unverified, 0, '有 token 且全跑通 → 没有未验项')
|
|
253
|
+
assert.equal(withToken.summary.fail, 0)
|
|
254
|
+
|
|
255
|
+
const missing = spyModule(['status', 'listModels'])
|
|
256
|
+
const m = await probeSupplier(loaded(missing.m), { models: [] })
|
|
257
|
+
assert.ok(m.summary.fail > 0, '必填成员缺失要体现在失败数里')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('summary.ran 只数真的跑过的成员(清单里全部实跑)', async () => {
|
|
261
|
+
const { m } = spyModule(SUPPLIER_CONTRACT_MEMBERS.map((x) => x.key))
|
|
262
|
+
const report = await probeSupplier(loaded(m), { models: [{ id: 'a', enabled: true }] })
|
|
263
|
+
assert.equal(report.summary.ran, report.summary.total,
|
|
264
|
+
'清单里的成员一律实跑(会毁掉前提的早已移出清单),所以 ran 应等于 total')
|
|
265
|
+
})
|
|
266
|
+
|