@dsh-plugins/dsh-llm-hub 1.3.0 → 1.3.2

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
@@ -356,6 +356,20 @@ function sendJson(response, status, payload) {
356
356
  response.end(JSON.stringify(payload))
357
357
  }
358
358
 
359
+ /**
360
+ * API Key 脱敏 —— 保留首尾(前4~6位 + 后4位),中间用 •••• 掩码。
361
+ * @param {string|undefined|null} key - 原始 Key
362
+ * @returns {string|null} 脱敏后的 Key,无有效值时返回 null
363
+ */
364
+ export function maskKey(key) {
365
+ if (typeof key !== 'string' || key.trim() === '') return null
366
+ const trimmed = key.trim()
367
+ if (trimmed.length <= 8) return '••••••••'
368
+ const head = trimmed.length >= 24 ? 6 : 4
369
+ const tail = 4
370
+ return `${trimmed.slice(0, head)}••••${trimmed.slice(-tail)}`
371
+ }
372
+
359
373
  export const name = 'dsh-llm-hub'
360
374
  /**
361
375
  * 硬依赖:llm(注册发现 + 监听 llm/stream + 过滤 listProviders)、webServer(HTTP 路由)。
@@ -691,13 +705,14 @@ export function apply(ctx) {
691
705
  return
692
706
  }
693
707
  try {
708
+ const { apiKey } = await connectionFacts()
694
709
  const payload = shapeBalance(await getJson('/user/balance'))
695
710
  if (request.method === 'HEAD') {
696
711
  response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
697
712
  response.end()
698
713
  return
699
714
  }
700
- send(200, { ok: true, ...payload })
715
+ send(200, { ok: true, apiKey: apiKey ?? null, keyMasked: maskKey(apiKey), ...payload })
701
716
  } catch (error) {
702
717
  send(502, { ok: false, error: error instanceof Error ? error.message : String(error) })
703
718
  }
@@ -870,6 +885,8 @@ export function apply(ctx) {
870
885
  baseURLSource: baseURL !== undefined ? 'settings' : (KNOWN_GATEWAY_BASE[String(provider ?? '')] !== undefined ? 'known' : 'none'),
871
886
  apiKeyEnv: ref,
872
887
  keyConfigured: apiKey !== undefined,
888
+ keyMasked: maskKey(apiKey),
889
+ apiKey: apiKey ?? null,
873
890
  modelCount: models.length,
874
891
  // 已配置的 id 列表:前端拉到网关目录后要据此把「已经在用的」默认勾上,
875
892
  // 光有数量做不到这件事。
@@ -1473,11 +1490,29 @@ export function apply(ctx) {
1473
1490
  return [...merged.values()].sort((left, right) => left.provider.localeCompare(right.provider))
1474
1491
  }
1475
1492
 
1493
+ /**
1494
+ * 判定是否属于应从模型下拉彻底摘掉的严重凭据故障。
1495
+ *
1496
+ * ⚠️ 设计原则(避免死锁):
1497
+ * 只有确凿的凭据故障(CREDENTIAL_MISSING / AUTH_REJECTED / ACCOUNT_UNAVAILABLE)才从下拉框剔除。
1498
+ * 配额耗尽(QUOTA_EXHAUSTED)与现金余额为 0(BALANCE_EMPTY / PAYMENT_REQUIRED)属于临时限额/计费状态,
1499
+ * 绝不能从下拉框中剔除,否则会导致:
1500
+ * 1) 用户在界面上永久丢失模型入口;
1501
+ * 2) 5小时滚动配额或周配额恢复后,用户因无法选模型而无法触发运行期遥测恢复;
1502
+ * 3) 形成死锁。
1503
+ */
1504
+ const isDropDeadHidden = (verdict) => {
1505
+ if (verdict === null || typeof verdict !== 'object') return false
1506
+ if (verdict.state !== 'unavailable') return false
1507
+ const code = verdict.code
1508
+ return code === 'CREDENTIAL_MISSING' || code === 'AUTH_REJECTED' || code === 'ACCOUNT_UNAVAILABLE'
1509
+ }
1510
+
1476
1511
  /** 当前该从下拉里摘掉的 provider 集合。 */
1477
1512
  const hiddenProviders = () => {
1478
1513
  const hidden = new Set()
1479
1514
  for (const verdict of currentAvailability()) {
1480
- if (verdict.state === 'unavailable') hidden.add(verdict.provider)
1515
+ if (isDropDeadHidden(verdict)) hidden.add(verdict.provider)
1481
1516
  }
1482
1517
  return hidden
1483
1518
  }
@@ -1513,7 +1548,7 @@ export function apply(ctx) {
1513
1548
  checkedAt: lastProbeAt,
1514
1549
  probing: probeAll !== null,
1515
1550
  providers,
1516
- hidden: providers.filter((verdict) => verdict.state === 'unavailable').map((verdict) => verdict.provider)
1551
+ hidden: providers.filter(isDropDeadHidden).map((verdict) => verdict.provider)
1517
1552
  }
1518
1553
  }
1519
1554
 
@@ -1613,8 +1648,16 @@ export function apply(ctx) {
1613
1648
  const timer = setTimeout(() => {
1614
1649
  refreshAvailability().catch(() => {})
1615
1650
  }, 1500)
1616
- return () => clearTimeout(timer)
1617
- }, 'dsh-llm-hub: initial availability probe')
1651
+ timer.unref?.()
1652
+ const interval = setInterval(() => {
1653
+ ensureAvailabilityFresh()
1654
+ }, AVAILABILITY_TTL_MS)
1655
+ interval.unref?.()
1656
+ return () => {
1657
+ clearTimeout(timer)
1658
+ clearInterval(interval)
1659
+ }
1660
+ }, 'dsh-llm-hub: initial and periodic availability probe')
1618
1661
 
1619
1662
  // 把不可用的 provider 从 llm 服务的路由表里摘掉。
1620
1663
  //
@@ -2258,32 +2301,6 @@ export function apply(ctx) {
2258
2301
  }
2259
2302
  }), 'dsh-llm-hub: routing resolve route')
2260
2303
 
2261
- ctx.effect(() => ctx.webServer.register({
2262
- kind: 'exact',
2263
- path: ROUTING_RESOLVE_PATH,
2264
- handler: async (request, response) => {
2265
- if (request.method !== 'POST') {
2266
- response.writeHead(405, { allow: 'POST' })
2267
- response.end()
2268
- return
2269
- }
2270
- if (!sameOrigin(request)) {
2271
- sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
2272
- return
2273
- }
2274
- let payload
2275
- try {
2276
- const collected = []
2277
- for await (const chunk of request) collected.push(chunk)
2278
- payload = JSON.parse(Buffer.concat(collected).toString('utf8'))
2279
- } catch {
2280
- sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
2281
- return
2282
- }
2283
- sendJson(response, 200, resolveRoute(payload?.active))
2284
- }
2285
- }), 'dsh-llm-hub: routing resolve route')
2286
-
2287
2304
  // ── 模型别名 ──────────────────────────────────────────────────────────────
2288
2305
  //
2289
2306
  // settings.dsh-llm-hub.aliases:把长 model id 映射成短显示名,典型场景:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plugins/dsh-llm-hub",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",