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/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 675b23f..1f7ffaa 100644 --- a/router.test.ts +++ b/router.test.ts @@ -1,12 +1,16 @@ 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, matchModelOverride, parseDifficultyDecision, quotaRemainingByProvider, + isRoutableProvider, } from "./router.js"; function candidate( @@ -57,6 +61,59 @@ 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); + }); + + // 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", () => { const candidates = [ candidate("codex", "gpt-5.6-sol", ["medium", "high"]), diff --git a/router.ts b/router.ts index 9c304c4..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,6 +16,8 @@ const MAX_TASK_TEXT_LENGTH = 20_000; const DEFAULT_DIFFICULTY = 50; const CLASSIFIER_TIMEOUT_MS = 60_000; +export { isRoutableProvider }; + const difficultyDecisionSchema = z .object({ difficulty: z.number().int().min(0).max(100), @@ -278,7 +281,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) => ({ @@ -491,7 +494,7 @@ function safePermissionMode( return supported[0] ?? "accept-edits"; } -function fallbackSelection( +export function fallbackSelection( candidates: AutoModelCandidate[], request: NewThreadRequest, difficulty: number,