use bitrouter_sdk::language_model::routing::ModelInfo;
use super::ToolError;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ModelsSource {
Live,
Config,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct ModelsReport {
pub models: Vec<ModelInfo>,
pub resolved_via: ModelsSource,
}
impl ModelsReport {
pub fn live(models: Vec<ModelInfo>) -> Self {
Self {
models,
resolved_via: ModelsSource::Live,
}
}
pub fn from_config(models: Vec<ModelInfo>) -> Self {
Self {
models,
resolved_via: ModelsSource::Config,
}
}
pub fn filtered(mut self, provider: Option<&str>) -> Self {
if let Some(provider) = provider {
self.models
.retain(|m| m.providers.iter().any(|p| p == provider));
}
self
}
}
#[async_trait::async_trait]
pub trait ModelsQuery: Send + Sync {
async fn list_models(&self) -> Result<ModelsReport, ToolError>;
}
use std::path::{Path, PathBuf};
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) -> 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 port = ModelsQuery::list_models(&models)
.await
.expect("port surface");
for (surface, report) in [("cli", &cli), ("port", &port)] {
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(&port).expect("port json"),
"the two surfaces of one action must be the same bytes"
);
}
#[tokio::test]
async fn the_port_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")),
))
.await
.expect("a stopped daemon must not fail list_models");
assert_eq!(
report.resolved_via,
crate::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());
}
#[tokio::test]
async fn listed_subscription_selector_is_accepted_by_route_preview() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("bitrouter.yaml");
std::fs::write(
&path,
r#"
inherit_defaults: false
providers:
openai-codex:
api_base: https://chatgpt.com/backend-api/codex
api_key: subscription
class: first-party-subscription
models:
- id: openai/gpt-5.6-sol
provider_model_id: gpt-5.6-sol
"#,
)?;
let source = ConfigSource::File(path);
let models = RoutableModels::new(source.clone(), None).report().await?;
let selector = models
.models
.first()
.ok_or_else(|| anyhow::anyhow!("subscription model was not listed"))?;
assert_eq!(selector.id, "openai-codex:openai/gpt-5.6-sol");
let route = crate::actions::route::RouteAction::new(source, None)
.report(crate::actions::route::RouteInput {
model: selector.id.clone(),
prompt: None,
})
.await?;
assert_eq!(route.provider_chain.len(), 1);
assert_eq!(route.provider_chain[0].provider, "openai-codex");
assert_eq!(route.provider_chain[0].service_id, "gpt-5.6-sol");
Ok(())
}
}