dsh-router-core 0.1.0

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 (42) hide show
  1. package/README.md +151 -0
  2. package/cordis.patch.yml +9 -0
  3. package/lib/client-registry.js +3006 -0
  4. package/lib/client.js +3006 -0
  5. package/lib/index.js +1756 -0
  6. package/lib/types/credential-store.d.ts +15 -0
  7. package/lib/types/index.d.ts +6 -0
  8. package/lib/types/keys.d.ts +39 -0
  9. package/lib/types/llm/adapter.d.ts +31 -0
  10. package/lib/types/router/index.d.ts +73 -0
  11. package/lib/types/router/types.d.ts +80 -0
  12. package/lib/types/shared.d.ts +138 -0
  13. package/lib/types/supplier-config.d.ts +28 -0
  14. package/lib/types/suppliers/contract.d.ts +98 -0
  15. package/lib/types/suppliers/loader.d.ts +41 -0
  16. package/lib/types/suppliers/registry.d.ts +33 -0
  17. package/package.json +86 -0
  18. package/src/client/CombosTab.tsx +458 -0
  19. package/src/client/EndpointTab.tsx +360 -0
  20. package/src/client/Modal.tsx +46 -0
  21. package/src/client/RouterView.tsx +186 -0
  22. package/src/client/SupplierDetail.tsx +943 -0
  23. package/src/client/index.tsx +18 -0
  24. package/src/client/router.css +2096 -0
  25. package/src/client/sidebar-entry.ts +167 -0
  26. package/src/client/workspace-controller.ts +26 -0
  27. package/src/client/workspace-mount.tsx +117 -0
  28. package/src/credential-store.ts +64 -0
  29. package/src/index.ts +468 -0
  30. package/src/keys.ts +143 -0
  31. package/src/llm/adapter.ts +273 -0
  32. package/src/router/index.ts +273 -0
  33. package/src/router/types.ts +85 -0
  34. package/src/shared.ts +152 -0
  35. package/src/supplier-config.ts +144 -0
  36. package/src/suppliers/contract.ts +73 -0
  37. package/src/suppliers/loader.ts +191 -0
  38. package/src/suppliers/nvidia/plugin.ts +268 -0
  39. package/src/suppliers/opencode/plugin.ts +200 -0
  40. package/src/suppliers/openrouter/plugin.ts +282 -0
  41. package/src/suppliers/registry.ts +365 -0
  42. package/src/types.d.ts +45 -0
