1use std::collections::BTreeMap;
25use std::time::Duration;
26
27use crate::catalog::BuiltinModelEntry;
28use serde::Deserialize;
29
30use futures::future::join_all;
31
32const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
34
35#[derive(Debug, Deserialize)]
39struct ModelsResponse {
40 data: Vec<RemoteModel>,
41}
42
43#[derive(Debug, Deserialize)]
44struct RemoteModel {
45 id: String,
46 #[serde(default, rename = "object")]
47 #[allow(dead_code)]
48 object: Option<String>,
49 #[serde(default, rename = "owned_by")]
50 #[allow(dead_code)]
51 owned_by: Option<String>,
52 #[serde(default)]
53 #[allow(dead_code)]
54 created: Option<u64>,
55}
56
57pub async fn discover_models(
67 provider_id: &str,
68 api_type: &str,
69 base_url: &str,
70 env_key: Option<&str>,
71) -> Vec<BuiltinModelEntry> {
72 if base_url.is_empty() {
73 return Vec::new();
74 }
75 let url = format!("{}/models", base_url.trim_end_matches('/'));
76 let client = reqwest::Client::builder()
77 .timeout(DISCOVERY_TIMEOUT)
78 .build()
79 .unwrap_or_else(|_| reqwest::Client::new());
80
81 let mut request = client.get(&url);
82 if let Some(env_var) = env_key
83 && let Ok(api_key) = std::env::var(env_var)
84 {
85 request = request.bearer_auth(api_key);
86 }
87
88 let result = request.send().await;
89 let response = match result {
90 Ok(r) => r,
91 Err(e) => {
92 tracing::debug!(provider = provider_id, error = %e, "Discovery: fetch failed");
93 return Vec::new();
94 }
95 };
96
97 if !response.status().is_success() {
98 tracing::debug!(provider = provider_id, status = %response.status(),
99 "Discovery: non-success status");
100 return Vec::new();
101 }
102
103 let body = match response.text().await {
104 Ok(t) => t,
105 Err(e) => {
106 tracing::debug!(provider = provider_id, error = %e, "Discovery: body read failed");
107 return Vec::new();
108 }
109 };
110
111 let parsed: ModelsResponse = match serde_json::from_str(&body) {
112 Ok(p) => p,
113 Err(e) => {
114 tracing::debug!(provider = provider_id, error = %e, "Discovery: parse failed");
115 return Vec::new();
116 }
117 };
118
119 parsed
120 .data
121 .into_iter()
122 .map(|m| BuiltinModelEntry {
123 id: m.id.clone(),
124 name: m.id.clone(), api: api_type.to_string(),
126 provider: provider_id.to_string(),
127 reasoning: false, input: vec!["text".into()], cost_input: 0.0, cost_output: 0.0,
131 cost_cache_read: 0.0,
132 cost_cache_write: 0.0,
133 context_window: 0, max_tokens: 0,
135 auth_method: crate::catalog::provider::AuthMethod::Bearer,
136 base_url: None,
137 })
138 .collect()
139}
140
141pub async fn discover_all_local() -> BTreeMap<String, Vec<BuiltinModelEntry>> {
154 let targets = [
155 ("ollama", "openai-completions", "http://localhost:11434/v1"),
156 ("lmstudio", "openai-completions", "http://localhost:1234/v1"),
157 ("vllm", "openai-completions", "http://localhost:8000/v1"),
158 ("sglang", "openai-completions", "http://localhost:30000/v1"),
159 ];
160
161 let futures = targets
162 .iter()
163 .map(|(id, api, url)| {
164 let id = *id;
165 let api = *api;
166 let url = *url;
167 let env_key = match id {
168 "ollama" => Some("OLLAMA_API_KEY"),
169 "lmstudio" => Some("LMSTUDIO_API_KEY"),
170 "vllm" => Some("VLLM_API_KEY"),
171 "sglang" => Some("SGLANG_API_KEY"),
172 _ => None,
173 };
174 async move {
175 let models = discover_models(id, api, url, env_key).await;
179 if !models.is_empty() {
180 tracing::info!(
181 provider = %id,
182 count = models.len(),
183 "Discovered local models"
184 );
185 }
186 (id.to_string(), models)
187 }
188 })
189 .collect::<Vec<_>>();
190
191 let results = join_all(futures).await;
192 let mut out = BTreeMap::new();
193 for (id, models) in results {
194 if !models.is_empty() {
195 out.insert(id, models);
196 }
197 }
198 out
199}
200
201pub async fn discover_all_authenticated() -> BTreeMap<String, Vec<BuiltinModelEntry>> {
227 let targets: &[(&str, &str, &str)] = &[
230 ("chutes", "CHUTES_API_KEY", "https://api.chutes.ai/v1"),
231 (
232 "deepinfra",
233 "DEEPINFRA_API_KEY",
234 "https://api.deepinfra.com/v1/openai",
235 ),
236 ("gmi", "GMI_API_KEY", "https://api.gmi-serving.com/v1"),
237 ("kilocode", "KILOCODE_API_KEY", "https://api.kilocode.ai/v1"),
238 ("moonshot", "MOONSHOT_API_KEY", "https://api.moonshot.ai/v1"),
239 (
240 "novita",
241 "NOVITA_API_KEY",
242 "https://api.novita.ai/v3/openai",
243 ),
244 (
245 "nvidia",
246 "NVIDIA_API_KEY",
247 "https://integrate.api.nvidia.com/v1",
248 ),
249 ("qwen-oauth", "QWEN_API_KEY", "https://api.qwen.ai/v1"),
250 ("stepfun", "STEPFUN_API_KEY", "https://api.stepfun.com/v1"),
251 (
252 "byteplus",
253 "BYTEPLUS_API_KEY",
254 "https://ark.ap-southeast.bytepluses.com/api/v3",
255 ),
256 ("venice", "VENICE_API_KEY", "https://api.venice.ai/api/v1"),
257 ];
258
259 let providers = crate::catalog::materialize::materialize_providers();
260 let provider_map: BTreeMap<&str, &crate::catalog::BuiltinProviderEntry> =
261 providers.iter().map(|p| (p.id.as_str(), p)).collect();
262
263 let mut active: Vec<(String, String, String, String)> = Vec::new();
266 for (id, env_key, default_url) in targets {
267 if std::env::var(env_key).is_err() {
268 continue;
269 }
270 let url = provider_map
271 .get(id)
272 .map(|p| {
273 if p.base_url.is_empty() {
274 (*default_url).to_string()
275 } else {
276 p.base_url.clone()
277 }
278 })
279 .unwrap_or_else(|| (*default_url).to_string());
280 let api = provider_map
281 .get(id)
282 .map(|p| p.api.clone())
283 .unwrap_or_else(|| "openai-completions".to_string());
284 active.push((id.to_string(), api, url, env_key.to_string()));
285 }
286
287 let futures = active
288 .into_iter()
289 .map(|(id, api, url, env_key)| async move {
290 let models = discover_models(&id, &api, &url, Some(&env_key)).await;
291 if !models.is_empty() {
292 tracing::info!(
293 provider = %id,
294 count = models.len(),
295 "Discovered authenticated models"
296 );
297 }
298 (id, models)
299 })
300 .collect::<Vec<_>>();
301
302 let results = join_all(futures).await;
303 let mut out = BTreeMap::new();
304 for (id, models) in results {
305 if !models.is_empty() {
306 out.insert(id, models);
307 }
308 }
309 out
310}
311
312pub async fn discover_all() -> BTreeMap<String, Vec<BuiltinModelEntry>> {
318 let mut all = discover_all_local().await;
319 all.extend(discover_all_authenticated().await);
320 all
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[tokio::test]
328 async fn discover_empty_url_returns_empty() {
329 let result = discover_models("test", "openai-completions", "", None).await;
330 assert!(result.is_empty());
331 }
332
333 #[tokio::test]
334 async fn discover_unreachable_returns_empty() {
335 let result = discover_models(
337 "test",
338 "openai-completions",
339 "http://127.0.0.1:1/v1", None,
341 )
342 .await;
343 assert!(result.is_empty());
344 }
345
346 #[tokio::test]
347 async fn discover_all_authenticated_no_keys_is_empty() {
348 let result = discover_all_authenticated().await;
353 let _: std::collections::BTreeMap<String, Vec<_>> = result;
356 }
357}