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.
This commit is contained in:
gsxdsm
2026-06-15 19:57:20 -07:00
parent e617ce65d9
commit cf46859335
6 changed files with 693 additions and 1 deletions

View File

@@ -0,0 +1,246 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database } from "../db.js";
import { queryUsageEvents } from "../usage-events.js";
import {
routeModel,
routeModelAndEmit,
isMechanicalRoutableContext,
type RouteModelInput,
} from "../model-router.js";
import {
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
routeTaskExecutionModel,
routeTaskPlanningModel,
routeTaskValidatorModel,
} from "../model-resolution.js";
import type { Settings } from "../types.js";
const DEFAULT = { provider: "anthropic", modelId: "claude-opus-4-8" } as const;
const CHEAP = { provider: "anthropic", modelId: "claude-haiku-4-5" } as const;
const routerSettings: Partial<Settings> = {
modelRouterEnabled: true,
modelRouterCheapProvider: CHEAP.provider,
modelRouterCheapModelId: CHEAP.modelId,
// give the default-pair lanes a concrete value
defaultProvider: DEFAULT.provider,
defaultModelId: DEFAULT.modelId,
};
function baseInput(overrides: Partial<RouteModelInput> = {}): RouteModelInput {
return {
lane: "execution",
defaultPair: { ...DEFAULT },
settings: routerSettings,
context: { traits: ["dependabot"] },
...overrides,
};
}
describe("isMechanicalRoutableContext", () => {
it("matches dependabot/renovate sources", () => {
expect(isMechanicalRoutableContext({ source: "dependabot" })).toBe(true);
expect(isMechanicalRoutableContext({ source: "renovate" })).toBe(true);
});
it("matches mechanical traits and labels", () => {
expect(isMechanicalRoutableContext({ traits: ["lint-only"] })).toBe(true);
expect(isMechanicalRoutableContext({ labels: ["dependencies"] })).toBe(true);
});
it("matches conservative title keywords", () => {
expect(isMechanicalRoutableContext({ title: "Bump lodash from 4.17.20 to 4.17.21" })).toBe(true);
expect(isMechanicalRoutableContext({ title: "chore(deps): update eslint" })).toBe(true);
expect(isMechanicalRoutableContext({ title: "Lint-only fix for unused imports" })).toBe(true);
});
it("does NOT match normal work (conservative default)", () => {
expect(isMechanicalRoutableContext({ title: "Implement OAuth login flow" })).toBe(false);
expect(isMechanicalRoutableContext({ traits: ["needs-review"] })).toBe(false);
expect(isMechanicalRoutableContext(undefined)).toBe(false);
expect(isMechanicalRoutableContext({})).toBe(false);
});
});
describe("routeModel — core selection layer", () => {
it("allowlisted step → cheap tier with escalation seam to the default pair", () => {
const d = routeModel(baseInput());
expect(d.routed).toBe(true);
expect(d.reason).toBe("cheap-tier");
expect(d.selection).toEqual(CHEAP);
expect(d.counterfactual).toEqual(DEFAULT);
expect(d.escalation).toEqual(DEFAULT);
});
it("normal task → default pair (not routable)", () => {
const d = routeModel(baseInput({ context: { title: "Build a feature" } }));
expect(d.routed).toBe(false);
expect(d.reason).toBe("not-routable");
expect(d.selection).toEqual(DEFAULT);
expect(d.counterfactual).toEqual(DEFAULT);
});
it("column-agent override wins — router defers even for an allowlisted step", () => {
const override = { provider: "openai", modelId: "gpt-5" };
const d = routeModel(baseInput({ overridePair: override }));
expect(d.routed).toBe(false);
expect(d.reason).toBe("override");
expect(d.selection).toEqual(override);
// counterfactual is still the default-pair, not the override
expect(d.counterfactual).toEqual(DEFAULT);
});
it("a project-policy-restricted model is NEVER selected even if it is the best pick", () => {
const isPermitted = (p: { provider?: string; modelId?: string }) =>
!(p.provider === CHEAP.provider && p.modelId === CHEAP.modelId);
const d = routeModel(baseInput({ isPermitted }));
expect(d.routed).toBe(false);
expect(d.reason).toBe("cheap-forbidden");
expect(d.selection).toEqual(DEFAULT); // fallback path also respects governance
});
it("governance is absolute — a forbidden override is NOT honored, falls through", () => {
const override = { provider: "openai", modelId: "gpt-5" };
const isPermitted = (p: { provider?: string }) => p.provider !== "openai";
// override forbidden + not routable → default
const d = routeModel(baseInput({ overridePair: override, isPermitted, context: { title: "x" } }));
expect(d.reason).toBe("not-routable");
expect(d.selection).toEqual(DEFAULT);
});
it("router disabled → byte-identical to the default pair", () => {
const d = routeModel(baseInput({ settings: { ...routerSettings, modelRouterEnabled: false } }));
expect(d.routed).toBe(false);
expect(d.reason).toBe("disabled");
expect(d.selection).toEqual(DEFAULT);
expect(d.escalation).toBeUndefined();
});
it("cheap tier unconfigured → default pair", () => {
const d = routeModel(
baseInput({ settings: { modelRouterEnabled: true } }),
);
expect(d.reason).toBe("cheap-unconfigured");
expect(d.selection).toEqual(DEFAULT);
});
it("no usable default pair → reason no-default", () => {
const d = routeModel(baseInput({ defaultPair: {}, context: { title: "x" } }));
expect(d.reason).toBe("no-default");
expect(d.selection).toEqual({});
});
});
describe("governed lanes vs ungoverned lanes (model-resolution wrappers)", () => {
const task = {};
it("execution lane: disabled router === resolveTaskExecutionModel (no regression)", () => {
const settings = { ...routerSettings, modelRouterEnabled: false };
const direct = resolveTaskExecutionModel(task, settings);
const routed = routeTaskExecutionModel(task, settings).selection;
expect(routed).toEqual(direct);
});
it("planning lane: disabled router === resolveTaskPlanningModel", () => {
const settings = { ...routerSettings, modelRouterEnabled: false };
expect(routeTaskPlanningModel(task, settings).selection).toEqual(
resolveTaskPlanningModel(task, settings),
);
});
it("validation lane: disabled router === resolveTaskValidatorModel", () => {
const settings = { ...routerSettings, modelRouterEnabled: false };
expect(routeTaskValidatorModel(task, settings).selection).toEqual(
resolveTaskValidatorModel(task, settings),
);
});
it("each governed lane down-routes an allowlisted step and reports its lane", () => {
const opts = { context: { traits: ["dependabot"] } };
const exec = routeTaskExecutionModel(task, routerSettings, opts);
const plan = routeTaskPlanningModel(task, routerSettings, opts);
const val = routeTaskValidatorModel(task, routerSettings, opts);
expect(exec.lane).toBe("execution");
expect(plan.lane).toBe("planning");
expect(val.lane).toBe("validation");
for (const d of [exec, plan, val]) {
expect(d.routed).toBe(true);
expect(d.selection).toEqual(CHEAP);
}
});
it("each governed lane never returns a forbidden pair", () => {
const opts = {
context: { traits: ["dependabot"] },
isPermitted: (p: { modelId?: string }) => p.modelId !== CHEAP.modelId,
};
for (const fn of [routeTaskExecutionModel, routeTaskPlanningModel, routeTaskValidatorModel]) {
const d = fn(task, routerSettings, opts);
expect(d.selection.modelId).not.toBe(CHEAP.modelId);
}
});
it("ungoverned lanes (settings-only / title summarizer / project default) are untouched — no router wrappers exist for them", async () => {
const mod = await import("../model-resolution.js");
// Only the three task lanes get router wrappers; ensure no extra ones leaked in.
expect(typeof mod.routeTaskExecutionModel).toBe("function");
expect(typeof mod.routeTaskPlanningModel).toBe("function");
expect(typeof mod.routeTaskValidatorModel).toBe("function");
expect((mod as Record<string, unknown>).routeProjectDefaultModel).toBeUndefined();
expect((mod as Record<string, unknown>).routeExecutionSettingsModel).toBeUndefined();
expect((mod as Record<string, unknown>).routeTitleSummarizerSettingsModel).toBeUndefined();
});
});
describe("routeModelAndEmit — telemetry with counterfactual", () => {
let tmpDir: string;
let db: Database;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-model-router-test-"));
db = new Database(join(tmpDir, ".fusion"));
db.init();
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("emits a routing decision with the counterfactual model into usage_events", () => {
const d = routeModelAndEmit(db, { ...baseInput(), taskId: "t1", nodeId: "n1" });
expect(d.routed).toBe(true);
const rows = queryUsageEvents(db, { kind: "session_start" });
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.category).toBe("model-router");
expect(row.provider).toBe(CHEAP.provider);
expect(row.model).toBe(CHEAP.modelId);
expect(row.taskId).toBe("t1");
expect(row.nodeId).toBe("n1");
// The counterfactual model that WOULD have run absent the router:
expect(row.meta?.routed).toBe(true);
expect(row.meta?.reason).toBe("cheap-tier");
expect(row.meta?.counterfactualProvider).toBe(DEFAULT.provider);
expect(row.meta?.counterfactualModelId).toBe(DEFAULT.modelId);
});
it("emits the counterfactual even when not routed (default pair selected)", () => {
routeModelAndEmit(db, { ...baseInput({ context: { title: "real work" } }), taskId: "t2" });
const rows = queryUsageEvents(db, { kind: "session_start" });
expect(rows).toHaveLength(1);
expect(rows[0].provider).toBe(DEFAULT.provider);
expect(rows[0].meta?.routed).toBe(false);
expect(rows[0].meta?.counterfactualModelId).toBe(DEFAULT.modelId);
});
it("emission is fail-soft and does not alter the decision when db is undefined", () => {
const d = routeModelAndEmit(undefined, baseInput());
expect(d.selection).toEqual(CHEAP);
});
});