package/src/shared.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared constants and wire types for dsh-router.
3
+ *
4
+ * Pure values — no framework imports — so the host (tsdown ESM) and the
5
+ * client (vite CJS browser bundle) can both inline them safely.
6
+ */
7
+
8
+ /** The sidebar entry / panel dataset attribute family name. */
9
+ export const ROUTER_ENTRY_SELECTOR = '[data-dsh-router-entry]'
10
+
11
+ /** The center-column takeover panel attribute. */
12
+ export const ROUTER_VIEW_SELECTOR = '[data-dsh-router-view]'
13
+
14
+ /** Active-state attribute on <html> that shows the panel. */
15
+ export const ROUTER_ACTIVE_ATTR = 'data-dsh-router-active'
16
+
17
+ /** The panel's visible title (also the sidebar entry label). */
18
+ export const ROUTER_TITLE = '路由系统'
19
+
20
+ /** Same-origin API base the client panel fetches (host half serves it). */
21
+ export const ROUTER_API_BASE = '/router/api'
22
+
23
+ /** Bearer key env / credential reference protecting /v1/*. */
24
+ export const API_KEY_REF = 'TW2A_API_KEY'
25
+
26
+ /** One account row from `/router/api/status`. */
27
+ export interface RouterAccount {
28
+ uid: string
29
+ nickname?: string
30
+ credits: number
31
+ cooling: boolean
32
+ until?: string
33
+ reason?: string
34
+ disabled: boolean
35
+ err_count?: number
36
+ /** 所属供应商 id。 */
37
+ supplier?: string
38
+ }
39
+
40
+ /** `/router/api/status` response. */
41
+ export interface RouterStatusResponse {
42
+ ok: boolean
43
+ error?: string
44
+ accounts?: RouterAccount[]
45
+ }
46
+
47
+ /** `/router/api/models` response. */
48
+ export interface RouterModelsResponse {
49
+ ok: boolean
50
+ error?: string
51
+ models?: Array<{ id: string; context_length?: number }>
52
+ }
53
+
54
+ /** `/router/api/health` response. */
55
+ export interface RouterHealthResponse {
56
+ ok: boolean
57
+ suppliers?: Array<{
58
+ id: string
59
+ name: string
60
+ /** 图标(URL 或 SVG data URI)。 */
61
+ icon?: string
62
+ /** 面板能力集合(登录/签到/连接池/模型管理等,按存在性)。 */
63
+ capabilities?: string[]
64
+ /** 来源:内置 / 用户目录 / 外部插件。 */
65
+ source?: 'builtin' | 'user' | 'external'
66
+ }>
67
+ error?: string
68
+ }
69
+
70
+ /** 组合策略。 */
71
+ export type RouterComboStrategy = 'fallback' | 'round-robin'
72
+
73
+ /** 组合。models = 该组合命中的模型列表(裸模型 id,展示时动态拼前缀)。 */
74
+ export interface RouterCombo {
75
+ id: string
76
+ name: string
77
+ strategy: RouterComboStrategy
78
+ models: string[]
79
+ }
80
+
81
+ /** `/router/api/combos` response. */
82
+ export interface RouterCombosResponse {
83
+ ok: boolean
84
+ error?: string
85
+ combos?: RouterCombo[]
86
+ /** 可用模型(按供应商分组,仅启用),面板加模型用。 */
87
+ groups?: RouterComboSupplierGroup[]
88
+ /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。 */
89
+ aliases?: RouterComboAlias[]
90
+ }
91
+
92
+ /** 组合页供应商前缀信息。 */
93
+ export interface RouterComboAlias {
94
+ id: string
95
+ name: string
96
+ alias: string
97
+ }
98
+
99
+ /** 组合页可用模型分组(一个供应商一组)。 */
100
+ export interface RouterComboSupplierGroup {
101
+ supplier: { id: string; name: string; alias: string }
102
+ models: RouterSupplierModel[]
103
+ }
104
+
105
+ /** 库内一条 key(列表含完整 key,客户端脱敏显示)。 */
106
+ export interface RouterKey {
107
+ id: string
108
+ name: string
109
+ key: string
110
+ masked: string
111
+ isActive: boolean
112
+ createdAt: string
113
+ }
114
+
115
+ /** `/router/api/keys` response. */
116
+ export interface RouterKeysResponse {
117
+ ok: boolean
118
+ error?: string
119
+ keys?: RouterKey[]
120
+ }
121
+
122
+ /** `/router/api/settings` response. */
123
+ export interface RouterSettingsResponse {
124
+ ok: boolean
125
+ error?: string
126
+ requireApiKey?: boolean
127
+ }
128
+
129
+ /** 供应商模型(含启用状态)。 */
130
+ export interface RouterSupplierModel {
131
+ id: string
132
+ context_length?: number
133
+ enabled: boolean
134
+ /** 是否为自定义(手动添加 / 拉取)模型,可删除。 */
135
+ custom?: boolean
136
+ }
137
+
138
+ /** `/router/api/suppliers/:id/models` response. */
139
+ export interface RouterSupplierModelsResponse {
140
+ ok: boolean
141
+ error?: string
142
+ /** 供应商前缀(模型全名 = alias/id)。 */
143
+ alias?: string
144
+ models?: RouterSupplierModel[]
145
+ }
146
+
147
+ /** `/router/api/suppliers/:id/login` response. */
148
+ export interface RouterLoginResponse {
149
+ ok: boolean
150
+ error?: string
151
+ loginUrl?: string
152
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 通用供应商配置 —— dsh-router 核心能力,按供应商 id 存储。
3
+ *
4
+ * 通用能力(所有供应商共享,供应商 js 不需要实现):
5
+ * - 别名 alias(前缀,模型全名 = alias/id)
6
+ * - 模型管理:启用/禁用 disabled、自定义 custom
7
+ * - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
8
+ * - 签到规则 checkinRule(所有链接 / 首个链接)
9
+ *
10
+ * 持久化到 data/supplier-config.json。
11
+ */
12
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ export type PoolStrategy = 'fallback' | 'round-robin'
16
+ export type CheckinRule = 'all' | 'first'
17
+
18
+ export interface SupplierConfig {
19
+ alias: string
20
+ disabled: string[]
21
+ custom: string[]
22
+ poolOrder: string[]
23
+ poolStrategy: PoolStrategy
24
+ checkinRule: CheckinRule
25
+ }
26
+
27
+ interface ConfigFile {
28
+ suppliers: Record<string, Partial<SupplierConfig>>
29
+ }
30
+
31
+ /** 通用供应商配置存储。 */
32
+ export class SupplierConfigStore {
33
+ private fp = ''
34
+ private bySupplier = new Map<string, SupplierConfig>()
35
+
36
+ constructor(stateFile: string) {
37
+ this.fp = stateFile ? join(dirname(stateFile), 'supplier-config.json') : ''
38
+ this.load()
39
+ }
40
+
41
+ /** 读取某供应商配置(不存在则返回默认并注册)。 */
42
+ get(supplierId: string): SupplierConfig {
43
+ let cfg = this.bySupplier.get(supplierId)
44
+ if (!cfg) {
45
+ cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback', checkinRule: 'all' }
46
+ this.bySupplier.set(supplierId, cfg)
47
+ }
48
+ return cfg
49
+ }
50
+
51
+ setAlias(supplierId: string, alias: string): void {
52
+ this.get(supplierId).alias = alias
53
+ this.saveLocked()
54
+ }
55
+
56
+ setModelEnabled(supplierId: string, modelId: string, enabled: boolean): void {
57
+ const cfg = this.get(supplierId)
58
+ cfg.disabled = enabled
59
+ ? cfg.disabled.filter((m) => m !== modelId)
60
+ : [...new Set([...cfg.disabled, modelId])]
61
+ this.saveLocked()
62
+ }
63
+
64
+ setAllModelsEnabled(supplierId: string, enabled: boolean, allModelIds: string[]): void {
65
+ const cfg = this.get(supplierId)
66
+ cfg.disabled = enabled ? [] : [...new Set(allModelIds)]
67
+ this.saveLocked()
68
+ }
69
+
70
+ addCustomModel(supplierId: string, modelId: string): void {
71
+ const cfg = this.get(supplierId)
72
+ if (!cfg.custom.includes(modelId)) cfg.custom.push(modelId)
73
+ this.saveLocked()
74
+ }
75
+
76
+ removeCustomModel(supplierId: string, modelId: string): void {
77
+ const cfg = this.get(supplierId)
78
+ cfg.custom = cfg.custom.filter((m) => m !== modelId)
79
+ cfg.disabled = cfg.disabled.filter((m) => m !== modelId)
80
+ this.saveLocked()
81
+ }
82
+
83
+ setPoolOrder(supplierId: string, uids: string[]): void {
84
+ this.get(supplierId).poolOrder = [...new Set(uids)]
85
+ this.saveLocked()
86
+ }
87
+
88
+ setPoolStrategy(supplierId: string, strategy: string): void {
89
+ if (strategy !== 'fallback' && strategy !== 'round-robin') return
90
+ this.get(supplierId).poolStrategy = strategy
91
+ this.saveLocked()
92
+ }
93
+
94
+ setCheckinRule(supplierId: string, rule: string): void {
95
+ if (rule !== 'all' && rule !== 'first') return
96
+ this.get(supplierId).checkinRule = rule
97
+ this.saveLocked()
98
+ }
99
+
100
+ private load(): void {
101
+ if (this.fp === '') return
102
+ let f: ConfigFile
103
+ try {
104
+ f = JSON.parse(readFileSync(this.fp, 'utf8')) as ConfigFile
105
+ } catch {
106
+ return
107
+ }
108
+ for (const [id, c] of Object.entries(f.suppliers ?? {})) {
109
+ this.bySupplier.set(id, {
110
+ alias: typeof c.alias === 'string' ? c.alias : '',
111
+ disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === 'string') : [],
112
+ custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === 'string') : [],
113
+ poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === 'string') : [],
114
+ poolStrategy: c.poolStrategy === 'round-robin' ? 'round-robin' : 'fallback',
115
+ checkinRule: c.checkinRule === 'first' ? 'first' : 'all',
116
+ })
117
+ }
118
+ }
119
+
120
+ private saveLocked(): void {
121
+ if (this.fp === '') return
122
+ const f: ConfigFile = { suppliers: {} }
123
+ for (const [id, cfg] of this.bySupplier) {
124
+ f.suppliers[id] = {
125
+ alias: cfg.alias,
126
+ disabled: [...cfg.disabled],
127
+ custom: [...cfg.custom],
128
+ poolOrder: [...cfg.poolOrder],
129
+ poolStrategy: cfg.poolStrategy,
130
+ checkinRule: cfg.checkinRule,
131
+ }
132
+ }
133
+ try {
134
+ const dir = dirname(this.fp)
135
+ if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
136
+ const raw = JSON.stringify(f, null, 2)
137
+ const tmp = this.fp + '.tmp'
138
+ writeFileSync(tmp, raw, { mode: 0o600 })
139
+ renameSync(tmp, this.fp)
140
+ } catch {
141
+ // 持久化失败不阻断运行
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 通用供应商契约 —— 供应商 js 只需 export 一个满足本契约的对象/类实例。
3
+ *
4
+ * 分工:
5
+ * - 通用能力(连接池排序/策略、模型启用/自定义、别名)由 dsh-router 核心
6
+ * SupplierConfigStore 统一管理,供应商 js 不需要实现。
7
+ * - 可插拔 js 模块只提供**差异化能力**:如何列模型/调上游/登录/签到等。
8
+ *
9
+ * 用户自定义供应商 js 放到 `~/.dsh/profiles/web/suppliers/*.js`,重启后自动加载。
10
+ * 内置供应商与用户 js 同构(差异化能力驱动)。
11
+ */
12
+ import type { ServerResponse } from 'node:http'
13
+ import type { ChatRequest, ModelInfo, SupplierAccount, SupplierStatus } from '../router/types.ts'
14
+ import type { SupplierConfigStore } from '../supplier-config.ts'
15
+ import type { CredentialStore } from '../credential-store.ts'
16
+
17
+ /** 供应商配置:loader 注入的运行时环境。 */
18
+ export interface SupplierEnv {
19
+ /** 数据目录(state.json / keys.json 所在目录)。 */
20
+ dataDir: string
21
+ /** 日志。 */
22
+ log: (msg: string) => void
23
+ /** 通用配置存储(连接池/模型管理/别名/签到规则,核心统一管)。 */
24
+ store: SupplierConfigStore
25
+ /** 通用凭证存储(账号凭证,核心统一管)。 */
26
+ credentials: CredentialStore
27
+ }
28
+
29
+ /** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
30
+ export type SupplierFactory = (env: SupplierEnv) => SupplierModule
31
+
32
+ /** 供应商模块 —— 契约(核心必须,差异化可选)。 */
33
+ export interface SupplierModule {
34
+ /** 供应商唯一 id(小写字母数字 - _)。 */
35
+ readonly id: string
36
+ /** 显示名。 */
37
+ readonly name: string
38
+ /** 优先级:数字越小越优先(免费通道优先)。 */
39
+ readonly priority?: number
40
+ /** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
41
+ readonly icon?: string
42
+
43
+ // ---- 核心(必须) ----
44
+ status(): SupplierStatus
45
+ /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
46
+ listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
47
+ /** 默认前缀(可被通用层 alias 覆盖)。 */
48
+ getAlias(): string
49
+ chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean>
50
+ /** 测试模型(验证上游真实可用,必要差异化能力)。 */
51
+ testModel(id: string): Promise<{ ok: boolean; error?: string }>
52
+ dispose(): void
53
+
54
+ // ---- 差异化能力(可选,按存在性暴露端点/UI) ----
55
+
56
+ /** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
57
+ generateLoginUrl?(): string | { ok: boolean; error?: string; loginUrl?: string } | Promise<string | { ok: boolean; error?: string; loginUrl?: string }>
58
+ /** 回调完成登录。 */
59
+ completeLogin?(callbackUrl: string): Promise<{ uid: string; nickname: string }>
60
+
61
+ /** 添加 API key 账号(弹窗填名字+key,而非 URL 登录)。有它同样显示连接池。 */
62
+ addApiKey?(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }>
63
+ /** 删除链接(清理凭证;内部能力)。 */
64
+ removeLink?(uid: string): Promise<boolean>
65
+ /** 轮询式登录标记:有它 → 面板隐藏「粘贴回调链接」步骤,登录后直接点完成(后台轮询)。 */
66
+ pollLogin?(): boolean
67
+
68
+ /** 触发签到。签到规则(all/first)是 dsh-router 通用策略,不在此列。 */
69
+ checkinNow?(): Promise<{ ok: boolean; total: number; succeeded: number; already?: number; error?: string; results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }> }>
70
+ }
71
+
72
+ /** 供应商账号(复用 router 类型,供 loader 包装)。 */
73
+ export type { SupplierAccount }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 供应商 loader —— 扫描 suppliers 目录下的 *.js,动态 import,按契约包装成
3
+ * 内部 Supplier,并暴露"能力检测"(capabilities)供通用路由/前端按能力渲染。
4
+ *
5
+ * 加载来源(两处,profile 优先):
6
+ * 1. 内置:<plugin>/lib/suppliers/*.js(随插件分发)
7
+ * 2. 用户:~/.dsh/profiles/web/suppliers/*.js(用户自定义)
8
+ */
9
+ import { readdirSync } from 'node:fs'
10
+ import { join, dirname, basename } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+ import type { ServerResponse } from 'node:http'
13
+ import type { ChatRequest, ModelInfo, ModelWithEnabled, Supplier, SupplierStatus } from '../router/types.ts'
14
+ import type { SupplierConfigStore } from '../supplier-config.ts'
15
+ import type { CredentialStore } from '../credential-store.ts'
16
+ import type { SupplierEnv, SupplierFactory, SupplierModule } from './contract.ts'
17
+
18
+ /** 供应商加载错误(不阻断其他供应商)。 */
19
+ export interface SupplierLoadError {
20
+ id: string
21
+ file: string
22
+ error: string
23
+ }
24
+
25
+ /** 供应商实例 + 能力标记。 */
26
+ export interface LoadedSupplier {
27
+ supplier: Supplier
28
+ /** 差异化能力集合(方法名存在性,通用能力不在此列)。 */
29
+ capabilities: Set<string>
30
+ /** 来源:内置 / 用户目录 / 外部插件(cordis service)。 */
31
+ source: 'builtin' | 'user' | 'external'
32
+ }
33
+
34
+ function isFn(v: unknown): v is (...args: unknown[]) => unknown {
35
+ return typeof v === 'function'
36
+ }
37
+
38
+ /** 从模块导出解析出供应商模块对象(支持 export default / 命名导出 / factory)。 */
39
+ function resolveModule(mod: Record<string, unknown>, file: string): SupplierModule {
40
+ const cand = (mod.default ?? mod) as Record<string, unknown>
41
+ // factory 形式:模块 default 是 (env) => SupplierModule,跳过静态检查,loadOne 调用后实例生效
42
+ if (typeof cand === 'function') return cand as unknown as SupplierModule
43
+ if (typeof cand !== 'object' || cand === null) throw new Error(`供应商模块无效: ${file}`)
44
+ if (typeof cand.id !== 'string' || cand.id === '') throw new Error(`供应商缺少 id: ${file}`)
45
+ if (typeof cand.name !== 'string' || cand.name === '') throw new Error(`供应商缺少 name: ${file}`)
46
+ if (typeof cand.testModel !== 'function') throw new Error(`供应商 ${cand.id} 缺少 testModel(必要差异化能力): ${file}`)
47
+ return cand as unknown as SupplierModule
48
+ }
49
+
50
+ /** 加载单个供应商文件,返回模块对象或抛错。 */
51
+ async function importSupplierFile(file: string): Promise<SupplierModule> {
52
+ const url = pathToFileURL(file).href
53
+ const mod = (await import(url)) as Record<string, unknown>
54
+ return resolveModule(mod, file)
55
+ }
56
+
57
+ /**
58
+ * 差异化能力(供应商 js 实现;其余为通用能力由核心提供)。
59
+ * testModel 是必要差异化能力(核心必须),不在此列。
60
+ */
61
+ const DIFF_CAPS = [
62
+ 'generateLoginUrl', 'completeLogin',
63
+ 'addApiKey',
64
+ 'pollLogin',
65
+ 'checkinNow',
66
+ ] as const
67
+
68
+ /** 从文件构造 LoadedSupplier。 */
69
+ async function loadOne(file: string, env: SupplierEnv, source: 'builtin' | 'user'): Promise<LoadedSupplier> {
70
+ const mod = await importSupplierFile(file)
71
+ // 支持 factory: 模块本身就是工厂函数
72
+ let instance: SupplierModule
73
+ if (typeof mod === 'function') {
74
+ instance = (mod as unknown as SupplierFactory)(env)
75
+ if (typeof instance !== 'object' || instance === null || typeof instance.id !== 'string' || instance.id === '') {
76
+ throw new Error(`供应商 factory 返回无效: ${file}`)
77
+ }
78
+ if (typeof instance.testModel !== 'function') {
79
+ throw new Error(`供应商 ${instance.id} 缺少 testModel(必要差异化能力): ${file}`)
80
+ }
81
+ } else {
82
+ instance = mod
83
+ }
84
+ return wrapModule(instance, env, file, source)
85
+ }
86
+
87
+ /**
88
+ * 从 SupplierModule 实例构造 LoadedSupplier(能力检测 + Supplier 包装)。
89
+ * 文件加载(loadOne)与外部插件供应商(cordis service)共用。
90
+ */
91
+ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: string, kind: 'builtin' | 'user' | 'external' = 'external'): LoadedSupplier {
92
+ if (typeof instance !== 'object' || instance === null || typeof instance.id !== 'string' || instance.id === '') {
93
+ throw new Error(`供应商模块无效: ${source}`)
94
+ }
95
+ if (typeof instance.testModel !== 'function') {
96
+ throw new Error(`供应商 ${instance.id} 缺少 testModel(必要差异化能力): ${source}`)
97
+ }
98
+ const capabilities = new Set<string>()
99
+ for (const key of DIFF_CAPS) {
100
+ if (isFn((instance as unknown as Record<string, unknown>)[key])) capabilities.add(key)
101
+ }
102
+
103
+ const supplier: Supplier = {
104
+ id: instance.id,
105
+ name: instance.name,
106
+ priority: instance.priority ?? 0,
107
+ icon: (instance as { icon?: string }).icon,
108
+ status: (): SupplierStatus => instance.status(),
109
+ listModels: (force?: boolean): Promise<ModelInfo[]> | ModelInfo[] => instance.listModels(force),
110
+ // 通用能力:模型启用状态/自定义由核心 SupplierConfigStore 合并
111
+ modelsWithEnabled: (force?: boolean): Promise<ModelWithEnabled[]> | ModelWithEnabled[] => {
112
+ const cfg = env.store.get(instance.id)
113
+ const disabled = new Set(cfg.disabled)
114
+ const custom = new Set(cfg.custom)
115
+ return Promise.resolve(instance.listModels(force)).then((list) =>
116
+ list.map((mm) => ({
117
+ ...mm,
118
+ enabled: !disabled.has(mm.id),
119
+ custom: custom.has(mm.id) ? true : undefined,
120
+ })),
121
+ )
122
+ },
123
+ getAlias: (): string => env.store.get(instance.id).alias || instance.getAlias(),
124
+ customModelIds: (): string[] => [...env.store.get(instance.id).custom],
125
+ chatCompletions: (req: ChatRequest, res: ServerResponse): Promise<boolean> => instance.chatCompletions(req, res),
126
+ testModel: (id: string): Promise<{ ok: boolean; error?: string }> => instance.testModel(id),
127
+ // 删除链接:数据删除是通用能力,凭证清理由供应商内部实现(js 契约不要求)
128
+ removeLink: isFn((instance as unknown as Record<string, unknown>).removeLink)
129
+ ? (uid: string): Promise<boolean> => (instance as unknown as { removeLink(uid: string): Promise<boolean> }).removeLink(uid)
130
+ : undefined,
131
+ dispose: (): void => instance.dispose(),
132
+ }
133
+ // 差异化路由通过 LoadedSupplier.capabilities + supplier 实例调用面板方法
134
+ ;(supplier as Supplier & { __module?: SupplierModule }).__module = instance
135
+
136
+ return { supplier, capabilities, source: kind }
137
+ }
138
+
139
+ /** 扫描目录下所有 *.js(一层)。 */
140
+ function scanDir(dir: string): string[] {
141
+ try {
142
+ return readdirSync(dir)
143
+ .filter((f) => f.endsWith('.js'))
144
+ .map((f) => join(dir, f))
145
+ } catch {
146
+ return []
147
+ }
148
+ }
149
+
150
+ export interface LoadSuppliersOptions {
151
+ /** 内置供应商目录(<plugin>/lib/suppliers)。 */
152
+ builtinDir: string
153
+ /** 用户供应商目录(~/.dsh/profiles/web/suppliers)。 */
154
+ userDir: string
155
+ /** 数据目录。 */
156
+ dataDir: string
157
+ /** 通用配置存储。 */
158
+ store: SupplierConfigStore
159
+ /** 通用凭证存储。 */
160
+ credentials: CredentialStore
161
+ log: (msg: string) => void
162
+ }
163
+
164
+ /** 加载全部供应商(内置 + 用户,用户覆盖内置同 id)。 */
165
+ export async function loadSuppliers(opts: LoadSuppliersOptions): Promise<{
166
+ suppliers: LoadedSupplier[]
167
+ errors: SupplierLoadError[]
168
+ }> {
169
+ const env: SupplierEnv = { dataDir: opts.dataDir, log: opts.log, store: opts.store, credentials: opts.credentials }
170
+ const byId = new Map<string, LoadedSupplier>()
171
+ const errors: SupplierLoadError[] = []
172
+
173
+ const files = [
174
+ ...scanDir(opts.builtinDir).map((f) => ({ f, builtin: true })),
175
+ ...scanDir(opts.userDir).map((f) => ({ f, builtin: false })),
176
+ ]
177
+ for (const { f, builtin } of files) {
178
+ try {
179
+ const loaded = await loadOne(f, env, builtin ? 'builtin' : 'user')
180
+ // 用户目录覆盖内置
181
+ const existing = byId.get(loaded.supplier.id)
182
+ if (existing && builtin) continue // 内置已被用户版覆盖
183
+ byId.set(loaded.supplier.id, loaded)
184
+ opts.log(`supplier ${loaded.supplier.id} loaded (${builtin ? 'builtin' : 'user'}) from ${basename(f)}`)
185
+ } catch (err) {
186
+ errors.push({ id: basename(f, '.js'), file: f, error: (err as Error).message })
187
+ opts.log(`supplier load failed ${basename(f)}: ${(err as Error).message}`)
188
+ }
189
+ }
190
+ return { suppliers: [...byId.values()], errors }
191
+ }