Skip to main content

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;