From 12d7bc14d615b79ba23343d694f73b7b85bfc2df Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 2 Nov 2026 02:08:49 +0700 Subject: [PATCH] Look for AI picks a model that can chat, not an embedding model (#134) Detection picks the first listed model that isn't named for embedding, ranking or speech, and falls back to the first only when that's all there is. Fixes the cause of #127. --- crates/ai/src/detect.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ai/src/detect.rs b/crates/ai/src/detect.rs index baeb4bf6..95f772c6 100644 --- a/crates/ai/src/detect.rs +++ b/crates/ai/src/detect.rs @@ -55,6 +55,16 @@ pub fn port_open(base_url: &str, timeout: Duration) -> bool { addrs.iter().any(|a| TcpStream::connect_timeout(a, timeout).is_ok()) } +/// Words in the names of models that turn text into numbers, rank or transcribe, and can't chat: +/// an embedding model listed first was used for every message and each one failed (#127). +const NOT_CHAT: &[&str] = &["embed", "minilm", "bge-", "rerank", "whisper", "tts"]; + +/// The first model a server lists that can chat (else its first, so something is chosen). +fn chat_model(models: &[String]) -> Option { + let chats = |m: &&String| !NOT_CHAT.iter().any(|w| m.to_ascii_lowercase().contains(w)); + models.iter().find(chats).or(models.first()).cloned() +} + /// One local server: connect, then list models. pub fn probe_local(label: &str, base: &str) -> Option { if !port_open(base, PROBE) { @@ -68,7 +78,7 @@ pub fn probe_local(label: &str, base: &str) -> Option { // No answer in time: it may still be loading a model. Err(_) => Vec::new(), }; - let model = models.first().cloned().unwrap_or_default(); + let model = chat_model(&models).unwrap_or_default(); let reason = match models.len() { 0 => format!("Found {label} running at {base}"), 1 => format!("Found {label} running with 1 model"), @@ -248,6 +258,18 @@ mod tests { assert!(probe_local("Ollama", &serve("200 OK", r#"{"object":"list","data":[]}"#)).is_some(), "running with no model yet"); } + /// #127: an embedding model listed first isn't the one chatted with. + #[test] + fn a_chat_model_is_picked_over_an_embedding_model() { + let names = |l: &[&str]| l.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(chat_model(&names(&["nomic-embed-text:latest", "llama3.2:3b"])).as_deref(), Some("llama3.2:3b")); + assert_eq!(chat_model(&names(&["mxbai-embed-large", "all-MiniLM-L6-v2", "qwen3:8b"])).as_deref(), Some("qwen3:8b")); + assert_eq!(chat_model(&names(&["nomic-embed-text"])).as_deref(), Some("nomic-embed-text"), "something is chosen"); + assert_eq!(chat_model(&[]), None); + let url = crate::tests::serve("200 OK", r#"{"object":"list","data":[{"id":"nomic-embed-text:latest"},{"id":"gemma3:4b"}]}"#); + assert_eq!(probe_local("Ollama", &url).unwrap().suggestion.model, "gemma3:4b"); + } + /// #83: a server set up in OpenCode (here LM Studio on another port) is offered; hosted /// services and broken settings aren't. #[test]