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:
246
packages/core/src/__tests__/model-router.test.ts
Normal file
246
packages/core/src/__tests__/model-router.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
332
packages/core/src/model-router.ts
Normal file
332
packages/core/src/model-router.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user