Files
fusion/packages/core/src/model-resolution.ts
gsxdsm cf46859335 feat(router): U17 — Fusion Model Router (session-level selection layer)
routeModel + conservative v0 allowlist (dependabot/lint → cheap tier) wired
into execution/planning/validation lanes in model-resolution.ts; governance
(isPermitted) and column-agent override are absolute, OFF by default. Routing
decisions (with counterfactual) emit via the U1 usage_events seam.
2026-06-15 19:57:20 -07:00

263 lines
7.6 KiB
TypeScript

import type { Settings } from "./types.js";
import type {
ModelGovernancePredicate,
RouterDecision,
RouterLane,
RouterTaskContext,
} from "./model-router.js";
import { routeModel } from "./model-router.js";
export interface ResolvedModelSelection {
provider?: string;
modelId?: string;
}
export const TEST_MODE_RESOLVED: ResolvedModelSelection = { provider: "mock", modelId: "scripted" };
export function isTestModeActive(settings?: Partial<Settings>): boolean {
return settings?.testMode === true || settings?.defaultProvider?.trim().toLowerCase() === "mock";
}
export function applyTestModeOverrides(
resolved: ResolvedModelSelection,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return isTestModeActive(settings) ? TEST_MODE_RESOLVED : resolved;
}
type ModelPair =
| ResolvedModelSelection
| {
provider?: string | null;
modelId?: string | null;
}
| undefined;
type TaskModelLike = {
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
planningModelProvider?: string | null;
planningModelId?: string | null;
};
function hasCompleteModelPair(pair: ModelPair): pair is { provider: string; modelId: string } {
return Boolean(pair?.provider && pair?.modelId);
}
function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection {
for (const pair of pairs) {
if (hasCompleteModelPair(pair)) {
return { provider: pair.provider, modelId: pair.modelId };
}
}
return {};
}
export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.defaultProviderOverride,
modelId: settings?.defaultModelIdOverride,
},
{
provider: settings?.defaultProvider,
modelId: settings?.defaultModelId,
},
),
settings,
);
}
export function resolveExecutionSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.executionProvider,
modelId: settings?.executionModelId,
},
{
provider: settings?.executionGlobalProvider,
modelId: settings?.executionGlobalModelId,
},
resolveProjectDefaultModel(settings),
),
settings,
);
}
export function resolvePlanningSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.planningProvider,
modelId: settings?.planningModelId,
},
{
provider: settings?.planningGlobalProvider,
modelId: settings?.planningGlobalModelId,
},
resolveProjectDefaultModel(settings),
),
settings,
);
}
export function resolveValidatorSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.validatorProvider,
modelId: settings?.validatorModelId,
},
{
provider: settings?.validatorGlobalProvider,
modelId: settings?.validatorGlobalModelId,
},
resolveProjectDefaultModel(settings),
),
settings,
);
}
export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.titleSummarizerProvider,
modelId: settings?.titleSummarizerModelId,
},
{
provider: settings?.titleSummarizerGlobalProvider,
modelId: settings?.titleSummarizerGlobalModelId,
},
{
provider: settings?.planningProvider,
modelId: settings?.planningModelId,
},
resolveProjectDefaultModel(settings),
),
settings,
);
}
export function resolveTaskExecutionModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: task.modelProvider,
modelId: task.modelId,
},
resolveExecutionSettingsModel(settings),
),
settings,
);
}
export function resolveTaskValidatorModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: task.validatorModelProvider,
modelId: task.validatorModelId,
},
resolveValidatorSettingsModel(settings),
),
settings,
);
}
export function resolveTaskPlanningModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: task.planningModelProvider,
modelId: task.planningModelId,
},
resolvePlanningSettingsModel(settings),
),
settings,
);
}
// ── Fusion Model Router lane wrappers (U17 / KTD9) ─────────────────────────
//
// These are the **governed** session-start lanes: execution, planning, and
// validation. Each first resolves the lane's default pair exactly as today (the
// router's counterfactual), then hands it to the selection layer. The router is
// OFF by default — when disabled it returns the default pair byte-identically,
// so these wrappers are safe drop-ins. The non-routed resolvers above remain
// untouched; the settings-only resolvers, `resolveProjectDefaultModel`, and
// `resolveTitleSummarizerSettingsModel` are **ungoverned** (no task signal /
// non-session purpose) and the router never touches them.
/** Options shared by the router-aware lane resolvers. */
export interface RouterLaneOptions {
/** Per-task per-lane override pair (e.g. a column-agent binding). When complete,
* the router defers to it. */
overridePair?: ResolvedModelSelection | null;
/** Classification signal for the conservative v0 allowlist. */
context?: RouterTaskContext;
/** Governance gate — the router never returns a pair this rejects. */
isPermitted?: ModelGovernancePredicate;
}
function routeLane(
lane: RouterLane,
defaultPair: ResolvedModelSelection,
settings: Partial<Settings> | undefined,
options: RouterLaneOptions | undefined,
): RouterDecision {
return routeModel({
lane,
defaultPair,
overridePair: options?.overridePair ?? null,
context: options?.context,
settings,
isPermitted: options?.isPermitted,
});
}
/**
* Router-aware execution-lane resolution. Returns the full {@link RouterDecision}
* (selection + counterfactual + reason) so the caller can emit telemetry and wire
* the escalation seam. With the router disabled, `decision.selection` equals
* {@link resolveTaskExecutionModel}.
*/
export function routeTaskExecutionModel(
task: TaskModelLike,
settings?: Partial<Settings>,
options?: RouterLaneOptions,
): RouterDecision {
return routeLane("execution", resolveTaskExecutionModel(task, settings), settings, options);
}
/** Router-aware planning-lane resolution. See {@link routeTaskExecutionModel}. */
export function routeTaskPlanningModel(
task: TaskModelLike,
settings?: Partial<Settings>,
options?: RouterLaneOptions,
): RouterDecision {
return routeLane("planning", resolveTaskPlanningModel(task, settings), settings, options);
}
/** Router-aware validation-lane resolution. See {@link routeTaskExecutionModel}. */
export function routeTaskValidatorModel(
task: TaskModelLike,
settings?: Partial<Settings>,
options?: RouterLaneOptions,
): RouterDecision {
return routeLane("validation", resolveTaskValidatorModel(task, settings), settings, options);
}