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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:

Expand All @@ -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

Expand Down Expand Up @@ -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`**

Expand Down
12 changes: 9 additions & 3 deletions examples/opencode.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
}
}
}
}
Expand Down
33 changes: 26 additions & 7 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
103 changes: 87 additions & 16 deletions src/discovery.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
);
}
65 changes: 46 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -62,19 +71,20 @@ export const BitRouterPlugin: Plugin = async ({ client }): Promise<Hooks> => {
};

// 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<BitrouterCredentials> | undefined;
Expand Down Expand Up @@ -137,6 +147,20 @@ export const BitRouterPlugin: Plugin = async ({ client }): Promise<Hooks> => {
},
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: {
Expand Down Expand Up @@ -186,8 +210,11 @@ export const BitRouterPlugin: Plugin = async ({ client }): Promise<Hooks> => {
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)]),
);
},
},
Expand Down
Loading
Loading