@goodandready/dsh-moa 0.2.65 → 0.2.66

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
@@ -385,3 +385,9 @@ export {
385
385
  classifyLlmError,
386
386
  isFailoverEligible,
387
387
  } from './errors.js'
388
+ export {
389
+ validatePreset,
390
+ validateAllPresets,
391
+ getActiveLlmProviders,
392
+ getActiveLlmModels,
393
+ } from './moa-model-validator.js'
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Dynamic Preset Model Validation via ctx.llm Provider Registry (#283)
3
+ * Inspects active providers and models, detects dead/unregistered slots,
4
+ * and produces diagnostic reports for logging and UI surfacing.
5
+ */
6
+
7
+ import { logger } from './logger.js'
8
+
9
+ /**
10
+ * Discovers currently registered / active LLM providers in ctx.
11
+ * Queries ctx.llm.listProviders(), ctx.llm.listConfigurableProviders(),
12
+ * and active provider maps.
13
+ *
14
+ * @param {object} ctx
15
+ * @returns {Promise<Set<string>>}
16
+ */
17
+ export async function getActiveLlmProviders(ctx) {
18
+ const providers = new Set()
19
+ if (!ctx || !ctx.llm) return providers
20
+
21
+ // 1. ctx.llm.listProviders()
22
+ if (typeof ctx.llm.listProviders === 'function') {
23
+ try {
24
+ const list = await ctx.llm.listProviders()
25
+ if (Array.isArray(list)) {
26
+ for (const p of list) {
27
+ const id = typeof p === 'string' ? p : (p?.id || p?.provider)
28
+ if (id) providers.add(String(id).toLowerCase())
29
+ }
30
+ }
31
+ } catch (_e) {}
32
+ }
33
+
34
+ // 2. ctx.llm.listConfigurableProviders()
35
+ if (typeof ctx.llm.listConfigurableProviders === 'function') {
36
+ try {
37
+ const list = ctx.llm.listConfigurableProviders()
38
+ if (Array.isArray(list)) {
39
+ for (const p of list) {
40
+ const id = typeof p === 'string' ? p : (p?.id || p?.provider)
41
+ if (id) providers.add(String(id).toLowerCase())
42
+ }
43
+ }
44
+ } catch (_e) {}
45
+ }
46
+
47
+ // 3. Fallback to ctx.llm.providers object or map
48
+ if (ctx.llm.providers) {
49
+ if (typeof ctx.llm.providers.keys === 'function') {
50
+ for (const k of ctx.llm.providers.keys()) providers.add(String(k).toLowerCase())
51
+ } else if (typeof ctx.llm.providers === 'object') {
52
+ for (const k of Object.keys(ctx.llm.providers)) providers.add(String(k).toLowerCase())
53
+ }
54
+ }
55
+
56
+ return providers
57
+ }
58
+
59
+ /**
60
+ * Discovers registered models per provider if available.
61
+ *
62
+ * @param {object} ctx
63
+ * @param {Set<string>} [providers]
64
+ * @returns {Promise<Map<string, Set<string>>>} Map of provider -> Set of model IDs
65
+ */
66
+ export async function getActiveLlmModels(ctx, providers) {
67
+ const modelMap = new Map()
68
+ if (!ctx || !ctx.llm) return modelMap
69
+
70
+ const targetProviders = providers || await getActiveLlmProviders(ctx)
71
+
72
+ // 1. ctx.llm.listAvailableModels()
73
+ if (typeof ctx.llm.listAvailableModels === 'function') {
74
+ try {
75
+ const all = await ctx.llm.listAvailableModels()
76
+ if (Array.isArray(all)) {
77
+ for (const item of all) {
78
+ const p = String(item?.provider || '').toLowerCase()
79
+ const m = String(item?.id || item?.model || '').toLowerCase()
80
+ if (p && m) {
81
+ if (!modelMap.has(p)) modelMap.set(p, new Set())
82
+ modelMap.get(p).add(m)
83
+ }
84
+ }
85
+ }
86
+ } catch (_e) {}
87
+ }
88
+
89
+ // 2. Query each provider with ctx.llm.listModels(p)
90
+ if (typeof ctx.llm.listModels === 'function') {
91
+ for (const p of targetProviders) {
92
+ try {
93
+ const mList = await Promise.race([
94
+ ctx.llm.listModels(p),
95
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1500)),
96
+ ])
97
+ if (Array.isArray(mList)) {
98
+ if (!modelMap.has(p)) modelMap.set(p, new Set())
99
+ for (const m of mList) {
100
+ const mId = typeof m === 'string' ? m : (m?.id || m?.model || m?.name)
101
+ if (mId) modelMap.get(p).add(String(mId).toLowerCase())
102
+ }
103
+ }
104
+ } catch (_e) {}
105
+ }
106
+ }
107
+
108
+ return modelMap
109
+ }
110
+
111
+ /**
112
+ * Validates a single slot against active providers and models.
113
+ */
114
+ export function validateSlot(slot, role, activeProviders, modelMap) {
115
+ if (!slot) return { valid: false, role, reason: 'empty_slot' }
116
+ const provider = String(slot.provider || '').toLowerCase().trim()
117
+ const model = String(slot.model || '').toLowerCase().trim()
118
+ const label = `${slot.provider || 'unknown'}:${slot.model || 'unknown'}`
119
+
120
+ if (!provider) {
121
+ return { valid: false, role, provider: slot.provider, model: slot.model, label, reason: 'missing_provider' }
122
+ }
123
+
124
+ // If we have active providers discovered and provider is not in the list
125
+ if (activeProviders && activeProviders.size > 0 && !activeProviders.has(provider)) {
126
+ return { valid: false, role, provider: slot.provider, model: slot.model, label, reason: 'provider_not_found' }
127
+ }
128
+
129
+ // If we have models list for this provider, check if model exists
130
+ if (modelMap && modelMap.has(provider)) {
131
+ const models = modelMap.get(provider)
132
+ if (models && models.size > 0 && !models.has(model)) {
133
+ return { valid: false, role, provider: slot.provider, model: slot.model, label, reason: 'model_not_found' }
134
+ }
135
+ }
136
+
137
+ return { valid: true, role, provider: slot.provider, model: slot.model, label }
138
+ }
139
+
140
+ /**
141
+ * Validates a preset's reference models, aggregator, and fallbacks.
142
+ *
143
+ * @param {object} preset
144
+ * @param {object} [options]
145
+ * @returns {object}
146
+ */
147
+ export function validatePreset(preset, { activeProviders, modelMap } = {}) {
148
+ if (!preset || typeof preset !== 'object') {
149
+ return {
150
+ valid: false,
151
+ presetName: 'unknown',
152
+ deadModels: [{ role: 'preset', reason: 'invalid_preset_object' }],
153
+ warnings: ['Preset is not a valid object'],
154
+ }
155
+ }
156
+
157
+ const presetName = preset.name || preset.id || 'unnamed'
158
+ const deadModels = []
159
+ const warnings = []
160
+
161
+ // Check aggregator
162
+ const aggSlot = preset.aggregator_model || preset.aggregator
163
+ if (aggSlot) {
164
+ const res = validateSlot(aggSlot, 'aggregator', activeProviders, modelMap)
165
+ if (!res.valid) {
166
+ deadModels.push(res)
167
+ warnings.push(`Aggregator model ${res.label} has unreachable provider or model (${res.reason})`)
168
+ }
169
+ }
170
+
171
+ // Check reference models
172
+ const refs = Array.isArray(preset.references)
173
+ ? preset.references
174
+ : (Array.isArray(preset.reference_models) ? preset.reference_models : [])
175
+
176
+ if (refs.length === 0) {
177
+ warnings.push('Preset has no reference models configured')
178
+ }
179
+
180
+ for (let idx = 0; idx < refs.length; idx++) {
181
+ const slot = refs[idx]
182
+ const res = validateSlot(slot, `reference_${idx + 1}`, activeProviders, modelMap)
183
+ if (!res.valid) {
184
+ deadModels.push(res)
185
+ warnings.push(`Reference candidate ${res.label} has unreachable provider or model (${res.reason})`)
186
+ }
187
+
188
+ // Check slot fallbacks if any
189
+ if (Array.isArray(slot?.fallbacks)) {
190
+ for (const fb of slot.fallbacks) {
191
+ const fbRes = validateSlot(fb, `fallback_for_${res.label}`, activeProviders, modelMap)
192
+ if (!fbRes.valid) {
193
+ deadModels.push(fbRes)
194
+ warnings.push(`Reserve fallback ${fbRes.label} for ${res.label} is unreachable (${fbRes.reason})`)
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ return {
201
+ presetId: preset.id || presetName,
202
+ presetName,
203
+ valid: deadModels.length === 0,
204
+ hasDeadModels: deadModels.length > 0,
205
+ deadModels,
206
+ warnings,
207
+ totalSlotsChecked: (aggSlot ? 1 : 0) + refs.length,
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Validates an array of presets and logs warnings if any dead models exist.
213
+ *
214
+ * @param {Array<object>} presets
215
+ * @param {object} ctx
216
+ * @returns {Promise<object>}
217
+ */
218
+ export async function validateAllPresets(presets, ctx) {
219
+ const activeProviders = await getActiveLlmProviders(ctx)
220
+ const modelMap = await getActiveLlmModels(ctx, activeProviders)
221
+
222
+ const presetResults = []
223
+ let totalDeadModels = 0
224
+
225
+ for (const preset of (presets || [])) {
226
+ const result = validatePreset(preset, { activeProviders, modelMap })
227
+ presetResults.push(result)
228
+ if (result.hasDeadModels) {
229
+ totalDeadModels += result.deadModels.length
230
+ const deadLabels = result.deadModels.map((m) => `${m.label} (${m.reason})`).join(', ')
231
+ logger.warn(`[dsh-moa] Preset "${result.presetName}" contains ${result.deadModels.length} dead/unregistered models: ${deadLabels}`)
232
+ }
233
+ }
234
+
235
+ return {
236
+ valid: totalDeadModels === 0,
237
+ activeProviders: Array.from(activeProviders),
238
+ totalDeadModels,
239
+ presets: presetResults,
240
+ validatedAt: Date.now(),
241
+ }
242
+ }
@@ -152,4 +152,25 @@ export class MoaService {
152
152
  emit(event, payload) {
153
153
  emitEvent(this.ctx, event, payload)
154
154
  }
155
+
156
+ /**
157
+ * Validate presets against active LLM providers in ctx.llm
158
+ * @param {Array<object>} [presets]
159
+ * @returns {Promise<object>}
160
+ */
161
+ async validatePresets(presets) {
162
+ const list = presets || this.getPresets()
163
+ const { validateAllPresets } = await import('./moa-model-validator.js')
164
+ return validateAllPresets(list, this.ctx)
165
+ }
166
+
167
+ /**
168
+ * Get list of currently active LLM provider names
169
+ * @returns {Promise<Array<string>>}
170
+ */
171
+ async getActiveProviders() {
172
+ const { getActiveLlmProviders } = await import('./moa-model-validator.js')
173
+ const set = await getActiveLlmProviders(this.ctx)
174
+ return Array.from(set)
175
+ }
155
176
  }
@@ -1,3 +1,4 @@
1
+ import { validateAllPresets } from '../moa-model-validator.js'
1
2
  import { writeJson, readBody, assertAuthorizedRequest } from '../access.js'
2
3
  import { isSafeWriteRequest } from '../updater.js'
3
4
  import { resolvePresetForPrompt } from '../moa-router.js'
@@ -134,10 +135,15 @@ ctx.effect(() => {
134
135
  if (!assertAuthorizedRequest(req, res, ctx)) return
135
136
  const cfg = live()
136
137
  if (req.method === 'GET') {
138
+ let validation = null
139
+ try {
140
+ validation = await validateAllPresets(cfg.presets || [], ctx)
141
+ } catch (_e) {}
137
142
  writeJson(res, 200, {
138
143
  ok: true,
139
144
  defaultPreset: cfg.default_preset || 'default',
140
145
  presets: cfg.presets || [],
146
+ validation,
141
147
  })
142
148
  return
143
149
  }
@@ -179,12 +185,17 @@ ctx.effect(() => {
179
185
  }
180
186
 
181
187
  const unwrapped = typeof plainConfig === 'function' ? plainConfig(validated) : validated
188
+ let validation = null
189
+ try {
190
+ validation = await validateAllPresets(unwrapped.presets || [], ctx)
191
+ } catch (_e) {}
182
192
  writeJson(res, 200, {
183
193
  ok: true,
184
194
  persisted: persistResult?.persisted ?? false,
185
195
  enabled: unwrapped.enabled !== false,
186
196
  defaultPreset: unwrapped.default_preset,
187
197
  presets: unwrapped.presets,
198
+ validation,
188
199
  })
189
200
  } catch (err) { writeJson(res, 400, { ok: false, error: err?.message || String(err) }) }
190
201
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.65",
3
+ "version": "0.2.66",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --import ./test/_setup.mjs --test test/*.test.mjs",
22
+ "test": "node scripts/build-client.mjs --check && node --import ./test/_setup.mjs --test test/*.test.mjs",
23
23
  "build:client": "node scripts/build-client.mjs"
24
24
  },
25
25
  "keywords": [
@@ -66,6 +66,8 @@
66
66
  }
67
67
  },
68
68
  "devDependencies": {
69
+ "esbuild": "^0.28.2",
70
+ "eslint": "^10.14.0",
69
71
  "react": "^18.3.1",
70
72
  "react-dom": "^18.3.1"
71
73
  }