@dsh-plugins/dsh-llm-hub 1.7.0 → 1.9.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
@@ -33,12 +33,14 @@
33
33
  /** 官方直连适配器拥有的 settings 命名空间。 */
34
34
  import { readFileSync } from 'node:fs'
35
35
  import { fileURLToPath } from 'node:url'
36
+ import z from '@deepseek-ai/schemastery'
36
37
  import { applyHarness, harnessReport } from './harness.js'
37
38
  import { detectBillingMode, sortProviders } from './sorter.js'
38
39
  import { AvailabilityStore, isDropDeadHidden, classifyFailure } from './availability-store.js'
39
40
 
40
41
  export { detectBillingMode, sortProviders } from './sorter.js'
41
42
  export { AvailabilityStore, isDropDeadHidden, classifyFailure } from './availability-store.js'
43
+ export { Config }
42
44
 
43
45
  /**
44
46
  * 用量统计 domain 的 spec。
@@ -657,66 +659,70 @@ export const name = 'dsh-llm-hub'
657
659
  * 用量统计走的是**局部注入**:`storageDomain` 缺席时不报错,本插件其余功能照常工作
658
660
  * —— 没有 storageDomain 也能用余额/可用性/harness,只是没法跨会话记 token 用量。
659
661
  */
662
+ /**
663
+ * 本插件的配置形态(DSH 0.1.7 的 settings 契约)。
664
+ *
665
+ * 0.1.7 起配置不再运行时 `settings.register()` 注册,而是由 Loader 按**条目 id**
666
+ * 从 profile 的 `cordis.patch.yml` 读取;插件导出这个 `Config`,`apply(ctx, config)`
667
+ * 收下 `config`,页面的表单与 `settings.update(ns, patch)` 也都按它生成。
668
+ * 字段与 1.5.0 起的段结构一一对应,所以老 settings.yaml 里的段能原样迁进 patch。
669
+ *
670
+ * note / notes:页面上写的备注。YAML 注释在页面保存时会被整段替换冲掉,
671
+ * 所以说明要落成字段才留得住。notes 以 'provider/model' 为键。
672
+ */
673
+ const groupItem = z.object({
674
+ id: z.string().required(),
675
+ label: z.string(),
676
+ note: z.string(),
677
+ primary: z.string(),
678
+ fallbacks: z.array(z.string()),
679
+ notes: z.dict(z.string())
680
+ })
681
+ // ⚠️ 三个字段都必须 .volatile():0.1.7 的 settings.update() 拒绝「没有 volatile
682
+ // 字段的条目」(原话:Plugin entry "dsh-llm-hub" has no volatile fields)。
683
+ // volatile 的语义正是本插件要的行为 —— 配置改动只换值、不重跑 apply:路由组、预警
684
+ // 阈值、别名全是**每次请求惰性重读**的(见 hubSectionOf 的注释),让切一次路由就去
685
+ // 重新 apply 整个插件是白付代价。官方 dsh-llm-pi-ai 的 providers 段同样标了 volatile。
686
+ const Config = z.object({
687
+ routing: z.object({
688
+ mode: z.union(['manual', 'smart']).default('manual'),
689
+ activeGroup: z.string().default(''),
690
+ groups: z.array(groupItem).default([])
691
+ }).default({}).volatile(),
692
+ warning: z.object({ cashCNY: z.number().default(10), planPercent: z.number().default(10) }).default({}).volatile(),
693
+ aliases: z.dict(z.string()).default({}).volatile()
694
+ })
695
+
660
696
  export const inject = ['llm', 'webServer']
661
697
 
662
698
  /**
663
699
  * 注册模型发现与余额路由。
664
700
  * @param ctx - 插件上下文。
665
701
  */
