dsh-router-core 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -38
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +29 -6
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +939 -132
- package/lib/client.js +939 -132
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +838 -89
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -0
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-base-url.d.ts +31 -0
- package/lib/types/llm/adapter.d.ts +40 -4
- package/lib/types/router/index.d.ts +61 -8
- package/lib/types/shared.d.ts +12 -0
- package/lib/types/supplier-config.d.ts +15 -0
- package/lib/types/suppliers/contract.d.ts +35 -2
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -1
- package/package.json +39 -4
- package/src/client/ExtDetail.tsx +21 -22
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +41 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +490 -9
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -0
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-base-url.test.ts +129 -0
- package/src/host-base-url.ts +43 -0
- package/src/index.ts +46 -11
- package/src/llm/adapter.test.ts +101 -4
- package/src/llm/adapter.ts +89 -20
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +255 -35
- package/src/router/models-cache.test.ts +45 -0
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/shared.ts +12 -0
- package/src/supplier-config.ts +32 -1
- package/src/suppliers/contract.ts +54 -2
- package/src/suppliers/loader.test.ts +36 -2
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +102 -3
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供应商开关测试 —— 锁住「关掉 = 真的不参与路由」,而且是**全部入口**都拦。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这个文件:这个开关的唯一实现是 `Router` 里的活跃集合
|
|
5
|
+
* (`active` = 装载清单里开关为开的那一份)。而路由有 15 处读供应商清单,
|
|
6
|
+
* 少收一处就出现「面板显示已关闭、请求照样打过去」——那是最坏的一种:开关说了
|
|
7
|
+
* 假话,用户以为断了流量其实在烧额度。所以这里逐个入口钉住:
|
|
8
|
+
*
|
|
9
|
+
* 1. 组合腿(`supplierId,modelId` 形态)
|
|
10
|
+
* 2. 直调(`alias/model` 形态)
|
|
11
|
+
* 3. 模型目录 / 别名前缀(关掉的不能留下「前缀存在但必然 503」的死前缀)
|
|
12
|
+
* 4. status() 仍带出关掉的(否则关掉后就没法再开,开关失去对象)
|
|
13
|
+
* 5. 关掉不能连带卸载(`removeSupplier` 是插件卸载用的另一码事)
|
|
14
|
+
*/
|
|
15
|
+
import test from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import type { ServerResponse } from 'node:http'
|
|
18
|
+
import { Router } from './index.ts'
|
|
19
|
+
import type { ModelInfo } from './types.ts'
|
|
20
|
+
import { AccountPool } from './account-pool.ts'
|
|
21
|
+
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
22
|
+
import type { AccountPool as Pool } from './account-pool.ts'
|
|
23
|
+
import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
|
|
24
|
+
|
|
25
|
+
interface Spy {
|
|
26
|
+
calls: number
|
|
27
|
+
seen: string[]
|
|
28
|
+
s: {
|
|
29
|
+
id: string
|
|
30
|
+
name: string
|
|
31
|
+
priority: number
|
|
32
|
+
pool: Pool
|
|
33
|
+
status: () => SupplierStatusNow
|
|
34
|
+
listModels: () => Promise<ModelInfo[]>
|
|
35
|
+
modelsWithEnabled: () => Promise<Array<ModelInfo & { enabled: boolean }>>
|
|
36
|
+
customModelIds: () => string[]
|
|
37
|
+
getAlias: () => string
|
|
38
|
+
accounts: () => SupplierAccountNow[]
|
|
39
|
+
chatOnce: (uid: string, lv: string, req: { rawBody: string }) => Promise<ChatOnceResult>
|
|
40
|
+
dispose: () => void
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 带探针的供应商:只服务自己的模型,记录被问过什么。 */
|
|
45
|
+
function spy(id: string, modelId: string, alias = id): Spy {
|
|
46
|
+
const calls = { n: 0 }
|
|
47
|
+
const seen: string[] = []
|
|
48
|
+
return {
|
|
49
|
+
get calls() { return calls.n },
|
|
50
|
+
seen,
|
|
51
|
+
s: {
|
|
52
|
+
id, name: id, priority: 0, pool: new AccountPool(),
|
|
53
|
+
status: () => ({ id, name: id, accounts: [{ uid: `${id}-u1`, credits: 0, state: 'ok' }] }),
|
|
54
|
+
listModels: async () => [{ id: modelId }],
|
|
55
|
+
modelsWithEnabled: async () => [{ id: modelId, enabled: true }],
|
|
56
|
+
customModelIds: () => [`${id}-custom`],
|
|
57
|
+
getAlias: () => alias,
|
|
58
|
+
accounts: () => [{ uid: `${id}-u1`, credits: 0, state: 'ok' }],
|
|
59
|
+
chatOnce: async (_uid, _lv, req) => {
|
|
60
|
+
calls.n += 1
|
|
61
|
+
const m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
|
|
62
|
+
seen.push(m)
|
|
63
|
+
const base = m.startsWith(`${alias}/`) ? m.slice(alias.length + 1) : m
|
|
64
|
+
if (base !== modelId) return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
|
|
65
|
+
return { ok: true, status: 200, body: '{"ok":1}' }
|
|
66
|
+
},
|
|
67
|
+
dispose: () => {},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function add(router: Router, s: unknown): void {
|
|
73
|
+
router.add(s as Parameters<Router['add']>[0])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fakeRes(): ServerResponse {
|
|
77
|
+
return {
|
|
78
|
+
writeHead: (): unknown => undefined,
|
|
79
|
+
end: (): unknown => undefined,
|
|
80
|
+
write: (): boolean => true,
|
|
81
|
+
once: (): unknown => undefined,
|
|
82
|
+
removeListener: (): unknown => undefined,
|
|
83
|
+
destroy: (): unknown => undefined,
|
|
84
|
+
} as unknown as ServerResponse
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function reqWith(model: string) {
|
|
88
|
+
return { model, stream: false, rawBody: JSON.stringify({ model, messages: [] }) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 路由器 + 共享 store(开关要能从 store 落盘/读回,所以显式传同一个实例)。 */
|
|
92
|
+
function rig() {
|
|
93
|
+
const store = new SupplierConfigStore('')
|
|
94
|
+
return { router: new Router('', store, () => {}), store }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test('关掉的供应商:组合腿不会打到它(唯一的路由入口之一)', async () => {
|
|
98
|
+
const { router, store } = rig()
|
|
99
|
+
const a = spy('supA', 'a-model')
|
|
100
|
+
const b = spy('supB', 'b-model')
|
|
101
|
+
add(router, a.s); add(router, b.s)
|
|
102
|
+
assert.equal(router.createCombo('c', ['supA,a-model', 'supB,b-model']).ok, true)
|
|
103
|
+
|
|
104
|
+
store.setEnabled('supA', false)
|
|
105
|
+
await router.chatCompletions(reqWith('c'), fakeRes())
|
|
106
|
+
|
|
107
|
+
assert.equal(a.calls, 0, '已关闭的供应商不该被组合腿问到')
|
|
108
|
+
assert.equal(b.calls, 1, '开着的供应商照常服务')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('关掉的供应商:alias 直调也进不去(第二条入口)', async () => {
|
|
112
|
+
const { router, store } = rig()
|
|
113
|
+
const a = spy('supA', 'a-model', 'aaa')
|
|
114
|
+
add(router, a.s)
|
|
115
|
+
|
|
116
|
+
store.setEnabled('supA', false)
|
|
117
|
+
await router.chatCompletions(reqWith('aaa/a-model'), fakeRes())
|
|
118
|
+
|
|
119
|
+
assert.equal(a.calls, 0, '已关闭的供应商不该被直调命中')
|
|
120
|
+
assert.equal(router.supplierByAlias('aaa'), undefined, '别名反查也不该命中')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('关掉再打开,路由立刻恢复(开关不是卸载)', async () => {
|
|
124
|
+
const { router, store } = rig()
|
|
125
|
+
const a = spy('supA', 'a-model', 'aaa')
|
|
126
|
+
add(router, a.s)
|
|
127
|
+
|
|
128
|
+
store.setEnabled('supA', false)
|
|
129
|
+
assert.equal(a.calls, 0)
|
|
130
|
+
store.setEnabled('supA', true)
|
|
131
|
+
await router.chatCompletions(reqWith('aaa/a-model'), fakeRes())
|
|
132
|
+
assert.equal(a.calls, 1, '重新打开后应当照常服务')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('关掉的供应商仍出现在 status()(关掉后要能再开)且带 enabled=false', () => {
|
|
136
|
+
const { router, store } = rig()
|
|
137
|
+
add(router, spy('supA', 'a-model').s)
|
|
138
|
+
store.setEnabled('supA', false)
|
|
139
|
+
|
|
140
|
+
const { suppliers } = router.status()
|
|
141
|
+
assert.equal(suppliers.length, 1, '关掉不等于卸载,列表里还得有它')
|
|
142
|
+
assert.equal(suppliers[0]?.enabled, false)
|
|
143
|
+
assert.equal(router.isEnabled('supA'), false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('关掉的供应商不留死前缀、不进模型目录(否则是「存在但必然 503」)', async () => {
|
|
147
|
+
const { router, store } = rig()
|
|
148
|
+
add(router, spy('supA', 'a-model', 'aaa').s)
|
|
149
|
+
store.setEnabled('supA', false)
|
|
150
|
+
|
|
151
|
+
assert.deepEqual(router.aliases(), [], '关掉的供应商不该再提供模型前缀')
|
|
152
|
+
assert.deepEqual(await router.modelsOf('supA'), [], '关掉的供应商不该还能拉模型')
|
|
153
|
+
const models = await router.listModels()
|
|
154
|
+
assert.equal(
|
|
155
|
+
models.some((m) => m.id.includes('supA-custom')),
|
|
156
|
+
false,
|
|
157
|
+
'关掉的供应商的自定义模型不该进模型目录',
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('没记录过的供应商默认开着(升级不把全灭变成全开)', () => {
|
|
162
|
+
const { router } = rig()
|
|
163
|
+
add(router, spy('supA', 'a-model').s)
|
|
164
|
+
assert.equal(router.isEnabled('supA'), true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// ---- 指定连接自检(插件自检面板的连接测试用它) ----
|
|
168
|
+
// 面板「供应商」详情里的「测试」按钮走账号池(任选可用号、失败自动换号);
|
|
169
|
+
// 面板的连接测试可以**钉死一个连接**,用来诊断单个链接到底通不通。关键性质:
|
|
170
|
+
// 指定了连接就**不回退**到别的号,否则「测这个连接」测出来的是另一个号的结果。
|
|
171
|
+
|
|
172
|
+
test('指定连接:只试这一个连接', async () => {
|
|
173
|
+
const { router } = rig()
|
|
174
|
+
const a = spy('supA', 'a-model')
|
|
175
|
+
a.s.accounts = () => [
|
|
176
|
+
{ uid: 'u1', credits: 0, state: 'ok' },
|
|
177
|
+
{ uid: 'u2', credits: 0, state: 'ok' },
|
|
178
|
+
]
|
|
179
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
180
|
+
add(router, a.s)
|
|
181
|
+
|
|
182
|
+
const r = await router.testModel('supA', 'a-model', 'u1')
|
|
183
|
+
assert.equal(r.ok, true)
|
|
184
|
+
assert.equal(a.calls, 1, '只该试一次')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('指定连接失败时不回退到别的号(否则诊断结论是假的)', async () => {
|
|
188
|
+
const { router } = rig()
|
|
189
|
+
const a = spy('supA', 'a-model')
|
|
190
|
+
a.s.accounts = () => [
|
|
191
|
+
{ uid: 'u1', credits: 0, state: 'ok' },
|
|
192
|
+
{ uid: 'u2', credits: 0, state: 'ok' },
|
|
193
|
+
]
|
|
194
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
195
|
+
// 自己记账:spy 的 calls 计数封在原 chatOnce 里,换掉实现就得自己数。
|
|
196
|
+
const seen: string[] = []
|
|
197
|
+
a.s.chatOnce = async (uid) => {
|
|
198
|
+
seen.push(uid)
|
|
199
|
+
return { ok: false, state: 'transport', message: `link ${uid} down` }
|
|
200
|
+
}
|
|
201
|
+
add(router, a.s)
|
|
202
|
+
|
|
203
|
+
const r = await router.testModel('supA', 'a-model', 'u1')
|
|
204
|
+
assert.equal(r.ok, false)
|
|
205
|
+
assert.deepEqual(seen, ['u1'], '失败也不该换号重试')
|
|
206
|
+
assert.match(r.error ?? '', /u1/, '错误信息要点名是哪个连接挂了')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('指定不存在的连接:明确报「连接不存在」,而不是静默测了别的号', async () => {
|
|
210
|
+
const { router } = rig()
|
|
211
|
+
const a = spy('supA', 'a-model')
|
|
212
|
+
a.s.accounts = () => [{ uid: 'u1', credits: 0, state: 'ok' }]
|
|
213
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
214
|
+
add(router, a.s)
|
|
215
|
+
|
|
216
|
+
const r = await router.testModel('supA', 'a-model', 'gone')
|
|
217
|
+
assert.equal(r.ok, false)
|
|
218
|
+
assert.equal(a.calls, 0, '一个号都不该被试')
|
|
219
|
+
assert.match(r.error ?? '', /不存在/)
|
|
220
|
+
})
|
package/src/shared.ts
CHANGED
|
@@ -79,6 +79,11 @@ export interface RouterHealthResponse {
|
|
|
79
79
|
capabilities?: string[]
|
|
80
80
|
/** 来源:内置 / 用户目录 / 外部插件。 */
|
|
81
81
|
source?: 'builtin' | 'user' | 'external'
|
|
82
|
+
/**
|
|
83
|
+
* 供应商开关(是否参与路由)。**关掉的仍出现在这个列表里** —— 否则关掉之后
|
|
84
|
+
* 就没有可指的对象、再也开不回来。缺省按开启读(老版本核心没有这个字段)。
|
|
85
|
+
*/
|
|
86
|
+
enabled?: boolean
|
|
82
87
|
}>
|
|
83
88
|
error?: string
|
|
84
89
|
}
|
|
@@ -152,6 +157,13 @@ export interface RouterExtItem {
|
|
|
152
157
|
ready?: boolean
|
|
153
158
|
/** 一句话状态说明(如「本机未装 rtk」)。 */
|
|
154
159
|
detail?: string
|
|
160
|
+
/**
|
|
161
|
+
* 来源:随核心分发的内置扩展标 `builtin`,独立安装的不标(= plugin)。
|
|
162
|
+
*
|
|
163
|
+
* 插件页那个自绘的「路由组件」节据此**只列独立安装的**:内置的已经在原生
|
|
164
|
+
* 「包含的组件」里占一行了,两处都列就是同一个东西显示两遍(与内置供应商同理)。
|
|
165
|
+
*/
|
|
166
|
+
source?: 'builtin'
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
/** `/router/api/ext` response(扩展插件列表 + 开关)。 */
|
package/src/supplier-config.ts
CHANGED
|
@@ -23,6 +23,14 @@ import { dirname, join } from 'node:path'
|
|
|
23
23
|
export const CREDITS_UNKNOWN = -1
|
|
24
24
|
|
|
25
25
|
export interface SupplierConfig {
|
|
26
|
+
/**
|
|
27
|
+
* 这个供应商是否参与路由(用户开关,与扩展开关同构)。
|
|
28
|
+
*
|
|
29
|
+
* **缺省 true** —— 没记录过就是开着。关掉 = 核心不把它接进路由(`Router` 的
|
|
30
|
+
* 活跃集合),请求不会落到它、模型列表/组合里也不再出现它,而它自己仍留在
|
|
31
|
+
* `/health` 与面板里(不然关掉之后就找不回来、开关也失去了开关的对象)。
|
|
32
|
+
*/
|
|
33
|
+
enabled: boolean
|
|
26
34
|
alias: string
|
|
27
35
|
disabled: string[]
|
|
28
36
|
custom: string[]
|
|
@@ -87,7 +95,7 @@ export class SupplierConfigStore {
|
|
|
87
95
|
get(supplierId: string): SupplierConfig {
|
|
88
96
|
let cfg = this.bySupplier.get(supplierId)
|
|
89
97
|
if (!cfg) {
|
|
90
|
-
cfg = { alias: '', disabled: [], custom: [], poolOrder: [], credits: {}, accountNames: {} }
|
|
98
|
+
cfg = { enabled: true, alias: '', disabled: [], custom: [], poolOrder: [], credits: {}, accountNames: {} }
|
|
91
99
|
this.bySupplier.set(supplierId, cfg)
|
|
92
100
|
}
|
|
93
101
|
return cfg
|
|
@@ -110,6 +118,26 @@ export class SupplierConfigStore {
|
|
|
110
118
|
return { ok: true }
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
// ---- 供应商开关(用户对核心面板的操作,归核心持久化,与 ext.json 同一层) ----
|
|
122
|
+
|
|
123
|
+
/** 这个供应商是否参与路由(未记录过 = 开)。 */
|
|
124
|
+
isEnabled(supplierId: string): boolean {
|
|
125
|
+
return this.get(supplierId).enabled
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 置供应商开关。**不写盘**时值不变也要返回,让调用方知道是否需要重连路由。
|
|
130
|
+
* @returns 值确实变了
|
|
131
|
+
*/
|
|
132
|
+
setEnabled(supplierId: string, enabled: boolean): boolean {
|
|
133
|
+
if (supplierId === '') return false
|
|
134
|
+
const cfg = this.get(supplierId)
|
|
135
|
+
if (cfg.enabled === enabled) return false
|
|
136
|
+
cfg.enabled = enabled
|
|
137
|
+
this.saveLocked()
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
setModelEnabled(supplierId: string, modelId: string, enabled: boolean): void {
|
|
114
142
|
const cfg = this.get(supplierId)
|
|
115
143
|
cfg.disabled = enabled
|
|
@@ -208,6 +236,8 @@ export class SupplierConfigStore {
|
|
|
208
236
|
}
|
|
209
237
|
for (const [id, c] of Object.entries(f.suppliers ?? {})) {
|
|
210
238
|
this.bySupplier.set(id, {
|
|
239
|
+
// 老配置没有这个字段(写盘早于本开关)→ 视为开着,不让升级把全灭变成全开
|
|
240
|
+
enabled: typeof c.enabled === 'boolean' ? c.enabled : true,
|
|
211
241
|
alias: typeof c.alias === 'string' ? c.alias : '',
|
|
212
242
|
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === 'string') : [],
|
|
213
243
|
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === 'string') : [],
|
|
@@ -223,6 +253,7 @@ export class SupplierConfigStore {
|
|
|
223
253
|
const f: ConfigFile = { suppliers: {} }
|
|
224
254
|
for (const [id, cfg] of this.bySupplier) {
|
|
225
255
|
f.suppliers[id] = {
|
|
256
|
+
enabled: cfg.enabled,
|
|
226
257
|
alias: cfg.alias,
|
|
227
258
|
disabled: [...cfg.disabled],
|
|
228
259
|
custom: [...cfg.custom],
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 用户自定义供应商 js 放到 `~/.dsh/profiles/web/suppliers/*.js`,重启后自动加载。
|
|
10
10
|
* 内置供应商与用户 js 同构(差异化能力驱动)。
|
|
11
11
|
*/
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
13
|
import type { ChatRequest, ModelInfo, SupplierAccount } from '../router/types.ts'
|
|
13
14
|
import type { SupplierConfigStore } from '../supplier-config.ts'
|
|
14
15
|
import type { CredentialStore } from '../credential-store.ts'
|
|
@@ -40,8 +41,16 @@ export interface SupplierEnv {
|
|
|
40
41
|
onLateFailure?: (uid: string, model: string, state: AccountState, message: string) => void
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。
|
|
46
|
+
*
|
|
47
|
+
* `source` 是**可选标签**,声明这个供应商是从哪儿来的。核心用它把 `/health` 的
|
|
48
|
+
* `source` 报准(面板按它分「内置 / 插件」两组,面板和插件页都看这个值)。
|
|
49
|
+
* 不声明 = `external`(独立安装的供应商插件的现状,一个字段都不用改)。
|
|
50
|
+
*/
|
|
51
|
+
export type SupplierFactory = ((env: SupplierEnv) => SupplierModule) & {
|
|
52
|
+
source?: 'builtin'
|
|
53
|
+
}
|
|
45
54
|
|
|
46
55
|
/**
|
|
47
56
|
* 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
|
|
@@ -170,3 +179,46 @@ export interface SupplierModule {
|
|
|
170
179
|
|
|
171
180
|
/** 供应商账号(复用 router 类型,供 loader 包装)。 */
|
|
172
181
|
export type { SupplierAccount }
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `router.suppliers` 共享聚合表的取值:一个供应商工厂。
|
|
185
|
+
*
|
|
186
|
+
* 核心 provide 空表;**谁都可以往里 append** —— 内置供应商(core 自己的插件行)
|
|
187
|
+
* 与独立安装的供应商插件走的是同一条通道、同一套契约,核心那边不区分内外,
|
|
188
|
+
* 只看工厂上的 `source` 标签。
|
|
189
|
+
*/
|
|
190
|
+
export type SupplierRegistry = Record<string, SupplierFactory>
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 读当前 `router.suppliers` 表(同一 live 对象,可追加)——供应商行用它登记自己。
|
|
194
|
+
*
|
|
195
|
+
* 读法与 `ext/contract.ts` 的 `currentExts` 同一套(cordis 的 ctx 是 Proxy,
|
|
196
|
+
* 未声明的服务直接 get 会抛,所以走 `get()` 兜底)。
|
|
197
|
+
*/
|
|
198
|
+
export function currentSuppliers(ctx: unknown): SupplierRegistry | undefined {
|
|
199
|
+
const c = ctx as {
|
|
200
|
+
get?: (key: string) => unknown
|
|
201
|
+
router?: { suppliers?: SupplierRegistry }
|
|
202
|
+
}
|
|
203
|
+
return (c.get?.('router.suppliers') ?? c.router?.suppliers) as SupplierRegistry | undefined
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 把一个供应商工厂登记进 `router.suppliers`,并通知核心去装载它。
|
|
208
|
+
*
|
|
209
|
+
* 为什么自己 emit `internal/service`:核心靠这个事件发现「表里多了供应商」
|
|
210
|
+
* (同 `router.ext` 的做法)。**幂等** —— 同一个 id 已经在表里就直接返回,
|
|
211
|
+
* 不会把别人的登记顶掉(内置行与用户目录 js 同名时由核心按优先级裁决,
|
|
212
|
+
* 这里不越权覆盖)。
|
|
213
|
+
*/
|
|
214
|
+
export function registerSupplierRow(ctx: Context, id: string, factory: SupplierFactory): void {
|
|
215
|
+
ctx.inject(['router.suppliers'], (sctx) => {
|
|
216
|
+
const table = currentSuppliers(sctx)
|
|
217
|
+
if (table === undefined) return
|
|
218
|
+
if (table[id] !== undefined) return
|
|
219
|
+
table[id] = factory
|
|
220
|
+
// 核心只 provide 空表、**不监听表的变更**(表里放的是工厂,实例化要它的
|
|
221
|
+
// env),所以新增条目必须自己喊一声,否则核心只在启动时扫过一遍。
|
|
222
|
+
ctx.emit('internal/service', 'router.suppliers', table)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import test from 'node:test'
|
|
11
11
|
import assert from 'node:assert/strict'
|
|
12
|
-
import { mkdtempSync } from 'node:fs'
|
|
12
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { tmpdir } from 'node:os'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import { wrapModule } from './loader.ts'
|
|
15
|
+
import { loadSuppliers, supplierWins, wrapModule } from './loader.ts'
|
|
16
16
|
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
17
17
|
import type { CredentialStore } from '../credential-store.ts'
|
|
18
18
|
import type { SupplierEnv, SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
|
|
@@ -181,3 +181,37 @@ test('onLateFailure 空 uid(无账号供应商)→ 不记,不炸', () => {
|
|
|
181
181
|
assert.doesNotThrow(() => e.onLateFailure?.('', 'm1', 'quota', 'boom'))
|
|
182
182
|
assert.deepEqual(loaded.supplier.status().accounts.map((a) => a.cooling), [false, false])
|
|
183
183
|
})
|
|
184
|
+
|
|
185
|
+
// ---- 同 id 冲突的优先级:用户目录 js > 内置行 > 外部插件供应商 ----
|
|
186
|
+
// 规则错了不抛异常,只会让用户的覆盖悄悄失效,所以必须有闸门。
|
|
187
|
+
|
|
188
|
+
test('优先级:用户目录的 js 压过内置行(内置改行之后不能反过来)', () => {
|
|
189
|
+
assert.equal(supplierWins('user', 'builtin'), true)
|
|
190
|
+
assert.equal(supplierWins('user', 'external'), true)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('优先级:内置行压过外部插件供应商,同级不顶替', () => {
|
|
194
|
+
assert.equal(supplierWins('builtin', 'external'), true)
|
|
195
|
+
assert.equal(supplierWins('builtin', 'builtin'), false)
|
|
196
|
+
assert.equal(supplierWins('external', 'builtin'), false)
|
|
197
|
+
assert.equal(supplierWins('external', 'external'), false)
|
|
198
|
+
assert.equal(supplierWins('user', 'user'), false)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('用户目录只加载 *.js,且同 id 后者覆盖前者(按文件名序)', async () => {
|
|
202
|
+
const dir = mkdtempSync(join(tmpdir(), 'sup-dir-'))
|
|
203
|
+
writeFileSync(join(dir, 'a-first.js'), 'export const id = "dup"\nexport const name = "First"\nexport const accounts = () => []\n')
|
|
204
|
+
writeFileSync(join(dir, 'b-second.js'), 'export const id = "dup"\nexport const name = "Second"\nexport const accounts = () => []\n')
|
|
205
|
+
writeFileSync(join(dir, 'notes.txt'), '不是 js,不该被加载')
|
|
206
|
+
const { suppliers, errors } = await loadSuppliers({
|
|
207
|
+
userDir: dir,
|
|
208
|
+
dataDir: dir,
|
|
209
|
+
store: new SupplierConfigStore(join(dir, 'cfg.json')),
|
|
210
|
+
credentials: {} as CredentialStore,
|
|
211
|
+
log: () => {},
|
|
212
|
+
})
|
|
213
|
+
assert.deepEqual(errors, [])
|
|
214
|
+
assert.deepEqual(suppliers.map((s) => s.supplier.id), ['dup'])
|
|
215
|
+
assert.equal(suppliers[0]?.supplier.name, 'Second')
|
|
216
|
+
assert.equal(suppliers[0]?.source, 'user')
|
|
217
|
+
})
|
package/src/suppliers/loader.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 供应商 loader —— 扫描
|
|
2
|
+
* 供应商 loader —— 扫描 **用户目录**下的 *.js,动态 import,按契约包装成
|
|
3
3
|
* 内部 Supplier,并暴露"能力检测"(capabilities)供通用路由/前端按能力渲染。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* 1.
|
|
7
|
-
* 2.
|
|
5
|
+
* 加载来源(两处):
|
|
6
|
+
* 1. 用户:`~/.dsh/profiles/<profile>/suppliers/*.js`(自定义供应商)
|
|
7
|
+
* 2. 内置:**插件页「包含的组件」里的行** —— core 自己的 cordis.patch.yml
|
|
8
|
+
* insert 了三个子路径模块,经 `router.suppliers` 表进来,与独立安装的
|
|
9
|
+
* 供应商插件同一条通道,核心不区分内外,只看工厂上的 `source` 标签。
|
|
10
|
+
*
|
|
11
|
+
* 内置曾经也是「扫 `<plugin>/lib/suppliers/*.js`」(本文件早先的 builtinDir)。
|
|
12
|
+
* 改成行之后那条路径已删:扫目录拿不到行的身份(插件页的开关/显示名/说明),
|
|
13
|
+
* 而且关一个内置供应商要手改配置才生效 —— 行才有宿主管的开关。
|
|
8
14
|
*/
|
|
9
15
|
import { readdirSync } from 'node:fs'
|
|
10
16
|
import { join, dirname, basename } from 'node:path'
|
|
@@ -196,10 +202,23 @@ function scanDir(dir: string): string[] {
|
|
|
196
202
|
}
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
/**
|
|
206
|
+
* 同 id 冲突时的优先级:**用户目录 js > 内置行 > 外部插件供应商**。
|
|
207
|
+
*
|
|
208
|
+
* 为什么用户目录最高:用户放一个同 id 的 js 就是想替换它,这是「扫目录时代」
|
|
209
|
+
* 就有的语义(见本文件早先的 `loadSuppliers`:内置被用户版覆盖),内置改成行
|
|
210
|
+
* 之后不能悄悄反过来。
|
|
211
|
+
*
|
|
212
|
+
* 纯函数 + 有测试:这条规则错了不会抛异常,只会让用户的覆盖悄悄失效。
|
|
213
|
+
*/
|
|
214
|
+
const SUPPLIER_RANK: Record<LoadedSupplier['source'], number> = { user: 2, builtin: 1, external: 0 }
|
|
215
|
+
|
|
216
|
+
export function supplierWins(incoming: LoadedSupplier['source'], existing: LoadedSupplier['source']): boolean {
|
|
217
|
+
return SUPPLIER_RANK[incoming] > SUPPLIER_RANK[existing]
|
|
218
|
+
}
|
|
219
|
+
|
|
199
220
|
export interface LoadSuppliersOptions {
|
|
200
|
-
/**
|
|
201
|
-
builtinDir: string
|
|
202
|
-
/** 用户供应商目录(~/.dsh/profiles/web/suppliers)。 */
|
|
221
|
+
/** 用户供应商目录(~/.dsh/profiles/<profile>/suppliers)。 */
|
|
203
222
|
userDir: string
|
|
204
223
|
/** 数据目录。 */
|
|
205
224
|
dataDir: string
|
|
@@ -210,7 +229,10 @@ export interface LoadSuppliersOptions {
|
|
|
210
229
|
log: (msg: string) => void
|
|
211
230
|
}
|
|
212
231
|
|
|
213
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* 加载用户目录下的供应商。同一个 id 出现多个文件时**后者覆盖前者**(按文件名序),
|
|
234
|
+
* 与内置共存的裁决交给核心(`registerLoaded` 的优先级:用户目录 js > 行)。
|
|
235
|
+
*/
|
|
214
236
|
export async function loadSuppliers(opts: LoadSuppliersOptions): Promise<{
|
|
215
237
|
suppliers: LoadedSupplier[]
|
|
216
238
|
errors: SupplierLoadError[]
|
|
@@ -219,18 +241,11 @@ export async function loadSuppliers(opts: LoadSuppliersOptions): Promise<{
|
|
|
219
241
|
const byId = new Map<string, LoadedSupplier>()
|
|
220
242
|
const errors: SupplierLoadError[] = []
|
|
221
243
|
|
|
222
|
-
const
|
|
223
|
-
...scanDir(opts.builtinDir).map((f) => ({ f, builtin: true })),
|
|
224
|
-
...scanDir(opts.userDir).map((f) => ({ f, builtin: false })),
|
|
225
|
-
]
|
|
226
|
-
for (const { f, builtin } of files) {
|
|
244
|
+
for (const f of scanDir(opts.userDir).sort()) {
|
|
227
245
|
try {
|
|
228
|
-
const loaded = await loadOne(f, env,
|
|
229
|
-
// 用户目录覆盖内置
|
|
230
|
-
const existing = byId.get(loaded.supplier.id)
|
|
231
|
-
if (existing && builtin) continue // 内置已被用户版覆盖
|
|
246
|
+
const loaded = await loadOne(f, env, 'user')
|
|
232
247
|
byId.set(loaded.supplier.id, loaded)
|
|
233
|
-
opts.log(`supplier ${loaded.supplier.id} loaded (
|
|
248
|
+
opts.log(`supplier ${loaded.supplier.id} loaded (user) from ${basename(f)}`)
|
|
234
249
|
} catch (err) {
|
|
235
250
|
errors.push({ id: basename(f, '.js'), file: f, error: (err as Error).message })
|
|
236
251
|
opts.log(`supplier load failed ${basename(f)}: ${(err as Error).message}`)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NVIDIA NIM 的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
|
|
3
|
+
* 「包含的组件」里的那一行。
|
|
4
|
+
*
|
|
5
|
+
* 为什么多一层壳:`plugin.ts` 已经把 `name` 用作**面板显示名**(供应商契约的
|
|
6
|
+
* 字段,所有供应商插件都这么写),而 cordis 的插件身份也要一个 `name`,两者
|
|
7
|
+
* 抢同一个名字。所以行 = 这一层壳(cordis 身份 + 登记),实现 = `plugin.ts`。
|
|
8
|
+
*
|
|
9
|
+
* 这一行只做一件事:把供应商工厂登记进核心的 `router.suppliers` 表。**通道和
|
|
10
|
+
* 契约与独立安装的供应商插件完全一样**(core 不区分内外,只看工厂上的
|
|
11
|
+
* `source: 'builtin'` 标签),因此这一行在插件页能拿到宿主管的开关:关掉它 =
|
|
12
|
+
* loader 不 import 这个模块 = 供应商压根没注册 = 路由不会落到它。
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import { registerSupplierRow, type SupplierEnv, type SupplierFactory } from '../contract.ts'
|
|
16
|
+
import factory, { id } from './plugin.ts'
|
|
17
|
+
|
|
18
|
+
/** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
|
|
19
|
+
export const name = 'dsh-router-supplier-nvidia'
|
|
20
|
+
|
|
21
|
+
/** 登记本供应商。幂等:同一个 id 已在表里就直接返回,不顶掉别人的登记。 */
|
|
22
|
+
export function apply(ctx: Context): void {
|
|
23
|
+
// `source: 'builtin'` 让 /health 如实报出「内置」,面板的「内置 / 插件」
|
|
24
|
+
// 两组才不会把随核心分发的供应商误标成插件提供的。
|
|
25
|
+
const row: SupplierFactory = Object.assign((env: SupplierEnv) => factory(env), { source: 'builtin' as const })
|
|
26
|
+
registerSupplierRow(ctx, id, row)
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode 的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
|
|
3
|
+
* 「包含的组件」里的那一行。
|
|
4
|
+
*
|
|
5
|
+
* 为什么多一层壳:`plugin.ts` 已经把 `name` 用作**面板显示名**(供应商契约的
|
|
6
|
+
* 字段,所有供应商插件都这么写),而 cordis 的插件身份也要一个 `name`,两者
|
|
7
|
+
* 抢同一个名字。所以行 = 这一层壳(cordis 身份 + 登记),实现 = `plugin.ts`。
|
|
8
|
+
*
|
|
9
|
+
* 这一行只做一件事:把供应商工厂登记进核心的 `router.suppliers` 表。**通道和
|
|
10
|
+
* 契约与独立安装的供应商插件完全一样**(core 不区分内外,只看工厂上的
|
|
11
|
+
* `source: 'builtin'` 标签),因此这一行在插件页能拿到宿主管的开关:关掉它 =
|
|
12
|
+
* loader 不 import 这个模块 = 供应商压根没注册 = 路由不会落到它。
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import { registerSupplierRow, type SupplierEnv, type SupplierFactory } from '../contract.ts'
|
|
16
|
+
import factory, { id } from './plugin.ts'
|
|
17
|
+
|
|
18
|
+
/** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
|
|
19
|
+
export const name = 'dsh-router-supplier-opencode'
|
|
20
|
+
|
|
21
|
+
/** 登记本供应商。幂等:同一个 id 已在表里就直接返回,不顶掉别人的登记。 */
|
|
22
|
+
export function apply(ctx: Context): void {
|
|
23
|
+
// `source: 'builtin'` 让 /health 如实报出「内置」,面板的「内置 / 插件」
|
|
24
|
+
// 两组才不会把随核心分发的供应商误标成插件提供的。
|
|
25
|
+
const row: SupplierFactory = Object.assign((env: SupplierEnv) => factory(env), { source: 'builtin' as const })
|
|
26
|
+
registerSupplierRow(ctx, id, row)
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter 的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
|
|
3
|
+
* 「包含的组件」里的那一行。
|
|
4
|
+
*
|
|
5
|
+
* 为什么多一层壳:`plugin.ts` 已经把 `name` 用作**面板显示名**(供应商契约的
|
|
6
|
+
* 字段,所有供应商插件都这么写),而 cordis 的插件身份也要一个 `name`,两者
|
|
7
|
+
* 抢同一个名字。所以行 = 这一层壳(cordis 身份 + 登记),实现 = `plugin.ts`。
|
|
8
|
+
*
|
|
9
|
+
* 这一行只做一件事:把供应商工厂登记进核心的 `router.suppliers` 表。**通道和
|
|
10
|
+
* 契约与独立安装的供应商插件完全一样**(core 不区分内外,只看工厂上的
|
|
11
|
+
* `source: 'builtin'` 标签),因此这一行在插件页能拿到宿主管的开关:关掉它 =
|
|
12
|
+
* loader 不 import 这个模块 = 供应商压根没注册 = 路由不会落到它。
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import { registerSupplierRow, type SupplierEnv, type SupplierFactory } from '../contract.ts'
|
|
16
|
+
import factory, { id } from './plugin.ts'
|
|
17
|
+
|
|
18
|
+
/** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
|
|
19
|
+
export const name = 'dsh-router-supplier-openrouter'
|
|
20
|
+
|
|
21
|
+
/** 登记本供应商。幂等:同一个 id 已在表里就直接返回,不顶掉别人的登记。 */
|
|
22
|
+
export function apply(ctx: Context): void {
|
|
23
|
+
// `source: 'builtin'` 让 /health 如实报出「内置」,面板的「内置 / 插件」
|
|
24
|
+
// 两组才不会把随核心分发的供应商误标成插件提供的。
|
|
25
|
+
const row: SupplierFactory = Object.assign((env: SupplierEnv) => factory(env), { source: 'builtin' as const })
|
|
26
|
+
registerSupplierRow(ctx, id, row)
|
|
27
|
+
}
|