1use async_trait::async_trait;
19use pointlock_ir::{RectIR, VerdictStatus};
20
21pub const STUB_REASON: &str = "vision verifier not configured";
23
24#[derive(Debug, Clone, PartialEq)]
30pub struct VisionRequest<'a> {
31 pub prompt: &'a str,
35 pub region: Option<&'a RectIR>,
37 pub screenshot: &'a [u8],
39 pub media_type: &'a str,
41}
42
43#[derive(Debug, Clone, PartialEq)]
46pub struct VisionJudge {
47 pub provider: String,
49 pub model: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq)]
60pub struct VisionVerdict {
61 pub status: VerdictStatus,
63 pub reason: String,
65 pub judge: Option<VisionJudge>,
68 pub observations: Vec<String>,
72}
73
74#[async_trait]
76pub trait VisionVerifier: Send + Sync {
77 async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict;
81}
82
83#[derive(Debug, Clone, Copy, Default)]
85pub struct StubVisionVerifier;
86
87#[async_trait]
88impl VisionVerifier for StubVisionVerifier {
89 async fn verify(&self, _request: VisionRequest<'_>) -> VisionVerdict {
90 VisionVerdict {
91 status: VerdictStatus::Unknown,
92 reason: STUB_REASON.to_owned(),
93 judge: None,
94 observations: Vec::new(),
95 }
96 }
97}
98
99pub const DEFAULT_VISION_MODEL: &str = "claude-opus-4-8";
103
104const REQUEST_TIMEOUT_SECS: u64 = 60;
110
111const CONNECT_TIMEOUT_SECS: u64 = 10;
114
115pub struct AnthropicVisionVerifier {
123 api_key: String,
124 model: String,
125 base_url: String,
126 client: reqwest::Client,
127}
128
129impl AnthropicVisionVerifier {
130 pub fn new(
132 api_key: impl Into<String>,
133 model: impl Into<String>,
134 base_url: impl Into<String>,
135 ) -> Self {
136 AnthropicVisionVerifier {
137 api_key: api_key.into(),
138 model: model.into(),
139 base_url: normalize_base_url(base_url.into()),
140 client: http_client(),
141 }
142 }
143
144 pub fn from_env() -> Option<Self> {
150 Self::from_lookup(|key| std::env::var(key).ok())
151 }
152
153 fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
156 let api_key = get("ANTHROPIC_API_KEY").filter(|key| !key.is_empty())?;
157 let model = get("POINTLOCK_VISION_MODEL")
158 .filter(|model| !model.is_empty())
159 .unwrap_or_else(|| DEFAULT_VISION_MODEL.to_owned());
160 let base_url = get("ANTHROPIC_BASE_URL")
161 .filter(|url| !url.is_empty())
162 .unwrap_or_else(|| "https://api.anthropic.com".to_owned());
163 Some(Self::new(api_key, model, base_url))
164 }
165
166 fn judge(&self) -> VisionJudge {
167 VisionJudge {
168 provider: "anthropic".to_owned(),
169 model: Some(self.model.clone()),
170 }
171 }
172
173 fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
174 VisionVerdict {
175 status: VerdictStatus::Unknown,
176 reason: reason.into(),
177 judge: Some(self.judge()),
178 observations: Vec::new(),
179 }
180 }
181}
182
183pub const MAX_OBSERVATIONS: usize = 16;
187pub const MAX_OBSERVATION_CHARS: usize = 300;
189
190pub const MAX_REASON_CHARS: usize = MAX_OBSERVATION_CHARS;
194
195const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
198
199fn normalize_base_url(base_url: String) -> String {
202 let trimmed = base_url.trim_end_matches('/');
203 if trimmed.len() == base_url.len() {
204 base_url
205 } else {
206 trimmed.to_owned()
207 }
208}
209
210async fn bounded_body(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
213 let over = format!("vision response exceeds {MAX_RESPONSE_BYTES} bytes");
214 if response
215 .content_length()
216 .is_some_and(|len| len > MAX_RESPONSE_BYTES as u64)
217 {
218 return Err(over);
219 }
220 let mut body = Vec::new();
221 loop {
222 match response.chunk().await {
223 Ok(Some(chunk)) => {
224 body.extend_from_slice(&chunk);
225 if body.len() > MAX_RESPONSE_BYTES {
226 return Err(over);
227 }
228 }
229 Ok(None) => return Ok(body),
230 Err(err) => return Err(format!("vision response unreadable: {err}")),
231 }
232 }
233}
234
235fn verification_instruction(request: &VisionRequest<'_>) -> String {
241 let region_note = request.region.map_or(String::new(), |region| {
242 format!(
243 " Consider ONLY the region at x={}, y={}, width={}, height={} (pixels from the top-left).",
244 region.x, region.y, region.width, region.height
245 )
246 });
247 format!(
248 "You are a visual verification oracle for a device-automation audit trail. \
249 Judge the following claim against the screenshot.{region_note}\n\
250 Claim: {}\n\
251 Answer in EXACTLY this form and nothing else. First, zero or more lines, each:\n\
252 OBSERVED: <one concrete on-screen fact relevant to the claim>\n\
253 Then exactly one final line, one of:\n\
254 PASS: <what you see that confirms it>\n\
255 FAIL: <what you see that contradicts it>\n\
256 UNKNOWN: <why it cannot be determined>\n\
257 Answer UNKNOWN unless the claim is clearly confirmed or clearly contradicted.",
258 request.prompt
259 )
260}
261
262fn http_client() -> reqwest::Client {
268 reqwest::Client::builder()
269 .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
270 .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
271 .no_proxy()
272 .build()
273 .expect("reqwest client construction")
274}
275
276fn parse_answer(text: &str, judge: &VisionJudge) -> VisionVerdict {
284 let mut observations: Vec<String> = Vec::new();
285 let mut verdict_line: Option<&str> = None;
286 for line in text.trim().lines().map(str::trim) {
287 if line.is_empty() {
288 continue;
289 }
290 if let Some(fact) = line.strip_prefix("OBSERVED:") {
291 if observations.len() < MAX_OBSERVATIONS {
292 observations.push(bounded_chars(fact.trim(), MAX_OBSERVATION_CHARS));
293 }
294 continue;
295 }
296 verdict_line = Some(line);
297 break;
298 }
299 let unknown = |reason: String, observations: Vec<String>| VisionVerdict {
300 status: VerdictStatus::Unknown,
301 reason,
302 judge: Some(judge.clone()),
303 observations,
304 };
305 let Some(first) = verdict_line else {
306 return unknown(
307 "the verifier answer carried no verdict line".to_owned(),
308 observations,
309 );
310 };
311 let (status, rest) = if let Some(rest) = first.strip_prefix("PASS:") {
312 (VerdictStatus::Pass, rest)
313 } else if let Some(rest) = first.strip_prefix("FAIL:") {
314 (VerdictStatus::Fail, rest)
315 } else if let Some(rest) = first.strip_prefix("UNKNOWN:") {
316 (VerdictStatus::Unknown, rest)
317 } else {
318 return unknown(
319 format!("unparseable verifier answer: {first:.120}"),
320 observations,
321 );
322 };
323 VisionVerdict {
324 status,
325 reason: format!("vision: {}", bounded_chars(rest.trim(), MAX_REASON_CHARS)),
326 judge: Some(judge.clone()),
327 observations,
328 }
329}
330
331fn bounded_chars(value: &str, max_chars: usize) -> String {
333 if value.chars().count() <= max_chars {
334 return value.to_owned();
335 }
336 let mut bounded: String = value.chars().take(max_chars).collect();
337 bounded.push('…');
338 bounded
339}
340
341#[async_trait]
342impl VisionVerifier for AnthropicVisionVerifier {
343 async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
344 use base64::Engine as _;
345 let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
346 let instruction = verification_instruction(&request);
347 let body = serde_json::json!({
348 "model": self.model,
349 "max_tokens": 2048,
351 "messages": [{
352 "role": "user",
353 "content": [
354 { "type": "image", "source": {
355 "type": "base64",
356 "media_type": request.media_type,
357 "data": data,
358 }},
359 { "type": "text", "text": instruction },
360 ],
361 }],
362 });
363
364 let response = match self
365 .client
366 .post(format!("{}/v1/messages", self.base_url))
367 .header("x-api-key", &self.api_key)
368 .header("anthropic-version", "2023-06-01")
369 .json(&body)
370 .send()
371 .await
372 {
373 Ok(response) => response,
374 Err(err) => return self.unknown(format!("vision transport failed: {err}")),
375 };
376 if !response.status().is_success() {
377 let status = response.status();
378 let body = bounded_body(response).await.unwrap_or_default();
379 return self.unknown(format!(
380 "vision API answered {status}: {:.200}",
381 String::from_utf8_lossy(&body).trim()
382 ));
383 }
384 let body = match bounded_body(response).await {
385 Ok(body) => body,
386 Err(reason) => return self.unknown(reason),
387 };
388 let parsed: serde_json::Value = match serde_json::from_slice(&body) {
389 Ok(parsed) => parsed,
390 Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
391 };
392 let text: String = parsed
394 .get("content")
395 .and_then(|content| content.as_array())
396 .map(|blocks| {
397 blocks
398 .iter()
399 .filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text"))
400 .filter_map(|block| block.get("text").and_then(|t| t.as_str()))
401 .collect::<Vec<_>>()
402 .join("")
403 })
404 .unwrap_or_default();
405 if text.trim().is_empty() {
406 return self.unknown("vision response carried no text answer");
407 }
408 parse_answer(&text, &self.judge())
409 }
410}
411
412pub struct OpenAiCompatVisionVerifier {
422 api_key: Option<String>,
424 model: String,
425 base_url: String,
428 client: reqwest::Client,
429}
430
431impl OpenAiCompatVisionVerifier {
432 pub fn new(
434 api_key: Option<String>,
435 model: impl Into<String>,
436 base_url: impl Into<String>,
437 ) -> Self {
438 OpenAiCompatVisionVerifier {
439 api_key,
440 model: model.into(),
441 base_url: normalize_base_url(base_url.into()),
442 client: http_client(),
443 }
444 }
445
446 pub fn from_env() -> Option<Self> {
452 Self::from_lookup(|key| std::env::var(key).ok())
453 }
454
455 fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
458 let base_url = get("POINTLOCK_VISION_BASE_URL").filter(|url| !url.is_empty())?;
459 let model = get("POINTLOCK_VISION_MODEL").filter(|model| !model.is_empty())?;
460 let api_key = get("POINTLOCK_VISION_API_KEY").filter(|key| !key.is_empty());
461 Some(Self::new(api_key, model, base_url))
462 }
463
464 fn judge(&self) -> VisionJudge {
465 VisionJudge {
466 provider: "openai-compat".to_owned(),
467 model: Some(self.model.clone()),
468 }
469 }
470
471 fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
472 VisionVerdict {
473 status: VerdictStatus::Unknown,
474 reason: reason.into(),
475 judge: Some(self.judge()),
476 observations: Vec::new(),
477 }
478 }
479}
480
481#[async_trait]
482impl VisionVerifier for OpenAiCompatVisionVerifier {
483 async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
484 use base64::Engine as _;
485 let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
486 let instruction = verification_instruction(&request);
487 let body = serde_json::json!({
488 "model": self.model,
489 "max_tokens": 2048,
491 "messages": [{
492 "role": "user",
493 "content": [
494 { "type": "image_url", "image_url": {
495 "url": format!("data:{};base64,{data}", request.media_type),
496 }},
497 { "type": "text", "text": instruction },
498 ],
499 }],
500 });
501
502 let mut post = self
503 .client
504 .post(format!("{}/chat/completions", self.base_url))
505 .json(&body);
506 if let Some(key) = &self.api_key {
507 post = post.bearer_auth(key);
508 }
509 let response = match post.send().await {
510 Ok(response) => response,
511 Err(err) => return self.unknown(format!("vision transport failed: {err}")),
512 };
513 if !response.status().is_success() {
514 let status = response.status();
515 let body = bounded_body(response).await.unwrap_or_default();
516 return self.unknown(format!(
517 "vision API answered {status}: {:.200}",
518 String::from_utf8_lossy(&body).trim()
519 ));
520 }
521 let body = match bounded_body(response).await {
522 Ok(body) => body,
523 Err(reason) => return self.unknown(reason),
524 };
525 let parsed: serde_json::Value = match serde_json::from_slice(&body) {
526 Ok(parsed) => parsed,
527 Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
528 };
529 let content = &parsed["choices"][0]["message"]["content"];
533 let text: String = match content {
534 serde_json::Value::String(text) => text.clone(),
535 serde_json::Value::Array(parts) => parts
536 .iter()
537 .filter(|part| part.get("type").and_then(|t| t.as_str()) == Some("text"))
538 .filter_map(|part| part.get("text").and_then(|t| t.as_str()))
539 .collect::<Vec<_>>()
540 .join(""),
541 _ => String::new(),
542 };
543 if text.trim().is_empty() {
544 return self.unknown("vision response carried no text answer");
545 }
546 parse_answer(&text, &self.judge())
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn from_lookup_requires_a_nonempty_api_key() {
556 assert!(AnthropicVisionVerifier::from_lookup(|_| None).is_none());
557 assert!(
558 AnthropicVisionVerifier::from_lookup(|key| {
559 (key == "ANTHROPIC_API_KEY").then(String::new)
560 })
561 .is_none()
562 );
563 }
564
565 #[test]
566 fn from_lookup_honors_the_model_override_and_defaults_without_it() {
567 let with_override = AnthropicVisionVerifier::from_lookup(|key| match key {
568 "ANTHROPIC_API_KEY" => Some("k".to_owned()),
569 "POINTLOCK_VISION_MODEL" => Some("claude-haiku-4-5".to_owned()),
570 _ => None,
571 })
572 .expect("key present");
573 assert_eq!(with_override.model, "claude-haiku-4-5");
574
575 let defaulted = AnthropicVisionVerifier::from_lookup(|key| {
576 (key == "ANTHROPIC_API_KEY").then(|| "k".to_owned())
577 })
578 .expect("key present");
579 assert_eq!(defaulted.model, DEFAULT_VISION_MODEL);
580 assert_eq!(defaulted.base_url, "https://api.anthropic.com");
581 }
582
583 #[tokio::test]
584 async fn stub_always_answers_unknown_with_the_fixed_reason() {
585 let verdict = StubVisionVerifier
586 .verify(VisionRequest {
587 prompt: "the Wi-Fi toggle is visible",
588 region: None,
589 screenshot: b"png-bytes",
590 media_type: "image/png",
591 })
592 .await;
593 assert_eq!(verdict.status, VerdictStatus::Unknown);
594 assert_eq!(verdict.reason, STUB_REASON);
595 assert_eq!(verdict.judge, None);
597 assert!(verdict.observations.is_empty());
598 }
599
600 fn judge() -> VisionJudge {
601 VisionJudge {
602 provider: "test".to_owned(),
603 model: Some("test-model".to_owned()),
604 }
605 }
606
607 #[test]
608 fn parse_collects_observations_ahead_of_the_verdict() {
609 let verdict = parse_answer(
610 "OBSERVED: the SSID field shows HomeWifi\n\
611 OBSERVED: the connect button is enabled\n\
612 PASS: the field shows the requested name",
613 &judge(),
614 );
615 assert_eq!(verdict.status, VerdictStatus::Pass);
616 assert_eq!(verdict.reason, "vision: the field shows the requested name");
617 assert_eq!(verdict.judge, Some(judge()));
618 assert_eq!(
619 verdict.observations,
620 vec![
621 "the SSID field shows HomeWifi".to_owned(),
622 "the connect button is enabled".to_owned(),
623 ]
624 );
625 }
626
627 #[test]
628 fn parse_accepts_a_bare_verdict_without_observed_lines() {
629 let verdict = parse_answer("FAIL: the field is empty", &judge());
630 assert_eq!(verdict.status, VerdictStatus::Fail);
631 assert_eq!(verdict.judge, Some(judge()));
632 assert!(verdict.observations.is_empty());
633 }
634
635 #[test]
636 fn parse_fails_closed_but_keeps_observations_without_a_verdict() {
637 let missing = parse_answer("OBSERVED: a dialog covers the screen", &judge());
638 assert_eq!(missing.status, VerdictStatus::Unknown);
639 assert!(
640 missing.reason.contains("no verdict line"),
641 "{}",
642 missing.reason
643 );
644 assert_eq!(
645 missing.observations,
646 vec!["a dialog covers the screen".to_owned()]
647 );
648
649 let chatty = parse_answer(
650 "OBSERVED: a dialog covers the screen\nSure! I believe it passes.",
651 &judge(),
652 );
653 assert_eq!(chatty.status, VerdictStatus::Unknown);
654 assert!(chatty.reason.contains("unparseable"), "{}", chatty.reason);
655 assert_eq!(
656 chatty.observations,
657 vec!["a dialog covers the screen".to_owned()]
658 );
659 }
660
661 #[test]
662 fn observation_bounds_cap_count_and_length_on_char_boundaries() {
663 let mut answer = String::new();
664 for index in 0..(MAX_OBSERVATIONS + 3) {
665 answer.push_str(&format!("OBSERVED: fact {index}\n"));
666 }
667 answer.push_str("PASS: ok");
668 let verdict = parse_answer(&answer, &judge());
669 assert_eq!(verdict.observations.len(), MAX_OBSERVATIONS);
670
671 let long = format!(
673 "OBSERVED: {}\nPASS: ok",
674 "界".repeat(MAX_OBSERVATION_CHARS + 5)
675 );
676 let verdict = parse_answer(&long, &judge());
677 let kept = &verdict.observations[0];
678 assert_eq!(kept.chars().count(), MAX_OBSERVATION_CHARS + 1);
679 assert!(kept.ends_with('…'));
680 }
681
682 #[test]
683 fn reason_is_bounded_like_observations() {
684 let long = format!("PASS: {}", "界".repeat(MAX_REASON_CHARS + 5));
685 let verdict = parse_answer(&long, &judge());
686 assert_eq!(verdict.status, VerdictStatus::Pass);
687 let reason = verdict.reason.strip_prefix("vision: ").expect("prefix");
688 assert_eq!(reason.chars().count(), MAX_REASON_CHARS + 1);
689 assert!(reason.ends_with('…'));
690 }
691
692 #[test]
693 fn trailing_slashes_are_trimmed_from_base_urls() {
694 let anthropic = AnthropicVisionVerifier::new("k", "m", "https://api.anthropic.com/");
695 assert_eq!(anthropic.base_url, "https://api.anthropic.com");
696 let anthropic = AnthropicVisionVerifier::from_lookup(|key| match key {
697 "ANTHROPIC_API_KEY" => Some("k".to_owned()),
698 "ANTHROPIC_BASE_URL" => Some("http://127.0.0.1:1//".to_owned()),
699 _ => None,
700 })
701 .expect("key present");
702 assert_eq!(anthropic.base_url, "http://127.0.0.1:1");
703 let openai = OpenAiCompatVisionVerifier::new(None, "m", "http://127.0.0.1:8000/v1/");
704 assert_eq!(openai.base_url, "http://127.0.0.1:8000/v1");
705 }
706
707 #[test]
708 fn openai_from_lookup_requires_base_url_and_model_with_the_key_optional() {
709 assert!(OpenAiCompatVisionVerifier::from_lookup(|_| None).is_none());
710 assert!(
711 OpenAiCompatVisionVerifier::from_lookup(|key| {
712 (key == "POINTLOCK_VISION_BASE_URL").then(|| "http://127.0.0.1:1/v1".to_owned())
713 })
714 .is_none()
715 );
716 let keyless = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
717 "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
718 "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
719 _ => None,
720 })
721 .expect("base url and model present");
722 assert_eq!(keyless.api_key, None);
723 assert_eq!(keyless.model, "qwen2.5-vl");
724 let keyed = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
725 "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
726 "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
727 "POINTLOCK_VISION_API_KEY" => Some("k".to_owned()),
728 _ => None,
729 })
730 .expect("all present");
731 assert_eq!(keyed.api_key.as_deref(), Some("k"));
732 }
733}