Skip to main content

gproxy_protocol/transform/images/
response.rs

1use 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
73/// Container validation and dimensions; this does not decode pixels. PNG CRCs,
74/// JPEG marker framing and WebP RIFF framing are checked before publication.
75pub 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}