Plugins & hooks
The hook pipeline and plugin model behind BitRouter, and how to write one against the trait-based Rust SDK.
Plugins & hooks
BitRouter is a programmable router for LLM API traffic. Inbound requests on any supported wire protocol are normalised into a canonical pipeline, run through an ordered chain of hooks, dispatched to an upstream provider, and rendered back in the inbound protocol. A plugin is the unit that packages one or more hooks (plus any database migrations) and installs them into the router in a single call.
Everything here is verified against the bitrouter-sdk crate and the two
plugins that ship in the core repository:
bitrouter-guardrails and bitrouter-observe.
Hooks and plugins, conceptually
A hook is a callback that runs at a specific point as a request flows through the router. Depending on the stage it attaches to, a hook can allow, deny, mutate, or observe the request — those four are the only things a stage is ever allowed to do.
A plugin bundles a related set of hooks (plus any migrations they need) and
installs them in one call. It's a convenience package, not the atomic unit: every
plugin can be reproduced by registering its hooks one by one — bundling just makes a
capability reproducible and installable in a single step. The core ships two
this way: bitrouter-guardrails and bitrouter-observe.
The pipeline a plugin hooks into
The SDK exposes three independent protocol pipelines, each with its own hook traits (they are deliberately not generic over a shared trait, so a stage meant for one pipeline can't be registered on another):
language_model— the main LLM pipeline, with the full hook set.mcp— Model Context Protocol routing (pure routing, no settlement).acp— Agent Client Protocol routing (pure routing, no settlement).
Most plugins target language_model. A request flows through its stages in order:
- Pre-request — every
PreRequestHookruns; auth, policy, rate-limit, and upstream guardrails can reject early. - Route — each
RouteHookmay rewrite the ordered chain of routing targets. - Execute — the executor calls the first target, falling back to the next on
a retriable failure. Streaming responses run through every
StreamHook. - Settle — each
SettlementRecorderruns against the immutable settlement context (metering, charging, receipts). - Observe —
ObserveHooks see every phase boundary read-only; they never influence the request.
Source:
crates/bitrouter-sdk/src/lib.rs(the "Anatomy of a request" docs) andcrates/bitrouter-sdk/src/language_model/hooks.rs.
The hook traits
These are the language_model hook traits, defined in
crates/bitrouter-sdk/src/language_model/hooks.rs (and settlement.rs for the
recorder). All are Send + Sync and use async_trait.
// Stage 1 — auth, policy, rate limit, balance, guardrails. First Deny stops the pipeline.
#[async_trait]
pub trait PreRequestHook: Send + Sync {
async fn check(&self, ctx: &mut PipelineContext) -> Result<HookDecision>;
}
// Stage 2 — resolve / mutate the ordered routing chain.
#[async_trait]
pub trait RouteHook: Send + Sync {
async fn resolve(&self, chain: &mut Vec<RoutingTarget>, ctx: &mut PipelineContext) -> Result<()>;
}
// Stage 3 — execution observation + fallback control.
#[async_trait]
pub trait ExecutionHook: Send + Sync {
async fn on_success(&self, ctx: &PipelineContext, result: &ExecutionResult) -> Result<()>;
async fn on_failure(&self, ctx: &PipelineContext, error: &BitrouterError) -> FallbackDecision;
}
// Streaming — intercept canonical stream parts (rewrite / drop / abort).
#[async_trait]
pub trait StreamHook: Send + Sync {
fn interest(&self) -> StreamInterest;
async fn on_part(&self, ctx: &mut StreamContext, part: StreamPart) -> Result<StreamAction>;
async fn on_stream_end(&self, ctx: &mut StreamContext, outcome: &StreamOutcome) -> Result<()>;
}
// Read-only observation at every stage boundary. Errors/panics here never affect the request.
#[async_trait]
pub trait ObserveHook: Send + Sync {
async fn after_phase(&self, phase: Phase, ctx: &PipelineContext);
async fn on_stream_part(&self, ctx: &StreamContext, part: &StreamPart);
async fn on_request_end(&self, ctx: &PipelineContext, outcome: &RequestOutcome);
// plus default-no-op on_hop_start / on_hop_end / stream_interest
}A PreRequestHook returns a HookDecision — Allow, or Deny(DenyReason) where
DenyReason maps to an HTTP status (Unauthorized → 401, Forbidden → 403,
PaymentRequired → 402, RateLimited → 429, GuardrailViolation / BadRequest
→ 400, or a Custom(status, message)).
The Plugin trait
A plugin is a convenience package — it bundles a related set of hooks plus any
SQL migrations and installs them in one call. It is not the atomic unit: every
plugin can be reproduced by calling the relevant sub-builder's hook methods one by
one. The trait lives in crates/bitrouter-sdk/src/app.rs:
pub trait Plugin {
/// The plugin's identity (for config mapping and logs).
fn id(&self) -> &PluginId;
/// Database migrations carried by this plugin. Empty = no database.
fn migrations(&self) -> Vec<MigrationItem> {
Vec::new()
}
/// Install this plugin's hooks into the builder.
fn install(&self, app: &mut AppBuilder);
}Inside install, you reach the language_model sub-builder via
app.language_model_builder() and register hooks with pre_request_hook(...),
route_hook(...), execution_hook(...), stream_hook(...),
settlement_recorder(...), or observe_hook(...). Hooks run in registration
order.
A minimal annotated example
Here is a minimal plugin — a PreRequestHook that denies any request carrying
a banned substring in its system prompt. The structure (an id, an install
that registers hooks) mirrors crates/bitrouter-guardrails/src/plugin.rs
exactly.
use async_trait::async_trait;
use bitrouter_sdk::{AppBuilder, Plugin, PluginId, Result};
use bitrouter_sdk::language_model::{
DenyReason, HookDecision, PipelineContext, PreRequestHook,
};
/// A pre-request hook that denies requests whose system prompt contains
/// a banned phrase.
struct BannedPhraseHook {
phrase: String,
}
#[async_trait]
impl PreRequestHook for BannedPhraseHook {
async fn check(&self, ctx: &mut PipelineContext) -> Result<HookDecision> {
if let Some(system) = &ctx.prompt().system {
if system.contains(&self.phrase) {
return Ok(HookDecision::Deny(DenyReason::GuardrailViolation(
"request blocked by banned-phrase policy".into(),
)));
}
}
Ok(HookDecision::Allow)
}
}
/// The plugin: one id, registers one hook. No migrations, so we lean on
/// the trait's default `migrations()`.
pub struct BannedPhrasePlugin {
id: PluginId,
phrase: String,
}
impl BannedPhrasePlugin {
pub fn new(phrase: impl Into<String>) -> Self {
Self {
id: PluginId::new("banned-phrase"),
phrase: phrase.into(),
}
}
}
impl Plugin for BannedPhrasePlugin {
fn id(&self) -> &PluginId {
&self.id
}
fn install(&self, app: &mut AppBuilder) {
app.language_model_builder()
.pre_request_hook(BannedPhraseHook { phrase: self.phrase.clone() });
}
}The Cargo.toml depends on the SDK and async-trait (the same two
dependencies the shipped guardrails plugin uses):
[dependencies]
bitrouter-sdk = "..." # the BitRouter SDK
async-trait = "0.1"Installing the plugin into a router
A plugin is installed through AppBuilder::plugin, which extends the migration
set and calls your install. This is the same App::builder() flow shown in the
SDK crate docs:
use std::sync::Arc;
use bitrouter_sdk::App;
use bitrouter_sdk::language_model::{HttpExecutor, StaticRoutingTable};
let app = App::builder()
.language_model(|lm| {
lm.routing_table(Arc::new(StaticRoutingTable::new()))
.executor(Arc::new(HttpExecutor::with_defaults()?));
})
.plugin(BannedPhrasePlugin::new("forbidden"))
.build()?;With the SDK's server feature enabled, app.serve("127.0.0.1:4356") wires the
HTTP router and runs it until SIGTERM.
How the shipped plugins use these hooks
The two plugins in the repo are the canonical worked examples:
bitrouter-guardrails— registers aPreRequestHook(GuardrailPreHook, denies request content on aBlockrule) and aStreamHook(GuardrailStreamHook, redactsRedactmatches and aborts onBlockin the response stream). Both read the activeRuleSetfrom the pipeline's typed extensions. Seecrates/bitrouter-guardrails/src/.bitrouter-observe— an OpenTelemetry exporter (OTLP traces + metrics) that installs anObserveHook; the same handle is also wired as the app'smetrics_renderersoGET /metricscan serve it. It is feature-gated behind a transport (otel-http/otel-grpc). Seecrates/bitrouter-observe/src/.
What belongs in a plugin vs deployment code
The SDK is opinionated about pipeline-data correctness, not business logic. Auth,
policy, charging, and metering are deployment-specific — the open-source
bitrouter binary provides its own implementations of those traits, and a hosted
deployment writes its own. Shared, reusable cross-cutting behaviour (guardrails,
observability) is what ships as a plugin.
Next steps
How is this guide?
Call BitRouter Cloud APIs from the CLI
Use a gh api-style command for models, Chat Completions, Messages, Responses, and Google Generative AI with OAuth or API-key login.
Self-host BitRouter
A production walkthrough for running self-hosted BitRouter — config file, keys, daemon lifecycle, metrics, and hardening.