From 1bf3b9c0ea39785a200cda54d392a4f23a4bfd4c Mon Sep 17 00:00:00 2001 From: Spikel Date: Sun, 23 Aug 2026 18:04:24 +0800 Subject: [PATCH 1/3] feat: default to bitrouter/auto, and read the catalog off the real wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The auto route.** `bitrouter/auto` hands model choice back to BitRouter: the request carries `auto` as its model and the gateway's policy picks per request. It leads every catalog the plugin produces, and the `config` hook names it as `model` and `small_model` — so a fresh opencode.json carrying nothing but `"plugin": ["@bitrouter/opencode"]` is a complete installation. `??=` is the whole of the courtesy: a model the user wrote themselves, or another plugin set first, is left exactly as it stands. Routing the small-model errands through `auto` is what the auto route is for — BitRouter's own ladder is a better judge of "cheap enough for this" than a hardcoded second model id. It is the default, not the only option: the rest of the catalog is registered behind it. A served `auto` entry always wins over the synthesized one, so the real capacities arrive with no release here. Before authentication on cloud the provider is now seeded with the auto route alone rather than a `kimi-k2.5` placeholder — without at least one model the provider would not be selectable and `/connect` would be unreachable. **The discovery field mapping.** Neither data plane sends `context_window`, a flat `cost` object, or boolean `reasoning`/`tool_call` fields — which is what this plugin was reading. Cloud sends `max_input_tokens`, nested per-million `pricing`, and `capabilities` tokens; the local daemon sends `{id, object, providers}` and nothing else. Every cloud model was therefore shown at the 128K default and priced at zero, a 1M-context model included. Absent capability tokens read as "the plane did not say" rather than "no": the local daemon never sends them, and a coding agent is unusable against a model it believes cannot call tools. test/wire.test.ts pins the mapping against bodies captured verbatim from both planes, so a field rename upstream fails a test rather than silently defaulting a model. Co-Authored-By: Claude Opus 5 --- README.md | 50 +++++++--- examples/opencode.json | 9 +- src/constants.ts | 24 +++-- src/discovery.ts | 103 +++++++++++++++++---- src/index.ts | 65 +++++++++---- src/models.ts | 151 +++++++++++++++++++++++++----- test/fixtures/cloud-models.json | 94 +++++++++++++++++++ test/fixtures/local-models.json | 19 ++++ test/models.test.ts | 158 ++++++++++++++++++++++++++------ test/plugin.test.ts | 50 ++++++++-- test/wire.test.ts | 86 +++++++++++++++++ 11 files changed, 695 insertions(+), 114 deletions(-) create mode 100644 test/fixtures/cloud-models.json create mode 100644 test/fixtures/local-models.json create mode 100644 test/wire.test.ts diff --git a/README.md b/README.md index ff096bf..65c4652 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,34 @@ 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 +`auto` as its model and the gateway's routing policy picks the model per +request. 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 `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. The moment `/v1/models` serves an `auto` +entry of its own, that entry wins and carries the real numbers with no release +here. ## Configuration @@ -93,11 +117,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..bd837cd 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 — 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/auto", + "small_model": "bitrouter/auto", "provider": { "bitrouter": { "npm": "@ai-sdk/openai-compatible", @@ -10,8 +12,9 @@ "apiKey": "{env:BITROUTER_API_KEY}" }, "models": { - "kimi-k2.5": { - "name": "Kimi K2.5 via BitRouter" + "auto": { + "name": "BitRouter Auto", + "limit": { "context": 128000, "output": 16384 } } } } diff --git a/src/constants.ts b/src/constants.ts index df8b2fe..17e3fde 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,12 +17,22 @@ 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. Paired with + * {@link PROVIDER_ID} this is the `bitrouter/auto` reference a user selects, + * and it travels to the gateway as the request's `model` field. + * + * BitRouter serves the route; this package only advertises it. Until the + * catalog lists `auto` itself, {@link autoModel} synthesizes the entry so the + * route is selectable — and once the catalog does list it, the served entry + * wins and carries the real metadata. + */ +export const AUTO_MODEL_ID = "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..418f4af 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,36 @@ 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. + * + * The served entry always wins: BitRouter owns the route, and once its catalog + * describes `auto` the real context window, pricing, and capabilities replace + * the placeholder above without a release here. 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..e44cb01 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(["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(["auto"]); + }); + + it("prefers the served entry once BitRouter lists auto itself", () => { + const served: DiscoveredModel = { + id: "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: "auto" }, CLOUD, { id: "auto" }]); + expect(out.filter((m) => m.id === "auto")).toHaveLength(1); + }); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 7744dc9..e762ddf 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"], + }, ], }; @@ -112,11 +120,36 @@ 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(["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); + expect(config.model).toBe("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 +159,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(["auto"]); }); it("does not overwrite a provider block the user wrote", async () => { @@ -148,6 +183,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([ + "auto", "claude-opus-4-8", "kimi-k2.5", "my-model", @@ -257,7 +293,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(["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..fe82d9e --- /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("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("auto"); + }); +}); From 771f11bd4f9b025e89e0ea56310a0c113edc26a7 Mon Sep 17 00:00:00 2001 From: Spikel Date: Sun, 23 Aug 2026 18:09:15 +0800 Subject: [PATCH 2/3] fix: satisfy the test-only typecheck on optional model fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsconfig.test.json` is stricter than the source project and is its own CI step. `modalities` and `limit` are optional on opencode's config-model type, so reading through them needs `?.` — the assertion is unchanged either way, since an undefined left-hand side still fails the comparison. The plugin.test.ts ProviderBlock helper gains the `limit` it now asserts on. Co-Authored-By: Claude Opus 5 --- test/models.test.ts | 4 ++-- test/plugin.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/models.test.ts b/test/models.test.ts index e44cb01..55fef13 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -114,7 +114,7 @@ describe("toConfigModel", () => { 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.modalities?.input).toEqual(["text", "pdf"]); expect(m.attachment).toBe(true); }); @@ -188,7 +188,7 @@ describe("withAutoModel", () => { 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); + expect(toConfigModel(out[0]).limit?.context).toBe(1000000); }); it("never lists the auto route twice", () => { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index e762ddf..ab4ec58 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -87,7 +87,7 @@ type ProviderBlock = { npm?: string; name?: string; options: { baseURL?: string; apiKey?: string }; - models: Record; + models: Record; }; function providerBlock(config: unknown): ProviderBlock { @@ -122,7 +122,7 @@ describe("config hook", () => { expect(p.options.apiKey).toBe("bitrouter-local"); expect(Object.keys(p.models).sort()).toEqual(["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); + expect(p.models["kimi-k2.5"].limit?.context).toBe(256000); }); it("makes the auto route the default model and small model", async () => { From 441493743e2921ea16574b1f76f36380ca39a449 Mon Sep 17 00:00:00 2001 From: Spikel Date: Sun, 23 Aug 2026 18:33:03 +0800 Subject: [PATCH 3/3] fix: the auto model id is bitrouter/auto, not auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BitRouter reserves the whole `bitrouter/` namespace for itself (`RESERVED_NAMESPACE` in `crates/bitrouter-sdk/src/config/presets.rs`) and `bitrouter/auto` is the public slug for policy-driven routing (`AUTO_SLUG`). The vendor segment names the router being addressed, not the token destination. A bare `auto` is not that id: the gateway answers 404 `no active provider declares model 'auto'`, because nothing resolves it before the provider lookup. Verified end to end against a daemon carrying the reserved-namespace build: `bitrouter/auto` completes and the response echoes the public slug, while the request is fulfilled by whichever tier the bound policy picks. Two claims went with it. The namespace is resolved before any provider lookup and BitRouter's registry validator refuses catalog models under `bitrouter/`, so `GET /v1/models` will never list this id — the entry has to come from here, and the "once the catalog lists it" framing was wrong. The synthesized-entry check is kept anyway: it costs nothing and stops the placeholder shadowing a served entry if one ever appears. The id still resolves only where a preset named `auto` is bound to a routing policy; without one the gateway answers 400 naming `bitrouter optimize setup`. Co-Authored-By: Claude Opus 5 --- README.md | 14 +++++++++----- examples/opencode.json | 13 ++++++++----- src/constants.ts | 25 +++++++++++++++++-------- src/models.ts | 8 +++++--- test/models.test.ts | 10 +++++----- test/plugin.test.ts | 14 +++++++++----- test/wire.test.ts | 4 ++-- 7 files changed, 55 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 65c4652..f4d2dc0 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,10 @@ stage of the provider's life: ## The auto route `bitrouter/auto` hands model choice back to BitRouter: the request carries -`auto` as its model and the gateway's routing policy picks the model per -request. It leads every catalog the plugin produces, and it is the default +`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 @@ -77,12 +79,14 @@ 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 `auto`, the plugin synthesizes the entry +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. The moment `/v1/models` serves an `auto` -entry of its own, that entry wins and carries the real numbers with no release +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 diff --git a/examples/opencode.json b/examples/opencode.json index bd837cd..4c8d4ff 100644 --- a/examples/opencode.json +++ b/examples/opencode.json @@ -1,8 +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, 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/auto", - "small_model": "bitrouter/auto", + "$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", @@ -12,9 +12,12 @@ "apiKey": "{env:BITROUTER_API_KEY}" }, "models": { - "auto": { + "bitrouter/auto": { "name": "BitRouter Auto", - "limit": { "context": 128000, "output": 16384 } + "limit": { + "context": 128000, + "output": 16384 + } } } } diff --git a/src/constants.ts b/src/constants.ts index 17e3fde..a38eca3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,16 +23,25 @@ export const bitrouter = { export const PROVIDER_ID = "bitrouter"; /** - * The model id that hands model choice back to BitRouter. Paired with - * {@link PROVIDER_ID} this is the `bitrouter/auto` reference a user selects, - * and it travels to the gateway as the request's `model` field. + * The model id that hands model choice back to BitRouter. * - * BitRouter serves the route; this package only advertises it. Until the - * catalog lists `auto` itself, {@link autoModel} synthesizes the entry so the - * route is selectable — and once the catalog does list it, the served entry - * wins and carries the real metadata. + * `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 = "auto"; +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/models.ts b/src/models.ts index 418f4af..c33421a 100644 --- a/src/models.ts +++ b/src/models.ts @@ -208,9 +208,11 @@ export function autoModel(): DiscoveredModel { * Put the auto route at the head of the catalog, synthesizing it when the * gateway does not serve one yet. * - * The served entry always wins: BitRouter owns the route, and once its catalog - * describes `auto` the real context window, pricing, and capabilities replace - * the placeholder above without a release here. Order matters because the head + * 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[] { diff --git a/test/models.test.ts b/test/models.test.ts index 55fef13..a1d379c 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -169,17 +169,17 @@ describe("toRuntimeModel", () => { 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(["auto", "anthropic/claude-opus-4.6"]); + 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(["auto"]); + expect(withAutoModel([]).map((m) => m.id)).toEqual(["bitrouter/auto"]); }); it("prefers the served entry once BitRouter lists auto itself", () => { const served: DiscoveredModel = { - id: "auto", + id: "bitrouter/auto", name: "BitRouter Auto", max_input_tokens: 1000000, capabilities: ["tools", "reasoning"], @@ -192,7 +192,7 @@ describe("withAutoModel", () => { }); it("never lists the auto route twice", () => { - const out = withAutoModel([{ id: "auto" }, CLOUD, { id: "auto" }]); - expect(out.filter((m) => m.id === "auto")).toHaveLength(1); + 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 ab4ec58..8848c02 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -120,7 +120,7 @@ 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(["auto", "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); }); @@ -132,7 +132,11 @@ describe("config hook", () => { await hooks.config!(config); expect(config.model).toBe(AUTO_MODEL_REF); - expect(config.model).toBe("bitrouter/auto"); + // 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); }); @@ -161,7 +165,7 @@ describe("config hook", () => { expect(p.options.apiKey).toBeUndefined(); // 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(["auto"]); + expect(Object.keys(p.models)).toEqual(["bitrouter/auto"]); }); it("does not overwrite a provider block the user wrote", async () => { @@ -183,7 +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([ - "auto", + "bitrouter/auto", "claude-opus-4-8", "kimi-k2.5", "my-model", @@ -293,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(["auto", "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 index fe82d9e..0340b36 100644 --- a/test/wire.test.ts +++ b/test/wire.test.ts @@ -61,7 +61,7 @@ describe("BitRouter Cloud wire shape", () => { it("leads with the auto route", () => { const { models } = mapped("cloud-models"); - expect(models[0].id).toBe("auto"); + expect(models[0].id).toBe("bitrouter/auto"); expect(models).toHaveLength(4); // three served + auto }); }); @@ -81,6 +81,6 @@ describe("local daemon wire shape", () => { }); it("still leads with the auto route", () => { - expect(mapped("local-models").models[0].id).toBe("auto"); + expect(mapped("local-models").models[0].id).toBe("bitrouter/auto"); }); });