@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.
@@ -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
+ }