666
- export function apply(ctx) {
702
+ export function apply(ctx, config) {
667
703
 
668
704
  // ── 插件自有 settings 命名空间(dsh-llm-hub)────────────────────────────
669
705
  //
670
- // 1.5.0 起:routing / warning / aliases 的真源从 `llm-deepseek` 段迁到独立
671
- // 的 `dsh-llm-hub` 段 —— README 文档里写的顶层 `dsh-llm-hub:` 键此前**从未
672
- // 被读到过**(没注册过命名空间,settings.get 会抛),这是 1.1.0 起的历史
673
- // 缺陷,这里一起修掉。
674
- //
675
- // schema 尽量懒加载 `@deepseek-ai/schemastery`;失败时退回 `Object` 构造器
676
- // (schemastery 接受原生构造器作为 schema)。注册失败不 fatal:配置读取退
677
- // 回 legacy 路径,智能路由降级为「只建议」。
678
- let hubRegistered = false
679
- ctx.inject(['settings'], (settingsCtx) => {
680
- void (async () => {
681
- let schema
682
- try {
683
- const mod = await import('@deepseek-ai/schemastery')
684
- const z = (mod && typeof mod === 'object' ? mod.default ?? mod : mod)
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()) })
688
- schema = z.object({
689
- routing: z.object({
690
- mode: z.union(['manual', 'smart']).default('manual'),
691
- activeGroup: z.string().default(''),
692
- groups: z.array(groupItem).default([])
693
- }).default({}),
694
- warning: z.object({ cashCNY: z.number().default(10), planPercent: z.number().default(10) }).default({}),
695
- aliases: z.dict(z.string()).default({})
696
- })
697
- } catch {
698
- // 零依赖环境:schemastery 没解析到 —— 用原生构造器兜底。
699
- schema = Object
700
- }
701
- try {
702
- settingsCtx.settings.register(HUB_NS, schema)
703
- hubRegistered = true
704
- } catch (error) {
705
- ctx.logger.warn(`dsh-llm-hub: settings ns "dsh-llm-hub" register failed: ${error?.message ?? error}`)
706
- }
707
- })()
708
- })
706
+ // DSH 0.1.7 把 settings 架构整个换了:`settings.register(ns, schema)` 与
707
+ // `settings.get(ns)` 双双消失,配置改由 Loader 按**条目 id**从 profile 的
708
+ // cordis.patch.yml 读,插件自己导出 `Config`,再由 `apply(ctx, config)` 收下。
709
+ // 老写法在 0.1.7 上是 `TypeError: settings.register is not a function`,
710
+ // 被下面那个 catch 吞掉 → hubRegistered 恒 false → 整个 dsh-llm-hub 段读写
711
+ // 全死。对外表现是「路由组凭空消失」:数据好好躺在 patch 里,只是没人读。
709
712
 
710
713
  /**
711
714
  * 读 `dsh-llm-hub` 设置段(1.5.0 起的真源)。
712
715
  * @returns 已解析设置段;未注册 / 未配置时为 `undefined`。
713
716
  */
