use super::*;
const DEFAULT_OLLAMA_BASE: &str = "http://localhost:11434";
const DEFAULT_OLLAMA_MODEL: &str = "llama3.2-vision:11b";
const DEFAULT_ANTHROPIC_BASE: &str = "https://api.anthropic.com";
const DEFAULT_ANTHROPIC_MODEL: &str = "claude-haiku-4-5";
const DEFAULT_OPENAI_BASE: &str = "https://api.openai.com";
const DEFAULT_OPENAI_MODEL: &str = "gpt-4o-mini";
const ENV_VLM_ENDPOINT: &str = "CAPTCHAFORGE_VLM_ENDPOINT";
const ENV_VLM_MODEL: &str = "CAPTCHAFORGE_VLM_MODEL";
const ENV_ANTHROPIC_KEY: &str = "ANTHROPIC_API_KEY";
const ENV_OPENAI_KEY: &str = "OPENAI_API_KEY";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VlmProvider {
Ollama,
Anthropic,
OpenAI,
}
impl VlmProvider {
fn default_endpoint(self) -> &'static str {
match self {
Self::Ollama => DEFAULT_OLLAMA_BASE,
Self::Anthropic => DEFAULT_ANTHROPIC_BASE,
Self::OpenAI => DEFAULT_OPENAI_BASE,
}
}
fn default_model(self) -> &'static str {
match self {
Self::Ollama => DEFAULT_OLLAMA_MODEL,
Self::Anthropic => DEFAULT_ANTHROPIC_MODEL,
Self::OpenAI => DEFAULT_OPENAI_MODEL,
}
}
}
#[derive(Debug, Serialize)]
struct OllamaRequest<'a> {
model: &'a str,
prompt: &'a str,
images: Vec<String>,
stream: bool,
}
#[derive(Debug, Deserialize)]
struct OllamaResponse {
response: String,
}
async fn vlm_query(
client: &reqwest::Client,
provider: VlmProvider,
endpoint: &str,
model: &str,
api_key: Option<&str>,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
match provider {
VlmProvider::Ollama => vlm_query_ollama(client, endpoint, model, image_b64, prompt, timeout_ms).await,
VlmProvider::Anthropic => {
let key = api_key.ok_or_else(|| anyhow!("anthropic provider needs api_key"))?;
vlm_query_anthropic(client, endpoint, model, key, image_b64, prompt, timeout_ms).await
}
VlmProvider::OpenAI => {
let key = api_key.ok_or_else(|| anyhow!("openai provider needs api_key"))?;
vlm_query_openai(client, endpoint, model, key, image_b64, prompt, timeout_ms).await
}
}
}
async fn vlm_query_ollama(
client: &reqwest::Client,
endpoint: &str,
model: &str,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
let req = OllamaRequest {
model,
prompt,
images: vec![image_b64.to_string()],
stream: false,
};
let resp = client
.post(format!("{}/api/generate", endpoint.trim_end_matches('/')))
.json(&req)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| anyhow!("ollama request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("ollama returned {}: {}", status, body));
}
let ollama_resp: OllamaResponse = resp
.json()
.await
.map_err(|e| anyhow!("ollama json parse: {}", e))?;
Ok(ollama_resp.response)
}
async fn vlm_query_anthropic(
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: &str,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
let body = serde_json::json!({
"model": model,
"max_tokens": 512,
"messages": [{
"role": "user",
"content": [
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": image_b64 } },
{ "type": "text", "text": prompt },
],
}],
});
let resp = client
.post(format!("{}/v1/messages", endpoint.trim_end_matches('/')))
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| anyhow!("anthropic request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("anthropic returned {}: {}", status, body));
}
let val: serde_json::Value = resp.json().await.map_err(|e| anyhow!("anthropic json parse: {}", e))?;
val["content"]
.as_array()
.and_then(|arr| arr.iter().find(|c| c["type"] == "text"))
.and_then(|c| c["text"].as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("anthropic response missing text content: {val}"))
}
async fn vlm_query_openai(
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: &str,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
let data_url = format!("data:image/png;base64,{}", image_b64);
let body = serde_json::json!({
"model": model,
"max_tokens": 512,
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": prompt },
{ "type": "image_url", "image_url": { "url": data_url } },
],
}],
});
let resp = client
.post(format!("{}/v1/chat/completions", endpoint.trim_end_matches('/')))
.header("authorization", format!("Bearer {}", api_key))
.header("content-type", "application/json")
.json(&body)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| anyhow!("openai request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("openai returned {}: {}", status, body));
}
let val: serde_json::Value = resp.json().await.map_err(|e| anyhow!("openai json parse: {}", e))?;
val["choices"][0]["message"]["content"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("openai response missing content: {val}"))
}
pub(crate) fn extract_json(text: &str) -> Option<&str> {
if let Some(start) = text.find("```json") {
let rest = &text[start + 7..];
if let Some(end) = rest.find("```") {
return Some(rest[..end].trim());
}
}
if let Some(start) = text.find('{') {
let rest = &text[start..];
let mut depth = 0;
let mut end = None;
for (i, c) in rest.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(i + 1);
break;
}
}
_ => {}
}
}
if let Some(e) = end {
return Some(&rest[..e]);
}
}
None
}
pub struct VlmCaptchaSolver {
pub(crate) client: reqwest::Client,
pub(crate) provider: VlmProvider,
pub(crate) endpoint: String,
pub(crate) model: String,
pub(crate) api_key: Option<String>,
pub(crate) config: SolveConfig,
}
impl Default for VlmCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
impl VlmCaptchaSolver {
pub fn new() -> Self {
Self::new_with_env(|name| std::env::var(name).ok())
}
pub(crate) fn new_with_env<F>(env: F) -> Self
where
F: Fn(&str) -> Option<String>,
{
let config = SolveConfig::default();
let api_key_anthropic = env(ENV_ANTHROPIC_KEY).filter(|s| !s.is_empty());
let api_key_openai = env(ENV_OPENAI_KEY).filter(|s| !s.is_empty());
let (provider, api_key) = if let Some(k) = api_key_anthropic {
(VlmProvider::Anthropic, Some(k))
} else if let Some(k) = api_key_openai {
(VlmProvider::OpenAI, Some(k))
} else {
(VlmProvider::Ollama, None)
};
let endpoint = env(ENV_VLM_ENDPOINT)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| provider.default_endpoint().to_string());
let model = env(ENV_VLM_MODEL)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| provider.default_model().to_string());
Self {
client: reqwest::Client::builder()
.timeout(Duration::from_millis(config.client_http_timeout_ms))
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
provider,
endpoint,
model,
api_key,
config,
}
}
pub fn with_provider(mut self, provider: VlmProvider) -> Self {
self.provider = provider;
if self.endpoint.is_empty() {
self.endpoint = provider.default_endpoint().to_string();
}
self
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
self.endpoint = url.into();
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
pub fn grid_prompt(task: &str) -> String {
format!(
r#"You are solving an image-grid CAPTCHA.
Task: {}
Respond with a JSON object exactly like this:
{{"selected": [0,1,2], "confidence": 0.95}}
where `selected` is a list of zero-based tile indices that match the task.
If none match, use an empty list.
Only respond with the JSON object, no extra text."#,
task
)
}
pub fn text_captcha_prompt() -> String {
r#"You are solving a text CAPTCHA.
Read the text in the image and respond with a JSON object exactly like this:
{"text": "answer", "confidence": 0.95}
Only respond with the JSON object, no extra text."#
.to_string()
}
pub fn click_target_prompt(target: &str) -> String {
format!(
r#"You are solving a click-target CAPTCHA.
Click the object described as: "{}"
Respond with a JSON object exactly like this:
{{"x": 123, "y": 456, "confidence": 0.95}}
where x and y are pixel coordinates in the screenshot.
Only respond with the JSON object, no extra text."#,
target
)
}
}
#[async_trait]
impl CaptchaSolver for VlmCaptchaSolver {
fn name(&self) -> &'static str {
"VlmCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::VisionLLM
}
fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
use crate::captcha_detect::DetectedCaptcha;
matches!(
kind,
DetectedCaptcha::RecaptchaV2
| DetectedCaptcha::RecaptchaV3
| DetectedCaptcha::HCaptcha
| DetectedCaptcha::ImageCaptcha
| DetectedCaptcha::Turnstile
| DetectedCaptcha::CanvasCaptcha
| DetectedCaptcha::ShadowDomCaptcha
| DetectedCaptcha::MultiStepCaptcha
| DetectedCaptcha::SliderCaptcha
| DetectedCaptcha::Custom(_)
)
}
async fn solve(
&self,
page: &Page,
captcha_info: &crate::captcha_detect::CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let image_b64 = screenshot_b64(page).await?;
match captcha_info.kind {
crate::captcha_detect::DetectedCaptcha::RecaptchaV2
| crate::captcha_detect::DetectedCaptcha::HCaptcha
| crate::captcha_detect::DetectedCaptcha::ImageCaptcha => {
let task_text = page
.evaluate(
r#"(() => {
const probes = [
'.rc-imageselect-desc-no-canonical', '.rc-imageselect-desc',
'.task-text', '.captcha-prompt',
'#widget > div', '.captcha-task'
];
for (const sel of probes) {
const el = document.querySelector(sel);
if (el && el.textContent && el.textContent.trim().length > 0) {
return el.textContent.trim();
}
}
return 'Select all matching images';
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<String>().ok())
.unwrap_or_else(|| "Select all matching images".to_string());
let prompt = Self::grid_prompt(&task_text);
let raw = vlm_query(
&self.client,
self.provider,
&self.endpoint,
&self.model,
self.api_key.as_deref(),
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let confidence = val["confidence"].as_f64().unwrap_or(0.5) as f32;
let selected: Vec<usize> = val["selected"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_u64().map(|n| n as usize))
.collect();
let click_indices_js = format!(
r#"
((indices) => {{
const probes = [
'.rc-imageselect-tile',
'.task-image',
'.image-task',
'.hcaptcha-checkbox-img',
'.rc-imageselect-table td',
'.hcaptcha-table td',
'.captcha-grid > .tile',
// Generic local-grid selectors: icon picker,
// color picker, image picker. Order matters
// — first non-empty match wins.
'#widget .icon-item',
'#widget .color-item',
'#widget .cell',
'#widget .grid > .cell',
];
let tiles = null;
for (const sel of probes) {{
const els = document.querySelectorAll(sel);
if (els.length > 0) {{ tiles = Array.from(els); break; }}
}}
if (!tiles || tiles.length === 0) return {{ ok: false, count: 0 }};
let clicked = 0;
for (const idx of indices) {{
const el = tiles[idx];
if (!el) continue;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
['mousedown','mouseup','click'].forEach(t => {{
el.dispatchEvent(new MouseEvent(t, {{
bubbles: true, button: 0,
clientX: cx, clientY: cy
}}));
}});
clicked++;
}}
/* Find + click the verify/submit button. */
const verifyProbes = [
'#recaptcha-verify-button',
'.rc-button-default',
'.button-submit',
'[data-pp="submit"]',
'button[type="submit"]',
// Generic verify button used by local-mock
// grid fixtures (icon_selection,
// color_match, image/grid).
'#widget #submit',
'#widget button',
];
for (const sel of verifyProbes) {{
const b = document.querySelector(sel);
if (b) {{ b.click(); break; }}
}}
return {{ ok: true, count: clicked, total: tiles.length }};
}})({selected})
"#,
selected = serde_json::to_string(&selected).unwrap_or_else(|_| "[]".into())
);
let _ = page.evaluate(click_indices_js).await;
let success = confidence > 0.5 && !selected.is_empty();
let cookies = if success {
crate::cookies::capture_from_page(page)
.await
.unwrap_or_default()
} else {
Vec::new()
};
Ok(CaptchaSolveResult {
solution: serde_json::to_string(&selected).unwrap_or_default(),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success,
screenshot: None,
cookies,
verified_outcome: None,
})
}
crate::captcha_detect::DetectedCaptcha::RecaptchaV3
| crate::captcha_detect::DetectedCaptcha::CanvasCaptcha
| crate::captcha_detect::DetectedCaptcha::ShadowDomCaptcha => {
let prompt = Self::text_captcha_prompt();
let raw = vlm_query(
&self.client,
self.provider,
&self.endpoint,
&self.model,
self.api_key.as_deref(),
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let text = val["text"].as_str().unwrap_or("").trim().to_string();
let confidence = val["confidence"].as_f64().unwrap_or(0.5) as f32;
let typed_via_pierce = page
.evaluate(
format!(
r#"((answer) => {{
function* walkAllRoots(root) {{
const queue = [root];
const seen = new WeakSet();
while (queue.length) {{
const r = queue.shift();
if (seen.has(r)) continue;
seen.add(r);
yield r;
const subtree = r.querySelectorAll ? r.querySelectorAll('*') : [];
for (const el of subtree) {{
if (el.shadowRoot) queue.push(el.shadowRoot);
if (el.tagName === 'IFRAME') {{
let inner = null;
try {{ inner = el.contentDocument; }} catch (_) {{}}
if (inner) queue.push(inner);
}}
}}
}}
}}
for (const root of walkAllRoots(document)) {{
let inp = null;
try {{ inp = root.querySelector('input[type=text], input:not([type]), textarea, .rc-response-input, input[name="captcha"], input[name="answer"]'); }} catch(_) {{ continue; }}
if (!inp) continue;
inp.focus(); inp.value = answer;
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
inp.dispatchEvent(new Event('change', {{bubbles: true}}));
return true;
}}
return false;
}})({})"#,
serde_json::to_string(&text).unwrap_or_else(|_| "\"\"".into())
),
)
.await
.and_then(|r| r.into_value::<bool>().map_err(Into::into))
.unwrap_or(false);
if !typed_via_pierce {
if let Ok(input) = page
.find_element("input[type=text], textarea, .rc-response-input")
.await
{
input.click().await.ok();
input.type_str(&text).await.ok();
}
}
let success = confidence > 0.5 && !text.is_empty();
let cookies = if success {
crate::cookies::capture_from_page(page)
.await
.unwrap_or_default()
} else {
Vec::new()
};
Ok(CaptchaSolveResult {
solution: text.clone(),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success,
screenshot: None,
cookies,
verified_outcome: None,
})
}
_ => {
let prompt = Self::click_target_prompt("the CAPTCHA checkbox or submit button");
let raw = vlm_query(
&self.client,
self.provider,
&self.endpoint,
&self.model,
self.api_key.as_deref(),
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let x = val["x"].as_f64().unwrap_or(0.0);
let y = val["y"].as_f64().unwrap_or(0.0);
let confidence = val["confidence"].as_f64().unwrap_or(0.0) as f32;
let viewport = page
.evaluate("window.innerWidth + ',' + window.innerHeight")
.await?
.into_value::<String>()
.unwrap_or_default();
let mut parts = viewport.split(',');
let vw: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1920.0);
let vh: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1080.0);
if x > 0.0 && y > 0.0 && x < vw && y < vh {
crate::behavior::click_realistic(page, x, y).await?;
}
let success = confidence > 0.5;
let cookies = if success {
crate::cookies::capture_from_page(page)
.await
.unwrap_or_default()
} else {
Vec::new()
};
Ok(CaptchaSolveResult {
solution: format!("click:{},{}:{:.2}", x as i64, y as i64, confidence),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success,
screenshot: None,
cookies,
verified_outcome: None,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vlm_solver_defaults_when_env_returns_none() {
let s = VlmCaptchaSolver::new_with_env(|_| None);
assert_eq!(s.endpoint, "http://localhost:11434");
assert_eq!(s.model, "llama3.2-vision:11b");
}
#[test]
fn vlm_solver_env_overrides_defaults() {
let s = VlmCaptchaSolver::new_with_env(|k| match k {
"CAPTCHAFORGE_VLM_ENDPOINT" => Some("http://env-host:11434".into()),
"CAPTCHAFORGE_VLM_MODEL" => Some("env-model:latest".into()),
_ => None,
});
assert_eq!(s.endpoint, "http://env-host:11434");
assert_eq!(s.model, "env-model:latest");
}
#[test]
fn vlm_solver_builder_overrides_env() {
let s = VlmCaptchaSolver::new_with_env(|k| match k {
"CAPTCHAFORGE_VLM_ENDPOINT" => Some("http://env-host:11434".into()),
"CAPTCHAFORGE_VLM_MODEL" => Some("env-model:latest".into()),
_ => None,
})
.with_endpoint("http://builder:11434")
.with_model("builder-model:7b");
assert_eq!(s.endpoint, "http://builder:11434");
assert_eq!(s.model, "builder-model:7b");
}
#[test]
fn vlm_solver_empty_env_falls_through_to_defaults() {
let s = VlmCaptchaSolver::new_with_env(|_| Some(String::new()));
assert_eq!(s.endpoint, "http://localhost:11434");
assert_eq!(s.model, "llama3.2-vision:11b");
}
#[test]
fn vlm_solver_env_constants_match_documented_names() {
assert_eq!(ENV_VLM_ENDPOINT, "CAPTCHAFORGE_VLM_ENDPOINT");
assert_eq!(ENV_VLM_MODEL, "CAPTCHAFORGE_VLM_MODEL");
assert_eq!(ENV_ANTHROPIC_KEY, "ANTHROPIC_API_KEY");
assert_eq!(ENV_OPENAI_KEY, "OPENAI_API_KEY");
}
#[test]
fn vlm_solver_no_api_key_picks_ollama() {
let s = VlmCaptchaSolver::new_with_env(|_| None);
assert_eq!(s.provider, VlmProvider::Ollama);
assert!(s.api_key.is_none());
assert_eq!(s.endpoint, "http://localhost:11434");
assert_eq!(s.model, "llama3.2-vision:11b");
}
#[test]
fn vlm_solver_anthropic_api_key_picks_anthropic() {
let s = VlmCaptchaSolver::new_with_env(|k| match k {
"ANTHROPIC_API_KEY" => Some("sk-ant-test".into()),
_ => None,
});
assert_eq!(s.provider, VlmProvider::Anthropic);
assert_eq!(s.api_key.as_deref(), Some("sk-ant-test"));
assert_eq!(s.endpoint, "https://api.anthropic.com");
assert_eq!(s.model, "claude-haiku-4-5");
}
#[test]
fn vlm_solver_openai_api_key_picks_openai_when_no_anthropic() {
let s = VlmCaptchaSolver::new_with_env(|k| match k {
"OPENAI_API_KEY" => Some("sk-openai-test".into()),
_ => None,
});
assert_eq!(s.provider, VlmProvider::OpenAI);
assert_eq!(s.api_key.as_deref(), Some("sk-openai-test"));
assert_eq!(s.endpoint, "https://api.openai.com");
assert_eq!(s.model, "gpt-4o-mini");
}
#[test]
fn vlm_solver_anthropic_wins_over_openai_when_both_set() {
let s = VlmCaptchaSolver::new_with_env(|k| match k {
"ANTHROPIC_API_KEY" => Some("sk-ant-test".into()),
"OPENAI_API_KEY" => Some("sk-openai-test".into()),
_ => None,
});
assert_eq!(s.provider, VlmProvider::Anthropic);
assert_eq!(s.api_key.as_deref(), Some("sk-ant-test"));
}
#[test]
fn vlm_solver_with_provider_overrides_env_detection() {
let s = VlmCaptchaSolver::new_with_env(|k| match k {
"ANTHROPIC_API_KEY" => Some("sk-ant-test".into()),
_ => None,
})
.with_provider(VlmProvider::Ollama);
assert_eq!(s.provider, VlmProvider::Ollama);
}
#[test]
fn vlm_solver_with_api_key_overrides_env_detection() {
let s = VlmCaptchaSolver::new_with_env(|_| None).with_api_key("override-key");
assert_eq!(s.api_key.as_deref(), Some("override-key"));
}
#[test]
fn vlm_solver_empty_anthropic_key_falls_through_to_ollama() {
let s = VlmCaptchaSolver::new_with_env(|k| match k {
"ANTHROPIC_API_KEY" => Some(String::new()),
_ => None,
});
assert_eq!(s.provider, VlmProvider::Ollama);
}
#[test]
fn vlm_provider_default_endpoints_and_models_consistent() {
assert_eq!(VlmProvider::Ollama.default_endpoint(), DEFAULT_OLLAMA_BASE);
assert_eq!(VlmProvider::Ollama.default_model(), DEFAULT_OLLAMA_MODEL);
assert_eq!(VlmProvider::Anthropic.default_endpoint(), DEFAULT_ANTHROPIC_BASE);
assert_eq!(VlmProvider::Anthropic.default_model(), DEFAULT_ANTHROPIC_MODEL);
assert_eq!(VlmProvider::OpenAI.default_endpoint(), DEFAULT_OPENAI_BASE);
assert_eq!(VlmProvider::OpenAI.default_model(), DEFAULT_OPENAI_MODEL);
}
#[test]
fn vlm_solver_custom_endpoint() {
let s = VlmCaptchaSolver::new().with_endpoint("http://ollama.internal:11434");
assert_eq!(s.endpoint, "http://ollama.internal:11434");
}
#[test]
fn vlm_solver_custom_model() {
let s = VlmCaptchaSolver::new().with_model("llava:13b");
assert_eq!(s.model, "llava:13b");
}
#[test]
fn vlm_solver_chained_builders() {
let s = VlmCaptchaSolver::new()
.with_endpoint("http://gpu-box:11434")
.with_model("llama3.2-vision:72b");
assert_eq!(s.endpoint, "http://gpu-box:11434");
assert_eq!(s.model, "llama3.2-vision:72b");
}
#[test]
fn grid_prompt_includes_task() {
let prompt = VlmCaptchaSolver::grid_prompt("Select all traffic lights");
assert!(prompt.contains("Select all traffic lights"));
assert!(prompt.contains("selected"));
assert!(prompt.contains("confidence"));
}
#[test]
fn text_captcha_prompt_is_valid() {
let prompt = VlmCaptchaSolver::text_captcha_prompt();
assert!(prompt.contains("text"));
assert!(prompt.contains("confidence"));
}
#[test]
fn click_target_prompt_includes_target() {
let prompt = VlmCaptchaSolver::click_target_prompt("submit button");
assert!(prompt.contains("submit button"));
assert!(prompt.contains('"'));
}
#[test]
fn extract_json_from_plain_text() {
let s = r#"Some preamble {"key": "value"} trailing"#;
assert_eq!(extract_json(s), Some(r#"{"key": "value"}"#));
}
#[test]
fn extract_json_from_markdown_fence() {
let s = "```json\n{\"answer\": 42}\n```";
assert_eq!(extract_json(s), Some("{\"answer\": 42}"));
}
#[test]
fn extract_json_no_json_returns_none() {
assert_eq!(extract_json("no json here"), None);
}
}