@goodandready/dsh-moa 0.2.64 → 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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Official Slash Command Registration for /moa via dsh-commands service (#275)
3
+ */
4
+
5
+ import { service } from './services.js'
6
+
7
+ export function registerMoaCommands(ctx, { live, moaService }) {
8
+ const registerWithService = (cmdCtx) => {
9
+ if (!cmdCtx) return
10
+ const commands = service(cmdCtx, 'commands')
11
+ if (!commands || typeof commands.register !== 'function') return
12
+
13
+ const formatPresetsList = (cfg) => {
14
+ const presets = Array.isArray(cfg?.presets) ? cfg.presets : []
15
+ if (presets.length === 0) return 'No presets configured in dsh-moa.'
16
+ return '### 🧠 Configured MoA Presets:\n' + presets.map((p, i) =>
17
+ `- **${p.name}** (${p.id || `preset-${i + 1}`}): ${p.description || 'Consensus preset'} | ${p.references?.length || p.reference_models?.length || 0} advisors`
18
+ ).join('\n')
19
+ }
20
+
21
+ const commandHandler = async (invocation) => {
22
+ const rawText = typeof invocation === 'string' ? invocation : (invocation?.text || invocation?.raw || invocation?.args || '')
23
+ const trimmed = String(rawText).trim()
24
+
25
+ if (!trimmed || trimmed === 'help' || trimmed === '--help') {
26
+ return {
27
+ text: '🧠 **MoA Command Guide**:\n- `/moa presets` — list configured presets\n- `/moa history` — view recent runs\n- `/moa status` — check plugin status\n- `/moa [--preset <name>] <prompt>` — run MoA consensus in chat',
28
+ }
29
+ }
30
+
31
+ if (trimmed === 'presets' || trimmed === 'list') {
32
+ const cfg = typeof live === 'function' ? live() : {}
33
+ return { text: formatPresetsList(cfg) }
34
+ }
35
+
36
+ if (trimmed === 'status') {
37
+ const cfg = typeof live === 'function' ? live() : {}
38
+ return {
39
+ text: `🧠 **MoA Plugin Status**: ${cfg?.enabled !== false ? 'Enabled' : 'Disabled'} | Default preset: \`${cfg?.default_preset || 'default'}\``,
40
+ }
41
+ }
42
+
43
+ if (trimmed === 'history') {
44
+ const hist = moaService?.getHistory ? moaService.getHistory(5, 0) : { runs: [] }
45
+ if (!hist?.runs?.length) return { text: 'No MoA runs recorded in history.' }
46
+ return {
47
+ text: '### 📜 Recent MoA Runs:\n' + hist.runs.slice(0, 5).map((r) =>
48
+ `- \`${r.id?.slice(0, 8)}\` (${r.preset || 'default'}): "${r.prompt?.slice(0, 40)}..." (Winner: ${r.winnerModel || 'none'}, $${(r.totalCostUsd || 0).toFixed(4)})`
49
+ ).join('\n'),
50
+ }
51
+ }
52
+
53
+ return {
54
+ text: `🧠 MoA turn dispatched: "${trimmed}". The turn will execute with live consensus streaming.`,
55
+ }
56
+ }
57
+
58
+ const unregister = commands.register({
59
+ definitionId: '@goodandready/dsh-moa',
60
+ name: 'moa',
61
+ description: 'Run Mixture of Agents consensus or inspect presets (/moa [presets|history|status|<prompt>])',
62
+ input: { hint: '[presets|history|status|<prompt>]' },
63
+ handler: commandHandler,
64
+ execute: async (rawArgs, invocation = null) => {
65
+ const res = await commandHandler(invocation || rawArgs)
66
+ return res?.text || String(res || '')
67
+ },
68
+ })
69
+
70
+ const eff = typeof cmdCtx.effect === 'function' ? cmdCtx.effect : (fn) => fn()
71
+ eff(() => () => {
72
+ try {
73
+ if (typeof unregister === 'function') unregister()
74
+ } catch (_e) {}
75
+ }, 'dsh-moa: slash command registration')
76
+ }
77
+
78
+ if (service(ctx, 'commands')?.register) {
79
+ registerWithService(ctx)
80
+ } else if (typeof ctx.inject === 'function') {
81
+ ctx.inject(['commands'], registerWithService)
82
+ }
83
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Centralized LLM and MoA Pipeline Error Taxonomy and Classification
3
+ */
4
+
5
+ export class MoaError extends Error {
6
+ /**
7
+ * @param {string} message
8
+ * @param {object} [options]
9
+ */
10
+ constructor(message, options = {}) {
11
+ super(message)
12
+ this.name = new.target.name || 'MoaError'
13
+ this.code = options.code || 'MOA_ERROR'
14
+ this.status = typeof options.status === 'number' ? options.status : undefined
15
+ this.isRetryable = Boolean(options.isRetryable)
16
+ this.fallbackEligible = options.fallbackEligible !== false
17
+ this.cause = options.cause
18
+ }
19
+ }
20
+
21
+ export class RateLimitError extends MoaError {
22
+ constructor(message, options = {}) {
23
+ super(message, {
24
+ code: 'RATE_LIMIT_EXCEEDED',
25
+ status: 429,
26
+ isRetryable: true,
27
+ fallbackEligible: true,
28
+ ...options,
29
+ })
30
+ }
31
+ }
32
+
33
+ export class ContextOverflowError extends MoaError {
34
+ constructor(message, options = {}) {
35
+ super(message, {
36
+ code: 'CONTEXT_WINDOW_EXCEEDED',
37
+ status: 400,
38
+ isRetryable: false,
39
+ fallbackEligible: true,
40
+ ...options,
41
+ })
42
+ }
43
+ }
44
+
45
+ export class AuthenticationError extends MoaError {
46
+ constructor(message, options = {}) {
47
+ super(message, {
48
+ code: 'AUTHENTICATION_FAILED',
49
+ status: options.status || 401,
50
+ isRetryable: false,
51
+ fallbackEligible: false,
52
+ ...options,
53
+ })
54
+ }
55
+ }
56
+
57
+ export class ModelNotFoundError extends MoaError {
58
+ constructor(message, options = {}) {
59
+ super(message, {
60
+ code: 'MODEL_NOT_FOUND',
61
+ status: 404,
62
+ isRetryable: false,
63
+ fallbackEligible: true,
64
+ ...options,
65
+ })
66
+ }
67
+ }
68
+
69
+ export class TransientNetworkError extends MoaError {
70
+ constructor(message, options = {}) {
71
+ super(message, {
72
+ code: 'TRANSIENT_NETWORK_ERROR',
73
+ status: options.status || 503,
74
+ isRetryable: true,
75
+ fallbackEligible: true,
76
+ ...options,
77
+ })
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Classifies an unknown error or exception into a structured MoA domain error.
83
+ *
84
+ * @param {unknown} err
85
+ * @returns {MoaError}
86
+ */
87
+ export function classifyLlmError(err) {
88
+ if (!err) return new MoaError('Unknown error')
89
+ if (err instanceof MoaError) return err
90
+
91
+ const msg = String(err.message || err).toLowerCase()
92
+ const status = Number(err.status || err.statusCode || (msg.match(/\b(401|403|404|429|500|502|503|504)\b/)?.[1])) || undefined
93
+ const code = String(err.code || '').toUpperCase()
94
+
95
+ // Rate limit / Quota
96
+ if (
97
+ status === 429 ||
98
+ msg.includes('rate limit') ||
99
+ msg.includes('too many requests') ||
100
+ msg.includes('tpm') ||
101
+ msg.includes('rpm') ||
102
+ msg.includes('quota exceeded') ||
103
+ code.includes('RATE_LIMIT') ||
104
+ code.includes('QUOTA')
105
+ ) {
106
+ return new RateLimitError(err.message || String(err), { status: 429, cause: err })
107
+ }
108
+
109
+ // Context window overflow
110
+ if (
111
+ msg.includes('context window') ||
112
+ msg.includes('context length') ||
113
+ msg.includes('maximum context') ||
114
+ msg.includes('too long') ||
115
+ msg.includes('token limit') ||
116
+ code.includes('CONTEXT_WINDOW')
117
+ ) {
118
+ return new ContextOverflowError(err.message || String(err), { status: 400, cause: err })
119
+ }
120
+
121
+ // Authentication & permissions
122
+ if (
123
+ status === 401 ||
124
+ status === 403 ||
125
+ msg.includes('unauthorized') ||
126
+ msg.includes('invalid api key') ||
127
+ msg.includes('forbidden') ||
128
+ msg.includes('authentication') ||
129
+ code.includes('AUTH') ||
130
+ code.includes('INVALID_CREDENTIAL')
131
+ ) {
132
+ return new AuthenticationError(err.message || String(err), { status: status || 401, cause: err })
133
+ }
134
+
135
+ // Model not found / unavailable
136
+ if (
137
+ status === 404 ||
138
+ msg.includes('model not found') ||
139
+ msg.includes('does not exist') ||
140
+ msg.includes('unknown model') ||
141
+ code.includes('MODEL_NOT_FOUND')
142
+ ) {
143
+ return new ModelNotFoundError(err.message || String(err), { status: 404, cause: err })
144
+ }
145
+
146
+ // Transient network / gateway / timeout
147
+ if (
148
+ status === 502 ||
149
+ status === 503 ||
150
+ status === 504 ||
151
+ msg.includes('timeout') ||
152
+ msg.includes('econnreset') ||
153
+ msg.includes('etimedout') ||
154
+ msg.includes('network') ||
155
+ msg.includes('socket hang up') ||
156
+ msg.includes('service unavailable') ||
157
+ msg.includes('bad gateway') ||
158
+ code.includes('TIMEOUT') ||
159
+ code.includes('ECONNRESET')
160
+ ) {
161
+ return new TransientNetworkError(err.message || String(err), { status: status || 503, cause: err })
162
+ }
163
+
164
+ return new MoaError(err.message || String(err), { status, cause: err })
165
+ }
166
+
167
+ /**
168
+ * Returns true if the error qualifies for candidate failover to a reserve model.
169
+ *
170
+ * @param {unknown} err
171
+ * @returns {boolean}
172
+ */
173
+ export function isFailoverEligible(err) {
174
+ const classified = classifyLlmError(err)
175
+ return classified.fallbackEligible
176
+ }
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerMoaCommands } from './commands.js'
1
2
  import { service, getWebServerPort } from './services.js'
2
3
  import crypto from 'node:crypto'
3
4
  import path from 'node:path'
@@ -185,6 +186,9 @@ export function apply(ctx, config) {
185
186
  ctx.inject(['webServer'], registerWebSurfaces)
186
187
  }
187
188
 
189
+ // Official slash command /moa registration via dsh-commands (#275)
190
+ registerMoaCommands(ctx, { live, moaService })
191
+
188
192
  // Session turn interceptor: route turn to real aggregator model & stream MoA synthesis
189
193
  ctx.effect(() => {
190
194
  return () => {
@@ -370,3 +374,20 @@ export {
370
374
  handleWorkbenchGet,
371
375
  handleWorkbenchAssemble,
372
376
  } from './moa-workbench.js'
377
+ export { registerMoaCommands } from './commands.js'
378
+ export {
379
+ MoaError,
380
+ RateLimitError,
381
+ ContextOverflowError,
382
+ AuthenticationError,
383
+ ModelNotFoundError,
384
+ TransientNetworkError,
385
+ classifyLlmError,
386
+ isFailoverEligible,
387
+ } from './errors.js'
388
+ export {
389
+ validatePreset,
390
+ validateAllPresets,
391
+ getActiveLlmProviders,
392
+ getActiveLlmModels,
393
+ } from './moa-model-validator.js'
@@ -1,3 +1,4 @@
1
+ import { classifyLlmError, isFailoverEligible } from './errors.js'
1
2
  import { logger } from './logger.js'
2
3
  /**
3
4
  * DeepSeek Harness Mixture of Agents (MoA) — Candidate Fan-Out & Retry
@@ -18,14 +19,13 @@ export async function callWithTransientRetry(callLlmFn, callArgs, maxRetries = 0
18
19
  return await callLlmFn(callArgs)
19
20
  } catch (err) {
20
21
  attempt++
21
- const msg = err?.message || String(err)
22
- const isTransient = /429|rate limit|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
23
- if (attempt <= maxRetries && isTransient && !callArgs?.signal?.aborted) {
22
+ const classified = classifyLlmError(err)
23
+ if (attempt <= maxRetries && classified.isRetryable && !callArgs?.signal?.aborted) {
24
24
  await new Promise((r) => setTimeout(r, retryDelayMs))
25
25
  if (callArgs?.signal?.aborted) throw new Error('Aborted')
26
26
  continue
27
27
  }
28
- throw err
28
+ throw classified
29
29
  }
30
30
  }
31
31
  }
@@ -176,10 +176,29 @@ export async function runReferencesParallel(references, messages, options = {},
176
176
  return
177
177
  }
178
178
 
179
- // Feature 10: Local fallback models if online candidate fails
179
+ const classified = classifyLlmError(err)
180
+ const canFailover = isFailoverEligible(classified)
181
+
182
+ // Reserve model failover: checks slot.fallbacks, options.fallback_models, or options.local_fallback_models
180
183
  let fallbackRecovered = false
181
- if (options.local_fallback_enabled && Array.isArray(options.local_fallback_models) && options.local_fallback_models.length > 0) {
182
- for (const fbSlot of options.local_fallback_models) {
184
+ const fallbackCandidates = [
185
+ ...(Array.isArray(slot?.fallbacks) ? slot.fallbacks : []),
186
+ ...(Array.isArray(options.fallback_models) ? options.fallback_models : []),
187
+ ...(options.local_fallback_enabled && Array.isArray(options.local_fallback_models) ? options.local_fallback_models : []),
188
+ ].filter(Boolean)
189
+
190
+ const seenFb = new Set([`${slot.provider}:${slot.model}`])
191
+ const eligibleFallbacks = []
192
+ for (const fb of fallbackCandidates) {
193
+ const key = `${fb.provider}:${fb.model}`
194
+ if (!seenFb.has(key)) {
195
+ seenFb.add(key)
196
+ eligibleFallbacks.push(fb)
197
+ }
198
+ }
199
+
200
+ if (canFailover && eligibleFallbacks.length > 0) {
201
+ for (const fbSlot of eligibleFallbacks) {
183
202
  if (options.signal?.aborted) break
184
203
  const fbLabel = slotLabel(fbSlot)
185
204
  const fbAbortCtrl = new AbortController()
@@ -198,7 +217,7 @@ export async function runReferencesParallel(references, messages, options = {},
198
217
 
199
218
  try {
200
219
  if (typeof onProgress === 'function') {
201
- onProgress(`🔄 *Candidate ${i + 1} (${label}) failed, attempting local fallback to ${fbLabel}...*\n`)
220
+ onProgress(`🔄 *Candidate ${i + 1} (${label}) hit ${classified.name}, failover to ${fbLabel}...*\n`)
202
221
  }
203
222
  const fbCallPromise = callWithTransientRetry(
204
223
  callLlm,
@@ -243,14 +262,15 @@ export async function runReferencesParallel(references, messages, options = {},
243
262
  ok: true,
244
263
  was_fallback: true,
245
264
  original_model: label,
265
+ failoverReason: classified.code,
246
266
  }
247
267
  fallbackRecovered = true
248
268
  if (typeof onProgress === 'function') {
249
- onProgress(`✅ *Candidate ${i + 1}/${total} recovered using fallback (${fbLabel})*\n`)
269
+ onProgress(`✅ *Candidate ${i + 1}/${total} recovered using failover (${fbLabel})*\n`)
250
270
  }
251
271
  break
252
272
  } catch (fbErr) {
253
- logger.warn(`[dsh-moa] Fallback ${fbLabel} also failed:`, fbErr?.message || fbErr)
273
+ logger.warn(`[dsh-moa] Reserve model ${fbLabel} also failed:`, fbErr?.message || fbErr)
254
274
  } finally {
255
275
  if (fbTimer) clearTimeout(fbTimer)
256
276
  if (removeParentAbort) removeParentAbort()
@@ -260,7 +280,7 @@ export async function runReferencesParallel(references, messages, options = {},
260
280
 
261
281
  if (!fallbackRecovered) {
262
282
  finishedCount++
263
- const errMsg = err?.message || String(err)
283
+ const errMsg = classified?.message || err?.message || String(err)
264
284
  logger.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
265
285
  if (typeof onProgress === 'function') {
266
286
  onProgress(`⚠️ *Candidate ${i + 1}/${total} (${label}) error: ${errMsg}*\n`)
@@ -276,6 +296,8 @@ export async function runReferencesParallel(references, messages, options = {},
276
296
  costUsd: 0,
277
297
  ok: false,
278
298
  error: errMsg,
299
+ errorClass: classified.name,
300
+ errorCode: classified.code,
279
301
  }
280
302
  }
281
303
  } finally {
@@ -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
  }