dsh-modelscope-pool 0.0.0-stage → 0.9.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 +343 -2
- package/cordis.patch.yml +4 -0
- package/lib/catalog.js +105 -0
- package/lib/i18n.js +231 -0
- package/lib/index.js +1094 -0
- package/lib/pool.js +336 -0
- package/lib/provider.js +233 -0
- package/lib/proxy.js +426 -0
- package/lib/quota.js +131 -0
- package/lib/route.js +104 -0
- package/package.json +111 -4
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// 本地 OpenAI 兼容聚合网关:把请求转给魔搭推理接口,
|
|
2
|
+
// 遇到 Key 级错误就换下一个 Key:额度用尽停用到次日 00:01(北京时间),
|
|
3
|
+
// 令牌失效/限流这类临时问题冷却 5 分钟。
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import { Readable } from 'node:stream'
|
|
6
|
+
import { nextQuotaResetAt } from './pool.js'
|
|
7
|
+
import { REGIONS } from './quota.js'
|
|
8
|
+
import { modelUnavailable } from './route.js'
|
|
9
|
+
|
|
10
|
+
const DISABLE_STATUS = new Set([401, 402, 429])
|
|
11
|
+
// 403 多半是「这个账号没开这个模型」,换 Key 可能就好了,所以只轮换、不停用。
|
|
12
|
+
const ROTATE_STATUS = new Set([403])
|
|
13
|
+
const CLIENT_STATUS = new Set([400, 404, 405, 409, 413, 422])
|
|
14
|
+
const HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'host', 'content-length', 'authorization'])
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 「余额/额度真的用完了」和「只是被限流」要分开处理:
|
|
18
|
+
* 前一种要停到次日 00:01,后者冷却几分钟就好。
|
|
19
|
+
* 魔搭的实际报错是 429 + {"message":"insufficient balance"},
|
|
20
|
+
* 另外还见过 QuotaLimitExceed / E3027 / quota exceeded。
|
|
21
|
+
*/
|
|
22
|
+
export function quotaLike(text) {
|
|
23
|
+
const s = String(text ?? '')
|
|
24
|
+
if (/Throttling\.(ServiceOverloaded|ResourceExhausted)/i.test(s)) return false
|
|
25
|
+
return /QuotaLimitExceed|E3027|insufficient[_\s-]?(quota|balance|funds)|quota[_\s-]?exceeded|AccountOverdue|(余额|额度)不足/i.test(s)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function reasonOf(status) {
|
|
29
|
+
if (status === 401) return '令牌无效'
|
|
30
|
+
if (status === 403) return '无权访问'
|
|
31
|
+
if (status === 402) return '额度用尽'
|
|
32
|
+
if (status === 429) return '被限流'
|
|
33
|
+
return '额度用尽'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sendJson(res, status, payload) {
|
|
37
|
+
const body = Buffer.from(JSON.stringify(payload))
|
|
38
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': body.length })
|
|
39
|
+
res.end(body)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fail(res, status, message, code = 'pool_error') {
|
|
43
|
+
sendJson(res, status, { error: { message, type: code, code } })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readBody(req, limit = 32 * 1024 * 1024) {
|
|
47
|
+
const chunks = []
|
|
48
|
+
let size = 0
|
|
49
|
+
for await (const chunk of req) {
|
|
50
|
+
size += chunk.length
|
|
51
|
+
if (size > limit) throw Object.assign(new Error('请求体太大'), { status: 413 })
|
|
52
|
+
chunks.push(chunk)
|
|
53
|
+
}
|
|
54
|
+
return Buffer.concat(chunks)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function copyHeaders(upstream, res) {
|
|
58
|
+
for (const [name, value] of upstream.headers) {
|
|
59
|
+
if (HOP.has(name.toLowerCase())) continue
|
|
60
|
+
try {
|
|
61
|
+
res.setHeader(name, value)
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isStreaming(headers, body) {
|
|
67
|
+
const ct = String(headers['content-type'] ?? '')
|
|
68
|
+
if (ct.includes('text/event-stream')) return true
|
|
69
|
+
if (!body?.length) return false
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(body.toString('utf8'))?.stream === true
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 把请求体里的 model 换成路由选中的那个;本来就是它就不动,别白序列化一遍大 body。 */
|
|
78
|
+
function withModel(body, model, method) {
|
|
79
|
+
if (!body?.length || method === 'GET' || method === 'HEAD') return body
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(body.toString('utf8'))
|
|
82
|
+
if (!parsed || typeof parsed !== 'object') return body
|
|
83
|
+
if (parsed.model === model) return body
|
|
84
|
+
parsed.model = model
|
|
85
|
+
return Buffer.from(JSON.stringify(parsed))
|
|
86
|
+
} catch {
|
|
87
|
+
return body
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createProxy({ pool, config, router, logger, hooks = {} }) {
|
|
92
|
+
// 上游可以有多条线路:主线路连不上就自动换,客户端无感。
|
|
93
|
+
// 国际版和国内版的推理域名不同,Key 认出版本后优先走自己那一边。
|
|
94
|
+
const allEndpoints = (() => {
|
|
95
|
+
const list = Array.isArray(config.upstreams) ? config.upstreams : []
|
|
96
|
+
const all = [config.upstream, ...list, ...Object.values(REGIONS).map((r) => r.inference)]
|
|
97
|
+
.map((u) => String(u ?? '').trim().replace(/\/+$/, ''))
|
|
98
|
+
.filter((u) => /^https?:\/\//.test(u))
|
|
99
|
+
const unique = [...new Set(all)]
|
|
100
|
+
return unique.length > 0 ? unique : ['https://api-inference.modelscope.ai/v1']
|
|
101
|
+
})()
|
|
102
|
+
let boundPort = 0
|
|
103
|
+
|
|
104
|
+
const authorized = (req) => {
|
|
105
|
+
if (!config.apiKey) return true
|
|
106
|
+
const auth = String(req.headers.authorization ?? '')
|
|
107
|
+
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
|
|
108
|
+
return token === config.apiKey
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handle(req, res) {
|
|
112
|
+
let url
|
|
113
|
+
try {
|
|
114
|
+
url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
115
|
+
} catch {
|
|
116
|
+
return fail(res, 400, '地址看不懂')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
120
|
+
const s = pool.status()
|
|
121
|
+
return sendJson(res, 200, {
|
|
122
|
+
service: 'local-modelscope-pool',
|
|
123
|
+
endpoint: `http://127.0.0.1:${boundPort}/v1`,
|
|
124
|
+
keys: { total: s.total, available: s.available, cooling: s.cooling },
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (url.pathname === '/healthz') {
|
|
129
|
+
const s = pool.status()
|
|
130
|
+
return sendJson(res, s.available > 0 ? 200 : 503, { ok: s.available > 0, total: s.total, available: s.available, cooling: s.cooling })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!authorized(req)) return fail(res, 401, '本地网关的访问口令不对', 'invalid_token')
|
|
134
|
+
|
|
135
|
+
if (req.method === 'GET' && url.pathname === '/v1/pool/status') {
|
|
136
|
+
const s = pool.status()
|
|
137
|
+
return sendJson(res, 200, {
|
|
138
|
+
endpoint: `http://127.0.0.1:${boundPort}/v1`,
|
|
139
|
+
total: s.total,
|
|
140
|
+
available: s.available,
|
|
141
|
+
cooling: s.cooling,
|
|
142
|
+
requests: s.stats.requests,
|
|
143
|
+
failovers: s.stats.failovers,
|
|
144
|
+
keys: s.keys.map((k) => ({
|
|
145
|
+
id: k.id,
|
|
146
|
+
key: k.masked,
|
|
147
|
+
region: k.region ?? '',
|
|
148
|
+
state: k.state,
|
|
149
|
+
cooldownSec: Math.ceil(k.leftMs / 1000),
|
|
150
|
+
untilAt: k.untilAt,
|
|
151
|
+
resumeAt: k.untilAt ? new Date(k.untilAt + 8 * 3600_000).toISOString().slice(11, 16) : '',
|
|
152
|
+
reason: k.reason,
|
|
153
|
+
lastError: k.lastError,
|
|
154
|
+
used: k.used,
|
|
155
|
+
})),
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 本地接口要排在 /v1/* 转发之前,否则会被当成上游路径。
|
|
160
|
+
if (req.method === 'GET' && url.pathname === '/v1/pool/provider') {
|
|
161
|
+
return sendJson(res, 200, hooks.provider?.() ?? { registered: false })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.method === 'GET' && url.pathname === '/v1/pool/models') {
|
|
165
|
+
const models = (await hooks.models?.(false)) ?? []
|
|
166
|
+
return sendJson(res, 200, { selected: hooks.selected?.() ?? '', models })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (req.method === 'GET' && url.pathname === '/v1/pool/balance') {
|
|
170
|
+
const list = (await hooks.balance?.(url.searchParams.get('refresh') === '1')) ?? []
|
|
171
|
+
return sendJson(res, 200, { keys: list, total: list.reduce((sum, k) => sum + (k.ok ? k.available : 0), 0) })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (req.method === 'POST' && url.pathname === '/v1/pool/model') {
|
|
175
|
+
let payload
|
|
176
|
+
try {
|
|
177
|
+
payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}')
|
|
178
|
+
} catch {
|
|
179
|
+
return fail(res, 400, '请求体不是 JSON')
|
|
180
|
+
}
|
|
181
|
+
const model = String(payload?.model ?? '').trim()
|
|
182
|
+
if (!model) return fail(res, 400, '缺少 model')
|
|
183
|
+
const saved = (await hooks.setModel?.(model)) ?? false
|
|
184
|
+
return sendJson(res, 200, { ok: saved, selected: model })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!url.pathname.startsWith('/v1/')) return fail(res, 404, '没有这个接口')
|
|
188
|
+
|
|
189
|
+
let body
|
|
190
|
+
try {
|
|
191
|
+
body = await readBody(req)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return fail(res, error.status ?? 400, '读取请求失败')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const streaming = isStreaming(req.headers, body)
|
|
197
|
+
const baseHeaders = {}
|
|
198
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
199
|
+
const lower = name.toLowerCase()
|
|
200
|
+
if (HOP.has(lower)) continue
|
|
201
|
+
if (value !== undefined) baseHeaders[lower] = value
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 客户端要的模型:没写或写 auto 就交给路由决定。
|
|
205
|
+
const requested = (() => {
|
|
206
|
+
if (!body?.length || req.method === 'GET' || req.method === 'HEAD') return ''
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(body.toString('utf8'))
|
|
209
|
+
const model = typeof parsed?.model === 'string' ? parsed.model.trim() : ''
|
|
210
|
+
return model === 'auto' || model === 'pool-auto' ? '' : model
|
|
211
|
+
} catch {
|
|
212
|
+
return ''
|
|
213
|
+
}
|
|
214
|
+
})()
|
|
215
|
+
|
|
216
|
+
// 无感路由:模型固定为客户端要的那个(auto 时用默认模型),只换 Key。
|
|
217
|
+
// 客户端可能用短名(DeepSeek-V4-Flash-1017),这里换回上游真实 id。
|
|
218
|
+
const resolved = requested && hooks?.resolveModel ? String(hooks.resolveModel(requested) ?? '') : ''
|
|
219
|
+
const model = resolved || requested || String(config.model ?? '').trim()
|
|
220
|
+
const path = url.pathname.slice(3)
|
|
221
|
+
let endpointIndex = 0
|
|
222
|
+
|
|
223
|
+
pool.stats.requests++
|
|
224
|
+
let lastStatus = 0
|
|
225
|
+
let lastText = ''
|
|
226
|
+
let attempts = 0
|
|
227
|
+
let networkFails = 0
|
|
228
|
+
const maxNetworkFails = Math.max(2, allEndpoints.length * 2)
|
|
229
|
+
|
|
230
|
+
// 「这个模型上办不了的 Key」直接跳过,别每次都从第一个开始撞。
|
|
231
|
+
// 这个模型已经被确认「所有 Key 都办不了」时直接回错误,不再把 87 个 Key 扫一遍。
|
|
232
|
+
if (model && router?.modelDead?.(model)) {
|
|
233
|
+
res.setHeader('retry-after', '300')
|
|
234
|
+
return fail(res, 403, `这个模型当前所有 Key 都拿不到(可能没开通),5 分钟后会再试`, 'model_unavailable')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const tried = new Set(router?.blockedKeys?.(model) ?? [])
|
|
238
|
+
const skipped = tried.size
|
|
239
|
+
const maxAttempts = Math.max(1, pool.keys.length)
|
|
240
|
+
// 第一次扫这个模型时最多撞这么多个 Key:够覆盖「权限只在少数账号上」,
|
|
241
|
+
// 又不至于让用户干等十几秒。撞满就整体标记,下次直接快速失败。
|
|
242
|
+
const maxDeniedScan = Math.min(maxAttempts, 24)
|
|
243
|
+
let denied = 0
|
|
244
|
+
|
|
245
|
+
while (attempts < maxAttempts) {
|
|
246
|
+
const key = pool.pick(tried)
|
|
247
|
+
if (!key) break
|
|
248
|
+
tried.add(key.id)
|
|
249
|
+
attempts++
|
|
250
|
+
if (attempts + skipped > 1) pool.stats.failovers++
|
|
251
|
+
|
|
252
|
+
const live = allEndpoints.filter((ep) => router?.endpointOk?.(ep) !== false)
|
|
253
|
+
const usable = live.length > 0 ? live : allEndpoints
|
|
254
|
+
// Key 认出的版本优先;版本未知或该线路被标记坏了,就按顺序轮。
|
|
255
|
+
const own = key.region ? REGIONS[key.region]?.inference : ''
|
|
256
|
+
const base = own && usable.includes(own) ? own : usable[endpointIndex % usable.length]
|
|
257
|
+
const target = `${base}${path}${url.search}`
|
|
258
|
+
// 同一个模型在不同站点可能有不同 id,按这个 Key 的站点换一次。
|
|
259
|
+
const upstreamModel = (model && hooks?.modelFor?.(model, key.region)) || model
|
|
260
|
+
const bodyForModel = upstreamModel ? withModel(body, upstreamModel, req.method) : body
|
|
261
|
+
|
|
262
|
+
const controller = new AbortController()
|
|
263
|
+
const timer = streaming ? null : setTimeout(() => controller.abort(), Number(config.timeoutMs) || 120_000)
|
|
264
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
265
|
+
|
|
266
|
+
let upstreamRes
|
|
267
|
+
try {
|
|
268
|
+
upstreamRes = await fetch(target, {
|
|
269
|
+
method: req.method,
|
|
270
|
+
headers: { ...baseHeaders, authorization: `Bearer ${key.key}` },
|
|
271
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : bodyForModel,
|
|
272
|
+
signal: controller.signal,
|
|
273
|
+
})
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (timer) clearTimeout(timer)
|
|
276
|
+
lastStatus = 0
|
|
277
|
+
lastText = controller.signal.aborted ? '上游超时' : String(error?.message ?? error)
|
|
278
|
+
// 连不上是线路问题:换线路重试同一个 Key,不占 Key 的尝试次数。
|
|
279
|
+
if (allEndpoints.length > 1) router?.noteEndpoint?.(base, lastText)
|
|
280
|
+
endpointIndex++
|
|
281
|
+
tried.delete(key.id)
|
|
282
|
+
attempts--
|
|
283
|
+
networkFails++
|
|
284
|
+
logger?.warn?.(`modelscope-pool: ${base} 请求失败(${lastText}),换一条线路`)
|
|
285
|
+
if (networkFails >= maxNetworkFails) break
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
if (timer) clearTimeout(timer)
|
|
289
|
+
|
|
290
|
+
if (upstreamRes.status >= 200 && upstreamRes.status < 300) {
|
|
291
|
+
pool.markSuccess(key.id)
|
|
292
|
+
pool.stats.ok++
|
|
293
|
+
res.statusCode = upstreamRes.status
|
|
294
|
+
copyHeaders(upstreamRes, res)
|
|
295
|
+
res.setHeader('x-pool-key', key.id)
|
|
296
|
+
if (model) res.setHeader('x-pool-model', model)
|
|
297
|
+
if (upstreamModel) res.setHeader('x-pool-upstream-model', upstreamModel)
|
|
298
|
+
if (key.region) res.setHeader('x-pool-region', key.region)
|
|
299
|
+
if (req.method === 'HEAD' || !upstreamRes.body) {
|
|
300
|
+
res.end()
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
const stream = Readable.fromWeb(upstreamRes.body)
|
|
304
|
+
stream.on('error', () => res.destroy())
|
|
305
|
+
stream.pipe(res)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const text = await upstreamRes.text().catch(() => '')
|
|
310
|
+
lastStatus = upstreamRes.status
|
|
311
|
+
lastText = text
|
|
312
|
+
|
|
313
|
+
// 403 没开这个模型、404 模型不存在、400「模型不支持」——都是这个 Key 办不了,
|
|
314
|
+
// 换下一个 Key;Key 本身不停用,换个模型它可能还能用。
|
|
315
|
+
if (modelUnavailable(upstreamRes.status, text) && !quotaLike(text)) {
|
|
316
|
+
pool.stats.failed++
|
|
317
|
+
router?.noteKeyModel?.(model, key.id, `HTTP ${upstreamRes.status}`)
|
|
318
|
+
denied++
|
|
319
|
+
if (denied >= maxDeniedScan) {
|
|
320
|
+
router?.noteModelDead?.(model)
|
|
321
|
+
res.setHeader('retry-after', '300')
|
|
322
|
+
return fail(res, 403, `已连续 ${denied} 个 Key 都拿不到 ${model},换别的时间或别在面板里恢复全部 Key 再试`, 'model_unavailable')
|
|
323
|
+
}
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (CLIENT_STATUS.has(upstreamRes.status) && !quotaLike(text)) {
|
|
328
|
+
// 参数/格式这类问题换 Key 也没用,直接把上游的回答交回去。
|
|
329
|
+
pool.stats.failed++
|
|
330
|
+
res.statusCode = upstreamRes.status
|
|
331
|
+
copyHeaders(upstreamRes, res)
|
|
332
|
+
res.end(text)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (DISABLE_STATUS.has(upstreamRes.status) || quotaLike(text)) {
|
|
337
|
+
// 额度用尽停到北京时间次日 00:01;限流/令牌失效冷却 5 分钟。
|
|
338
|
+
const quota = quotaLike(text) || upstreamRes.status === 402
|
|
339
|
+
const reason = quota ? '额度用尽' : reasonOf(upstreamRes.status)
|
|
340
|
+
const untilAt = quota ? nextQuotaResetAt() : undefined
|
|
341
|
+
pool.markFailure(key.id, reason, text.slice(0, 120), untilAt)
|
|
342
|
+
pool.stats.failed++
|
|
343
|
+
const when = quota ? `至北京时间 ${new Date(untilAt + 8 * 3600_000).toISOString().slice(11, 16)}` : '5 分钟'
|
|
344
|
+
logger?.warn?.(`modelscope-pool: key ${key.id} 已停用${when}(${reason},HTTP ${upstreamRes.status}),换下一个 Key`)
|
|
345
|
+
// 429 可能是「额度真没了」,也可能只是一时抽风:让额度接口把这一把 Key 查到底,
|
|
346
|
+
// 查出来还有额度就立刻放回去,别白白少一个 Key。
|
|
347
|
+
if (upstreamRes.status === 429 || quota) {
|
|
348
|
+
try {
|
|
349
|
+
Promise.resolve(hooks?.quotaSuspect?.(key.id, key.key)).catch(() => {})
|
|
350
|
+
} catch {}
|
|
351
|
+
}
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 5xx / 其他:上游故障,换下一个 Key 但不停用当前 Key。
|
|
356
|
+
pool.stats.failed++
|
|
357
|
+
logger?.warn?.(`modelscope-pool: key ${key.id} 上游 ${upstreamRes.status},换下一个 Key`)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const status = pool.status()
|
|
361
|
+
if (status.total === 0) return fail(res, 503, '池里还没有 Key:在魔搭复制令牌后点输入框上方的「添加 Key」', 'no_keys')
|
|
362
|
+
if (status.available === 0 && status.cooling > 0) {
|
|
363
|
+
const coolingKeys = status.keys.filter((k) => k.leftMs > 0)
|
|
364
|
+
const least = Math.min(...coolingKeys.map((k) => k.leftMs))
|
|
365
|
+
const sec = Math.max(1, Math.ceil(least / 1000))
|
|
366
|
+
res.setHeader('retry-after', String(sec))
|
|
367
|
+
const quotaOnly = coolingKeys.every((k) => k.reason === '额度用尽')
|
|
368
|
+
const soonest = coolingKeys.reduce((a, b) => (a.leftMs <= b.leftMs ? a : b))
|
|
369
|
+
const at = soonest.untilAt ? new Date(soonest.untilAt + 8 * 3600_000).toISOString().slice(11, 16) : ''
|
|
370
|
+
if (quotaOnly && at) return fail(res, 429, `全部 Key 额度用尽,北京时间 ${at} 自动恢复`, 'all_keys_quota')
|
|
371
|
+
return fail(res, 429, `所有 Key 都在冷却中,约 ${sec} 秒后自动恢复`, 'all_keys_cooling')
|
|
372
|
+
}
|
|
373
|
+
if (lastStatus === 0 && lastText) return fail(res, 502, `所有 Key 都连不上上游(${lastText.slice(0, 80)})`, 'upstream_unreachable')
|
|
374
|
+
if (lastStatus >= 400 && lastStatus < 600) {
|
|
375
|
+
// 上游故障(5xx 等):把上游的回答原样交回去,Key 不停用。
|
|
376
|
+
res.statusCode = lastStatus
|
|
377
|
+
let isJson = false
|
|
378
|
+
try {
|
|
379
|
+
JSON.parse(lastText)
|
|
380
|
+
isJson = true
|
|
381
|
+
} catch {}
|
|
382
|
+
res.setHeader('content-type', isJson ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8')
|
|
383
|
+
res.end(lastText || `上游返回 ${lastStatus}`)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
return fail(res, 429, '没有可用的 Key,请稍后再试', 'no_available_key')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const server = http.createServer((req, res) => {
|
|
390
|
+
handle(req, res).catch((error) => {
|
|
391
|
+
try {
|
|
392
|
+
if (!res.headersSent) fail(res, 500, '网关内部出错')
|
|
393
|
+
else res.destroy()
|
|
394
|
+
} catch {}
|
|
395
|
+
logger?.warn?.(`modelscope-pool: ${String(error?.stack ?? error)}`)
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
/** 端口被占就顺延;返回实际监听端口。 */
|
|
400
|
+
async function listen() {
|
|
401
|
+
const start = Number(config.port) || 8899
|
|
402
|
+
for (let port = start; port < start + 20; port++) {
|
|
403
|
+
const ok = await new Promise((resolve) => {
|
|
404
|
+
const onError = () => resolve(false)
|
|
405
|
+
server.once('error', onError)
|
|
406
|
+
server.listen(port, '127.0.0.1', () => {
|
|
407
|
+
server.off('error', onError)
|
|
408
|
+
resolve(true)
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
if (ok) {
|
|
412
|
+
boundPort = port
|
|
413
|
+
return port
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
throw new Error('8899–8918 都被占用了')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
listen,
|
|
421
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
422
|
+
get port() {
|
|
423
|
+
return boundPort
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
}
|
package/lib/quota.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 魔搭「魔粒」额度查询:只读接口,查额度本身不扣额度。
|
|
2
|
+
// 这个接口会真正校验令牌(/v1/models 是公开的,拿它验 Key 等于没验),
|
|
3
|
+
// 而且国际版和国内版的令牌互不通用,所以「哪个站点返回 200」就等于认出了 Key 属于哪个版本。
|
|
4
|
+
export const REGIONS = {
|
|
5
|
+
ai: {
|
|
6
|
+
id: 'ai',
|
|
7
|
+
label: '国际版',
|
|
8
|
+
site: 'modelscope.ai',
|
|
9
|
+
balance: 'https://modelscope.ai/openapi/v1/magicubes/balance',
|
|
10
|
+
inference: 'https://api-inference.modelscope.ai/v1',
|
|
11
|
+
},
|
|
12
|
+
cn: {
|
|
13
|
+
id: 'cn',
|
|
14
|
+
label: '国内版',
|
|
15
|
+
site: 'modelscope.cn',
|
|
16
|
+
balance: 'https://modelscope.cn/openapi/v1/magicubes/balance',
|
|
17
|
+
inference: 'https://api-inference.modelscope.cn/v1',
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
const REGION_ORDER = ['ai', 'cn']
|
|
21
|
+
const BALANCE_URLS = REGION_ORDER.map((r) => REGIONS[r].balance)
|
|
22
|
+
|
|
23
|
+
// 一次刷 95 个 Key 的额度时别全并发,免得额度接口自己把我们限流(那也是一种 429)。
|
|
24
|
+
const CONCURRENCY = 6
|
|
25
|
+
|
|
26
|
+
export function createQuotaBook({ ttlMs = 60_000, timeoutMs = 12_000, logger } = {}) {
|
|
27
|
+
const cache = new Map() // keyId -> { at, state }
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
30
|
+
|
|
31
|
+
/** 429 是「问得太急」,不是 Key 坏了:退让重试,别把好 Key 判死。 */
|
|
32
|
+
async function ask(url, secret) {
|
|
33
|
+
let last
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
if (i > 0) await sleep(600 * i * (1 + Math.random()))
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
headers: { authorization: `Bearer ${secret}`, accept: 'application/json' },
|
|
39
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
40
|
+
})
|
|
41
|
+
const body = await res.json().catch(() => undefined)
|
|
42
|
+
if (res.ok && body?.success !== false) {
|
|
43
|
+
const data = body?.data ?? {}
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
available: Number(data.available_balance ?? data.total_balance ?? 0),
|
|
47
|
+
total: Number(data.total_balance ?? data.available_balance ?? 0),
|
|
48
|
+
frozen: Number(data.frozen_amount ?? 0),
|
|
49
|
+
at: Date.now(),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
last = {
|
|
53
|
+
status: res.status,
|
|
54
|
+
error: String(body?.message ?? body?.code ?? `HTTP ${res.status}`).slice(0, 40),
|
|
55
|
+
}
|
|
56
|
+
if (res.status !== 429 && res.status !== 503) break
|
|
57
|
+
} catch (error) {
|
|
58
|
+
last = { status: 0, error: String(error?.cause?.code ?? error?.message ?? error).slice(0, 40) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ok: false, ...last }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 查一个 Key 的额度,顺便认出它的版本。
|
|
66
|
+
* preferred 是已知版本;先按它试,认不出再试另一个站点。
|
|
67
|
+
*/
|
|
68
|
+
async function fetchOne(secret, preferred) {
|
|
69
|
+
const order = preferred && REGIONS[preferred] ? [preferred, ...REGION_ORDER.filter((r) => r !== preferred)] : REGION_ORDER
|
|
70
|
+
const attempts = []
|
|
71
|
+
for (const region of order) {
|
|
72
|
+
const info = REGIONS[region]
|
|
73
|
+
const state = await ask(info.balance, secret)
|
|
74
|
+
if (state.ok) return { ...state, region }
|
|
75
|
+
attempts.push(`${info.site}: ${state.error}`)
|
|
76
|
+
// 401/403 才说明「这个版本不认这个令牌」,值得换版本再试;
|
|
77
|
+
// 429/网络问题换版本也没用,直接记下。
|
|
78
|
+
if (state.status !== 401 && state.status !== 403) break
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, error: attempts.join(' | ').slice(0, 150), at: Date.now() }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
/** 只要缓存,不联网(界面每秒重绘时用)。 */
|
|
85
|
+
cached(id) {
|
|
86
|
+
return cache.get(id)?.state
|
|
87
|
+
},
|
|
88
|
+
/** 全部 Key 的可用额度之和;没查到的按 0 计。 */
|
|
89
|
+
sum(ids) {
|
|
90
|
+
let available = 0
|
|
91
|
+
let known = 0
|
|
92
|
+
for (const id of ids) {
|
|
93
|
+
const state = cache.get(id)?.state
|
|
94
|
+
if (state?.ok) {
|
|
95
|
+
available += state.available
|
|
96
|
+
known++
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { available, known }
|
|
100
|
+
},
|
|
101
|
+
async refresh(entries, { force = false } = {}) {
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
const jobs = []
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const hit = cache.get(entry.id)
|
|
106
|
+
if (!force && hit && now - hit.at < ttlMs) continue
|
|
107
|
+
jobs.push(() =>
|
|
108
|
+
fetchOne(entry.key, entry.region).then((state) => {
|
|
109
|
+
cache.set(entry.id, { at: Date.now(), state })
|
|
110
|
+
if (!state.ok) logger?.warn?.(`modelscope-pool: key ${entry.id} 额度查询失败(${state.error})`)
|
|
111
|
+
return state
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i < jobs.length; i += CONCURRENCY) {
|
|
116
|
+
await Promise.all(jobs.slice(i, i + CONCURRENCY).map((job) => job()))
|
|
117
|
+
if (i + CONCURRENCY < jobs.length) await sleep(250)
|
|
118
|
+
}
|
|
119
|
+
return entries.map((entry) => ({ id: entry.id, state: cache.get(entry.id)?.state }))
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** 单个 Key 的额度:refresh 的窄版本,429 之后重试查询时用。 */
|
|
123
|
+
async refreshOne(id, secret, { force = false } = {}) {
|
|
124
|
+
const [row] = await this.refresh([{ id, key: secret }], { force })
|
|
125
|
+
return row?.state
|
|
126
|
+
},
|
|
127
|
+
forget(id) {
|
|
128
|
+
cache.delete(id)
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
package/lib/route.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Key 级无感路由:模型始终是客户端要的那个,变的只是用哪个 Key 去请求。
|
|
2
|
+
// 某个 Key 限流、额度不足、没开这个模型(403)、模型不存在(404)——
|
|
3
|
+
// 都算「这个 Key 现在办不了这件事」,静默换下一个 Key 重试,客户端只看到最终结果。
|
|
4
|
+
//
|
|
5
|
+
// 为了让第二次不再从第一个 Key 重新撞一遍,这里记住「哪个 Key 拿不到哪个模型」,
|
|
6
|
+
// 5 分钟内同一模型直接跳过这些 Key;Key 本身不做全局停用(别处它可能还能用)。
|
|
7
|
+
const BLOCK_TTL = 5 * 60_000
|
|
8
|
+
|
|
9
|
+
// 上游明确表示「这个模型我这儿没有 / 你没权限」的语气。
|
|
10
|
+
const UNAVAILABLE =
|
|
11
|
+
/(has no provider supported|model[_\s-]?not[_\s-]?(found|supported|available)|does\s+not\s+exist|not\s+found|no\s+access|not\s+authori[sz]ed|permission\s+denied|unsupported\s+model|invalid\s+model|model\s+does\s+not|无权|不存在|没有权限)/i
|
|
12
|
+
|
|
13
|
+
/** 这次失败是「这个 Key 拿不到这个模型」(换 Key),还是「请求参数本身不对」(原样返回)? */
|
|
14
|
+
export function modelUnavailable(status, text) {
|
|
15
|
+
if (status === 403 || status === 404) return true
|
|
16
|
+
if (status === 400 || status === 422) return UNAVAILABLE.test(String(text ?? ''))
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createRouter({ logger } = {}) {
|
|
21
|
+
/** `model|keyId` -> 到期时间。 */
|
|
22
|
+
const blocked = new Map()
|
|
23
|
+
/** 上游线路 -> 到期时间(连不上的线路暂时绕开)。 */
|
|
24
|
+
const badEndpoints = new Map()
|
|
25
|
+
/** 模型 -> 到期时间:扫了一圈 Key 都没权限,就先整体标记,别让每次请求都重扫。 */
|
|
26
|
+
const deadModels = new Map()
|
|
27
|
+
|
|
28
|
+
const prune = () => {
|
|
29
|
+
const now = Date.now()
|
|
30
|
+
for (const [pair, until] of blocked) if (until <= now) blocked.delete(pair)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pairOf = (model, keyId) => `${model}\u0000${keyId}`
|
|
34
|
+
|
|
35
|
+
/** 这个模型上已知办不了的 Key,用来在重试时直接跳过。 */
|
|
36
|
+
function blockedKeys(model) {
|
|
37
|
+
const want = String(model ?? '')
|
|
38
|
+
if (!want) return []
|
|
39
|
+
prune()
|
|
40
|
+
const out = []
|
|
41
|
+
for (const pair of blocked.keys()) {
|
|
42
|
+
const at = pair.indexOf('\u0000')
|
|
43
|
+
if (pair.slice(0, at) === want) out.push(pair.slice(at + 1))
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 记下「这个 Key 拿不到这个模型」,5 分钟内不再用它试。 */
|
|
49
|
+
function noteKeyModel(model, keyId, reason = '') {
|
|
50
|
+
if (!model || !keyId) return
|
|
51
|
+
blocked.set(pairOf(model, keyId), Date.now() + BLOCK_TTL)
|
|
52
|
+
logger?.warn?.(`modelscope-pool: key ${keyId} 暂不用于 ${model}(${String(reason).slice(0, 40)}),换下一个 Key`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 某个 Key 恢复可用(比如手动恢复全部)时,把它从所有模型的屏蔽表里摘掉。 */
|
|
56
|
+
function clearKey(keyId) {
|
|
57
|
+
for (const pair of [...blocked.keys()]) {
|
|
58
|
+
if (pair.slice(pair.indexOf('\u0000') + 1) === keyId) blocked.delete(pair)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearAll() {
|
|
63
|
+
blocked.clear()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 这条上游线路还通不通。 */
|
|
67
|
+
function endpointOk(endpoint) {
|
|
68
|
+
const until = badEndpoints.get(endpoint)
|
|
69
|
+
if (!until) return true
|
|
70
|
+
if (until > Date.now()) return false
|
|
71
|
+
badEndpoints.delete(endpoint)
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function noteEndpoint(endpoint, reason = '') {
|
|
76
|
+
if (!endpoint) return
|
|
77
|
+
badEndpoints.set(endpoint, Date.now() + BLOCK_TTL)
|
|
78
|
+
logger?.warn?.(`modelscope-pool: 上游 ${endpoint} 连不上(${String(reason).slice(0, 40)}),暂时换线路`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 这个模型是不是刚被确认「所有 Key 都办不了」。 */
|
|
82
|
+
function modelDead(model) {
|
|
83
|
+
const until = deadModels.get(model)
|
|
84
|
+
if (!until) return false
|
|
85
|
+
if (until > Date.now()) return true
|
|
86
|
+
deadModels.delete(model)
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function noteModelDead(model) {
|
|
91
|
+
if (!model) return
|
|
92
|
+
deadModels.set(model, Date.now() + BLOCK_TTL)
|
|
93
|
+
logger?.warn?.(`modelscope-pool: ${model} 扫完一圈 Key 都拿不到,${BLOCK_TTL / 60000} 分钟内直接返回错误`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function snapshot() {
|
|
97
|
+
prune()
|
|
98
|
+
for (const [ep, until] of badEndpoints) if (until <= Date.now()) badEndpoints.delete(ep)
|
|
99
|
+
for (const [m, until] of deadModels) if (until <= Date.now()) deadModels.delete(m)
|
|
100
|
+
return { blockedPairs: blocked.size, badEndpoints: [...badEndpoints.keys()], deadModels: [...deadModels.keys()] }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { blockedKeys, noteKeyModel, clearKey, clearAll, endpointOk, noteEndpoint, modelDead, noteModelDead, snapshot }
|
|
104
|
+
}
|