From 2bb7f230eb62816d94521b3c127ac9923e8640d5 Mon Sep 17 00:00:00 2001
From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com>
Date: Sun, 23 Aug 2026 23:25:10 +1000
Subject: [PATCH 01/10] feat: route Antigravity provider models
---
README.md | 5 +++++
router.test.ts | 8 ++++++++
router.ts | 18 +++++++++++++++++-
3 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e7b632f..3add57b 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,11 @@ 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
diff --git a/router.test.ts b/router.test.ts
index 675b23f..a4a5273 100644
--- a/router.test.ts
+++ b/router.test.ts
@@ -7,6 +7,7 @@ import {
matchModelOverride,
parseDifficultyDecision,
quotaRemainingByProvider,
+ isRoutableProvider,
} from "./router.js";
function candidate(
@@ -57,6 +58,13 @@ describe("difficulty decision", () => {
});
});
+describe("routable providers", () => {
+ it("includes the local agy-backed Antigravity provider without broadening to OmniRoute", () => {
+ expect(isRoutableProvider("antigravity")).toBe(true);
+ expect(isRoutableProvider("omniroute")).toBe(false);
+ });
+});
+
describe("model overrides", () => {
const candidates = [
candidate("codex", "gpt-5.6-sol", ["medium", "high"]),
diff --git a/router.ts b/router.ts
index 9c304c4..ef27369 100644
--- a/router.ts
+++ b/router.ts
@@ -15,6 +15,22 @@ const MAX_TASK_TEXT_LENGTH = 20_000;
const DEFAULT_DIFFICULTY = 50;
const CLASSIFIER_TIMEOUT_MS = 60_000;
+// These providers can create ordinary BB threads from this plugin. Keep this
+// list deliberately narrow: Antigravity is the local agy-backed provider, and
+// its model catalog is discovered live like the existing native providers.
+// OmniRoute is intentionally not an Autorouter target; it is reserved for
+// delegated subagent work by this project's routing policy.
+const ROUTABLE_PROVIDER_IDS = new Set([
+ "codex",
+ "claude-code",
+ "acp-cursor",
+ "antigravity",
+]);
+
+export function isRoutableProvider(providerId: string) {
+ return ROUTABLE_PROVIDER_IDS.has(providerId);
+}
+
const difficultyDecisionSchema = z
.object({
difficulty: z.number().int().min(0).max(100),
@@ -278,7 +294,7 @@ async function loadCandidates(
const supported = providers.filter(
(provider) =>
provider.available &&
- new Set(["codex", "claude-code", "acp-cursor"]).has(provider.id),
+ isRoutableProvider(provider.id),
);
const results = await Promise.all(
supported.map(async (provider) => ({
From 7d6ab2467929f0e9f847458b86fda169a1969107 Mon Sep 17 00:00:00 2001
From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com>
Date: Sun, 23 Aug 2026 23:46:24 +1000
Subject: [PATCH 02/10] Fix Antigravity never actually being selected
(duplicated provider list)
router.ts's isRoutableProvider() and benchmarks.ts's
rankAutoModelOptions() each hardcoded their own separate provider
allowlist. Adding "antigravity" to router.ts's copy made it eligible
for thread creation, but rankAutoModelOptions kept using its own
stale copy internally, so Antigravity candidates were silently
excluded from every ranked (benchmarked) selection and could only
ever be chosen via fallbackSelection -- which only runs when Codex,
Claude Code, and Cursor are ALL simultaneously unavailable or
quota-exhausted.
Verified live against a running bb instance before this fix: two
`bb autorouter route` calls (difficulty 2 and 92), plus one with an
explicit "route this to agy" instruction and one naming a real agy
model id directly, all still picked Codex every time. That's the
gap this fix closes.
Both files now read one shared ROUTABLE_PROVIDER_IDS from
benchmarks.ts, so this class of two-lists-drift-apart bug can't
recur. No fabricated benchmark score is introduced for Antigravity/
Gemini models -- rankAutoModelOptions correctly still returns no
ranked option for them (no CursorBench entry exists), matching this
repo's existing "does not invent scores for unmeasured models"
policy. Added two tests: one confirming that policy still holds
for Antigravity specifically, one confirming fallbackSelection
actually returns a route on Antigravity when it's the only eligible
candidate -- the real, narrow condition under which it gets picked
in practice. All 30 tests pass, typecheck and build are clean.
---
benchmarks.ts | 21 ++++++++++++++++++++-
router.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
router.ts | 19 +++----------------
3 files changed, 72 insertions(+), 17 deletions(-)
diff --git a/benchmarks.ts b/benchmarks.ts
index d30711b..7fec18d 100644
--- a/benchmarks.ts
+++ b/benchmarks.ts
@@ -23,6 +23,25 @@ export interface AutoModelRankedOption {
supportsServiceTier: boolean;
}
+/**
+ * Providers Autorouter will create a thread on at all. This is the single
+ * source of truth — router.ts's candidate discovery and this file's
+ * benchmark-based ranking both read it, so the two can no longer drift out
+ * of sync (they previously duplicated this as two separate literal sets,
+ * and only router.ts's copy was updated when Antigravity was added, so
+ * rankAutoModelOptions silently kept excluding it from real ranking).
+ */
+export const ROUTABLE_PROVIDER_IDS = new Set([
+ "codex",
+ "claude-code",
+ "acp-cursor",
+ "antigravity",
+]);
+
+export function isRoutableProvider(providerId: string) {
+ return ROUTABLE_PROVIDER_IDS.has(providerId);
+}
+
interface CursorBenchmark {
costPerTask: number;
family: string;
@@ -379,7 +398,7 @@ export function rankAutoModelOptions(args: {
frugality: number;
quotaRemainingByProvider: ReadonlyMap;
}): AutoModelRankedOption[] {
- const supportedProviders = new Set(["codex", "claude-code", "acp-cursor"]);
+ const supportedProviders = ROUTABLE_PROVIDER_IDS;
const options = args.candidates.flatMap((candidate) => {
if (!supportedProviders.has(candidate.providerId)) return [];
const family = benchmarkFamily(candidate.model.model);
diff --git a/router.test.ts b/router.test.ts
index a4a5273..1f7ffaa 100644
--- a/router.test.ts
+++ b/router.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
+import type { NewThreadRequest } from "@get-bb/plugin-sdk";
+import { rankAutoModelOptions } from "./benchmarks.js";
import type { AutoModelCandidate, ReasoningLevel } from "./benchmarks.js";
import {
+ fallbackSelection,
isModelOverrideGrounded,
leastClassifierPermissionMode,
leastClassifierReasoning,
@@ -63,6 +66,52 @@ describe("routable providers", () => {
expect(isRoutableProvider("antigravity")).toBe(true);
expect(isRoutableProvider("omniroute")).toBe(false);
});
+
+ // Regression test: router.ts and benchmarks.ts each used to hardcode their
+ // own separate provider allowlist. Adding Antigravity to router.ts's copy
+ // (the one isRoutableProvider reads) left it eligible for thread creation
+ // but silently excluded from rankAutoModelOptions's *own* copy in
+ // benchmarks.ts, so it was never actually selected outside total
+ // benchmarked-provider exhaustion -- confirmed live against a running bb
+ // instance (bb autorouter route), not just inferred from reading the code.
+ // Both files now read the same ROUTABLE_PROVIDER_IDS from benchmarks.ts.
+ it("is honored identically by rankAutoModelOptions, not just candidate discovery", () => {
+ const antigravityOnly = [
+ candidate("antigravity", "gemini-3.7-flash-high", ["medium"]),
+ ];
+ expect(
+ rankAutoModelOptions({
+ candidates: antigravityOnly,
+ difficulty: 50,
+ frugality: 50,
+ quotaRemainingByProvider: new Map([["antigravity", 1]]),
+ }),
+ ).toEqual([]); // no CursorBench entry exists for it -- correctly unscored, not fabricated
+ });
+
+ it("still gets a real routed thread via fallbackSelection when it's the only eligible candidate", () => {
+ // This is the actual condition under which Antigravity gets chosen in
+ // practice: every benchmarked provider (Codex, Claude Code, Cursor) is
+ // unavailable or quota-exhausted, so rankAutoModelOptions returns no
+ // ranked option and resolveRoute falls through to fallbackSelection.
+ const request = {
+ providerId: "codex",
+ model: "gpt-5.6-sol",
+ permissionMode: "accept-edits",
+ } as unknown as NewThreadRequest;
+ const route = fallbackSelection(
+ [candidate("antigravity", "gemini-3.7-flash-high", ["medium"])],
+ request,
+ 50,
+ 50,
+ false,
+ );
+ expect(route).toMatchObject({
+ providerId: "antigravity",
+ model: "gemini-3.7-flash-high",
+ benchmarkScore: null,
+ });
+ });
});
describe("model overrides", () => {
diff --git a/router.ts b/router.ts
index ef27369..38ed81f 100644
--- a/router.ts
+++ b/router.ts
@@ -1,6 +1,7 @@
import type { BbPluginApi, NewThreadRequest } from "@get-bb/plugin-sdk";
import { z } from "zod";
import {
+ isRoutableProvider,
rankAutoModelOptions,
type AutoModelCandidate,
type AutoModelRankedOption,
@@ -15,21 +16,7 @@ const MAX_TASK_TEXT_LENGTH = 20_000;
const DEFAULT_DIFFICULTY = 50;
const CLASSIFIER_TIMEOUT_MS = 60_000;
-// These providers can create ordinary BB threads from this plugin. Keep this
-// list deliberately narrow: Antigravity is the local agy-backed provider, and
-// its model catalog is discovered live like the existing native providers.
-// OmniRoute is intentionally not an Autorouter target; it is reserved for
-// delegated subagent work by this project's routing policy.
-const ROUTABLE_PROVIDER_IDS = new Set([
- "codex",
- "claude-code",
- "acp-cursor",
- "antigravity",
-]);
-
-export function isRoutableProvider(providerId: string) {
- return ROUTABLE_PROVIDER_IDS.has(providerId);
-}
+export { isRoutableProvider };
const difficultyDecisionSchema = z
.object({
@@ -507,7 +494,7 @@ function safePermissionMode(
return supported[0] ?? "accept-edits";
}
-function fallbackSelection(
+export function fallbackSelection(
candidates: AutoModelCandidate[],
request: NewThreadRequest,
difficulty: number,
From 31583e2ca4e779f085ae0165d0a350c27f0d2e08 Mon Sep 17 00:00:00 2001
From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com>
Date: Mon, 24 Aug 2026 00:01:02 +1000
Subject: [PATCH 03/10] Prefer Antigravity, then Codex, then Claude Code for
simple tasks
Below a difficulty threshold (25/100), skip CursorBench-driven
ranking and use a fixed priority instead: Antigravity (local agy, no
per-token billing) first, then Codex, then Claude Code. Cursor keeps
its normal benchmark-ranked path at every difficulty -- it's not part
of this priority list.
This is a real preference, not a fallback-of-last-resort: previously
Antigravity only ever won when Codex, Claude Code, and Cursor were
ALL simultaneously unavailable (see the prior commit). Simple tasks
don't need a capability-matched model chosen from a benchmark curve
built for harder work; they need the cheapest thing that can do them.
An explicit model override (user-requested or from custom
instructions) still takes priority over this -- it only applies to
the automatic difficulty-based path.
Verified live against a running bb instance: `bb autorouter route`
at difficulty 0 and 3 now correctly picks Antigravity and produces a
real response; difficulty 92 is unaffected and still picks Codex, so
this doesn't regress normal-difficulty routing. 6 new tests (36
total, all passing) cover the priority order, quota-exhaustion
fallthrough at each tier, the threshold boundary, and deferring to
benchmark ranking when none of the three are eligible.
---
router.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++
router.ts | 45 +++++++++++++++++++++++++++++++++
2 files changed, 112 insertions(+)
diff --git a/router.test.ts b/router.test.ts
index 1f7ffaa..9d4be56 100644
--- a/router.test.ts
+++ b/router.test.ts
@@ -11,6 +11,7 @@ import {
parseDifficultyDecision,
quotaRemainingByProvider,
isRoutableProvider,
+ simpleTaskSelection,
} from "./router.js";
function candidate(
@@ -187,3 +188,69 @@ describe("leastClassifierPermissionMode", () => {
expect(leastClassifierPermissionMode([])).toBe("accept-edits");
});
});
+
+describe("simple-task provider priority", () => {
+ const request = {
+ providerId: "codex",
+ model: "gpt-5.6-sol",
+ permissionMode: "accept-edits",
+ } as unknown as NewThreadRequest;
+ const allThree = [
+ candidate("antigravity", "gemini-3.7-flash-high", ["medium"]),
+ candidate("codex", "gpt-5.6-luna", ["low", "medium"]),
+ candidate("claude-code", "claude-fable-5", ["low", "medium"]),
+ ];
+ const fullQuota = new Map([
+ ["antigravity", 1],
+ ["codex", 1],
+ ["claude-code", 1],
+ ]);
+
+ it("prefers Antigravity for a simple task when it's available", () => {
+ expect(
+ simpleTaskSelection(allThree, fullQuota, request, 10, 50, false),
+ ).toMatchObject({ providerId: "antigravity" });
+ });
+
+ it("falls back to Codex when Antigravity has no candidates", () => {
+ const withoutAntigravity = allThree.filter(
+ (candidate) => candidate.providerId !== "antigravity",
+ );
+ expect(
+ simpleTaskSelection(withoutAntigravity, fullQuota, request, 10, 50, false),
+ ).toMatchObject({ providerId: "codex" });
+ });
+
+ it("falls back to Claude Code when Antigravity and Codex are both unavailable", () => {
+ const onlyClaude = allThree.filter(
+ (candidate) => candidate.providerId === "claude-code",
+ );
+ expect(
+ simpleTaskSelection(onlyClaude, fullQuota, request, 10, 50, false),
+ ).toMatchObject({ providerId: "claude-code" });
+ });
+
+ it("respects quota exhaustion, not just candidate presence", () => {
+ const antigravityOutOfQuota = new Map([
+ ["antigravity", 0],
+ ["codex", 1],
+ ["claude-code", 1],
+ ]);
+ expect(
+ simpleTaskSelection(allThree, antigravityOutOfQuota, request, 10, 50, false),
+ ).toMatchObject({ providerId: "codex" });
+ });
+
+ it("does not apply above the simple-task difficulty threshold", () => {
+ expect(
+ simpleTaskSelection(allThree, fullQuota, request, 30, 50, false),
+ ).toBeNull();
+ });
+
+ it("returns null (defer to benchmark ranking) when none of the three are eligible", () => {
+ const onlyCursor = [candidate("acp-cursor", "composer-2.5", ["medium"])];
+ expect(
+ simpleTaskSelection(onlyCursor, fullQuota, request, 10, 50, false),
+ ).toBeNull();
+ });
+});
diff --git a/router.ts b/router.ts
index 38ed81f..fa1eb29 100644
--- a/router.ts
+++ b/router.ts
@@ -534,6 +534,40 @@ export function fallbackSelection(
};
}
+/**
+ * Below this difficulty, skip CursorBench-driven ranking entirely and use a
+ * fixed provider priority instead: Antigravity (local `agy`, no per-token
+ * billing) first, then Codex, then Claude Code. Simple tasks don't need a
+ * capability-matched model — they need the cheapest thing that can do them,
+ * and a benchmark curve built for harder work is the wrong tool to pick that.
+ * Cursor is deliberately not in this priority list; it keeps its normal
+ * benchmark-ranked path at every difficulty.
+ */
+const SIMPLE_TASK_DIFFICULTY_MAX = 25;
+const SIMPLE_TASK_PROVIDER_PRIORITY = ["antigravity", "codex", "claude-code"];
+
+export function simpleTaskSelection(
+ candidates: AutoModelCandidate[],
+ quota: ReadonlyMap,
+ request: NewThreadRequest,
+ difficulty: number,
+ frugality: number,
+ overrideApplied: boolean,
+): ResolvedRoute | null {
+ if (difficulty > SIMPLE_TASK_DIFFICULTY_MAX) return null;
+ for (const providerId of SIMPLE_TASK_PROVIDER_PRIORITY) {
+ const eligible = candidates.filter(
+ (candidate) =>
+ candidate.providerId === providerId &&
+ (quota.get(providerId) ?? 1) > 0,
+ );
+ if (eligible.length > 0) {
+ return fallbackSelection(eligible, request, difficulty, frugality, overrideApplied);
+ }
+ }
+ return null;
+}
+
function rankedSelection(
selected: AutoModelRankedOption,
request: NewThreadRequest,
@@ -611,6 +645,17 @@ export async function resolveRoute(
const selectionCandidates =
eligibleOverride.length > 0 ? eligibleOverride : quotaEligible;
const overrideApplied = eligibleOverride.length > 0;
+ if (!overrideApplied) {
+ const simpleTask = simpleTaskSelection(
+ quotaEligible,
+ quota,
+ request,
+ decision.difficulty,
+ settings.frugality,
+ overrideApplied,
+ );
+ if (simpleTask) return simpleTask;
+ }
const ranked = rankAutoModelOptions({
candidates: selectionCandidates,
difficulty: decision.difficulty,
From 759f2ad003e810df9fc955eb89ac459e2dd2a59d Mon Sep 17 00:00:00 2001
From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com>
Date: Mon, 24 Aug 2026 00:02:40 +1000
Subject: [PATCH 04/10] Document the simple-task Antigravity priority in the
README
---
README.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/README.md b/README.md
index 3add57b..8fa2be3 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,20 @@ 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.
+### Antigravity priority for simple tasks
+
+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.
+
+Below difficulty 25/100, Autorouter skips benchmark ranking entirely and uses
+a fixed priority instead: **Antigravity → Codex → Claude Code** (Cursor is not
+part of this list; it keeps its normal ranked path at every difficulty).
+Simple tasks don't need a capability-matched model chosen from a curve built
+for harder work — an explicit model override (user-requested, or from custom
+instructions) still takes priority over this and applies before it.
+
## Current bb extension boundary
bb 0.39 plugins cannot intercept the native root New Thread submit or insert
From 0e310f182359bac2fed7f4a700a62dad77f60c86 Mon Sep 17 00:00:00 2001
From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com>
Date: Mon, 24 Aug 2026 14:39:50 +1000
Subject: [PATCH 05/10] Make the decision-agent classifier model configurable
via a live picker
Replace the free-text "provider/model" input with a searchable
provider/model list, following the same pattern as
bb-plugin-prompt-enhancer's ModelSettingsSection: a listModels RPC backed
by a KV-cached, stale-while-revalidate provider/model catalog, rendered
as a cmdk-based Command picker. Settings storage is unchanged
(decisionAgent stays a "provider/model" string, still readable via
`bb autorouter config --decision-agent`); this only changes how it's set
from the settings UI.
Vendors components/ui/command.tsx adapted from prompt-enhancer's version,
swapped to autorouter's own HugeIcons-based instead of adding
lucide-react as a second icon library.
---
app.tsx | 117 ++++++++++++++++++++++++----
components/ui/command.tsx | 156 ++++++++++++++++++++++++++++++++++++++
package-lock.json | 44 +++++------
package.json | 1 +
server.ts | 92 ++++++++++++++++++++++
5 files changed, 368 insertions(+), 42 deletions(-)
create mode 100644 components/ui/command.tsx
diff --git a/app.tsx b/app.tsx
index a980a62..f4981a1 100644
--- a/app.tsx
+++ b/app.tsx
@@ -8,10 +8,19 @@ 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 { Input } from "@/components/ui/input";
+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,
@@ -126,6 +135,11 @@ function AutoRouterPage() {
);
}
+interface CatalogState {
+ status: "loading" | "ready" | "failed";
+ catalog: ModelCatalog | null;
+}
+
function AutoRouterSettings() {
const rpc = useRpc();
const [settings, setSettings] = useState(null);
@@ -134,6 +148,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,24 +309,71 @@ function AutoRouterSettings() {
-
diff --git a/components/ui/command.tsx b/components/ui/command.tsx
new file mode 100644
index 0000000..af280ab
--- /dev/null
+++ b/components/ui/command.tsx
@@ -0,0 +1,156 @@
+import * as React from "react"
+import { type DialogProps } from "@radix-ui/react-dialog"
+import { Command as CommandPrimitive } from "cmdk"
+
+import { cn } from "../../lib/utils"
+import { Dialog, DialogContent } from "../../components/ui/dialog"
+import { Icon } from "../../components/ui/icon"
+
+// Ported from bb-plugin-prompt-enhancer's components/ui/command.tsx, with
+// lucide-react's swapped for autorouter's own HugeIcons-backed
+// (autorouter never added lucide-react as a dependency, and there's
+// no reason to pull in a second icon library for one glyph).
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Command.displayName = CommandPrimitive.displayName
+
+const CommandDialog = ({ children, ...props }: DialogProps) => {
+ return (
+
+ )
+}
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+ When Decision agent is Automatic, these are tried in order — first
+ one actually available and quota-eligible wins. Not opinionated by
+ default: this is a plain, editable preference list, not a fixed
+ rule. If none of these are available, it falls back to any
+ launchable model as a last resort.
+
{
+ for (const entry of settings.automaticFallbackChain) {
+ const [providerId, model] = entry.split("/", 2);
+ const found = exact(providerId ?? "", model ?? "");
+ if (found) return found;
+ }
+ return undefined;
+ };
const selected =
settings.decisionAgent === AUTOMATIC_DECISION_AGENT
- ? (exact("acp-cursor", "gpt-5.6-sol-medium") ??
- exact("codex", "gpt-5.6-luna") ??
+ ? // The user's ordered provider/model preferences, tried in order;
+ // falling further to any launchable model, then whatever's first,
+ // is a last-resort safety net (not a preference) for when none of
+ // the configured chain is actually available right now.
+ (fromFallbackChain() ??
usable.find((candidate) =>
candidate.model.supportedReasoningEfforts.some(
(effort) => effort.reasoningEffort === "none",
diff --git a/server.ts b/server.ts
index 89a3ac2..09c8f8a 100644
--- a/server.ts
+++ b/server.ts
@@ -121,6 +121,11 @@ function formatSettings(settings: AutorouterSettings, json: boolean): string {
`Enabled: ${settings.enabled ? "yes" : "no"}`,
`Frugality: ${settings.frugality}/100 ($ -> $$$)`,
`Decision agent: ${settings.decisionAgent}`,
+ `Automatic fallback chain: ${
+ settings.automaticFallbackChain.length > 0
+ ? settings.automaticFallbackChain.join(" -> ")
+ : "(none — falls straight to any launchable model)"
+ }`,
`Custom instructions: ${settings.customInstructions || "(none)"}`,
"",
].join("\n");
@@ -229,7 +234,7 @@ export default async function plugin(bb: BbPluginApi) {
name: "config",
summary: "Update Autorouter settings",
usage:
- "bb autorouter config [--enabled true|false] [--frugality 0-100] [--decision-agent automatic|provider/model] [--instructions text] [--json]",
+ "bb autorouter config [--enabled true|false] [--frugality 0-100] [--decision-agent automatic|provider/model] [--automatic-fallback provider/model,provider/model,...] [--instructions text] [--json]",
},
{
name: "route",
@@ -265,6 +270,11 @@ export default async function plugin(bb: BbPluginApi) {
if (flag === "--enabled") patch.enabled = parseBoolean(value);
else if (flag === "--frugality") patch.frugality = Number(value);
else if (flag === "--decision-agent") patch.decisionAgent = value;
+ else if (flag === "--automatic-fallback")
+ patch.automaticFallbackChain = value
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
else if (flag === "--instructions")
patch.customInstructions = value;
else throw new Error(`Unknown config flag: ${flag}`);
diff --git a/settings.ts b/settings.ts
index 9c7ed7e..3f68a51 100644
--- a/settings.ts
+++ b/settings.ts
@@ -2,6 +2,12 @@ import { z } from "zod";
export const AUTOMATIC_DECISION_AGENT = "automatic";
+const providerModelSchema = z
+ .string()
+ .min(1)
+ .max(200)
+ .regex(/^[^/]+\/[^/]+$/u, "Must use provider/model format");
+
export const autorouterSettingsSchema = z
.object({
enabled: z.boolean(),
@@ -14,6 +20,17 @@ export const autorouterSettingsSchema = z
value === AUTOMATIC_DECISION_AGENT || /^[^/]+\/[^/]+$/u.test(value),
"Decision agent must be 'automatic' or use provider/model format",
),
+ /**
+ * Ordered provider/model preference list tried, in order, when
+ * `decisionAgent` is `"automatic"`. Not opinionated by default beyond
+ * matching pre-existing behavior: the stock default reproduces the
+ * chain this plugin used to hardcode (Cursor -> Codex), but it's a
+ * plain user setting now — reorder, add, or clear it freely. If none
+ * of these are available, the classifier falls back to any launchable
+ * model, then the first available candidate; that fallback is a
+ * last-resort safety net, not a preference, so it isn't user-facing.
+ */
+ automaticFallbackChain: z.array(providerModelSchema).max(20),
customInstructions: z.string().max(12_000),
frugality: z.number().int().min(0).max(100),
})
@@ -26,11 +43,20 @@ export type AutorouterSettings = z.infer;
export const defaultAutorouterSettings: AutorouterSettings = {
enabled: true,
decisionAgent: AUTOMATIC_DECISION_AGENT,
+ automaticFallbackChain: ["acp-cursor/gpt-5.6-sol-medium", "codex/gpt-5.6-luna"],
customInstructions: "",
frugality: 50,
};
export function parseStoredSettings(value: unknown): AutorouterSettings {
const parsed = autorouterSettingsSchema.safeParse(value);
- return parsed.success ? parsed.data : defaultAutorouterSettings;
+ if (parsed.success) return parsed.data;
+ // Merge with defaults before giving up, so settings stored before a field
+ // was added (e.g. automaticFallbackChain) don't get silently wiped back
+ // to every default — only the genuinely new/invalid keys fall back.
+ const merged = autorouterSettingsSchema.safeParse({
+ ...defaultAutorouterSettings,
+ ...(typeof value === "object" && value !== null ? value : {}),
+ });
+ return merged.success ? merged.data : defaultAutorouterSettings;
}
From 3fb4c9b6224bbf51673b0aef4dbd263d8608ce62 Mon Sep 17 00:00:00 2001
From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:00:25 +1000
Subject: [PATCH 07/10] Per-difficulty model selection, and merge the Decision
section's pickers
1. Per-difficulty model selection (settings.difficultyBands): replaces the
hardcoded "simple task" shortcut (SIMPLE_TASK_DIFFICULTY_MAX = 25,
SIMPLE_TASK_PROVIDER_PRIORITY = [antigravity, codex, claude-code]) with
a plain, editable list of {maxDifficulty, fallbackChain} bands, checked
low-to-high. Chain entries may be a bare provider (any/default model)
or an exact provider/model pin. Default settings reproduce the old
hardcoded band as ordinary data, not logic baked into router.ts. New
difficultyBandSelection() replaces simpleTaskSelection(); router.test.ts
updated to match (39 tests, was 36) plus new coverage for band ordering,
exact-model pins in a chain, and "no band covers this difficulty".
2. Decision section: merged the "Decision agent" picker and "Automatic
fallback chain" picker into one Command control per the same UX
pattern used for difficulty bands. In Automatic mode the single list
is the fallback-order editor (checking a model toggles its membership
in automaticFallbackChain); switching to a specific model pins the
classifier to it directly, and the fallback-order editor hides since
it's not in play. Same picker component now reused three ways
(Decision agent, and once per difficulty band) via a shared
DifficultyBandsSection component.
Verified: tsc clean, 39/39 tests pass, `bb plugin build` produces real
dist/ output.
---
app.tsx | 454 ++++++++++++++++++++++++++++++++++---------------
router.test.ts | 75 ++++++--
router.ts | 55 +++---
server.ts | 21 ++-
settings.ts | 34 ++++
5 files changed, 473 insertions(+), 166 deletions(-)
diff --git a/app.tsx b/app.tsx
index eafb258..2d68325 100644
--- a/app.tsx
+++ b/app.tsx
@@ -10,7 +10,7 @@ import {
import { toast } from "sonner";
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 {
@@ -149,6 +149,215 @@ interface CatalogState {
catalog: ModelCatalog | null;
}
+type UpdateSettings = (
+ patch: Partial,
+ options?: { debounceMs?: number },
+) => void;
+
+/**
+ * Per-difficulty model selection: an ordered list of difficulty bands
+ * (`maxDifficulty` + its own fallback chain), checked low-to-high. 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,
+ { maxDifficulty: 50, fallbackChain: [] },
+ ],
+ });
+ }
+
+ const sortedForDisplay = [...settings.difficultyBands]
+ .map((band, index) => ({ band, index }))
+ .sort((a, b) => a.band.maxDifficulty - b.band.maxDifficulty);
+
+ return (
+
+ Per-difficulty model selection
+
+ Route tasks under a difficulty threshold straight through a fixed
+ fallback chain instead of the normal benchmark-ranked selection.
+ 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.
+
The model that rates each task's difficulty (0-100) before routing.
- Automatic picks the cheapest launchable model on the classifier's
- fixed fallback chain (Cursor gpt-5.6-sol-medium → Codex
- gpt-5.6-luna → any model supporting `none` reasoning effort). Pin a
- specific model instead if you want the classifier itself to run on
- a known, fixed model — e.g. Antigravity's free-tier Gemini instead
- of a paid-provider fallback.
+ Automatic tries
+ your fallback order below, first one actually available wins. Pick a
+ specific model instead to pin the classifier to it, skipping the
+ fallback order entirely.
@@ -344,7 +551,7 @@ function AutoRouterSettings() {
update({ decisionAgent: "automatic" })}
>
- Automatic
+ Automatic (use fallback order below)default
@@ -360,15 +567,34 @@ function AutoRouterSettings() {
{provider.models.map((model) => {
const value = `${provider.id}/${model.model}`;
- const isSelected = settings.decisionAgent === value;
+ const isAutomatic = settings.decisionAgent === "automatic";
+ const isChecked = isAutomatic
+ ? settings.automaticFallbackChain.includes(value)
+ : settings.decisionAgent === value;
return (
update({ decisionAgent: value })}
+ onSelect={() => {
+ if (isAutomatic) {
+ // In Automatic mode, this single control doubles
+ // as the fallback-order editor: selecting a
+ // model toggles its membership in the ordered
+ // chain instead of pinning the classifier to it.
+ update({
+ automaticFallbackChain: isChecked
+ ? settings.automaticFallbackChain.filter(
+ (entry) => entry !== value,
+ )
+ : [...settings.automaticFallbackChain, value],
+ });
+ } else {
+ update({ decisionAgent: value });
+ }
+ }}
>
-
+ {model.displayName}
{model.isDefault ? (
default
@@ -381,133 +607,91 @@ function AutoRouterSettings() {
-
- Current: {settings.decisionAgent}
-
-
-
-
-
- Automatic fallback chain
-
-
- When Decision agent is Automatic, these are tried in order — first
- one actually available and quota-eligible wins. Not opinionated by
- default: this is a plain, editable preference list, not a fixed
- rule. If none of these are available, it falls back to any
- launchable model as a last resort.
-
- Empty — automatic mode goes straight to the last-resort fallback.
+ {settings.decisionAgent === "automatic" ? (
+
+
+ Fallback order — tried top to bottom, first available and
+ quota-eligible wins. Check items above to add or remove them;
+ reorder with the arrows. Not opinionated by default: this is a
+ plain, editable list, not a fixed rule. Empty, or if none of
+ these are available, falls back to any launchable model as a
+ last resort.