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
package/src/supplier-config.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 通用能力(所有供应商共享,供应商 js 不需要实现):
|
|
5
5
|
* - 别名 alias(前缀,模型全名 = alias/id)
|
|
6
6
|
* - 模型管理:启用/禁用 disabled、自定义 custom
|
|
7
|
-
* - 连接池:poolOrder(拖动排序)
|
|
7
|
+
* - 连接池:poolOrder(拖动排序) —— 选号本身是会话亲和,无策略旋钮
|
|
8
8
|
* - 积分缓存 credits:按 supplier/uid 缓存最后一次拿到的剩余额度
|
|
9
9
|
*
|
|
10
10
|
* 持久化到 data/supplier-config.json。
|
|
@@ -12,8 +12,6 @@
|
|
|
12
12
|
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
13
13
|
import { dirname, join } from 'node:path'
|
|
14
14
|
|
|
15
|
-
export type PoolStrategy = 'fallback' | 'round-robin'
|
|
16
|
-
|
|
17
15
|
/**
|
|
18
16
|
* 积分哨兵:插件还**没拿到过**真实额度(刚重启、积分拉取失败、或压根不支持
|
|
19
17
|
* 积分)时报 `-1`,核心据此保留上一次的缓存值,而不是把 0 写进去。
|
|
@@ -25,11 +23,18 @@ export type PoolStrategy = 'fallback' | 'round-robin'
|
|
|
25
23
|
export const CREDITS_UNKNOWN = -1
|
|
26
24
|
|
|
27
25
|
export interface SupplierConfig {
|
|
26
|
+
/**
|
|
27
|
+
* 这个供应商是否参与路由(用户开关,与扩展开关同构)。
|
|
28
|
+
*
|
|
29
|
+
* **缺省 true** —— 没记录过就是开着。关掉 = 核心不把它接进路由(`Router` 的
|
|
30
|
+
* 活跃集合),请求不会落到它、模型列表/组合里也不再出现它,而它自己仍留在
|
|
31
|
+
* `/health` 与面板里(不然关掉之后就找不回来、开关也失去了开关的对象)。
|
|
32
|
+
*/
|
|
33
|
+
enabled: boolean
|
|
28
34
|
alias: string
|
|
29
35
|
disabled: string[]
|
|
30
36
|
custom: string[]
|
|
31
37
|
poolOrder: string[]
|
|
32
|
-
poolStrategy: PoolStrategy
|
|
33
38
|
/** 积分缓存:uid → 剩余额度(最后一次从插件拿到的非 -1 值)。 */
|
|
34
39
|
credits: Record<string, number>
|
|
35
40
|
/** 每连接显示别名:uid → 显示名(空/缺省用供应商原始昵称,纯展示不改路由键)。 */
|
|
@@ -90,7 +95,7 @@ export class SupplierConfigStore {
|
|
|
90
95
|
get(supplierId: string): SupplierConfig {
|
|
91
96
|
let cfg = this.bySupplier.get(supplierId)
|
|
92
97
|
if (!cfg) {
|
|
93
|
-
cfg = { alias: '', disabled: [], custom: [], poolOrder: [],
|
|
98
|
+
cfg = { enabled: true, alias: '', disabled: [], custom: [], poolOrder: [], credits: {}, accountNames: {} }
|
|
94
99
|
this.bySupplier.set(supplierId, cfg)
|
|
95
100
|
}
|
|
96
101
|
return cfg
|
|
@@ -113,6 +118,26 @@ export class SupplierConfigStore {
|
|
|
113
118
|
return { ok: true }
|
|
114
119
|
}
|
|
115
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
|
+
|
|
116
141
|
setModelEnabled(supplierId: string, modelId: string, enabled: boolean): void {
|
|
117
142
|
const cfg = this.get(supplierId)
|
|
118
143
|
cfg.disabled = enabled
|
|
@@ -145,12 +170,6 @@ export class SupplierConfigStore {
|
|
|
145
170
|
this.saveLocked()
|
|
146
171
|
}
|
|
147
172
|
|
|
148
|
-
setPoolStrategy(supplierId: string, strategy: string): void {
|
|
149
|
-
if (strategy !== 'fallback' && strategy !== 'round-robin') return
|
|
150
|
-
this.get(supplierId).poolStrategy = strategy
|
|
151
|
-
this.saveLocked()
|
|
152
|
-
}
|
|
153
|
-
|
|
154
173
|
// ---- 每连接显示别名(连接池改名,纯展示;uid 仍是路由/匹配键) ----
|
|
155
174
|
|
|
156
175
|
/** 读某连接的显示别名;没设或为空返回 `undefined`(用供应商原始昵称)。 */
|
|
@@ -217,11 +236,12 @@ export class SupplierConfigStore {
|
|
|
217
236
|
}
|
|
218
237
|
for (const [id, c] of Object.entries(f.suppliers ?? {})) {
|
|
219
238
|
this.bySupplier.set(id, {
|
|
239
|
+
// 老配置没有这个字段(写盘早于本开关)→ 视为开着,不让升级把全灭变成全开
|
|
240
|
+
enabled: typeof c.enabled === 'boolean' ? c.enabled : true,
|
|
220
241
|
alias: typeof c.alias === 'string' ? c.alias : '',
|
|
221
242
|
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === 'string') : [],
|
|
222
243
|
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === 'string') : [],
|
|
223
244
|
poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === 'string') : [],
|
|
224
|
-
poolStrategy: c.poolStrategy === 'round-robin' ? 'round-robin' : 'fallback',
|
|
225
245
|
credits: readCredits(c.credits),
|
|
226
246
|
accountNames: readNames(c.accountNames),
|
|
227
247
|
})
|
|
@@ -233,11 +253,11 @@ export class SupplierConfigStore {
|
|
|
233
253
|
const f: ConfigFile = { suppliers: {} }
|
|
234
254
|
for (const [id, cfg] of this.bySupplier) {
|
|
235
255
|
f.suppliers[id] = {
|
|
256
|
+
enabled: cfg.enabled,
|
|
236
257
|
alias: cfg.alias,
|
|
237
258
|
disabled: [...cfg.disabled],
|
|
238
259
|
custom: [...cfg.custom],
|
|
239
260
|
poolOrder: [...cfg.poolOrder],
|
|
240
|
-
poolStrategy: cfg.poolStrategy,
|
|
241
261
|
credits: { ...cfg.credits },
|
|
242
262
|
accountNames: { ...cfg.accountNames },
|
|
243
263
|
}
|
|
@@ -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'
|
|
@@ -32,16 +33,24 @@ export interface SupplierEnv {
|
|
|
32
33
|
*
|
|
33
34
|
* 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
|
|
34
35
|
* 冷却。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
|
|
35
|
-
* 流内 `code=4008
|
|
36
|
-
* (2026-09-08
|
|
36
|
+
* 流内 `code=4008`,三个号里两个如此,选号一旦落到坏号就 2/3 请求直接失败
|
|
37
|
+
* (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出选号。
|
|
37
38
|
*
|
|
38
39
|
* 插件拿不到也不用兜底:不调用就退化成今天的行为,不影响正确性。
|
|
39
40
|
*/
|
|
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'
|
|
@@ -159,8 +159,8 @@ test('onLateFailure 挂到 env 上,且冷却作用于本供应商的池(坏
|
|
|
159
159
|
assert.equal(after.get('u1'), true, '上报后该号应进入冷却')
|
|
160
160
|
assert.equal(after.get('u2'), false, '冷却是 (模型,号) 粒度,不能连坐同供应商其它号')
|
|
161
161
|
// 且真的退出选号:只剩 u2
|
|
162
|
-
assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], '
|
|
163
|
-
assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], '
|
|
162
|
+
assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], 'm1'), 'u2')
|
|
163
|
+
assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], 'm2'), 'u1', '换个模型不受影响')
|
|
164
164
|
})
|
|
165
165
|
|
|
166
166
|
test('onLateFailure 不串供应商:各自落到自己的池', () => {
|
|
@@ -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
|
+
}
|