use async_trait::async_trait;
use pointlock_ir::{RectIR, VerdictStatus};
pub const STUB_REASON: &str = "vision verifier not configured";
#[derive(Debug, Clone, PartialEq)]
pub struct VisionRequest<'a> {
pub prompt: &'a str,
pub region: Option<&'a RectIR>,
pub screenshot: &'a [u8],
pub media_type: &'a str,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisionJudge {
pub provider: String,
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisionVerdict {
pub status: VerdictStatus,
pub reason: String,
pub judge: Option<VisionJudge>,
pub observations: Vec<String>,
}
#[async_trait]
pub trait VisionVerifier: Send + Sync {
async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StubVisionVerifier;
#[async_trait]
impl VisionVerifier for StubVisionVerifier {
async fn verify(&self, _request: VisionRequest<'_>) -> VisionVerdict {
VisionVerdict {
status: VerdictStatus::Unknown,
reason: STUB_REASON.to_owned(),
judge: None,
observations: Vec::new(),
}
}
}
pub const DEFAULT_VISION_MODEL: &str = "claude-opus-4-8";
const REQUEST_TIMEOUT_SECS: u64 = 60;
const CONNECT_TIMEOUT_SECS: u64 = 10;
pub struct AnthropicVisionVerifier {
api_key: String,
model: String,
base_url: String,
client: reqwest::Client,
}
impl AnthropicVisionVerifier {
pub fn new(
api_key: impl Into<String>,
model: impl Into<String>,
base_url: impl Into<String>,
) -> Self {
AnthropicVisionVerifier {
api_key: api_key.into(),
model: model.into(),
base_url: normalize_base_url(base_url.into()),
client: http_client(),
}
}
pub fn from_env() -> Option<Self> {
Self::from_lookup(|key| std::env::var(key).ok())
}
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
let api_key = get("ANTHROPIC_API_KEY").filter(|key| !key.is_empty())?;
let model = get("POINTLOCK_VISION_MODEL")
.filter(|model| !model.is_empty())
.unwrap_or_else(|| DEFAULT_VISION_MODEL.to_owned());
let base_url = get("ANTHROPIC_BASE_URL")
.filter(|url| !url.is_empty())
.unwrap_or_else(|| "https://api.anthropic.com".to_owned());
Some(Self::new(api_key, model, base_url))
}
fn judge(&self) -> VisionJudge {
VisionJudge {
provider: "anthropic".to_owned(),
model: Some(self.model.clone()),
}
}
fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
VisionVerdict {
status: VerdictStatus::Unknown,
reason: reason.into(),
judge: Some(self.judge()),
observations: Vec::new(),
}
}
}
pub const MAX_OBSERVATIONS: usize = 16;
pub const MAX_OBSERVATION_CHARS: usize = 300;
pub const MAX_REASON_CHARS: usize = MAX_OBSERVATION_CHARS;
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
fn normalize_base_url(base_url: String) -> String {
let trimmed = base_url.trim_end_matches('/');
if trimmed.len() == base_url.len() {
base_url
} else {
trimmed.to_owned()
}
}
async fn bounded_body(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
let over = format!("vision response exceeds {MAX_RESPONSE_BYTES} bytes");
if response
.content_length()
.is_some_and(|len| len > MAX_RESPONSE_BYTES as u64)
{
return Err(over);
}
let mut body = Vec::new();
loop {
match response.chunk().await {
Ok(Some(chunk)) => {
body.extend_from_slice(&chunk);
if body.len() > MAX_RESPONSE_BYTES {
return Err(over);
}
}
Ok(None) => return Ok(body),
Err(err) => return Err(format!("vision response unreadable: {err}")),
}
}
}
fn verification_instruction(request: &VisionRequest<'_>) -> String {
let region_note = request.region.map_or(String::new(), |region| {
format!(
" Consider ONLY the region at x={}, y={}, width={}, height={} (pixels from the top-left).",
region.x, region.y, region.width, region.height
)
});
format!(
"You are a visual verification oracle for a device-automation audit trail. \
Judge the following claim against the screenshot.{region_note}\n\
Claim: {}\n\
Answer in EXACTLY this form and nothing else. First, zero or more lines, each:\n\
OBSERVED: <one concrete on-screen fact relevant to the claim>\n\
Then exactly one final line, one of:\n\
PASS: <what you see that confirms it>\n\
FAIL: <what you see that contradicts it>\n\
UNKNOWN: <why it cannot be determined>\n\
Answer UNKNOWN unless the claim is clearly confirmed or clearly contradicted.",
request.prompt
)
}
fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
.no_proxy()
.build()
.expect("reqwest client construction")
}
fn parse_answer(text: &str, judge: &VisionJudge) -> VisionVerdict {
let mut observations: Vec<String> = Vec::new();
let mut verdict_line: Option<&str> = None;
for line in text.trim().lines().map(str::trim) {
if line.is_empty() {
continue;
}
if let Some(fact) = line.strip_prefix("OBSERVED:") {
if observations.len() < MAX_OBSERVATIONS {
observations.push(bounded_chars(fact.trim(), MAX_OBSERVATION_CHARS));
}
continue;
}
verdict_line = Some(line);
break;
}
let unknown = |reason: String, observations: Vec<String>| VisionVerdict {
status: VerdictStatus::Unknown,
reason,
judge: Some(judge.clone()),
observations,
};
let Some(first) = verdict_line else {
return unknown(
"the verifier answer carried no verdict line".to_owned(),
observations,
);
};
let (status, rest) = if let Some(rest) = first.strip_prefix("PASS:") {
(VerdictStatus::Pass, rest)
} else if let Some(rest) = first.strip_prefix("FAIL:") {
(VerdictStatus::Fail, rest)
} else if let Some(rest) = first.strip_prefix("UNKNOWN:") {
(VerdictStatus::Unknown, rest)
} else {
return unknown(
format!("unparseable verifier answer: {first:.120}"),
observations,
);
};
VisionVerdict {
status,
reason: format!("vision: {}", bounded_chars(rest.trim(), MAX_REASON_CHARS)),
judge: Some(judge.clone()),
observations,
}
}
fn bounded_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_owned();
}
let mut bounded: String = value.chars().take(max_chars).collect();
bounded.push('…');
bounded
}
#[async_trait]
impl VisionVerifier for AnthropicVisionVerifier {
async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
let instruction = verification_instruction(&request);
let body = serde_json::json!({
"model": self.model,
"max_tokens": 2048,
"messages": [{
"role": "user",
"content": [
{ "type": "image", "source": {
"type": "base64",
"media_type": request.media_type,
"data": data,
}},
{ "type": "text", "text": instruction },
],
}],
});
let response = match self
.client
.post(format!("{}/v1/messages", self.base_url))
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
{
Ok(response) => response,
Err(err) => return self.unknown(format!("vision transport failed: {err}")),
};
if !response.status().is_success() {
let status = response.status();
let body = bounded_body(response).await.unwrap_or_default();
return self.unknown(format!(
"vision API answered {status}: {:.200}",
String::from_utf8_lossy(&body).trim()
));
}
let body = match bounded_body(response).await {
Ok(body) => body,
Err(reason) => return self.unknown(reason),
};
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
Ok(parsed) => parsed,
Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
};
let text: String = parsed
.get("content")
.and_then(|content| content.as_array())
.map(|blocks| {
blocks
.iter()
.filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text"))
.filter_map(|block| block.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
if text.trim().is_empty() {
return self.unknown("vision response carried no text answer");
}
parse_answer(&text, &self.judge())
}
}
pub struct OpenAiCompatVisionVerifier {
api_key: Option<String>,
model: String,
base_url: String,
client: reqwest::Client,
}
impl OpenAiCompatVisionVerifier {
pub fn new(
api_key: Option<String>,
model: impl Into<String>,
base_url: impl Into<String>,
) -> Self {
OpenAiCompatVisionVerifier {
api_key,
model: model.into(),
base_url: normalize_base_url(base_url.into()),
client: http_client(),
}
}
pub fn from_env() -> Option<Self> {
Self::from_lookup(|key| std::env::var(key).ok())
}
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
let base_url = get("POINTLOCK_VISION_BASE_URL").filter(|url| !url.is_empty())?;
let model = get("POINTLOCK_VISION_MODEL").filter(|model| !model.is_empty())?;
let api_key = get("POINTLOCK_VISION_API_KEY").filter(|key| !key.is_empty());
Some(Self::new(api_key, model, base_url))
}
fn judge(&self) -> VisionJudge {
VisionJudge {
provider: "openai-compat".to_owned(),
model: Some(self.model.clone()),
}
}
fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
VisionVerdict {
status: VerdictStatus::Unknown,
reason: reason.into(),
judge: Some(self.judge()),
observations: Vec::new(),
}
}
}
#[async_trait]
impl VisionVerifier for OpenAiCompatVisionVerifier {
async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
let instruction = verification_instruction(&request);
let body = serde_json::json!({
"model": self.model,
"max_tokens": 2048,
"messages": [{
"role": "user",
"content": [
{ "type": "image_url", "image_url": {
"url": format!("data:{};base64,{data}", request.media_type),
}},
{ "type": "text", "text": instruction },
],
}],
});
let mut post = self
.client
.post(format!("{}/chat/completions", self.base_url))
.json(&body);
if let Some(key) = &self.api_key {
post = post.bearer_auth(key);
}
let response = match post.send().await {
Ok(response) => response,
Err(err) => return self.unknown(format!("vision transport failed: {err}")),
};
if !response.status().is_success() {
let status = response.status();
let body = bounded_body(response).await.unwrap_or_default();
return self.unknown(format!(
"vision API answered {status}: {:.200}",
String::from_utf8_lossy(&body).trim()
));
}
let body = match bounded_body(response).await {
Ok(body) => body,
Err(reason) => return self.unknown(reason),
};
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
Ok(parsed) => parsed,
Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
};
let content = &parsed["choices"][0]["message"]["content"];
let text: String = match content {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Array(parts) => parts
.iter()
.filter(|part| part.get("type").and_then(|t| t.as_str()) == Some("text"))
.filter_map(|part| part.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join(""),
_ => String::new(),
};
if text.trim().is_empty() {
return self.unknown("vision response carried no text answer");
}
parse_answer(&text, &self.judge())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_lookup_requires_a_nonempty_api_key() {
assert!(AnthropicVisionVerifier::from_lookup(|_| None).is_none());
assert!(
AnthropicVisionVerifier::from_lookup(|key| {
(key == "ANTHROPIC_API_KEY").then(String::new)
})
.is_none()
);
}
#[test]
fn from_lookup_honors_the_model_override_and_defaults_without_it() {
let with_override = AnthropicVisionVerifier::from_lookup(|key| match key {
"ANTHROPIC_API_KEY" => Some("k".to_owned()),
"POINTLOCK_VISION_MODEL" => Some("claude-haiku-4-5".to_owned()),
_ => None,
})
.expect("key present");
assert_eq!(with_override.model, "claude-haiku-4-5");
let defaulted = AnthropicVisionVerifier::from_lookup(|key| {
(key == "ANTHROPIC_API_KEY").then(|| "k".to_owned())
})
.expect("key present");
assert_eq!(defaulted.model, DEFAULT_VISION_MODEL);
assert_eq!(defaulted.base_url, "https://api.anthropic.com");
}
#[tokio::test]
async fn stub_always_answers_unknown_with_the_fixed_reason() {
let verdict = StubVisionVerifier
.verify(VisionRequest {
prompt: "the Wi-Fi toggle is visible",
region: None,
screenshot: b"png-bytes",
media_type: "image/png",
})
.await;
assert_eq!(verdict.status, VerdictStatus::Unknown);
assert_eq!(verdict.reason, STUB_REASON);
assert_eq!(verdict.judge, None);
assert!(verdict.observations.is_empty());
}
fn judge() -> VisionJudge {
VisionJudge {
provider: "test".to_owned(),
model: Some("test-model".to_owned()),
}
}
#[test]
fn parse_collects_observations_ahead_of_the_verdict() {
let verdict = parse_answer(
"OBSERVED: the SSID field shows HomeWifi\n\
OBSERVED: the connect button is enabled\n\
PASS: the field shows the requested name",
&judge(),
);
assert_eq!(verdict.status, VerdictStatus::Pass);
assert_eq!(verdict.reason, "vision: the field shows the requested name");
assert_eq!(verdict.judge, Some(judge()));
assert_eq!(
verdict.observations,
vec![
"the SSID field shows HomeWifi".to_owned(),
"the connect button is enabled".to_owned(),
]
);
}
#[test]
fn parse_accepts_a_bare_verdict_without_observed_lines() {
let verdict = parse_answer("FAIL: the field is empty", &judge());
assert_eq!(verdict.status, VerdictStatus::Fail);
assert_eq!(verdict.judge, Some(judge()));
assert!(verdict.observations.is_empty());
}
#[test]
fn parse_fails_closed_but_keeps_observations_without_a_verdict() {
let missing = parse_answer("OBSERVED: a dialog covers the screen", &judge());
assert_eq!(missing.status, VerdictStatus::Unknown);
assert!(
missing.reason.contains("no verdict line"),
"{}",
missing.reason
);
assert_eq!(
missing.observations,
vec!["a dialog covers the screen".to_owned()]
);
let chatty = parse_answer(
"OBSERVED: a dialog covers the screen\nSure! I believe it passes.",
&judge(),
);
assert_eq!(chatty.status, VerdictStatus::Unknown);
assert!(chatty.reason.contains("unparseable"), "{}", chatty.reason);
assert_eq!(
chatty.observations,
vec!["a dialog covers the screen".to_owned()]
);
}
#[test]
fn observation_bounds_cap_count_and_length_on_char_boundaries() {
let mut answer = String::new();
for index in 0..(MAX_OBSERVATIONS + 3) {
answer.push_str(&format!("OBSERVED: fact {index}\n"));
}
answer.push_str("PASS: ok");
let verdict = parse_answer(&answer, &judge());
assert_eq!(verdict.observations.len(), MAX_OBSERVATIONS);
let long = format!(
"OBSERVED: {}\nPASS: ok",
"界".repeat(MAX_OBSERVATION_CHARS + 5)
);
let verdict = parse_answer(&long, &judge());
let kept = &verdict.observations[0];
assert_eq!(kept.chars().count(), MAX_OBSERVATION_CHARS + 1);
assert!(kept.ends_with('…'));
}
#[test]
fn reason_is_bounded_like_observations() {
let long = format!("PASS: {}", "界".repeat(MAX_REASON_CHARS + 5));
let verdict = parse_answer(&long, &judge());
assert_eq!(verdict.status, VerdictStatus::Pass);
let reason = verdict.reason.strip_prefix("vision: ").expect("prefix");
assert_eq!(reason.chars().count(), MAX_REASON_CHARS + 1);
assert!(reason.ends_with('…'));
}
#[test]
fn trailing_slashes_are_trimmed_from_base_urls() {
let anthropic = AnthropicVisionVerifier::new("k", "m", "https://api.anthropic.com/");
assert_eq!(anthropic.base_url, "https://api.anthropic.com");
let anthropic = AnthropicVisionVerifier::from_lookup(|key| match key {
"ANTHROPIC_API_KEY" => Some("k".to_owned()),
"ANTHROPIC_BASE_URL" => Some("http://127.0.0.1:1//".to_owned()),
_ => None,
})
.expect("key present");
assert_eq!(anthropic.base_url, "http://127.0.0.1:1");
let openai = OpenAiCompatVisionVerifier::new(None, "m", "http://127.0.0.1:8000/v1/");
assert_eq!(openai.base_url, "http://127.0.0.1:8000/v1");
}
#[test]
fn openai_from_lookup_requires_base_url_and_model_with_the_key_optional() {
assert!(OpenAiCompatVisionVerifier::from_lookup(|_| None).is_none());
assert!(
OpenAiCompatVisionVerifier::from_lookup(|key| {
(key == "POINTLOCK_VISION_BASE_URL").then(|| "http://127.0.0.1:1/v1".to_owned())
})
.is_none()
);
let keyless = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
"POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
"POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
_ => None,
})
.expect("base url and model present");
assert_eq!(keyless.api_key, None);
assert_eq!(keyless.model, "qwen2.5-vl");
let keyed = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
"POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
"POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
"POINTLOCK_VISION_API_KEY" => Some("k".to_owned()),
_ => None,
})
.expect("all present");
assert_eq!(keyed.api_key.as_deref(), Some("k"));
}
}