diff --git a/README.md b/README.md index e7b632f..2787cc8 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,72 @@ grounded in one of those two sources before honoring the override. ## Supported providers +Autorouter discovers available Codex, Claude Code, Cursor, and Antigravity +(`agy`) models from BB at route time. OmniRoute remains deliberately outside +this list: it is intended for delegated subagent work rather than interactive +Autorouter threads. + The provider, model, and CursorBench snapshot tables are compiled into the extension (`router.ts`, `benchmarks.ts`) and cover Codex, Claude Code, and Cursor as of v0.2.0. Models outside that table are still routable as fallbacks but do not get a capability or cost score, so the table needs a new release whenever a provider ships new models. +### Per-difficulty model selection + +Antigravity has no CursorBench entry, so under normal benchmark-ranked routing +it is only ever chosen when Codex, Claude Code, and Cursor are all +simultaneously unavailable or quota-exhausted — a narrow, mostly-last-resort +condition. + +There is no hardcoded difficulty cutoff or provider priority in the code — +`settings.difficultyBands` is a plain, editable list of native two-sided +range bands, `{ minDifficulty, maxDifficulty, fallbackChain }`, checked +low-to-high by effective lower bound. Either bound may be `null` for +"unbounded" on that side (`minDifficulty: null` matches down to 0, +`maxDifficulty: null` matches up to 100; both `null` matches everything). +The first band whose `[minDifficulty, maxDifficulty]` range (inclusive) +covers a task's difficulty score skips benchmark ranking entirely and +routes straight through that band's own ordered fallback chain — first +provider (or exact `provider/model` pin) that's actually available and +quota-eligible wins. If nothing in the matched band is available, or no +band covers the score at all, routing falls through to the normal +benchmark-ranked path below. + +Ranges are genuinely two-sided, not just a series of independent +thresholds — e.g. `0–25` for one model, `26–75` for another, `76–100` for a +third, all expressible as three bands with no gaps or overlaps needed. A +single exact score is just `minDifficulty === maxDifficulty`. + +The shipped default reproduces what used to be hardcoded — a `0–25` band +(`minDifficulty: null`) with the chain `antigravity → codex → claude-code` +(Cursor is deliberately not in the default chain; it keeps its normal +ranked path at every difficulty) — but it's just the *default value* of a +setting now, not logic baked into `router.ts`. Add, remove, reorder, or +clear bands freely from the settings page or +`bb autorouter config --difficulty-bands '[...]'`. + +An explicit model override (user-requested, or from custom instructions) +still takes priority over band matching and applies before it. + +### Decision agent + +The model that rates each task's difficulty (0-100) before routing is itself +configurable from the same settings page, via a single searchable +provider/model picker (`Decision agent`): + +- **Automatic** (default) tries an ordered `automaticFallbackChain` — + a plain, editable preference list, same shape and same picker as the + per-difficulty bands above. In this mode, checking a model in the picker + toggles its membership in the fallback order shown right below it, instead + of pinning the classifier to it. +- Picking a specific model instead pins the classifier to exactly that + model, skipping the fallback order entirely. + +If nothing in the fallback order is available, the classifier falls back to +any launchable model, then whatever's first — a last-resort safety net, not +a preference, so it isn't a user-facing setting. + ## Current bb extension boundary bb 0.39 plugins cannot intercept the native root New Thread submit or insert diff --git a/app.tsx b/app.tsx index a980a62..d6ac9d5 100644 --- a/app.tsx +++ b/app.tsx @@ -8,10 +8,20 @@ import { useRpc, } from "@get-bb/plugin-sdk/app"; import { toast } from "sonner"; -import type { rpcContract } from "./server"; +import type { ModelCatalog, rpcContract } from "./server"; import type { RoutedThreadResult } from "./router"; -import type { AutorouterSettings } from "./settings"; +import type { AutorouterSettings, DifficultyBand } from "./settings"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Icon } from "@/components/ui/icon"; import { SELECTING_LABEL_BASE, SELECTING_LABEL_INTERVAL_MS, @@ -20,6 +30,14 @@ import { } from "./selecting-label"; import "./autorouter.css"; +/** Swaps the entries at `from` and `to`; out-of-range indices are a no-op. */ +function moveEntry(list: readonly T[], from: number, to: number): T[] { + if (to < 0 || to >= list.length || from === to) return [...list]; + const next = [...list]; + [next[from], next[to]] = [next[to] as T, next[from] as T]; + return next; +} + const AUTO_ROUTER_COMPOSE_LAYOUT_CLASS = "mx-auto flex w-full max-w-[760px] flex-col px-4 pb-4 pt-14"; @@ -126,6 +144,251 @@ function AutoRouterPage() { ); } +interface CatalogState { + status: "loading" | "ready" | "failed"; + catalog: ModelCatalog | null; +} + +type UpdateSettings = ( + patch: Partial, + options?: { debounceMs?: number }, +) => void; + +/** Empty means "unbounded" on that side; the input shows a blank field. */ +function boundInputValue(bound: number | null): string { + return bound === null ? "" : String(bound); +} + +function parseBoundInput(raw: string): number | null { + if (raw.trim() === "") return null; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return null; + return Math.max(0, Math.min(100, Math.round(parsed))); +} + +/** + * Per-difficulty model selection: an ordered list of difficulty bands, each + * a native two-sided range (`minDifficulty`/`maxDifficulty`, either end + * nullable for "unbounded") plus its own fallback chain. Checked low to + * high by effective lower bound. Mirrors the Decision agent section's + * fallback-order editor, just scoped per band instead of to the classifier + * — same list/reorder/remove pattern, plus a per-band model picker and + * add/remove band controls. + */ +function DifficultyBandsSection({ + settings, + update, + picker, +}: { + settings: AutorouterSettings; + update: UpdateSettings; + picker: CatalogState; +}) { + function updateBand(index: number, patch: Partial) { + update({ + difficultyBands: settings.difficultyBands.map((band, i) => + i === index ? { ...band, ...patch } : band, + ), + }); + } + + function removeBand(index: number) { + update({ + difficultyBands: settings.difficultyBands.filter((_, i) => i !== index), + }); + } + + function addBand() { + update({ + difficultyBands: [ + ...settings.difficultyBands, + { minDifficulty: null, maxDifficulty: 50, fallbackChain: [] }, + ], + }); + } + + const sortedForDisplay = [...settings.difficultyBands] + .map((band, index) => ({ band, index })) + .sort((a, b) => (a.band.minDifficulty ?? -1) - (b.band.minDifficulty ?? -1)); + + return ( +
+ +

