diff --git a/README.md b/README.md index ff096bf..f4d2dc0 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ An [opencode plugin](https://opencode.ai/docs/plugins/) that wires [BitRouter](https://github.com/bitrouter/bitrouter) in as a provider. It -declares the provider for you, discovers the available models from your -BitRouter instance instead of shipping a hard-coded list, and adds a BitRouter -Cloud device login to `/connect`. +declares the provider for you, makes `bitrouter/auto` your default model, +discovers the available models from your BitRouter instance instead of shipping +a hard-coded list, and adds a BitRouter Cloud device login to `/connect`. BitRouter can run two ways: @@ -28,7 +28,11 @@ caches it automatically: ``` That is the whole configuration. The plugin contributes the `provider.bitrouter` -block itself, so you do not need to write one. +block itself, and sets `model` and `small_model` to `bitrouter/auto`, so you do +not need to write either one. + +A `model` you set yourself always wins — the plugin only fills in what your +config leaves out. Then authenticate: @@ -52,14 +56,38 @@ stage of the provider's life: | Hook | What it does | |---|---| -| `config` | Declares the `bitrouter` provider (`@ai-sdk/openai-compatible`, the resolved base URL) seeded with whatever catalog is reachable at load time. A `provider.bitrouter` block you wrote yourself always wins — the hook only fills in what you left out. | +| `config` | Declares the `bitrouter` provider (`@ai-sdk/openai-compatible`, the resolved base URL) seeded with whatever catalog is reachable at load time, and names `bitrouter/auto` as `model` and `small_model`. A `provider.bitrouter` block — or a `model` — you wrote yourself always wins; the hook only fills in what you left out. | | `auth` | Offers the device login and the API-key method, and turns whichever credential is stored into provider options. An expired OAuth grant is refreshed per request and written back through `client.auth.set`. | -| `provider` | Re-discovers the live catalog via `GET ${baseUrl}/models` once a credential exists, so the model list reflects your account rather than the seed. If discovery fails it keeps the current list rather than blanking it. | +| `provider` | Re-discovers the live catalog via `GET ${baseUrl}/models` once a credential exists, so the model list reflects your account rather than the seed. The auto route leads the refreshed list too. If discovery fails it keeps the current list rather than blanking it. | + +## The auto route + +`bitrouter/auto` hands model choice back to BitRouter: the request carries +`bitrouter/auto` as its model and the gateway's routing policy picks the model +per request. `bitrouter/` is a namespace BitRouter reserves for itself, so the +vendor segment names the router being addressed rather than the token +destination. It leads every catalog the plugin produces, and it is the default +`model` and `small_model`. + +The rest of the catalog is still there. `bitrouter/auto` is the default, not +the only option — pin `bitrouter/anthropic/claude-opus-5` (or anything else +BitRouter serves) with `/models` or in `opencode.json` whenever you want one +specific model, and switch back whenever you do not. Before you authenticate on cloud there is no token to discover with, so the -provider is seeded with a single placeholder model. That is deliberate: -without at least one model the provider would not be selectable and you could -not reach `/connect` at all. It is replaced by the real catalog on first use. +provider is seeded with the auto route alone. That is deliberate: without at +least one model the provider would not be selectable and you could not reach +`/connect` at all. The rest of the catalog fills in on first use. + +Until BitRouter's own catalog lists `bitrouter/auto`, the plugin synthesizes the entry +with deliberately conservative capacities (128K context, 16K output). They are +the floor rather than the ceiling on purpose — `auto` may land on any model in +the ladder, and under-claiming compacts a session early where over-claiming +fails a request outright, mid-turn. A gateway that ever serves an entry under +this id supersedes the placeholder, though none does today: the namespace is +resolved before any provider lookup and BitRouter's registry validator +refuses catalog models under `bitrouter/`, so the entry has to come from +here. ## Configuration @@ -93,11 +121,11 @@ device login, and you maintain the model list yourself. ## Troubleshooting -**The model list only shows `kimi-k2.5`** +**The model list only shows `bitrouter/auto`** -That is the placeholder — the catalog has not been fetched yet. Run -`opencode auth login` and connect BitRouter; the real list appears on the next -request. +The catalog has not been fetched yet. Run `opencode auth login` and connect +BitRouter; the real list appears on the next request. `bitrouter/auto` itself +still works meanwhile — routing is the gateway's job, not the plugin's. **`model refresh failed at .../models: HTTP 401`** diff --git a/examples/opencode.json b/examples/opencode.json index 51b06f4..4c8d4ff 100644 --- a/examples/opencode.json +++ b/examples/opencode.json @@ -1,6 +1,8 @@ { "$schema": "https://opencode.ai/config.json", - "$comment": "Manual alternative to installing @bitrouter/opencode. Declares the provider by hand — no dynamic model discovery, no device login. Swap baseURL to http://127.0.0.1:4356/v1 for a local BitRouter daemon.", + "$comment": "Manual alternative to installing @bitrouter/opencode. Declares the provider by hand \u2014 no dynamic model discovery, no device login, and the model list is yours to maintain. Swap baseURL to http://127.0.0.1:4356/v1 for a local BitRouter daemon.", + "model": "bitrouter/bitrouter/auto", + "small_model": "bitrouter/bitrouter/auto", "provider": { "bitrouter": { "npm": "@ai-sdk/openai-compatible", @@ -10,8 +12,12 @@ "apiKey": "{env:BITROUTER_API_KEY}" }, "models": { - "kimi-k2.5": { - "name": "Kimi K2.5 via BitRouter" + "bitrouter/auto": { + "name": "BitRouter Auto", + "limit": { + "context": 128000, + "output": 16384 + } } } } diff --git a/src/constants.ts b/src/constants.ts index df8b2fe..a38eca3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,12 +17,31 @@ export const bitrouter = { local: { apiBaseUrl: "http://127.0.0.1:4356/v1", }, - /** - * Shown as the sole placeholder model when the catalog cannot be fetched yet - * (typically: cloud, before `/connect bitrouter`). Keeps the provider - * selectable so the user can reach the login flow at all. - */ - defaultModel: "kimi-k2.5", } as const; -export type BitrouterConstants = typeof bitrouter; +/** The provider id. Must match the key used in `opencode.json` and `/connect`. */ +export const PROVIDER_ID = "bitrouter"; + +/** + * The model id that hands model choice back to BitRouter. + * + * `bitrouter/` is a namespace BitRouter reserves for itself + * (`RESERVED_NAMESPACE` in `crates/bitrouter-sdk/src/config/presets.rs`), and + * `bitrouter/auto` is the public slug for policy-driven automatic routing + * (`AUTO_SLUG`). The vendor segment names the *router being addressed*, not the + * token destination: the request is still fulfilled by whichever upstream + * provider the bound policy selects. + * + * This is the id as it travels on the wire, so it is the id this plugin + * advertises. The gateway never lists it in `GET /v1/models` — the namespace is + * resolved before any provider lookup, and BitRouter's registry validator + * refuses catalog models under `bitrouter/` so it can never be shadowed — which + * is why this plugin has to supply the entry itself. + * + * It resolves only where a preset named `auto` is bound to a routing policy; + * without one the gateway answers 400 naming `bitrouter optimize setup`. + */ +export const AUTO_MODEL_ID = "bitrouter/auto"; + +/** The `provider/model` reference a harness surface shows for the auto route. */ +export const AUTO_MODEL_REF = `${PROVIDER_ID}/${AUTO_MODEL_ID}`; diff --git a/src/discovery.ts b/src/discovery.ts index 7fb320b..40fac9e 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -1,30 +1,94 @@ /** - * One entry from BitRouter's `GET /v1/models` response. BitRouter enriches the - * plain OpenAI shape with routing/pricing metadata; everything past `id` is - * optional because a bare OpenAI-compatible upstream will not send it. + * BitRouter's `GET /v1/models` catalog, and the normalization that makes its + * two data planes look alike. + * + * The two planes answer with genuinely different bodies, and neither is the + * plain OpenAI shape: + * + * - **Local daemon** (`crates/bitrouter-sdk/src/server.rs`) lists ids only — + * `{ id, object, providers: string[] }`. Every capability field is absent, + * so a local route is described entirely by this package's defaults. + * - **Cloud** (`bitrouter-cloud/src/v1/http/models.rs`) lists a rich catalog: + * `max_input_tokens`, `max_output_tokens`, `input_modalities`, + * `output_modalities`, `pricing`, `capabilities`, and `providers` as an + * object (`{ total_online }`) rather than a list. + * + * Note what cloud does *not* send: there is no `context_window`, no `cost`, + * no `reasoning` boolean, and no `tool_call` boolean. Reading those names — + * as this package used to — leaves every model at its default context window + * and priced at zero. The capability booleans are carried by `capabilities` + * token strings instead, and the window by `max_input_tokens`. + */ + +/** Per-million-token rates, as `bitrouter-cloud/src/service/billing.rs` emits them. */ +export interface DiscoveredPricing { + input_tokens?: { + /** Cost per million non-cached input tokens. */ + no_cache?: number; + /** Cost per million cache-read input tokens. */ + cache_read?: number; + /** Cost per million cache-write input tokens. */ + cache_write?: number; + }; + output_tokens?: { + /** Cost per million text output tokens. */ + text?: number; + reasoning?: number; + image?: number; + audio?: number; + }; +} + +/** + * One entry as it arrives on the wire, union of both planes. Everything past + * `id` is optional: the local daemon sends none of it, and cloud omits any + * field no provider of that model declares. */ export interface DiscoveredModel { id: string; object?: string; - providers?: string[]; name?: string; - reasoning?: boolean; - tool_call?: boolean; + description?: string; + /** Context window. Cloud's name for it; there is no `context_window` field. */ + max_input_tokens?: number; + max_output_tokens?: number; input_modalities?: string[]; output_modalities?: string[]; - context_window?: number; - max_output_tokens?: number; - cost?: { - input?: number; - output?: number; - cache_read?: number; - cache_write?: number; - }; + /** Per-million rates; there is no flat `cost` field. */ + pricing?: DiscoveredPricing; + /** + * Capability tokens, from `Capability` in + * `crates/bitrouter-sdk/src/language_model/types.rs`: `reasoning`, `tools`, + * `structured_outputs`, `image_input`, `file_input`, `web_search`, and so on. + */ + capabilities?: string[]; + /** `string[]` from the local daemon; `{ total_online }` from cloud. */ + providers?: string[] | { total_online?: number }; +} + +/** A capability token BitRouter advertises for a model. */ +export function hasCapability(m: DiscoveredModel, token: string): boolean { + return Array.isArray(m.capabilities) && m.capabilities.includes(token); +} + +/** + * How many providers can serve this model, when the plane says. Cloud answers + * with a count; the local daemon answers with the provider names. + */ +export function providerCount(m: DiscoveredModel): number | undefined { + if (Array.isArray(m.providers)) return m.providers.length; + if (m.providers && typeof m.providers.total_online === "number") { + return m.providers.total_online; + } + return undefined; } /** * Fetch BitRouter's model catalog. Throws on a non-OK response so the caller * can decide between "fall back to a placeholder" and "surface the error". + * + * Entries without a usable string id are dropped rather than failing the whole + * listing — one malformed row should not cost the provider its whole catalog. */ export async function discoverModels( baseUrl: string, @@ -35,6 +99,13 @@ export async function discoverModels( if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetchImpl(`${baseUrl}/models`, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const payload = (await res.json()) as { data?: DiscoveredModel[] }; - return payload.data ?? []; + const payload = (await res.json()) as { data?: unknown }; + if (!Array.isArray(payload.data)) return []; + return payload.data.filter( + (m): m is DiscoveredModel => + typeof m === "object" && + m !== null && + typeof (m as DiscoveredModel).id === "string" && + (m as DiscoveredModel).id.length > 0, + ); } diff --git a/src/index.ts b/src/index.ts index a424d60..b043d13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,13 @@ import type { AuthHook, Hooks, Plugin } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk/v2"; -import { bitrouter } from "./constants.js"; +import { AUTO_MODEL_REF, PROVIDER_ID } from "./constants.js"; import { discoverModels, type DiscoveredModel } from "./discovery.js"; -import { OPENAI_COMPATIBLE_NPM, toConfigModel, toRuntimeModel } from "./models.js"; +import { + OPENAI_COMPATIBLE_NPM, + toConfigModel, + toRuntimeModel, + withAutoModel, +} from "./models.js"; import { EXPIRY_SKEW_MS, pollForToken, @@ -13,13 +18,17 @@ import { } from "./oauth.js"; import { resolveSmartTarget } from "./target.js"; -/** The provider id. Must match the key used in `opencode.json` and `/connect`. */ -export const PROVIDER_ID = "bitrouter"; - -/** A placeholder catalog so the provider stays selectable before authentication. */ -function placeholderModels(): DiscoveredModel[] { - return [{ id: bitrouter.defaultModel, name: `${bitrouter.defaultModel} (BitRouter)` }]; -} +export { AUTO_MODEL_ID, AUTO_MODEL_REF, PROVIDER_ID, bitrouter } from "./constants.js"; +export { discoverModels, hasCapability, providerCount } from "./discovery.js"; +export type { DiscoveredModel, DiscoveredPricing } from "./discovery.js"; +export { + OPENAI_COMPATIBLE_NPM, + autoModel, + toConfigModel, + toCost, + toRuntimeModel, + withAutoModel, +} from "./models.js"; /** Pull a usable bearer token out of whatever opencode has stored for us. */ function tokenFrom(auth: Auth | undefined): string | undefined { @@ -62,19 +71,20 @@ export const BitRouterPlugin: Plugin = async ({ client }): Promise => { }; // Seed catalog: best effort at load time. Cloud before `/connect` has no - // token, so this usually falls back to the placeholder and the `provider` - // hook fills in the real list later. - let seed: DiscoveredModel[]; + // token, so this often discovers nothing and the `provider` hook fills in + // the real list later. `withAutoModel` still puts the auto route at the head + // either way, which is what keeps the provider selectable — and therefore + // `/connect` reachable — before any credential exists. + let discovered: DiscoveredModel[] = []; try { - seed = await discoverModels(target.baseUrl, configuredKey); - if (seed.length === 0) { - log("info", `no models at ${target.baseUrl}/models yet; using a placeholder`); - seed = placeholderModels(); + discovered = await discoverModels(target.baseUrl, configuredKey); + if (discovered.length === 0) { + log("info", `no models at ${target.baseUrl}/models yet; offering ${AUTO_MODEL_REF} alone`); } } catch (err) { - log("info", `model discovery deferred (${String(err)}); using a placeholder`); - seed = placeholderModels(); + log("info", `model discovery deferred (${String(err)}); offering ${AUTO_MODEL_REF} alone`); } + const seed = withAutoModel(discovered); // Serialize refreshes so concurrent requests don't each burn the refresh token. let refreshing: Promise | undefined; @@ -137,6 +147,20 @@ export const BitRouterPlugin: Plugin = async ({ client }): Promise => { }, models: { ...seeded, ...(existing?.models ?? {}) }, }; + + // Make BitRouter the default the moment the plugin is installed, so a + // fresh `opencode.json` carrying nothing but `"plugin": ["@bitrouter/opencode"]` + // lands on the auto route with no second configuration step. + // + // `??=` is the whole of the courtesy: a `model` the user wrote in their + // own config, or another plugin set first, is already on `config` by the + // time this hook runs and is left exactly as it stands. Title generation + // and the other small-model errands go the same way — routing them + // through `auto` is what the auto route is for, and BitRouter's own + // policy ladder is a better judge of "cheap enough for this" than a + // hardcoded second model id would be. + config.model ??= AUTO_MODEL_REF; + config.small_model ??= AUTO_MODEL_REF; }, auth: { @@ -186,8 +210,11 @@ export const BitRouterPlugin: Plugin = async ({ client }): Promise => { log("warn", "BitRouter returned an empty model catalog"); return provider.models ?? {}; } + // The auto route leads the refreshed catalog too — a gateway that does + // not list it yet must not have it disappear from under a session that + // is already using it. return Object.fromEntries( - discovered.map((m) => [m.id, toRuntimeModel(m, provider)]), + withAutoModel(discovered).map((m) => [m.id, toRuntimeModel(m, provider)]), ); }, }, diff --git a/src/models.ts b/src/models.ts index 1a8876d..c33421a 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,16 +1,44 @@ import type { Model as ModelV2, Provider as ProviderV2 } from "@opencode-ai/sdk/v2"; import type { ProviderConfig } from "@opencode-ai/sdk"; -import type { DiscoveredModel } from "./discovery.js"; +import { AUTO_MODEL_ID } from "./constants.js"; +import { + hasCapability, + type DiscoveredModel, + type DiscoveredPricing, +} from "./discovery.js"; /** The AI SDK package opencode drives an OpenAI-compatible upstream with. */ export const OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible"; +/** + * Capabilities assumed for the synthesized `auto` entry, used only while + * BitRouter's own catalog does not list it. They are deliberately the floor + * rather than the ceiling of what the route can reach: `auto` may land on any + * model in the tier ladder, and the two wrong answers do not cost the same. + * Under-claiming compacts a session earlier than it needed to; over-claiming + * sends a request the chosen model rejects outright, mid-turn. A catalog that + * lists `auto` replaces every one of these with the served value. + */ +const AUTO_CONTEXT = 128_000; +const AUTO_MAX_TOKENS = 16_384; + const DEFAULT_CONTEXT = 128_000; const DEFAULT_MAX_TOKENS = 4096; type Modality = "text" | "audio" | "image" | "video" | "pdf"; const MODALITIES: readonly Modality[] = ["text", "audio", "image", "video", "pdf"]; +/** + * Capability tokens that imply an input modality, for a plane that advertises + * the capability but leaves `input_modalities` empty. + */ +const MODALITY_CAPABILITIES: ReadonlyArray = [ + ["image_input", "image"], + ["audio_input", "audio"], + ["video_input", "video"], + ["file_input", "pdf"], +]; + function normalizeModalities(raw: string[] | undefined, fallback: Modality[]): Modality[] { const kept = (raw ?? []).filter((m): m is Modality => (MODALITIES as readonly string[]).includes(m), @@ -18,6 +46,20 @@ function normalizeModalities(raw: string[] | undefined, fallback: Modality[]): M return kept.length > 0 ? kept : fallback; } +/** Input modalities, taking the declared list and the capability tokens together. */ +function inputModalities(m: DiscoveredModel): Modality[] { + const declared = normalizeModalities(m.input_modalities, ["text"]); + const merged = new Set(declared); + for (const [token, modality] of MODALITY_CAPABILITIES) { + if (hasCapability(m, token)) merged.add(modality); + } + return MODALITIES.filter((x) => merged.has(x)); +} + +function outputModalities(m: DiscoveredModel): Modality[] { + return normalizeModalities(m.output_modalities, ["text"]); +} + function modalityFlags(list: Modality[]): Record { return { text: list.includes("text"), @@ -28,6 +70,44 @@ function modalityFlags(list: Modality[]): Record { }; } +/** + * Flatten BitRouter's nested per-million rates into the flat per-million shape + * opencode carries. Both sides are already per-million, so this is a reshape + * and not a conversion. An undeclared rate reads as 0 — "not priced here" — + * which is what opencode shows for a model whose cost it does not know. + */ +export function toCost(pricing: DiscoveredPricing | undefined): { + input: number; + output: number; + cache_read: number; + cache_write: number; +} { + const input = pricing?.input_tokens ?? {}; + const output = pricing?.output_tokens ?? {}; + return { + input: input.no_cache ?? 0, + output: output.text ?? 0, + cache_read: input.cache_read ?? 0, + cache_write: input.cache_write ?? 0, + }; +} + +/** Whether the model advertises extended reasoning. */ +function isReasoning(m: DiscoveredModel): boolean { + return hasCapability(m, "reasoning"); +} + +/** + * Whether the model can call tools. Absent capability tokens mean the plane + * did not say — the local daemon never does — and a coding agent is unusable + * against a model it believes cannot call tools, so silence reads as yes. + * Cloud, which does advertise the token, is taken at its word. + */ +function isToolCall(m: DiscoveredModel): boolean { + if (!Array.isArray(m.capabilities) || m.capabilities.length === 0) return true; + return m.capabilities.includes("tools"); +} + /** * Map a `/v1/models` entry to the model shape opencode accepts inside a * `provider..models` config block. Used by the `config` hook, which seeds @@ -36,24 +116,18 @@ function modalityFlags(list: Modality[]): Record { export function toConfigModel( m: DiscoveredModel, ): NonNullable[string] { - const c = m.cost ?? {}; - const input = normalizeModalities(m.input_modalities, ["text"]); - const output = normalizeModalities(m.output_modalities, ["text"]); + const input = inputModalities(m); + const output = outputModalities(m); return { id: m.id, name: m.name ?? m.id, - reasoning: m.reasoning ?? false, - tool_call: m.tool_call ?? true, + reasoning: isReasoning(m), + tool_call: isToolCall(m), attachment: input.includes("image") || input.includes("pdf"), temperature: true, - cost: { - input: c.input ?? 0, - output: c.output ?? 0, - cache_read: c.cache_read ?? 0, - cache_write: c.cache_write ?? 0, - }, + cost: toCost(m.pricing), limit: { - context: m.context_window ?? DEFAULT_CONTEXT, + context: m.max_input_tokens ?? DEFAULT_CONTEXT, output: m.max_output_tokens ?? DEFAULT_MAX_TOKENS, }, modalities: { input, output }, @@ -73,11 +147,11 @@ export function toRuntimeModel( m: DiscoveredModel, provider: Pick, ): ModelV2 { - const c = m.cost ?? {}; - const input = normalizeModalities(m.input_modalities, ["text"]); - const output = normalizeModalities(m.output_modalities, ["text"]); + const input = inputModalities(m); + const output = outputModalities(m); const inherited = Object.values(provider.models ?? {})[0]?.api; const baseURL = provider.options?.baseURL; + const cost = toCost(m.pricing); return { id: m.id, providerID: provider.id, @@ -89,20 +163,20 @@ export function toRuntimeModel( name: m.name ?? m.id, capabilities: { temperature: true, - reasoning: m.reasoning ?? false, + reasoning: isReasoning(m), attachment: input.includes("image") || input.includes("pdf"), - toolcall: m.tool_call ?? true, + toolcall: isToolCall(m), input: modalityFlags(input), output: modalityFlags(output), interleaved: false, }, cost: { - input: c.input ?? 0, - output: c.output ?? 0, - cache: { read: c.cache_read ?? 0, write: c.cache_write ?? 0 }, + input: cost.input, + output: cost.output, + cache: { read: cost.cache_read, write: cost.cache_write }, }, limit: { - context: m.context_window ?? DEFAULT_CONTEXT, + context: m.max_input_tokens ?? DEFAULT_CONTEXT, output: m.max_output_tokens ?? DEFAULT_MAX_TOKENS, }, status: "active", @@ -111,3 +185,38 @@ export function toRuntimeModel( release_date: "", }; } + +/** + * The synthesized `auto` entry, used only while BitRouter's catalog does not + * list one itself. Tool calling is on because the route exists to serve a + * coding agent; the capacities are the conservative floor documented above. + */ +export function autoModel(): DiscoveredModel { + return { + id: AUTO_MODEL_ID, + name: "BitRouter Auto", + description: "Let BitRouter choose the model for each request.", + max_input_tokens: AUTO_CONTEXT, + max_output_tokens: AUTO_MAX_TOKENS, + input_modalities: ["text", "image"], + output_modalities: ["text"], + capabilities: ["tools", "reasoning"], + }; +} + +/** + * Put the auto route at the head of the catalog, synthesizing it when the + * gateway does not serve one yet. + * + * A served entry still wins if one ever appears, though none does today: + * `bitrouter/` is resolved before any provider lookup, and BitRouter's registry + * validator refuses catalog models under it, so the entry has to come from + * here. The check costs nothing and keeps the placeholder from shadowing a + * future one. Order matters because the head + * of this list is what a surface offers first. + */ +export function withAutoModel(discovered: DiscoveredModel[]): DiscoveredModel[] { + const served = discovered.find((m) => m.id === AUTO_MODEL_ID); + const rest = discovered.filter((m) => m.id !== AUTO_MODEL_ID); + return [served ?? autoModel(), ...rest]; +} diff --git a/test/fixtures/cloud-models.json b/test/fixtures/cloud-models.json new file mode 100644 index 0000000..559b4b6 --- /dev/null +++ b/test/fixtures/cloud-models.json @@ -0,0 +1,94 @@ +{ + "object": "list", + "data": [ + { + "id": "anthropic/claude-fable-5", + "name": "Anthropic: Claude Fable 5", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ], + "pricing": { + "input_tokens": { + "no_cache": 10.0, + "cache_read": 1.0, + "cache_write": 12.5 + }, + "output_tokens": { + "text": 50.0 + } + }, + "capabilities": [ + "reasoning" + ], + "providers": { + "total_online": 1 + } + }, + { + "id": "anthropic/claude-haiku-4.5", + "name": "Anthropic: Claude Haiku 4.5", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ], + "pricing": { + "input_tokens": { + "no_cache": 1.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "output_tokens": { + "text": 5.0 + } + }, + "capabilities": [ + "tools" + ], + "providers": { + "total_online": 1 + } + }, + { + "id": "anthropic/claude-opus-4.6", + "name": "Anthropic: Claude Opus 4.6", + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ], + "pricing": { + "input_tokens": { + "no_cache": 5.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "output_tokens": { + "text": 25.0 + } + }, + "capabilities": [ + "reasoning", + "structured_outputs", + "tools" + ], + "providers": { + "total_online": 2 + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/local-models.json b/test/fixtures/local-models.json new file mode 100644 index 0000000..89d20ec --- /dev/null +++ b/test/fixtures/local-models.json @@ -0,0 +1,19 @@ +{ + "object": "list", + "data": [ + { + "id": "anthropic/claude-fable-5", + "object": "model", + "providers": [ + "claude-code" + ] + }, + { + "id": "anthropic/claude-haiku-4.5", + "object": "model", + "providers": [ + "claude-code" + ] + } + ] +} \ No newline at end of file diff --git a/test/models.test.ts b/test/models.test.ts index 759cbe4..a1d379c 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -1,16 +1,40 @@ import { describe, it, expect } from "vitest"; -import { toConfigModel, toRuntimeModel, OPENAI_COMPATIBLE_NPM } from "../src/models.js"; +import { + autoModel, + toConfigModel, + toCost, + toRuntimeModel, + withAutoModel, + OPENAI_COMPATIBLE_NPM, +} from "../src/models.js"; +import type { DiscoveredModel } from "../src/discovery.js"; -const RICH = { - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - reasoning: true, - tool_call: true, +/** + * A cloud catalog entry exactly as `GET https://api.bitrouter.ai/v1/models` + * serves one — nested per-million `pricing`, `max_input_tokens` rather than a + * `context_window`, capability tokens rather than booleans, and `providers` as + * a count object. + */ +const CLOUD: DiscoveredModel = { + id: "anthropic/claude-opus-4.6", + name: "Anthropic: Claude Opus 4.6", + max_input_tokens: 200000, + max_output_tokens: 16384, input_modalities: ["text", "image"], output_modalities: ["text"], - context_window: 200000, - max_output_tokens: 64000, - cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + pricing: { + input_tokens: { no_cache: 5, cache_read: 0.5, cache_write: 6.25 }, + output_tokens: { text: 25 }, + }, + capabilities: ["reasoning", "structured_outputs", "tools"], + providers: { total_online: 2 }, +}; + +/** A local-daemon entry: the whole of what `bitrouter start` serves. */ +const LOCAL: DiscoveredModel = { + id: "anthropic/claude-opus-4.6", + object: "model", + providers: ["claude-code"], }; const provider = { @@ -19,33 +43,81 @@ const provider = { options: { baseURL: "https://api.bitrouter.ai/v1" }, }; +describe("toCost", () => { + it("flattens BitRouter's nested per-million rates", () => { + expect(toCost(CLOUD.pricing)).toEqual({ + input: 5, + output: 25, + cache_read: 0.5, + cache_write: 6.25, + }); + }); + + it("reads an undeclared rate as zero rather than dropping the model", () => { + expect(toCost({ output_tokens: { text: 5 } })).toEqual({ + input: 0, + output: 5, + cache_read: 0, + cache_write: 0, + }); + expect(toCost(undefined)).toEqual({ + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }); + }); +}); + describe("toConfigModel", () => { - it("maps an enriched entry", () => { - expect(toConfigModel(RICH)).toEqual({ - id: "claude-opus-4-8", - name: "Claude Opus 4.8", + it("maps a cloud entry off the fields cloud actually sends", () => { + expect(toConfigModel(CLOUD)).toEqual({ + id: "anthropic/claude-opus-4.6", + name: "Anthropic: Claude Opus 4.6", reasoning: true, tool_call: true, attachment: true, temperature: true, - cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - limit: { context: 200000, output: 64000 }, + cost: { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, + limit: { context: 200000, output: 16384 }, modalities: { input: ["text", "image"], output: ["text"] }, status: "active", }); }); - it("falls back to safe defaults when metadata is absent", () => { - const m = toConfigModel({ id: "mystery" }); - expect(m.name).toBe("mystery"); - expect(m.reasoning).toBe(false); - expect(m.tool_call).toBe(true); + it("reads reasoning and tool use from capability tokens, not booleans", () => { + const plain = toConfigModel({ ...CLOUD, capabilities: ["tools"] }); + expect(plain.reasoning).toBe(false); + expect(plain.tool_call).toBe(true); + + const thinker = toConfigModel({ ...CLOUD, capabilities: ["reasoning"] }); + expect(thinker.reasoning).toBe(true); + // Cloud advertised its capabilities and did not include `tools`. + expect(thinker.tool_call).toBe(false); + }); + + it("assumes tool use when the plane advertises no capabilities at all", () => { + // The local daemon never sends capability tokens, and a coding agent is + // unusable against a model it believes cannot call tools. + expect(toConfigModel(LOCAL).tool_call).toBe(true); + expect(toConfigModel(LOCAL).reasoning).toBe(false); + }); + + it("falls back to safe defaults for a local entry that describes nothing", () => { + const m = toConfigModel(LOCAL); + expect(m.name).toBe("anthropic/claude-opus-4.6"); expect(m.attachment).toBe(false); expect(m.limit).toEqual({ context: 128000, output: 4096 }); expect(m.modalities).toEqual({ input: ["text"], output: ["text"] }); expect(m.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 }); }); + it("infers an input modality from a capability token", () => { + const m = toConfigModel({ id: "x", capabilities: ["file_input", "tools"] }); + expect(m.modalities?.input).toEqual(["text", "pdf"]); + expect(m.attachment).toBe(true); + }); + it("drops modalities opencode does not model", () => { const m = toConfigModel({ id: "x", input_modalities: ["text", "hologram"] }); expect(m.modalities).toEqual({ input: ["text"], output: ["text"] }); @@ -54,8 +126,8 @@ describe("toConfigModel", () => { describe("toRuntimeModel", () => { it("produces a fully-materialized opencode Model", () => { - const m = toRuntimeModel(RICH, provider); - expect(m.id).toBe("claude-opus-4-8"); + const m = toRuntimeModel(CLOUD, provider); + expect(m.id).toBe("anthropic/claude-opus-4.6"); expect(m.providerID).toBe("bitrouter"); expect(m.api).toEqual({ id: "bitrouter", @@ -63,6 +135,7 @@ describe("toRuntimeModel", () => { npm: OPENAI_COMPATIBLE_NPM, }); expect(m.capabilities.reasoning).toBe(true); + expect(m.capabilities.toolcall).toBe(true); expect(m.capabilities.input).toEqual({ text: true, audio: false, @@ -71,16 +144,16 @@ describe("toRuntimeModel", () => { pdf: false, }); expect(m.cost).toEqual({ - input: 3, - output: 15, - cache: { read: 0.3, write: 3.75 }, + input: 5, + output: 25, + cache: { read: 0.5, write: 6.25 }, }); - expect(m.limit).toEqual({ context: 200000, output: 64000 }); + expect(m.limit).toEqual({ context: 200000, output: 16384 }); }); it("inherits the api block from a model the provider already carries", () => { const inherited = { id: "custom", url: "https://proxy.internal/v1", npm: "@ai-sdk/openai" }; - const m = toRuntimeModel(RICH, { + const m = toRuntimeModel(CLOUD, { ...provider, models: { existing: { api: inherited } } as never, }); @@ -92,3 +165,34 @@ describe("toRuntimeModel", () => { expect(m.api.url).toBe(""); }); }); + +describe("withAutoModel", () => { + it("puts a synthesized auto route at the head of the catalog", () => { + const out = withAutoModel([CLOUD]); + expect(out.map((m) => m.id)).toEqual(["bitrouter/auto", "anthropic/claude-opus-4.6"]); + expect(out[0]).toEqual(autoModel()); + }); + + it("offers the auto route even when nothing was discovered", () => { + expect(withAutoModel([]).map((m) => m.id)).toEqual(["bitrouter/auto"]); + }); + + it("prefers the served entry once BitRouter lists auto itself", () => { + const served: DiscoveredModel = { + id: "bitrouter/auto", + name: "BitRouter Auto", + max_input_tokens: 1000000, + capabilities: ["tools", "reasoning"], + }; + const out = withAutoModel([CLOUD, served]); + expect(out[0]).toBe(served); + expect(out).toHaveLength(2); + // The served metadata wins over the placeholder's conservative floor. + expect(toConfigModel(out[0]).limit?.context).toBe(1000000); + }); + + it("never lists the auto route twice", () => { + const out = withAutoModel([{ id: "bitrouter/auto" }, CLOUD, { id: "bitrouter/auto" }]); + expect(out.filter((m) => m.id === "bitrouter/auto")).toHaveLength(1); + }); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 7744dc9..8848c02 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Hooks, PluginInput } from "@opencode-ai/plugin"; -import { BitRouterPlugin, PROVIDER_ID } from "../src/index.js"; +import { AUTO_MODEL_REF, BitRouterPlugin, PROVIDER_ID } from "../src/index.js"; /** * Behavioral tests for the three hooks the plugin returns. The contract: @@ -11,12 +11,20 @@ import { BitRouterPlugin, PROVIDER_ID } from "../src/index.js"; * refreshing an expired OAuth grant rather than sending a dead token. * - `provider.models` replaces the seed catalog with the live one, and keeps * the current catalog rather than blanking it when discovery fails. + * + * The `auto` route leads every catalog these hooks produce, and `config` names + * it as the default model, which is what makes a bare + * `"plugin": ["@bitrouter/opencode"]` a complete installation. */ const CATALOG = { data: [ - { id: "kimi-k2.5", name: "Kimi K2.5", context_window: 256000 }, - { id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true }, + { id: "kimi-k2.5", name: "Kimi K2.5", max_input_tokens: 256000 }, + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + capabilities: ["reasoning", "tools"], + }, ], }; @@ -79,7 +87,7 @@ type ProviderBlock = { npm?: string; name?: string; options: { baseURL?: string; apiKey?: string }; - models: Record; + models: Record; }; function providerBlock(config: unknown): ProviderBlock { @@ -112,11 +120,40 @@ describe("config hook", () => { expect(p.options.baseURL).toBe("http://127.0.0.1:4356/v1"); // loopback daemons run skip_auth, but opencode still wants a key present expect(p.options.apiKey).toBe("bitrouter-local"); - expect(Object.keys(p.models).sort()).toEqual(["claude-opus-4-8", "kimi-k2.5"]); + expect(Object.keys(p.models).sort()).toEqual(["bitrouter/auto", "claude-opus-4-8", "kimi-k2.5"]); expect(p.models["kimi-k2.5"].name).toBe("Kimi K2.5"); + expect(p.models["kimi-k2.5"].limit?.context).toBe(256000); + }); + + it("makes the auto route the default model and small model", async () => { + stubFetch({ "/models": CATALOG }); + const hooks = await load({ BITROUTER_TARGET: "local" }); + const config: Record = {}; + await hooks.config!(config); + + expect(config.model).toBe(AUTO_MODEL_REF); + // opencode references a model as `/`, and the model id + // here is BitRouter's reserved slug `bitrouter/auto` — so the vendor + // segment appears twice, exactly as it does for every catalog model + // (`bitrouter/anthropic/claude-opus-4.6`). + expect(config.model).toBe("bitrouter/bitrouter/auto"); + expect(config.small_model).toBe(AUTO_MODEL_REF); + }); + + it("leaves a model the user already chose alone", async () => { + stubFetch({ "/models": CATALOG }); + const hooks = await load({ BITROUTER_TARGET: "local" }); + const config: Record = { + model: "anthropic/claude-opus-4-8", + small_model: "anthropic/claude-haiku-4.5", + }; + await hooks.config!(config as never); + + expect(config.model).toBe("anthropic/claude-opus-4-8"); + expect(config.small_model).toBe("anthropic/claude-haiku-4.5"); }); - it("seeds a placeholder model when the catalog is unreachable", async () => { + it("still offers the auto route when the catalog is unreachable", async () => { stubFetch({ "/models": new Error("ECONNREFUSED") }); const hooks = await load({ BITROUTER_TARGET: "cloud" }); const config = {}; @@ -126,7 +163,9 @@ describe("config hook", () => { expect(p.options.baseURL).toBe("https://api.bitrouter.ai/v1"); // no filler key on cloud — the user authenticates for real expect(p.options.apiKey).toBeUndefined(); - expect(Object.keys(p.models)).toEqual(["kimi-k2.5"]); + // Unreachable is not unusable: the auto route keeps the provider + // selectable, which is what makes `/connect` reachable at all. + expect(Object.keys(p.models)).toEqual(["bitrouter/auto"]); }); it("does not overwrite a provider block the user wrote", async () => { @@ -148,6 +187,7 @@ describe("config hook", () => { expect(p.options.baseURL).toBe("https://proxy.internal/v1"); // the user's model survives, and the discovered ones are added alongside expect(Object.keys(p.models).sort()).toEqual([ + "bitrouter/auto", "claude-opus-4-8", "kimi-k2.5", "my-model", @@ -257,7 +297,7 @@ describe("provider hook", () => { const models = await hooks.provider!.models!(provider, { auth: { type: "api", key: "brvk_1" }, }); - expect(Object.keys(models).sort()).toEqual(["claude-opus-4-8", "kimi-k2.5"]); + expect(Object.keys(models).sort()).toEqual(["bitrouter/auto", "claude-opus-4-8", "kimi-k2.5"]); expect(models["kimi-k2.5"].limit.context).toBe(256000); }); diff --git a/test/wire.test.ts b/test/wire.test.ts new file mode 100644 index 0000000..0340b36 --- /dev/null +++ b/test/wire.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { toConfigModel, withAutoModel } from "../src/models.js"; +import type { DiscoveredModel } from "../src/discovery.js"; + +/** + * Regression tests against bodies captured verbatim from both BitRouter data + * planes, so a future change to the field mapping is caught by the wire and + * not by a hand-written guess at it. + * + * The fixtures are trimmed to the fields this package reads; every value in + * them is exactly what the endpoint served. + */ +function catalog(name: string): DiscoveredModel[] { + const path = fileURLToPath(new URL(`./fixtures/${name}.json`, import.meta.url)); + return (JSON.parse(readFileSync(path, "utf8")) as { data: DiscoveredModel[] }).data; +} + +function mapped(name: string) { + const models = withAutoModel(catalog(name)).map(toConfigModel); + return { models, byId: Object.fromEntries(models.map((m) => [m.id, m])) }; +} + +describe("BitRouter Cloud wire shape", () => { + it("reads the context window off max_input_tokens", () => { + const { byId } = mapped("cloud-models"); + // Before this mapping existed the plugin read `context_window`, which + // neither plane sends, so every one of these showed the 128K default. + expect(byId["anthropic/claude-fable-5"].limit.context).toBe(1_000_000); + expect(byId["anthropic/claude-haiku-4.5"].limit.context).toBe(200_000); + expect(byId["anthropic/claude-opus-4.6"].limit.context).toBe(200_000); + }); + + it("reads the output cap off max_output_tokens", () => { + const { byId } = mapped("cloud-models"); + expect(byId["anthropic/claude-fable-5"].limit.output).toBe(128_000); + expect(byId["anthropic/claude-haiku-4.5"].limit.output).toBe(8192); + }); + + it("reads per-million cost off the nested pricing block", () => { + const { byId } = mapped("cloud-models"); + // Previously read as a flat `cost` object, which cloud never sends — so + // every model was displayed as free. + expect(byId["anthropic/claude-fable-5"].cost).toEqual({ + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + }); + }); + + it("reads reasoning and tool use off the capability tokens", () => { + const { byId } = mapped("cloud-models"); + expect(byId["anthropic/claude-fable-5"].reasoning).toBe(true); + // Fable advertises `reasoning` and not `tools`. + expect(byId["anthropic/claude-fable-5"].tool_call).toBe(false); + expect(byId["anthropic/claude-haiku-4.5"].reasoning).toBe(false); + expect(byId["anthropic/claude-haiku-4.5"].tool_call).toBe(true); + }); + + it("leads with the auto route", () => { + const { models } = mapped("cloud-models"); + expect(models[0].id).toBe("bitrouter/auto"); + expect(models).toHaveLength(4); // three served + auto + }); +}); + +describe("local daemon wire shape", () => { + it("falls back to opencode's defaults, since the daemon describes nothing", () => { + const { byId } = mapped("local-models"); + // `{ id, object, providers }` is the whole of what `bitrouter start` serves. + const m = byId["anthropic/claude-fable-5"]; + expect(m.name).toBe("anthropic/claude-fable-5"); + expect(m.limit).toEqual({ context: 128000, output: 4096 }); + expect(m.reasoning).toBe(false); + // No capability tokens at all reads as "the plane did not say", and a + // coding agent is unusable against a model it believes cannot call tools. + expect(m.tool_call).toBe(true); + expect(m.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 }); + }); + + it("still leads with the auto route", () => { + expect(mapped("local-models").models[0].id).toBe("bitrouter/auto"); + }); +});