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}