use std::time::Duration;
use serde::Deserialize;
const DEFAULT_LOCAL_PORTS: &[u16] = &[8000, 8012, 8080, 11434, 1234];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DetectResult {
pub api_base: String,
pub model_id: String,
}
#[derive(Deserialize)]
struct ModelsResponse {
#[serde(default)]
data: Vec<ModelEntry>,
}
#[derive(Deserialize)]
struct ModelEntry {
id: String,
}
pub(crate) fn local_candidates() -> Vec<String> {
if let Some(url) = exclusive_url_override() {
return vec![normalize_base(&url)];
}
let mut ports: Vec<u16> = DEFAULT_LOCAL_PORTS.to_vec();
for extra in extra_ports_from_env() {
if !ports.contains(&extra) {
ports.push(extra);
}
}
ports
.into_iter()
.map(|p| format!("http://127.0.0.1:{p}/v1"))
.collect()
}
fn exclusive_url_override() -> Option<String> {
for var in ["ELI_LOCAL_URL", "AGENT_INFER_URL"] {
if let Ok(url) = std::env::var(var) {
let url = url.trim();
if !url.is_empty() {
return Some(url.to_owned());
}
}
}
None
}
fn extra_ports_from_env() -> Vec<u16> {
std::env::var("ELI_LOCAL_PORTS")
.ok()
.map(|raw| {
raw.split(',')
.filter_map(|s| s.trim().parse::<u16>().ok())
.collect()
})
.unwrap_or_default()
}
fn normalize_base(raw: &str) -> String {
let trimmed = raw.trim_end_matches('/');
if trimmed.ends_with("/v1") {
trimmed.to_owned()
} else {
format!("{trimmed}/v1")
}
}
pub(crate) fn parse_first_model_id(body: &str) -> Option<String> {
let parsed: ModelsResponse = serde_json::from_str(body).ok()?;
parsed
.data
.into_iter()
.map(|m| m.id.trim().to_owned())
.find(|id| !id.is_empty())
}
pub(crate) async fn probe(api_base: &str) -> Option<DetectResult> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_millis(500))
.timeout(Duration::from_secs(1))
.build()
.ok()?;
let url = format!("{api_base}/models");
let resp = client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body = resp.text().await.ok()?;
let model_id = parse_first_model_id(&body)?;
Some(DetectResult {
api_base: api_base.to_owned(),
model_id,
})
}
pub(crate) async fn detect_local() -> Option<DetectResult> {
for candidate in local_candidates() {
if let Some(hit) = probe(&candidate).await {
return Some(hit);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
fn local_env_guard() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap()
}
fn clear_local_env() {
unsafe {
std::env::remove_var("ELI_LOCAL_URL");
std::env::remove_var("AGENT_INFER_URL");
std::env::remove_var("ELI_LOCAL_PORTS");
}
}
#[test]
fn parse_extracts_first_model_id() {
let body = r#"{"object":"list","data":[
{"id":"mlx-community/Qwen3-0.6B-4bit","object":"model"},
{"id":"second/model","object":"model"}
]}"#;
assert_eq!(
parse_first_model_id(body).as_deref(),
Some("mlx-community/Qwen3-0.6B-4bit")
);
}
#[test]
fn parse_returns_none_on_empty_list() {
let body = r#"{"object":"list","data":[]}"#;
assert!(parse_first_model_id(body).is_none());
}
#[test]
fn parse_returns_none_on_malformed_json() {
assert!(parse_first_model_id("not json").is_none());
assert!(parse_first_model_id("{").is_none());
}
#[test]
fn parse_skips_empty_ids() {
let body = r#"{"data":[{"id":""},{"id":"real-model"}]}"#;
assert_eq!(parse_first_model_id(body).as_deref(), Some("real-model"));
}
#[test]
fn candidates_use_default_ports_without_env() {
clear_local_env();
let candidates = local_candidates();
assert_eq!(
candidates,
vec![
"http://127.0.0.1:8000/v1".to_owned(),
"http://127.0.0.1:8012/v1".to_owned(),
"http://127.0.0.1:8080/v1".to_owned(),
"http://127.0.0.1:11434/v1".to_owned(),
"http://127.0.0.1:1234/v1".to_owned(),
]
);
}
#[test]
fn normalize_appends_v1_when_missing() {
assert_eq!(normalize_base("http://host:9000"), "http://host:9000/v1");
assert_eq!(normalize_base("http://host:9000/"), "http://host:9000/v1");
assert_eq!(normalize_base("http://host:9000/v1"), "http://host:9000/v1");
assert_eq!(
normalize_base("http://host:9000/v1/"),
"http://host:9000/v1"
);
}
#[test]
fn candidates_local_url_env_is_exclusive() {
let _guard = local_env_guard();
clear_local_env();
unsafe {
std::env::set_var("ELI_LOCAL_URL", "http://explicit-host:9000");
}
let candidates = local_candidates();
clear_local_env();
assert_eq!(candidates, vec!["http://explicit-host:9000/v1".to_owned()]);
}
#[test]
fn candidates_legacy_agent_infer_url_still_works() {
let _guard = local_env_guard();
clear_local_env();
unsafe {
std::env::set_var("AGENT_INFER_URL", "http://legacy-host:7000");
}
let candidates = local_candidates();
clear_local_env();
assert_eq!(candidates, vec!["http://legacy-host:7000/v1".to_owned()]);
}
#[test]
fn candidates_extra_ports_appended_after_defaults() {
let _guard = local_env_guard();
clear_local_env();
unsafe {
std::env::set_var("ELI_LOCAL_PORTS", "8000,9090, 7000 ,bogus,1234");
}
let candidates = local_candidates();
clear_local_env();
assert!(candidates.contains(&"http://127.0.0.1:9090/v1".to_owned()));
assert!(candidates.contains(&"http://127.0.0.1:7000/v1".to_owned()));
let port_count = candidates.iter().filter(|c| c.contains(":8000/")).count();
assert_eq!(port_count, 1, "8000 should not be duplicated");
}
#[tokio::test]
async fn probe_returns_none_on_connection_refused() {
assert!(probe("http://127.0.0.1:1/v1").await.is_none());
}
}