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/LICENSE +21 -0
- package/README.md +302 -2
- package/cordis.patch.yml +4 -0
- package/lib/catalog.js +105 -0
- package/lib/index.js +965 -0
- package/lib/pool.js +336 -0
- package/lib/provider.js +227 -0
- package/lib/proxy.js +426 -0
- package/lib/quota.js +131 -0
- package/lib/route.js +104 -0
- package/package.json +107 -4
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
|
+
}
|
package/lib/provider.js
ADDED
|
@@ -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
|
+
}
|