返回 CodeWhale
local_ollama.rs
根目录 / crates / tui / src / local_ollama.rs
1 //! Unconfigured first-run adoption of a live local Ollama catalog.
2 //!
3 //! Virgin sessions default to the DeepSeek costume (`deepseek-flash`). When a
4 //! real local daemon answers `GET /api/tags` (or the OpenAI-compat
5 //! `GET /v1/models` roster), the painted route must switch to a tag that
6 //! actually exists. Discovery never replaces an explicit provider/model,
7 //! including a configured route whose credentials need repair.
8
9 use std::time::Duration;
10
11 use codewhale_config::catalog::{
12 CatalogOffering, CatalogSource, ProviderCatalogDelta, base_url_fingerprint, now_unix,
13 };
14 use serde::Deserialize;
15
16 use crate::config::{Config, ProviderKind};
17
18 const TAGS_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
19
20 /// Upper bound on `/api/show` lookups per probe. A developer box can hold
21 /// dozens of tags; ranking needs only the plausible chat candidates.
22 const SHOW_PROBE_LIMIT: usize = 8;
23
24 /// Result of a successful local Ollama tags/models probe.
25 #[derive(Debug, Clone, PartialEq, Eq)]
26 pub(crate) struct LiveLocalOllamaCatalog {
27 pub(crate) endpoint_v1: String,
28 pub(crate) tags: Vec<String>,
29 /// The tag adoption may switch to: a model that can hold a conversation.
30 /// `None` when every live tag is an embedding/reranker model — adopting
31 /// one of those would make every first message fail.
32 pub(crate) chat_tag: Option<String>,
33 }
34
35 impl LiveLocalOllamaCatalog {
36 /// The chat-capable tag to adopt, if the catalog has one.
37 pub(crate) fn preferred_tag(&self) -> Option<&str> {
38 self.chat_tag.as_deref()
39 }
40 }
41
42 /// What `/api/show` reports about one tag. Both fields are optional because
43 /// older daemons omit `capabilities` and some architectures omit a context
44 /// length; a missing fact is unknown, never a "no".
45 #[derive(Debug, Clone, Default, PartialEq, Eq)]
46 pub(crate) struct OllamaTagProfile {
47 pub(crate) capabilities: Option<Vec<String>>,
48 pub(crate) context_length: Option<u64>,
49 }
50
51 impl OllamaTagProfile {
52 fn has_capability(&self, name: &str) -> Option<bool> {
53 self.capabilities
54 .as_ref()
55 .map(|caps| caps.iter().any(|cap| cap.eq_ignore_ascii_case(name)))
56 }
57 }
58
59 /// Name heuristic for tags that cannot chat: embedding and reranking models.
60 /// Used only when the daemon did not report capabilities.
61 pub(crate) fn looks_like_non_chat_tag(tag: &str) -> bool {
62 let lower = tag.to_ascii_lowercase();
63 ["embed", "bge", "rerank", "minilm"]
64 .iter()
65 .any(|needle| lower.contains(needle))
66 }
67
68 fn looks_like_coder_tag(tag: &str) -> bool {
69 let lower = tag.to_ascii_lowercase();
70 lower.contains("coder") || lower.contains("code")
71 }
72
73 /// Pick the tag adoption should switch to.
74 ///
75 /// A tag is a chat candidate when `/api/show` lists `completion`, or — when
76 /// the daemon reported no capabilities — when its name is not an embedding or
77 /// reranker. Among candidates: coder or tool-capable models first, then the
78 /// largest reported context, then alphabetical order for stability.
79 pub(crate) fn choose_chat_tag(
80 tags: &[String],
81 profiles: &std::collections::HashMap<String, OllamaTagProfile>,
82 ) -> Option<String> {
83 let unknown = OllamaTagProfile::default();
84 tags.iter()
85 .filter_map(|tag| {
86 let profile = profiles.get(tag).unwrap_or(&unknown);
87 let chat = match profile.has_capability("completion") {
88 Some(known) => known,
89 None => !looks_like_non_chat_tag(tag),
90 };
91 if !chat {
92 return None;
93 }
94 let preferred =
95 looks_like_coder_tag(tag) || profile.has_capability("tools").unwrap_or(false);
96 Some((
97 preferred,
98 profile.context_length.unwrap_or(0),
99 std::cmp::Reverse(tag.as_str()),
100 tag,
101 ))
102 })
103 .max_by(|a, b| (a.0, a.1, &a.2).cmp(&(b.0, b.1, &b.2)))
104 .map(|(_, _, _, tag)| tag.clone())
105 }
106
107 #[derive(Debug, Deserialize)]
108 struct OllamaShowResponse {
109 #[serde(default)]
110 capabilities: Option<Vec<String>>,
111 #[serde(default)]
112 model_info: Option<serde_json::Map<String, serde_json::Value>>,
113 }
114
115 /// Parse `POST /api/show` JSON into the facts adoption ranks on.
116 pub(crate) fn parse_ollama_show_response(payload: &str) -> anyhow::Result<OllamaTagProfile> {
117 let parsed: OllamaShowResponse = serde_json::from_str(payload)
118 .map_err(|err| anyhow::anyhow!("Failed to parse Ollama /api/show JSON: {err}"))?;
119 let context_length = parsed.model_info.as_ref().and_then(|info| {
120 info.iter()
121 .filter(|(key, _)| key.ends_with(".context_length"))
122 .find_map(|(_, value)| value.as_u64())
123 });
124 Ok(OllamaTagProfile {
125 capabilities: parsed.capabilities,
126 context_length,
127 })
128 }
129
130 /// True when this session should adopt a live local catalog into chrome.
131 ///
132 /// An unconfigured first run paints DeepSeek by default; a live local
133 /// roster can replace that default. Explicit routes are left alone,
134 /// and so is a provider picker the person has already started using: the
135 /// probe answers late, and switching provider under them would close the
136 /// picker mid-choice or mid-key.
137 #[must_use]
138 pub(crate) fn should_adopt_live_local_ollama(app: &mut crate::tui::app::App) -> bool {
139 if app.startup_route_configured {
140 return false;
141 }
142 if app.api_provider == ProviderKind::Ollama {
143 // Already on Ollama — route_runtime + #5795 own the tag; don't fight it.
144 return false;
145 }
146 if app.view_stack.provider_picker_interacted() {
147 return false;
148 }
149 app.onboarding_needs_api_key || app.onboarding_missing_key_recovery
150 }
151
152 /// Resolve the OpenAI-compat Ollama base URL (`…/v1`) from config defaults.
153 pub(crate) fn ollama_v1_base_url(config: &Config) -> String {
154 config
155 .resolve_provider_pin_identity("ollama")
156 .ok()
157 .filter(|identity| identity.provider == ProviderKind::Ollama)
158 .map(|identity| config.base_url_for_route(&identity))
159 .unwrap_or_default()
160 }
161
162 /// Strip a trailing `/v1` (with optional slash) so we can hit native `/api/tags`.
163 pub(crate) fn ollama_native_origin(v1_base: &str) -> String {
164 let trimmed = v1_base.trim().trim_end_matches('/');
165 if let Some(origin) = trimmed.strip_suffix("/v1") {
166 origin.to_string()
167 } else {
168 trimmed.to_string()
169 }
170 }
171
172 #[derive(Debug, Deserialize)]
173 struct OllamaTagsResponse {
174 #[serde(default)]
175 models: Vec<OllamaTagModel>,
176 }
177
178 #[derive(Debug, Deserialize)]
179 struct OllamaTagModel {
180 #[serde(default)]
181 name: String,
182 #[serde(default)]
183 model: String,
184 }
185
186 /// Parse native Ollama `GET /api/tags` JSON into sorted unique tag ids.
187 pub(crate) fn parse_ollama_tags_response(payload: &str) -> anyhow::Result<Vec<String>> {
188 let parsed: OllamaTagsResponse = serde_json::from_str(payload)
189 .map_err(|err| anyhow::anyhow!("Failed to parse Ollama /api/tags JSON: {err}"))?;
190 let mut tags: Vec<String> = parsed
191 .models
192 .into_iter()
193 .filter_map(|row| {
194 let name = row.name.trim();
195 if !name.is_empty() {
196 return Some(name.to_string());
197 }
198 let model = row.model.trim();
199 if !model.is_empty() {
200 Some(model.to_string())
201 } else {
202 None
203 }
204 })
205 .collect();
206 tags.sort();
207 tags.dedup();
208 Ok(tags)
209 }
210
211 fn record_ollama_tags_into_lake(endpoint_v1: &str, tags: &[String]) {
212 if tags.is_empty() {
213 return;
214 }
215 let fingerprint = base_url_fingerprint(endpoint_v1);
216 let fetched_at = now_unix();
217 let offerings = tags
218 .iter()
219 .map(|tag| CatalogOffering {
220 provider: "ollama".into(),
221 wire_model_id: tag.clone(),
222 endpoint_key: "chat".into(),
223 source: CatalogSource::Live {
224 base_url_fingerprint: fingerprint.clone(),
225 fetched_at,
226 },
227 default_for_provider: false,
228 ..Default::default()
229 })
230 .collect();
231 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
232 ProviderKind::Ollama,
233 "ollama",
234 endpoint_v1,
235 );
236 let _ = crate::provider_catalog_live::record_success_if_current(
237 &ticket,
238 ProviderCatalogDelta {
239 provider: "ollama".into(),
240 base_url_fingerprint: fingerprint,
241 fetched_at,
242 offerings,
243 },
244 );
245 }
246
247 fn probe_client() -> anyhow::Result<reqwest::Client> {
248 // The first-run probe can run before any provider client has installed
249 // the rustls crypto provider; the shared builder installs it (the bare
250 // `reqwest::Client::builder()` panics under `rustls-no-provider`).
251 Ok(crate::tls::reqwest_client_builder()
252 .timeout(TAGS_PROBE_TIMEOUT)
253 .build()?)
254 }
255
256 async fn fetch_text(url: &str) -> anyhow::Result<String> {
257 let response = probe_client()?.get(url).send().await?;
258 if !response.status().is_success() {
259 anyhow::bail!("HTTP {}", response.status());
260 }
261 Ok(response.text().await?)
262 }
263
264 async fn fetch_tag_profile(origin: &str, tag: &str) -> anyhow::Result<OllamaTagProfile> {
265 let response = probe_client()?
266 .post(format!("{origin}/api/show"))
267 .json(&serde_json::json!({ "model": tag }))
268 .send()
269 .await?;
270 if !response.status().is_success() {
271 anyhow::bail!("HTTP {}", response.status());
272 }
273 parse_ollama_show_response(&response.text().await?)
274 }
275
276 /// Ask `/api/show` about the plausible chat tags. Failures leave a tag
277 /// unprofiled, so the name heuristic decides for it.
278 async fn fetch_tag_profiles(
279 origin: &str,
280 tags: &[String],
281 ) -> std::collections::HashMap<String, OllamaTagProfile> {
282 let candidates: Vec<&String> = tags
283 .iter()
284 .filter(|tag| !looks_like_non_chat_tag(tag))
285 .take(SHOW_PROBE_LIMIT)
286 .collect();
287 let lookups = candidates
288 .iter()
289 .map(|tag| fetch_tag_profile(origin, tag.as_str()));
290 let results = futures_util::future::join_all(lookups).await;
291 candidates
292 .into_iter()
293 .zip(results)
294 .filter_map(|(tag, result)| match result {
295 Ok(profile) => Some((tag.clone(), profile)),
296 Err(err) => {
297 tracing::debug!(
298 target: "local_ollama",
299 error = %err,
300 tag = %tag,
301 "POST /api/show probe failed"
302 );
303 None
304 }
305 })
306 .collect()
307 }
308
309 /// Probe local Ollama for a live catalog. Prefers native `/api/tags`, falls
310 /// back to OpenAI-compat `/v1/models`. Returns `None` when nothing useful
311 /// answered — never invents a tag.
312 pub(crate) async fn probe_live_local_ollama_catalog(
313 config: &Config,
314 ) -> Option<LiveLocalOllamaCatalog> {
315 let endpoint_v1 = ollama_v1_base_url(config);
316 let origin = ollama_native_origin(&endpoint_v1);
317 let tags_url = format!("{origin}/api/tags");
318
319 let tags = match fetch_text(&tags_url).await {
320 Ok(body) => match parse_ollama_tags_response(&body) {
321 Ok(tags) if !tags.is_empty() => tags,
322 Ok(_) => return None,
323 Err(err) => {
324 tracing::debug!(
325 target: "local_ollama",
326 error = %err,
327 "GET /api/tags returned unusable body"
328 );
329 Vec::new()
330 }
331 },
332 Err(err) => {
333 tracing::debug!(
334 target: "local_ollama",
335 error = %err,
336 url = %tags_url,
337 "GET /api/tags probe failed"
338 );
339 Vec::new()
340 }
341 };
342
343 let tags = if tags.is_empty() {
344 // Fallback: OpenAI-compat roster (same tags, different shape).
345 let models_url = format!("{}/models", endpoint_v1.trim_end_matches('/'));
346 match fetch_text(&models_url).await {
347 Ok(body) => match crate::client::parse_models_response(&body) {
348 Ok(models) if !models.is_empty() => models.into_iter().map(|m| m.id).collect(),
349 _ => return None,
350 },
351 Err(_) => return None,
352 }
353 } else {
354 tags
355 };
356
357 record_ollama_tags_into_lake(&endpoint_v1, &tags);
358 let profiles = fetch_tag_profiles(&origin, &tags).await;
359 let chat_tag = choose_chat_tag(&tags, &profiles);
360 Some(LiveLocalOllamaCatalog {
361 endpoint_v1,
362 tags,
363 chat_tag,
364 })
365 }
366
367 /// Env opt-out for harnesses that must not see the developer's machine.
368 ///
369 /// `spawn_local_ollama_adoption_probe` is already inert under `cfg(test)`, but
370 /// the PTY suites spawn the real binary, so that guard never reaches them. A
371 /// developer running Ollama on :11434 therefore gets the launch screen replaced
372 /// by a "Provider switched: deepseek -> ollama" notice, and the PTY tests that
373 /// wait for launch text fail on their machine while CI stays green. Sealing the
374 /// HOME is not enough, because this leak arrives over the loopback network
375 /// rather than through the filesystem.
376 pub(crate) const DISABLE_LOCAL_OLLAMA_PROBE_ENV: &str = "CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE";
377
378 fn local_ollama_probe_disabled() -> bool {
379 std::env::var_os(DISABLE_LOCAL_OLLAMA_PROBE_ENV).is_some_and(|value| !value.is_empty())
380 }
381
382 /// Background probe used by the event loop (mirrors `spawn_startup_version_check`).
383 pub(crate) fn spawn_local_ollama_adoption_probe(
384 config: &Config,
385 should_probe: bool,
386 ) -> Option<tokio::task::JoinHandle<Option<LiveLocalOllamaCatalog>>> {
387 if !should_probe || local_ollama_probe_disabled() {
388 return None;
389 }
390 #[cfg(test)]
391 {
392 let _ = config;
393 None
394 }
395 #[cfg(not(test))]
396 {
397 let config = config.clone();
398 Some(tokio::spawn(async move {
399 probe_live_local_ollama_catalog(&config).await
400 }))
401 }
402 }
403
404 #[cfg(test)]
405 mod tests {
406 use super::*;
407 use crate::test_support::{EnvVarGuard, lock_test_env};
408 use std::collections::HashMap;
409
410 #[test]
411 fn parse_ollama_tags_response_reads_name_field() {
412 let body = r#"{"models":[{"name":"qwen2.5:0.5b","model":"qwen2.5:0.5b","size":0}]}"#;
413 let tags = parse_ollama_tags_response(body).expect("parse");
414 assert_eq!(tags, vec!["qwen2.5:0.5b".to_string()]);
415 }
416
417 #[test]
418 fn parse_ollama_tags_response_sorts_and_dedups() {
419 let body = r#"{"models":[
420 {"name":"zeta:tag"},
421 {"name":"alpha:tag"},
422 {"name":"alpha:tag"}
423 ]}"#;
424 let tags = parse_ollama_tags_response(body).expect("parse");
425 assert_eq!(tags, vec!["alpha:tag".to_string(), "zeta:tag".to_string()]);
426 }
427
428 #[test]
429 fn ollama_native_origin_strips_v1() {
430 assert_eq!(
431 ollama_native_origin("http://localhost:11434/v1"),
432 "http://localhost:11434"
433 );
434 assert_eq!(
435 ollama_native_origin("http://127.0.0.1:11434/v1/"),
436 "http://127.0.0.1:11434"
437 );
438 }
439
440 fn tags(names: &[&str]) -> Vec<String> {
441 let mut tags: Vec<String> = names.iter().map(|name| (*name).to_string()).collect();
442 tags.sort();
443 tags
444 }
445
446 #[test]
447 fn chat_tag_never_picks_an_embedding_model() {
448 // The installed-0.10.0 re-run adopted `nomic-embed-text:latest`
449 // because it sorted first; with no /api/show facts the name decides.
450 let tags = tags(&["qwen2.5-coder:7b", "qwen3:4b", "nomic-embed-text:latest"]);
451 let chosen = choose_chat_tag(&tags, &HashMap::new());
452 assert_eq!(chosen.as_deref(), Some("qwen2.5-coder:7b"));
453 }
454
455 #[test]
456 fn embed_only_catalog_adopts_nothing() {
457 let tags = tags(&[
458 "nomic-embed-text:latest",
459 "bge-m3:latest",
460 "all-minilm:l6-v2",
461 "qllama/bge-reranker-v2-m3:latest",
462 ]);
463 assert_eq!(choose_chat_tag(&tags, &HashMap::new()), None);
464 let catalog = LiveLocalOllamaCatalog {
465 endpoint_v1: "http://localhost:11434/v1".into(),
466 chat_tag: choose_chat_tag(&tags, &HashMap::new()),
467 tags,
468 };
469 assert_eq!(catalog.preferred_tag(), None);
470 }
471
472 #[test]
473 fn reported_capabilities_outrank_the_name_heuristic() {
474 let tags = tags(&["alpha:1b", "mystery:latest", "zeta:8b"]);
475 let mut profiles = HashMap::new();
476 // An embedding model with an innocent name is excluded by its facts.
477 profiles.insert(
478 "alpha:1b".to_string(),
479 OllamaTagProfile {
480 capabilities: Some(vec!["embedding".into()]),
481 context_length: Some(8_192),
482 },
483 );
484 // Tool support is preferred over a larger context without it.
485 profiles.insert(
486 "mystery:latest".to_string(),
487 OllamaTagProfile {
488 capabilities: Some(vec!["completion".into(), "tools".into()]),
489 context_length: Some(32_768),
490 },
491 );
492 profiles.insert(
493 "zeta:8b".to_string(),
494 OllamaTagProfile {
495 capabilities: Some(vec!["completion".into()]),
496 context_length: Some(131_072),
497 },
498 );
499 assert_eq!(
500 choose_chat_tag(&tags, &profiles).as_deref(),
501 Some("mystery:latest")
502 );
503 }
504
505 #[test]
506 fn larger_context_then_name_breaks_ties_among_equals() {
507 let tags = tags(&["b-model:7b", "a-model:7b", "c-model:7b"]);
508 let mut profiles = HashMap::new();
509 profiles.insert(
510 "c-model:7b".to_string(),
511 OllamaTagProfile {
512 capabilities: Some(vec!["completion".into()]),
513 context_length: Some(65_536),
514 },
515 );
516 assert_eq!(
517 choose_chat_tag(&tags, &profiles).as_deref(),
518 Some("c-model:7b")
519 );
520 assert_eq!(
521 choose_chat_tag(&tags, &HashMap::new()).as_deref(),
522 Some("a-model:7b"),
523 "without facts the first chat tag wins, as before"
524 );
525 }
526
527 #[test]
528 fn parse_ollama_show_response_reads_capabilities_and_context() {
529 let body = r#"{
530 "capabilities": ["completion", "tools"],
531 "model_info": {"general.architecture": "qwen2", "qwen2.context_length": 32768}
532 }"#;
533 let profile = parse_ollama_show_response(body).expect("parse");
534 assert_eq!(
535 profile.capabilities,
536 Some(vec!["completion".to_string(), "tools".to_string()])
537 );
538 assert_eq!(profile.context_length, Some(32_768));
539 let legacy = parse_ollama_show_response(r#"{"modelfile":""}"#).expect("parse");
540 assert_eq!(legacy, OllamaTagProfile::default());
541 }
542
543 #[tokio::test]
544 async fn probe_live_local_ollama_catalog_reads_api_tags() {
545 let _lock = lock_test_env();
546 let _live = crate::provider_lake::lock_live_snapshot();
547 let home = tempfile::tempdir().unwrap();
548 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
549 crate::provider_catalog_live::reset_cache_for_test();
550 crate::provider_lake::clear_live_snapshot();
551
552 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
553 let addr = listener.local_addr().unwrap();
554 std::thread::spawn(move || {
555 let (mut stream, _) = listener.accept().unwrap();
556 use std::io::{Read, Write};
557 let mut buf = [0u8; 1024];
558 let _ = stream.read(&mut buf);
559 let body = br#"{"models":[{"name":"qwen2.5:0.5b","model":"qwen2.5:0.5b"}]}"#;
560 let header = format!(
561 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
562 body.len()
563 );
564 stream.write_all(header.as_bytes()).unwrap();
565 stream.write_all(body).unwrap();
566 });
567
568 let endpoint = format!("http://{addr}/v1");
569 let mut config = Config::default();
570 config
571 .provider_config_for_mut(&config.test_identity_for_kind(ProviderKind::Ollama))
572 .unwrap()
573 .base_url = Some(endpoint.clone());
574
575 let catalog = probe_live_local_ollama_catalog(&config)
576 .await
577 .expect("tags probe should succeed");
578 assert_eq!(catalog.endpoint_v1, endpoint);
579 assert_eq!(catalog.tags, vec!["qwen2.5:0.5b".to_string()]);
580 assert_eq!(catalog.preferred_tag(), Some("qwen2.5:0.5b"));
581 }
582 }
583
583 lines RUST