diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts index 8519553d9..544a97857 100644 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ b/src/resources/extensions/gsd/auto-model-selection.ts @@ -10,7 +10,7 @@ import type { GSDPreferences } from "./preferences.js"; import { resolveModelWithFallbacksForUnit, resolveDynamicRoutingConfig } from "./preferences.js"; import type { ComplexityTier } from "./complexity-classifier.js"; import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js"; -import { resolveModelForComplexity, escalateTier } from "./model-router.js"; +import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabilityOverrides } from "./model-router.js"; import { getLedger, getProjectTotals } from "./metrics.js"; import { unitPhaseLabel } from "./auto-dashboard.js"; @@ -107,26 +107,89 @@ export async function selectAndApplyModel( } } - const routingResult = resolveModelForComplexity( - classification, - modelConfig, - routingConfig, - availableModelIds, - unitType, - classification.taskMetadata, + // Load user capability overrides from preferences (D-17: deep-merged with built-in profiles) + const capabilityOverrides = loadCapabilityOverrides( + (prefs as { modelOverrides?: Record }> } | undefined) ?? {}, ); + // Fire before_model_select hook (ADR-004, D-03) + // Hook can override model selection entirely by returning { modelId } + let hookOverride: string | undefined; + if (routingConfig.hooks !== false) { + const eligible = getEligibleModels( + classification.tier, + availableModelIds, + routingConfig, + ); + const hookResult = await pi.emitBeforeModelSelect({ + unitType, + unitId, + classification: { + tier: classification.tier, + reason: classification.reason, + downgraded: classification.downgraded, + }, + taskMetadata: classification.taskMetadata as Record | undefined, + eligibleModels: eligible, + phaseConfig: modelConfig ? { + primary: modelConfig.primary, + fallbacks: modelConfig.fallbacks ?? [], + } : undefined, + }); + if (hookResult?.modelId) { + hookOverride = hookResult.modelId; + } + } + + let routingResult: ReturnType; + if (hookOverride) { + // Hook override bypasses capability scoring entirely + routingResult = { + modelId: hookOverride, + fallbacks: [ + ...(modelConfig?.fallbacks ?? []).filter(f => f !== hookOverride), + ...(modelConfig?.primary && modelConfig.primary !== hookOverride ? [modelConfig.primary] : []), + ], + tier: classification.tier, + wasDowngraded: hookOverride !== modelConfig?.primary, + reason: `hook override: ${hookOverride}`, + selectionMethod: "tier-only", + }; + } else { + routingResult = resolveModelForComplexity( + classification, + modelConfig, + routingConfig, + availableModelIds, + unitType, + classification.taskMetadata, + capabilityOverrides, + ); + } + if (routingResult.wasDowngraded) { effectiveModelConfig = { primary: routingResult.modelId, fallbacks: routingResult.fallbacks, }; if (verbose) { - const method = routingResult.selectionMethod === "capability-scored" ? "capability-scored" : "tier-only"; - ctx.ui.notify( - `Dynamic routing [${tierLabel(classification.tier)}]: ${routingResult.modelId} (${method} — ${classification.reason})`, - "info", - ); + if (routingResult.selectionMethod === "capability-scored" && routingResult.capabilityScores) { + // Verbose scoring breakdown for capability-scored decisions (D-20) + const tierLbl = tierLabel(classification.tier); + const scores = Object.entries(routingResult.capabilityScores) + .sort(([, a], [, b]) => b - a) + .map(([id, score]) => `${id}: ${score.toFixed(1)}`) + .join(", "); + ctx.ui.notify( + `Dynamic routing [${tierLbl}]: ${routingResult.modelId} (capability-scored) — ${scores}`, + "info", + ); + } else { + ctx.ui.notify( + `Dynamic routing [${tierLabel(classification.tier)}]: ${routingResult.modelId} (${classification.reason})`, + "info", + ); + } } } routingTierLabel = ` [${tierLabel(classification.tier)}]`; diff --git a/src/resources/extensions/gsd/model-router.ts b/src/resources/extensions/gsd/model-router.ts index 445ce6b73..34bea3469 100644 --- a/src/resources/extensions/gsd/model-router.ts +++ b/src/resources/extensions/gsd/model-router.ts @@ -289,6 +289,25 @@ function buildFallbackChain(selectedModelId: string, phaseConfig: ResolvedModelC ].filter(f => f !== selectedModelId); } +/** + * Load capability overrides from user preferences' modelOverrides section. + * Returns a map of model ID → partial capability overrides to deep-merge with built-in profiles. + * + * Per D-17: partial capability overrides via models.json modelOverrides, deep-merged with defaults. + */ +export function loadCapabilityOverrides( + prefs: { modelOverrides?: Record }> }, +): Record> { + const result: Record> = {}; + if (!prefs.modelOverrides) return result; + for (const [modelId, overrideEntry] of Object.entries(prefs.modelOverrides)) { + if (overrideEntry.capabilities) { + result[modelId] = overrideEntry.capabilities; + } + } + return result; +} + /** * Resolve the model to use for a given complexity tier. * @@ -300,12 +319,13 @@ function buildFallbackChain(selectedModelId: string, phaseConfig: ResolvedModelC * when capability_routing is enabled and multiple eligible models exist. * STEP 3: Fallback chain assembly. * - * @param classification The complexity classification result - * @param phaseConfig The user's configured model for this phase (ceiling) - * @param routingConfig Dynamic routing configuration - * @param availableModelIds List of available model IDs (from registry) - * @param unitType The unit type for capability requirement computation (optional) - * @param taskMetadata Task metadata for refined requirement vectors (optional) + * @param classification The complexity classification result + * @param phaseConfig The user's configured model for this phase (ceiling) + * @param routingConfig Dynamic routing configuration + * @param availableModelIds List of available model IDs (from registry) + * @param unitType The unit type for capability requirement computation (optional) + * @param taskMetadata Task metadata for refined requirement vectors (optional) + * @param capabilityOverrides User-provided capability overrides (deep-merged with built-in profiles, optional) */ export function resolveModelForComplexity( classification: ClassificationResult, @@ -314,6 +334,7 @@ export function resolveModelForComplexity( availableModelIds: string[], unitType?: string, taskMetadata?: TaskMetadata, + capabilityOverrides?: Record>, ): RoutingDecision { // If no phase config or routing disabled, pass through if (!phaseConfig || !routingConfig.enabled) { @@ -377,7 +398,7 @@ export function resolveModelForComplexity( // STEP 2: Capability scoring (when enabled and multiple eligible models exist) if (routingConfig.capability_routing !== false && eligible.length > 1 && unitType) { const requirements = computeTaskRequirements(unitType, taskMetadata); - const scored = scoreEligibleModels(eligible, requirements); + const scored = scoreEligibleModels(eligible, requirements, capabilityOverrides); const winner = scored[0]; if (winner) { const capScores: Record = {};