714
717
  const hubSectionOf = () => {
715
- const settings = ctx.get('settings')
716
- if (settings === undefined) return undefined
718
+ // 0.1.7:配置由 `apply(ctx, config)` 注入,惰性 .get() 每次拿当前修订。
719
+ // 不再走 settings.get(HUB_NS) —— 那个方法在 0.1.7 已被移除。
720
+ if (config === undefined || config === null) return undefined
717
721
  try {
718
- const value = settings.get(HUB_NS)
719
- return value !== null && typeof value === 'object' ? value : undefined
722
+ const routing = config.routing?.get?.()
723
+ const warning = config.warning?.get?.()
724
+ const aliases = config.aliases?.get?.()
725
+ return { routing, warning, aliases }
720
726
  } catch {
721
727
  return undefined
722
728
  }
@@ -729,13 +735,31 @@ export function apply(ctx) {
729
735
  * @returns 已解析的设置段;不可用时为 `undefined`。
730
736
  */
731
737
  const sectionOf = () => {
738
+ // 0.1.7:读**别的插件**的配置不再有 settings.get(ns)。改走
739
+ // settings.describe() —— 它返回所有条目的 ns + 实时 value。
732
740
  const settings = ctx.get('settings')
733
741
  if (settings === undefined) return undefined
734
742
  try {
735
- const value = settings.get(NS)
743
+ const row = settings.describe().find((item) => item.ns === NS)
744
+ const value = row?.value
745
+ return value !== null && typeof value === 'object' ? value : undefined
746
+ } catch {
747
+ return undefined
748
+ }
749
+ }
750
+
751
+ /**
752
+ * 读任意命名空间(跨插件)在 0.1.7 的唯一途径。
753
+ * @param {string} ns - profile 条目 id。
754
+ * @returns {object | undefined} 该条目的实时配置值。
755
+ */
756
+ const sectionOfNs = (ns) => {
757
+ const settings = ctx.get('settings')
758
+ if (settings === undefined) return undefined
759
+ try {
760
+ const value = settings.describe().find((item) => item.ns === ns)?.value
736
761
  return value !== null && typeof value === 'object' ? value : undefined
737
762
  } catch {
738
- // 命名空间尚未注册时会抛 —— 那是正常启动竞态,不是失败。
739
763
  return undefined
740
764
  }
741
765
  }
@@ -1025,13 +1049,13 @@ export function apply(ctx) {
1025
1049
  try {
1026
1050
  const raw = await getJson('/user/balance', { apiKey })
1027
1051
  const shaped = shapeBalance(raw)
1028
- const first = shaped.balances?.[0]
1052
+ const activeBalance = shaped.balances?.find((b) => Number(b.total) > 0) ?? shaped.balances?.[0]
1029
1053
  return {
1030
1054
  ref,
1031
1055
  keyMasked: maskKey(apiKey),
1032
1056
  isAvailable: shaped.isAvailable,
1033
- total: first?.total ?? '0.00',
1034
- currency: first?.currency ?? 'CNY',
1057
+ total: activeBalance?.total ?? '0.00',
1058
+ currency: activeBalance?.currency ?? 'CNY',
1035
1059
  ok: true
1036
1060
  }
1037
1061
  } catch (e) {
@@ -1125,7 +1149,7 @@ export function apply(ctx) {
1125
1149
  const settings = ctx.get('settings')
1126
1150
  if (settings === undefined) return []
1127
1151
  try {
1128
- const section = settings.get(PIAI_NS)
1152
+ const section = sectionOfNs(PIAI_NS)
1129
1153
  const providers = section !== null && typeof section === 'object' ? section.providers : undefined
1130
1154
  if (providers === null || typeof providers !== 'object') return []
1131
1155
  return Object.entries(providers).filter(([, profile]) => profile !== null && typeof profile === 'object')
@@ -1145,7 +1169,7 @@ export function apply(ctx) {
1145
1169
  const settings = ctx.get('settings')
1146
1170
  if (settings === undefined) return undefined
1147
1171
  try {
1148
- const section = settings.get(PIAI_NS)
1172
+ const section = sectionOfNs(PIAI_NS)
1149
1173
  const providers = section !== null && typeof section === 'object' ? section.providers : undefined
1150
1174
  const profile = providers !== null && typeof providers === 'object' ? providers[provider] : undefined
1151
1175
  return profile !== null && typeof profile === 'object' ? profile : undefined
@@ -1849,7 +1873,25 @@ export function apply(ctx) {
1849
1873
  }
1850
1874
  // 已经被新一轮取代:这份结论过期了,丢掉。
1851
1875
  if (generation !== probeGeneration) return
1852
- baseVerdicts.set(target.id, { provider: target.id, displayName: target.displayName, ns: target.ns, checkedAt: Date.now(), ...outcome })
1876
+ let modelIds = []
1877
+ if (target.ns === NS) {
1878
+ const deepseek = sectionOf()
1879
+ const models = Array.isArray(deepseek?.models) ? deepseek.models : []
1880
+ modelIds = models.map((m) => (m !== null && typeof m === 'object' ? m.id : m)).filter((id) => typeof id === 'string')
1881
+ } else {
1882
+ const profile = piaiProfileOf(target.id)
1883
+ const models = Array.isArray(profile?.models) ? profile.models : []
1884
+ modelIds = models.map((m) => (m !== null && typeof m === 'object' ? m.id : m)).filter((id) => typeof id === 'string')
1885
+ }
1886
+ baseVerdicts.set(target.id, {
1887
+ provider: target.id,
1888
+ displayName: target.displayName,
1889
+ ns: target.ns,
1890
+ checkedAt: Date.now(),
1891
+ modelCount: modelIds.length,
1892
+ modelIds,
1893
+ ...outcome
1894
+ })
1853
1895
  }))
1854
1896
  if (generation !== probeGeneration) return
1855
1897
  // 清掉真正从路由表里消失的 provider。判据必须是**原始路由表**,不是本轮的
@@ -2204,10 +2246,10 @@ export function apply(ctx) {
2204
2246
  // modelgo 这种 pi-ai 风格 provider 在 'llm-pi-ai' namespace 下;
2205
2247
  // dsh-llm-hub 自己的 NS 是 'llm-deepseek',但写回属于跨 namespace 兜底:
2206
2248
  // plugin 不是 pi-ai 的 owner,只是借用 settings 服务给 pi-ai 段补一份目录。
2207
- const source = settingsCtx.settings.get(PIAI_NS)
2249
+ const source = sectionOfNs(PIAI_NS)
2208
2250
  const updates = await populateEmptyProviderModels(source)
2209
2251
  if (Object.keys(updates).length === 0) return
2210
- const current = settingsCtx.settings.get(PIAI_NS)
2252
+ const current = sectionOfNs(PIAI_NS)
2211
2253
  for (const provider of Object.keys(updates)) {
2212
2254
  const live = current?.providers?.[provider]
2213
2255
  if (Array.isArray(live?.models) && live.models.length > 0) {
@@ -2812,7 +2854,7 @@ export function apply(ctx) {
2812
2854
  /** 读 settings 的 routing 段并按需覆写 mode / activeGroup,持久化。 */
2813
2855
  const applyRoutingSelect = async (patch) => {
2814
2856
  const settings = ctx.get('settings')
2815
- if (settings === undefined || hubRegistered !== true) {
2857
+ if (settings === undefined) {
2816
2858
  return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
2817
2859
  }
2818
2860
  const current = routingObjOf()
@@ -2930,7 +2972,7 @@ export function apply(ctx) {
2930
2972
  /** 写回整份路由组(设置页编辑器保存路径),并返回最新状态。 */
2931
2973
  const applyRoutingConfig = async (payload) => {
2932
2974
  const settings = ctx.get('settings')
2933
- if (settings === undefined || hubRegistered !== true) {
2975
+ if (settings === undefined) {
2934
2976
  return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
2935
2977
  }
2936
2978
  const normalized = normalizeRoutingGroupsInput(payload?.groups)
@@ -3173,6 +3215,18 @@ export function apply(ctx) {
3173
3215
 
3174
3216
  // ── harness 子代理 ────────────────────────────────────────────────────────
3175
3217
 
3218
+ // 把本机装好的外部 agent CLI(codex / claude / agy)注册成子代理提供方,详见
3219
+ // `./harness.js`。
3220
+ //
3221
+ // 为什么用**局部注入**而不是加进模块顶部的 `inject` 数组:`inject` 是硬依赖,
3222
+ // 少一个服务整个插件就不加载 —— 那会让「宿主没装子代理栈」的部署连余额和模型
3223
+ // 发现一起失去。局部注入只让 harness 这一段跟着服务走,缺了就不启用。
3224
+ ctx.inject(['subagents', 'subprocess'], (scope) => {
3225
+ applyHarness(scope).catch((error) => {
3226
+ scope.logger?.warn?.(`dsh-llm-hub: harness 探测失败:${error?.message ?? error}`)
3227
+ })
3228
+ })
3229
+
3176
3230
  // 自检路由:`GET /api/dsh-llm-hub/harness` 报告探到了什么、注册了几个。
3177
3231
  //
3178
3232
  // 为什么需要它:宿主的 `info` 级日志**不落盘**(实测 launchd.log 里只有
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plugins/dsh-llm-hub",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -28,7 +28,7 @@
28
28
  }
29
29
  },
30
30
  "scripts": {
31
- "deploy": "rsync -av --delete lib/ ~/.dsh/profiles/web/node_modules/@dsh-plugins/dsh-llm-hub/lib/ && rsync -av types package.json cordis.patch.yml README.md README.en.md LICENSE ~/.dsh/profiles/web/node_modules/@dsh-plugins/dsh-llm-hub/",
31
+ "deploy": "rsync -av --delete lib/ ~/.dsh/profiles/${DSH_PROFILE:-desktop}/node_modules/@dsh-plugins/dsh-llm-hub/lib/ && rsync -av types package.json cordis.patch.yml README.md README.en.md LICENSE ~/.dsh/profiles/${DSH_PROFILE:-desktop}/node_modules/@dsh-plugins/dsh-llm-hub/",
32
32
  "preview": "npm run deploy && open http://127.0.0.1:3080",
33
33
  "check": "for f in lib/*.js; do node --check \"$f\" || exit 1; done",
34
34
  "test": "node --test",
@@ -79,5 +79,8 @@
79
79
  "lib",
80
80
  "scripts",
81
81
  "types"
82
- ]
82
+ ],
83
+ "devDependencies": {
84
+ "@deepseek-ai/schemastery": "3.18.4"
85
+ }
83
86
  }
@@ -86,3 +86,41 @@ export function detectChangedRuntime(prevRoot, curRoot, curFiles) {
86
86
  }
87
87
  return changed
88
88
  }
89
+
90
+ /**
91
+ * 取 `npm pack --json` 的那一条结果 —— 两种 npm 大版本形状都认。
92
+ *
93
+ * **npm 11 → 12 的破坏性变更**:11 回数组 `[{ files: [...] }]`,
94
+ * 12 回以包名为键的对象 `{ "<name>": { files: [...] } }`。
95
+ * 原先写死 `JSON.parse(out)[0].files`,在 npm 12 上 `[0]` 是 undefined,
96
+ * 报出来的是 `Cannot read properties of undefined (reading 'files')`
97
+ * —— 从这句话完全看不出跟 npm 版本有任何关系。
98
+ *
99
+ * 2026-10-02 同一天咬了两次(museav-cli v3.9.2、museav-mcp 2.2.0):各仓的
100
+ * publish workflow 里写着 `npm install -g npm@latest`,于是 npm 12 一发版,
101
+ * **所有仓的 CI 发版同时挂掉**,而本地(还是 npm 11)怎么跑都是绿的。
102
+ *
103
+ * 抽进 lib 是为了能测(门禁脚本顶层会跑 pack/连 registry,import 不进来)。
104
+ * 形状认不出时**抛一句能看懂的话** —— 这正是今天最缺的东西:当时那句
105
+ * `Cannot read properties of undefined` 让人以为是代码问题,实际是上游换了形状。
106
+ */
107
+ export function packEntry(parsed) {
108
+ // 形状先记下来再判空:报错里要能看出 npm 回了什么,而不是只说「认不出」。
109
+ const shape = Array.isArray(parsed) ? 'array' : parsed === null ? 'null' : typeof parsed
110
+ // 先判空再 Object.values —— 否则 parsed 为 null/undefined 时
111
+ // Object.values 先抛「Cannot convert undefined or null to object」,
112
+ // 又回到那个「看不出是 npm 换了形状」的老问题上。
113
+ let entry
114
+ if (Array.isArray(parsed)) {
115
+ entry = parsed[0]
116
+ } else if (parsed && typeof parsed === 'object') {
117
+ entry = Object.values(parsed)[0]
118
+ }
119
+ if (!entry || !Array.isArray(entry.files)) {
120
+ throw new Error(
121
+ `npm pack --json 的返回形状认不出(顶层是 ${shape})` +
122
+ `,npm 可能又换了形状 —— 见 gate-lib.mjs 的 packEntry 注释`,
123
+ )
124
+ }
125
+ return entry
126
+ }
@@ -31,7 +31,7 @@ import { dirname, join, relative } from 'node:path'
31
31
  import { fileURLToPath } from 'node:url'
32
32
  import { tmpdir } from 'node:os'
33
33
  import { createHash } from 'node:crypto'
34
- import { EXCLUDE, isRuntime, collectAllFiles, detectChangedRuntime } from './gate-lib.mjs'
34
+ import { EXCLUDE, isRuntime, collectAllFiles, detectChangedRuntime, packEntry } from './gate-lib.mjs'
35
35
 
36
36
  const GATE_VERSION = 1
37
37
 
@@ -48,7 +48,7 @@ try {
48
48
  const out = execFileSync('npm', ['pack', '--dry-run', '--json'], {
49
49
  cwd: pkgRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit']
50
50
  })
51
- packFiles = JSON.parse(out)[0].files.map(f => f.path)
51
+ packFiles = packEntry(JSON.parse(out)).files.map(f => f.path)
52
52
  } catch (err) {
53
53
  console.error('[prepublish-gate] npm pack --dry-run 失败:', err.message)
54
54
  process.exit(2)