Skip to main content

netget/llm/
model_selection.rs

1//! Model selection utilities for Ollama
2//!
3//! This module provides functions to query available models from Ollama
4//! and select the best one based on size and recency.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use tracing::{debug, info, warn};
9
10/// Information about an Ollama model
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ModelInfo {
13    pub name: String,
14    pub size: u64,
15    pub modified_at: String,
16}
17
18/// Query Ollama for available models and return detailed information
19pub async fn query_available_models(ollama_url: &str) -> Result<Vec<ModelInfo>> {
20    #[cfg(target_arch = "wasm32")]
21    {
22        let _ = ollama_url;
23        anyhow::bail!("the browser build has no Ollama to query; models come from the page");
24    }
25    #[cfg(not(target_arch = "wasm32"))]
26    {
27        // Built through the shared helper, not `Client::new()`: this call has a 5-second timeout,
28        // and pointing it at a literal IP used to spend all five inside `getaddrinfo("127.0.0.1")`
29        // under load. It surfaced as `bluetooth_read_request` handlers "answering" in exactly
30        // 5.003s with zero calls reaching the backend — the read failed closed on a timeout that
31        // was pure name resolution. See `crate::llm::ollama_client::client_for_endpoint`.
32        let client = crate::llm::ollama_client::client_for_endpoint(ollama_url);
33        let url = format!("{}/api/tags", ollama_url);
34        let response = client
35            .get(&url)
36            .timeout(std::time::Duration::from_secs(5))
37            .send()
38            .await
39            .context("Failed to connect to Ollama API. Is Ollama running?")?;
40
41        if !response.status().is_success() {
42            anyhow::bail!("Ollama API returned error status: {}", response.status());
43        }
44
45        let body = crate::client::http_fetch::read_response_text(response, 8 * 1024 * 1024)
46            .await
47            .context("Failed to read response from Ollama")?;
48
49        // Parse the response
50        let json: serde_json::Value =
51            serde_json::from_str(&body).context("Failed to parse Ollama API response")?;
52
53        let models = json
54            .get("models")
55            .and_then(|m| m.as_array())
56            .context("Ollama API response missing 'models' array")?;
57
58        let mut result = Vec::new();
59        for model in models {
60            let name = model
61                .get("name")
62                .and_then(|n| n.as_str())
63                .unwrap_or("")
64                .to_string();
65            let size = model.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
66            let modified_at = model
67                .get("modified_at")
68                .and_then(|m| m.as_str())
69                .unwrap_or("")
70                .to_string();
71
72            if !name.is_empty() {
73                result.push(ModelInfo {
74                    name,
75                    size,
76                    modified_at,
77                });
78            }
79        }
80
81        Ok(result)
82    }
83}
84
85/// Select the best model from available models
86/// Prioritizes: largest size, then most recent modification
87pub fn select_best_model(models: &[ModelInfo]) -> Option<String> {
88    if models.is_empty() {
89        return None;
90    }
91
92    // Sort by size (descending), then by modified_at (descending)
93    let mut sorted = models.to_vec();
94    sorted.sort_by(|a, b| {
95        // First compare by size (larger is better)
96        match b.size.cmp(&a.size) {
97            std::cmp::Ordering::Equal => {
98                // If sizes are equal, compare by modified_at (more recent is better)
99                b.modified_at.cmp(&a.modified_at)
100            }
101            other => other,
102        }
103    });
104
105    debug!(
106        "Available models sorted by size: {:?}",
107        sorted
108            .iter()
109            .map(|m| format!("{} ({} bytes)", m.name, m.size))
110            .collect::<Vec<_>>()
111    );
112
113    Some(sorted[0].name.clone())
114}
115
116/// Check if Ollama is available and return available models
117pub async fn check_ollama_availability(ollama_url: &str) -> Result<Vec<ModelInfo>> {
118    query_available_models(ollama_url)
119        .await
120        .context("Ollama is not available")
121}
122
123/// Select or validate a model for use
124///
125/// # Arguments
126/// * `configured_model` - Model from settings/args (None = auto-select)
127/// * `interactive` - Whether running in interactive mode (affects error handling)
128/// * `ollama_url` - URL of the Ollama server
129///
130/// # Returns
131/// * `Ok(Some(model))` - Model selected and ready to use
132/// * `Ok(None)` - No model available but should continue (interactive mode only)
133/// * `Err(_)` - Critical error, should exit
134pub async fn select_or_validate_model(
135    configured_model: Option<String>,
136    interactive: bool,
137    ollama_url: &str,
138) -> Result<Option<String>> {
139    // Query Ollama for available models
140    let models = match check_ollama_availability(ollama_url).await {
141        Ok(models) => models,
142        Err(e) => {
143            // Name the endpoint. "Ollama is not available" without a host is unactionable
144            // precisely when it matters most — the failure this function had was that it
145            // asked a *different* host than the operator configured, and a message that
146            // omits the host cannot show that.
147            let error_msg = format!(
148                "✗  Ollama is not available at {}: {}\n   Please ensure Ollama is running there: https://ollama.ai\n   Use `/model` to list and select a model once Ollama is running.",
149                ollama_url, e
150            );
151
152            if interactive {
153                // In interactive mode, show warning but allow continuing
154                warn!("{}", error_msg);
155                return Ok(None);
156            } else {
157                // In non-interactive mode, this is a critical error
158                anyhow::bail!("{}", error_msg);
159            }
160        }
161    };
162
163    if models.is_empty() {
164        let error_msg = "✗  No models available in Ollama.\n   Please pull a model first: ollama pull qwen2.5-coder:32b\n   Use `/model` to list and select a model.";
165
166        if interactive {
167            warn!("{}", error_msg);
168            return Ok(None);
169        } else {
170            anyhow::bail!("{}", error_msg);
171        }
172    }
173
174    // If a model is configured, validate it exists
175    if let Some(ref model_name) = configured_model {
176        let model_exists = models.iter().any(|m| m.name == *model_name);
177
178        if !model_exists {
179            let available_models = models
180                .iter()
181                .map(|m| m.name.as_str())
182                .collect::<Vec<_>>()
183                .join(", ");
184
185            let error_msg = format!(
186                "✗  Configured model '{}' not found in Ollama.\n   Available models: {}\n   Use `/model` to select a different model.",
187                model_name, available_models
188            );
189
190            if interactive {
191                warn!("{}", error_msg);
192                // Try to auto-select a different model
193                if let Some(best_model) = select_best_model(&models) {
194                    warn!("⚠  Auto-selecting model: {}", best_model);
195                    return Ok(Some(best_model));
196                }
197                return Ok(None);
198            } else {
199                anyhow::bail!("{}", error_msg);
200            }
201        }
202
203        info!("✓  Using configured model: {}", model_name);
204        return Ok(Some(model_name.clone()));
205    }
206
207    // No model configured, auto-select the best one
208    if let Some(best_model) = select_best_model(&models) {
209        warn!(
210            "⚠  No model configured, auto-selected: {} (largest/most recent)",
211            best_model
212        );
213        info!("   To set a different model, use: /model or edit ~/.netget settings");
214        Ok(Some(best_model))
215    } else {
216        // This shouldn't happen since we checked models.is_empty() above
217        Ok(None)
218    }
219}
220
221/// Ensure we have a model for LLM calls.
222///
223/// If no model is set, auto-selects one from the LLM endpoint **this process was pointed
224/// at** — `llm_endpoint`, which every caller reads from `AppState::get_ollama_url()`.
225/// Returns an error if that endpoint is unreachable or advertises no models.
226///
227/// The endpoint is a parameter rather than a constant because it used to be
228/// `"http://localhost:11434"`, hardcoded, with a comment claiming this function is "typically
229/// called from interactive TUI mode where ollama_url should already be validated during
230/// startup". That was false for three shipped paths, each of which sets the model only
231/// `if let Some(model) = configured_model`: `--mcp`/`--mcp-http`, `netget --client … --connect …`
232/// (`run_client`, which deliberately skips `select_or_validate_model`), and the dashboard,
233/// which stores `None` when `resolve_startup_model` comes back empty. On any of them,
234/// `netget --mcp --ollama-url http://gpu-box:11434` with no `--model` auto-selected against
235/// **localhost** — so it either failed closed with "Failed to ensure model is selected" while a
236/// perfectly good backend sat waiting, or, worse, picked a model name off a local Ollama and
237/// sent it to the remote endpoint, which does not have it.
238///
239/// The symptom is the giveaway and is worth recognising: the endpoint the operator configured
240/// receives **no request at all**, not even a failing one. `tests/model_selection_endpoint_test.rs`
241/// pins this by counting `/api/tags` hits on a mock, which is the only thing that can tell
242/// "asked the right host and it said no" apart from "asked a different host".
243pub async fn ensure_model_selected(
244    current_model: Option<String>,
245    llm_endpoint: &str,
246) -> Result<String> {
247    if let Some(model) = current_model {
248        // Model already set
249        debug!("Model already selected: {}", model);
250        return Ok(model);
251    }
252
253    // No model set, try to auto-select one from the configured endpoint.
254    warn!(
255        "⚠  No model selected, attempting to auto-select from models available at {}...",
256        llm_endpoint
257    );
258
259    match select_or_validate_model(None, false, llm_endpoint).await {
260        Ok(Some(model)) => {
261            info!("✓  Auto-selected model: {}", model);
262            warn!(
263                "⚠  Auto-selected model: {} (no model was configured)",
264                model
265            );
266            Ok(model)
267        }
268        Ok(None) => {
269            anyhow::bail!(
270                "✗  No model available at {}.\n   Please:\n   1. Ensure Ollama is running there: https://ollama.ai\n   2. Pull a model: ollama pull qwen2.5-coder:32b\n   3. Use `/model` to select a model",
271                llm_endpoint
272            )
273        }
274        Err(e) => Err(e),
275    }
276}