From 142eb05de86971cd095052d5d5da5ba7cec8d76c Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 1 Nov 2026 15:20:13 +0700 Subject: [PATCH] Another program on a model server's port is no longer offered as an AI (#39) Local model servers were found by an open port alone, so any program listening on 8080 (a common port for local web servers) was offered as "llama.cpp server (local)" with no model, and chatting with it failed with "answered 404: 404 page not found". A local server is now offered only when its model list answers; one that answers with an error or a page that isn't JSON is something else. A server that doesn't answer in time is still offered, as before, since it may be loading. Fixes #30 --- crates/ai/src/detect.rs | 42 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/ai/src/detect.rs b/crates/ai/src/detect.rs index 70d71b23..0ed37235 100644 --- a/crates/ai/src/detect.rs +++ b/crates/ai/src/detect.rs @@ -3,6 +3,7 @@ use crate::openai::{LOCAL_SERVERS, OpenAi}; use crate::{BackendConfig, BackendKind, Detected}; +use craft_anything_evidence::ErrorCode; use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -57,7 +58,14 @@ pub fn probe_local(label: &str, base: &str) -> Option { if !port_open(base, PROBE) { return None; } - let models = OpenAi::compatible(label, base, None, "").list_models(Duration::from_millis(700)).unwrap_or_default(); + let models = match OpenAi::compatible(label, base, None, "").list_models(Duration::from_millis(700)) { + Ok(m) => m, + // Something else listens there (another app's web server on 8080 answering 404): no model + // server, so chats with it could only fail (#30). + Err(e) if e.code == ErrorCode::GarbledOutput || e.details.as_ref().is_some_and(|d| d.get("status").is_some()) => return None, + // No answer in time: it may still be loading a model. + Err(_) => Vec::new(), + }; let model = models.first().cloned().unwrap_or_default(); let reason = match models.len() { 0 => format!("Found {label} running at {base}"), @@ -167,6 +175,38 @@ mod tests { assert!(t.elapsed() < Duration::from_secs(1)); } + /// A stand-in server on a free port that answers every request with `status` and `body`. + fn serve(status: &'static str, body: &'static str) -> String { + use std::io::{Read as _, Write as _}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/v1", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut s in l.incoming().flatten() { + let (mut got, mut buf) = (Vec::new(), [0u8; 4096]); + while !got.windows(4).any(|w| w == b"\r\n\r\n") { + match s.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => got.extend_from_slice(&buf[..n]), + } + } + if !got.is_empty() { + let _ = write!(s, "HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()); + } + } + }); + base + } + + /// #30: another program on a model server's port isn't offered as one. + #[test] + fn only_a_server_that_lists_models_is_offered() { + assert!(probe_local("llama.cpp server", &serve("404 Not Found", "404 page not found")).is_none(), "a Go web server's 404"); + assert!(probe_local("llama.cpp server", &serve("200 OK", "Admin")).is_none(), "a web page"); + let found = probe_local("llama.cpp server", &serve("200 OK", r#"{"object":"list","data":[{"id":"qwen3-8b"}]}"#)).unwrap(); + assert_eq!((found.suggestion.name.as_str(), found.suggestion.model.as_str()), ("llama.cpp server (local)", "qwen3-8b")); + assert!(probe_local("Ollama", &serve("200 OK", r#"{"object":"list","data":[]}"#)).is_some(), "running with no model yet"); + } + #[test] fn whole_scan_is_bounded() { let t = Instant::now();