feat(01-05): fire before_model_select hook, add verbose scoring output, load capability overrides

- Fire pi.emitBeforeModelSelect() in selectAndApplyModel before resolveModelForComplexity
- Hook override bypasses capability scoring entirely with tier-only selectionMethod
- Verbose output shows capability-scored breakdown: model scores sorted descending
- Add loadCapabilityOverrides() to model-router.ts for deep-merge with built-in profiles
- Extend resolveModelForComplexity signature with optional capabilityOverrides parameter
- Pass capabilityOverrides through to scoreEligibleModels in STEP 2
This commit is contained in:
Jeremy 2026-03-26 17:28:00 -05:00
parent 6cc42bb504
commit 1645be072c
2 changed files with 104 additions and 20 deletions

View file

@ -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<string, { capabilities?: Record<string, number> }> } | 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<string, unknown> | undefined,
eligibleModels: eligible,
phaseConfig: modelConfig ? {
primary: modelConfig.primary,
fallbacks: modelConfig.fallbacks ?? [],
} : undefined,
});
if (hookResult?.modelId) {
hookOverride = hookResult.modelId;
}
}
let routingResult: ReturnType<typeof resolveModelForComplexity>;
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)}]`;

View file

@ -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<string, { capabilities?: Partial<ModelCapabilities> }> },
): Record<string, Partial<ModelCapabilities>> {
const result: Record<string, Partial<ModelCapabilities>> = {};
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<string, Partial<ModelCapabilities>>,
): 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<string, number> = {};