captchaforge/backends.rs
1//! Auto-detect available solver backends and configure the chain.
2//!
3//! Production captchaforge ergonomics: a developer should be able to
4//! `cargo add captchaforge && captchaforge::auto_solve(page).await` and
5//! have it Just Work, no manual VLM endpoint configuration, no STT
6//! server URLs, no tesseract path hunting. This module does the
7//! probing and surfaces the result as [`Capabilities`].
8//!
9//! # What gets probed
10//!
11//! - **Ollama**: HTTP probe `localhost:11434/api/tags`. If reachable,
12//! the response lists locally-pulled models. We pick the first
13//! model whose name matches a known vision-model pattern
14//! (`qwen3-vl`, `llava`, `bakllava`, `qwen2-vl`,
15//! `qwen3-vl`, `gemma3-vl`, `pixtral`, `minicpm-v`).
16//! - **Whisper CLI**: `which whisper` on PATH. The OpenAI Whisper
17//! reference CLI; `whisper <audio.mp3> --model tiny` returns a
18//! transcript on stdout.
19//! - **Tesseract**: `which tesseract` on PATH. Used for canvas /
20//! SVG / static-text CAPTCHAs.
21//! - **OpenAI**: `OPENAI_API_KEY` env var. If set, OpenAI Whisper
22//! API is available as STT fallback.
23//!
24//! # Auto-install
25//!
26//! [`Capabilities::ensure_vlm_model`] will `ollama pull` a default
27//! vision model if Ollama is present but no vision model is. Disabled
28//! by default; opt in by calling `ensure_vlm_model()` directly.
29//!
30//! # Example
31//!
32//! ```rust,no_run
33//! # async fn run() {
34//! let caps = captchaforge::backends::probe().await;
35//! if caps.vlm.is_some() {
36//! println!("VLM ready: {}", caps.vlm.as_ref().unwrap().model);
37//! }
38//! # }
39//! ```
40
41use std::time::Duration;
42
43use serde::{Deserialize, Serialize};
44
45/// Set of vendor model-name fragments that identify a vision-capable
46/// Ollama model. Order matters: earlier entries are preferred when
47/// multiple match.
48pub const VISION_MODEL_PATTERNS: &[&str] = &[
49 "qwen3-vl",
50 "llava",
51 "bakllava",
52 "qwen2-vl",
53 "gemma3-vl",
54 "pixtral",
55 "minicpm-v",
56 "moondream",
57];
58
59/// Default vision model to auto-pull when Ollama is present but no
60/// vision model is. Picked for size/quality tradeoff: ~7GB, runs on
61/// most consumer GPUs and decent CPUs.
62pub const DEFAULT_VLM_AUTO_PULL: &str = "qwen3-vl:8b-thinking";
63
64/// Default Whisper model size to use when invoking the local CLI.
65///
66/// `base` (~142MB) reliably transcribes spelled digits where `tiny` often
67/// hallucinates homophones ("four" → "for", "two" → "to/too", "nine" →
68/// "high"). For CAPTCHA audio, usually 3-6 spoken digits, accuracy
69/// matters more than the ~70MB size delta. CPU latency stays under 1s.
70pub const DEFAULT_WHISPER_MODEL: &str = "base";
71
72/// Detected backend capabilities. Populated by [`probe`]; consumed by
73/// [`crate::config::Config::build_chain`] to wire solvers conditionally.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct Capabilities {
76 pub vlm: Option<VlmBackend>,
77 pub stt: Option<SttBackend>,
78 pub ocr: Option<OcrBackend>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct VlmBackend {
83 /// Endpoint for chat-completion (Ollama-compatible).
84 pub endpoint: String,
85 /// Model name to send in the request body.
86 pub model: String,
87 /// Source: `"ollama"`, `"openai"`, etc. (for logging/telemetry).
88 pub source: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SttBackend {
93 pub kind: SttKind,
94 /// For `WhisperCli` this is the path to the binary; for
95 /// `OpenAIApi` the API base URL; for `LocalServer` the HTTP URL.
96 pub endpoint: String,
97 /// For `WhisperCli` the model size (`tiny`/`base`/`small`/...).
98 pub model: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum SttKind {
104 WhisperCli,
105 OpenAIApi,
106 LocalServer,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct OcrBackend {
111 /// Path to the `tesseract` binary.
112 pub binary: String,
113}
114
115/// Probe the local environment for available backends. Cheap (~50ms
116/// total) (three parallel HTTP/PATH checks).
117pub async fn probe() -> Capabilities {
118 let (vlm, stt, ocr) = tokio::join!(probe_vlm(), probe_stt(), probe_ocr());
119 Capabilities { vlm, stt, ocr }
120}
121
122async fn probe_vlm() -> Option<VlmBackend> {
123 let client = match crate::http_client::timed_client(Duration::from_millis(800)) {
124 Ok(c) => c,
125 Err(_) => return None,
126 };
127
128 // Tier 1: Ollama on the default port.
129 let ollama_endpoint = "http://localhost:11434";
130 if let Some(backend) = probe_vlm_ollama(&client, ollama_endpoint).await {
131 return Some(backend);
132 }
133
134 // Tier 2: llama.cpp server on the conventional port (8000).
135 let llama_endpoint = std::env::var("LLAMACPP_VLM_ENDPOINT")
136 .unwrap_or_else(|_| "http://localhost:8000".to_string());
137 if let Some(backend) = probe_vlm_llamacpp(&client, &llama_endpoint).await {
138 return Some(backend);
139 }
140
141 None
142}
143
144async fn probe_vlm_ollama(client: &reqwest::Client, endpoint: &str) -> Option<VlmBackend> {
145 let resp = client
146 .get(format!("{endpoint}/api/tags"))
147 .send()
148 .await
149 .ok()?;
150 if !resp.status().is_success() {
151 return None;
152 }
153 #[derive(Deserialize)]
154 struct OllamaTags {
155 models: Vec<OllamaModel>,
156 }
157 #[derive(Deserialize)]
158 struct OllamaModel {
159 name: String,
160 }
161 let body: OllamaTags = resp.json().await.ok()?;
162 let model = pick_best_vision_model(
163 &body
164 .models
165 .iter()
166 .map(|m| m.name.as_str())
167 .collect::<Vec<_>>(),
168 )?;
169 Some(VlmBackend {
170 endpoint: endpoint.to_string(),
171 model: model.to_string(),
172 source: "ollama".to_string(),
173 })
174}
175
176async fn probe_vlm_llamacpp(client: &reqwest::Client, endpoint: &str) -> Option<VlmBackend> {
177 // llama.cpp server exposes /v1/models (OpenAI-compatible) when running.
178 let resp = client
179 .get(format!("{endpoint}/v1/models"))
180 .send()
181 .await
182 .ok()?;
183 if !resp.status().is_success() {
184 return None;
185 }
186 #[derive(Deserialize)]
187 struct LlamaModels {
188 data: Vec<LlamaModel>,
189 }
190 #[derive(Deserialize)]
191 struct LlamaModel {
192 id: String,
193 }
194 let body: LlamaModels = resp.json().await.ok()?;
195 let model = body.data.first()?;
196 Some(VlmBackend {
197 endpoint: endpoint.to_string(),
198 model: model.id.clone(),
199 source: "llamacpp".to_string(),
200 })
201}
202
203/// Pure helper: pick the best vision model from a list of Ollama
204/// model names by matching against [`VISION_MODEL_PATTERNS`] in
205/// priority order.
206///
207/// # Examples
208///
209/// ```
210/// use captchaforge::backends::pick_best_vision_model;
211/// let names = ["llama3.1:8b", "llava:13b", "qwen2.5-coder:14b"];
212/// assert_eq!(pick_best_vision_model(&names), Some("llava:13b"));
213/// // No vision model → None.
214/// let names = ["llama3.1:8b", "qwen2.5-coder:14b"];
215/// assert_eq!(pick_best_vision_model(&names), None);
216/// // Highest-priority pattern wins (qwen3-vl before llava).
217/// let names = ["llava:13b", "qwen3-vl:8b-thinking"];
218/// assert_eq!(pick_best_vision_model(&names), Some("qwen3-vl:8b-thinking"));
219/// ```
220pub fn pick_best_vision_model<'a>(model_names: &[&'a str]) -> Option<&'a str> {
221 for pattern in VISION_MODEL_PATTERNS {
222 for name in model_names {
223 if name.contains(pattern) {
224 return Some(name);
225 }
226 }
227 }
228 None
229}
230
231async fn probe_stt() -> Option<SttBackend> {
232 // Tier 1: local whisper CLI on PATH.
233 if let Some(path) = which("whisper").or_else(|| which("whisper-cli")) {
234 return Some(SttBackend {
235 kind: SttKind::WhisperCli,
236 endpoint: path,
237 model: Some(DEFAULT_WHISPER_MODEL.to_string()),
238 });
239 }
240 // Tier 2: OpenAI Whisper API via env var.
241 if std::env::var("OPENAI_API_KEY").is_ok() {
242 return Some(SttBackend {
243 kind: SttKind::OpenAIApi,
244 endpoint: "https://api.openai.com/v1/audio/transcriptions".to_string(),
245 model: Some("whisper-1".to_string()),
246 });
247 }
248 // Tier 3: classic local server on the conventional port.
249 let client = crate::http_client::timed_client(Duration::from_millis(300)).ok()?;
250 if client
251 .get("http://localhost:9000/")
252 .send()
253 .await
254 .map(|r| r.status().is_success() || r.status().as_u16() == 405)
255 .unwrap_or(false)
256 {
257 return Some(SttBackend {
258 kind: SttKind::LocalServer,
259 endpoint: "http://localhost:9000/asr".to_string(),
260 model: None,
261 });
262 }
263 None
264}
265
266async fn probe_ocr() -> Option<OcrBackend> {
267 which("tesseract").map(|binary| OcrBackend { binary })
268}
269
270/// Locate a binary on PATH. Returns the absolute path on hit, `None`
271/// on miss. Pure helper used by both `probe_stt` and `probe_ocr`.
272///
273/// # Examples
274///
275/// ```
276/// use captchaforge::backends::which;
277/// // Almost-certainly-present binary on a Unix system.
278/// // Skipped on Windows / locked-down environments.
279/// # if cfg!(unix) {
280/// assert!(which("sh").is_some(), "sh should be on PATH");
281/// # }
282/// // Definitely-not-present binary.
283/// assert!(which("definitely-not-a-real-binary-xyzzy").is_none());
284/// ```
285pub fn which(binary: &str) -> Option<String> {
286 let path = std::env::var_os("PATH")?;
287 for dir in std::env::split_paths(&path) {
288 let candidate = dir.join(binary);
289 if candidate.is_file() {
290 return Some(candidate.to_string_lossy().into_owned());
291 }
292 }
293 None
294}
295
296impl Capabilities {
297 /// Returns true if any solver-backing capability is present. Used
298 /// by the chain to short-circuit the "no backends installed,
299 /// chain will only handle passive Turnstile" log message.
300 pub fn any(&self) -> bool {
301 self.vlm.is_some() || self.stt.is_some() || self.ocr.is_some()
302 }
303
304 /// Human-readable one-liner describing what was detected. Used by
305 /// `auto_solve` to log on first call.
306 pub fn summary(&self) -> String {
307 let mut parts = Vec::new();
308 if let Some(v) = &self.vlm {
309 parts.push(format!("vlm:{} ({})", v.model, v.source));
310 }
311 if let Some(s) = &self.stt {
312 parts.push(format!("stt:{:?}", s.kind));
313 }
314 if self.ocr.is_some() {
315 parts.push("ocr:tesseract".to_string());
316 }
317 if parts.is_empty() {
318 "no backends detected".to_string()
319 } else {
320 parts.join(", ")
321 }
322 }
323
324 /// Returns a one-line install hint per missing backend, suitable
325 /// for surfacing to a user who needs higher solve coverage. Empty
326 /// when every backend is already present.
327 ///
328 /// # Examples
329 ///
330 /// ```
331 /// use captchaforge::backends::Capabilities;
332 /// let caps = Capabilities::default();
333 /// let hints = caps.install_hints();
334 /// assert!(!hints.is_empty(), "no backends ⇒ at least one hint");
335 /// assert!(hints.iter().any(|h| h.contains("ollama")));
336 /// ```
337 pub fn install_hints(&self) -> Vec<String> {
338 let mut out = Vec::new();
339 if self.vlm.is_none() {
340 out.push(
341 "vlm: install ollama (https://ollama.com/download) and run \
342 `ollama pull qwen3-vl:8b-thinking`: captchaforge will then \
343 auto-detect it. Solves canvas / SVG / image-grid CAPTCHAs."
344 .to_string(),
345 );
346 }
347 if self.stt.is_none() {
348 out.push(
349 "stt: install OpenAI Whisper CLI (`pip install -U openai-whisper`) \
350 OR set OPENAI_API_KEY for the hosted Whisper API. Solves \
351 reCAPTCHA v2 audio + generic spoken-digit CAPTCHAs."
352 .to_string(),
353 );
354 }
355 if self.ocr.is_none() {
356 out.push(
357 "ocr: install tesseract (`apt install tesseract-ocr` / \
358 `brew install tesseract`). Used as a fallback when no VLM \
359 is available."
360 .to_string(),
361 );
362 }
363 out
364 }
365
366 /// Auto-install a default vision model when Ollama is reachable
367 /// but no vision model is present. Returns `Ok(true)` if pull
368 /// succeeded, `Ok(false)` if Ollama isn't reachable, or `Err` on
369 /// pull failure.
370 ///
371 /// Spawns `ollama pull <DEFAULT_VLM_AUTO_PULL>`. Pull progress is
372 /// inherited stdio; the call blocks until pull completes (~5-10
373 /// minutes for an 11B model on first run).
374 pub async fn ensure_vlm_model(&mut self) -> anyhow::Result<bool> {
375 if self.vlm.is_some() {
376 return Ok(true);
377 }
378 let ollama = which("ollama");
379 let Some(ollama_bin) = ollama else {
380 return Ok(false);
381 };
382 // Verify Ollama daemon is up before spawning a pull.
383 let client = crate::http_client::timed_client(Duration::from_millis(800))?;
384 if client
385 .get("http://localhost:11434/api/tags")
386 .send()
387 .await
388 .map(|r| !r.status().is_success())
389 .unwrap_or(true)
390 {
391 return Ok(false);
392 }
393 tracing::info!(
394 model = DEFAULT_VLM_AUTO_PULL,
395 "auto-pulling vision model via Ollama (first-run install)"
396 );
397 let status = std::process::Command::new(&ollama_bin)
398 .arg("pull")
399 .arg(DEFAULT_VLM_AUTO_PULL)
400 .status()?;
401 if !status.success() {
402 anyhow::bail!("ollama pull {DEFAULT_VLM_AUTO_PULL} exited {status}");
403 }
404 // Re-probe so the caller sees the new vlm.
405 self.vlm = probe_vlm().await;
406 Ok(self.vlm.is_some())
407 }
408}
409
410#[cfg(test)]
411#[path = "backends/tests.rs"]
412mod tests;