@goodandready/dsh-agent-orchestrator 0.1.6
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/LICENSE +22 -0
- package/README.md +184 -0
- package/README.ru.md +86 -0
- package/README.zh.md +64 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1374 -0
- package/lib/http-guard.js +166 -0
- package/lib/index.js +950 -0
- package/lib/integrations/kanban-bridge.js +102 -0
- package/lib/pipeline/cache-prefixer.js +226 -0
- package/lib/pipeline/concurrency-gate.js +110 -0
- package/lib/pipeline/dag-engine.js +290 -0
- package/lib/pipeline/decision-trace.js +124 -0
- package/lib/pipeline/decomposer.js +102 -0
- package/lib/pipeline/delegation.js +625 -0
- package/lib/pipeline/guidance.js +94 -0
- package/lib/pipeline/intent.js +98 -0
- package/lib/pipeline/intersection.js +235 -0
- package/lib/pipeline/model-selection.js +742 -0
- package/lib/pipeline/preset-sync.js +84 -0
- package/lib/pipeline/scenarios.js +468 -0
- package/lib/pipeline/session-lifecycle.js +520 -0
- package/lib/pipeline/snapshots.js +240 -0
- package/lib/pipeline/token-watchdog.js +101 -0
- package/lib/pipeline/worker-pool.js +143 -0
- package/lib/routes.js +277 -0
- package/lib/store.js +176 -0
- package/package.json +94 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Pool, Smart Routing, Dynamic Catalog, and Validation Helper for Multi-Agent Orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* 1. Subagent Model Selection settings integration (`subagent-model-selection`).
|
|
6
|
+
* 2. Fail-Fast Model Validation with Candidate Shortlist (Issue #82).
|
|
7
|
+
* 3. Smart Model Routing by Task Type and Complexity (Issue #48).
|
|
8
|
+
* 4. Dynamic Provider & Model Catalog Discovery (`model_subagent_catalog`) (Issue #76).
|
|
9
|
+
* 5. Semantic Capability Tags (coding, reasoning, fast, general) (Issue #74).
|
|
10
|
+
* 6. Granular maxTokens ceiling per model route (Issue #79).
|
|
11
|
+
* 7. Reasoning Effort control (off, low, medium, high, max) with compatibility check (Issue #86).
|
|
12
|
+
* 8. Model Identity Chips with friendly aliases (Issue #73).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class ModelValidationError extends Error {
|
|
16
|
+
constructor(message, { requestedProvider, requestedModel, candidateShortlist = [] } = {}) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'ModelValidationError'
|
|
19
|
+
this.requestedProvider = requestedProvider
|
|
20
|
+
this.requestedModel = requestedModel
|
|
21
|
+
this.candidateShortlist = candidateShortlist
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Task complexity tier classifications.
|
|
27
|
+
*/
|
|
28
|
+
export const TASK_TIERS = {
|
|
29
|
+
LIGHT: 'light',
|
|
30
|
+
BALANCED: 'balanced',
|
|
31
|
+
REASONING: 'reasoning',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Semantic Model Capabilities (Issue #74).
|
|
36
|
+
*/
|
|
37
|
+
export const MODEL_CAPABILITIES = {
|
|
38
|
+
CODING: 'coding',
|
|
39
|
+
REASONING: 'reasoning',
|
|
40
|
+
FAST: 'fast',
|
|
41
|
+
GENERAL: 'general',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Supported reasoning effort levels (Issue #86).
|
|
46
|
+
*/
|
|
47
|
+
export const REASONING_EFFORTS = ['off', 'low', 'medium', 'high', 'max']
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Default and tier-based max token boundaries (Issue #79).
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_MAX_TOKENS = 4096
|
|
53
|
+
const MAX_TOKENS_BY_CAPABILITY = {
|
|
54
|
+
fast: 2048,
|
|
55
|
+
general: 4096,
|
|
56
|
+
coding: 8192,
|
|
57
|
+
reasoning: 8192,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Keywords indicating reasoning-heavy analytical tasks.
|
|
62
|
+
*/
|
|
63
|
+
const REASONING_KEYWORDS = [
|
|
64
|
+
'architecture', 'design_system', 'security',
|
|
65
|
+
'audit', 'vulnerability', 'refactor',
|
|
66
|
+
'algorithm', 'deadlock', 'race condition'
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Keywords indicating light computational tasks.
|
|
71
|
+
*/
|
|
72
|
+
const LIGHT_KEYWORDS = [
|
|
73
|
+
'format', 'lint', 'docs', 'readme',
|
|
74
|
+
'find', 'grep', 'scan', 'rename'
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Infer task complexity tier based on role and task text.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} roleId
|
|
81
|
+
* @param {string} taskText
|
|
82
|
+
* @returns {'light'|'balanced'|'reasoning'}
|
|
83
|
+
*/
|
|
84
|
+
export function inferTaskComplexityTier(roleId = '', taskText = '') {
|
|
85
|
+
const r = String(roleId).toLowerCase()
|
|
86
|
+
const t = String(taskText).toLowerCase()
|
|
87
|
+
|
|
88
|
+
if (r === 'architecture' || r === 'security_audit' || r === 'dba') {
|
|
89
|
+
return TASK_TIERS.REASONING
|
|
90
|
+
}
|
|
91
|
+
if (r === 'docs' || r === 'technical_writer') {
|
|
92
|
+
return TASK_TIERS.LIGHT
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const isReasoning = REASONING_KEYWORDS.some((kw) => t.includes(kw))
|
|
96
|
+
if (isReasoning) return TASK_TIERS.REASONING
|
|
97
|
+
|
|
98
|
+
const isLight = LIGHT_KEYWORDS.some((kw) => t.includes(kw))
|
|
99
|
+
if (isLight) return TASK_TIERS.LIGHT
|
|
100
|
+
|
|
101
|
+
return TASK_TIERS.BALANCED
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Infer semantic capability tags for a given model and provider (Issue #74).
|
|
106
|
+
*
|
|
107
|
+
* @param {string} modelId
|
|
108
|
+
* @param {string} providerId
|
|
109
|
+
* @returns {string[]}
|
|
110
|
+
*/
|
|
111
|
+
export function inferModelCapabilities(modelId = '', providerId = '') {
|
|
112
|
+
const m = String(modelId).toLowerCase()
|
|
113
|
+
const p = String(providerId).toLowerCase()
|
|
114
|
+
const caps = new Set()
|
|
115
|
+
|
|
116
|
+
// Reasoning detection
|
|
117
|
+
if (/reasoner|r1|o1|o3|thinking|cot|deep-reason/i.test(m)) {
|
|
118
|
+
caps.add(MODEL_CAPABILITIES.REASONING)
|
|
119
|
+
caps.add('deep-reasoning')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Coding detection
|
|
123
|
+
if (/coder|coding|code|deepseek-coder|sonnet|claude-3-5|gpt-4o|qwen.*coder/i.test(m)) {
|
|
124
|
+
caps.add(MODEL_CAPABILITIES.CODING)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fast / lightweight detection
|
|
128
|
+
if (/fast|flash|mini|haiku|turbo|8b|cheap|nano|small/i.test(m)) {
|
|
129
|
+
caps.add(MODEL_CAPABILITIES.FAST)
|
|
130
|
+
caps.add('fast-search')
|
|
131
|
+
caps.add('cheap-lint')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Document processing
|
|
135
|
+
if (/doc|writer|translat/i.test(m)) {
|
|
136
|
+
caps.add('document-processing')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (caps.size === 0) {
|
|
140
|
+
caps.add(MODEL_CAPABILITIES.GENERAL)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Array.from(caps)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check whether reasoning effort control is supported by the target model (Issue #86).
|
|
148
|
+
*
|
|
149
|
+
* @param {string} provider
|
|
150
|
+
* @param {string} model
|
|
151
|
+
* @param {object} [modelMeta]
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
export function isReasoningEffortSupported(provider = '', model = '', modelMeta = null) {
|
|
155
|
+
if (modelMeta?.reasoning?.supported !== undefined) return Boolean(modelMeta.reasoning.supported)
|
|
156
|
+
if (modelMeta?.capabilities?.reasoningEffort !== undefined) return Boolean(modelMeta.capabilities.reasoningEffort)
|
|
157
|
+
if (modelMeta?.reasoningEffortSupported !== undefined) return Boolean(modelMeta.reasoningEffortSupported)
|
|
158
|
+
const m = String(model).toLowerCase()
|
|
159
|
+
return /reasoner|r1|o1|o3|thinking|deep-reason/i.test(m)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Normalize and validate reasoning effort value (Issue #86).
|
|
164
|
+
*
|
|
165
|
+
* @param {string} effort
|
|
166
|
+
* @returns {'off'|'low'|'medium'|'high'|'max'}
|
|
167
|
+
*/
|
|
168
|
+
export function normalizeReasoningEffort(effort) {
|
|
169
|
+
if (!effort || effort === 'disabled' || effort === 'off' || effort === 'none') return 'off'
|
|
170
|
+
const e = String(effort).toLowerCase()
|
|
171
|
+
return REASONING_EFFORTS.includes(e) ? e : 'medium'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve effective maxTokens ceiling for a model route (Issue #79).
|
|
176
|
+
*
|
|
177
|
+
* @param {object} params
|
|
178
|
+
* @param {number} [params.requestedMaxTokens]
|
|
179
|
+
* @param {number} [params.roleMaxTokens]
|
|
180
|
+
* @param {number} [params.routeMaxTokens]
|
|
181
|
+
* @param {string[]} [params.capabilities=[]]
|
|
182
|
+
* @returns {number}
|
|
183
|
+
*/
|
|
184
|
+
export function resolveMaxTokens({ requestedMaxTokens, roleMaxTokens, routeMaxTokens, capabilities = [] } = {}) {
|
|
185
|
+
if (typeof requestedMaxTokens === 'number' && requestedMaxTokens > 0) return requestedMaxTokens
|
|
186
|
+
if (typeof roleMaxTokens === 'number' && roleMaxTokens > 0) return roleMaxTokens
|
|
187
|
+
if (typeof routeMaxTokens === 'number' && routeMaxTokens > 0) return routeMaxTokens
|
|
188
|
+
if (capabilities.includes('fast') || capabilities.includes('fast-search') || capabilities.includes('cheap-lint')) {
|
|
189
|
+
return MAX_TOKENS_BY_CAPABILITY.fast
|
|
190
|
+
}
|
|
191
|
+
if (capabilities.includes('reasoning') || capabilities.includes('deep-reasoning')) {
|
|
192
|
+
return MAX_TOKENS_BY_CAPABILITY.reasoning
|
|
193
|
+
}
|
|
194
|
+
if (capabilities.includes('coding')) {
|
|
195
|
+
return MAX_TOKENS_BY_CAPABILITY.coding
|
|
196
|
+
}
|
|
197
|
+
if (capabilities.includes('general')) {
|
|
198
|
+
return MAX_TOKENS_BY_CAPABILITY.general
|
|
199
|
+
}
|
|
200
|
+
return DEFAULT_MAX_TOKENS
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build a structured Model Identity Chip with alias and tooltip metadata (Issue #73).
|
|
205
|
+
*
|
|
206
|
+
* @param {{ provider?: string, model?: string }} route
|
|
207
|
+
* @param {object} [meta={}]
|
|
208
|
+
* @returns {{ chipText: string, label: string, alias: string, badgeClass: string, provider: string, model: string, tooltip: string }}
|
|
209
|
+
*/
|
|
210
|
+
export function getModelIdentityChip(route = {}, meta = {}) {
|
|
211
|
+
const provider = route.provider || 'default'
|
|
212
|
+
const model = route.model || 'default'
|
|
213
|
+
const m = String(model).toLowerCase()
|
|
214
|
+
|
|
215
|
+
let alias = model
|
|
216
|
+
let badgeClass = 'general'
|
|
217
|
+
let label = 'General'
|
|
218
|
+
|
|
219
|
+
if (/reasoner|r1/i.test(m)) {
|
|
220
|
+
alias = 'R1'
|
|
221
|
+
label = 'Reasoner'
|
|
222
|
+
badgeClass = 'reasoner'
|
|
223
|
+
} else if (/o1/i.test(m)) {
|
|
224
|
+
alias = 'o1'
|
|
225
|
+
label = 'Reasoner'
|
|
226
|
+
badgeClass = 'reasoner'
|
|
227
|
+
} else if (/o3/i.test(m)) {
|
|
228
|
+
alias = 'o3'
|
|
229
|
+
label = 'Reasoner'
|
|
230
|
+
badgeClass = 'reasoner'
|
|
231
|
+
} else if (/deepseek-chat|v3/i.test(m)) {
|
|
232
|
+
alias = 'V3'
|
|
233
|
+
label = 'Fast Coder'
|
|
234
|
+
badgeClass = 'coder'
|
|
235
|
+
} else if (/coder/i.test(m)) {
|
|
236
|
+
alias = 'Coder'
|
|
237
|
+
label = 'Coder'
|
|
238
|
+
badgeClass = 'coder'
|
|
239
|
+
} else if (/sonnet/i.test(m)) {
|
|
240
|
+
alias = 'Sonnet 3.5'
|
|
241
|
+
label = 'Coder'
|
|
242
|
+
badgeClass = 'coder'
|
|
243
|
+
} else if (/flash|mini|haiku/i.test(m)) {
|
|
244
|
+
alias = m.includes('mini') ? 'Mini' : (m.includes('haiku') ? 'Haiku' : 'Flash')
|
|
245
|
+
label = 'Fast'
|
|
246
|
+
badgeClass = 'fast'
|
|
247
|
+
} else if (/qwen/i.test(m)) {
|
|
248
|
+
alias = 'Qwen'
|
|
249
|
+
label = 'Local'
|
|
250
|
+
badgeClass = 'local'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const chipText = `[${label} · ${alias}]`
|
|
254
|
+
const maxTokens = meta.maxTokens || resolveMaxTokens({ capabilities: inferModelCapabilities(model, provider) })
|
|
255
|
+
const reasoningStatus = meta.reasoningEffortSupported ?? isReasoningEffortSupported(provider, model)
|
|
256
|
+
const tooltip = `${provider}:${model} (Context: ${meta.contextWindow || '64k'}, Output: ${maxTokens}, Reasoning: ${reasoningStatus ? 'Supported' : 'No'})`
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
chipText,
|
|
260
|
+
label,
|
|
261
|
+
alias,
|
|
262
|
+
badgeClass,
|
|
263
|
+
provider,
|
|
264
|
+
model,
|
|
265
|
+
tooltip,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Reads the official `subagent-model-selection` configuration from DSH settings.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} ctx Cordis context
|
|
273
|
+
* @returns {{ sectionPresent: boolean, allowedRoutes?: Array<{ provider: string, model: string, maxTokens?: number }> }}
|
|
274
|
+
*/
|
|
275
|
+
export function readModelSelection(ctx) {
|
|
276
|
+
if (!ctx || !ctx.settings || typeof ctx.settings.get !== 'function') {
|
|
277
|
+
return { sectionPresent: false, allowedRoutes: undefined }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const selection = ctx.settings.get('subagent-model-selection')
|
|
282
|
+
if (!selection || typeof selection !== 'object') {
|
|
283
|
+
return { sectionPresent: false, allowedRoutes: undefined }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const models = Array.isArray(selection.allowedModels)
|
|
287
|
+
? selection.allowedModels.filter(
|
|
288
|
+
(m) => m && typeof m.provider === 'string' && typeof m.model === 'string'
|
|
289
|
+
)
|
|
290
|
+
: []
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
sectionPresent: true,
|
|
294
|
+
allowedRoutes: selection.enabled === true && models.length > 0 ? models : undefined,
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
return { sectionPresent: false, allowedRoutes: undefined }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Checks whether a given provider and model pair is in the allowed list.
|
|
303
|
+
*
|
|
304
|
+
* @param {{ provider: string, model: string }} route
|
|
305
|
+
* @param {Array<{ provider: string, model: string }>} [allowedRoutes]
|
|
306
|
+
* @returns {boolean}
|
|
307
|
+
*/
|
|
308
|
+
export function isRouteAllowed(route, allowedRoutes) {
|
|
309
|
+
if (!allowedRoutes || allowedRoutes.length === 0) return true
|
|
310
|
+
if (!route || !route.provider || !route.model) return false
|
|
311
|
+
return allowedRoutes.some((m) => m.provider === route.provider && m.model === route.model)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Validates a model against provider registry with Candidate Shortlist (Issue #82).
|
|
316
|
+
*
|
|
317
|
+
* @param {object} params
|
|
318
|
+
* @param {string} params.provider Requested provider ID
|
|
319
|
+
* @param {string} params.model Requested model ID
|
|
320
|
+
* @param {Array<object>} [params.registryProviders=[]] Available providers from ctx.llm.listProviders()
|
|
321
|
+
* @param {boolean} [params.failFast=false] Throw ModelValidationError on failure
|
|
322
|
+
* @returns {{ valid: boolean, candidateShortlist: string[], resolvedModel?: string, warning?: string }}
|
|
323
|
+
*/
|
|
324
|
+
export function validateModelCandidate({
|
|
325
|
+
provider,
|
|
326
|
+
model,
|
|
327
|
+
registryProviders = [],
|
|
328
|
+
failFast = false,
|
|
329
|
+
}) {
|
|
330
|
+
if (!registryProviders || registryProviders.length === 0) {
|
|
331
|
+
return { valid: true, candidateShortlist: [] }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const prov = registryProviders.find(
|
|
335
|
+
(p) => p.id === provider || (provider === 'deepseek' && p.id === 'deepseek-official')
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if (!prov) {
|
|
339
|
+
const allProviders = registryProviders.map((p) => p.id)
|
|
340
|
+
const err = new ModelValidationError(
|
|
341
|
+
`[ModelValidation] Provider "${provider}" not found. Available providers: [${allProviders.join(', ')}]`,
|
|
342
|
+
{ requestedProvider: provider, requestedModel: model, candidateShortlist: allProviders }
|
|
343
|
+
)
|
|
344
|
+
if (failFast) throw err
|
|
345
|
+
return { valid: false, candidateShortlist: allProviders, warning: err.message }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const availableModels = Array.isArray(prov.models)
|
|
349
|
+
? prov.models.map((m) => (typeof m === 'string' ? m : m?.id || m?.name)).filter(Boolean)
|
|
350
|
+
: []
|
|
351
|
+
|
|
352
|
+
if (availableModels.length === 0) {
|
|
353
|
+
return { valid: true, candidateShortlist: [] }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Exact match
|
|
357
|
+
if (availableModels.includes(model)) {
|
|
358
|
+
return { valid: true, candidateShortlist: availableModels, resolvedModel: model }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Fuzzy / case-insensitive match
|
|
362
|
+
const lowerModel = model.toLowerCase()
|
|
363
|
+
const match = availableModels.find((m) => m.toLowerCase() === lowerModel)
|
|
364
|
+
if (match) {
|
|
365
|
+
return { valid: true, candidateShortlist: availableModels, resolvedModel: match }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const err = new ModelValidationError(
|
|
369
|
+
`[ModelValidation] Model "${model}" not found for provider "${provider}". Available candidates: [${availableModels.join(', ')}]`,
|
|
370
|
+
{ requestedProvider: provider, requestedModel: model, candidateShortlist: availableModels }
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if (failFast) {
|
|
374
|
+
throw err
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
valid: false,
|
|
379
|
+
candidateShortlist: availableModels,
|
|
380
|
+
suggestedModel: availableModels[0],
|
|
381
|
+
warning: err.message,
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Smart Model Router (Issue #48).
|
|
387
|
+
* Selects optimal model tier depending on task complexity while respecting allowedProviders whitelist.
|
|
388
|
+
*
|
|
389
|
+
* @param {object} params
|
|
390
|
+
* @param {string} params.roleId
|
|
391
|
+
* @param {string} params.task
|
|
392
|
+
* @param {object} params.baseModel Default fallback model { provider, model }
|
|
393
|
+
* @param {boolean} [params.enabled=false]
|
|
394
|
+
* @param {Array<string>} [params.allowedProviders=[]]
|
|
395
|
+
* @param {object} [params.tierModels] Custom model mappings per tier
|
|
396
|
+
* @returns {{ provider: string, model: string, tier: string, reason: string }}
|
|
397
|
+
*/
|
|
398
|
+
export function resolveSmartModel({
|
|
399
|
+
roleId,
|
|
400
|
+
task,
|
|
401
|
+
baseModel = { provider: 'deepseek-official', model: 'deepseek-chat' },
|
|
402
|
+
enabled = false,
|
|
403
|
+
allowedProviders = [],
|
|
404
|
+
tierModels = {},
|
|
405
|
+
}) {
|
|
406
|
+
if (!enabled) {
|
|
407
|
+
return {
|
|
408
|
+
provider: baseModel.provider,
|
|
409
|
+
model: baseModel.model,
|
|
410
|
+
tier: 'default',
|
|
411
|
+
reason: 'smart_routing_disabled',
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const tier = inferTaskComplexityTier(roleId, task)
|
|
416
|
+
|
|
417
|
+
// Default tier routing presets
|
|
418
|
+
const defaults = {
|
|
419
|
+
[TASK_TIERS.LIGHT]: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
|
420
|
+
[TASK_TIERS.BALANCED]: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
|
421
|
+
[TASK_TIERS.REASONING]: { provider: 'deepseek-official', model: 'deepseek-reasoner' },
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const candidate = tierModels[tier] || defaults[tier] || baseModel
|
|
425
|
+
|
|
426
|
+
// Validate against allowedProviders whitelist if set
|
|
427
|
+
if (Array.isArray(allowedProviders) && allowedProviders.length > 0) {
|
|
428
|
+
if (!allowedProviders.includes(candidate.provider)) {
|
|
429
|
+
return {
|
|
430
|
+
provider: baseModel.provider,
|
|
431
|
+
model: baseModel.model,
|
|
432
|
+
tier: 'fallback',
|
|
433
|
+
reason: `provider_${candidate.provider}_not_in_allowedProviders`,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
provider: candidate.provider,
|
|
440
|
+
model: candidate.model,
|
|
441
|
+
tier,
|
|
442
|
+
reason: `smart_route_${tier}`,
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Resolves a model candidate by semantic capability requirement (Issue #74).
|
|
448
|
+
*
|
|
449
|
+
* @param {object} params
|
|
450
|
+
* @param {string} params.capability e.g. 'coding', 'reasoning', 'fast', 'general'
|
|
451
|
+
* @param {Array<object>} [params.catalog=[]]
|
|
452
|
+
* @param {Array<object>} [params.allowedRoutes]
|
|
453
|
+
* @param {object} [params.fallbackRoute]
|
|
454
|
+
* @returns {{ provider: string, model: string, maxTokens: number, reasoningEffortSupported: boolean, capabilities: string[], selectionReason: string }}
|
|
455
|
+
*/
|
|
456
|
+
export function resolveModelByCapability({
|
|
457
|
+
capability,
|
|
458
|
+
catalog = [],
|
|
459
|
+
allowedRoutes,
|
|
460
|
+
fallbackRoute = { provider: 'deepseek-official', model: 'deepseek-chat' },
|
|
461
|
+
}) {
|
|
462
|
+
if (!capability) {
|
|
463
|
+
return {
|
|
464
|
+
...fallbackRoute,
|
|
465
|
+
maxTokens: fallbackRoute.maxTokens || DEFAULT_MAX_TOKENS,
|
|
466
|
+
reasoningEffortSupported: isReasoningEffortSupported(fallbackRoute.provider, fallbackRoute.model),
|
|
467
|
+
capabilities: inferModelCapabilities(fallbackRoute.model, fallbackRoute.provider),
|
|
468
|
+
selectionReason: 'default_fallback',
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const matches = catalog.filter((entry) => {
|
|
473
|
+
const caps = entry.capabilities || inferModelCapabilities(entry.model, entry.provider)
|
|
474
|
+
const hasCap = Array.isArray(caps) && (caps.includes(capability) || caps.includes(capability.toLowerCase()))
|
|
475
|
+
if (!hasCap) return false
|
|
476
|
+
return isRouteAllowed(entry, allowedRoutes)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
if (matches.length > 0) {
|
|
480
|
+
const candidate = matches[0]
|
|
481
|
+
return {
|
|
482
|
+
provider: candidate.provider,
|
|
483
|
+
model: candidate.model,
|
|
484
|
+
maxTokens: candidate.maxTokens || resolveMaxTokens({ capabilities: candidate.capabilities }),
|
|
485
|
+
reasoningEffortSupported: candidate.reasoningEffortSupported ?? isReasoningEffortSupported(candidate.provider, candidate.model),
|
|
486
|
+
capabilities: candidate.capabilities || inferModelCapabilities(candidate.model, candidate.provider),
|
|
487
|
+
selectionReason: `capability_match_${capability}`,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
...fallbackRoute,
|
|
493
|
+
maxTokens: fallbackRoute.maxTokens || DEFAULT_MAX_TOKENS,
|
|
494
|
+
reasoningEffortSupported: isReasoningEffortSupported(fallbackRoute.provider, fallbackRoute.model),
|
|
495
|
+
capabilities: inferModelCapabilities(fallbackRoute.model, fallbackRoute.provider),
|
|
496
|
+
selectionReason: `capability_fallback_${capability}`,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Queries live registry of providers and models from Cordis context (Issue #76).
|
|
502
|
+
*
|
|
503
|
+
* @param {object} ctx Cordis context
|
|
504
|
+
* @param {object} [options={}]
|
|
505
|
+
* @param {string} [options.provider] Optional provider filter
|
|
506
|
+
* @param {string} [options.capability] Optional capability filter
|
|
507
|
+
* @param {Array<object>} [options.allowedRoutes]
|
|
508
|
+
* @returns {Promise<Array<object>>}
|
|
509
|
+
*/
|
|
510
|
+
export async function fetchModelCatalog(ctx, options = {}) {
|
|
511
|
+
const selection = readModelSelection(ctx)
|
|
512
|
+
const allowed = options.allowedRoutes || selection.allowedRoutes
|
|
513
|
+
const models = []
|
|
514
|
+
|
|
515
|
+
let providers = []
|
|
516
|
+
if (ctx?.llm && typeof ctx.llm.listProviders === 'function') {
|
|
517
|
+
try {
|
|
518
|
+
providers = await ctx.llm.listProviders()
|
|
519
|
+
} catch (_) {
|
|
520
|
+
/* safe ignore */
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (Array.isArray(providers) && providers.length > 0) {
|
|
525
|
+
for (const p of providers) {
|
|
526
|
+
const providerId = p.id || p.name || 'unknown'
|
|
527
|
+
let provModels = p.models || []
|
|
528
|
+
if (ctx?.llm && typeof ctx.llm.listModels === 'function') {
|
|
529
|
+
try {
|
|
530
|
+
const list = await ctx.llm.listModels(providerId)
|
|
531
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
532
|
+
provModels = list
|
|
533
|
+
}
|
|
534
|
+
} catch (_) {
|
|
535
|
+
/* safe ignore */
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
for (const m of provModels) {
|
|
540
|
+
const modelId = typeof m === 'string' ? m : (m?.id || m?.name)
|
|
541
|
+
if (!modelId) continue
|
|
542
|
+
const capabilities = inferModelCapabilities(modelId, providerId)
|
|
543
|
+
const reasoningEffortSupported = isReasoningEffortSupported(providerId, modelId, typeof m === 'object' ? m : null)
|
|
544
|
+
const maxTokens = (typeof m === 'object' && typeof m.maxTokens === 'number')
|
|
545
|
+
? m.maxTokens
|
|
546
|
+
: resolveMaxTokens({ capabilities })
|
|
547
|
+
const authorized = isRouteAllowed({ provider: providerId, model: modelId }, allowed)
|
|
548
|
+
const chip = getModelIdentityChip({ provider: providerId, model: modelId }, { maxTokens, reasoningEffortSupported })
|
|
549
|
+
|
|
550
|
+
models.push({
|
|
551
|
+
provider: providerId,
|
|
552
|
+
model: modelId,
|
|
553
|
+
label: `${providerId}:${modelId}`,
|
|
554
|
+
alias: chip.alias,
|
|
555
|
+
chipText: chip.chipText,
|
|
556
|
+
badgeClass: chip.badgeClass,
|
|
557
|
+
capabilities,
|
|
558
|
+
maxTokens,
|
|
559
|
+
reasoningEffortSupported,
|
|
560
|
+
supportedEfforts: reasoningEffortSupported ? ['off', 'low', 'medium', 'high', 'max'] : ['off'],
|
|
561
|
+
authorized,
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Fallback defaults if registry returns empty
|
|
568
|
+
if (models.length === 0) {
|
|
569
|
+
const defaultPool = [
|
|
570
|
+
{ provider: 'deepseek-official', model: 'deepseek-chat', maxTokens: 4096 },
|
|
571
|
+
{ provider: 'deepseek-official', model: 'deepseek-reasoner', maxTokens: 8192 },
|
|
572
|
+
{ provider: 'anthropic', model: 'claude-3-5-sonnet', maxTokens: 8192 },
|
|
573
|
+
]
|
|
574
|
+
for (const item of defaultPool) {
|
|
575
|
+
const capabilities = inferModelCapabilities(item.model, item.provider)
|
|
576
|
+
const reasoningEffortSupported = isReasoningEffortSupported(item.provider, item.model)
|
|
577
|
+
const chip = getModelIdentityChip(item, { maxTokens: item.maxTokens, reasoningEffortSupported })
|
|
578
|
+
models.push({
|
|
579
|
+
provider: item.provider,
|
|
580
|
+
model: item.model,
|
|
581
|
+
label: `${item.provider}:${item.model}`,
|
|
582
|
+
alias: chip.alias,
|
|
583
|
+
chipText: chip.chipText,
|
|
584
|
+
badgeClass: chip.badgeClass,
|
|
585
|
+
capabilities,
|
|
586
|
+
maxTokens: item.maxTokens,
|
|
587
|
+
reasoningEffortSupported,
|
|
588
|
+
supportedEfforts: reasoningEffortSupported ? ['off', 'low', 'medium', 'high', 'max'] : ['off'],
|
|
589
|
+
authorized: isRouteAllowed(item, allowed),
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
let result = models
|
|
595
|
+
if (options.provider) {
|
|
596
|
+
const targetProv = options.provider.toLowerCase()
|
|
597
|
+
result = result.filter((m) => m.provider.toLowerCase() === targetProv)
|
|
598
|
+
}
|
|
599
|
+
if (options.capability) {
|
|
600
|
+
const targetCap = options.capability.toLowerCase()
|
|
601
|
+
result = result.filter((m) => m.capabilities.includes(targetCap))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return result
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Resolves the appropriate model for a specialist execution.
|
|
609
|
+
* Combines role binding, capability routing (Issue #74), smart routing (Issue #48),
|
|
610
|
+
* Candidate Shortlist check (Issue #82), maxTokens ceiling (Issue #79),
|
|
611
|
+
* and reasoning effort negotiation (Issue #86).
|
|
612
|
+
*
|
|
613
|
+
* @param {object} params
|
|
614
|
+
* @param {object} [params.roleModel] Model assigned to the role
|
|
615
|
+
* @param {object} [params.fallbackModel] Fallback default model
|
|
616
|
+
* @param {Array<{ provider: string, model: string, maxTokens?: number }>} [params.allowedRoutes]
|
|
617
|
+
* @param {string} [params.roleId]
|
|
618
|
+
* @param {string} [params.task]
|
|
619
|
+
* @param {string} [params.capability]
|
|
620
|
+
* @param {string} [params.reasoningEffort]
|
|
621
|
+
* @param {number} [params.maxTokens]
|
|
622
|
+
* @param {boolean} [params.smartRoutingEnabled=false]
|
|
623
|
+
* @param {Array<string>} [params.allowedProviders=[]]
|
|
624
|
+
* @param {Array<object>} [params.registryProviders=[]]
|
|
625
|
+
* @param {Array<object>} [params.catalog=[]]
|
|
626
|
+
* @param {boolean} [params.failFast=false]
|
|
627
|
+
* @returns {{ provider: string, model: string, maxTokens: number, reasoningEffort?: string, warning?: string, candidateShortlist?: string[], selectionReason: string }}
|
|
628
|
+
*/
|
|
629
|
+
export function resolveSpecialistModel({
|
|
630
|
+
roleModel,
|
|
631
|
+
fallbackModel,
|
|
632
|
+
allowedRoutes,
|
|
633
|
+
roleId,
|
|
634
|
+
task,
|
|
635
|
+
capability,
|
|
636
|
+
reasoningEffort,
|
|
637
|
+
maxTokens: explicitMaxTokens,
|
|
638
|
+
smartRoutingEnabled = false,
|
|
639
|
+
allowedProviders = [],
|
|
640
|
+
registryProviders = [],
|
|
641
|
+
catalog = [],
|
|
642
|
+
failFast = false,
|
|
643
|
+
}) {
|
|
644
|
+
const defaultFallback = fallbackModel || { provider: 'deepseek-official', model: 'deepseek-chat' }
|
|
645
|
+
let targetModel = roleModel || defaultFallback
|
|
646
|
+
let selectionReason = roleModel ? 'role_preset' : 'default_fallback'
|
|
647
|
+
|
|
648
|
+
// 1. Semantic Capability Routing (Issue #74)
|
|
649
|
+
if (capability) {
|
|
650
|
+
const byCap = resolveModelByCapability({
|
|
651
|
+
capability,
|
|
652
|
+
catalog,
|
|
653
|
+
allowedRoutes,
|
|
654
|
+
fallbackRoute: targetModel,
|
|
655
|
+
})
|
|
656
|
+
targetModel = { provider: byCap.provider, model: byCap.model }
|
|
657
|
+
selectionReason = byCap.selectionReason
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// 2. Smart Model Routing (Issue #48)
|
|
661
|
+
if (!capability && smartRoutingEnabled && task) {
|
|
662
|
+
const smart = resolveSmartModel({
|
|
663
|
+
roleId,
|
|
664
|
+
task,
|
|
665
|
+
baseModel: targetModel,
|
|
666
|
+
enabled: true,
|
|
667
|
+
allowedProviders,
|
|
668
|
+
})
|
|
669
|
+
targetModel = { provider: smart.provider, model: smart.model }
|
|
670
|
+
selectionReason = smart.reason
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 3. Candidate Shortlist Validation (Issue #82)
|
|
674
|
+
let candidateShortlist = []
|
|
675
|
+
let warning
|
|
676
|
+
if (registryProviders && registryProviders.length > 0) {
|
|
677
|
+
const validation = validateModelCandidate({
|
|
678
|
+
provider: targetModel.provider,
|
|
679
|
+
model: targetModel.model,
|
|
680
|
+
registryProviders,
|
|
681
|
+
failFast,
|
|
682
|
+
})
|
|
683
|
+
candidateShortlist = validation.candidateShortlist
|
|
684
|
+
|
|
685
|
+
if (!validation.valid) {
|
|
686
|
+
warning = validation.warning
|
|
687
|
+
if (validation.suggestedModel) {
|
|
688
|
+
targetModel.model = validation.suggestedModel
|
|
689
|
+
selectionReason = 'candidate_shortlist_fallback'
|
|
690
|
+
}
|
|
691
|
+
} else if (validation.resolvedModel) {
|
|
692
|
+
targetModel.model = validation.resolvedModel
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 4. Official subagent-model-selection allowedRoutes constraint
|
|
697
|
+
let matchedRoute = null
|
|
698
|
+
if (allowedRoutes && allowedRoutes.length > 0) {
|
|
699
|
+
matchedRoute = allowedRoutes.find((m) => m.provider === targetModel.provider && m.model === targetModel.model)
|
|
700
|
+
if (!matchedRoute) {
|
|
701
|
+
const safeFallback = allowedRoutes[0]
|
|
702
|
+
warning = `Model ${targetModel.provider}:${targetModel.model} is not in subagent-model-selection.allowedModels; fell back to ${safeFallback.provider}:${safeFallback.model}`
|
|
703
|
+
targetModel = { provider: safeFallback.provider, model: safeFallback.model }
|
|
704
|
+
selectionReason = 'allowed_routes_fallback'
|
|
705
|
+
matchedRoute = safeFallback
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 5. Output tokens ceiling negotiation (Issue #79)
|
|
710
|
+
const inferredCaps = inferModelCapabilities(targetModel.model, targetModel.provider)
|
|
711
|
+
const effectiveMaxTokens = resolveMaxTokens({
|
|
712
|
+
requestedMaxTokens: explicitMaxTokens,
|
|
713
|
+
roleMaxTokens: roleModel?.maxTokens,
|
|
714
|
+
routeMaxTokens: matchedRoute?.maxTokens,
|
|
715
|
+
capabilities: inferredCaps,
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
// 6. Reasoning Effort negotiation & compatibility check (Issue #86)
|
|
719
|
+
let effectiveReasoningEffort
|
|
720
|
+
const requestedEffort = reasoningEffort || roleModel?.reasoningEffort
|
|
721
|
+
if (requestedEffort && requestedEffort !== 'off' && requestedEffort !== 'disabled') {
|
|
722
|
+
const normalized = normalizeReasoningEffort(requestedEffort)
|
|
723
|
+
const supported = isReasoningEffortSupported(targetModel.provider, targetModel.model)
|
|
724
|
+
if (supported) {
|
|
725
|
+
effectiveReasoningEffort = normalized
|
|
726
|
+
} else {
|
|
727
|
+
const effortWarning = `Reasoning effort "${requestedEffort}" requested, but model ${targetModel.provider}:${targetModel.model} does not support reasoning effort; downgraded to standard mode`
|
|
728
|
+
warning = warning ? `${warning}; ${effortWarning}` : effortWarning
|
|
729
|
+
effectiveReasoningEffort = undefined
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
provider: targetModel.provider,
|
|
735
|
+
model: targetModel.model,
|
|
736
|
+
maxTokens: effectiveMaxTokens,
|
|
737
|
+
reasoningEffort: effectiveReasoningEffort,
|
|
738
|
+
warning,
|
|
739
|
+
candidateShortlist,
|
|
740
|
+
selectionReason,
|
|
741
|
+
}
|
|
742
|
+
}
|