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.
Files changed (120) hide show
  1. package/README.md +238 -63
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/pool-sticky-block.md +158 -0
  5. package/docs/suppliers.md +28 -5
  6. package/lib/client-registry.js +1300 -549
  7. package/lib/client.js +1300 -549
  8. package/lib/ext-test/index.js +47 -0
  9. package/lib/ext-test/locale/en.json +6 -0
  10. package/lib/ext-test/locale/zh.json +6 -0
  11. package/lib/ext-test/package.json +5 -0
  12. package/lib/http-state-C1vb3gW2.js +48 -0
  13. package/lib/index.js +1169 -254
  14. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  15. package/lib/suppliers/nvidia/locale/en.json +6 -0
  16. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  17. package/lib/suppliers/nvidia/package.json +5 -0
  18. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  19. package/lib/suppliers/opencode/locale/en.json +6 -0
  20. package/lib/suppliers/opencode/locale/zh.json +6 -0
  21. package/lib/suppliers/opencode/package.json +5 -0
  22. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  23. package/lib/suppliers/openrouter/locale/en.json +6 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  25. package/lib/suppliers/openrouter/package.json +5 -0
  26. package/lib/types/ext/contract.d.ts +9 -7
  27. package/lib/types/ext-test/index.d.ts +21 -0
  28. package/lib/types/ext-test/plugin.d.ts +23 -0
  29. package/lib/types/host-version.d.ts +15 -0
  30. package/lib/types/index.d.ts +6 -0
  31. package/lib/types/llm/adapter.d.ts +35 -4
  32. package/lib/types/router/account-pool.d.ts +53 -18
  33. package/lib/types/router/index.d.ts +65 -15
  34. package/lib/types/router/prefix-affinity.d.ts +18 -1
  35. package/lib/types/router/types.d.ts +7 -4
  36. package/lib/types/router/usage-store.d.ts +12 -0
  37. package/lib/types/shared.d.ts +25 -21
  38. package/lib/types/supplier-config.d.ts +15 -3
  39. package/lib/types/suppliers/contract.d.ts +37 -4
  40. package/lib/types/suppliers/http-state.d.ts +23 -0
  41. package/lib/types/suppliers/loader.d.ts +6 -4
  42. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  43. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  44. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  45. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  46. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  47. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  48. package/lib/types/suppliers/probe.d.ts +160 -0
  49. package/lib/types/suppliers/registry.d.ts +3 -2
  50. package/package.json +50 -16
  51. package/src/client/CombosTab.tsx +8 -53
  52. package/src/client/ExtDetail.tsx +32 -196
  53. package/src/client/ExtTab.tsx +25 -42
  54. package/src/client/ExtTestPanel.tsx +392 -0
  55. package/src/client/LastHitDock.tsx +141 -0
  56. package/src/client/RouterComponentsSection.tsx +241 -0
  57. package/src/client/RouterView.tsx +12 -5
  58. package/src/client/StatsTab.tsx +8 -6
  59. package/src/client/SupplierDetail.tsx +2 -34
  60. package/src/client/comp-button-scope.test.ts +58 -0
  61. package/src/client/comp-panel-layout.test.ts +58 -0
  62. package/src/client/env.d.ts +38 -1
  63. package/src/client/ext-panels.test.ts +44 -0
  64. package/src/client/ext-panels.ts +49 -0
  65. package/src/client/index.tsx +86 -0
  66. package/src/client/last-hit-store.ts +89 -0
  67. package/src/client/model-choice.test.ts +41 -0
  68. package/src/client/model-choice.ts +37 -0
  69. package/src/client/model-hint-copy.test.ts +7 -2
  70. package/src/client/model-hint-copy.ts +2 -2
  71. package/src/client/router-components.test.ts +114 -0
  72. package/src/client/router-components.ts +104 -0
  73. package/src/client/router.css +2592 -1635
  74. package/src/client/supplier-visibility.test.ts +57 -0
  75. package/src/client/supplier-visibility.ts +63 -0
  76. package/src/ext/contract.ts +9 -7
  77. package/src/ext-test/ext-test.test.ts +60 -0
  78. package/src/ext-test/index.ts +35 -0
  79. package/src/ext-test/locale/en.json +6 -0
  80. package/src/ext-test/locale/zh.json +6 -0
  81. package/src/ext-test/plugin.ts +34 -0
  82. package/src/ext-test/row.json +5 -0
  83. package/src/host-version.test.ts +61 -0
  84. package/src/host-version.ts +101 -0
  85. package/src/index.ts +165 -68
  86. package/src/llm/adapter.test.ts +175 -4
  87. package/src/llm/adapter.ts +109 -16
  88. package/src/router/account-pool.test.ts +189 -83
  89. package/src/router/account-pool.ts +120 -31
  90. package/src/router/disconnect-leak.test.ts +7 -12
  91. package/src/router/first-byte-budget.test.ts +160 -0
  92. package/src/router/index.ts +276 -62
  93. package/src/router/models-cache.test.ts +54 -9
  94. package/src/router/precise-dispatch.test.ts +6 -6
  95. package/src/router/prefix-affinity.ts +22 -1
  96. package/src/router/supplier-switch.test.ts +220 -0
  97. package/src/router/types.ts +7 -5
  98. package/src/router/usage-store.ts +16 -0
  99. package/src/router/usage.test.ts +1 -1
  100. package/src/shared.ts +25 -24
  101. package/src/supplier-config.ts +33 -13
  102. package/src/suppliers/contract.ts +56 -4
  103. package/src/suppliers/loader.test.ts +38 -4
  104. package/src/suppliers/loader.ts +33 -18
  105. package/src/suppliers/nvidia/index.ts +27 -0
  106. package/src/suppliers/nvidia/locale/en.json +6 -0
  107. package/src/suppliers/nvidia/locale/zh.json +6 -0
  108. package/src/suppliers/nvidia/row.json +5 -0
  109. package/src/suppliers/opencode/index.ts +27 -0
  110. package/src/suppliers/opencode/locale/en.json +6 -0
  111. package/src/suppliers/opencode/locale/zh.json +6 -0
  112. package/src/suppliers/opencode/row.json +5 -0
  113. package/src/suppliers/openrouter/index.ts +27 -0
  114. package/src/suppliers/openrouter/locale/en.json +6 -0
  115. package/src/suppliers/openrouter/locale/zh.json +6 -0
  116. package/src/suppliers/openrouter/row.json +5 -0
  117. package/src/suppliers/probe.test.ts +266 -0
  118. package/src/suppliers/probe.ts +475 -0
  119. package/src/suppliers/registry.ts +103 -23
  120. package/src/types.d.ts +2 -1
