dsh-router-core 0.4.7 → 0.4.8
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 +9 -69
- package/cordis.patch.yml +2 -7
- package/docs/ext.md +3 -17
- package/lib/client-registry.js +50 -565
- package/lib/client.js +50 -565
- package/lib/ext-prompt/index.js +20 -15
- package/lib/index.js +0 -419
- package/lib/types/ext/contract.d.ts +1 -1
- package/lib/types/ext-prompt/content.d.ts +11 -0
- package/lib/types/ext-prompt/index.d.ts +7 -9
- package/lib/types/ext-prompt/mount.d.ts +2 -2
- package/lib/types/ext-prompt/plugin.d.ts +3 -5
- package/lib/types/suppliers/registry.d.ts +0 -1
- package/package.json +1 -10
- package/src/client/ExtDetail.tsx +1 -13
- package/src/client/group-controls.test.ts +1 -1
- package/src/client/group-controls.ts +1 -1
- package/src/client/index.tsx +0 -17
- package/src/client/router-components.test.ts +1 -1
- package/src/client/router.css +3 -354
- package/src/ext/contract.ts +1 -1
- package/src/ext-prompt/content.ts +17 -12
- package/src/ext-prompt/ext-prompt.test.ts +3 -3
- package/src/ext-prompt/index.ts +7 -9
- package/src/ext-prompt/mount.ts +2 -2
- package/src/ext-prompt/plugin.ts +3 -5
- package/src/router/index.ts +2 -3
- package/src/router/models-cache.test.ts +5 -6
- package/src/router/supplier-switch.test.ts +1 -1
- package/src/suppliers/registry.ts +0 -71
- package/lib/contract-D4BWZ7Xe.js +0 -13
- package/lib/ext-test/index.js +0 -36
- package/lib/ext-test/locale/en.json +0 -6
- package/lib/ext-test/locale/zh.json +0 -6
- package/lib/ext-test/package.json +0 -5
- package/lib/types/ext-test/index.d.ts +0 -21
- package/lib/types/ext-test/plugin.d.ts +0 -23
- package/lib/types/suppliers/probe.d.ts +0 -160
- package/src/client/ExtTestPanel.tsx +0 -392
- package/src/client/comp-button-scope.test.ts +0 -58
- package/src/client/comp-panel-layout.test.ts +0 -58
- package/src/client/ext-panels.test.ts +0 -44
- package/src/client/ext-panels.ts +0 -49
- package/src/client/model-choice.test.ts +0 -41
- package/src/client/model-choice.ts +0 -37
- package/src/ext-test/ext-test.test.ts +0 -60
- package/src/ext-test/index.ts +0 -35
- package/src/ext-test/locale/en.json +0 -6
- package/src/ext-test/locale/zh.json +0 -6
- package/src/ext-test/plugin.ts +0 -34
- package/src/ext-test/row.json +0 -5
- package/src/suppliers/probe.test.ts +0 -266
- package/src/suppliers/probe.ts +0 -475
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 自检面板的「模型」下拉该列哪些 —— 纯函数,不碰 React。
|
|
3
|
-
*
|
|
4
|
-
* 为什么不能把端点返回的模型全列出来:那个端点给的是**全部**模型(含用户在
|
|
5
|
-
* 供应商详情里停用的)。实测某供应商 30 个模型里只有 3 个是启用的 —— 把 30 个都
|
|
6
|
-
* 塞进下拉,用户会挑到一个自己明明关掉的模型去测,测通了也不代表配置是对的。
|
|
7
|
-
* 所以只列**可用**的(用户没停用的),与路由实际会用的那批保持一致。
|
|
8
|
-
*
|
|
9
|
-
* 顺带把「没有可选模型」拆成两种,因为**用户该做的事不一样**:
|
|
10
|
-
* - `none`(一个模型都没有)→ 该去供应商详情拉取模型;
|
|
11
|
-
* - `all-disabled`(有模型但全被停用)→ 该去供应商详情把它们打开,
|
|
12
|
-
* 去拉取是白跑一趟。
|
|
13
|
-
* 两种共用一句「没有模型」就是把用户往错的方向指。
|
|
14
|
-
*
|
|
15
|
-
* `enabled` 缺省按**未停用**读(`!== false`):这个字段是用户的配置,缺失时不该
|
|
16
|
-
* 凭空把模型藏起来。
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** `/suppliers/:id/models` 里的一条模型。 */
|
|
20
|
-
export interface ModelRow {
|
|
21
|
-
id: string
|
|
22
|
-
enabled?: boolean
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface ModelChoice {
|
|
26
|
-
/** 可用模型的 id,按端点返回的顺序(不重排 —— 端点已经按优先级排过了)。 */
|
|
27
|
-
ids: string[]
|
|
28
|
-
/** 没有可选模型时的原因;`undefined` = 有得选。 */
|
|
29
|
-
empty?: 'none' | 'all-disabled'
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** 从端点答复里挑出可用的模型,并说明「空的」到底是哪一种空。 */
|
|
33
|
-
export function modelChoice(models: readonly ModelRow[]): ModelChoice {
|
|
34
|
-
const ids = models.filter((m) => m.enabled !== false).map((m) => m.id)
|
|
35
|
-
if (ids.length > 0) return { ids }
|
|
36
|
-
return { ids, empty: models.length === 0 ? 'none' : 'all-disabled' }
|
|
37
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 插件自检扩展(`router.ext` 侧)测试 —— 锁住三件事:
|
|
3
|
-
* 1. 登记进 `router.ext` 表,带上可显示的身份(名字/说明/就绪);
|
|
4
|
-
* 2. **幂等**:同一个 id 已在表里就不顶掉别人的登记;
|
|
5
|
-
* 3. 恒为就绪 —— 自检能力不依赖外部二进制,不该提前否决(真跑一次才知道)。
|
|
6
|
-
*
|
|
7
|
-
* 为什么测这个:扩展的「卡片出现与否」完全取决于它在不在这张表里,而表是核心
|
|
8
|
-
* provide 的**同一个 live 对象**。登记没发生的话,面板与插件页都看不到它,且不报错。
|
|
9
|
-
*/
|
|
10
|
-
import test from 'node:test'
|
|
11
|
-
import assert from 'node:assert/strict'
|
|
12
|
-
import { createTestExt, EXT_TEST_ID } from './plugin.ts'
|
|
13
|
-
import { apply, name as CORDIS_NAME } from './index.ts'
|
|
14
|
-
import type { RouterExtService } from '../ext/contract.ts'
|
|
15
|
-
|
|
16
|
-
test('扩展器带上面板要用的身份,且恒为就绪', () => {
|
|
17
|
-
const ext = createTestExt()
|
|
18
|
-
assert.equal(ext.id, EXT_TEST_ID)
|
|
19
|
-
assert.equal(ext.name, '插件自检')
|
|
20
|
-
assert.ok((ext.description ?? '').length > 0, '没有说明,插件页那一行就没有副标题')
|
|
21
|
-
assert.equal(ext.getState().ready, true, '自检不依赖本机二进制,不该报未就绪')
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test('cordis 身份是行 id(与 patch 里声明的逐字相同,否则 loader 认不出)', () => {
|
|
25
|
-
assert.equal(CORDIS_NAME, 'dsh-router-ext-test')
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
/** 最小 ctx:inject 立刻回调、emit 记下来。 */
|
|
29
|
-
function fakeCtx(table: RouterExtService): {
|
|
30
|
-
ctx: Parameters<typeof apply>[0]
|
|
31
|
-
events: string[]
|
|
32
|
-
} {
|
|
33
|
-
const events: string[] = []
|
|
34
|
-
return {
|
|
35
|
-
events,
|
|
36
|
-
ctx: {
|
|
37
|
-
inject: (deps: string[], cb: (sctx: unknown) => void) => {
|
|
38
|
-
cb({ get: (key: string) => (key === 'router.ext' ? table : undefined) })
|
|
39
|
-
},
|
|
40
|
-
emit: (event: string) => { events.push(event) },
|
|
41
|
-
} as unknown as Parameters<typeof apply>[0],
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', () => {
|
|
46
|
-
const table: RouterExtService = {}
|
|
47
|
-
const { ctx, events } = fakeCtx(table)
|
|
48
|
-
apply(ctx)
|
|
49
|
-
assert.equal(table[EXT_TEST_ID]?.name, '插件自检')
|
|
50
|
-
assert.deepEqual(events, [], '核心持有同一个 live 对象且每次请求现读,不需要广播事件')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('幂等:同一个 id 已在表里时不顶掉别人的登记', () => {
|
|
54
|
-
const table: RouterExtService = {}
|
|
55
|
-
const { ctx } = fakeCtx(table)
|
|
56
|
-
apply(ctx)
|
|
57
|
-
const first = table[EXT_TEST_ID]
|
|
58
|
-
apply(ctx)
|
|
59
|
-
assert.equal(table[EXT_TEST_ID], first, '重复登记不该换成一个新对象')
|
|
60
|
-
})
|
package/src/ext-test/index.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 插件自检扩展的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
|
|
3
|
-
* 原生「包含的组件」里的那一行。
|
|
4
|
-
*
|
|
5
|
-
* 壳存在的理由与内置供应商那三层一样:`plugin.ts` 已经把 `name` 用作**面板显示名**
|
|
6
|
-
* (扩展契约的字段,所有扩展都这么写),而 cordis 的插件身份也要一个 `name`,两者抢
|
|
7
|
-
* 同一个名字。所以行 = 这一层壳(cordis 身份 + 登记),实现 = `plugin.ts`。
|
|
8
|
-
*
|
|
9
|
-
* **默认关闭**(patch 里 `disabled: true`):行关闭时 loader 根本不 import 这个模块,
|
|
10
|
-
* 扩展也就没进 `router.ext` 表 —— 所以「打开才出现这张卡片」是天然的,不需要额外的
|
|
11
|
-
* 状态位。用户在这一行打开后,卡片出现在插件页的「路由组件 → 扩展」组与面板的
|
|
12
|
-
* 「扩展」页。
|
|
13
|
-
*
|
|
14
|
-
* 登记时把扩展开关置为开:本扩展**没有独立的运行期开关**,它的开关就是这一行
|
|
15
|
-
* (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
|
|
16
|
-
*/
|
|
17
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
-
import { currentExts, currentExtStore } from '../ext/contract.ts'
|
|
19
|
-
import { createTestExt, EXT_TEST_ID } from './plugin.ts'
|
|
20
|
-
|
|
21
|
-
/** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
|
|
22
|
-
export const name = 'dsh-router-ext-test'
|
|
23
|
-
|
|
24
|
-
/** 登记本扩展。幂等:同一个 id 已在表里就直接返回,不顶掉别人的登记。 */
|
|
25
|
-
export function apply(ctx: Context): void {
|
|
26
|
-
ctx.inject(['router.ext'], (sctx) => {
|
|
27
|
-
const table = currentExts(sctx)
|
|
28
|
-
if (table === undefined) return
|
|
29
|
-
if (table[EXT_TEST_ID] !== undefined) return
|
|
30
|
-
table[EXT_TEST_ID] = createTestExt()
|
|
31
|
-
currentExtStore(sctx)?.setEnabled(EXT_TEST_ID, true)
|
|
32
|
-
// 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
|
|
33
|
-
// 现读这张表 —— 所以往里 append 就够了,不需要(也不存在)广播事件。
|
|
34
|
-
})
|
|
35
|
-
}
|
package/src/ext-test/plugin.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 插件自检扩展(`router.ext` 侧的实现)—— 声明与状态都在这里,**不做任何拦截**。
|
|
3
|
-
*
|
|
4
|
-
* 它和 rtk 那类扩展的区别要写清楚:本扩展**不挂任何监听、不改写任何命令**,
|
|
5
|
-
* 唯一作用是让用户在官方插件页里打开一块自检面板(选供应商 / 模型 / 连接,跑一次
|
|
6
|
-
* 真实的访问测试并看到底报了什么)。所以它复用 `router.ext` 这条通道只是为了拿到
|
|
7
|
-
* **发现 + 开关**两件事,而不是为了拦截。
|
|
8
|
-
*
|
|
9
|
-
* 面板本身在浏览器侧(`src/client/ExtTestPanel.tsx`),通过原生行的
|
|
10
|
-
* `plugins.row.config` 座位挂在「包含的组件」里那一行的详情页上;这里只负责让它在
|
|
11
|
-
* 列表里有个身份、以及报出运行时就绪状态。
|
|
12
|
-
*/
|
|
13
|
-
import type { RouterExt } from '../ext/contract.ts'
|
|
14
|
-
|
|
15
|
-
/** 扩展开关表里的注册键(也是 URL 面板读它的 id)。 */
|
|
16
|
-
export const EXT_TEST_ID = 'test'
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* 构造这个扩展器。
|
|
20
|
-
*
|
|
21
|
-
* `getState()` 恒为就绪:自检能力不依赖外部二进制(不像 rtk 需要本机装 rtk),
|
|
22
|
-
* 「能不能测」取决于用户选的供应商/模型/连接,不该由扩展提前否决 —— 真跑一次
|
|
23
|
-
* 才知道,那时错误信息比一个「未就绪」有用得多。
|
|
24
|
-
*/
|
|
25
|
-
export function createTestExt(): RouterExt {
|
|
26
|
-
return {
|
|
27
|
-
id: EXT_TEST_ID,
|
|
28
|
-
name: '插件自检',
|
|
29
|
-
description: '选供应商、模型与连接跑一次真实访问测试;或对外部供应商插件做契约体检,逐个成员报告实现与可用情况',
|
|
30
|
-
// 随核心分发 -> 插件页的原生「包含的组件」里有它一行,自绘节里不再重复列。
|
|
31
|
-
source: 'builtin' as const,
|
|
32
|
-
getState: () => ({ ready: true }),
|
|
33
|
-
}
|
|
34
|
-
}
|
package/src/ext-test/row.json
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
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
|
-
|