diff --git a/packages/core/src/__tests__/model-router.test.ts b/packages/core/src/__tests__/model-router.test.ts new file mode 100644 index 0000000000..fbb1dc19f7 --- /dev/null +++ b/packages/core/src/__tests__/model-router.test.ts @@ -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 = { + 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 { + 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).routeProjectDefaultModel).toBeUndefined(); + expect((mod as Record).routeExecutionSettingsModel).toBeUndefined(); + expect((mod as Record).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); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 329086268c..d336658c3d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 ───────────────────────────────────────────────── diff --git a/packages/core/src/model-resolution.ts b/packages/core/src/model-resolution.ts index 85b527c567..6159ea1752 100644 --- a/packages/core/src/model-resolution.ts +++ b/packages/core/src/model-resolution.ts @@ -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 | 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, + 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, + 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, + options?: RouterLaneOptions, +): RouterDecision { + return routeLane("validation", resolveTaskValidatorModel(task, settings), settings, options); +} diff --git a/packages/core/src/model-router.ts b/packages/core/src/model-router.ts new file mode 100644 index 0000000000..c61ec1adab --- /dev/null +++ b/packages/core/src/model-router.ts @@ -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; + /** 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 = new Set([ + "dependabot", + "renovate", + "renovatebot", +]); + +const MECHANICAL_TRAITS: ReadonlySet = new Set([ + "dependabot", + "dependency-bump", + "deps", + "lint-only", + "lint-fix", + "lint", + "formatting", + "format-only", +]); + +const MECHANICAL_LABELS: ReadonlySet = 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 | undefined): RouterPair | undefined { + const provider = settings?.modelRouterCheapProvider; + const modelId = settings?.modelRouterCheapModelId; + if (provider && modelId) return { provider, modelId }; + return undefined; +} + +function isRouterEnabled(settings: Partial | 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; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 5ba21535e9..060dd4cea2 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f6564b3343..ed4518b1cd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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.