@dsh-plugins/dsh-llm-hub 1.3.3 → 1.5.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/CHANGELOG.md +193 -0
- package/README.md +36 -27
- package/lib/availability-store.js +168 -0
- package/lib/client.js +322 -80
- package/lib/index.js +567 -225
- package/lib/sorter.js +106 -0
- package/package.json +19 -9
- package/types/globals.d.ts +3 -0
- package/types/index.d.ts +62 -0
package/lib/index.js
CHANGED
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
import { readFileSync } from 'node:fs'
|
|
35
35
|
import { fileURLToPath } from 'node:url'
|
|
36
36
|
import { applyHarness, harnessReport } from './harness.js'
|
|
37
|
+
import { detectBillingMode, sortProviders } from './sorter.js'
|
|
38
|
+
import { AvailabilityStore, isDropDeadHidden, classifyFailure } from './availability-store.js'
|
|
39
|
+
|
|
40
|
+
export { detectBillingMode, sortProviders } from './sorter.js'
|
|
41
|
+
export { AvailabilityStore, isDropDeadHidden, classifyFailure } from './availability-store.js'
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
44
|
* 用量统计 domain 的 spec。
|
|
@@ -129,6 +134,7 @@ const PIAI_PROBE_PATH = '/api/dsh-llm-hub/pi-ai/probe'
|
|
|
129
134
|
const PIAI_CATALOG_PATH = '/api/dsh-llm-hub/pi-ai/catalog'
|
|
130
135
|
const PIAI_BALANCE_PATH = '/api/dsh-llm-hub/pi-ai/balance'
|
|
131
136
|
const PIAI_MODELS_PATH = '/api/dsh-llm-hub/pi-ai/models'
|
|
137
|
+
const BILLING_MODE_PATH = '/api/dsh-llm-hub/billing-mode'
|
|
132
138
|
const META_PATH = '/api/dsh-llm-hub/meta'
|
|
133
139
|
/** harness 探测自检路由;宿主 info 日志不落盘,靠它从外部确认注册结果。 */
|
|
134
140
|
const HARNESS_PATH = '/api/dsh-llm-hub/harness'
|
|
@@ -177,25 +183,166 @@ const WARNING_CHECK_PATH = '/api/dsh-llm-hub/warning/check'
|
|
|
177
183
|
* }
|
|
178
184
|
*/
|
|
179
185
|
const ROUTING_RESOLVE_PATH = '/api/dsh-llm-hub/routing/resolve'
|
|
186
|
+
/**
|
|
187
|
+
* 路由组状态:`GET /api/dsh-llm-hub/routing/state`。
|
|
188
|
+
*
|
|
189
|
+
* composer 快切 chip 的数据源 —— 返回当前模式(manual/smart)、激活组、每个组
|
|
190
|
+
* 的候选与可用状态、以及 smart 模式最近一次的真实路由判断(lastDecision)。
|
|
191
|
+
*/
|
|
192
|
+
const ROUTING_STATE_PATH = '/api/dsh-llm-hub/routing/state'
|
|
193
|
+
/**
|
|
194
|
+
* 切换模式 / 组:`POST /api/dsh-llm-hub/routing/select`。
|
|
195
|
+
*
|
|
196
|
+
* 请求体 `{ mode?, activeGroup? }`(都可选,至少一个)。写回 settings 的
|
|
197
|
+
* `dsh-llm-hub.routing` 段 —— 状态持久化,重启不丢。
|
|
198
|
+
*/
|
|
199
|
+
const ROUTING_SELECT_PATH = '/api/dsh-llm-hub/routing/select'
|
|
200
|
+
/** 插件自有的 settings 命名空间(1.5.0 起注册)—— routing / warning / aliases 都住这里。 */
|
|
201
|
+
const HUB_NS = 'dsh-llm-hub'
|
|
180
202
|
/** 模型别名:客户端渲染 model id 时显示短名(典型场景:deepseek-reasoner → 推理)。 */
|
|
181
203
|
const ALIASES_PATH = '/api/dsh-llm-hub/aliases'
|
|
182
204
|
/**
|
|
183
|
-
*
|
|
205
|
+
* 1.4.0 多 Key 轮换纯函数:支持 string(单 key)或 string[](多 key round-robin)。
|
|
206
|
+
*
|
|
207
|
+
* @param {string|string[]|undefined} refs - 单 Key 或多 Key 环境变量名数组
|
|
208
|
+
* @param {string} provider - Provider 标识(用于维护独立索引)
|
|
209
|
+
* @param {Map<string, number>} [stateMap] - 轮换游标存储
|
|
210
|
+
* @returns {{ ref: string, index: number, total: number } | undefined}
|
|
211
|
+
*/
|
|
212
|
+
export function pickRotatedEnv(refs, provider, stateMap) {
|
|
213
|
+
if (typeof refs === 'string' && refs.length > 0) {
|
|
214
|
+
return { ref: refs, index: 0, total: 1 }
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(refs)) {
|
|
217
|
+
const list = refs.filter((r) => typeof r === 'string' && r.trim().length > 0)
|
|
218
|
+
if (list.length === 0) return undefined
|
|
219
|
+
const current = stateMap ? (stateMap.get(provider) ?? 0) : 0
|
|
220
|
+
const index = current % list.length
|
|
221
|
+
if (stateMap) stateMap.set(provider, (current + 1) % list.length)
|
|
222
|
+
return { ref: list[index], index, total: list.length }
|
|
223
|
+
}
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 智能识别计费模式:'usage'(按量付费)| 'subscription'(套餐)。
|
|
229
|
+
* 优先级:
|
|
230
|
+
* 1. 显式覆盖:profile?.billingMode
|
|
231
|
+
* 2. 定时限额特征(5h/周/月/resetsAt/quota/plan/percent)→ 'subscription'
|
|
232
|
+
* 3. 原厂域名(deepseek.com / bigmodel.cn / openai.com 等)或现金余额 → 'usage'
|
|
233
|
+
* 4. 兜底 → 'usage'
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 「provider/model」字符串解析(路由组的真源实现;closure 里的
|
|
238
|
+
* `parseRouteEntry` 是同语义副本 —— 测不出 export,所以这里留正源)。
|
|
184
239
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* session 持久化(重启会从 0 开始)。失败的 key 不会自动跳过 —— 用户从
|
|
190
|
-
* debug 路由看到「上次失败的是 X」,下次请求自然轮换过去。这是 1.x 的轻量方案,
|
|
191
|
-
* 真要自动跳过失败 key 等 1.1 收集了真实失败场景再加。
|
|
192
|
-
* - **不覆盖显式 key**:request.apiKey 是一次性覆盖(发现草稿用的),绕过
|
|
193
|
-
* 轮换直接用它;轮换索引不动。
|
|
194
|
-
* - **provider 范围**:DeepSeek 官方直连用 NS = 'llm-deepseek' 作为 key,pi-ai
|
|
195
|
-
* provider 用 provider 自己的 id(modelgo / minimax ...);keyPool 段里
|
|
196
|
-
* 直接用 provider id 作 key。
|
|
240
|
+
* provider 严格(字母数字与连字符/下划线),model 放宽(允许 `.` `+` —— DSH
|
|
241
|
+
* 真实 id 会含它们),缺斜杠 / 空段 / 非法字符一律 undefined。
|
|
242
|
+
* @param {string} entry - 配置里的一行。
|
|
243
|
+
* @returns {{ provider: string, model: string } | undefined}
|
|
197
244
|
*/
|
|
198
|
-
|
|
245
|
+
export function parseRouteEntryPure(entry) {
|
|
246
|
+
if (typeof entry !== 'string') return undefined
|
|
247
|
+
const ROUTE_KEY_RE = /^[a-z0-9][a-z0-9_-]*$/i
|
|
248
|
+
const ROUTE_MODEL_RE = /^[a-z0-9][a-z0-9_.+-]*$/i
|
|
249
|
+
const slash = entry.indexOf('/')
|
|
250
|
+
if (slash <= 0 || slash === entry.length - 1) return undefined
|
|
251
|
+
const provider = entry.slice(0, slash).trim()
|
|
252
|
+
const model = entry.slice(slash + 1).trim()
|
|
253
|
+
if (!ROUTE_KEY_RE.test(provider) || !ROUTE_MODEL_RE.test(model)) return undefined
|
|
254
|
+
return { provider, model }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 把 settings 的 `routing` 段折成路由组列表(1.5.0 智能路由组的真源实现)。
|
|
259
|
+
*
|
|
260
|
+
* 形态:
|
|
261
|
+
* routing:
|
|
262
|
+
* mode: smart # manual | smart(缺省 manual)
|
|
263
|
+
* activeGroup: company # 缺省取第一个组
|
|
264
|
+
* groups:
|
|
265
|
+
* - id: company
|
|
266
|
+
* label: 公司
|
|
267
|
+
* primary: modelgo/gpt-4o
|
|
268
|
+
* fallbacks: [modelgo/claude-3.5-sonnet]
|
|
269
|
+
* - id: personal
|
|
270
|
+
* primary: deepseek-official/deepseek-chat
|
|
271
|
+
* fallbacks: [minimax/MiniMax-M2.7]
|
|
272
|
+
*
|
|
273
|
+
* 兼容:没有 `groups` 时 flat 的 `primary/fallbacks` 折成一个 id 为
|
|
274
|
+
* `default` 的组 —— 老配置不写 groups 也能继续用。
|
|
275
|
+
*
|
|
276
|
+
* @param {object|undefined|null} routing - settings.routing 段。
|
|
277
|
+
* @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
|
|
278
|
+
*/
|
|
279
|
+
export function parseRoutingGroups(routing) {
|
|
280
|
+
const r = routing !== null && typeof routing === 'object' ? routing : {}
|
|
281
|
+
const mode = r.mode === 'smart' ? 'smart' : 'manual'
|
|
282
|
+
const groups = []
|
|
283
|
+
const seen = new Set()
|
|
284
|
+
for (const raw of Array.isArray(r.groups) ? r.groups : []) {
|
|
285
|
+
if (raw === null || typeof raw !== 'object') continue
|
|
286
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : ''
|
|
287
|
+
if (id === '' || seen.has(id)) continue
|
|
288
|
+
const primary = parseRouteEntryPure(raw.primary) ?? null
|
|
289
|
+
const fallbacks = (Array.isArray(raw.fallbacks) ? raw.fallbacks : [])
|
|
290
|
+
.map(parseRouteEntryPure)
|
|
291
|
+
.filter((entry) => entry !== undefined)
|
|
292
|
+
if (primary === null && fallbacks.length === 0) continue
|
|
293
|
+
seen.add(id)
|
|
294
|
+
groups.push({
|
|
295
|
+
id,
|
|
296
|
+
label: typeof raw.label === 'string' && raw.label.trim() !== '' ? raw.label.trim() : null,
|
|
297
|
+
primary,
|
|
298
|
+
fallbacks
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
if (groups.length === 0) {
|
|
302
|
+
// legacy 形态:flat primary + fallbacks 折成 default 组。
|
|
303
|
+
const primary = parseRouteEntryPure(r.primary) ?? null
|
|
304
|
+
const fallbacks = (Array.isArray(r.fallbacks) ? r.fallbacks : [])
|
|
305
|
+
.map(parseRouteEntryPure)
|
|
306
|
+
.filter((entry) => entry !== undefined)
|
|
307
|
+
if (primary !== null || fallbacks.length > 0) {
|
|
308
|
+
groups.push({ id: 'default', label: null, primary, fallbacks })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const declared = typeof r.activeGroup === 'string' ? r.activeGroup.trim() : ''
|
|
312
|
+
const activeGroup = groups.some((group) => group.id === declared) ? declared : (groups[0]?.id ?? '')
|
|
313
|
+
return { groups, mode, activeGroup }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 智能路由裁决(真源实现,waterfall 与测试共用)。
|
|
318
|
+
*
|
|
319
|
+
* 语义:按组内「primary → fallbacks」顺序,取第一个**未探明不可用**的候选;
|
|
320
|
+
* 额度耗尽(402)/ 网关探测失败都会在可用性表里标 `unavailable`,于是被跳过
|
|
321
|
+
* —— 这就是「按额度自动切换」。全不可用时不动原配置(让请求照常打出去、
|
|
322
|
+
* 错误可见),并给 reason。
|
|
323
|
+
*
|
|
324
|
+
* @param {Array<{ provider: string, model: string }>} entries - 组内候选,primary 在前。
|
|
325
|
+
* @param {(provider: string) => string} stateFor - provider 级可用状态:'available' | 'unknown' | 'unavailable'。
|
|
326
|
+
* @param {{ provider: string, model: string } | null} active - 本次请求机器本来要用的配置。
|
|
327
|
+
* @returns {{ to: { provider: string, model: string } | null, reason: string | null, switched: boolean }}
|
|
328
|
+
*/
|
|
329
|
+
export function pickSmartRoute(entries, stateFor, active) {
|
|
330
|
+
const list = Array.isArray(entries) ? entries : []
|
|
331
|
+
if (list.length === 0) {
|
|
332
|
+
return { to: null, reason: '当前组没有可用候选 —— 去设置里加 primary / fallbacks', switched: false }
|
|
333
|
+
}
|
|
334
|
+
const winner = list.find((entry) => stateFor(entry.provider) !== 'unavailable')
|
|
335
|
+
if (winner === undefined) {
|
|
336
|
+
return { to: null, reason: '主力与 fallbacks 全部不可用 —— 充值或换一个再说', switched: false }
|
|
337
|
+
}
|
|
338
|
+
const activeKey = active === null || active === undefined ? null : `${active.provider}/${active.model}`
|
|
339
|
+
const winnerKey = `${winner.provider}/${winner.model}`
|
|
340
|
+
if (winnerKey === activeKey) {
|
|
341
|
+
return { to: null, reason: null, switched: false }
|
|
342
|
+
}
|
|
343
|
+
return { to: { provider: winner.provider, model: winner.model }, reason: null, switched: true }
|
|
344
|
+
}
|
|
345
|
+
|
|
199
346
|
/** 路由配置从 settings 哪一段读 —— 顶层 dsh-llm-hub 段下,与 warning 同级。 */
|
|
200
347
|
const ROUTING_KEY = 'routing'
|
|
201
348
|
/** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
|
|
@@ -385,8 +532,62 @@ export const inject = ['llm', 'webServer']
|
|
|
385
532
|
*/
|
|
386
533
|
export function apply(ctx) {
|
|
387
534
|
|
|
535
|
+
// ── 插件自有 settings 命名空间(dsh-llm-hub)────────────────────────────
|
|
536
|
+
//
|
|
537
|
+
// 1.5.0 起:routing / warning / aliases 的真源从 `llm-deepseek` 段迁到独立
|
|
538
|
+
// 的 `dsh-llm-hub` 段 —— README 文档里写的顶层 `dsh-llm-hub:` 键此前**从未
|
|
539
|
+
// 被读到过**(没注册过命名空间,settings.get 会抛),这是 1.1.0 起的历史
|
|
540
|
+
// 缺陷,这里一起修掉。
|
|
541
|
+
//
|
|
542
|
+
// schema 尽量懒加载 `@deepseek-ai/schemastery`;失败时退回 `Object` 构造器
|
|
543
|
+
// (schemastery 接受原生构造器作为 schema)。注册失败不 fatal:配置读取退
|
|
544
|
+
// 回 legacy 路径,智能路由降级为「只建议」。
|
|
545
|
+
let hubRegistered = false
|
|
546
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
547
|
+
void (async () => {
|
|
548
|
+
let schema
|
|
549
|
+
try {
|
|
550
|
+
const mod = await import('@deepseek-ai/schemastery')
|
|
551
|
+
const z = (mod && typeof mod === 'object' ? mod.default ?? mod : mod)
|
|
552
|
+
const groupItem = z.object({ id: z.string().required(), label: z.string(), primary: z.string(), fallbacks: z.array(z.string()) })
|
|
553
|
+
schema = z.object({
|
|
554
|
+
routing: z.object({
|
|
555
|
+
mode: z.union(['manual', 'smart']).default('manual'),
|
|
556
|
+
activeGroup: z.string().default(''),
|
|
557
|
+
groups: z.array(groupItem).default([])
|
|
558
|
+
}).default({}),
|
|
559
|
+
warning: z.object({ cashCNY: z.number().default(10), planPercent: z.number().default(10) }).default({}),
|
|
560
|
+
aliases: z.dict(z.string()).default({})
|
|
561
|
+
})
|
|
562
|
+
} catch {
|
|
563
|
+
// 零依赖环境:schemastery 没解析到 —— 用原生构造器兜底。
|
|
564
|
+
schema = Object
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
settingsCtx.settings.register(HUB_NS, schema)
|
|
568
|
+
hubRegistered = true
|
|
569
|
+
} catch (error) {
|
|
570
|
+
ctx.logger.warn(`dsh-llm-hub: settings ns "dsh-llm-hub" register failed: ${error?.message ?? error}`)
|
|
571
|
+
}
|
|
572
|
+
})()
|
|
573
|
+
})
|
|
574
|
+
|
|
388
575
|
/**
|
|
389
|
-
*
|
|
576
|
+
* 读 `dsh-llm-hub` 设置段(1.5.0 起的真源)。
|
|
577
|
+
* @returns 已解析设置段;未注册 / 未配置时为 `undefined`。
|
|
578
|
+
*/
|
|
579
|
+
const hubSectionOf = () => {
|
|
580
|
+
const settings = ctx.get('settings')
|
|
581
|
+
if (settings === undefined) return undefined
|
|
582
|
+
try {
|
|
583
|
+
const value = settings.get(HUB_NS)
|
|
584
|
+
return value !== null && typeof value === 'object' ? value : undefined
|
|
585
|
+
} catch {
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** 当前已解析的 `llm-deepseek` 设置段。
|
|
390
591
|
*
|
|
391
592
|
* 每次调用惰性重读:apply 时该段可能尚未注册,而适配器本身也按请求重解析
|
|
392
593
|
* 连接事实 —— 惰性读才能让两边看同一份修订。
|
|
@@ -423,8 +624,11 @@ export function apply(ctx) {
|
|
|
423
624
|
* @returns 阈值表,未配置时返回默认值。
|
|
424
625
|
*/
|
|
425
626
|
const warningThresholds = () => {
|
|
426
|
-
|
|
427
|
-
const
|
|
627
|
+
// 真源是 dsh-llm-hub 段(1.5.0 注册);legacy llm-deepseek 段只补缺。
|
|
628
|
+
const hub = hubSectionOf() ?? {}
|
|
629
|
+
const legacy = sectionOf() ?? {}
|
|
630
|
+
const merged = { ...(legacy.warning !== null && typeof legacy.warning === 'object' ? legacy.warning : {}), ...(hub.warning !== null && typeof hub.warning === 'object' ? hub.warning : {}) }
|
|
631
|
+
const warning = merged
|
|
428
632
|
const readNumber = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
|
|
429
633
|
return {
|
|
430
634
|
cashCNY: readNumber(warning.cashCNY, 10),
|
|
@@ -522,56 +726,23 @@ export function apply(ctx) {
|
|
|
522
726
|
/**
|
|
523
727
|
* 读 `settings.dsh-llm-hub.keyPool[provider]` 段。
|
|
524
728
|
*
|
|
525
|
-
* 每条 entry 是 `{ name, env }` —— name 是人话(debug 用),env 是
|
|
526
|
-
* 凭据服务引用的环境变量名;后者用 `process.env[ref]` 直接读,**不**走
|
|
527
|
-
* credentials 服务,因为这些 key 多半不会进凭据库(轮换场景通常是
|
|
528
|
-
* 测试 / 临时 key,手贴环境变量更简单)。
|
|
529
|
-
*
|
|
530
|
-
* @returns 规范化后的 entry 数组(缺 name 或 env 的过滤掉);没配或非数组返 `undefined`。
|
|
531
|
-
*/
|
|
532
|
-
const keyPoolOf = (provider) => {
|
|
533
|
-
const section = sectionOf() ?? {}
|
|
534
|
-
const raw = section.keyPool
|
|
535
|
-
if (raw === null || typeof raw !== 'object') return undefined
|
|
536
|
-
const entries = raw[provider]
|
|
537
|
-
if (!Array.isArray(entries)) return undefined
|
|
538
|
-
const out = []
|
|
539
|
-
for (const entry of entries) {
|
|
540
|
-
if (entry === null || typeof entry !== 'object') continue
|
|
541
|
-
const name = typeof entry.name === 'string' && entry.name.trim().length > 0 ? entry.name.trim() : undefined
|
|
542
|
-
const env = typeof entry.env === 'string' && entry.env.trim().length > 0 ? entry.env.trim() : undefined
|
|
543
|
-
if (env === undefined) continue
|
|
544
|
-
out.push({ name: name ?? env, env })
|
|
545
|
-
}
|
|
546
|
-
return out.length > 0 ? out : undefined
|
|
547
|
-
}
|
|
548
|
-
|
|
549
729
|
/**
|
|
550
|
-
*
|
|
551
|
-
* @
|
|
730
|
+
* 多 Key 自动轮换索引 —— per provider 维护独立的自增游标。
|
|
731
|
+
* @type {Map<string, number>}
|
|
552
732
|
*/
|
|
553
|
-
const
|
|
554
|
-
const entries = keyPoolOf(provider)
|
|
555
|
-
if (entries === undefined) return undefined
|
|
556
|
-
const current = keyPoolIndex.current.get(provider) ?? 0
|
|
557
|
-
const index = current % entries.length
|
|
558
|
-
keyPoolIndex.current.set(provider, (current + 1) % entries.length)
|
|
559
|
-
const entry = entries[index]
|
|
560
|
-
keyPoolLastChosen.current.set(provider, { entry, index, at: Date.now() })
|
|
561
|
-
return { entry, index }
|
|
562
|
-
}
|
|
733
|
+
const keyRotationIndex = new Map()
|
|
563
734
|
|
|
564
735
|
/**
|
|
565
736
|
* 解析本次调用要用的连接事实。
|
|
566
737
|
*
|
|
567
738
|
* 解析顺序(向后兼容):
|
|
568
739
|
* 1) request.apiKey 一次性覆盖 —— 跳过轮换、原样返回(不污染索引)
|
|
569
|
-
* 2)
|
|
570
|
-
*
|
|
740
|
+
* 2) section.apiKeyEnv 是 string[] → 按 provider 轮换取下一个 ref,
|
|
741
|
+
* 再走 credentials 服务或 process.env 解析密钥
|
|
742
|
+
* 3) section.apiKeyEnv 是 string → 原单 key 路径(行为完全不变)
|
|
571
743
|
*
|
|
572
|
-
* 返 ref 不变 ——
|
|
573
|
-
*
|
|
574
|
-
* 看到当前用的是哪个 env。
|
|
744
|
+
* 返 ref 不变 —— 客户端拿到的是凭据引用;下游诊断(探针 / 余额路由)
|
|
745
|
+
* 能继续看到当前用的是哪个 env。
|
|
575
746
|
* @param request - 可选的一次性覆盖(发现草稿会带 baseURL / apiKey / provider)。
|
|
576
747
|
* @returns baseURL、凭据引用与可用密钥。
|
|
577
748
|
*/
|
|
@@ -590,14 +761,10 @@ export function apply(ctx) {
|
|
|
590
761
|
return { baseURL, ref, apiKey: explicit }
|
|
591
762
|
}
|
|
592
763
|
const providerKey = label(request.provider) ?? DEEPSEEK_PROVIDER
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
// 配了 keyPool 但 env var 没值 —— 退回旧单 key 路径,不让 keyPool
|
|
598
|
-
// 静默吞掉请求。
|
|
599
|
-
}
|
|
600
|
-
const ref = label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV
|
|
764
|
+
const picked = pickRotatedEnv(section.apiKeyEnv, providerKey, keyRotationIndex)
|
|
765
|
+
const ref = picked === undefined
|
|
766
|
+
? (label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV)
|
|
767
|
+
: picked.ref
|
|
601
768
|
let apiKey
|
|
602
769
|
const credentials = ctx.get('credentials')
|
|
603
770
|
if (credentials !== undefined) {
|
|
@@ -705,6 +872,37 @@ export function apply(ctx) {
|
|
|
705
872
|
return
|
|
706
873
|
}
|
|
707
874
|
try {
|
|
875
|
+
const section = sectionOf() ?? {}
|
|
876
|
+
const refs = Array.isArray(section.apiKeyEnv)
|
|
877
|
+
? section.apiKeyEnv.filter((r) => typeof r === 'string' && r.trim().length > 0)
|
|
878
|
+
: (typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.trim().length > 0
|
|
879
|
+
? [section.apiKeyEnv.trim()]
|
|
880
|
+
: [DEFAULT_API_KEY_ENV])
|
|
881
|
+
const credentials = ctx.get('credentials')
|
|
882
|
+
const keys = await Promise.all(refs.map(async (ref) => {
|
|
883
|
+
let apiKey
|
|
884
|
+
if (credentials !== undefined) {
|
|
885
|
+
const hit = await credentials.resolve(ref)
|
|
886
|
+
apiKey = label(hit?.value)
|
|
887
|
+
}
|
|
888
|
+
if (apiKey === undefined) apiKey = label(process.env[ref])
|
|
889
|
+
if (!apiKey) return { ref, keyMasked: '未配置', isAvailable: false, total: '0.00', error: '未找到密钥', ok: false }
|
|
890
|
+
try {
|
|
891
|
+
const raw = await getJson('/user/balance', { apiKey })
|
|
892
|
+
const shaped = shapeBalance(raw)
|
|
893
|
+
const first = shaped.balances?.[0]
|
|
894
|
+
return {
|
|
895
|
+
ref,
|
|
896
|
+
keyMasked: maskKey(apiKey),
|
|
897
|
+
isAvailable: shaped.isAvailable,
|
|
898
|
+
total: first?.total ?? '0.00',
|
|
899
|
+
currency: first?.currency ?? 'CNY',
|
|
900
|
+
ok: true
|
|
901
|
+
}
|
|
902
|
+
} catch (e) {
|
|
903
|
+
return { ref, keyMasked: maskKey(apiKey), isAvailable: false, total: '0.00', error: e instanceof Error ? e.message : String(e), ok: false }
|
|
904
|
+
}
|
|
905
|
+
}))
|
|
708
906
|
const { apiKey } = await connectionFacts()
|
|
709
907
|
const payload = shapeBalance(await getJson('/user/balance'))
|
|
710
908
|
if (request.method === 'HEAD') {
|
|
@@ -712,12 +910,67 @@ export function apply(ctx) {
|
|
|
712
910
|
response.end()
|
|
713
911
|
return
|
|
714
912
|
}
|
|
715
|
-
send(200, {
|
|
913
|
+
send(200, {
|
|
914
|
+
ok: true,
|
|
915
|
+
apiKey: apiKey ?? null,
|
|
916
|
+
keyMasked: maskKey(apiKey),
|
|
917
|
+
billingMode: section.billingMode || 'usage',
|
|
918
|
+
keys,
|
|
919
|
+
...payload
|
|
920
|
+
})
|
|
716
921
|
} catch (error) {
|
|
717
922
|
send(502, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
718
923
|
}
|
|
719
924
|
}
|
|
720
925
|
}), 'dsh-llm-hub: balance route')
|
|
926
|
+
|
|
927
|
+
ctx.effect(() => ctx.webServer.register({
|
|
928
|
+
kind: 'exact',
|
|
929
|
+
path: BILLING_MODE_PATH,
|
|
930
|
+
handler: async (request, response) => {
|
|
931
|
+
const send = (status, payload) => {
|
|
932
|
+
response.writeHead(status, {
|
|
933
|
+
'content-type': 'application/json; charset=utf-8',
|
|
934
|
+
'cache-control': 'no-store'
|
|
935
|
+
})
|
|
936
|
+
response.end(JSON.stringify(payload))
|
|
937
|
+
}
|
|
938
|
+
if (request.method !== 'POST') {
|
|
939
|
+
response.writeHead(405, { allow: 'POST' })
|
|
940
|
+
response.end()
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
if (!sameOrigin(request)) {
|
|
944
|
+
send(403, { ok: false, error: 'cross-origin requests are refused' })
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
const collected = []
|
|
949
|
+
for await (const chunk of request) collected.push(chunk)
|
|
950
|
+
const body = JSON.parse(Buffer.concat(collected).toString('utf8'))
|
|
951
|
+
const provider = label(body?.provider)
|
|
952
|
+
const mode = label(body?.mode)
|
|
953
|
+
if (mode !== 'usage' && mode !== 'subscription') {
|
|
954
|
+
send(400, { ok: false, error: 'mode must be "usage" or "subscription"' })
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
const settings = ctx.get('settings')
|
|
958
|
+
if (!settings) {
|
|
959
|
+
send(500, { ok: false, error: 'settings service not available' })
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
if (provider === 'deepseek' || provider === 'llm-deepseek' || provider === 'deepseek-official') {
|
|
963
|
+
await settings.update('llm-deepseek', { billingMode: mode })
|
|
964
|
+
} else if (provider) {
|
|
965
|
+
const profile = piaiProfileOf(provider) ?? {}
|
|
966
|
+
await settings.update(PIAI_NS, { providers: { [provider]: { ...profile, billingMode: mode } } })
|
|
967
|
+
}
|
|
968
|
+
send(200, { ok: true, provider, mode })
|
|
969
|
+
} catch (error) {
|
|
970
|
+
send(500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}), 'dsh-llm-hub: set billing mode route')
|
|
721
974
|
//#endregion
|
|
722
975
|
|
|
723
976
|
//#region 3) pi-ai 旁路补缺
|
|
@@ -891,6 +1144,7 @@ export function apply(ctx) {
|
|
|
891
1144
|
// 已配置的 id 列表:前端拉到网关目录后要据此把「已经在用的」默认勾上,
|
|
892
1145
|
// 光有数量做不到这件事。
|
|
893
1146
|
modelIds: models.map((m) => (m !== null && typeof m === 'object' ? m.id : m)).filter((id) => typeof id === 'string'),
|
|
1147
|
+
billingMode: label(profile.billingMode) ?? null,
|
|
894
1148
|
balanceAdapter: balanceAdapterFor(provider, profile) ?? null
|
|
895
1149
|
}
|
|
896
1150
|
}
|
|
@@ -1245,12 +1499,14 @@ export function apply(ctx) {
|
|
|
1245
1499
|
// 比多显示一个不能用的更糟:前者让人以为插件坏了。
|
|
1246
1500
|
// · 服务端只管判定;「从下拉里摘掉」在 client 半做(目录无法在 host 侧过滤)。
|
|
1247
1501
|
|
|
1502
|
+
/** 可用性与健康状态机实例 */
|
|
1503
|
+
const availabilityStore = new AvailabilityStore({ runtimeTtlMs: RUNTIME_TTL_MS })
|
|
1248
1504
|
/** provider -> 探针判定(凭据 / 探测 / 余额)。 */
|
|
1249
|
-
const baseVerdicts =
|
|
1505
|
+
const baseVerdicts = availabilityStore.baseVerdicts
|
|
1250
1506
|
/** provider -> 最近一次运行期失败判定(真实请求的鉴权/欠费错误)。 */
|
|
1251
|
-
const runtimeMarks =
|
|
1507
|
+
const runtimeMarks = availabilityStore.runtimeMarks
|
|
1252
1508
|
/** provider -> 最近一次真实请求成功的时刻。 */
|
|
1253
|
-
const successAt =
|
|
1509
|
+
const successAt = availabilityStore.successAt
|
|
1254
1510
|
/** 全量重探的去重句柄。 */
|
|
1255
1511
|
let probeAll = null
|
|
1256
1512
|
/** 探针代际:手动重探会 +1,让在途的旧探针彻底作废。 */
|
|
@@ -1464,79 +1720,11 @@ export function apply(ctx) {
|
|
|
1464
1720
|
refreshAvailability(stuck).catch(() => {})
|
|
1465
1721
|
}
|
|
1466
1722
|
|
|
1467
|
-
/** 把真实请求的失败/成功折成运行期标记。 */
|
|
1468
|
-
const classifyFailure = (failure) => {
|
|
1469
|
-
const code = typeof failure?.code === 'string' ? failure.code : ''
|
|
1470
|
-
const status = typeof failure?.status === 'number' ? failure.status : undefined
|
|
1471
|
-
// MISSING_CREDENTIAL 也归这一类:pi-ai 解析不到 apiKeyEnv 时抛的就是它,
|
|
1472
|
-
// 与「key 无效」对使用者的意义一样 —— 这条路由现在调不了。
|
|
1473
|
-
if (code === 'INVALID_CREDENTIAL' || code === 'MISSING_CREDENTIAL' || status === 401 || status === 403) {
|
|
1474
|
-
return { code: 'AUTH_REJECTED', reason: '最近一次请求被拒(API key 缺失、无效或已过期)' }
|
|
1475
|
-
}
|
|
1476
|
-
if (code === 'QUOTA_EXCEEDED' || status === 402) {
|
|
1477
|
-
return { code: 'QUOTA_EXHAUSTED', reason: '最近一次请求因余额/配额耗尽失败' }
|
|
1478
|
-
}
|
|
1479
|
-
return undefined
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
1723
|
/** 汇总:运行期标记优先于探针判定,真实成功又覆盖运行期失败。 */
|
|
1483
|
-
const currentAvailability = () =>
|
|
1484
|
-
const now = Date.now()
|
|
1485
|
-
const merged = new Map()
|
|
1486
|
-
for (const [id, verdict] of baseVerdicts) merged.set(id, verdict)
|
|
1487
|
-
for (const [id, mark] of [...runtimeMarks]) {
|
|
1488
|
-
if (now - mark.at >= RUNTIME_TTL_MS) {
|
|
1489
|
-
runtimeMarks.delete(id)
|
|
1490
|
-
continue
|
|
1491
|
-
}
|
|
1492
|
-
if ((successAt.get(id) ?? 0) > mark.at) continue
|
|
1493
|
-
const base = merged.get(id)
|
|
1494
|
-
merged.set(id, {
|
|
1495
|
-
provider: id,
|
|
1496
|
-
displayName: base?.displayName ?? id,
|
|
1497
|
-
ns: base?.ns ?? PIAI_NS,
|
|
1498
|
-
state: 'unavailable',
|
|
1499
|
-
code: mark.code,
|
|
1500
|
-
reason: mark.reason,
|
|
1501
|
-
source: 'runtime',
|
|
1502
|
-
checkedAt: mark.at
|
|
1503
|
-
})
|
|
1504
|
-
}
|
|
1505
|
-
for (const [id, at] of successAt) {
|
|
1506
|
-
const base = merged.get(id)
|
|
1507
|
-
if (base === undefined || base.state === 'available') continue
|
|
1508
|
-
if (now - at >= RUNTIME_TTL_MS || at <= base.checkedAt) continue
|
|
1509
|
-
merged.set(id, { ...base, state: 'available', code: 'RECOVERED', reason: '', source: 'runtime', checkedAt: at })
|
|
1510
|
-
}
|
|
1511
|
-
return [...merged.values()].sort((left, right) => left.provider.localeCompare(right.provider))
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
/**
|
|
1515
|
-
* 判定是否属于应从模型下拉彻底摘掉的严重凭据故障。
|
|
1516
|
-
*
|
|
1517
|
-
* ⚠️ 设计原则(避免死锁):
|
|
1518
|
-
* 只有确凿的凭据故障(CREDENTIAL_MISSING / AUTH_REJECTED / ACCOUNT_UNAVAILABLE)才从下拉框剔除。
|
|
1519
|
-
* 配额耗尽(QUOTA_EXHAUSTED)与现金余额为 0(BALANCE_EMPTY / PAYMENT_REQUIRED)属于临时限额/计费状态,
|
|
1520
|
-
* 绝不能从下拉框中剔除,否则会导致:
|
|
1521
|
-
* 1) 用户在界面上永久丢失模型入口;
|
|
1522
|
-
* 2) 5小时滚动配额或周配额恢复后,用户因无法选模型而无法触发运行期遥测恢复;
|
|
1523
|
-
* 3) 形成死锁。
|
|
1524
|
-
*/
|
|
1525
|
-
const isDropDeadHidden = (verdict) => {
|
|
1526
|
-
if (verdict === null || typeof verdict !== 'object') return false
|
|
1527
|
-
if (verdict.state !== 'unavailable') return false
|
|
1528
|
-
const code = verdict.code
|
|
1529
|
-
return code === 'CREDENTIAL_MISSING' || code === 'AUTH_REJECTED' || code === 'ACCOUNT_UNAVAILABLE'
|
|
1530
|
-
}
|
|
1724
|
+
const currentAvailability = () => availabilityStore.currentAvailability()
|
|
1531
1725
|
|
|
1532
1726
|
/** 当前该从下拉里摘掉的 provider 集合。 */
|
|
1533
|
-
const hiddenProviders = () =>
|
|
1534
|
-
const hidden = new Set()
|
|
1535
|
-
for (const verdict of currentAvailability()) {
|
|
1536
|
-
if (isDropDeadHidden(verdict)) hidden.add(verdict.provider)
|
|
1537
|
-
}
|
|
1538
|
-
return hidden
|
|
1539
|
-
}
|
|
1727
|
+
const hiddenProviders = () => availabilityStore.hiddenProviders()
|
|
1540
1728
|
|
|
1541
1729
|
const sameHidden = (left, right) => left.size === right.size && [...left].every((id) => right.has(id))
|
|
1542
1730
|
|
|
@@ -1701,12 +1889,33 @@ export function apply(ctx) {
|
|
|
1701
1889
|
return () => {}
|
|
1702
1890
|
}
|
|
1703
1891
|
const original = service.listProviders
|
|
1892
|
+
|
|
1893
|
+
const billingModeOf = (id) => {
|
|
1894
|
+
if (!id || typeof id !== 'string') return 'usage'
|
|
1895
|
+
const cleanId = id.replace(/^pi-ai:/, '')
|
|
1896
|
+
if (cleanId === 'deepseek-official' || cleanId === 'deepseek' || cleanId === 'llm-deepseek') {
|
|
1897
|
+
const section = sectionOf()
|
|
1898
|
+
if (section?.billingMode === 'usage' || section?.billingMode === 'subscription') {
|
|
1899
|
+
return section.billingMode
|
|
1900
|
+
}
|
|
1901
|
+
return 'usage'
|
|
1902
|
+
}
|
|
1903
|
+
const profile = piaiProfileOf(cleanId) || piaiProfileOf(id)
|
|
1904
|
+
if (profile?.billingMode === 'usage' || profile?.billingMode === 'subscription') {
|
|
1905
|
+
return profile.billingMode
|
|
1906
|
+
}
|
|
1907
|
+
return detectBillingMode(profile || { provider: cleanId })
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1704
1910
|
const filtered = function (...args) {
|
|
1705
1911
|
const providers = original.apply(this, args)
|
|
1706
1912
|
if (!Array.isArray(providers)) return providers
|
|
1707
|
-
const hidden = hiddenProviders()
|
|
1708
|
-
|
|
1709
|
-
return providers
|
|
1913
|
+
const hidden = availabilityStore.hiddenProviders()
|
|
1914
|
+
// 用户要求:LLM 下拉列表优先使用套餐额度,套餐排在上面,按量付费排在下面,从上往下选最容易先选到套餐。
|
|
1915
|
+
return sortProviders(providers, (id) => ({
|
|
1916
|
+
available: !hidden.has(id),
|
|
1917
|
+
billingMode: billingModeOf(id)
|
|
1918
|
+
}), { filterUnavailable: true })
|
|
1710
1919
|
}
|
|
1711
1920
|
// ⚠️ 不要用 `service.listProviders !== filtered` 校验「写进去了没有」:
|
|
1712
1921
|
// cordis 的服务访问返回 traceable 代理,每次读方法都是一个新的包装对象,永远不相等。
|
|
@@ -1862,10 +2071,6 @@ export function apply(ctx) {
|
|
|
1862
2071
|
// 对照官方价目表,CSV 导出(`GET /api/dsh-llm-hub/usage/export`)已经做了。
|
|
1863
2072
|
const usageTable = { current: null }
|
|
1864
2073
|
const usageReady = { current: false }
|
|
1865
|
-
// 多账号 key 轮换:每个 provider 一个 round-robin 索引。重启后归零,跨 session
|
|
1866
|
-
// 不持久化 —— 失败的 key 不会自动跳过,下次自然轮换过去(看 debug 路由查清)。
|
|
1867
|
-
const keyPoolIndex = { current: new Map() }
|
|
1868
|
-
const keyPoolLastChosen = { current: new Map() }
|
|
1869
2074
|
const recordUsage = (entry) => {
|
|
1870
2075
|
const table = usageTable.current
|
|
1871
2076
|
if (table === null) return
|
|
@@ -2214,36 +2419,43 @@ export function apply(ctx) {
|
|
|
2214
2419
|
const ROUTE_MODEL_RE = /^[a-z0-9][a-z0-9_.+-]*$/i
|
|
2215
2420
|
|
|
2216
2421
|
/**
|
|
2217
|
-
* 解析「provider/model
|
|
2422
|
+
* 解析「provider/model」字符串 —— 委托给模块级真源 `parseRouteEntryPure`。
|
|
2218
2423
|
* @param entry - 配置里的一行。
|
|
2219
2424
|
* @returns 解析后的 { provider, model },无效时为 `undefined`。
|
|
2220
2425
|
*/
|
|
2221
|
-
const parseRouteEntry = (entry) =>
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2426
|
+
const parseRouteEntry = (entry) => parseRouteEntryPure(entry)
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* 合并读 settings 的 `routing` 段:真源 `dsh-llm-hub`(1.5.0 注册),
|
|
2430
|
+
* legacy `llm-deepseek` 段只补缺 —— 用户科研把配置写在新旧两处也能读。
|
|
2431
|
+
* @returns 合并后的 routing 对象(可能为空对象)。
|
|
2432
|
+
*/
|
|
2433
|
+
const routingObjOf = () => {
|
|
2434
|
+
const hub = hubSectionOf() ?? {}
|
|
2435
|
+
const legacy = sectionOf() ?? {}
|
|
2436
|
+
const hubRouting = hub.routing !== null && typeof hub.routing === 'object' ? hub.routing : {}
|
|
2437
|
+
const legacyRouting = legacy.routing !== null && typeof legacy.routing === 'object' ? legacy.routing : {}
|
|
2438
|
+
return { ...legacyRouting, ...hubRouting }
|
|
2230
2439
|
}
|
|
2231
2440
|
|
|
2232
2441
|
/**
|
|
2233
|
-
*
|
|
2234
|
-
* @returns
|
|
2442
|
+
* 读路由组(真源实现 `parseRoutingGroups`,settings 惰性重读)。
|
|
2443
|
+
* @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
|
|
2235
2444
|
*/
|
|
2236
|
-
const
|
|
2237
|
-
|
|
2238
|
-
|
|
2445
|
+
const routingGroupsOf = () => parseRoutingGroups(routingObjOf())
|
|
2446
|
+
|
|
2447
|
+
/**
|
|
2448
|
+
* legacy 候选列表(老 resolve 语义保留):当前激活组展开成
|
|
2449
|
+
* primary → fallbacks 顺序数组。
|
|
2450
|
+
* @returns 配置好的顺序数组,无效项过滤掉。空数组表示未配置。
|
|
2451
|
+
*/
|
|
2452
|
+
const routingCandidatesOf = (groupId) => {
|
|
2453
|
+
const state = routingGroupsOf()
|
|
2454
|
+
const group = state.groups.find((candidate) => candidate.id === groupId) ?? state.groups[0]
|
|
2455
|
+
if (group === undefined) return []
|
|
2239
2456
|
const out = []
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
const fallbacks = Array.isArray(routing.fallbacks) ? routing.fallbacks : []
|
|
2243
|
-
for (const fallback of fallbacks) {
|
|
2244
|
-
const parsed = parseRouteEntry(fallback)
|
|
2245
|
-
if (parsed !== undefined) out.push(parsed)
|
|
2246
|
-
}
|
|
2457
|
+
if (group.primary !== null) out.push(group.primary)
|
|
2458
|
+
for (const fallback of group.fallbacks) out.push(fallback)
|
|
2247
2459
|
return out
|
|
2248
2460
|
}
|
|
2249
2461
|
|
|
@@ -2262,12 +2474,15 @@ export function apply(ctx) {
|
|
|
2262
2474
|
}
|
|
2263
2475
|
|
|
2264
2476
|
/**
|
|
2265
|
-
* 路由解析:给客户端返回「该用哪个 +
|
|
2477
|
+
* 路由解析:给客户端返回「该用哪个 + 原因」(1.5.0 起按组解析)。
|
|
2266
2478
|
* @param active - 客户端 POST 进来的当前已激活 (provider, model)。
|
|
2267
|
-
* @
|
|
2479
|
+
* @param groupId - 指定组 id;缺省用当前激活组。
|
|
2480
|
+
* @returns 路由建议载荷(附 mode / activeGroup / groups 摘要)。
|
|
2268
2481
|
*/
|
|
2269
|
-
const resolveRoute = (active) => {
|
|
2270
|
-
const
|
|
2482
|
+
const resolveRoute = (active, groupId) => {
|
|
2483
|
+
const state = routingGroupsOf()
|
|
2484
|
+
const candidates = routingCandidatesOf(typeof groupId === 'string' && groupId !== '' ? groupId : undefined)
|
|
2485
|
+
const activeGroup = state.groups.some((group) => group.id === groupId) ? groupId : state.activeGroup
|
|
2271
2486
|
const verdicts = new Map()
|
|
2272
2487
|
for (const candidate of candidates) {
|
|
2273
2488
|
verdicts.set(`${candidate.provider}/${candidate.model}`, stateFor(candidate.provider, candidate.model))
|
|
@@ -2283,6 +2498,13 @@ export function apply(ctx) {
|
|
|
2283
2498
|
: (recommendation === undefined ? '主力与 fallbacks 全部不可用 —— 充值或换一个再说' : null)
|
|
2284
2499
|
return {
|
|
2285
2500
|
ok: true,
|
|
2501
|
+
mode: state.mode,
|
|
2502
|
+
activeGroup,
|
|
2503
|
+
groups: state.groups.map((group) => ({
|
|
2504
|
+
id: group.id,
|
|
2505
|
+
label: group.label,
|
|
2506
|
+
candidateCount: (group.primary !== null ? 1 : 0) + group.fallbacks.length
|
|
2507
|
+
})),
|
|
2286
2508
|
active: activeParsed === null
|
|
2287
2509
|
? null
|
|
2288
2510
|
: { provider: activeParsed.provider, model: activeParsed.model, state: activeState },
|
|
@@ -2318,10 +2540,174 @@ export function apply(ctx) {
|
|
|
2318
2540
|
sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
|
|
2319
2541
|
return
|
|
2320
2542
|
}
|
|
2321
|
-
sendJson(response, 200, resolveRoute(payload?.active))
|
|
2543
|
+
sendJson(response, 200, resolveRoute(payload?.active, typeof payload?.group === 'string' ? payload.group : undefined))
|
|
2322
2544
|
}
|
|
2323
2545
|
}), 'dsh-llm-hub: routing resolve route')
|
|
2324
2546
|
|
|
2547
|
+
// ── 智能路由组 + 模式接管(1.5.0)────────────────────────────────────────
|
|
2548
|
+
//
|
|
2549
|
+
// 上面的 resolveRoute 是「建议」;这一段是「接管」:
|
|
2550
|
+
//
|
|
2551
|
+
// · 配置(settings.dsh-llm-hub.routing):mode = manual|smart、activeGroup、
|
|
2552
|
+
// groups[] —— 每组 primary + fallbacks[],「公司」「个人」就是两个组。
|
|
2553
|
+
// · smart 模式:监听 `agent/request` waterfall,每次模型调用拿机器本要用
|
|
2554
|
+
// 的 config,按激活组 primary → fallbacks 找第一个未探明不可用的候选,
|
|
2555
|
+
// 直接替换 provider/model —— **完全接管**,composer 下拉显示什么不再
|
|
2556
|
+
// 决定实际路由(额度耗尽 = 探测 unavailable = 被跳过,这就是「按额度
|
|
2557
|
+
// 自动切换」)。waterfall 是官方请求改写点(«Replace the frozen call
|
|
2558
|
+
// configuration»),合法;README 里「不做自动切换」的旧红线只针对
|
|
2559
|
+
// composer slot 本身,这条不碰 slot。
|
|
2560
|
+
// · manual 模式:行为与 1.1.0 完全一致 —— 只建议,不改请求。
|
|
2561
|
+
// · 切组 / 切模式:POST /routing/select 持久化进 settings,重启不丢。
|
|
2562
|
+
//
|
|
2563
|
+
// 稳定状态:最近一次 smart 判断存 `lastSmartDecision`(内存即可,用于 chip
|
|
2564
|
+
// 展示「当前实际路由」)。
|
|
2565
|
+
const lastSmartDecision = { at: 0, group: null, from: null, to: null, reason: null }
|
|
2566
|
+
|
|
2567
|
+
/** smart 状态给前端(不带 settings 原文)。 */
|
|
2568
|
+
const smartStateOf = () => {
|
|
2569
|
+
const state = routingGroupsOf()
|
|
2570
|
+
const groups = state.groups.map((group) => {
|
|
2571
|
+
const candidates = [
|
|
2572
|
+
...(group.primary !== null ? [group.primary] : []),
|
|
2573
|
+
...group.fallbacks
|
|
2574
|
+
]
|
|
2575
|
+
const winner = pickSmartRoute(candidates, (provider) => stateFor(provider, ''), null)
|
|
2576
|
+
return {
|
|
2577
|
+
id: group.id,
|
|
2578
|
+
label: group.label,
|
|
2579
|
+
candidates: candidates.map((candidate) => ({
|
|
2580
|
+
provider: candidate.provider,
|
|
2581
|
+
model: candidate.model,
|
|
2582
|
+
state: stateFor(candidate.provider, candidate.model)
|
|
2583
|
+
})),
|
|
2584
|
+
wouldRoute: winner.to,
|
|
2585
|
+
reason: winner.reason
|
|
2586
|
+
}
|
|
2587
|
+
})
|
|
2588
|
+
return {
|
|
2589
|
+
ok: true,
|
|
2590
|
+
mode: state.mode,
|
|
2591
|
+
activeGroup: state.activeGroup,
|
|
2592
|
+
groups,
|
|
2593
|
+
lastDecision: lastSmartDecision.at > 0 ? {
|
|
2594
|
+
at: lastSmartDecision.at,
|
|
2595
|
+
group: lastSmartDecision.group,
|
|
2596
|
+
from: lastSmartDecision.from,
|
|
2597
|
+
to: lastSmartDecision.to,
|
|
2598
|
+
reason: lastSmartDecision.reason
|
|
2599
|
+
} : null
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
/** 读 settings 的 routing 段并按需覆写 mode / activeGroup,持久化。 */
|
|
2604
|
+
const applyRoutingSelect = async (patch) => {
|
|
2605
|
+
const settings = ctx.get('settings')
|
|
2606
|
+
if (settings === undefined || hubRegistered !== true) {
|
|
2607
|
+
return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
|
|
2608
|
+
}
|
|
2609
|
+
const current = routingObjOf()
|
|
2610
|
+
const merged = { ...current }
|
|
2611
|
+
if (patch.mode === 'manual' || patch.mode === 'smart') merged.mode = patch.mode
|
|
2612
|
+
if (typeof patch.activeGroup === 'string' && patch.activeGroup.trim() !== '') merged.activeGroup = patch.activeGroup.trim()
|
|
2613
|
+
try {
|
|
2614
|
+
await settings.update(HUB_NS, { routing: merged })
|
|
2615
|
+
return { ok: true, ...smartStateOf() }
|
|
2616
|
+
} catch (error) {
|
|
2617
|
+
return { ok: false, error: `写不进 settings(${error?.message ?? error})` }
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/** 读一个 POST body(JSON);非法返 null。 */
|
|
2622
|
+
const readPostBody = async (request) => {
|
|
2623
|
+
try {
|
|
2624
|
+
const collected = []
|
|
2625
|
+
for await (const chunk of request) collected.push(chunk)
|
|
2626
|
+
return JSON.parse(Buffer.concat(collected).toString('utf8'))
|
|
2627
|
+
} catch {
|
|
2628
|
+
return null
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2633
|
+
kind: 'exact',
|
|
2634
|
+
path: ROUTING_STATE_PATH,
|
|
2635
|
+
handler: (request, response) => {
|
|
2636
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
2637
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
2638
|
+
response.end()
|
|
2639
|
+
return
|
|
2640
|
+
}
|
|
2641
|
+
if (!sameOrigin(request)) {
|
|
2642
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
2643
|
+
return
|
|
2644
|
+
}
|
|
2645
|
+
if (request.method === 'HEAD') {
|
|
2646
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
2647
|
+
response.end()
|
|
2648
|
+
return
|
|
2649
|
+
}
|
|
2650
|
+
ensureAvailabilityFresh()
|
|
2651
|
+
sendJson(response, 200, smartStateOf())
|
|
2652
|
+
}
|
|
2653
|
+
}), 'dsh-llm-hub: routing state route')
|
|
2654
|
+
|
|
2655
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2656
|
+
kind: 'exact',
|
|
2657
|
+
path: ROUTING_SELECT_PATH,
|
|
2658
|
+
handler: async (request, response) => {
|
|
2659
|
+
if (request.method !== 'POST') {
|
|
2660
|
+
response.writeHead(405, { allow: 'POST' })
|
|
2661
|
+
response.end()
|
|
2662
|
+
return
|
|
2663
|
+
}
|
|
2664
|
+
if (!sameOrigin(request)) {
|
|
2665
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
|
|
2666
|
+
return
|
|
2667
|
+
}
|
|
2668
|
+
const payload = await readPostBody(request)
|
|
2669
|
+
if (payload === null) {
|
|
2670
|
+
sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
|
|
2671
|
+
return
|
|
2672
|
+
}
|
|
2673
|
+
const result = await applyRoutingSelect({
|
|
2674
|
+
mode: payload?.mode,
|
|
2675
|
+
activeGroup: payload?.activeGroup
|
|
2676
|
+
})
|
|
2677
|
+
sendJson(response, result.ok === true ? 200 : 503, result)
|
|
2678
|
+
}
|
|
2679
|
+
}), 'dsh-llm-hub: routing select route')
|
|
2680
|
+
|
|
2681
|
+
// smart 模式的请求接管。global: true —— 根上下文的监听要覆盖所有 agent
|
|
2682
|
+
// (含子代理),否则「自动按额度切换」只对当前会话生效。
|
|
2683
|
+
// 任何异常都吞掉并返回 next() 的原 config:路由接管必须 fail-open,不能
|
|
2684
|
+
// 因为分组配置写错了一行让会话发不出请求。
|
|
2685
|
+
ctx.on('agent/request', async (payload, next) => {
|
|
2686
|
+
const config = await next()
|
|
2687
|
+
try {
|
|
2688
|
+
if (config === null || typeof config !== 'object') return config
|
|
2689
|
+
const state = routingGroupsOf()
|
|
2690
|
+
if (state.mode !== 'smart' || state.groups.length === 0) return config
|
|
2691
|
+
const group = state.groups.find((candidate) => candidate.id === state.activeGroup) ?? state.groups[0]
|
|
2692
|
+
const candidates = [
|
|
2693
|
+
...(group.primary !== null ? [group.primary] : []),
|
|
2694
|
+
...group.fallbacks
|
|
2695
|
+
]
|
|
2696
|
+
const active = { provider: config.provider, model: config.model }
|
|
2697
|
+
const decision = pickSmartRoute(candidates, (provider) => stateFor(provider, ''), active)
|
|
2698
|
+
lastSmartDecision.at = Date.now()
|
|
2699
|
+
lastSmartDecision.group = group.id
|
|
2700
|
+
lastSmartDecision.from = active
|
|
2701
|
+
lastSmartDecision.to = decision.to
|
|
2702
|
+
lastSmartDecision.reason = decision.reason
|
|
2703
|
+
if (decision.switched !== true || decision.to === null) return config
|
|
2704
|
+
return { ...config, provider: decision.to.provider, model: decision.to.model }
|
|
2705
|
+
} catch (error) {
|
|
2706
|
+
ctx.logger.warn(`dsh-llm-hub: smart routing intercept failed, keeping original config: ${error?.message ?? error}`)
|
|
2707
|
+
return config
|
|
2708
|
+
}
|
|
2709
|
+
}, { global: true })
|
|
2710
|
+
|
|
2325
2711
|
// ── 模型别名 ──────────────────────────────────────────────────────────────
|
|
2326
2712
|
//
|
|
2327
2713
|
// settings.dsh-llm-hub.aliases:把长 model id 映射成短显示名,典型场景:
|
|
@@ -2344,8 +2730,13 @@ export function apply(ctx) {
|
|
|
2344
2730
|
* @returns 规范化后的别名表(空值 / 非法 key 全过滤掉)。
|
|
2345
2731
|
*/
|
|
2346
2732
|
const aliasesOf = () => {
|
|
2347
|
-
|
|
2348
|
-
const
|
|
2733
|
+
// 真源是 dsh-llm-hub 段(1.5.0 注册);legacy llm-deepseek 段只补缺。
|
|
2734
|
+
const hub = hubSectionOf() ?? {}
|
|
2735
|
+
const legacy = sectionOf() ?? {}
|
|
2736
|
+
const raw = {
|
|
2737
|
+
...(legacy.aliases !== null && typeof legacy.aliases === 'object' ? legacy.aliases : {}),
|
|
2738
|
+
...(hub.aliases !== null && typeof hub.aliases === 'object' ? hub.aliases : {})
|
|
2739
|
+
}
|
|
2349
2740
|
const out = {}
|
|
2350
2741
|
for (const [key, value] of Object.entries(raw)) {
|
|
2351
2742
|
if (!ALIAS_KEY_RE.test(key)) continue
|
|
@@ -2392,55 +2783,6 @@ export function apply(ctx) {
|
|
|
2392
2783
|
}
|
|
2393
2784
|
}), 'dsh-llm-hub: aliases route')
|
|
2394
2785
|
|
|
2395
|
-
/**
|
|
2396
|
-
* KeyPool 调试路由 —— 让 client 看到每个 provider 当前轮到了哪个 entry。
|
|
2397
|
-
* 不暴露 apiKey 实际值(只有 name + env 名);401 / 403 类失败时用户从
|
|
2398
|
-
* 这里看「轮到哪个 env 上次失败」,下次请求轮换过去。
|
|
2399
|
-
*/
|
|
2400
|
-
const keyPoolDebugPayload = () => {
|
|
2401
|
-
const section = sectionOf() ?? {}
|
|
2402
|
-
const raw = section.keyPool
|
|
2403
|
-
const pool = raw !== null && typeof raw === 'object' ? raw : {}
|
|
2404
|
-
const providers = []
|
|
2405
|
-
for (const [provider, entries] of Object.entries(pool)) {
|
|
2406
|
-
if (!Array.isArray(entries)) continue
|
|
2407
|
-
const lastChosen = keyPoolLastChosen.current.get(provider)
|
|
2408
|
-
providers.push({
|
|
2409
|
-
provider,
|
|
2410
|
-
total: entries.length,
|
|
2411
|
-
lastChosen: lastChosen === undefined ? null : {
|
|
2412
|
-
name: lastChosen.entry.name,
|
|
2413
|
-
env: lastChosen.entry.env,
|
|
2414
|
-
index: lastChosen.index,
|
|
2415
|
-
at: lastChosen.at
|
|
2416
|
-
},
|
|
2417
|
-
nextIndex: keyPoolIndex.current.get(provider) ?? 0
|
|
2418
|
-
})
|
|
2419
|
-
}
|
|
2420
|
-
return { ok: true, providers }
|
|
2421
|
-
}
|
|
2422
|
-
|
|
2423
|
-
ctx.effect(() => ctx.webServer.register({
|
|
2424
|
-
kind: 'exact',
|
|
2425
|
-
path: KEYPOOL_STATUS_PATH,
|
|
2426
|
-
handler: (request, response) => {
|
|
2427
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
2428
|
-
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
2429
|
-
response.end()
|
|
2430
|
-
return
|
|
2431
|
-
}
|
|
2432
|
-
if (!sameOrigin(request)) {
|
|
2433
|
-
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
2434
|
-
return
|
|
2435
|
-
}
|
|
2436
|
-
if (request.method === 'HEAD') {
|
|
2437
|
-
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
2438
|
-
response.end()
|
|
2439
|
-
return
|
|
2440
|
-
}
|
|
2441
|
-
sendJson(response, 200, keyPoolDebugPayload())
|
|
2442
|
-
}
|
|
2443
|
-
}), 'dsh-llm-hub: keypool status route')
|
|
2444
2786
|
|
|
2445
2787
|
// ── harness 子代理 ────────────────────────────────────────────────────────
|
|
2446
2788
|
|