metrale_bench/benchmarks/
media_integrity_responses.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The Responses-API and thinking-on parity cells of
4//! `media_integrity` (its `responses` submodule).
5//!
6//! Owner: bench, media integrity.
7//! Invariants: a cell whose request failed never returns `Cell::Pass`.
8
9use super::*;
10
11/// 2026-09-26: A blocking Responses-API request carrying one image.
12pub fn responses_image_request(model: &str, mime: &str, bytes: &[u8], prompt: &str) -> Value {
13    use base64::Engine;
14    let mut uri = format!("data:{mime};base64,");
15    base64::engine::general_purpose::STANDARD.encode_string(bytes, &mut uri);
16    json!({
17        "model": model,
18        "stream": false,
19        "temperature": 0.0,
20        // 2026-09-26: `reasoning.effort` is this surface's thinking control:
21        // `openai/responses_lowering.rs` passes `reasoning` through and sets
22        // `chat_template_kwargs: None`. The budget is generous because with
23        // thinking on the reply can arrive as reasoning with an empty
24        // `output_text`, and a truncated reply would read as a vision failure.
25        "max_output_tokens": 600,
26        "reasoning": {"effort": "low"},
27        "input": [{"role": "user", "content": [
28            {"type": "input_image", "image_url": {"url": uri}},
29            {"type": "input_text", "text": prompt},
30        ]}],
31    })
32}
33
34/// 2026-09-26: The Responses API sees the image, and sizes it the same.
35///
36/// `/v1/responses` has its own content vocabulary (`input_image`,
37/// `input_text`) and its own lowering into the IR
38/// (`openai/responses_lowering.rs`).
39///
40/// The assertion is a difference inside this one surface, not a comparison
41/// with chat completions: the chat-completions legs turn thinking off, this
42/// request asks for `reasoning.effort` "low", so their prompts differ in
43/// envelope as well as image. Two images whose vision-token counts differ by
44/// a known amount both go through Responses; the envelope is the same twice,
45/// so it cancels:
46///
47/// ```text
48///   (tokens_b - tokens_a)  ==  (vision_b - vision_a)
49/// ```
50///
51/// Both must also answer correctly, which shows the pixels arrived rather
52/// than merely being counted. A 404 on the first request is a skip.
53pub async fn responses_parity(
54    handle: &PluginHandle,
55    smaller: Value,
56    larger: Value,
57    expect_delta: usize,
58    want: &(dyn Fn(&str) -> bool + Sync),
59    timeout: Duration,
60) -> Cell {
61    const ID: &str = "responses-api-parity";
62    let a = match http::responses_blocking(handle.target(), &smaller, timeout).await {
63        Ok(o) => o,
64        Err(e) => {
65            let msg = crate::benchmarks::one_line(format!("{e:#}"));
66            return if msg.contains("404") {
67                Cell::Skipped { id: ID, why: msg }
68            } else {
69                Cell::Error { id: ID, msg }
70            };
71        }
72    };
73    let b = match http::responses_blocking(handle.target(), &larger, timeout).await {
74        Ok(o) => o,
75        Err(e) => {
76            return Cell::Error {
77                id: ID,
78                msg: crate::benchmarks::one_line(format!("{e:#}")),
79            };
80        }
81    };
82    let delta = b.prompt_tokens.abs_diff(a.prompt_tokens);
83    if delta != expect_delta {
84        return Cell::Fail {
85            id: ID,
86            detail: format!(
87                "via /v1/responses the two images differ by {delta} prompt tokens, expected \
88                 {expect_delta} ({} and {}) — the image is sized differently on this surface",
89                a.prompt_tokens, b.prompt_tokens
90            ),
91        };
92    }
93    let a_text = a.choices.first().cloned().unwrap_or_default();
94    let b_text = b.choices.first().cloned().unwrap_or_default();
95    if !want(a_text.trim()) || !want(b_text.trim()) {
96        return Cell::Fail {
97            id: ID,
98            detail: format!(
99                "sizing is right but the surface did not answer about the image: \"{}\"",
100                crate::benchmarks::one_line(a_text.chars().take(70).collect::<String>())
101            ),
102        };
103    }
104    Cell::Pass {
105        id: ID,
106        detail: format!(
107            "both images answered, and the image contributes exactly {expect_delta} tokens \
108             ({} vs {})",
109            a.prompt_tokens, b.prompt_tokens
110        ),
111    }
112}
113
114/// 2026-09-26: The same image request with thinking on.
115pub fn thinking_image_request(model: &str, mime: &str, bytes: &[u8], prompt: &str) -> Value {
116    let mut v = image_request(model, mime, bytes, prompt, 600);
117    v["chat_template_kwargs"] = json!({"enable_thinking": true});
118    // 2026-09-26: Generous on purpose: reasoning spends the budget, and a
119    // truncated reply reads as a vision failure when it is only a budget one.
120    v["max_tokens"] = json!(600);
121    v
122}
123
124/// 2026-09-26: Vision with thinking on, through chat completions.
125///
126/// The chat-completions legs built by `image_request` turn thinking off,
127/// because a reasoning block can consume the whole of `max_tokens` and leave
128/// empty content. This leg covers thinking on. The assertion is a difference,
129/// so the unknown thinking overhead cancels: two images whose vision-token
130/// counts differ by a known amount are sent, both with thinking on, and the
131/// gap between their prompt sizes must equal that amount exactly:
132///
133/// ```text
134///   (tokens_b - tokens_a)  ==  (vision_b - vision_a)
135/// ```
136///
137/// Whatever the thinking envelope costs, it costs the same in both, so it
138/// vanishes from the subtraction. A thinking-on path that mis-sized, dropped
139/// or double-counted the image moves it.
140pub async fn thinking_parity(
141    handle: &PluginHandle,
142    smaller: Value,
143    larger: Value,
144    expect_delta: usize,
145    want: &(dyn Fn(&str) -> bool + Sync),
146    timeout: Duration,
147) -> Cell {
148    const ID: &str = "thinking-on-vision";
149    let a = match http::chat_stream(handle.target(), &smaller, timeout).await {
150        Ok(o) => o,
151        Err(e) => {
152            return Cell::Error {
153                id: ID,
154                msg: crate::benchmarks::one_line(format!("{e:#}")),
155            };
156        }
157    };
158    let b = match http::chat_stream(handle.target(), &larger, timeout).await {
159        Ok(o) => o,
160        Err(e) => {
161            return Cell::Error {
162                id: ID,
163                msg: crate::benchmarks::one_line(format!("{e:#}")),
164            };
165        }
166    };
167    let delta = b.prompt_tokens.abs_diff(a.prompt_tokens);
168    if delta != expect_delta {
169        return Cell::Fail {
170            id: ID,
171            detail: format!(
172                "with thinking ON the two images differ by {delta} prompt tokens, expected \
173                 {expect_delta} ({} and {} total) — the image's contribution changes when \
174                 thinking is enabled",
175                a.prompt_tokens, b.prompt_tokens
176            ),
177        };
178    }
179    // 2026-09-26: The model must still answer. An empty reply is reported as
180    // the reasoning block consuming the budget, not as a vision fault.
181    let answered = want(a.text.trim()) && want(b.text.trim());
182    if !answered {
183        let empty = a.text.trim().is_empty() || b.text.trim().is_empty();
184        return Cell::Fail {
185            id: ID,
186            detail: if empty {
187                "geometry is right but a reply came back EMPTY with thinking on — the \
188                 reasoning block consumed the whole token budget"
189                    .to_string()
190            } else {
191                format!(
192                    "geometry is right but the answer is wrong with thinking on: \"{}\"",
193                    crate::benchmarks::one_line(a.text.chars().take(60).collect::<String>())
194                )
195            },
196        };
197    }
198    Cell::Pass {
199        id: ID,
200        detail: format!(
201            "thinking on: both answered, and the image still contributes exactly \
202             {expect_delta} tokens ({} vs {})",
203            a.prompt_tokens, b.prompt_tokens
204        ),
205    }
206}