@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/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
- * 当前已解析的 `llm-deepseek` 设置段。
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
- const section = sectionOf() ?? {}
432
- const warning = section.warning !== null && typeof section.warning === 'object' ? section.warning : {}
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, { ok: true, apiKey: apiKey ?? null, keyMasked: maskKey(apiKey), ...payload })
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 = new Map()
1505
+ const baseVerdicts = availabilityStore.baseVerdicts
1218
1506
  /** provider -> 最近一次运行期失败判定(真实请求的鉴权/欠费错误)。 */
1219
- const runtimeMarks = new Map()
1507
+ const runtimeMarks = availabilityStore.runtimeMarks
1220
1508
  /** provider -> 最近一次真实请求成功的时刻。 */
1221
- const successAt = new Map()
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
- if (hidden.size === 0) return providers
1677
- return providers.filter((provider) => !hidden.has(provider?.id))
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
- if (typeof entry !== 'string') return undefined
2187
- const slash = entry.indexOf('/')
2188
- if (slash <= 0 || slash === entry.length - 1) return undefined
2189
- const provider = entry.slice(0, slash).trim()
2190
- const model = entry.slice(slash + 1).trim()
2191
- // provider 严格(要拿去找 settings 段查),model 放宽(DSH 真实 id 可能含 .)
2192
- if (!ROUTE_KEY_RE.test(provider) || !ROUTE_MODEL_RE.test(model)) return undefined
2193
- return { provider, model }
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
- * 读 `routing.primary` + `routing.fallbacks[]`。
2198
- * @returns 配置好的顺序数组(primary 在前),无效项过滤掉。空数组表示未配置。
2442
+ * 读路由组(真源实现 `parseRoutingGroups`,settings 惰性重读)。
2443
+ * @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
2199
2444
  */
2200
- const routingCandidatesOf = () => {
2201
- const section = sectionOf() ?? {}
2202
- const routing = section[ROUTING_KEY] !== null && typeof section[ROUTING_KEY] === 'object' ? section[ROUTING_KEY] : {}
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
- const primary = parseRouteEntry(routing.primary)
2205
- if (primary !== undefined) out.push(primary)
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
- * @returns 路由建议载荷。
2479
+ * @param groupId - 指定组 id;缺省用当前激活组。
2480
+ * @returns 路由建议载荷(附 mode / activeGroup / groups 摘要)。
2232
2481
  */
2233
- const resolveRoute = (active) => {
2234
- const candidates = routingCandidatesOf()
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
- const section = sectionOf() ?? {}
2312
- const raw = section.aliases !== null && typeof section.aliases === 'object' ? section.aliases : {}
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