Skip to main content

oxicode_catalog/catalog/
runtime.rs

1//! Runtime model discovery layer of the dynamic catalog.
2//!
3//! Some providers (ollama, lmstudio, vllm, sglang, openrouter) expose
4//! `GET /v1/models` for runtime discovery. This module fetches those endpoints
5//! and merges the results into the catalog at startup.
6//!
7//! ## Why this is Layer 3
8//!
9//! - **Built-in (Layer 1)**: fast, deterministic, offline
10//! - **Override (Layer 2)**: user customization, fast, offline
11//! - **Runtime (Layer 3)**: dynamic, requires network, slow — only for providers
12//!   where the model list cannot be known a priori
13//!
14//! ## Failure handling
15//!
16//! - Network failures: silently skip that provider (with debug log)
17//! - HTTP errors: same
18//! - Parsing errors: same
19//! - Slow providers: bounded by a 5-second timeout
20//!
21//! The discovery is best-effort: if Ollama is not running, we just don't
22//! have its models, and the rest of the catalog still works.
23
24use std::collections::BTreeMap;
25use std::time::Duration;
26
27use crate::catalog::BuiltinModelEntry;
28use serde::Deserialize;
29
30use futures::future::join_all;
31
32/// Maximum time to wait for any single provider's `/v1/models` response.
33const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
34
35/// OpenAI-compatible `/v1/models` response shape.
36///
37/// Most providers (ollama, lmstudio, vllm, sglang, openrouter) follow this format.
38#[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
57/// Discover models from a single OpenAI-compatible endpoint.
58///
59/// Returns an empty Vec if the endpoint is unreachable, the response can't
60/// be parsed, or the timeout is exceeded.
61///
62/// `env_key` is the **name** of the env var holding the API key (e.g.
63/// `OPENAI_API_KEY`). If the env var is set, it's sent as a Bearer token.
64/// If not set, the request is unauthenticated (suitable for local servers
65/// like ollama/lmstudio that don't require auth).
66pub 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(), // runtime providers don't provide display names
125            api: api_type.to_string(),
126            provider: provider_id.to_string(),
127            reasoning: false,           // unknown at runtime
128            input: vec!["text".into()], // most local servers default to text
129            cost_input: 0.0,            // unknown at runtime
130            cost_output: 0.0,
131            cost_cache_read: 0.0,
132            cost_cache_write: 0.0,
133            context_window: 0, // unknown
134            max_tokens: 0,
135            auth_method: crate::catalog::provider::AuthMethod::Bearer,
136            base_url: None,
137        })
138        .collect()
139}
140
141/// Discover models from all known local-runtime providers in parallel.
142///
143/// This is the Layer 3 entry point for **always-on local servers**. It
144/// queries them unconditionally — if the server isn't running, the
145/// call fails fast and we move on. Total wall time is bounded by
146/// `DISCOVERY_TIMEOUT` (~5s) since the queries are in parallel.
147///
148/// The default set:
149/// - ollama (http://localhost:11434/v1)
150/// - lmstudio (http://localhost:1234/v1)
151/// - vllm (http://localhost:8000/v1)
152/// - sglang (http://localhost:30000/v1)
153pub 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                // Local servers typically don't need auth, but some setups
176                // configure one. The user's env var, if set, is sent as a
177                // Bearer token.
178                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
201/// Discover models from authenticated cloud providers whose API key is
202/// in the environment.
203///
204/// This is the openclaw-style **on-demand discovery**: only providers
205/// that have an API key in `std::env` AND a custom `base_url` configured
206/// in `providers.toml` are queried. The list is the openclaw-port
207/// providers that have known `/v1/models` endpoints:
208///
209/// - chutes, deepinfra, gmi, kilocode, novita, nvidia, qwen, stepfun,
210///   byteplus, venice
211///
212/// For each, the function:
213/// 1. Reads `providers.toml` to find the `base_url` and `env_key`.
214/// 2. Skips if `env_key` is not set in the environment.
215/// 3. Skips if `base_url` is empty (use vendor default).
216/// 4. Calls `GET {base_url}/models` with the API key as `Authorization: Bearer`.
217///
218/// All providers are queried in parallel. Total wall time is bounded by
219/// `DISCOVERY_TIMEOUT` (~5s).
220///
221/// The point: when the user has set `NOVITA_API_KEY=...`, we automatically
222/// fetch the live Novita model list (with prices) and merge it into the
223/// catalog. The user's existing built-in Novita TOML is REPLACED by
224/// the live data — this is the intended behavior of Layer 3, which
225/// supersedes Layer 1/2.
226pub async fn discover_all_authenticated() -> BTreeMap<String, Vec<BuiltinModelEntry>> {
227    // Targets: (provider_id, expected_env_key, default_base_url)
228    // default_base_url is used when the provider's TOML doesn't override it.
229    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    // Pre-compute active targets as owned Strings (avoids lifetime issues
264    // when moving into async futures).
265    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
312/// Discover all Layer 3 sources: local servers + authenticated cloud.
313///
314/// This is the high-level entry point. Call once at startup. Both
315/// sub-calls are bounded by `DISCOVERY_TIMEOUT` (~5s) because the
316/// underlying requests are in parallel.
317pub 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        // Use an unroutable address to ensure the request fails fast.
336        let result = discover_models(
337            "test",
338            "openai-completions",
339            "http://127.0.0.1:1/v1", // port 1 is privileged and unused
340            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        // With no env keys set, the function should return an empty map.
349        // We don't unset keys (other tests may need them), but we
350        // check the case where a non-existent key was used.
351        // This test is mainly to verify the function compiles and runs.
352        let result = discover_all_authenticated().await;
353        // Don't assert empty — other tests may have set keys. Just check
354        // it returns a map.
355        let _: std::collections::BTreeMap<String, Vec<_>> = result;
356    }
357}