@@ -4,7 +4,7 @@
4
4
  * 通用能力(所有供应商共享,供应商 js 不需要实现):
5
5
  * - 别名 alias(前缀,模型全名 = alias/id)
6
6
  * - 模型管理:启用/禁用 disabled、自定义 custom
7
- * - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
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: [], poolStrategy: 'fallback', credits: {}, accountNames: {} }
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`,三个号里两个如此,round-robin 下 2/3 请求直接失败
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
- /** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
44
- export type SupplierFactory = (env: SupplierEnv) => SupplierModule
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(), [], 'fallback', 'm1'), 'u2')
163
- assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], 'fallback', 'm2'), 'u1', '换个模型不受影响')
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
+ })
@@ -1,10 +1,16 @@
1
1
  /**
2
- * 供应商 loader —— 扫描 suppliers 目录下的 *.js,动态 import,按契约包装成
2
+ * 供应商 loader —— 扫描 **用户目录**下的 *.js,动态 import,按契约包装成
3
3
  * 内部 Supplier,并暴露"能力检测"(capabilities)供通用路由/前端按能力渲染。
4
4
  *
5
- * 加载来源(两处,profile 优先):
6
- * 1. 内置:<plugin>/lib/suppliers/*.js(随插件分发)
7
- * 2. 用户:~/.dsh/profiles/web/suppliers/*.js(用户自定义)
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
- /** 内置供应商目录(<plugin>/lib/suppliers)。 */
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
- /** 加载全部供应商(内置 + 用户,用户覆盖内置同 id)。 */
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 files = [
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, builtin ? 'builtin' : 'user')
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 (${builtin ? 'builtin' : 'user'}) from ${basename(f)}`)
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,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "NVIDIA NIM",
4
+ "description": "NVIDIA 官方推理通道,API key 制"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "NVIDIA NIM",
4
+ "description": "NVIDIA 官方推理通道,API key 制"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "dsh-router-core-supplier-nvidia",
3
+ "type": "module",
4
+ "description": "NVIDIA 官方推理通道,API key 制(随 dsh-router 核心分发)"
5
+ }
@@ -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,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenCode",
4
+ "description": "Zen 免费档(无需账号)"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenCode",
4
+ "description": "Zen 免费档(无需账号)"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "dsh-router-core-supplier-opencode",
3
+ "type": "module",
4
+ "description": "Zen 免费档(无需账号)(随 dsh-router 核心分发)"
5
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenRouter",
4
+ "description": "OpenRouter 聚合通道,API key 制"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenRouter",
4
+ "description": "OpenRouter 聚合通道,API key 制"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "dsh-router-core-supplier-openrouter",
3
+ "type": "module",
4
+ "description": "OpenRouter 聚合通道,API key 制(随 dsh-router 核心分发)"
5
+ }