@dsh-plugins/dsh-llm-hub 1.7.0 → 1.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.8.0] - 2026-09-29
6
+
7
+ ### 🔧 适配 DSH 0.1.7 的 settings 架构(升级到 0.1.7 后配置读不到的那版)
8
+
9
+ **症状**:升到 DSH 0.1.7 后,路由组「凭空消失」—— chip 不渲染,设置页路由卡是空的,
10
+ 写接口一律拒绝。数据其实好好躺在配置里,只是没人读。
11
+
12
+ **根因**:0.1.7 把 settings 架构整个换掉了。`settings.register(ns, schema)` 和
13
+ `settings.get(ns)` 双双消失(0.1.5 的 `dsh-settings` 里有 20 处 `register` 引用,
14
+ 0.1.7 是 0 处)。老代码里 `settings.register(...)` 抛 `TypeError` 被 catch 吞掉,
15
+ `hubRegistered` 恒为 false,于是整个 `dsh-llm-hub` 段的读写全部短路。
16
+
17
+ **改造**
18
+ - 导出 `Config`,配置改由 `apply(ctx, config)` 注入(0.1.7 的官方姿势,
19
+ `@deepseek-ai/dsh-llm-pi-ai` 的 providers 段同款)
20
+ - 读自己的段走 `config.routing.get()`;读**别人**的段(`llm-pi-ai` / `llm-deepseek`)
21
+ 走 `settings.describe()` —— 0.1.7 里那是唯一的跨插件读法
22
+ - 拆掉 `hubRegistered` 守卫(该概念已不存在)
23
+ - 三个字段全部标 `.volatile()`:`settings.update()` 拒绝「没有 volatile 字段的条目」
24
+ (原话:`Plugin entry "dsh-llm-hub" has no volatile fields`),而 volatile 的语义
25
+ 正是本插件要的 —— 路由组/预警/别名都是每次请求惰性重读的,切一次路由不该重跑 apply
26
+ - `@deepseek-ai/schemastery` 加为 **devDependency**(静态 import 需要它才能加载;
27
+ 运行时依赖仍是零 —— 它由 DSH 宿主提供)
28
+
29
+ **配置迁移**:0.1.7 启动时把全局 `settings.yaml` 迁进 profile 的 `cordis.patch.yml`
30
+ 并把原文件改名为 `settings.yaml.imported`。本插件的段需要一条 `dsh-llm-hub` entry;
31
+ 未迁移时该段读为空,也就是上面那个症状。
32
+
33
+ **验证**:157 个单测 + 门禁全过;真机确认路由组、设置页路由卡、跨插件读(balance 能
34
+ 解析出真实 key)、写路径(`routing/select` 200)全部恢复。
35
+
5
36
  ## [1.7.0] - 2026-09-29
6
37
 
7
38
  ### 🧭 路由设置卡:始终折叠,展开才编辑
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。
740
+ const settings = ctx.get('settings')
741
+ if (settings === undefined) return undefined
742
+ try {
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) => {
732
757
  const settings = ctx.get('settings')
733
758
  if (settings === undefined) return undefined
734
759
  try {
735
- const value = settings.get(NS)
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
  }
@@ -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
@@ -2204,10 +2228,10 @@ export function apply(ctx) {
2204
2228
  // modelgo 这种 pi-ai 风格 provider 在 'llm-pi-ai' namespace 下;
2205
2229
  // dsh-llm-hub 自己的 NS 是 'llm-deepseek',但写回属于跨 namespace 兜底:
2206
2230
  // plugin 不是 pi-ai 的 owner,只是借用 settings 服务给 pi-ai 段补一份目录。
2207
- const source = settingsCtx.settings.get(PIAI_NS)
2231
+ const source = sectionOfNs(PIAI_NS)
2208
2232
  const updates = await populateEmptyProviderModels(source)
2209
2233
  if (Object.keys(updates).length === 0) return
2210
- const current = settingsCtx.settings.get(PIAI_NS)
2234
+ const current = sectionOfNs(PIAI_NS)
2211
2235
  for (const provider of Object.keys(updates)) {
2212
2236
  const live = current?.providers?.[provider]
2213
2237
  if (Array.isArray(live?.models) && live.models.length > 0) {
@@ -2812,7 +2836,7 @@ export function apply(ctx) {
2812
2836
  /** 读 settings 的 routing 段并按需覆写 mode / activeGroup,持久化。 */
2813
2837
  const applyRoutingSelect = async (patch) => {
2814
2838
  const settings = ctx.get('settings')
2815
- if (settings === undefined || hubRegistered !== true) {
2839
+ if (settings === undefined) {
2816
2840
  return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
2817
2841
  }
2818
2842
  const current = routingObjOf()
@@ -2930,7 +2954,7 @@ export function apply(ctx) {
2930
2954
  /** 写回整份路由组(设置页编辑器保存路径),并返回最新状态。 */
2931
2955
  const applyRoutingConfig = async (payload) => {
2932
2956
  const settings = ctx.get('settings')
2933
- if (settings === undefined || hubRegistered !== true) {
2957
+ if (settings === undefined) {
2934
2958
  return { ok: false, error: 'settings 命名空间 dsh-llm-hub 尚未就绪 —— 稍后重试' }
2935
2959
  }
2936
2960
  const normalized = normalizeRoutingGroupsInput(payload?.groups)
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.8.0",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -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
  }