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 ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/package-lock.json b/package-lock.json index 2fe696e..6adc3e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-slot": "^1.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" }, @@ -358,7 +359,6 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", - "dev": true, "license": "MIT" }, "node_modules/@radix-ui/react-compose-refs": { @@ -380,7 +380,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -396,7 +395,6 @@ "version": "1.1.23", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", @@ -434,7 +432,6 @@ "version": "1.1.19", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", @@ -462,7 +459,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -478,7 +474,6 @@ "version": "1.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", @@ -504,7 +499,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -523,7 +517,6 @@ "version": "1.1.17", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.10", @@ -548,7 +541,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -572,7 +564,6 @@ "version": "2.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.3" @@ -614,7 +605,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -630,7 +620,6 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", @@ -651,7 +640,6 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -670,7 +658,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1085,7 +1072,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1243,7 +1230,6 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -1404,6 +1390,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1561,7 +1563,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -1683,7 +1684,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2421,7 +2421,6 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -2442,7 +2441,6 @@ "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "dev": true, "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -2468,7 +2466,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "dev": true, "license": "MIT", "dependencies": { "react-style-singleton": "^2.2.2", @@ -2491,7 +2488,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "dev": true, "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", @@ -2607,7 +2603,6 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -2879,7 +2874,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -2920,7 +2914,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -2942,7 +2935,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "dev": true, "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", diff --git a/package.json b/package.json index d317dc0..7e06493 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-slot": "^1.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" }, diff --git a/server.ts b/server.ts index 8b3022f..89a3ac2 100644 --- a/server.ts +++ b/server.ts @@ -14,6 +14,9 @@ import { } from "./settings.js"; const SETTINGS_KEY = "settings"; +const MODEL_CATALOG_CACHE_KEY = "model-catalog-cache"; +/** Stale-while-revalidate window, matching prompt-enhancer's picker cache. */ +const MODEL_CATALOG_CACHE_MAX_AGE_MS = 5 * 60_000; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -48,6 +51,23 @@ const reasoningLevelSchema = z.enum([ "ultra", ]); +const modelCatalogSchema = z.object({ + providers: z.array( + z.object({ + id: z.string(), + displayName: z.string(), + models: z.array( + z.object({ + model: z.string(), + displayName: z.string(), + isDefault: z.boolean(), + }), + ), + }), + ), +}); +export type ModelCatalog = z.infer; + const routeResultSchema = z .object({ benchmarkScore: z.number().nullable(), @@ -77,6 +97,16 @@ export const rpcContract = defineRpcContract({ input: z.object({ request: newThreadRequestSchema }).strict(), output: routeResultSchema, }, + /** + * Live provider/model catalog for the decision-agent picker, following + * the same pattern as bb-plugin-prompt-enhancer's `listModels`: fetch + * available providers + their models, cache to KV so a settings-page + * reopen answers instantly, refresh in the background afterward. + */ + listModels: { + input: z.null(), + output: modelCatalogSchema, + }, }); function parseBoolean(value: string): boolean { @@ -117,11 +147,73 @@ export default async function plugin(bb: BbPluginApi) { await bb.storage.kv.set(SETTINGS_KEY, defaultAutorouterSettings); } + // Model catalog for the decision-agent picker. Stale-while-revalidate: a + // cached catalog (persisted to KV, so a plugin reload still answers + // instantly) is served immediately and refreshed in the background; + // concurrent callers share one in-flight fetch. + let catalogCache: { at: number; catalog: ModelCatalog } | null = null; + let catalogInflight: Promise | null = null; + + function refreshModelCatalog(): Promise { + catalogInflight ??= (async () => { + const available = (await bb.sdk.providers.list({})).filter( + (provider) => provider.available, + ); + const settled = await Promise.allSettled( + available.map(async (provider): Promise => { + const result = await bb.sdk.providers.models({ providerId: provider.id }); + return { + id: provider.id, + displayName: provider.displayName, + models: result.models.map((model) => ({ + model: model.model, + displayName: model.displayName, + isDefault: model.isDefault, + })), + }; + }), + ); + const catalog: ModelCatalog = { + providers: settled + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ) + .map((result) => result.value) + .filter((provider) => provider.models.length > 0), + }; + catalogCache = { at: Date.now(), catalog }; + if (catalog.providers.length > 0) { + void bb.storage.kv.set(MODEL_CATALOG_CACHE_KEY, catalogCache).catch(() => {}); + } + return catalog; + })().finally(() => { + catalogInflight = null; + }); + return catalogInflight; + } + + async function listModels(): Promise { + if (catalogCache === null) { + const persisted = await bb.storage.kv.get(MODEL_CATALOG_CACHE_KEY); + const parsed = z + .object({ at: z.number(), catalog: modelCatalogSchema }) + .safeParse(persisted); + if (parsed.success) catalogCache = parsed.data; + } + if (catalogCache === null) return refreshModelCatalog(); + if (Date.now() - catalogCache.at > MODEL_CATALOG_CACHE_MAX_AGE_MS) { + void refreshModelCatalog(); + } + return catalogCache.catalog; + } + bb.rpc.register(rpcContract, { getSettings: readSettings, updateSettings, createThread: async ({ request }) => createRoutedThread(bb, request, await readSettings()), + listModels, }); bb.cli.register({ From 9f80a8c2122da42a5941abf848f4993bf2957fae Mon Sep 17 00:00:00 2001 From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:47:41 +1000 Subject: [PATCH 06/10] Make the automatic classifier fallback chain a plain user setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classifier's "automatic" mode used to hardcode a fixed provider priority (Cursor gpt-5.6-sol-medium -> Codex gpt-5.6-luna) directly in router.ts. Move it to a new automaticFallbackChain setting (ordered provider/model list, editable via the settings-page picker or `bb autorouter config --automatic-fallback`), so it's a preference users can reorder, extend, or clear rather than an opinion baked into the code. Default value reproduces the previous hardcoded order, so existing "automatic" behavior is unchanged unless a user edits it — verified via the existing router.test.ts suite (all 36 tests still pass unmodified, since they exercise resolveRoute through defaultAutorouterSettings). parseStoredSettings now merges stored settings with defaults before giving up, instead of discarding the whole object on schema mismatch — otherwise settings saved before this field existed (including this session's own decisionAgent override) would have silently reset to every default on first load after the update. --- app.tsx | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++ router.ts | 15 +++++- server.ts | 12 ++++- settings.ts | 28 ++++++++++- 4 files changed, 182 insertions(+), 4 deletions(-) diff --git a/app.tsx b/app.tsx index f4981a1..eafb258 100644 --- a/app.tsx +++ b/app.tsx @@ -12,6 +12,7 @@ import type { ModelCatalog, rpcContract } from "./server"; import type { RoutedThreadResult } from "./router"; import type { AutorouterSettings } from "./settings"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, @@ -29,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"; @@ -377,6 +386,128 @@ function AutoRouterSettings() {

+
+ +

+ 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. +

+
+ {settings.automaticFallbackChain.length === 0 ? ( +

+ Empty — automatic mode goes straight to the last-resort fallback. +

+ ) : ( + settings.automaticFallbackChain.map((entry, index) => ( +
+ {index + 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 = settings.automaticFallbackChain.includes(value); + return ( + + update({ + automaticFallbackChain: [ + ...settings.automaticFallbackChain, + value, + ], + }) + } + > + + {model.displayName} + {alreadyAdded ? ( + added + ) : null} + + ); + })} + + ))} + + +
+
+

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} -

-
- -
- -

- 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. -

-
- {settings.automaticFallbackChain.length === 0 ? ( -

- 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.

- ) : ( - settings.automaticFallbackChain.map((entry, index) => ( -
- {index + 1}. - {entry} - - - + {settings.automaticFallbackChain.length === 0 ? ( +

+ Empty — automatic mode goes straight to the last-resort fallback. +

+ ) : ( +
+ {settings.automaticFallbackChain.map((entry, index) => ( +
+ {index + 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 = settings.automaticFallbackChain.includes(value); - return ( - - update({ - automaticFallbackChain: [ - ...settings.automaticFallbackChain, - value, - ], - }) - } - > - - {model.displayName} - {alreadyAdded ? ( - added - ) : null} - - ); - })} - - ))} - - -
+ )} +
+ ) : ( +

+ Pinned: {settings.decisionAgent} +

+ )}
+ +