| 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 |