Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion benchmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -379,7 +398,7 @@ export function rankAutoModelOptions(args: {
frugality: number;
quotaRemainingByProvider: ReadonlyMap<string, number>;
}): 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);
Expand Down
57 changes: 57 additions & 0 deletions router.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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"]),
Expand Down
7 changes: 5 additions & 2 deletions router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BbPluginApi, NewThreadRequest } from "@get-bb/plugin-sdk";
import { z } from "zod";
import {
isRoutableProvider,
rankAutoModelOptions,
type AutoModelCandidate,
type AutoModelRankedOption,
Expand All @@ -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),
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -491,7 +494,7 @@ function safePermissionMode(
return supported[0] ?? "accept-edits";
}

function fallbackSelection(
export function fallbackSelection(
candidates: AutoModelCandidate[],
request: NewThreadRequest,
difficulty: number,
Expand Down