dsh-modelscope-pool 0.0.0-stage → 0.8.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.
package/lib/pool.js ADDED
@@ -0,0 +1,336 @@
1
+ // 多 Key 池:轮换、失败冷却、到点自动恢复。
2
+ // 只做内存状态与本地文件读写,不联网。
3
+ import { mkdir, readFile, writeFile, chmod, stat } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+
6
+ const COOLDOWN_MS = 5 * 60_000
7
+ const CN_OFFSET_MS = 8 * 3600_000
8
+ const DAY_MS = 86_400_000
9
+
10
+ /**
11
+ * 额度用尽后的统一恢复时刻:北京时间最近的一个 00:01:00。
12
+ * 魔搭的额度按自然日(北京时间)重置,留一分钟余量,
13
+ * 避免刚好卡在服务端切换的瞬间又失败一轮。
14
+ */
15
+ export function nextQuotaResetAt(now = Date.now()) {
16
+ const cn = now + CN_OFFSET_MS
17
+ const dayStart = Math.floor(cn / DAY_MS) * DAY_MS
18
+ let reset = dayStart + 60_000
19
+ if (reset <= cn) reset = dayStart + DAY_MS + 60_000
20
+ return reset - CN_OFFSET_MS
21
+ }
22
+
23
+ /** 掩码:永远不把完整 Key 交给界面、日志或响应头。 */
24
+ export function maskKey(key) {
25
+ const s = String(key ?? '')
26
+ if (s.length <= 6) return '••••'
27
+ return `${s.slice(0, 3)}…${s.slice(-4)}`
28
+ }
29
+
30
+ export class KeyPool {
31
+ constructor({ dir, cooldownMs = COOLDOWN_MS, logger }) {
32
+ this.dir = dir
33
+ this.keysFile = join(dir, 'keys.json')
34
+ this.coolFile = join(dir, 'cooldowns.json')
35
+ this.cooldownMs = cooldownMs
36
+ this.logger = logger
37
+ this.keys = []
38
+ this.states = new Map() // id -> { until, fails, lastReason, lastError, used, ok }
39
+ this.cursor = 0
40
+ this.stats = { requests: 0, ok: 0, failed: 0, failovers: 0 }
41
+ this.onChange = null
42
+ this._lastCooldownCount = 0
43
+ }
44
+
45
+ async init() {
46
+ await mkdir(this.dir, { recursive: true, mode: 0o700 })
47
+ await this.reload()
48
+ await this._loadCool()
49
+ return this
50
+ }
51
+
52
+ /**
53
+ * 冷却状态落盘:额度用尽的 Key 要停到次日 00:01,
54
+ * 引擎重启(App 每次冷启动都会重启)后不能又拿它去撞墙。
55
+ */
56
+ async _loadCool() {
57
+ try {
58
+ const raw = JSON.parse(await readFile(this.coolFile, 'utf8'))
59
+ const now = Date.now()
60
+ for (const [id, v] of Object.entries(raw ?? {})) {
61
+ const st = this.states.get(id)
62
+ const until = Number(v?.until) || 0
63
+ if (!st || until <= now) continue
64
+ st.until = until
65
+ st.lastReason = String(v?.reason ?? '额度用尽').slice(0, 24)
66
+ }
67
+ } catch {}
68
+ }
69
+
70
+ async _saveCool() {
71
+ const now = Date.now()
72
+ const out = {}
73
+ for (const [id, st] of this.states) {
74
+ if (st.until > now) out[id] = { until: st.until, reason: st.lastReason }
75
+ }
76
+ try {
77
+ await writeFile(this.coolFile, JSON.stringify(out, null, 1) + '\n', { mode: 0o600 })
78
+ } catch {}
79
+ }
80
+
81
+ async reload() {
82
+ try {
83
+ const raw = JSON.parse(await readFile(this.keysFile, 'utf8'))
84
+ const list = Array.isArray(raw?.keys) ? raw.keys : []
85
+ this.keys = list
86
+ .filter((k) => k && typeof k.key === 'string' && k.key.length >= 8)
87
+ .map((k, i) => ({
88
+ id: typeof k.id === 'string' && k.id ? k.id : `k${i + 1}`,
89
+ key: k.key,
90
+ label: typeof k.label === 'string' ? k.label.slice(0, 24) : '',
91
+ region: k.region === 'ai' || k.region === 'cn' ? k.region : '',
92
+ addedAt: Number(k.addedAt) || 0,
93
+ }))
94
+ } catch {
95
+ this.keys = []
96
+ }
97
+ this.keys.forEach((k) => {
98
+ if (!this.states.has(k.id)) this.states.set(k.id, { until: 0, fails: 0, lastReason: '', lastError: '', used: 0, ok: 0 })
99
+ })
100
+ for (const id of [...this.states.keys()]) if (!this.keys.some((k) => k.id === id)) this.states.delete(id)
101
+ return this.keys.length
102
+ }
103
+
104
+ async _save() {
105
+ const body = JSON.stringify({ keys: this.keys }, null, 2) + '\n'
106
+ await writeFile(this.keysFile, body, { mode: 0o600 })
107
+ try {
108
+ await chmod(this.keysFile, 0o600)
109
+ } catch {}
110
+ }
111
+
112
+ has(key) {
113
+ return this.keys.some((k) => k.key === key)
114
+ }
115
+
116
+ /** 认出 Key 的版本后写回 keys.json;版本变了(比如换站点后)也能改。 */
117
+ async setRegion(id, region) {
118
+ const key = this.keys.find((k) => k.id === id)
119
+ if (!key || (region !== 'ai' && region !== 'cn')) return false
120
+ if (key.region === region) return false
121
+ key.region = region
122
+ await this._save()
123
+ return true
124
+ }
125
+
126
+ async add(rawKey, label = '', region = '') {
127
+ const key = String(rawKey ?? '').trim()
128
+ if (key.length < 16) return { ok: false, error: 'Key 太短,看起来不像魔搭令牌' }
129
+ if (this.has(key)) return { ok: false, error: '这个 Key 已经在池里了' }
130
+ let id = `k${this.keys.length + 1}`
131
+ let n = this.keys.length + 1
132
+ while (this.keys.some((k) => k.id === id)) id = `k${++n}`
133
+ this.keys.push({
134
+ id,
135
+ key,
136
+ label: String(label ?? '').slice(0, 24),
137
+ region: region === 'ai' || region === 'cn' ? region : '',
138
+ addedAt: Date.now(),
139
+ })
140
+ this.states.set(id, { until: 0, fails: 0, lastReason: '', lastError: '', used: 0, ok: 0 })
141
+ await this._save()
142
+ this._notify()
143
+ return { ok: true, id }
144
+ }
145
+
146
+ async remove(id) {
147
+ const before = this.keys.length
148
+ this.keys = this.keys.filter((k) => k.id !== id)
149
+ this.states.delete(id)
150
+ void this._saveCool()
151
+ if (this.keys.length === before) return false
152
+ await this._save()
153
+ this._notify()
154
+ return true
155
+ }
156
+
157
+ /** 让某个 Key 立刻恢复可用(额度查明后又涨回去了、或手动点了恢复)。 */
158
+ restore(id) {
159
+ const st = this.states.get(id)
160
+ if (!st) return false
161
+ const was = st.until > 0 || Boolean(st.lastReason)
162
+ st.until = 0
163
+ st.lastReason = ''
164
+ st.lastError = ''
165
+ st.manual = false
166
+ if (was) {
167
+ void this._saveCool()
168
+ this._notify()
169
+ }
170
+ return was
171
+ }
172
+
173
+ async restoreAll() {
174
+ for (const st of this.states.values()) {
175
+ st.until = 0
176
+ st.lastReason = ''
177
+ st.manual = false
178
+ }
179
+ void this._saveCool()
180
+ this._notify()
181
+ }
182
+
183
+ _notify() {
184
+ try {
185
+ this.onChange?.()
186
+ } catch {}
187
+ }
188
+
189
+ /** 到期自动恢复:任何一次取状态或取 Key 都会顺手清理。 */
190
+ _expire() {
191
+ let cooling = 0
192
+ const now = Date.now()
193
+ for (const st of this.states.values()) {
194
+ if (st.until && st.until <= now) {
195
+ st.until = 0
196
+ st.lastReason = ''
197
+ }
198
+ if (st.until > now) cooling++
199
+ }
200
+ return cooling
201
+ }
202
+
203
+ available() {
204
+ this._expire()
205
+ const now = Date.now()
206
+ return this.keys.filter((k) => !(this.states.get(k.id)?.until > now))
207
+ }
208
+
209
+ /** 轮询取下一个可用 Key;exclude 用于同一次请求里不重复试同一个。 */
210
+ pick(exclude) {
211
+ const pool = this.available().filter((k) => !exclude?.has(k.id))
212
+ if (pool.length === 0) return null
213
+ const key = pool[this.cursor % pool.length]
214
+ this.cursor = (this.cursor + 1) % Math.max(1, pool.length)
215
+ return key
216
+ }
217
+
218
+ markSuccess(id) {
219
+ const st = this.states.get(id)
220
+ if (!st) return
221
+ st.used++
222
+ st.ok++
223
+ const wasCooling = st.until > 0
224
+ st.until = 0
225
+ st.lastReason = ''
226
+ st.lastError = ''
227
+ if (wasCooling) void this._saveCool()
228
+ }
229
+
230
+ /**
231
+ * 停用一个 Key。untilAt 给了就按它恢复(额度用尽 → 次日 00:01),
232
+ * 否则退回常规冷却。
233
+ */
234
+ /**
235
+ * 手动禁用:一直停用到点了「恢复」为止。
236
+ * 用 far-until + manual 标记,额度巡检也不会把它自动放开。
237
+ */
238
+ disable(id, reason = '手动禁用') {
239
+ const st = this.states.get(id)
240
+ if (!st) return false
241
+ st.until = Date.now() + 100 * 365 * 24 * 3600_000
242
+ st.lastReason = reason
243
+ st.lastError = ''
244
+ st.manual = true
245
+ void this._saveCool()
246
+ this._notify()
247
+ return true
248
+ }
249
+
250
+ /** 手动停用:按给定时间自动恢复(额度类 → 次日 00:01)。 */
251
+ async stop(id, reason = '手动停用', untilAt) {
252
+ const st = this.states.get(id)
253
+ if (!st) return false
254
+ st.until = Number(untilAt) > Date.now() ? Number(untilAt) : Date.now() + this.cooldownMs
255
+ st.lastReason = reason
256
+ st.lastError = ''
257
+ st.manual = false
258
+ void this._saveCool()
259
+ this._notify()
260
+ return true
261
+ }
262
+
263
+ markFailure(id, reason, error = '', untilAt) {
264
+ const st = this.states.get(id)
265
+ if (!st) return 0
266
+ const now = Date.now()
267
+ const explicit = Number(untilAt) || 0
268
+ st.fails++
269
+ st.used++
270
+ st.until = explicit > now ? explicit : now + this.cooldownMs
271
+ st.lastReason = reason
272
+ st.lastError = String(error ?? '').slice(0, 120)
273
+ void this._saveCool()
274
+ this._notify()
275
+ return st.until
276
+ }
277
+
278
+ status() {
279
+ const cooling = this._expire()
280
+ const now = Date.now()
281
+ const items = this.keys.map((k) => {
282
+ const st = this.states.get(k.id) ?? {}
283
+ const left = st.until > now ? st.until - now : 0
284
+ return {
285
+ id: k.id,
286
+ masked: maskKey(k.key),
287
+ label: k.label,
288
+ region: k.region ?? '',
289
+ state: left > 0 ? 'cooling' : 'ok',
290
+ leftMs: left,
291
+ untilAt: left > 0 ? st.until : 0,
292
+ fails: st.fails ?? 0,
293
+ used: st.used ?? 0,
294
+ reason: st.lastReason ?? '',
295
+ lastError: st.lastError ?? '',
296
+ manual: st.manual === true,
297
+ }
298
+ })
299
+ return {
300
+ total: items.length,
301
+ available: items.filter((k) => k.state === 'ok').length,
302
+ cooling,
303
+ keys: items,
304
+ stats: { ...this.stats },
305
+ }
306
+ }
307
+
308
+ /** 后台心跳:冷却到点自动恢复;有人手改 keys.json 也会被读进来。 */
309
+ startWatch(getRefresh) {
310
+ let lastMtime = 0
311
+ const checkFile = async () => {
312
+ try {
313
+ const info = await stat(this.keysFile)
314
+ const mtime = Number(info.mtimeMs) || 0
315
+ if (mtime && mtime !== lastMtime) {
316
+ lastMtime = mtime
317
+ await this.reload()
318
+ getRefresh?.()
319
+ }
320
+ } catch {}
321
+ }
322
+ const fileTimer = setInterval(checkFile, 4000)
323
+ const coolTimer = setInterval(() => {
324
+ const cooling = this._expire()
325
+ const changed = cooling !== this._lastCooldownCount
326
+ this._lastCooldownCount = cooling
327
+ if (changed) getRefresh?.()
328
+ }, 20_000)
329
+ if (typeof fileTimer.unref === 'function') fileTimer.unref()
330
+ if (typeof coolTimer.unref === 'function') coolTimer.unref()
331
+ return () => {
332
+ clearInterval(fileTimer)
333
+ clearInterval(coolTimer)
334
+ }
335
+ }
336
+ }
@@ -0,0 +1,227 @@
1
+ // 把本地聚合网关注册成 DSH 的模型供应商:
2
+ // 「模型」页里直接出现「魔搭 Key 池」,选中即可对话,请求仍由网关做多 Key 轮换。
3
+ // 做法与 dsh-connect-modelscope 相同:pi-ai provider + PiAiAdapter。
4
+ import { createRequire } from 'node:module'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { join } from 'node:path'
7
+
8
+ const HOST_ROOTS = ['/opt/dsh', process.cwd()]
9
+ const DEFAULT_CONTEXT_WINDOW = 131072
10
+ const IMAGE_BUDGETS = {
11
+ maxRequestImageBytes: 20971520,
12
+ requestImagePixelBudget: 4194304,
13
+ requestImageMaxBytes: 1048576,
14
+ }
15
+
16
+ // 这些类型的模型不能用来对话,列出来只会干扰选择。
17
+ const NON_CHAT = [/(^|[/-])(wan|hunyuanvideo|i2v|t2v|vace|animate|cogvideox)/i, /(image|flux|kolors|sd[-_]?xl|qwen-image|seedream)/i, /(speech|tts|asr|whisper|cosyvoice|audio)/i, /(embedding|rerank|bge-|gte-)/i]
18
+
19
+ export const isChatModel = (id) => !NON_CHAT.some((re) => re.test(String(id)))
20
+
21
+ const prettyName = (id) => {
22
+ const tail = id.includes('/') ? id.slice(id.lastIndexOf('/') + 1) : id
23
+ return tail.replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim()
24
+ }
25
+
26
+ /** 插件不在 profile 的 node_modules 里,宿主包要按 dsh 安装目录兜底解析。 */
27
+ const HOST_SPECIFIER = {
28
+ '@earendil-works/pi-ai': (root) => `${root}/node_modules/@earendil-works/pi-ai/dist/index.js`,
29
+ '@earendil-works/pi-ai/api/openai-completions.lazy': (root) =>
30
+ `${root}/node_modules/@earendil-works/pi-ai/dist/api/openai-completions.lazy.js`,
31
+ '@deepseek-ai/dsh-llm': (root) => `${root}/node_modules/@deepseek-ai/dsh-llm/lib/index.js`,
32
+ '@deepseek-ai/dsh-llm-pi-ai': (root) => `${root}/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js`,
33
+ }
34
+
35
+ async function loadHost(specifier) {
36
+ try {
37
+ return await import(specifier)
38
+ } catch (first) {
39
+ for (const root of HOST_ROOTS) {
40
+ const guess = HOST_SPECIFIER[specifier]?.(root)
41
+ if (!guess) continue
42
+ try {
43
+ return await import(pathToFileURL(guess).href)
44
+ } catch {}
45
+ try {
46
+ const require = createRequire(join(root, 'noop.cjs'))
47
+ return await import(pathToFileURL(require.resolve(specifier)).href)
48
+ } catch {}
49
+ }
50
+ throw first
51
+ }
52
+ }
53
+
54
+ const INERT_AUTH = {
55
+ credentials: {
56
+ async read() {},
57
+ async list() {
58
+ return []
59
+ },
60
+ async modify() {
61
+ throw new Error('dsh-modelscope-pool has no pi-ai credential lifecycle')
62
+ },
63
+ async delete() {},
64
+ },
65
+ authContext: { async env() {}, async fileExists() { return false } },
66
+ }
67
+
68
+ /**
69
+ * @param ctx 注入过 llm 的上下文
70
+ * @param options.providerId 供应商 id(出现在模型页)
71
+ * @param options.displayName 供应商显示名
72
+ * @param options.getPort 当前网关端口
73
+ * @param options.getKey 网关口令(没设就是空)
74
+ * @param options.getModels 当前可用模型 id 列表
75
+ * @param options.getEntries 带版本/短名的模型条目(优先于 getModels)
76
+ * @param options.entryId 本插件在 loader 里的条目 id
77
+ */
78
+ const REGION_TAG = { ai: '国际版', cn: '国内版' }
79
+
80
+ /** 模型页里显示的名字:短名 + 版本,例如「DeepSeek-V4-Flash-1017 · 国际版」。 */
81
+ const displayNameOf = (entry) => {
82
+ const base = String(entry?.name ?? '').trim() || prettyName(entry?.id)
83
+ const regions = Array.isArray(entry?.regions) ? entry.regions : []
84
+ // 两个站点都有就不加后缀,避免模型页里一堆「· 双站」。
85
+ if (regions.length === 0 || regions.length > 1) return base
86
+ const tag = REGION_TAG[regions[0]] ?? ''
87
+ return tag ? `${base} · ${tag}` : base
88
+ }
89
+
90
+ export async function registerPoolProvider(ctx, { providerId, displayName, getPort, getKey, getModels, getEntries, entryId, logger }) {
91
+ if (!ctx.llm || typeof ctx.llm.registerAdapter !== 'function') return false
92
+ globalThis.__poolProviderDeclared = false
93
+ // 配置里声明的路由由 pi-ai 在启动早期挂上;我们稍后再决定是否补一个适配器。
94
+ try {
95
+ await new Promise((resolve) => setTimeout(resolve, 1200))
96
+ if ((ctx.llm.listProviders?.() ?? []).some((route) => route.id === providerId)) {
97
+ globalThis.__poolProviderDeclared = true
98
+ return 'declared'
99
+ }
100
+ } catch {}
101
+
102
+ // 配置里已经声明了同名供应商(比如写进 cordis.patch.yml 的同一条),
103
+ // 宿主会自己把它挂上,重复注册会撞 DUPLICATE_DIRECTORY,直接让位。
104
+ try {
105
+ const rows = ctx.llm.listConfigurableProviders?.() ?? []
106
+ if (rows.some((row) => row.provider === providerId && row.declared === true)) {
107
+ globalThis.__poolProviderDeclared = true
108
+ return 'declared'
109
+ }
110
+ } catch {}
111
+
112
+ let createProvider, openAICompletionsApi, resolveRetryPolicy, PiAiAdapter
113
+ try {
114
+ ;[{ createProvider }, { openAICompletionsApi }, { resolveRetryPolicy }, { PiAiAdapter }] = await Promise.all([
115
+ loadHost('@earendil-works/pi-ai'),
116
+ loadHost('@earendil-works/pi-ai/api/openai-completions.lazy'),
117
+ loadHost('@deepseek-ai/dsh-llm'),
118
+ loadHost('@deepseek-ai/dsh-llm-pi-ai'),
119
+ ])
120
+ } catch (error) {
121
+ globalThis.__poolProviderError = String(error?.message ?? error)
122
+ logger?.warn?.(`modelscope-pool: 模型供应商注册跳过(${String(error?.message ?? error)})`)
123
+ return false
124
+ }
125
+
126
+ const baseUrl = () => `http://127.0.0.1:${getPort() || 8899}/v1`
127
+ const token = () => String(getKey?.() ?? '').trim() || 'pool-local'
128
+
129
+ const entriesOf = () => {
130
+ const rows = typeof getEntries === 'function' ? getEntries() : []
131
+ if (Array.isArray(rows) && rows.length > 0) return rows
132
+ return (typeof getModels === 'function' ? getModels() : []).map((id) => ({ id }))
133
+ }
134
+
135
+ const buildModels = () =>
136
+ entriesOf()
137
+ .filter((entry) => isChatModel(entry.id))
138
+ .map((entry) => ({
139
+ id: entry.id,
140
+ name: displayNameOf(entry),
141
+ api: 'openai-completions',
142
+ provider: providerId,
143
+ baseUrl: baseUrl(),
144
+ input: ['text'],
145
+ // DSH 拒绝没有正整数上下文的模型,这里给保守值。
146
+ contextWindow: DEFAULT_CONTEXT_WINDOW,
147
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
148
+ reasoning: false,
149
+ }))
150
+
151
+ const provider = {
152
+ ...createProvider({
153
+ id: providerId,
154
+ name: displayName,
155
+ auth: {
156
+ apiKey: {
157
+ name: '本地网关口令',
158
+ async resolve() {
159
+ return { auth: { apiKey: token() }, source: displayName }
160
+ },
161
+ },
162
+ },
163
+ models: buildModels(),
164
+ api: openAICompletionsApi(),
165
+ }),
166
+ getModels: () => buildModels(),
167
+ }
168
+
169
+ const profile = {
170
+ provider: providerId,
171
+ displayName,
172
+ streamIdleTimeoutMs: 180000,
173
+ retryPolicy: resolveRetryPolicy(undefined, 'dsh-modelscope-pool retryPolicy'),
174
+ configuredMaxTokens: new Map(),
175
+ modelErrors: new Map(),
176
+ defaultContextWindow: DEFAULT_CONTEXT_WINDOW,
177
+ ...IMAGE_BUDGETS,
178
+ piProvider: provider,
179
+ }
180
+
181
+ const adapter = new PiAiAdapter({
182
+ profiles: () => new Map([[providerId, profile]]),
183
+ auth: INERT_AUTH,
184
+ resolveApiKey: async () => token(),
185
+ })
186
+
187
+ ctx.effect(() => ctx.llm.registerAdapter([providerId], adapter))
188
+
189
+ ctx.effect(() => {
190
+ if (typeof ctx.llm.registerConfigurableProviders !== 'function') return
191
+ let off
192
+ try {
193
+ off = ctx.llm.registerConfigurableProviders([
194
+ { provider: providerId, displayName, settingsNs: entryId, settingsPath: [], declared: false },
195
+ ])
196
+ } catch (error) {
197
+ globalThis.__poolProviderError = `directory: ${String(error?.message ?? error)}`
198
+ logger?.warn?.(`modelscope-pool: 供应商目录注册失败(${String(error?.message ?? error)})`)
199
+ return
200
+ }
201
+ return () => {
202
+ try {
203
+ off?.()
204
+ } catch {}
205
+ }
206
+ })
207
+
208
+ ctx.effect(() => {
209
+ if (typeof ctx.llm.registerModelDiscovery !== 'function') return
210
+ let off
211
+ try {
212
+ off = ctx.llm.registerModelDiscovery(entryId, async () =>
213
+ buildModels().map((model) => ({ id: model.id, name: model.name, contextWindow: model.contextWindow, inputModalities: ['text'] })),
214
+ )
215
+ } catch (error) {
216
+ logger?.warn?.(`modelscope-pool: 模型发现注册失败(${String(error?.message ?? error)})`)
217
+ return
218
+ }
219
+ return () => {
220
+ try {
221
+ off?.()
222
+ } catch {}
223
+ }
224
+ })
225
+
226
+ return true
227
+ }