@dsh-plugins/dsh-llm-hub 1.4.0 → 1.5.1
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 +131 -0
- package/assets/01-family-dock.svg +79 -0
- package/assets/02-billing-capsule.svg +72 -0
- package/assets/03-model-dropdown-priorities.svg +76 -0
- package/lib/availability-store.js +168 -0
- package/lib/client.js +330 -11
- package/lib/index.js +536 -109
- package/lib/sorter.js +106 -0
- package/package.json +2 -1
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,6 +183,22 @@ 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
|
/**
|
|
@@ -201,6 +223,126 @@ export function pickRotatedEnv(refs, provider, stateMap) {
|
|
|
201
223
|
}
|
|
202
224
|
return undefined
|
|
203
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,所以这里留正源)。
|
|
239
|
+
*
|
|
240
|
+
* provider 严格(字母数字与连字符/下划线),model 放宽(允许 `.` `+` —— DSH
|
|
241
|
+
* 真实 id 会含它们),缺斜杠 / 空段 / 非法字符一律 undefined。
|
|
242
|
+
* @param {string} entry - 配置里的一行。
|
|
243
|
+
* @returns {{ provider: string, model: string } | undefined}
|
|
244
|
+
*/
|
|
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
|
+
|
|
204
346
|
/** 路由配置从 settings 哪一段读 —— 顶层 dsh-llm-hub 段下,与 warning 同级。 */
|
|
205
347
|
const ROUTING_KEY = 'routing'
|
|
206
348
|
/** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
|
|
@@ -390,8 +532,62 @@ export const inject = ['llm', 'webServer']
|
|
|
390
532
|
*/
|
|
391
533
|
export function apply(ctx) {
|
|
392
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
|
+
|
|
393
575
|
/**
|
|
394
|
-
*
|
|
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` 设置段。
|
|
395
591
|
*
|
|
396
592
|
* 每次调用惰性重读:apply 时该段可能尚未注册,而适配器本身也按请求重解析
|
|
397
593
|
* 连接事实 —— 惰性读才能让两边看同一份修订。
|
|
@@ -428,8 +624,11 @@ export function apply(ctx) {
|
|
|
428
624
|
* @returns 阈值表,未配置时返回默认值。
|
|
429
625
|
*/
|
|
430
626
|
const warningThresholds = () => {
|
|
431
|
-
|
|
432
|
-
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
|
|
433
632
|
const readNumber = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
|
|
434
633
|
return {
|
|
435
634
|
cashCNY: readNumber(warning.cashCNY, 10),
|
|
@@ -673,6 +872,37 @@ export function apply(ctx) {
|
|
|
673
872
|
return
|
|
674
873
|
}
|
|
675
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
|
+
}))
|
|
676
906
|
const { apiKey } = await connectionFacts()
|
|
677
907
|
const payload = shapeBalance(await getJson('/user/balance'))
|
|
678
908
|
if (request.method === 'HEAD') {
|
|
@@ -680,12 +910,67 @@ export function apply(ctx) {
|
|
|
680
910
|
response.end()
|
|
681
911
|
return
|
|
682
912
|
}
|
|
683
|
-
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
|
+
})
|
|
684
921
|
} catch (error) {
|
|
685
922
|
send(502, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
686
923
|
}
|
|
687
924
|
}
|
|
688
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')
|
|
689
974
|
//#endregion
|
|
690
975
|
|
|
691
976
|
//#region 3) pi-ai 旁路补缺
|
|
@@ -859,6 +1144,7 @@ export function apply(ctx) {
|
|
|
859
1144
|
// 已配置的 id 列表:前端拉到网关目录后要据此把「已经在用的」默认勾上,
|
|
860
1145
|
// 光有数量做不到这件事。
|
|
861
1146
|
modelIds: models.map((m) => (m !== null && typeof m === 'object' ? m.id : m)).filter((id) => typeof id === 'string'),
|
|
1147
|
+
billingMode: label(profile.billingMode) ?? null,
|
|
862
1148
|
balanceAdapter: balanceAdapterFor(provider, profile) ?? null
|
|
863
1149
|
}
|
|
864
1150
|
}
|
|
@@ -1213,12 +1499,14 @@ export function apply(ctx) {
|
|
|
1213
1499
|
// 比多显示一个不能用的更糟:前者让人以为插件坏了。
|
|
1214
1500
|
// · 服务端只管判定;「从下拉里摘掉」在 client 半做(目录无法在 host 侧过滤)。
|
|
1215
1501
|
|
|
1502
|
+
/** 可用性与健康状态机实例 */
|
|
1503
|
+
const availabilityStore = new AvailabilityStore({ runtimeTtlMs: RUNTIME_TTL_MS })
|
|
1216
1504
|
/** provider -> 探针判定(凭据 / 探测 / 余额)。 */
|
|
1217
|
-
const baseVerdicts =
|
|
1505
|
+
const baseVerdicts = availabilityStore.baseVerdicts
|
|
1218
1506
|
/** provider -> 最近一次运行期失败判定(真实请求的鉴权/欠费错误)。 */
|
|
1219
|
-
const runtimeMarks =
|
|
1507
|
+
const runtimeMarks = availabilityStore.runtimeMarks
|
|
1220
1508
|
/** provider -> 最近一次真实请求成功的时刻。 */
|
|
1221
|
-
const successAt =
|
|
1509
|
+
const successAt = availabilityStore.successAt
|
|
1222
1510
|
/** 全量重探的去重句柄。 */
|
|
1223
1511
|
let probeAll = null
|
|
1224
1512
|
/** 探针代际:手动重探会 +1,让在途的旧探针彻底作废。 */
|
|
@@ -1432,79 +1720,11 @@ export function apply(ctx) {
|
|
|
1432
1720
|
refreshAvailability(stuck).catch(() => {})
|
|
1433
1721
|
}
|
|
1434
1722
|
|
|
1435
|
-
/** 把真实请求的失败/成功折成运行期标记。 */
|
|
1436
|
-
const classifyFailure = (failure) => {
|
|
1437
|
-
const code = typeof failure?.code === 'string' ? failure.code : ''
|
|
1438
|
-
const status = typeof failure?.status === 'number' ? failure.status : undefined
|
|
1439
|
-
// MISSING_CREDENTIAL 也归这一类:pi-ai 解析不到 apiKeyEnv 时抛的就是它,
|
|
1440
|
-
// 与「key 无效」对使用者的意义一样 —— 这条路由现在调不了。
|
|
1441
|
-
if (code === 'INVALID_CREDENTIAL' || code === 'MISSING_CREDENTIAL' || status === 401 || status === 403) {
|
|
1442
|
-
return { code: 'AUTH_REJECTED', reason: '最近一次请求被拒(API key 缺失、无效或已过期)' }
|
|
1443
|
-
}
|
|
1444
|
-
if (code === 'QUOTA_EXCEEDED' || status === 402) {
|
|
1445
|
-
return { code: 'QUOTA_EXHAUSTED', reason: '最近一次请求因余额/配额耗尽失败' }
|
|
1446
|
-
}
|
|
1447
|
-
return undefined
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
1723
|
/** 汇总:运行期标记优先于探针判定,真实成功又覆盖运行期失败。 */
|
|
1451
|
-
const currentAvailability = () =>
|
|
1452
|
-
const now = Date.now()
|
|
1453
|
-
const merged = new Map()
|
|
1454
|
-
for (const [id, verdict] of baseVerdicts) merged.set(id, verdict)
|
|
1455
|
-
for (const [id, mark] of [...runtimeMarks]) {
|
|
1456
|
-
if (now - mark.at >= RUNTIME_TTL_MS) {
|
|
1457
|
-
runtimeMarks.delete(id)
|
|
1458
|
-
continue
|
|
1459
|
-
}
|
|
1460
|
-
if ((successAt.get(id) ?? 0) > mark.at) continue
|
|
1461
|
-
const base = merged.get(id)
|
|
1462
|
-
merged.set(id, {
|
|
1463
|
-
provider: id,
|
|
1464
|
-
displayName: base?.displayName ?? id,
|
|
1465
|
-
ns: base?.ns ?? PIAI_NS,
|
|
1466
|
-
state: 'unavailable',
|
|
1467
|
-
code: mark.code,
|
|
1468
|
-
reason: mark.reason,
|
|
1469
|
-
source: 'runtime',
|
|
1470
|
-
checkedAt: mark.at
|
|
1471
|
-
})
|
|
1472
|
-
}
|
|
1473
|
-
for (const [id, at] of successAt) {
|
|
1474
|
-
const base = merged.get(id)
|
|
1475
|
-
if (base === undefined || base.state === 'available') continue
|
|
1476
|
-
if (now - at >= RUNTIME_TTL_MS || at <= base.checkedAt) continue
|
|
1477
|
-
merged.set(id, { ...base, state: 'available', code: 'RECOVERED', reason: '', source: 'runtime', checkedAt: at })
|
|
1478
|
-
}
|
|
1479
|
-
return [...merged.values()].sort((left, right) => left.provider.localeCompare(right.provider))
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
/**
|
|
1483
|
-
* 判定是否属于应从模型下拉彻底摘掉的严重凭据故障。
|
|
1484
|
-
*
|
|
1485
|
-
* ⚠️ 设计原则(避免死锁):
|
|
1486
|
-
* 只有确凿的凭据故障(CREDENTIAL_MISSING / AUTH_REJECTED / ACCOUNT_UNAVAILABLE)才从下拉框剔除。
|
|
1487
|
-
* 配额耗尽(QUOTA_EXHAUSTED)与现金余额为 0(BALANCE_EMPTY / PAYMENT_REQUIRED)属于临时限额/计费状态,
|
|
1488
|
-
* 绝不能从下拉框中剔除,否则会导致:
|
|
1489
|
-
* 1) 用户在界面上永久丢失模型入口;
|
|
1490
|
-
* 2) 5小时滚动配额或周配额恢复后,用户因无法选模型而无法触发运行期遥测恢复;
|
|
1491
|
-
* 3) 形成死锁。
|
|
1492
|
-
*/
|
|
1493
|
-
const isDropDeadHidden = (verdict) => {
|
|
1494
|
-
if (verdict === null || typeof verdict !== 'object') return false
|
|
1495
|
-
if (verdict.state !== 'unavailable') return false
|
|
1496
|
-
const code = verdict.code
|
|
1497
|
-
return code === 'CREDENTIAL_MISSING' || code === 'AUTH_REJECTED' || code === 'ACCOUNT_UNAVAILABLE'
|
|
1498
|
-
}
|
|
1724
|
+
const currentAvailability = () => availabilityStore.currentAvailability()
|
|
1499
1725
|
|
|
1500
1726
|
/** 当前该从下拉里摘掉的 provider 集合。 */
|
|
1501
|
-
const hiddenProviders = () =>
|
|
1502
|
-
const hidden = new Set()
|
|
1503
|
-
for (const verdict of currentAvailability()) {
|
|
1504
|
-
if (isDropDeadHidden(verdict)) hidden.add(verdict.provider)
|
|
1505
|
-
}
|
|
1506
|
-
return hidden
|
|
1507
|
-
}
|
|
1727
|
+
const hiddenProviders = () => availabilityStore.hiddenProviders()
|
|
1508
1728
|
|
|
1509
1729
|
const sameHidden = (left, right) => left.size === right.size && [...left].every((id) => right.has(id))
|
|
1510
1730
|
|
|
@@ -1669,12 +1889,33 @@ export function apply(ctx) {
|
|
|
1669
1889
|
return () => {}
|
|
1670
1890
|
}
|
|
1671
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
|
+
|
|
1672
1910
|
const filtered = function (...args) {
|
|
1673
1911
|
const providers = original.apply(this, args)
|
|
1674
1912
|
if (!Array.isArray(providers)) return providers
|
|
1675
|
-
const hidden = hiddenProviders()
|
|
1676
|
-
|
|
1677
|
-
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 })
|
|
1678
1919
|
}
|
|
1679
1920
|
// ⚠️ 不要用 `service.listProviders !== filtered` 校验「写进去了没有」:
|
|
1680
1921
|
// cordis 的服务访问返回 traceable 代理,每次读方法都是一个新的包装对象,永远不相等。
|
|
@@ -2178,36 +2419,43 @@ export function apply(ctx) {
|
|
|
2178
2419
|
const ROUTE_MODEL_RE = /^[a-z0-9][a-z0-9_.+-]*$/i
|
|
2179
2420
|
|
|
2180
2421
|
/**
|
|
2181
|
-
* 解析「provider/model
|
|
2422
|
+
* 解析「provider/model」字符串 —— 委托给模块级真源 `parseRouteEntryPure`。
|
|
2182
2423
|
* @param entry - 配置里的一行。
|
|
2183
2424
|
* @returns 解析后的 { provider, model },无效时为 `undefined`。
|
|
2184
2425
|
*/
|
|
2185
|
-
const parseRouteEntry = (entry) =>
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
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 }
|
|
2194
2439
|
}
|
|
2195
2440
|
|
|
2196
2441
|
/**
|
|
2197
|
-
*
|
|
2198
|
-
* @returns
|
|
2442
|
+
* 读路由组(真源实现 `parseRoutingGroups`,settings 惰性重读)。
|
|
2443
|
+
* @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
|
|
2199
2444
|
*/
|
|
2200
|
-
const
|
|
2201
|
-
|
|
2202
|
-
|
|
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 []
|
|
2203
2456
|
const out = []
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
const fallbacks = Array.isArray(routing.fallbacks) ? routing.fallbacks : []
|
|
2207
|
-
for (const fallback of fallbacks) {
|
|
2208
|
-
const parsed = parseRouteEntry(fallback)
|
|
2209
|
-
if (parsed !== undefined) out.push(parsed)
|
|
2210
|
-
}
|
|
2457
|
+
if (group.primary !== null) out.push(group.primary)
|
|
2458
|
+
for (const fallback of group.fallbacks) out.push(fallback)
|
|
2211
2459
|
return out
|
|
2212
2460
|
}
|
|
2213
2461
|
|
|
@@ -2226,12 +2474,15 @@ export function apply(ctx) {
|
|
|
2226
2474
|
}
|
|
2227
2475
|
|
|
2228
2476
|
/**
|
|
2229
|
-
* 路由解析:给客户端返回「该用哪个 +
|
|
2477
|
+
* 路由解析:给客户端返回「该用哪个 + 原因」(1.5.0 起按组解析)。
|
|
2230
2478
|
* @param active - 客户端 POST 进来的当前已激活 (provider, model)。
|
|
2231
|
-
* @
|
|
2479
|
+
* @param groupId - 指定组 id;缺省用当前激活组。
|
|
2480
|
+
* @returns 路由建议载荷(附 mode / activeGroup / groups 摘要)。
|
|
2232
2481
|
*/
|
|
2233
|
-
const resolveRoute = (active) => {
|
|
2234
|
-
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
|
|
2235
2486
|
const verdicts = new Map()
|
|
2236
2487
|
for (const candidate of candidates) {
|
|
2237
2488
|
verdicts.set(`${candidate.provider}/${candidate.model}`, stateFor(candidate.provider, candidate.model))
|
|
@@ -2247,6 +2498,13 @@ export function apply(ctx) {
|
|
|
2247
2498
|
: (recommendation === undefined ? '主力与 fallbacks 全部不可用 —— 充值或换一个再说' : null)
|
|
2248
2499
|
return {
|
|
2249
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
|
+
})),
|
|
2250
2508
|
active: activeParsed === null
|
|
2251
2509
|
? null
|
|
2252
2510
|
: { provider: activeParsed.provider, model: activeParsed.model, state: activeState },
|
|
@@ -2282,10 +2540,174 @@ export function apply(ctx) {
|
|
|
2282
2540
|
sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
|
|
2283
2541
|
return
|
|
2284
2542
|
}
|
|
2285
|
-
sendJson(response, 200, resolveRoute(payload?.active))
|
|
2543
|
+
sendJson(response, 200, resolveRoute(payload?.active, typeof payload?.group === 'string' ? payload.group : undefined))
|
|
2286
2544
|
}
|
|
2287
2545
|
}), 'dsh-llm-hub: routing resolve route')
|
|
2288
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
|
+
|
|
2289
2711
|
// ── 模型别名 ──────────────────────────────────────────────────────────────
|
|
2290
2712
|
//
|
|
2291
2713
|
// settings.dsh-llm-hub.aliases:把长 model id 映射成短显示名,典型场景:
|
|
@@ -2308,8 +2730,13 @@ export function apply(ctx) {
|
|
|
2308
2730
|
* @returns 规范化后的别名表(空值 / 非法 key 全过滤掉)。
|
|
2309
2731
|
*/
|
|
2310
2732
|
const aliasesOf = () => {
|
|
2311
|
-
|
|
2312
|
-
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
|
+
}
|
|
2313
2740
|
const out = {}
|
|
2314
2741
|
for (const [key, value] of Object.entries(raw)) {
|
|
2315
2742
|
if (!ALIAS_KEY_RE.test(key)) continue
|