Skip to main content

pointlock_vision/
lib.rs

1//! # pointlock-vision
2//!
3//! The [`VisionVerifier`] plugin interface (verify role only, principle 7:
4//! vision never locates or acts) and the default [`StubVisionVerifier`].
5//!
6//! The verifier is the runtime consumer of the `vision` verify channel
7//! (spine §6.3): it answers an author-written prompt against localized
8//! screenshot bytes with a three-valued verdict. A `pass`/`fail` answer is
9//! a *completed* evaluation (fail is final, spine R5); an `unknown` answer
10//! means the channel could not complete and the chain advances — for the
11//! vision channel, which is only legal at the chain tail, that exhausts the
12//! chain into assertion `unknown` (principle 4).
13//!
14//! The v0.1 default is the stub: it always answers `unknown` with the
15//! reason `"vision verifier not configured"`. The runner treats an absent
16//! verifier (`RunOptions::vision == None`) as exactly equivalent.
17
18use async_trait::async_trait;
19use pointlock_ir::{RectIR, VerdictStatus};
20
21/// The reason the stub (and an unconfigured runner) yields `unknown`.
22pub const STUB_REASON: &str = "vision verifier not configured";
23
24/// One vision verification request: the author-written prompt (never
25/// synthesized by the compiler, principle 6), an optional region of
26/// interest, and the *localized* screenshot bytes (evidence is localized
27/// during `observing`, spine §6.6 — the verifier never reaches back into a
28/// provider session).
29#[derive(Debug, Clone, PartialEq)]
30pub struct VisionRequest<'a> {
31    /// The author-written prompt, verbatim (`AssertionIR.visionPrompt` for
32    /// `elementState`/`elementText` chain tails; `predicate.prompt` for
33    /// `visual` predicates).
34    pub prompt: &'a str,
35    /// Optional region of interest (`visual` predicates only).
36    pub region: Option<&'a RectIR>,
37    /// The localized screenshot bytes.
38    pub screenshot: &'a [u8],
39    /// The screenshot's media type, e.g. `image/png`.
40    pub media_type: &'a str,
41}
42
43/// Identity of a vision judge implementation (evidence honesty: once the
44/// model is swappable, a verdict must say which model answered).
45#[derive(Debug, Clone, PartialEq)]
46pub struct VisionJudge {
47    /// Implementation family, e.g. `anthropic` / `openai-compat`.
48    pub provider: String,
49    /// The requested model id, when the implementation has one.
50    pub model: Option<String>,
51}
52
53/// The verifier's three-valued answer with a human-readable reason.
54///
55/// `pass`/`fail` are completed evaluations; `unknown` means the verifier
56/// could not complete (unconfigured, low confidence, unusable image) and
57/// carries the why. The verdict never panics its way out — model/transport
58/// failures inside an implementation must fold to `unknown` (principle 4).
59#[derive(Debug, Clone, PartialEq)]
60pub struct VisionVerdict {
61    /// The three-valued status.
62    pub status: VerdictStatus,
63    /// Why the verifier answered this way.
64    pub reason: String,
65    /// Which judge answered, when a configured implementation did
66    /// (`None` from the stub — nothing judged).
67    pub judge: Option<VisionJudge>,
68    /// The judge's self-reported on-screen facts (the look-then-judge
69    /// answer protocol), bounded by [`MAX_OBSERVATIONS`] and
70    /// [`MAX_OBSERVATION_CHARS`]; empty when none were reported.
71    pub observations: Vec<String>,
72}
73
74/// The vision verification plugin interface (verify role only).
75#[async_trait]
76pub trait VisionVerifier: Send + Sync {
77    /// Answers `request.prompt` against the screenshot. Infallible by
78    /// construction: anything that prevents an answer is an `unknown`
79    /// verdict with a reason, never an error (principle 4).
80    async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict;
81}
82
83/// The v0.1 default verifier: always `unknown` with [`STUB_REASON`].
84#[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
99// ─── The Anthropic-backed verifier (M3a-W4: the first usable one) ───────────
100
101/// The default model of [`AnthropicVisionVerifier`].
102pub const DEFAULT_VISION_MODEL: &str = "claude-opus-4-8";
103
104/// Total per-request deadline. Without one, a TCP-accepted-but-silent
105/// endpoint stalls `verify()` forever — an unbounded await is a failure
106/// mode that never folds to `unknown`, breaking the module contract
107/// (principle 4). The step's `timeout_ms` governs only provider execute,
108/// not the assert phase, so the bound must live here.
109const REQUEST_TIMEOUT_SECS: u64 = 60;
110
111/// Connection-establishment deadline (part of the same fail-to-unknown
112/// bound; kept tighter so dead endpoints answer fast).
113const CONNECT_TIMEOUT_SECS: u64 = 10;
114
115/// The first usable verifier (08 §6.4): asks an Anthropic vision model to
116/// answer the author's prompt against the screenshot, over raw HTTP (no
117/// official Rust SDK exists). Discipline unchanged from the trait docs:
118/// verify-only, chain tail only, and every failure mode — missing key,
119/// transport, non-200, unparseable answer, model uncertainty — folds to
120/// `unknown` with a reason, never an error and never a guessed pass
121/// (principles 4/7).
122pub struct AnthropicVisionVerifier {
123    api_key: String,
124    model: String,
125    base_url: String,
126    client: reqwest::Client,
127}
128
129impl AnthropicVisionVerifier {
130    /// Builds a verifier from explicit configuration.
131    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: base_url.into(),
140            client: http_client(),
141        }
142    }
143
144    /// Builds from the environment: `ANTHROPIC_API_KEY` (required — `None`
145    /// without it), `POINTLOCK_VISION_MODEL` (default
146    /// [`DEFAULT_VISION_MODEL`]), `ANTHROPIC_BASE_URL` (default the public
147    /// API; overriding it is also how the tests run against a local
148    /// canned-response server).
149    pub fn from_env() -> Option<Self> {
150        Self::from_lookup(|key| std::env::var(key).ok())
151    }
152
153    /// The injectable body of [`Self::from_env`] (unit-testable without
154    /// process-global env mutation).
155    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
183/// Cap on self-reported observations kept per answer, and per-observation
184/// character bound — both bound ledger growth: observations enter the
185/// durable assertion record verbatim.
186pub const MAX_OBSERVATIONS: usize = 16;
187/// See [`MAX_OBSERVATIONS`].
188pub const MAX_OBSERVATION_CHARS: usize = 300;
189
190/// The shared verification instruction (the look-then-judge answer
191/// protocol): the judge first lists the on-screen facts it bases its
192/// answer on, then gives exactly one verdict line. The observation lines
193/// become checkable evidence next to the verdict; the pinned vocabulary
194/// keeps parsing fail-closed.
195fn verification_instruction(request: &VisionRequest<'_>) -> String {
196    let region_note = request.region.map_or(String::new(), |region| {
197        format!(
198            " Consider ONLY the region at x={}, y={}, width={}, height={} (pixels from the top-left).",
199            region.x, region.y, region.width, region.height
200        )
201    });
202    format!(
203        "You are a visual verification oracle for a device-automation audit trail. \
204         Judge the following claim against the screenshot.{region_note}\n\
205         Claim: {}\n\
206         Answer in EXACTLY this form and nothing else. First, zero or more lines, each:\n\
207         OBSERVED: <one concrete on-screen fact relevant to the claim>\n\
208         Then exactly one final line, one of:\n\
209         PASS: <what you see that confirms it>\n\
210         FAIL: <what you see that contradicts it>\n\
211         UNKNOWN: <why it cannot be determined>\n\
212         Answer UNKNOWN unless the claim is clearly confirmed or clearly contradicted.",
213        request.prompt
214    )
215}
216
217/// The shared HTTP client of the remote verifiers. Deadlines: see the
218/// timeout consts. `no_proxy` keeps egress deterministic (v0.1 makes no
219/// proxy promise) and the canned local-endpoint tests hermetic under
220/// ambient HTTP(S)_PROXY / ALL_PROXY environments. The `expect` matches
221/// `reqwest::Client::new`'s own panic-on-TLS-init semantics.
222fn http_client() -> reqwest::Client {
223    reqwest::Client::builder()
224        .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
225        .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
226        .no_proxy()
227        .build()
228        .expect("reqwest client construction")
229}
230
231/// Parses one answer under the look-then-judge protocol: zero or more
232/// leading `OBSERVED:` fact lines, then exactly one verdict line. A
233/// missing or malformed verdict line parses to `unknown` (fail-closed —
234/// a chatty answer is not a verdict); lines after the verdict are
235/// ignored. Observations are bounded before they can enter any durable
236/// record, and ride along even on a failed parse (they are still what
237/// the judge reported seeing).
238fn parse_answer(text: &str, judge: &VisionJudge) -> VisionVerdict {
239    let mut observations: Vec<String> = Vec::new();
240    let mut verdict_line: Option<&str> = None;
241    for line in text.trim().lines().map(str::trim) {
242        if line.is_empty() {
243            continue;
244        }
245        if let Some(fact) = line.strip_prefix("OBSERVED:") {
246            if observations.len() < MAX_OBSERVATIONS {
247                observations.push(bounded_chars(fact.trim(), MAX_OBSERVATION_CHARS));
248            }
249            continue;
250        }
251        verdict_line = Some(line);
252        break;
253    }
254    let unknown = |reason: String, observations: Vec<String>| VisionVerdict {
255        status: VerdictStatus::Unknown,
256        reason,
257        judge: Some(judge.clone()),
258        observations,
259    };
260    let Some(first) = verdict_line else {
261        return unknown(
262            "the verifier answer carried no verdict line".to_owned(),
263            observations,
264        );
265    };
266    let (status, rest) = if let Some(rest) = first.strip_prefix("PASS:") {
267        (VerdictStatus::Pass, rest)
268    } else if let Some(rest) = first.strip_prefix("FAIL:") {
269        (VerdictStatus::Fail, rest)
270    } else if let Some(rest) = first.strip_prefix("UNKNOWN:") {
271        (VerdictStatus::Unknown, rest)
272    } else {
273        return unknown(
274            format!("unparseable verifier answer: {first:.120}"),
275            observations,
276        );
277    };
278    VisionVerdict {
279        status,
280        reason: format!("vision: {}", rest.trim()),
281        judge: Some(judge.clone()),
282        observations,
283    }
284}
285
286/// Truncates to a character bound on a char boundary, marking the cut.
287fn bounded_chars(value: &str, max_chars: usize) -> String {
288    if value.chars().count() <= max_chars {
289        return value.to_owned();
290    }
291    let mut bounded: String = value.chars().take(max_chars).collect();
292    bounded.push('…');
293    bounded
294}
295
296#[async_trait]
297impl VisionVerifier for AnthropicVisionVerifier {
298    async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
299        use base64::Engine as _;
300        let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
301        let instruction = verification_instruction(&request);
302        let body = serde_json::json!({
303            "model": self.model,
304            // Room for the observation lines ahead of the verdict line.
305            "max_tokens": 2048,
306            "messages": [{
307                "role": "user",
308                "content": [
309                    { "type": "image", "source": {
310                        "type": "base64",
311                        "media_type": request.media_type,
312                        "data": data,
313                    }},
314                    { "type": "text", "text": instruction },
315                ],
316            }],
317        });
318
319        let response = match self
320            .client
321            .post(format!("{}/v1/messages", self.base_url))
322            .header("x-api-key", &self.api_key)
323            .header("anthropic-version", "2023-06-01")
324            .json(&body)
325            .send()
326            .await
327        {
328            Ok(response) => response,
329            Err(err) => return self.unknown(format!("vision transport failed: {err}")),
330        };
331        if !response.status().is_success() {
332            let status = response.status();
333            let body = response.text().await.unwrap_or_default();
334            return self.unknown(format!(
335                "vision API answered {status}: {:.200}",
336                body.trim()
337            ));
338        }
339        let parsed: serde_json::Value = match response.json().await {
340            Ok(parsed) => parsed,
341            Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
342        };
343        // Concatenate the text blocks (thinking blocks are skipped).
344        let text: String = parsed
345            .get("content")
346            .and_then(|content| content.as_array())
347            .map(|blocks| {
348                blocks
349                    .iter()
350                    .filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text"))
351                    .filter_map(|block| block.get("text").and_then(|t| t.as_str()))
352                    .collect::<Vec<_>>()
353                    .join("")
354            })
355            .unwrap_or_default();
356        if text.trim().is_empty() {
357            return self.unknown("vision response carried no text answer");
358        }
359        parse_answer(&text, &self.judge())
360    }
361}
362
363// ─── The OpenAI-compatible verifier (graphics-focused open models) ──────────
364
365/// A verifier over the OpenAI-compatible chat-completions wire format —
366/// how graphics-focused open models (Qwen-VL, GLM-4V, …) are typically
367/// served, both hosted and self-hosted (vLLM). Same discipline as the
368/// Anthropic verifier: verify-only, chain tail only, every failure mode
369/// folds to `unknown` with a reason (principles 4/7), and the same
370/// look-then-judge answer protocol so verdicts stay comparable across
371/// providers.
372pub struct OpenAiCompatVisionVerifier {
373    /// Bearer token; absent for endpoints that need none (local vLLM).
374    api_key: Option<String>,
375    model: String,
376    /// Endpoint base *including* the ecosystem-conventional `/v1`
377    /// (e.g. `http://127.0.0.1:8000/v1`); `/chat/completions` is appended.
378    base_url: String,
379    client: reqwest::Client,
380}
381
382impl OpenAiCompatVisionVerifier {
383    /// Builds a verifier from explicit configuration.
384    pub fn new(
385        api_key: Option<String>,
386        model: impl Into<String>,
387        base_url: impl Into<String>,
388    ) -> Self {
389        OpenAiCompatVisionVerifier {
390            api_key,
391            model: model.into(),
392            base_url: base_url.into(),
393            client: http_client(),
394        }
395    }
396
397    /// Builds from the environment: `POINTLOCK_VISION_BASE_URL` and
398    /// `POINTLOCK_VISION_MODEL` are both required (`None` without them —
399    /// there is no canonical public endpoint or model to default to);
400    /// `POINTLOCK_VISION_API_KEY` is optional (self-hosted endpoints
401    /// commonly need none).
402    pub fn from_env() -> Option<Self> {
403        Self::from_lookup(|key| std::env::var(key).ok())
404    }
405
406    /// The injectable body of [`Self::from_env`] (unit-testable without
407    /// process-global env mutation).
408    fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
409        let base_url = get("POINTLOCK_VISION_BASE_URL").filter(|url| !url.is_empty())?;
410        let model = get("POINTLOCK_VISION_MODEL").filter(|model| !model.is_empty())?;
411        let api_key = get("POINTLOCK_VISION_API_KEY").filter(|key| !key.is_empty());
412        Some(Self::new(api_key, model, base_url))
413    }
414
415    fn judge(&self) -> VisionJudge {
416        VisionJudge {
417            provider: "openai-compat".to_owned(),
418            model: Some(self.model.clone()),
419        }
420    }
421
422    fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
423        VisionVerdict {
424            status: VerdictStatus::Unknown,
425            reason: reason.into(),
426            judge: Some(self.judge()),
427            observations: Vec::new(),
428        }
429    }
430}
431
432#[async_trait]
433impl VisionVerifier for OpenAiCompatVisionVerifier {
434    async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
435        use base64::Engine as _;
436        let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
437        let instruction = verification_instruction(&request);
438        let body = serde_json::json!({
439            "model": self.model,
440            // Room for the observation lines ahead of the verdict line.
441            "max_tokens": 2048,
442            "messages": [{
443                "role": "user",
444                "content": [
445                    { "type": "image_url", "image_url": {
446                        "url": format!("data:{};base64,{data}", request.media_type),
447                    }},
448                    { "type": "text", "text": instruction },
449                ],
450            }],
451        });
452
453        let mut post = self
454            .client
455            .post(format!("{}/chat/completions", self.base_url))
456            .json(&body);
457        if let Some(key) = &self.api_key {
458            post = post.bearer_auth(key);
459        }
460        let response = match post.send().await {
461            Ok(response) => response,
462            Err(err) => return self.unknown(format!("vision transport failed: {err}")),
463        };
464        if !response.status().is_success() {
465            let status = response.status();
466            let body = response.text().await.unwrap_or_default();
467            return self.unknown(format!(
468                "vision API answered {status}: {:.200}",
469                body.trim()
470            ));
471        }
472        let parsed: serde_json::Value = match response.json().await {
473            Ok(parsed) => parsed,
474            Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
475        };
476        // `choices[0].message.content` is a string on the chat-completions
477        // format; some servers answer an array of typed parts instead —
478        // accept both, concatenating the text parts.
479        let content = &parsed["choices"][0]["message"]["content"];
480        let text: String = match content {
481            serde_json::Value::String(text) => text.clone(),
482            serde_json::Value::Array(parts) => parts
483                .iter()
484                .filter(|part| part.get("type").and_then(|t| t.as_str()) == Some("text"))
485                .filter_map(|part| part.get("text").and_then(|t| t.as_str()))
486                .collect::<Vec<_>>()
487                .join(""),
488            _ => String::new(),
489        };
490        if text.trim().is_empty() {
491            return self.unknown("vision response carried no text answer");
492        }
493        parse_answer(&text, &self.judge())
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn from_lookup_requires_a_nonempty_api_key() {
503        assert!(AnthropicVisionVerifier::from_lookup(|_| None).is_none());
504        assert!(
505            AnthropicVisionVerifier::from_lookup(|key| {
506                (key == "ANTHROPIC_API_KEY").then(String::new)
507            })
508            .is_none()
509        );
510    }
511
512    #[test]
513    fn from_lookup_honors_the_model_override_and_defaults_without_it() {
514        let with_override = AnthropicVisionVerifier::from_lookup(|key| match key {
515            "ANTHROPIC_API_KEY" => Some("k".to_owned()),
516            "POINTLOCK_VISION_MODEL" => Some("claude-haiku-4-5".to_owned()),
517            _ => None,
518        })
519        .expect("key present");
520        assert_eq!(with_override.model, "claude-haiku-4-5");
521
522        let defaulted = AnthropicVisionVerifier::from_lookup(|key| {
523            (key == "ANTHROPIC_API_KEY").then(|| "k".to_owned())
524        })
525        .expect("key present");
526        assert_eq!(defaulted.model, DEFAULT_VISION_MODEL);
527        assert_eq!(defaulted.base_url, "https://api.anthropic.com");
528    }
529
530    #[tokio::test]
531    async fn stub_always_answers_unknown_with_the_fixed_reason() {
532        let verdict = StubVisionVerifier
533            .verify(VisionRequest {
534                prompt: "the Wi-Fi toggle is visible",
535                region: None,
536                screenshot: b"png-bytes",
537                media_type: "image/png",
538            })
539            .await;
540        assert_eq!(verdict.status, VerdictStatus::Unknown);
541        assert_eq!(verdict.reason, STUB_REASON);
542        // Nothing judged: no judge identity, no observations.
543        assert_eq!(verdict.judge, None);
544        assert!(verdict.observations.is_empty());
545    }
546
547    fn judge() -> VisionJudge {
548        VisionJudge {
549            provider: "test".to_owned(),
550            model: Some("test-model".to_owned()),
551        }
552    }
553
554    #[test]
555    fn parse_collects_observations_ahead_of_the_verdict() {
556        let verdict = parse_answer(
557            "OBSERVED: the SSID field shows HomeWifi\n\
558             OBSERVED: the connect button is enabled\n\
559             PASS: the field shows the requested name",
560            &judge(),
561        );
562        assert_eq!(verdict.status, VerdictStatus::Pass);
563        assert_eq!(verdict.reason, "vision: the field shows the requested name");
564        assert_eq!(verdict.judge, Some(judge()));
565        assert_eq!(
566            verdict.observations,
567            vec![
568                "the SSID field shows HomeWifi".to_owned(),
569                "the connect button is enabled".to_owned(),
570            ]
571        );
572    }
573
574    #[test]
575    fn parse_accepts_a_bare_verdict_without_observed_lines() {
576        let verdict = parse_answer("FAIL: the field is empty", &judge());
577        assert_eq!(verdict.status, VerdictStatus::Fail);
578        assert_eq!(verdict.judge, Some(judge()));
579        assert!(verdict.observations.is_empty());
580    }
581
582    #[test]
583    fn parse_fails_closed_but_keeps_observations_without_a_verdict() {
584        let missing = parse_answer("OBSERVED: a dialog covers the screen", &judge());
585        assert_eq!(missing.status, VerdictStatus::Unknown);
586        assert!(
587            missing.reason.contains("no verdict line"),
588            "{}",
589            missing.reason
590        );
591        assert_eq!(
592            missing.observations,
593            vec!["a dialog covers the screen".to_owned()]
594        );
595
596        let chatty = parse_answer(
597            "OBSERVED: a dialog covers the screen\nSure! I believe it passes.",
598            &judge(),
599        );
600        assert_eq!(chatty.status, VerdictStatus::Unknown);
601        assert!(chatty.reason.contains("unparseable"), "{}", chatty.reason);
602        assert_eq!(
603            chatty.observations,
604            vec!["a dialog covers the screen".to_owned()]
605        );
606    }
607
608    #[test]
609    fn observation_bounds_cap_count_and_length_on_char_boundaries() {
610        let mut answer = String::new();
611        for index in 0..(MAX_OBSERVATIONS + 3) {
612            answer.push_str(&format!("OBSERVED: fact {index}\n"));
613        }
614        answer.push_str("PASS: ok");
615        let verdict = parse_answer(&answer, &judge());
616        assert_eq!(verdict.observations.len(), MAX_OBSERVATIONS);
617
618        // Multi-byte characters must be cut on a char boundary.
619        let long = format!(
620            "OBSERVED: {}\nPASS: ok",
621            "界".repeat(MAX_OBSERVATION_CHARS + 5)
622        );
623        let verdict = parse_answer(&long, &judge());
624        let kept = &verdict.observations[0];
625        assert_eq!(kept.chars().count(), MAX_OBSERVATION_CHARS + 1);
626        assert!(kept.ends_with('…'));
627    }
628
629    #[test]
630    fn openai_from_lookup_requires_base_url_and_model_with_the_key_optional() {
631        assert!(OpenAiCompatVisionVerifier::from_lookup(|_| None).is_none());
632        assert!(
633            OpenAiCompatVisionVerifier::from_lookup(|key| {
634                (key == "POINTLOCK_VISION_BASE_URL").then(|| "http://127.0.0.1:1/v1".to_owned())
635            })
636            .is_none()
637        );
638        let keyless = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
639            "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
640            "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
641            _ => None,
642        })
643        .expect("base url and model present");
644        assert_eq!(keyless.api_key, None);
645        assert_eq!(keyless.model, "qwen2.5-vl");
646        let keyed = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
647            "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
648            "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
649            "POINTLOCK_VISION_API_KEY" => Some("k".to_owned()),
650            _ => None,
651        })
652        .expect("all present");
653        assert_eq!(keyed.api_key.as_deref(), Some("k"));
654    }
655}