@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/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
- * 多账号 key 轮换:settings.dsh-llm-hub.keyPool[provider] 是 entry 数组。
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
- * - **向后兼容**:未配 keyPool 段时直接走原 apiKeyEnv 单 key 路径,行为完全
187
- * 不变;现有 80 个测试不破。
188
- * - **round-robin**(非失败感知):每次解析按索引取下一个 entry;不加锁、不跨
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
- const KEYPOOL_STATUS_PATH = '/api/dsh-llm-hub/keypool/status'
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
- * 当前已解析的 `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` 设置段。
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
- const section = sectionOf() ?? {}
427
- 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
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
- * 给一个 provider 取轮换到的下一个 entry + 索引推进 +1。
551
- * @returns `{ entry, index }` —— entry 是 { name, env };index 是 0-based 位置。
730
+ * 多 Key 自动轮换索引 —— per provider 维护独立的自增游标。
731
+ * @type {Map<string, number>}
552
732
  */
553
- const rotateKeyPool = (provider) => {
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) keyPool[provider] 命中 → round-robin 取下一个,env var 解析密钥
570
- * 3) 旧的 `apiKeyEnv` 单 key 路径(行为完全不变)
740
+ * 2) section.apiKeyEnv 是 string[] → 按 provider 轮换取下一个 ref,
741
+ * 再走 credentials 服务或 process.env 解析密钥
742
+ * 3) section.apiKeyEnv 是 string → 原单 key 路径(行为完全不变)
571
743
  *
572
- * 返 ref 不变 —— 客户端拿到的是凭据引用(keyPool 的 env 名),实际值是
573
- * `process.env[ref]` 解出来的字符串;下游诊断(探针 / 余额路由)能继续
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 pool = rotateKeyPool(providerKey)
594
- if (pool !== undefined) {
595
- const apiKey = label(process.env[pool.entry.env])
596
- if (apiKey !== undefined) return { baseURL, ref: pool.entry.env, apiKey }
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, { 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
+ })
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 = new Map()
1505
+ const baseVerdicts = availabilityStore.baseVerdicts
1250
1506
  /** provider -> 最近一次运行期失败判定(真实请求的鉴权/欠费错误)。 */
1251
- const runtimeMarks = new Map()
1507
+ const runtimeMarks = availabilityStore.runtimeMarks
1252
1508
  /** provider -> 最近一次真实请求成功的时刻。 */
1253
- const successAt = new Map()
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
- if (hidden.size === 0) return providers
1709
- 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 })
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
- if (typeof entry !== 'string') return undefined
2223
- const slash = entry.indexOf('/')
2224
- if (slash <= 0 || slash === entry.length - 1) return undefined
2225
- const provider = entry.slice(0, slash).trim()
2226
- const model = entry.slice(slash + 1).trim()
2227
- // provider 严格(要拿去找 settings 段查),model 放宽(DSH 真实 id 可能含 .)
2228
- if (!ROUTE_KEY_RE.test(provider) || !ROUTE_MODEL_RE.test(model)) return undefined
2229
- 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 }
2230
2439
  }
2231
2440
 
2232
2441
  /**
2233
- * 读 `routing.primary` + `routing.fallbacks[]`。
2234
- * @returns 配置好的顺序数组(primary 在前),无效项过滤掉。空数组表示未配置。
2442
+ * 读路由组(真源实现 `parseRoutingGroups`,settings 惰性重读)。
2443
+ * @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
2235
2444
  */
2236
- const routingCandidatesOf = () => {
2237
- const section = sectionOf() ?? {}
2238
- 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 []
2239
2456
  const out = []
2240
- const primary = parseRouteEntry(routing.primary)
2241
- if (primary !== undefined) out.push(primary)
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
- * @returns 路由建议载荷。
2479
+ * @param groupId - 指定组 id;缺省用当前激活组。
2480
+ * @returns 路由建议载荷(附 mode / activeGroup / groups 摘要)。
2268
2481
  */
2269
- const resolveRoute = (active) => {
2270
- 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
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
- const section = sectionOf() ?? {}
2348
- 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
+ }
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