+ Route tasks whose difficulty falls in a range straight through a + fixed fallback chain instead of the normal benchmark-ranked + selection — e.g. 0–25 for one model, 26–75 for another. Leave either + side blank for an open-ended range (blank min = 0, blank max = 100). + Checked low to high — the first band that covers a task's difficulty + score wins. Empty by default beyond whatever bands you add here; + there is no hardcoded difficulty cutoff. +

+ {sortedForDisplay.length === 0 ? ( +

+ No bands configured — every task uses benchmark-ranked selection. +

+ ) : ( +
+ {sortedForDisplay.map(({ band, index }) => ( +
+
+ + + updateBand(index, { + minDifficulty: parseBoundInput(event.target.value), + }) + } + /> + to + + updateBand(index, { + maxDifficulty: parseBoundInput(event.target.value), + }) + } + /> + +
+ {band.fallbackChain.length === 0 ? ( +

+ Empty — this band defers to benchmark-ranked selection. +

+ ) : ( +
+ {band.fallbackChain.map((entry, entryIndex) => ( +
+ + {entryIndex + 1}. + + {entry} + + + +
+ ))} +
+ )} +
+ + + + + {picker.status === "ready" + ? "No models match your search." + : picker.status === "failed" + ? "Couldn't load the model catalog." + : "Loading models…"} + + {(picker.catalog?.providers ?? []).map((provider) => ( + + {provider.models.map((model) => { + const value = `${provider.id}/${model.model}`; + const alreadyAdded = band.fallbackChain.includes(value); + return ( + + updateBand(index, { + fallbackChain: [...band.fallbackChain, value], + }) + } + > + + {model.displayName} + {alreadyAdded ? ( + added + ) : null} + + ); + })} + + ))} + + +
+
+ ))} +
+ )} + +
+ ); +} + function AutoRouterSettings() { const rpc = useRpc(); const [settings, setSettings] = useState(null); @@ -134,6 +397,30 @@ function AutoRouterSettings() { const timerRef = useRef | null>(null); const saveVersionRef = useRef(0); + // Decision-agent model picker: same pattern as bb-plugin-prompt-enhancer's + // ModelSettingsSection — fetch the live provider/model catalog once, offer + // a searchable list instead of a free-text "provider/model" box. + const [picker, setPicker] = useState({ + status: "loading", + catalog: null, + }); + + useEffect(() => { + let cancelled = false; + void rpc + .call("listModels") + .then((catalog) => { + if (!cancelled) setPicker({ status: "ready", catalog }); + }) + .catch(() => { + if (!cancelled) setPicker({ status: "failed", catalog: null }); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const load = () => { void rpc .call("getSettings") @@ -271,27 +558,171 @@ function AutoRouterSettings() {
-
+ +