use std::time::Duration;
use super::endpoints::EndpointList;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiscoveryResult {
pub discovery: bool,
pub models: Vec<String>,
}
pub fn discover(list: &EndpointList, model: Option<&str>, timeout: Duration) -> DiscoveryResult {
let mut models: Vec<String> = Vec::new();
let mut any = false;
for ep in list.iter() {
let discovered = ep.discover_models(timeout);
if !discovered.is_empty() {
any = true;
for m in discovered {
if !models.contains(&m) {
models.push(m);
}
}
}
}
if let Some(m) = model
&& !m.is_empty()
&& !models.contains(&m.to_string())
{
models.push(m.to_string());
}
DiscoveryResult {
discovery: any,
models,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list_of(uri: &str) -> EndpointList {
EndpointList::parse_with_env(uri, None, &|_| None).unwrap()
}
use std::io::{Read, Write};
use std::net::TcpListener;
fn serve_once(status: u16, body: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
let mut buf = [0u8; 2048];
let _ = s.read(&mut buf); let resp = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = s.write_all(resp.as_bytes());
let _ = s.flush();
}
});
format!("http://127.0.0.1:{port}")
}
fn dead_endpoint() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
format!("http://127.0.0.1:{port}")
}
#[test]
fn discovers_models_and_unions_configured() {
let uri = serve_once(
200,
r#"{"data":[{"id":"claude-opus-4"},{"id":"claude-haiku-4"}]}"#,
);
let list = list_of(&uri);
let r = discover(&list, Some("claude-opus-4"), Duration::from_secs(2));
assert!(r.discovery, "an endpoint answered /v1/models");
assert_eq!(
r.models,
vec!["claude-opus-4".to_string(), "claude-haiku-4".to_string()]
);
}
#[test]
fn configured_model_is_added_when_not_already_discovered() {
let uri = serve_once(200, r#"{"data":[{"id":"served-model"}]}"#);
let list = list_of(&uri);
let r = discover(&list, Some("configured-model"), Duration::from_secs(2));
assert!(r.discovery);
assert_eq!(
r.models,
vec!["served-model".to_string(), "configured-model".to_string()]
);
}
#[test]
fn http_404_degrades_silently_to_no_discovery() {
let uri = serve_once(404, r#"{"error":"not found"}"#);
let list = list_of(&uri);
let r = discover(&list, Some("only-configured"), Duration::from_secs(2));
assert!(!r.discovery, "a 404 is not an answer");
assert_eq!(r.models, vec!["only-configured".to_string()]);
}
#[test]
fn connection_failure_degrades_silently() {
let list = list_of(&dead_endpoint());
let r = discover(&list, Some("m"), Duration::from_secs(1));
assert!(!r.discovery);
assert_eq!(r.models, vec!["m".to_string()]);
}
#[test]
fn non_json_body_degrades_silently() {
let uri = serve_once(200, "<html>not json</html>");
let list = list_of(&uri);
let r = discover(&list, Some("m"), Duration::from_secs(2));
assert!(!r.discovery, "a non-JSON 200 yields no models");
assert_eq!(r.models, vec!["m".to_string()]);
}
#[test]
fn no_configured_and_no_discovery_is_empty() {
let list = list_of(&dead_endpoint());
let r = discover(&list, None, Duration::from_secs(1));
assert!(!r.discovery);
assert!(r.models.is_empty(), "[] if none discovered + no model");
}
}