use std::path::{Path, PathBuf};
use bitrouter_mcp::actions::models::{ModelsQuery, ModelsReport};
use bitrouter_mcp::backend::CallerAuth;
use bitrouter_mcp::error::ToolError;
use crate::daemon::{self, DaemonCommand, DaemonResponse};
use crate::paths::ConfigSource;
pub struct RoutableModels {
socket: Option<PathBuf>,
source: ConfigSource,
}
impl RoutableModels {
pub fn new(source: ConfigSource, socket: Option<PathBuf>) -> Self {
Self { socket, source }
}
pub async fn report(&self) -> anyhow::Result<ModelsReport> {
if let Some(models) = self.live_models().await {
return Ok(ModelsReport::live(models));
}
let config = crate::paths::load_config(&self.source).await?;
Ok(ModelsReport::from_config(
crate::commands::list_models(&config).await?,
))
}
async fn live_models(&self) -> Option<Vec<bitrouter_sdk::language_model::routing::ModelInfo>> {
let socket = self.socket.as_deref().filter(|s| endpoint_live(s))?;
match daemon::send_command(socket, &DaemonCommand::Models).await {
Ok(DaemonResponse::Models { models }) => Some(models),
Ok(DaemonResponse::Error { message }) => {
tracing::debug!(%message, "daemon refused `models` — listing from config");
None
}
Ok(_) => None,
Err(e) => {
tracing::debug!(error = %e, "daemon `models` failed — listing from config");
None
}
}
}
}
fn endpoint_live(socket: &Path) -> bool {
daemon::endpoint_in_use(socket)
}
#[async_trait::async_trait]
impl ModelsQuery for RoutableModels {
async fn list_models(&self, _caller: &CallerAuth) -> Result<ModelsReport, ToolError> {
self.report()
.await
.map_err(|e| ToolError::new(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn two_provider_config() -> (tempfile::TempDir, ConfigSource) {
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("bitrouter.yaml");
std::fs::write(
&config,
r#"
server:
listen: "127.0.0.1:0"
skip_auth: true
providers:
openai:
api_base: https://api.openai.com/v1
api_key: k1
models: [{ id: gpt-5 }, { id: o4-mini }]
azure:
api_base: https://example.openai.azure.com
api_key: k2
models: [{ id: gpt-5 }]
"#,
)
.expect("write config");
(dir, ConfigSource::File(config))
}
#[tokio::test]
async fn both_surfaces_keep_every_provider_of_a_model() {
let (dir, source) = two_provider_config();
let models = RoutableModels::new(source, Some(dir.path().join("nothing.sock")));
let cli = models.report().await.expect("cli surface");
let tool = ModelsQuery::list_models(&models, &CallerAuth::default())
.await
.expect("mcp surface");
for (surface, report) in [("cli", &cli), ("mcp", &tool)] {
let gpt5 = report
.models
.iter()
.find(|m| m.id == "gpt-5")
.unwrap_or_else(|| panic!("{surface} surface lost gpt-5"));
assert_eq!(
gpt5.providers,
vec!["azure".to_string(), "openai".to_string()],
"{surface} surface dropped a provider from the fallback chain"
);
let o4 = report
.models
.iter()
.find(|m| m.id == "o4-mini")
.unwrap_or_else(|| panic!("{surface} surface lost o4-mini"));
assert_eq!(o4.providers, vec!["openai".to_string()]);
}
assert_eq!(
serde_json::to_value(&cli).expect("cli json"),
serde_json::to_value(&tool).expect("mcp json"),
"the two surfaces of one action must be the same bytes"
);
}
#[tokio::test]
async fn the_mcp_surface_answers_with_no_daemon_running() {
let (dir, source) = two_provider_config();
let report = ModelsQuery::list_models(
&RoutableModels::new(source, Some(dir.path().join("nothing.sock"))),
&CallerAuth::default(),
)
.await
.expect("a stopped daemon must not fail list_models");
assert_eq!(
report.resolved_via,
bitrouter_mcp::actions::models::ModelsSource::Config
);
assert!(!report.models.is_empty());
}
#[tokio::test]
async fn no_socket_at_all_still_lists_from_config() {
let (_dir, source) = two_provider_config();
let report = RoutableModels::new(source, None)
.report()
.await
.expect("no socket must not fail list_models");
assert!(report.models.iter().any(|m| m.id == "gpt-5"));
}
#[tokio::test]
async fn the_provider_filter_narrows_the_shared_report() {
let (_dir, source) = two_provider_config();
let report = RoutableModels::new(source, None)
.report()
.await
.expect("report");
let azure = report.clone().filtered(Some("azure"));
assert_eq!(
azure.models.iter().map(|m| &m.id).collect::<Vec<_>>(),
vec!["gpt-5"],
"azure declares only gpt-5"
);
assert_eq!(
azure.models[0].providers,
vec!["azure".to_string(), "openai".to_string()]
);
assert!(report.filtered(Some("nobody")).models.is_empty());
}
}