use bitrouter_ai::types::ReasoningEffort;
use super::ToolError;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct RouteInput {
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ResolvedVia {
Live,
Config,
ZeroConfig,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct ProviderHop {
pub provider: String,
pub service_id: String,
pub api_protocol: String,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct PolicySelection {
pub request_key: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub static_tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub static_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub static_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_effort: Option<ReasoningEffort>,
pub pinned: bool,
pub locked: bool,
pub trialed: bool,
}
#[derive(
Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct ContextTierRates {
pub above_input_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_micro_usd_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_micro_usd_per_token: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct EstimatedCost {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_micro_usd_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_micro_usd_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_tiers: Vec<ContextTierRates>,
pub note: String,
}
impl EstimatedCost {
pub fn new(
input_micro_usd_per_token: Option<f64>,
output_micro_usd_per_token: Option<f64>,
context_tiers: Vec<ContextTierRates>,
) -> Self {
let note = if context_tiers.is_empty() {
"base-bracket per-token rates from the registry; multiply by expected token counts"
} else {
"base-bracket per-token rates from the registry; context_tiers lists steeper \
long-context brackets — each applies to the whole request once its input tokens \
exceed above_input_tokens. Multiply by expected token counts."
};
Self {
input_micro_usd_per_token,
output_micro_usd_per_token,
context_tiers,
note: note.to_string(),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct RouteReport {
pub requested_model: String,
pub effective_model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_effort: Option<ReasoningEffort>,
pub resolved_via: ResolvedVia,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_decision: Option<PolicySelection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub router: Option<bitrouter_sdk::language_model::routing::RouterRequestIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub router_source: Option<crate::actions::models::RouterSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_decision_executed: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidate_models: Vec<String>,
#[serde(default)]
pub provider_chain: Vec<ProviderHop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimated_cost: Option<EstimatedCost>,
}
#[async_trait::async_trait]
pub trait RouteQuery: Send + Sync {
async fn route(&self, input: RouteInput) -> Result<RouteReport, ToolError>;
}
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::config::{Config, ConfigRoutingTable};
use bitrouter_sdk::language_model::{RoutingPrefs, RoutingTable};
use crate::daemon::{DaemonCommand, DaemonResponse, RouteHop};
use crate::metering::PricingTable;
use crate::paths::ConfigSource;
use crate::policy_table_router::PolicyDecision;
#[derive(Default)]
struct RouteMetadata {
effective_effort: Option<bitrouter_ai::types::ReasoningEffort>,
router: Option<bitrouter_sdk::language_model::routing::RouterRequestIdentity>,
router_source: Option<crate::actions::models::RouterSource>,
bound_policy: Option<String>,
policy_decision_executed: Option<bool>,
candidate_models: Vec<String>,
}
pub(crate) fn router_source(
config: &Config,
router_id: &str,
) -> Option<crate::actions::models::RouterSource> {
let source = config
.router_inventory()
.ok()?
.into_iter()
.find(|entry| entry.id == router_id)?
.source;
Some(match source {
bitrouter_sdk::config::router::RouterConfigSource::User => {
crate::actions::models::RouterSource::User
}
bitrouter_sdk::config::router::RouterConfigSource::LegacyPreset => {
crate::actions::models::RouterSource::Legacy
}
})
}
pub(crate) fn policy_candidate_models(
policy: Option<&crate::actions::administration::PolicyReport>,
policy_name: Option<&str>,
base_model: &str,
) -> Vec<String> {
let Some(policy_name) = policy_name else {
return Vec::new();
};
let mut candidates = std::collections::BTreeSet::from([base_model.to_string()]);
if let Some(definition) = policy.and_then(|report| report.definitions.get(policy_name)) {
candidates.extend(
definition
.tiers
.values()
.map(|target| target.model().to_string()),
);
}
candidates.into_iter().collect()
}
pub(crate) async fn resolvable_policy_candidates(
table: &dyn RoutingTable,
candidates: Vec<String>,
prefs: &RoutingPrefs,
) -> Vec<String> {
let mut resolved = Vec::new();
for candidate in candidates {
if table
.route_resolved(&candidate, prefs, &CallerContext::local())
.await
.is_ok_and(|chain| !chain.is_empty())
{
resolved.push(candidate);
}
}
resolved
}
pub struct RouteAction {
source: ConfigSource,
socket: Option<PathBuf>,
}
impl RouteAction {
pub fn new(source: ConfigSource, socket: Option<PathBuf>) -> Self {
Self { source, socket }
}
pub async fn report(&self, input: RouteInput) -> Result<RouteReport> {
if let Some(socket) = self.socket.as_ref()
&& let Some(report) = self.via_daemon(socket, &input.model).await?
{
return Ok(report);
}
self.via_config(input).await
}
async fn via_daemon(&self, socket: &Path, model: &str) -> Result<Option<RouteReport>> {
match crate::daemon::send_command(
socket,
&DaemonCommand::Route {
model: model.to_string(),
},
)
.await
{
Ok(DaemonResponse::Route {
chain,
resolved_model,
router,
router_source,
bound_policy,
candidate_models,
}) => {
let effective_model = resolved_model.as_deref().unwrap_or(model);
let policy_decision_executed = bound_policy.as_ref().map(|_| false);
Ok(Some(assemble(
model,
effective_model,
ResolvedVia::Live,
None,
RouteMetadata {
effective_effort: None,
router,
router_source,
bound_policy,
policy_decision_executed,
candidate_models,
},
&chain,
&self.pricing().await,
)))
}
Ok(DaemonResponse::Error { message }) => {
Err(anyhow::anyhow!("resolving model '{model}': {message}"))
}
Ok(other) => {
tracing::debug!(response = ?other, "unexpected daemon route response — resolving from config");
Ok(None)
}
Err(e) => {
tracing::debug!(error = %e, "daemon route failed — resolving from config");
Ok(None)
}
}
}
async fn via_config(&self, input: RouteInput) -> Result<RouteReport> {
let resolved = crate::commands::resolve_discovered(self.resolved_config().await?).await;
let pricing = crate::assemble::build_pricing_table(&resolved);
let table = ConfigRoutingTable::from_config(resolved.clone());
let resolution = table
.resolve_model(&input.model)
.await
.with_context(|| format!("resolving model '{}'", input.model))?;
let router_source = resolution
.router
.as_ref()
.and_then(|identity| router_source(&resolved, &identity.router_id));
let bound_policy = resolution.policy.clone();
let effective_model = resolution.clean_model.clone();
let effective_effort = None;
let policy_report = if bound_policy.is_some() {
crate::actions::administration::disk_policy(&self.source)
.await
.ok()
} else {
None
};
let candidate_models = policy_candidate_models(
policy_report.as_ref(),
bound_policy.as_deref(),
&resolution.clean_model,
);
let candidate_models =
resolvable_policy_candidates(&table, candidate_models, &resolution.prefs).await;
let chain: Vec<RouteHop> = if bound_policy.is_some() {
Vec::new()
} else {
table
.route_resolved(&effective_model, &resolution.prefs, &CallerContext::local())
.await
.with_context(|| format!("resolving model '{effective_model}'"))?
.into_iter()
.map(|t| RouteHop {
provider: t.provider_name,
service_id: t.service_id,
api_protocol: format!("{:?}", t.api_protocol).to_lowercase(),
})
.collect()
};
let policy_decision_executed = bound_policy.as_ref().map(|_| false);
Ok(assemble(
&input.model,
&effective_model,
if self.source.is_default() {
ResolvedVia::ZeroConfig
} else {
ResolvedVia::Config
},
None,
RouteMetadata {
effective_effort,
router: resolution.router,
router_source,
bound_policy,
policy_decision_executed,
candidate_models,
},
&chain,
&pricing,
))
}
async fn resolved_config(&self) -> Result<Config> {
Ok(crate::commands::resolve_static(
crate::paths::load_config(&self.source).await?,
))
}
async fn pricing(&self) -> PricingTable {
match self.resolved_config().await {
Ok(config) => crate::assemble::build_pricing_table(&config),
Err(e) => {
tracing::debug!(error = %e, "no config for pricing — reporting the chain unpriced");
PricingTable::default()
}
}
}
}
fn assemble(
requested_model: &str,
effective_model: &str,
resolved_via: ResolvedVia,
decision: Option<&PolicyDecision>,
metadata: RouteMetadata,
chain: &[RouteHop],
pricing: &PricingTable,
) -> RouteReport {
let estimated_cost = chain
.first()
.and_then(|hop| {
serde_json::from_value::<bitrouter_ai::types::ApiProtocol>(serde_json::Value::String(
hop.api_protocol.clone(),
))
.ok()
.and_then(|protocol| pricing.resolve(&hop.provider, &hop.service_id, &protocol))
})
.filter(|p| !p.is_unconfigured())
.map(|p| estimated_cost(&p));
RouteReport {
requested_model: requested_model.to_string(),
effective_model: effective_model.to_string(),
effective_effort: metadata.effective_effort,
resolved_via,
policy_decision: decision.map(policy_selection),
router: metadata.router,
router_source: metadata.router_source,
bound_policy: metadata.bound_policy,
policy_decision_executed: metadata.policy_decision_executed,
candidate_models: metadata.candidate_models,
provider_chain: chain
.iter()
.map(|h| ProviderHop {
provider: h.provider.clone(),
service_id: h.service_id.clone(),
api_protocol: h.api_protocol.clone(),
})
.collect(),
estimated_cost,
}
}
fn estimated_cost(p: &crate::metering::pricing::ModelPricing) -> EstimatedCost {
EstimatedCost::new(
p.input_micro_usd_per_token,
p.output_micro_usd_per_token,
p.context_tiers
.iter()
.map(|t| ContextTierRates {
above_input_tokens: t.above_input_tokens,
input_micro_usd_per_token: t.input_micro_usd_per_token,
output_micro_usd_per_token: t.output_micro_usd_per_token,
})
.collect(),
)
}
fn policy_selection(d: &PolicyDecision) -> PolicySelection {
PolicySelection {
request_key: d.request_key.clone(),
reason: d.reason.to_string(),
static_tier: d.static_tier.clone(),
static_model: d.static_model.clone(),
static_effort: d.static_effort,
selected_tier: d.selected_tier.clone(),
selected_model: d.selected_model.clone(),
selected_effort: d.selected_effort,
pinned: d.pinned,
locked: d.locked,
trialed: d.trialed,
}
}
#[async_trait::async_trait]
impl RouteQuery for RouteAction {
async fn route(&self, input: RouteInput) -> Result<RouteReport, ToolError> {
self.report(input)
.await
.map_err(|e| ToolError::new(format!("{e:#}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn config_source(dir: &Path, yaml: &str) -> ConfigSource {
let path = dir.join("bitrouter.yaml");
std::fs::write(&path, yaml).expect("write config");
ConfigSource::File(path)
}
const ONE_MODEL: &str = r#"
providers:
demo:
api_base: https://api.example.test
api_key: sk-test
active: true
models:
- id: demo-model
routers:
demo:
selection: { kind: model, model: demo-model }
"#;
const ROUTER_REDIRECTS: &str = r#"
providers:
demo:
api_base: https://api.example.test
api_key: sk-test
active: true
models:
- id: demo-model
- id: demo-model-big
routers:
demo:
selection: { kind: model, model: demo-model-big }
"#;
#[tokio::test]
async fn both_surfaces_produce_the_same_report() {
let dir = tempfile::tempdir().expect("tempdir");
let action = RouteAction::new(config_source(dir.path(), ONE_MODEL), None);
let input = RouteInput {
model: "demo-model".to_string(),
prompt: Some("hello".to_string()),
};
let cli = action.report(input.clone()).await.expect("cli surface");
let port = RouteQuery::route(&action, input)
.await
.expect("port surface");
assert_eq!(
serde_json::to_value(&cli).expect("cli json"),
serde_json::to_value(&port).expect("port json"),
"the two surfaces of one action must be the same bytes"
);
assert_eq!(cli.requested_model, "demo-model");
assert_eq!(cli.resolved_via, ResolvedVia::Config);
assert_eq!(cli.provider_chain.len(), 1);
assert_eq!(cli.provider_chain[0].provider, "demo");
assert_eq!(cli.provider_chain[0].service_id, "demo-model");
}
#[tokio::test]
async fn config_route_resolves_models_discovered_by_models_listing() -> anyhow::Result<()> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "discovered-model"}]
})))
.mount(&server)
.await;
let dir = tempfile::tempdir()?;
let source = config_source(
dir.path(),
&format!(
"providers:\n demo:\n api_base: {}\n api_key: sk-test\n active: true\n auto_discover: true\n",
server.uri()
),
);
let config = crate::paths::load_config(&source).await?;
let models = crate::commands::list_models(&config).await?;
assert!(models.iter().any(|model| model.id == "discovered-model"));
let action = RouteAction::new(source, None);
let route = action
.report(RouteInput {
model: "discovered-model".to_string(),
prompt: None,
})
.await?;
assert_eq!(route.resolved_via, ResolvedVia::Config);
assert_eq!(route.provider_chain.len(), 1);
assert_eq!(route.provider_chain[0].provider, "demo");
assert_eq!(route.provider_chain[0].service_id, "discovered-model");
let direct = crate::commands::resolve_route(&config, "discovered-model").await?;
assert_eq!(direct.len(), 1);
assert_eq!(direct[0].provider, "demo");
Ok(())
}
#[tokio::test]
async fn both_surfaces_resolve_the_named_router() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let action = RouteAction::new(config_source(dir.path(), ROUTER_REDIRECTS), None);
let input = RouteInput {
model: "bitrouter/demo".into(),
prompt: Some("write a function".into()),
};
let cli = action.report(input.clone()).await?;
let port = RouteQuery::route(&action, input).await?;
for report in [&cli, &port] {
assert_eq!(report.requested_model, "bitrouter/demo");
assert_eq!(report.effective_model, "demo-model-big");
assert_eq!(report.provider_chain[0].service_id, "demo-model-big");
assert!(report.policy_decision.is_none());
}
assert_eq!(serde_json::to_value(&cli)?, serde_json::to_value(&port)?);
Ok(())
}
#[tokio::test]
async fn policy_router_preview_lists_candidates_without_fabricating_a_decision()
-> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let source = config_source(
dir.path(),
r#"
providers:
demo:
api_base: https://api.example.test
api_key: sk-test
active: true
models:
- id: demo-model
- id: demo-model-big
routers:
coding:
selection:
kind: policy
policy: coding
base_model: demo-model
"#,
);
std::fs::write(
dir.path().join("policy-lock.yaml"),
r#"lockfileVersion: 4
artifact:
evidence_root: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
source_snapshot_time_unix_ms: 0
compiler:
id: bitrouter-policy-compiler
version: 1
config_digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
policies:
coding:
key_strategy: agent_trace
tiers:
strong: { model: demo-model-big, context: evidence }
routes: {}
default_tier: strong
tool_use_tier: strong
tool_safe_tiers: [strong]
"#,
)?;
let report = RouteAction::new(source, None)
.report(RouteInput {
model: "bitrouter/coding".to_string(),
prompt: Some("write a function".to_string()),
})
.await?;
assert_eq!(report.effective_model, "demo-model");
assert_eq!(report.bound_policy.as_deref(), Some("coding"));
assert_eq!(report.policy_decision_executed, Some(false));
assert!(report.policy_decision.is_none());
assert_eq!(
report
.router
.as_ref()
.map(|router| router.router_id.as_str()),
Some("coding")
);
assert_eq!(
report.router_source,
Some(crate::actions::models::RouterSource::User)
);
assert_eq!(
report.candidate_models,
["demo-model".to_string(), "demo-model-big".to_string()]
);
assert!(
report.provider_chain.is_empty(),
"without a policy decision no provider chain is selected"
);
Ok(())
}
#[tokio::test]
async fn an_edited_config_is_visible_to_the_next_call() {
let dir = tempfile::tempdir().expect("tempdir");
let source = config_source(dir.path(), ONE_MODEL);
let served: std::sync::Arc<dyn RouteQuery> =
std::sync::Arc::new(RouteAction::new(source, None));
let ask = || RouteInput {
model: "bitrouter/demo".to_string(),
prompt: None,
};
let before = served.route(ask()).await.expect("resolves");
assert_eq!(before.effective_model, "demo-model");
assert!(before.policy_decision.is_none(), "no policy table yet");
config_source(dir.path(), ROUTER_REDIRECTS);
let after = served
.route(ask())
.await
.expect("resolves against the edited config");
assert_eq!(
after.effective_model, "demo-model-big",
"the same long-lived action must see the edited config, not a \
snapshot taken at construction"
);
}
#[tokio::test]
async fn a_dead_daemon_socket_falls_back_to_config() {
let dir = tempfile::tempdir().expect("tempdir");
let dead = dir.path().join("nothing-listening.sock");
let action = RouteAction::new(config_source(dir.path(), ONE_MODEL), Some(dead));
let report = action
.report(RouteInput {
model: "demo-model".to_string(),
prompt: None,
})
.await
.expect("resolves via config fallback");
assert_eq!(report.resolved_via, ResolvedVia::Config);
assert_eq!(report.provider_chain[0].provider, "demo");
}
#[tokio::test]
async fn an_unroutable_model_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let action = RouteAction::new(config_source(dir.path(), ONE_MODEL), None);
let err = RouteQuery::route(
&action,
RouteInput {
model: "nonexistent-model".to_string(),
prompt: None,
},
)
.await
.expect_err("an unroutable model surfaces a ToolError");
assert!(
err.0.contains("nonexistent-model"),
"the error names the unresolved model: {}",
err.0
);
}
#[test]
fn the_rate_card_surfaces_context_tiers() {
use crate::metering::pricing::{ContextTier, ModelPricing};
let flat = estimated_cost(&ModelPricing::new(1.0, 2.0));
assert!(flat.context_tiers.is_empty());
assert_eq!(flat.input_micro_usd_per_token, Some(1.0));
assert!(!flat.note.contains("context_tiers"), "note: {}", flat.note);
let tiered = estimated_cost(&ModelPricing {
endpoint_profile: None,
input_micro_usd_per_token: Some(1.0),
output_micro_usd_per_token: Some(2.0),
cache_read_micro_usd_per_token: None,
cache_write_micro_usd_per_token: None,
context_tiers: vec![ContextTier {
above_input_tokens: 200_000,
input_micro_usd_per_token: Some(2.0),
output_micro_usd_per_token: Some(4.0),
cache_read_micro_usd_per_token: None,
cache_write_micro_usd_per_token: None,
}],
});
assert_eq!(tiered.context_tiers.len(), 1);
assert_eq!(tiered.context_tiers[0].above_input_tokens, 200_000);
assert_eq!(tiered.context_tiers[0].input_micro_usd_per_token, Some(2.0));
assert!(
tiered.note.contains("context_tiers"),
"the note has to explain the brackets: {}",
tiered.note
);
}
}