@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.
- package/lib/client.js +2000 -2245
- package/lib/commands.js +83 -0
- package/lib/errors.js +176 -0
- package/lib/index.js +21 -0
- package/lib/moa-candidates.js +33 -11
- package/lib/moa-model-validator.js +242 -0
- package/lib/moa-service.js +21 -0
- package/lib/routes/presets.js +11 -0
- package/package.json +4 -2
package/lib/commands.js
ADDED
|
@@ -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'
|
package/lib/moa-candidates.js
CHANGED
|
@@ -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
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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})
|
|
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
|
|
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]
|
|
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
|
+
}
|
package/lib/moa-service.js
CHANGED
|
@@ -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
|
}
|