View File

@@ -1120,8 +1120,26 @@ export {
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
TEST_MODE_RESOLVED,
routeTaskExecutionModel,
routeTaskPlanningModel,
routeTaskValidatorModel,
} from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js";
export type { ResolvedModelSelection, RouterLaneOptions } from "./model-resolution.js";
export {
routeModel,
routeModelAndEmit,
isMechanicalRoutableContext,
} from "./model-router.js";
export type {
RouterLane,
RouterReason,
RouterPair,
RouterTaskContext,
RouteModelInput,
RouterDecision,
RouterEscalation,
ModelGovernancePredicate,
} from "./model-router.js";
// ── Memory Compaction ─────────────────────────────────────────────────

View File

@@ -1,4 +1,11 @@
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;
@@ -183,3 +190,73 @@ export function resolveTaskPlanningModel(
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);
}

View File

@@ -0,0 +1,332 @@
/**
* Fusion Model Router (U17 / KTD9).
*
* A **selection layer** that picks a `(provider, model)` pair *before* a session
* starts. It is NOT a new executor: it never adds an executor kind, it only
* chooses which already-configured CLI/provider runs. Routing is **session-level
* only** for this unit — per-request mid-session re-routing is deferred (it needs
* its own design pass on streaming continuity / context-window compatibility /
* prompt-cache invalidation).
*
* ## Conservative v0 signal
*
* There is no validated `complexity`/`difficulty` field on tasks or steps today,
* and prompt size is a weak proxy. So v0 does NOT invent a classifier. It routes
* only an **allowlist of mechanical traits** (dependabot bumps, lint-only fixes)
* to a cheap tier; **everything else resolves to the configured default pair**.
* The signal is isolated behind {@link isMechanicalRoutableContext} so a
* validated classifier can replace it later without touching the governance,
* override, or fallback machinery.
*
* ## Governance, override, fallback (load-bearing — tested per lane)
*
* 1. **Override wins.** If a column-agent (or any caller-supplied) override pins a
* pair, the router defers and returns that pair unchanged.
* 2. **Governance is absolute.** The router NEVER returns a pair an org/project/
* user model control forbids — including on the fallback path. A forbidden
* cheap pick is dropped and the router falls back; if the default pair is
* itself forbidden the router returns it untouched (governance of the default
* pair is the resolver/caller's job, not the router's to silently rewrite).
* 3. **Disabled / unavailable → default pair.** When the router is off, the cheap
* tier is unconfigured, or no pick is available, the result is byte-identical
* to the supplied default pair.
*
* ## Quality guardrail seam
*
* A cheap-tier pick carries an `escalation` describing the strong tier to retry
* with on cheap-tier failure (see {@link RouterDecision.escalation}). v0 wires
* the seam (the default pair is the escalation target) but does not itself run
* the retry loop — that lives in the executor/session layer that owns failure
* detection.
*
* ## Telemetry
*
* Every decision (including the **counterfactual** model that would have run
* absent the router) is emitted via the U1 {@link emitUsageEvent} seam so the
* Command Center can show adoption and realized cost delta versus always-premium.
* Emission is fail-soft and never alters the returned decision.
*/
import type { Database } from "./db.js";
import type { Settings } from "./types.js";
import { emitUsageEvent } from "./usage-events.js";
import type { ResolvedModelSelection } from "./model-resolution.js";
/** The resolution lanes the router governs. Ungoverned lanes are never touched. */
export type RouterLane = "execution" | "planning" | "validation";
/**
* Why the router produced the pair it did. Surfaced in telemetry `meta` and
* usable by callers for diagnostics.
*/
export type RouterReason =
| "disabled" // router off → default pair
| "override" // a column-agent/caller override pinned the pair → defer
| "cheap-tier" // an allowlisted mechanical step routed to the cheap tier
| "cheap-unconfigured" // router on but no cheap pair configured → default
| "cheap-forbidden" // cheap pick forbidden by governance → default
| "not-routable" // step not on the mechanical allowlist → default
| "no-default"; // no usable default pair to fall back to
/**
* A `(provider, model)` pair the router can choose. Mirrors
* {@link ResolvedModelSelection} but with both fields concrete when present.
*/
export interface RouterPair {
provider?: string;
modelId?: string;
}
/**
* Predicate that returns `true` iff a pair is **permitted** by the active model
* controls (org/project/user governance). The router NEVER returns a pair for
* which this returns `false` on a routed pick. Supplied by the caller because
* governance schema lives outside core's resolution layer; when omitted, all
* pairs are permitted (no governance configured).
*/
export type ModelGovernancePredicate = (pair: RouterPair) => boolean;
/**
* The signal the router classifies. Neutral, schema-light fields so the router
* does not depend on task schema that does not exist yet — callers populate from
* whatever trait/label/source data they have in scope.
*/
export interface RouterTaskContext {
/** Workflow trait flags on the task/column (e.g. `["dependabot", "lint-only"]`). */
traits?: readonly string[];
/** Labels on the task / source issue (e.g. `["dependencies", "lint"]`). */
labels?: readonly string[];
/** How the task was created (e.g. a `dependabot` / `renovate` source). */
source?: string | null;
/** Task title — used only for conservative keyword matching on the allowlist. */
title?: string | null;
}
export interface RouteModelInput {
lane: RouterLane;
/**
* The pair resolution would return absent the router — the **counterfactual**.
* The router falls back to this and emits it as the counterfactual in telemetry.
*/
defaultPair: ResolvedModelSelection;
/**
* A column-agent (or other) override pair. When it carries both provider and
* model, the router defers to it unconditionally (override wins).
*/
overridePair?: ResolvedModelSelection | null;
/** The classification signal. */
context?: RouterTaskContext;
settings?: Partial<Settings>;
/** Governance gate. When omitted, all pairs are permitted. */
isPermitted?: ModelGovernancePredicate;
}
/** The strong-tier retry target for the quality guardrail. */
export interface RouterEscalation {
provider?: string;
modelId?: string;
}
export interface RouterDecision {
/** The pair to actually use. */
selection: ResolvedModelSelection;
/** True iff the router down-routed to the cheap tier. */
routed: boolean;
reason: RouterReason;
lane: RouterLane;
/** What would have run absent the router (always the supplied default pair). */
counterfactual: ResolvedModelSelection;
/**
* Quality-guardrail seam: the strong tier to retry with if the cheap-tier pick
* fails. Present only when `routed` is true. v0 sets this to the counterfactual.
*/
escalation?: RouterEscalation;
}
const DEPENDABOT_SOURCES: ReadonlySet<string> = new Set([
"dependabot",
"renovate",
"renovatebot",
]);
const MECHANICAL_TRAITS: ReadonlySet<string> = new Set([
"dependabot",
"dependency-bump",
"deps",
"lint-only",
"lint-fix",
"lint",
"formatting",
"format-only",
]);
const MECHANICAL_LABELS: ReadonlySet<string> = new Set([
"dependencies",
"dependabot",
"deps",
"lint",
"lint-only",
"formatting",
"style",
]);
function normalize(s: string | null | undefined): string {
return (s ?? "").trim().toLowerCase();
}
function hasComplete(pair: ResolvedModelSelection | null | undefined): pair is { provider: string; modelId: string } {
return Boolean(pair?.provider && pair?.modelId);
}
/**
* Conservative v0 classifier: is this step a mechanical, allowlisted candidate
* for the cheap tier? Pure and isolated so a validated classifier can replace it
* later. Returns `true` ONLY for clearly-mechanical signals; the default is
* `false` (→ default pair).
*/
export function isMechanicalRoutableContext(context: RouterTaskContext | undefined): boolean {
if (!context) return false;
if (DEPENDABOT_SOURCES.has(normalize(context.source))) return true;
for (const trait of context.traits ?? []) {
if (MECHANICAL_TRAITS.has(normalize(trait))) return true;
}
for (const label of context.labels ?? []) {
if (MECHANICAL_LABELS.has(normalize(label))) return true;
}
// Conservative title keyword match: a dependabot/bump or lint-only chore.
const title = normalize(context.title);
if (title) {
if (/\bbump\b/.test(title) && /\bfrom\b/.test(title) && /\bto\b/.test(title)) return true;
if (title.startsWith("chore(deps)") || title.startsWith("build(deps)")) return true;
if (/\blint\b/.test(title) && /\b(only|fix|fixes)\b/.test(title)) return true;
}
return false;
}
/** Resolve the configured cheap-tier pair, or `undefined` when unconfigured. */
function resolveCheapPair(settings: Partial<Settings> | undefined): RouterPair | undefined {
const provider = settings?.modelRouterCheapProvider;
const modelId = settings?.modelRouterCheapModelId;
if (provider && modelId) return { provider, modelId };
return undefined;
}
function isRouterEnabled(settings: Partial<Settings> | undefined): boolean {
return settings?.modelRouterEnabled === true;
}
/**
* The core selection function. **Pure** (no DB, no telemetry) so it is trivially
* testable; {@link routeModelAndEmit} wraps it to also emit telemetry.
*
* Decision order (each rule is tested):
* 1. override pinned → defer (return override, `routed: false`)
* 2. router disabled → default pair
* 3. not mechanical → default pair
* 4. cheap tier unconfigured→ default pair
* 5. cheap pick forbidden → default pair (governance, incl. fallback path)
* 6. otherwise → cheap pick (with escalation seam)
*
* Governance also guards the override (an override forbidden by policy is NOT
* honored — governance is absolute) and is noted on the default-pair paths via
* `reason`, but the router never rewrites a forbidden default pair: governing the
* default is the resolver/caller's responsibility, the router only guarantees it
* does not *introduce* a forbidden pair.
*/
export function routeModel(input: RouteModelInput): RouterDecision {
const { lane, defaultPair, overridePair, context, settings } = input;
const isPermitted = input.isPermitted ?? (() => true);
const counterfactual: ResolvedModelSelection = { ...defaultPair };
const fallback = (reason: RouterReason): RouterDecision => ({
selection: { ...defaultPair },
routed: false,
reason: hasComplete(defaultPair) ? reason : "no-default",
lane,
counterfactual,
});
// 1. Override wins — but governance is absolute, so a forbidden override is not
// honored; it falls through to default resolution.
if (hasComplete(overridePair) && isPermitted({ provider: overridePair.provider, modelId: overridePair.modelId })) {
return {
selection: { provider: overridePair.provider, modelId: overridePair.modelId },
routed: false,
reason: "override",
lane,
counterfactual,
};
}
// 2. Disabled → byte-identical default-pair behavior.
if (!isRouterEnabled(settings)) {
return fallback("disabled");
}
// 3. Conservative allowlist: only mechanical steps are routable.
if (!isMechanicalRoutableContext(context)) {
return fallback("not-routable");
}
// 4. Cheap tier must be configured.
const cheap = resolveCheapPair(settings);
if (!cheap || !hasComplete(cheap)) {
return fallback("cheap-unconfigured");
}
// 5. Governance is absolute — never return a forbidden cheap pick.
if (!isPermitted({ provider: cheap.provider, modelId: cheap.modelId })) {
return fallback("cheap-forbidden");
}
// 6. Route to the cheap tier, wiring the quality-guardrail escalation seam.
return {
selection: { provider: cheap.provider, modelId: cheap.modelId },
routed: true,
reason: "cheap-tier",
lane,
counterfactual,
escalation: hasComplete(defaultPair)
? { provider: defaultPair.provider, modelId: defaultPair.modelId }
: undefined,
};
}
/**
* {@link routeModel} plus fail-soft telemetry: emits one `session_start` usage
* event carrying the routing decision and the **counterfactual** model. Emission
* never alters or blocks the returned decision (the U1 seam is itself fail-soft).
*/
export function routeModelAndEmit(
db: Database | undefined,
input: RouteModelInput & { taskId?: string | null; agentId?: string | null; nodeId?: string | null },
): RouterDecision {
const decision = routeModel(input);
if (db) {
emitUsageEvent(db, {
kind: "session_start",
taskId: input.taskId ?? null,
agentId: input.agentId ?? null,
nodeId: input.nodeId ?? null,
model: decision.selection.modelId ?? null,
provider: decision.selection.provider ?? null,
category: "model-router",
meta: {
router: true,
lane: decision.lane,
routed: decision.routed,
reason: decision.reason,
// The counterfactual model that WOULD have run absent the router.
counterfactualProvider: decision.counterfactual.provider ?? null,
counterfactualModelId: decision.counterfactual.modelId ?? null,
escalationProvider: decision.escalation?.provider ?? null,
escalationModelId: decision.escalation?.modelId ?? null,
},
});
}
return decision;
}

