gproxy_protocol/transform/images/
response.rs1use super::request::ImageRequestContext;
2use crate::{
3 transform::{Report, TransformError, TransformErrorKind},
4 wire::{
5 DeclaredFields, gemini as g,
6 openai::{images as o, responses as r},
7 },
8};
9use base64::Engine;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ImageMetadata {
13 pub format: o::ImageOutputFormat,
14 pub width: u32,
15 pub height: u32,
16}
17
18impl ImageMetadata {
19 pub fn mime(&self) -> &'static str {
20 match self.format {
21 o::ImageOutputFormat::Png => "image/png",
22 o::ImageOutputFormat::Jpeg => "image/jpeg",
23 o::ImageOutputFormat::Webp => "image/webp",
24 }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct GeneratedImagePart {
30 pub bytes: bytes::Bytes,
31 pub metadata: ImageMetadata,
32 pub revised_prompt: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub enum ImageUsageFacts {
37 Responses(r::response::ResponseUsage),
38 Gemini(g::UsageMetadata),
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct ImageCallResult {
43 pub image: GeneratedImagePart,
44 pub usage: Option<ImageUsageFacts>,
45 pub response_id: Option<String>,
46 pub model: Option<String>,
47 pub report: Report,
48}
49
50fn invalid(detail: &str) -> TransformError {
51 TransformError::invalid_result("image.output", detail)
52}
53
54fn u32be(b: &[u8]) -> u32 {
55 u32::from_be_bytes(b.try_into().unwrap())
56}
57
58fn u32le(b: &[u8]) -> u32 {
59 u32::from_le_bytes(b.try_into().unwrap())
60}
61
62fn crc32(b: &[u8]) -> u32 {
63 let mut c = !0u32;
64 for x in b {
65 c ^= u32::from(*x);
66 for _ in 0..8 {
67 c = (c >> 1) ^ ((0u32.wrapping_sub(c & 1)) & 0xedb88320);
68 }
69 }
70 !c
71}
72
73pub fn inspect_image(b: &[u8]) -> Result<ImageMetadata, TransformError> {
76 let (format, width, height) = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
77 let mut pos = 8usize;
78 let mut dimensions = None;
79 let mut data = false;
80 let mut ended = false;
81 while pos < b.len() {
82 if b.len() - pos < 12 {
83 return Err(invalid("truncated PNG chunk"));
84 }
85 let len = u32be(&b[pos..pos + 4]) as usize;
86 let end = pos
87 .checked_add(12)
88 .and_then(|p| p.checked_add(len))
89 .filter(|p| *p <= b.len())
90 .ok_or_else(|| invalid("truncated PNG payload"))?;
91 let kind = &b[pos + 4..pos + 8];
92 if crc32(&b[pos + 4..end - 4]) != u32be(&b[end - 4..end]) {
93 return Err(invalid("invalid PNG CRC"));
94 }
95 if pos == 8 {
96 if kind != b"IHDR" || len != 13 {
97 return Err(invalid("PNG requires IHDR"));
98 }
99 let width = u32be(&b[pos + 8..pos + 12]);
100 let height = u32be(&b[pos + 12..pos + 16]);
101 let depth = b[pos + 16];
102 let color = b[pos + 17];
103 if width > 0x7fffffff
104 || height > 0x7fffffff
105 || !match color {
106 0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
107 2 | 4 | 6 => matches!(depth, 8 | 16),
108 3 => matches!(depth, 1 | 2 | 4 | 8),
109 _ => false,
110 }
111 || b[pos + 18] != 0
112 || b[pos + 19] != 0
113 || b[pos + 20] > 1
114 {
115 return Err(invalid("invalid PNG header"));
116 }
117 dimensions = Some((width, height));
118 } else if kind == b"IHDR" {
119 return Err(invalid("duplicate PNG header"));
120 }
121 if kind == b"IDAT" && len > 0 {
122 data = true;
123 }
124 if kind == b"IEND" {
125 if len != 0 || end != b.len() {
126 return Err(invalid("invalid PNG end"));
127 }
128 ended = true;
129 }
130 pos = end;
131 }
132 if !data || !ended {
133 return Err(invalid("PNG image data or end missing"));
134 }
135 let (w, h) = dimensions.ok_or_else(|| invalid("PNG dimensions missing"))?;
136 (o::ImageOutputFormat::Png, w, h)
137 } else if b.starts_with(&[0xff, 0xd8]) {
138 let mut pos = 2;
139 let mut dims = None;
140 let mut scan = false;
141 let mut ended = false;
142 while pos < b.len() {
143 if b[pos] != 0xff {
144 return Err(invalid("invalid JPEG marker"));
145 }
146 while pos < b.len() && b[pos] == 0xff {
147 pos += 1;
148 }
149 if pos >= b.len() {
150 return Err(invalid("truncated JPEG marker"));
151 }
152 let marker = b[pos];
153 pos += 1;
154 if marker == 0xd9 {
155 ended = pos == b.len();
156 break;
157 }
158 if marker == 0x00 || marker == 0xd8 {
159 return Err(invalid("unexpected JPEG marker"));
160 }
161 if marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
162 continue;
163 }
164 if b.len() - pos < 2 {
165 return Err(invalid("truncated JPEG segment"));
166 }
167 let len = u16::from_be_bytes([b[pos], b[pos + 1]]) as usize;
168 let end = pos
169 .checked_add(len)
170 .filter(|p| len >= 2 && *p <= b.len())
171 .ok_or_else(|| invalid("invalid JPEG segment length"))?;
172 if matches!(marker,0xc0..=0xc3|0xc5..=0xc7|0xc9..=0xcb|0xcd..=0xcf) {
173 if len < 8 {
174 return Err(invalid("truncated JPEG frame"));
175 }
176 let h = u16::from_be_bytes([b[pos + 3], b[pos + 4]]) as u32;
177 let w = u16::from_be_bytes([b[pos + 5], b[pos + 6]]) as u32;
178 if dims.replace((w, h)).is_some() {
179 return Err(invalid("multiple JPEG frames unsupported"));
180 }
181 }
182 pos = end;
183 if marker == 0xda {
184 scan = true;
185 while pos < b.len() {
186 if b[pos] == 0xff {
187 if pos + 1 >= b.len() {
188 return Err(invalid("truncated JPEG scan"));
189 }
190 if b[pos + 1] == 0x00 || (0xd0..=0xd7).contains(&b[pos + 1]) {
191 pos += 2;
192 continue;
193 }
194 break;
195 }
196 pos += 1;
197 }
198 }
199 }
200 if !scan || !ended {
201 return Err(invalid("JPEG scan or end missing"));
202 }
203 let (w, h) = dims.ok_or_else(|| invalid("JPEG dimensions missing"))?;
204 (o::ImageOutputFormat::Jpeg, w, h)
205 } else if b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP" {
206 if u32le(&b[4..8]) as u64 + 8 != b.len() as u64 {
207 return Err(invalid("WebP length mismatch"));
208 }
209 let mut pos = 12;
210 let mut dims = None;
211 while pos < b.len() {
212 if b.len() - pos < 8 {
213 return Err(invalid("truncated WebP chunk"));
214 }
215 let size = u32le(&b[pos + 4..pos + 8]) as usize;
216 let end = pos
217 .checked_add(8)
218 .and_then(|p| p.checked_add(size))
219 .filter(|p| *p <= b.len())
220 .ok_or_else(|| invalid("truncated WebP payload"))?;
221 let data = &b[pos + 8..end];
222 let kind = &b[pos..pos + 4];
223 let next = if kind == b"VP8 " {
224 if data.len() < 10 || data[0] & 1 != 0 || &data[3..6] != b"\x9d\x01\x2a" {
225 return Err(invalid("invalid WebP VP8 frame"));
226 }
227 Some((
228 u32::from(u16::from_le_bytes([data[6], data[7]]) & 0x3fff),
229 u32::from(u16::from_le_bytes([data[8], data[9]]) & 0x3fff),
230 ))
231 } else if kind == b"VP8L" {
232 if data.len() < 5 || data[0] != 0x2f {
233 return Err(invalid("invalid WebP lossless frame"));
234 }
235 let bits = u32le(&data[1..5]);
236 Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1))
237 } else if kind == b"ANIM" || kind == b"ANMF" {
238 return Err(invalid("animated WebP is not a generated still image"));
239 } else {
240 None
241 };
242 if let Some(d) = next
243 && dims.replace(d).is_some()
244 {
245 return Err(invalid("multiple WebP images"));
246 }
247 pos = end
248 .checked_add(size % 2)
249 .filter(|p| *p <= b.len())
250 .ok_or_else(|| invalid("missing WebP padding"))?;
251 }
252 let (w, h) = dims.ok_or_else(|| invalid("WebP image data missing"))?;
253 (o::ImageOutputFormat::Webp, w, h)
254 } else {
255 return Err(invalid("unsupported or malformed PNG/JPEG/WebP image"));
256 };
257 if width == 0 || height == 0 {
258 return Err(invalid("zero image dimensions"));
259 }
260 Ok(ImageMetadata {
261 format,
262 width,
263 height,
264 })
265}
266
267pub fn decode_image(
268 encoded: &str,
269 mime: Option<&str>,
270 max_bytes: u64,
271) -> Result<GeneratedImagePart, TransformError> {
272 if !encoded.len().is_multiple_of(4) {
273 return Err(invalid("invalid padded image base64 length"));
274 }
275 let padding = if encoded.ends_with("==") {
276 2
277 } else if encoded.ends_with('=') {
278 1
279 } else {
280 0
281 };
282 let decoded_bound = (encoded.len() as u64 / 4)
283 .saturating_mul(3)
284 .saturating_sub(padding);
285 if decoded_bound > max_bytes {
286 return Err(TransformError::new(
287 TransformErrorKind::Limit,
288 "image.bytes",
289 "decoded image exceeds cap",
290 ));
291 }
292 let bytes = base64::engine::general_purpose::STANDARD
293 .decode(encoded)
294 .map_err(|_| invalid("invalid image base64"))?;
295 if bytes.len() as u64 > max_bytes {
296 return Err(TransformError::new(
297 TransformErrorKind::Limit,
298 "image.bytes",
299 "decoded image exceeds cap",
300 ));
301 }
302 let metadata = inspect_image(&bytes)?;
303 if mime.is_some_and(|m| m != metadata.mime()) {
304 return Err(invalid("declared MIME contradicts image bytes"));
305 }
306 Ok(GeneratedImagePart {
307 bytes: bytes.into(),
308 metadata,
309 revised_prompt: None,
310 })
311}
312
313pub fn image_response_from_responses(
314 body: &r::response::GenerateContentResponseBody,
315 _context: &ImageRequestContext,
316 max_bytes: u64,
317) -> Result<ImageCallResult, TransformError> {
318 if body.id.trim().is_empty()
319 || body.model.trim().is_empty()
320 || body.created_at < 0
321 || body.status != Some(r::response::ResponseStatus::Completed)
322 || body.error.is_some()
323 || body.incomplete_details.is_some()
324 {
325 return Err(invalid(
326 "Responses generation did not complete successfully",
327 ));
328 }
329 let mut out = None;
330 let mut report = Report::default();
331 for item in &body.output {
332 match item {
333 r::response::ResponseOutputItem::ImageGenerationCall(call) => {
334 if call.status != r::input::ImageGenerationStatus::Completed
335 || call.id.trim().is_empty()
336 {
337 return Err(invalid("image tool did not complete"));
338 }
339 let mut image = decode_image(
340 call.result
341 .as_deref()
342 .ok_or_else(|| invalid("missing image result"))?,
343 None,
344 max_bytes,
345 )?;
346
347 image.revised_prompt = call.revised_prompt.clone().flatten();
348 if out.replace(image).is_some() {
349 return Err(invalid("expected exactly one image per call"));
350 }
351 }
352 r::response::ResponseOutputItem::Reasoning(_) => {
353 report.omitted("output.reasoning", "Images has no reasoning representation");
354 }
355 r::response::ResponseOutputItem::Message(message) => {
356 if message.status != r::input::OutputMessageStatus::Completed
357 || message
358 .content
359 .iter()
360 .any(|p| matches!(p, r::input::OutputContent::Refusal(_)))
361 {
362 return Err(invalid("incomplete or refused image response message"));
363 }
364 report.omitted(
365 "output.message",
366 "ancillary text has no Images representation and is not a revised prompt",
367 );
368 }
369 _ => return Err(invalid("unexpected non-image tool output")),
370 }
371 }
372 let usage = body
373 .usage
374 .as_ref()
375 .and_then(Option::as_ref)
376 .map(|u| {
377 if [
378 u.input_tokens,
379 u.output_tokens,
380 u.total_tokens,
381 u.input_tokens_details.cache_write_tokens,
382 u.input_tokens_details.cached_tokens,
383 u.output_tokens_details.reasoning_tokens,
384 ]
385 .iter()
386 .any(|n| *n < 0)
387 || u.input_tokens.checked_add(u.output_tokens) != Some(u.total_tokens)
388 || u.input_tokens_details
389 .cache_write_tokens
390 .checked_add(u.input_tokens_details.cached_tokens)
391 .is_none_or(|v| v > u.input_tokens)
392 || u.output_tokens_details.reasoning_tokens > u.output_tokens
393 {
394 return Err(invalid("invalid Responses usage"));
395 }
396 Ok(ImageUsageFacts::Responses(u.clone().into_declared()))
397 })
398 .map(crate::transform::optional)
399 .transpose()?
400 .flatten();
401 Ok(ImageCallResult {
402 image: out.ok_or_else(|| invalid("missing image result"))?,
403 usage,
404 response_id: Some(body.id.clone()),
405 model: Some(body.model.clone()),
406 report,
407 })
408}
409
410pub fn image_response_from_gemini(
411 body: &g::GenerateContentResponseBody,
412 _context: &ImageRequestContext,
413 max_bytes: u64,
414) -> Result<ImageCallResult, TransformError> {
415 if body
416 .prompt_feedback
417 .as_ref()
418 .and_then(|p| p.block_reason)
419 .is_some_and(|v| v != g::BlockReason::Unspecified)
420 {
421 return Err(invalid("Gemini blocked image generation"));
422 }
423 let candidates = body.candidates.as_deref().unwrap_or_default();
424 if candidates.len() != 1 {
425 return Err(invalid("expected exactly one Gemini candidate"));
426 }
427 let candidate = &candidates[0];
428 if candidate.finish_reason != Some(g::FinishReason::Stop)
429 || candidate.index.is_some_and(|i| i != 0)
430 {
431 return Err(invalid("Gemini candidate did not finish normally"));
432 }
433 let content = candidate
434 .content
435 .as_ref()
436 .ok_or_else(|| invalid("missing Gemini image content"))?;
437 if content.role.as_deref().is_some_and(|r| r != "model") {
438 return Err(invalid("invalid Gemini candidate role"));
439 }
440 let mut out = None;
441 let mut report = Report::default();
442 for part in content.parts.as_deref().unwrap_or_default() {
443 if part.function_call.is_some()
444 || part.function_response.is_some()
445 || part.file_data.is_some()
446 || part.executable_code.is_some()
447 || part.code_execution_result.is_some()
448 || part.tool_call.is_some()
449 || part.tool_response.is_some()
450 || part.video_metadata.is_some()
451 || part.media_processing.is_some()
452 || part.audio_transcription.is_some()
453 || part.speech_metadata.is_some()
454 {
455 return Err(invalid("unexpected non-image Gemini tool or media output"));
456 }
457 if part.text.is_some() && part.inline_data.is_some() {
458 return Err(invalid("Gemini part contains multiple payloads"));
459 }
460 if part.thought == Some(true) || part.text.is_some() {
461 report.omitted(
462 "candidate.text/thought",
463 "ancillary text has no Images representation and is not a revised prompt",
464 );
465 continue;
466 }
467 let data = part
468 .inline_data
469 .as_ref()
470 .ok_or_else(|| invalid("unexpected empty Gemini output"))?;
471 let image = decode_image(&data.data, Some(&data.mime_type), max_bytes)?;
472
473 if out.replace(image).is_some() {
474 return Err(invalid("expected exactly one Gemini image"));
475 }
476 }
477 let usage = body
478 .usage_metadata
479 .as_ref()
480 .map(|u| {
481 if [
482 u.prompt_token_count,
483 u.candidates_token_count,
484 u.cached_content_token_count,
485 u.tool_use_prompt_token_count,
486 u.thoughts_token_count,
487 u.total_token_count,
488 ]
489 .into_iter()
490 .flatten()
491 .any(|n| n < 0)
492 {
493 return Err(invalid("negative Gemini usage"));
494 }
495 for details in [
496 &u.prompt_tokens_details,
497 &u.cache_tokens_details,
498 &u.candidates_tokens_details,
499 &u.tool_use_prompt_tokens_details,
500 ]
501 .into_iter()
502 .flatten()
503 {
504 if details.iter().any(|d| d.token_count.is_some_and(|n| n < 0)) {
505 return Err(invalid("negative Gemini modality token count"));
506 }
507 }
508 Ok(ImageUsageFacts::Gemini(u.clone().into_declared()))
509 })
510 .map(crate::transform::optional)
511 .transpose()?
512 .flatten();
513 Ok(ImageCallResult {
514 image: out.ok_or_else(|| invalid("missing Gemini image"))?,
515 usage,
516 response_id: body.response_id.clone(),
517 model: body.model_version.clone(),
518 report,
519 })
520}