use std::collections::BTreeMap;
use std::time::Duration;
use crate::catalog::BuiltinModelEntry;
use serde::Deserialize;
use futures::future::join_all;
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Deserialize)]
struct ModelsResponse {
data: Vec<RemoteModel>,
}
#[derive(Debug, Deserialize)]
struct RemoteModel {
id: String,
#[serde(default, rename = "object")]
#[allow(dead_code)]
object: Option<String>,
#[serde(default, rename = "owned_by")]
#[allow(dead_code)]
owned_by: Option<String>,
#[serde(default)]
#[allow(dead_code)]
created: Option<u64>,
}
pub async fn discover_models(
provider_id: &str,
api_type: &str,
base_url: &str,
env_key: Option<&str>,
) -> Vec<BuiltinModelEntry> {
if base_url.is_empty() {
return Vec::new();
}
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(DISCOVERY_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let mut request = client.get(&url);
if let Some(env_var) = env_key {
if let Ok(api_key) = std::env::var(env_var) {
request = request.bearer_auth(api_key);
}
}
let result = request.send().await;
let response = match result {
Ok(r) => r,
Err(e) => {
tracing::debug!(provider = provider_id, error = %e, "Discovery: fetch failed");
return Vec::new();
}
};
if !response.status().is_success() {
tracing::debug!(provider = provider_id, status = %response.status(),
"Discovery: non-success status");
return Vec::new();
}
let body = match response.text().await {
Ok(t) => t,
Err(e) => {
tracing::debug!(provider = provider_id, error = %e, "Discovery: body read failed");
return Vec::new();
}
};
let parsed: ModelsResponse = match serde_json::from_str(&body) {
Ok(p) => p,
Err(e) => {
tracing::debug!(provider = provider_id, error = %e, "Discovery: parse failed");
return Vec::new();
}
};
parsed
.data
.into_iter()
.map(|m| BuiltinModelEntry {
id: m.id.clone(),
name: m.id.clone(), api: api_type.to_string(),
provider: provider_id.to_string(),
reasoning: false, input: vec!["text".into()], cost_input: 0.0, cost_output: 0.0,
cost_cache_read: 0.0,
cost_cache_write: 0.0,
context_window: 0, max_tokens: 0,
})
.collect()
}
pub async fn discover_all_local() -> BTreeMap<String, Vec<BuiltinModelEntry>> {
let targets = [
("ollama", "openai-completions", "http://localhost:11434/v1"),
("lmstudio", "openai-completions", "http://localhost:1234/v1"),
("vllm", "openai-completions", "http://localhost:8000/v1"),
("sglang", "openai-completions", "http://localhost:30000/v1"),
];
let futures = targets
.iter()
.map(|(id, api, url)| {
let id = *id;
let api = *api;
let url = *url;
let env_key = match id {
"ollama" => Some("OLLAMA_API_KEY"),
"lmstudio" => Some("LMSTUDIO_API_KEY"),
"vllm" => Some("VLLM_API_KEY"),
"sglang" => Some("SGLANG_API_KEY"),
_ => None,
};
async move {
let models = discover_models(id, api, url, env_key).await;
if !models.is_empty() {
tracing::info!(
provider = %id,
count = models.len(),
"Discovered local models"
);
}
(id.to_string(), models)
}
})
.collect::<Vec<_>>();
let results = join_all(futures).await;
let mut out = BTreeMap::new();
for (id, models) in results {
if !models.is_empty() {
out.insert(id, models);
}
}
out
}
pub async fn discover_all_authenticated() -> BTreeMap<String, Vec<BuiltinModelEntry>> {
let targets: &[(&str, &str, &str)] = &[
("chutes", "CHUTES_API_KEY", "https://api.chutes.ai/v1"),
(
"deepinfra",
"DEEPINFRA_API_KEY",
"https://api.deepinfra.com/v1/openai",
),
("gmi", "GMI_API_KEY", "https://api.gmi-serving.com/v1"),
("kilocode", "KILOCODE_API_KEY", "https://api.kilocode.ai/v1"),
("moonshot", "MOONSHOT_API_KEY", "https://api.moonshot.ai/v1"),
(
"novita",
"NOVITA_API_KEY",
"https://api.novita.ai/v3/openai",
),
(
"nvidia",
"NVIDIA_API_KEY",
"https://integrate.api.nvidia.com/v1",
),
("qwen-oauth", "QWEN_API_KEY", "https://api.qwen.ai/v1"),
("stepfun", "STEPFUN_API_KEY", "https://api.stepfun.com/v1"),
(
"byteplus",
"BYTEPLUS_API_KEY",
"https://ark.ap-southeast.bytepluses.com/api/v3",
),
("venice", "VENICE_API_KEY", "https://api.venice.ai/api/v1"),
];
let providers = crate::catalog::load_builtin_providers();
let provider_map: BTreeMap<&str, &crate::catalog::BuiltinProviderEntry> =
providers.iter().map(|p| (p.id.as_str(), p)).collect();
let mut active: Vec<(String, String, String, String)> = Vec::new();
for (id, env_key, default_url) in targets {
if std::env::var(env_key).is_err() {
continue;
}
let url = provider_map
.get(id)
.map(|p| {
if p.base_url.is_empty() {
(*default_url).to_string()
} else {
p.base_url.clone()
}
})
.unwrap_or_else(|| (*default_url).to_string());
let api = provider_map
.get(id)
.map(|p| p.api.clone())
.unwrap_or_else(|| "openai-completions".to_string());
active.push((id.to_string(), api, url, env_key.to_string()));
}
let futures = active
.into_iter()
.map(|(id, api, url, env_key)| async move {
let models = discover_models(&id, &api, &url, Some(&env_key)).await;
if !models.is_empty() {
tracing::info!(
provider = %id,
count = models.len(),
"Discovered authenticated models"
);
}
(id, models)
})
.collect::<Vec<_>>();
let results = join_all(futures).await;
let mut out = BTreeMap::new();
for (id, models) in results {
if !models.is_empty() {
out.insert(id, models);
}
}
out
}
pub async fn discover_all() -> BTreeMap<String, Vec<BuiltinModelEntry>> {
let mut all = discover_all_local().await;
all.extend(discover_all_authenticated().await);
all
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn discover_empty_url_returns_empty() {
let result = discover_models("test", "openai-completions", "", None).await;
assert!(result.is_empty());
}
#[tokio::test]
async fn discover_unreachable_returns_empty() {
let result = discover_models(
"test",
"openai-completions",
"http://127.0.0.1:1/v1", None,
)
.await;
assert!(result.is_empty());
}
#[tokio::test]
async fn discover_all_authenticated_no_keys_is_empty() {
let result = discover_all_authenticated().await;
let _: std::collections::BTreeMap<String, Vec<_>> = result;
}
}