@dsh-plugins/dsh-llm-hub 1.6.0 → 1.7.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
@@ -291,6 +291,35 @@ export function parseRouteEntryPure(entry) {
291
291
  * @param {object|undefined|null} routing - settings.routing 段。
292
292
  * @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
293
293
  */
294
+ /**
295
+ * 备注清洗:只收字符串,去首尾空白,空串视为没有,最长 120 字。
296
+ * @param {unknown} value - 原始备注。
297
+ * @returns {string | null}
298
+ */
299
+ export function cleanRouteNote(value) {
300
+ if (typeof value !== 'string') return null
301
+ const note = value.trim().slice(0, 120)
302
+ return note === '' ? null : note
303
+ }
304
+
305
+ /**
306
+ * 候选备注表:只保留键是合法 `provider/model`、且确实在本组候选里的条目 ——
307
+ * 删掉候选时它的备注一并消失,不在 settings 里留孤儿。
308
+ * @param {unknown} raw - settings 里的 notes 字典。
309
+ * @param {Array<{provider: string, model: string}>} entries - 本组全部候选。
310
+ * @returns {Record<string, string> | null}
311
+ */
312
+ function cleanRouteNotes(raw, entries) {
313
+ if (raw === null || typeof raw !== 'object') return null
314
+ const keys = new Set(entries.map((entry) => `${entry.provider}/${entry.model}`))
315
+ const out = {}
316
+ for (const [key, value] of Object.entries(raw)) {
317
+ const note = cleanRouteNote(value)
318
+ if (note !== null && keys.has(key)) out[key] = note
319
+ }
320
+ return Object.keys(out).length > 0 ? out : null
321
+ }
322
+
294
323
  export function parseRoutingGroups(routing) {
295
324
  const r = routing !== null && typeof routing === 'object' ? routing : {}
296
325
  const mode = r.mode === 'smart' ? 'smart' : 'manual'
@@ -306,11 +335,15 @@ export function parseRoutingGroups(routing) {
306
335
  .filter((entry) => entry !== undefined)
307
336
  if (primary === null && fallbacks.length === 0) continue
308
337
  seen.add(id)
338
+ const note = cleanRouteNote(raw.note)
339
+ const notes = cleanRouteNotes(raw.notes, [...(primary !== null ? [primary] : []), ...fallbacks])
309
340
  groups.push({
310
341
  id,
311
342
  label: typeof raw.label === 'string' && raw.label.trim() !== '' ? raw.label.trim() : null,
312
343
  primary,
313
- fallbacks
344
+ fallbacks,
345
+ ...(note !== null ? { note } : {}),
346
+ ...(notes !== null ? { notes } : {})
314
347
  })
315
348
  }
316
349
  if (groups.length === 0) {
@@ -422,11 +455,22 @@ export function normalizeRoutingGroupsInput(raw) {
422
455
  }
423
456
  if (primary === null && fallbacks.length === 0) { dropped.push(`组 ${id} 没有任何合法候选`); continue }
424
457
  seen.add(id)
458
+ // 候选备注两种来源:编辑器形态里每个候选对象自带 note;落盘形态是组上的 notes 字典。
459
+ const noteSource = { ...(item.notes !== null && typeof item.notes === 'object' ? item.notes : {}) }
460
+ for (const candidate of ordered ?? []) {
461
+ const entry = asEntry(candidate)
462
+ const note = candidate !== null && typeof candidate === 'object' ? cleanRouteNote(candidate.note) : null
463
+ if (entry !== undefined && note !== null) noteSource[`${entry.provider}/${entry.model}`] = note
464
+ }
465
+ const note = cleanRouteNote(item.note)
466
+ const notes = cleanRouteNotes(noteSource, [...(primary !== null ? [primary] : []), ...fallbacks])
425
467
  groups.push({
426
468
  id,
427
469
  label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim().slice(0, 32) : null,
428
470
  primary,
429
- fallbacks
471
+ fallbacks,
472
+ ...(note !== null ? { note } : {}),
473
+ ...(notes !== null ? { notes } : {})
430
474
  })
431
475
  }
432
476
  return { groups, dropped }
@@ -638,7 +682,9 @@ export function apply(ctx) {
638
682
  try {
639
683
  const mod = await import('@deepseek-ai/schemastery')
640
684
  const z = (mod && typeof mod === 'object' ? mod.default ?? mod : mod)
641
- const groupItem = z.object({ id: z.string().required(), label: z.string(), primary: z.string(), fallbacks: z.array(z.string()) })
685
+ // note / notes(1.7.0):页面上写的备注。YAML 注释在页面保存时会被整段替换冲掉,
686
+ // 所以说明要落成字段才留得住。notes 以 'provider/model' 为键。
687
+ const groupItem = z.object({ id: z.string().required(), label: z.string(), note: z.string(), primary: z.string(), fallbacks: z.array(z.string()), notes: z.dict(z.string()) })
642
688
  schema = z.object({
643
689
  routing: z.object({
644
690
  mode: z.union(['manual', 'smart']).default('manual'),
@@ -1294,7 +1340,12 @@ export function apply(ctx) {
1294
1340
  auth: 'raw',
1295
1341
  shape: (body) => {
1296
1342
  if (body?.success === false || (typeof body?.code === 'number' && body.code !== 200)) {
1297
- return { unavailable: label(body?.msg) ?? 'quota unavailable' }
1343
+ // 服务端用一份完整的 JSON 明确回答「你这个号没有套餐」
1344
+ // (2026-09-29 实测:`当前用户不存在coding plan`)。
1345
+ // 这跟 `fail()` 那几条「连不上 / 看不懂 / 没这个接口」不是一回事:
1346
+ // 前者是确知不可用,值得判死并隐藏;后者是拿不准,必须 fail-open 保留。
1347
+ // 不标 definitive 的话,欠费会永远停在「显示着但一用就报错」。
1348
+ return { unavailable: label(body?.msg) ?? 'quota unavailable', definitive: true }
1298
1349
  }
1299
1350
  const limits = Array.isArray(body?.data?.limits) ? body.data.limits : []
1300
1351
  const items = []
@@ -1361,6 +1412,13 @@ export function apply(ctx) {
1361
1412
  return undefined
1362
1413
  }
1363
1414
 
1415
+ /**
1416
+ * provider -> 最近一次余额信封。排序要用它判计费模式,而排序是同步的
1417
+ * (`llm.listProviders` 不能 await),所以只能读探测顺手存下来的快照。
1418
+ * @type {Map<string, object>}
1419
+ */
1420
+ const balanceSnapshot = new Map()
1421
+
1364
1422
  /**
1365
1423
  * 查询某 provider 的余额/配额。统一信封:`{ ok, kind, available, items?, reason? }`;
1366
1424
  * 匹配不到适配器给 `{ ok, supported:false }`,无 key / 上游失败都折叠成 `available:false`
@@ -1419,9 +1477,12 @@ export function apply(ctx) {
1419
1477
  }
1420
1478
  const shaped = adapter.shape(body)
1421
1479
  if (shaped.unavailable !== undefined) {
1422
- return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable }
1480
+ // definitive 只在「上游结构化回答了不可用」时为真;网络/解析失败走 fail(),没有这个字段。
1481
+ return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable, definitive: shaped.definitive === true }
1423
1482
  }
1424
- return { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
1483
+ const envelope = { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
1484
+ balanceSnapshot.set(provider, envelope)
1485
+ return envelope
1425
1486
  }
1426
1487
 
1427
1488
  /**
@@ -1665,9 +1726,18 @@ export function apply(ctx) {
1665
1726
  return undefined
1666
1727
  }
1667
1728
  if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true || envelope.supported === false) return undefined
1668
- // available=false 只表示「这次余额查询没成功」;原因千差万别(没 key、查不通、
1669
- // 没有套餐),除开 key 缺失(已由凭据那步覆盖)都不构成隐藏理由。
1670
- if (envelope.available === false) return undefined
1729
+ // available=false 有两种,混在一起处理会让欠费永远漏网:
1730
+ // · definitive —— 上游用完整 JSON 确知「这个号没有套餐」,判死并隐藏;
1731
+ // · 其余(没 key / 连不上 / 看不懂 / 没这个接口)—— 只是这次没查成,
1732
+ // 按 fail-open 保留,否则一次网络抖动就把能用的模型藏了。
1733
+ // 2026-09-29 实测智谱 coding 端点就栽在这:余额接口白纸黑字回了
1734
+ // 「当前用户不存在coding plan」,却因为和「查不通」共用 available=false 而被放过。
1735
+ if (envelope.available === false) {
1736
+ if (envelope.definitive === true) {
1737
+ return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: label(envelope.reason) ?? '账户没有可用套餐' }
1738
+ }
1739
+ return undefined
1740
+ }
1671
1741
  const items = Array.isArray(envelope.items) ? envelope.items : []
1672
1742
  if (envelope.kind === 'cash') {
1673
1743
  const amount = items[0]?.amount
@@ -1993,7 +2063,13 @@ export function apply(ctx) {
1993
2063
  if (profile?.billingMode === 'usage' || profile?.billingMode === 'subscription') {
1994
2064
  return profile.billingMode
1995
2065
  }
1996
- return detectBillingMode(profile || { provider: cleanId })
2066
+ // ⚠️ balance 必须传:少了它,`detectBillingMode` 里认 `kind: 'plan' | 'quota'`、
2067
+ // `resetsAt`、`weeklyPercent` 的那几条分支全是死代码,最后一律落到兜底的
2068
+ // `return 'usage'`。2026-09-29 实测:MiniMax 余额信封明明是
2069
+ // `{kind:'plan', items:[{resetsAt:'15:00', weeklyPercent:97}]}`,下拉里却被当成
2070
+ // 按量付费排在套餐后面 —— 配置卡片显示对了(client 传了 balance),
2071
+ // 下拉排序却是错的(host 没传),两边对不上就是这个漏传造成的。
2072
+ return detectBillingMode(profile || { provider: cleanId }, profile, balanceSnapshot.get(cleanId) ?? balanceSnapshot.get(id))
1997
2073
  }
1998
2074
 
1999
2075
  const filtered = function (...args) {
@@ -2707,9 +2783,11 @@ export function apply(ctx) {
2707
2783
  return {
2708
2784
  id: group.id,
2709
2785
  label: group.label,
2786
+ note: group.note ?? null,
2710
2787
  candidates: candidates.map((candidate) => ({
2711
2788
  provider: candidate.provider,
2712
2789
  model: candidate.model,
2790
+ note: group.notes?.[`${candidate.provider}/${candidate.model}`] ?? null,
2713
2791
  state: stateFor(candidate.provider, candidate.model)
2714
2792
  })),
2715
2793
  wouldRoute: winner.to,
@@ -2877,8 +2955,10 @@ export function apply(ctx) {
2877
2955
  groups: normalized.groups.map((group) => ({
2878
2956
  id: group.id,
2879
2957
  ...(group.label !== null ? { label: group.label } : {}),
2958
+ ...(group.note ? { note: group.note } : {}),
2880
2959
  ...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
2881
- fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
2960
+ fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`),
2961
+ ...(group.notes ? { notes: group.notes } : {})
2882
2962
  }))
2883
2963
  }
2884
2964
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plugins/dsh-llm-hub",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -85,8 +85,17 @@ const walkStrings = (v) => {
85
85
  for (const field of ['main', 'types', 'module', 'bin', 'exports']) walkStrings(pkg[field])
86
86
  if (pkg.dsh && typeof pkg.dsh === 'object') walkStrings(pkg.dsh.bundle)
87
87
 
88
+ // exports 允许「子路径模式」:`"./styles/*": "./styles/*"`。
89
+ // 这是 npm 官方推荐的整目录可 import 写法,不是错字 —— 2026-09-22 的 retro 记过:
90
+ // 白名单式把每个子路径列一遍,漏一个下游构建就直接 `Missing "./styles/x.css"` 失败。
91
+ // 所以带 * 的条目必须按 glob 去匹配 tarball,而不是要求存在一个名叫 `styles/*` 的文件。
92
+ // 但「模式一个文件都匹配不到」仍要拦 —— 那说明目录被删了或没进 files[],模式是死的。
93
+ const entryGlobRe = (g) => new RegExp(
94
+ '^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
95
+ )
88
96
  for (const p of entryPaths) {
89
- if (!inPack(p)) {
97
+ const ok = /[*?]/.test(p) ? packFiles.some(f => entryGlobRe(p).test(f)) : inPack(p)
98
+ if (!ok) {
90
99
  errors.push(`package.json 的入口字段引用了 '${p}',但 tarball 里没有\n → 装包的人会在 require/import 时直接失败`)
91
100
  }
92
101
  }