use std::sync::Arc;
use anyhow::{Context, Result};
use sea_orm::DatabaseConnection;
use bitrouter_sdk::App;
use bitrouter_sdk::acp::{AcpStdioExecutor, ConfigAcpRoutingTable};
use bitrouter_sdk::config::{Config, ConfigRoutingTable};
use bitrouter_sdk::language_model::protocol::OutboundDispatch;
use bitrouter_sdk::language_model::{AuthAppliers, HttpExecutor, HttpTimeouts};
use bitrouter_sdk::mcp::{ConfigMcpRoutingTable, RmcpExecutor};
use bitrouter_guardrails::{Action, GuardrailPreHook, GuardrailRule, GuardrailStreamHook, RuleSet};
use bitrouter_observe::{OtlpExportHook, PrometheusHook};
use bitrouter_sdk::MetricsRenderer;
use crate::auth::AuthHook;
use crate::metering::{MeteringRecorder, MeteringStore, ModelPricing, PricingTable};
use crate::policy::{PolicyHook, PolicyStore};
pub struct Assembled {
pub app: App,
pub db: DatabaseConnection,
pub policy_store: Arc<PolicyStore>,
pub routing_table: Arc<ConfigRoutingTable>,
}
pub async fn build_app(config: &Config) -> Result<Assembled> {
build_app_with_path(config, None).await
}
pub async fn build_app_with_path(
config: &Config,
config_path: Option<&std::path::Path>,
) -> Result<Assembled> {
let db = crate::db::connect(&config.database.url)
.await
.with_context(|| format!("connecting to database {}", config.database.url))?;
crate::db::run_migrations(&db)
.await
.context("running database migrations")?;
#[cfg(unix)]
if let Some(path) = sqlite_file_path(&config.database.url) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
if let Err(e) = std::fs::set_permissions(&path, perms) {
tracing::warn!(?path, %e, "failed to chmod 0600 on sqlite db file");
}
}
}
let mut resolved = config.clone();
bitrouter_providers::apply_builtin_defaults(&mut resolved);
bitrouter_sdk::config::discover_models(&mut resolved).await;
let routing_table = Arc::new(match config_path {
Some(path) => ConfigRoutingTable::from_config_with_path(resolved, path),
None => ConfigRoutingTable::from_config(resolved),
});
let routing_table_for_reload = routing_table.clone();
let auth_appliers = build_auth_appliers(config)?;
let executor = Arc::new(
HttpExecutor::with_dispatch_and_auth(
HttpTimeouts::default(),
OutboundDispatch::builtin(),
auth_appliers,
)
.context("building the upstream HTTP executor")?,
);
let pricing = Arc::new(build_pricing_table(config));
let metering_store = MeteringStore::new(db.clone());
let metering_store_for_policy = metering_store.clone();
let metering_store_for_recorder = metering_store.clone();
let pricing_for_recorder = pricing.clone();
let policy_store: Arc<PolicyStore> = Arc::new(load_policy_store(config).await?);
let policy_store_for_reload = policy_store.clone();
let guardrail_rules = build_guardrail_rules(config)?;
let prometheus: Arc<PrometheusHook> = Arc::new(PrometheusHook::new());
let prometheus_for_observe = prometheus.clone();
let metrics_renderer: Arc<dyn MetricsRenderer> = prometheus;
let otlp_endpoint: Option<String> = config
.plugins
.get("bitrouter-observe")
.and_then(|c| c.get("otlp_endpoint"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mcp_routing = if config.mcp_servers.is_empty() {
None
} else {
Some(Arc::new(
ConfigMcpRoutingTable::from_configs(
config
.mcp_servers
.iter()
.map(|(k, v)| (k.clone(), v.clone())),
)
.context("building the MCP routing table from config.mcp_servers")?,
))
};
let mcp_executor: Option<Arc<RmcpExecutor>> =
mcp_routing.as_ref().map(|_| Arc::new(RmcpExecutor::new()));
let acp_routing = if config.agents.is_empty() {
None
} else {
Some(Arc::new(
ConfigAcpRoutingTable::from_configs(
config.agents.iter().map(|(k, v)| (k.clone(), v.clone())),
)
.context("building the ACP routing table from config.agents")?,
))
};
let acp_executor: Option<Arc<AcpStdioExecutor>> = acp_routing
.as_ref()
.map(|_| Arc::new(AcpStdioExecutor::new()));
let db_for_hooks = db.clone();
let app = App::builder()
.skip_auth(config.server.skip_auth)
.metrics_renderer(metrics_renderer)
.language_model(move |lm| {
lm.routing_table(routing_table).executor(executor);
lm.pre_request_hook(AuthHook::new(db_for_hooks.clone()));
lm.pre_request_hook(PolicyHook::new(
policy_store.clone(),
Some(metering_store_for_policy),
));
if !guardrail_rules.is_empty() {
lm.pre_request_hook(GuardrailPreHook::new(guardrail_rules.clone()));
lm.stream_hook(GuardrailStreamHook::new(guardrail_rules));
}
lm.observe_hook(PrometheusObserve(prometheus_for_observe.clone()));
if let Some(endpoint) = otlp_endpoint.as_ref() {
lm.observe_hook(OtlpExportHook::new(endpoint));
}
lm.settlement_recorder(MeteringRecorder::new(
metering_store_for_recorder,
pricing_for_recorder,
));
});
let app = match (mcp_routing, mcp_executor) {
(Some(table), Some(exec)) => app.mcp(move |m| {
m.routing_table(table).executor(exec);
}),
_ => app,
};
let app = match (acp_routing, acp_executor) {
(Some(table), Some(exec)) => app.acp(move |a| {
a.routing_table(table).executor(exec);
}),
_ => app,
};
let app = app.build().context("building the App")?;
Ok(Assembled {
app,
db,
policy_store: policy_store_for_reload,
routing_table: routing_table_for_reload,
})
}
fn build_auth_appliers(config: &Config) -> Result<AuthAppliers> {
let mut appliers = AuthAppliers::new();
if config.providers.contains_key("github-copilot") {
let token_store_path = bitrouter_providers::oauth::TokenStore::default_path()
.map(|s| s.path().to_path_buf())
.context("resolving OAuth token store path for github-copilot")?;
let applier = bitrouter_providers::copilot::CopilotAuthApplier::new(token_store_path)
.context("building the github-copilot AuthApplier")?;
appliers.register("github-copilot", Arc::new(applier));
}
Ok(appliers)
}
fn build_pricing_table(config: &Config) -> PricingTable {
let mut table = PricingTable::new();
for (provider_id, provider) in &config.providers {
for model in &provider.models {
if let Some(pricing) = model.pricing {
table.insert(
provider_id.clone(),
model.id.clone(),
ModelPricing::new(
pricing.input_micro_usd_per_token,
pricing.output_micro_usd_per_token,
),
);
}
}
}
table
}
async fn load_policy_store(config: &Config) -> Result<PolicyStore> {
let dir = config
.plugins
.get("bitrouter-policy")
.and_then(|c| c.get("policy_dir"))
.and_then(|v| v.as_str());
match dir {
Some(dir) => PolicyStore::load_dir(dir)
.await
.with_context(|| format!("loading policies from {dir}")),
None => Ok(PolicyStore::new()),
}
}
fn build_guardrail_rules(config: &Config) -> Result<RuleSet> {
let Some(patterns) = config
.plugins
.get("bitrouter-guardrails")
.and_then(|c| c.get("custom_patterns"))
.and_then(|v| v.as_array())
else {
return Ok(RuleSet::new());
};
let mut set = RuleSet::new();
for entry in patterns {
let name = entry
.get("name")
.and_then(|v| v.as_str())
.context("guardrail pattern missing 'name'")?;
let pattern = entry
.get("pattern")
.and_then(|v| v.as_str())
.context("guardrail pattern missing 'pattern'")?;
let action = match entry.get("action").and_then(|v| v.as_str()) {
Some("block") | None => Action::Block,
Some("redact") => Action::Redact,
Some(other) => anyhow::bail!("unknown guardrail action '{other}'"),
};
set.push(
GuardrailRule::new(name, pattern, action)
.with_context(|| format!("compiling guardrail pattern '{name}'"))?,
);
}
Ok(set)
}
struct PrometheusObserve(Arc<PrometheusHook>);
#[async_trait::async_trait]
impl bitrouter_sdk::language_model::ObserveHook for PrometheusObserve {
async fn after_phase(
&self,
phase: bitrouter_sdk::language_model::Phase,
ctx: &bitrouter_sdk::language_model::PipelineContext,
) {
self.0.after_phase(phase, ctx).await
}
fn stream_interest(&self) -> bitrouter_sdk::language_model::StreamInterest {
self.0.stream_interest()
}
async fn on_stream_part(
&self,
ctx: &bitrouter_sdk::language_model::StreamContext,
part: &bitrouter_sdk::language_model::StreamPart,
) {
self.0.on_stream_part(ctx, part).await
}
async fn on_request_end(
&self,
ctx: &bitrouter_sdk::language_model::PipelineContext,
outcome: &bitrouter_sdk::language_model::RequestOutcome,
) {
self.0.on_request_end(ctx, outcome).await
}
}
#[cfg(unix)]
fn sqlite_file_path(url: &str) -> Option<std::path::PathBuf> {
let after_scheme = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))?;
let path = after_scheme.split('?').next().unwrap_or(after_scheme);
if path.is_empty() || path == ":memory:" {
return None;
}
Some(std::path::PathBuf::from(path))
}
#[cfg(all(test, unix))]
mod sqlite_path_tests {
use super::sqlite_file_path;
use std::path::PathBuf;
#[test]
fn parses_common_sqlite_urls() {
assert_eq!(
sqlite_file_path("sqlite:///var/lib/bitrouter.db"),
Some(PathBuf::from("/var/lib/bitrouter.db"))
);
assert_eq!(
sqlite_file_path("sqlite:bitrouter.db"),
Some(PathBuf::from("bitrouter.db"))
);
assert_eq!(
sqlite_file_path("sqlite:bitrouter.db?cache=shared"),
Some(PathBuf::from("bitrouter.db"))
);
assert_eq!(sqlite_file_path(":memory:"), None);
assert_eq!(sqlite_file_path("sqlite::memory:"), None);
assert_eq!(sqlite_file_path("postgres://u:p@host/bitrouter"), None);
assert_eq!(sqlite_file_path("mysql://u:p@host/bitrouter"), None);
}
}