View File

@@ -69,6 +69,9 @@ export const DEFAULT_GLOBAL_SETTINGS = {
defaultProvider: undefined,
defaultModelId: undefined,
testMode: undefined,
modelRouterEnabled: undefined,
modelRouterCheapProvider: undefined,
modelRouterCheapModelId: undefined,
mergeRequestContractShadowEnabled: false,
fallbackProvider: undefined,
fallbackModelId: undefined,

View File

@@ -2814,6 +2814,22 @@ export interface GlobalSettings {
* of per-task or per-lane overrides. No network calls, zero token cost.
* Project `testMode` takes precedence over the global value. */
testMode?: boolean;
/** Fusion Model Router opt-in (U17/KTD9). When true, a conservative selection
* layer may down-route an allowlist of mechanical steps (dependabot bumps,
* lint-only fixes) to a cheap model tier before a session starts; everything
* else resolves to the configured default pair. OFF by default — when unset or
* false, model resolution is byte-identical to its non-router behavior.
* Selection is governed: it never returns a pair the model controls forbid and
* always defers to a column-agent override. */
modelRouterEnabled?: boolean;
/** Provider for the Model Router's cheap tier (U17). Used only when
* `modelRouterEnabled` is true and a step is allowlisted for down-routing.
* Must be set together with `modelRouterCheapModelId`; if either is unset the
* router falls back to the configured default pair. */
modelRouterCheapProvider?: string;
/** Model ID for the Model Router's cheap tier (U17). See
* `modelRouterCheapProvider`. */
modelRouterCheapModelId?: string;
/** Phase-1 FN-5741 write-only shadow seam toggle.
* When true, executor/self-healing/merger persist additive merge-request contract
* records and completion-handoff markers without changing merge authority.