Skip to main content

gproxy_protocol/transform/images/
request.rs

1use crate::{
2    WireRequest,
3    capability::ResourceReference,
4    transform::{Converted, Report, TransformError},
5    wire::{
6        gemini as g,
7        openai::{images as o, responses as r},
8    },
9};
10use r::{input as ri, tools as rt};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ImageDialect {
14    Responses,
15    Gemini,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum ImageInput {
20    Create(o::CreateImageRequestBody),
21    Edit(o::EditImageJsonBody),
22}
23
24/// The generation model is selected by the host. An image tool model is a
25/// separate routing decision; an absent override retains the source image model.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ImageTargetModels {
28    pub generation_model: String,
29    pub image_tool_model: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ResolvedImageInput {
34    pub reference: ResourceReference,
35    pub bytes_base64: String,
36    pub mime_type: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ImageRequestContext {
41    pub source_model: Option<String>,
42    pub target_models: ImageTargetModels,
43    pub n: usize,
44    pub response_format: Option<o::ImageResponseFormat>,
45    pub output_format: Option<o::ImageOutputFormat>,
46    pub requested_size: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ImageResponseFacts {
51    pub created: i64,
52    /// Host clock snapshot; the protocol never reads a native clock.
53    pub observed_at: std::time::SystemTime,
54    /// Host-resolved source endpoint/model default when response_format is absent.
55    pub default_response_format: o::ImageResponseFormat,
56    pub publish_expires_at: Option<std::time::SystemTime>,
57    pub operation_id: String,
58}
59
60#[derive(Debug)]
61#[allow(clippy::large_enum_variant)]
62pub enum ImageDialectBody {
63    Responses(WireRequest<r::GenerateContentRequestBody>),
64    Gemini(WireRequest<g::GenerateContentRequestBody>),
65}
66
67pub struct PreparedImageRequest {
68    pub body: ImageDialectBody,
69    pub context: ImageRequestContext,
70}
71
72struct Options {
73    prompt: String,
74    model: Option<String>,
75    n: i64,
76    response_format: Option<o::ImageResponseFormat>,
77    output_format: Option<o::ImageOutputFormat>,
78    size: Option<String>,
79    tool: rt::ImageGenerationTool,
80    images: Vec<ResourceReference>,
81    mask: Option<ResourceReference>,
82    user: Option<String>,
83}
84
85fn unsupported(field: &str) -> TransformError {
86    TransformError::unsupported(
87        field,
88        "no exact equivalent in selected image generation API",
89    )
90}
91
92fn options(input: &ImageInput) -> Result<Options, TransformError> {
93    let (
94        prompt,
95        model,
96        n,
97        format,
98        output,
99        size,
100        background,
101        moderation,
102        compression,
103        _partial,
104        _stream,
105        quality,
106        fidelity,
107        images,
108        mask,
109        user,
110    ) = match input {
111        ImageInput::Create(v) => {
112            let quality = v
113                .quality
114                .flatten()
115                .map(|q| match q {
116                    o::ImageQuality::Low => Ok(rt::ImageQuality::Low),
117                    o::ImageQuality::Medium => Ok(rt::ImageQuality::Medium),
118                    o::ImageQuality::High => Ok(rt::ImageQuality::High),
119                    o::ImageQuality::Xhigh => Ok(rt::ImageQuality::Xhigh),
120                    o::ImageQuality::Max => Ok(rt::ImageQuality::Max),
121                    o::ImageQuality::Auto => Ok(rt::ImageQuality::Auto),
122                    _ => Err(unsupported("quality")),
123                })
124                .map(crate::transform::optional)
125                .transpose()?
126                .flatten();
127            (
128                v.prompt.clone(),
129                v.model.clone().flatten(),
130                v.n.flatten().unwrap_or(1),
131                v.response_format.flatten(),
132                v.output_format.flatten(),
133                v.size.clone().flatten(),
134                v.background.flatten(),
135                v.moderation.flatten(),
136                v.output_compression.flatten(),
137                v.partial_images.flatten(),
138                v.stream.flatten(),
139                quality,
140                None,
141                Vec::new(),
142                None,
143                v.user.clone(),
144            )
145        }
146        ImageInput::Edit(v) => {
147            let quality = v.quality.flatten().map(|q| match q {
148                o::EditedImageQuality::Low => rt::ImageQuality::Low,
149                o::EditedImageQuality::Medium => rt::ImageQuality::Medium,
150                o::EditedImageQuality::High => rt::ImageQuality::High,
151                o::EditedImageQuality::Xhigh => rt::ImageQuality::Xhigh,
152                o::EditedImageQuality::Max => rt::ImageQuality::Max,
153                o::EditedImageQuality::Auto => rt::ImageQuality::Auto,
154            });
155            let size = v.size.clone().flatten().map(|s| {
156                match s {
157                    o::EditedImageSize::Auto => "auto",
158                    o::EditedImageSize::Square => "1024x1024",
159                    o::EditedImageSize::Landscape => "1536x1024",
160                    o::EditedImageSize::Portrait => "1024x1536",
161                    o::EditedImageSize::Custom(ref size) => size,
162                }
163                .to_owned()
164            });
165            (
166                v.prompt.clone(),
167                v.model.clone().flatten(),
168                v.n.flatten().unwrap_or(1),
169                None,
170                v.output_format.flatten(),
171                size,
172                v.background.flatten(),
173                v.moderation.flatten(),
174                v.output_compression.flatten(),
175                v.partial_images.flatten(),
176                v.stream.flatten(),
177                quality,
178                v.input_fidelity.flatten(),
179                v.images
180                    .iter()
181                    .map(image_reference)
182                    .filter_map(|value| crate::transform::optional(value).transpose())
183                    .collect::<Result<Vec<_>, _>>()?,
184                v.mask
185                    .as_ref()
186                    .map(image_reference)
187                    .map(crate::transform::optional)
188                    .transpose()?
189                    .flatten(),
190                v.user.clone(),
191            )
192        }
193    };
194
195    let mut tool = rt::ImageGenerationTool::builder().build();
196    tool.action = Some(if images.is_empty() {
197        rt::ImageAction::Generate
198    } else {
199        rt::ImageAction::Edit
200    });
201    tool.background = background.map(|v| match v {
202        o::ImageBackground::Auto => rt::ImageBackground::Auto,
203        o::ImageBackground::Opaque => rt::ImageBackground::Opaque,
204        o::ImageBackground::Transparent => rt::ImageBackground::Transparent,
205    });
206    tool.moderation = moderation.map(|v| match v {
207        o::ImageModeration::Auto => rt::Moderation::Auto,
208        o::ImageModeration::Low => rt::Moderation::Low,
209    });
210    tool.quality = quality;
211    tool.input_fidelity = fidelity.map(|v| {
212        Some(match v {
213            o::InputFidelity::Low => rt::InputFidelity::Low,
214            o::InputFidelity::High => rt::InputFidelity::High,
215        })
216    });
217    tool.output_format = output.map(|v| match v {
218        o::ImageOutputFormat::Png => rt::ImageOutputFormat::Png,
219        o::ImageOutputFormat::Jpeg => rt::ImageOutputFormat::Jpeg,
220        o::ImageOutputFormat::Webp => rt::ImageOutputFormat::Webp,
221    });
222    tool.output_compression = compression.map(Into::into);
223    tool.size = size.clone();
224    Ok(Options {
225        prompt,
226        model,
227        n,
228        response_format: format,
229        output_format: output,
230        size,
231        tool,
232        images,
233        mask,
234        user,
235    })
236}
237
238pub fn image_reference(v: &o::ImageReference) -> Result<ResourceReference, TransformError> {
239    match (&v.file_id, &v.image_url) {
240        (Some(id), None) if !id.trim().is_empty() => Ok(ResourceReference::Id(id.clone())),
241        (None, Some(url)) if !url.trim().is_empty() => Ok(ResourceReference::Url(url.clone())),
242        _ => Err(TransformError::shape(
243            "image.reference",
244            "exactly one nonempty file_id or image_url required",
245        )),
246    }
247}
248
249/// Selects the source resources and return parameters used by the adapter.
250pub fn preflight(
251    input: &ImageInput,
252    dialect: ImageDialect,
253    models: &ImageTargetModels,
254) -> Result<(ImageRequestContext, Vec<ResourceReference>, Report), TransformError> {
255    let v = options(input)?;
256
257    let mut report = Report::default();
258    if dialect == ImageDialect::Gemini {
259        // Gemini imageSize is a resolution class, not an exact pixel size.
260
261        if v.user.is_some() {
262            report.omitted("user", "Gemini has no equivalent user attribution field");
263        }
264        if v.model.is_some() {
265            report.changed("model", "host selected the Gemini image generation model");
266        }
267    }
268    let mut refs = v.images;
269    if let Some(mask) = v.mask.filter(|_| dialect == ImageDialect::Responses) {
270        refs.push(mask);
271    }
272    let n = usize::try_from(v.n).unwrap_or(1);
273    if n == 0 {
274        return Err(TransformError::shape("n", "at least one image required"));
275    }
276    if n > 1 {
277        report.changed("n", "one native image generation call per requested image");
278    }
279    Ok((
280        ImageRequestContext {
281            source_model: v.model,
282            target_models: models.clone(),
283            n,
284            response_format: v.response_format,
285            output_format: v.output_format,
286            requested_size: v.size,
287        },
288        refs,
289        report,
290    ))
291}
292
293fn resolved<'a>(
294    reference: &ResourceReference,
295    values: &'a [ResolvedImageInput],
296) -> Result<&'a ResolvedImageInput, TransformError> {
297    let mut matches = values.iter().filter(|v| &v.reference == reference);
298    let v = matches
299        .next()
300        .ok_or_else(|| TransformError::missing_metadata("image.resource"))?;
301
302    Ok(v)
303}
304
305fn data_url(v: &ResolvedImageInput) -> String {
306    format!("data:{};base64,{}", v.mime_type, v.bytes_base64)
307}
308
309/// Builds ONE call; `context.n` is the adapter's total call count.
310pub fn build_request(
311    input: ImageInput,
312    dialect: ImageDialect,
313    models: &ImageTargetModels,
314    values: &[ResolvedImageInput],
315) -> Result<Converted<PreparedImageRequest>, TransformError> {
316    let (context, _, report) = preflight(&input, dialect, models)?;
317    let mut v = options(&input)?;
318    let body = match dialect {
319        ImageDialect::Responses => {
320            v.tool.model = models.image_tool_model.clone().or(v.model);
321            if let Some(mask) = v.mask {
322                let mask = resolved(&mask, values)?;
323                v.tool.input_image_mask = Some(
324                    rt::InputImageMask::builder()
325                        .image_url(data_url(mask))
326                        .build(),
327                );
328            }
329            let mut content = vec![ri::InputContent::Text(
330                ri::ResponseInputText::builder(
331                    ri::ResponseInputTextType::ResponseInputText,
332                    v.prompt,
333                )
334                .build(),
335            )];
336            for reference in &v.images {
337                content.push(ri::InputContent::Image(
338                    ri::ResponseInputImage::builder(
339                        ri::ResponseInputImageType::ResponseInputImage,
340                        ri::ImageDetail::Auto,
341                    )
342                    .image_url(Some(data_url(resolved(reference, values)?)))
343                    .build(),
344                ));
345            }
346            let mut body = r::GenerateContentRequestBody::builder()
347                .model(models.generation_model.clone())
348                .input(ri::Input::Items(vec![ri::InputItem::Message(
349                    ri::InputMessage::builder(content, ri::InputMessageRole::User).build(),
350                )]))
351                .tools(vec![rt::Tool::ImageGeneration(v.tool)])
352                .tool_choice(ri::ToolChoice::Hosted(
353                    ri::ToolChoiceHosted::builder(ri::ToolChoiceHostedType::ImageGeneration)
354                        .build(),
355                ))
356                .build();
357            body.user = v.user.map(Some);
358            body.parallel_tool_calls = Some(Some(false));
359            body.max_tool_calls = Some(Some(1));
360            body.stream = Some(Some(false));
361            ImageDialectBody::Responses(request("/v1/responses".into(), body))
362        }
363        ImageDialect::Gemini => {
364            let mut parts = vec![g::Part::builder().text(v.prompt).build()];
365            for reference in &v.images {
366                let image = resolved(reference, values)?;
367                parts.push(
368                    g::Part::builder()
369                        .inline_data(
370                            g::Blob::builder(image.mime_type.clone(), image.bytes_base64.clone())
371                                .build(),
372                        )
373                        .build(),
374                );
375            }
376            let config = g::GenerationConfig::builder()
377                .response_modalities(vec![g::Modality::Image])
378                .build();
379            let body = g::GenerateContentRequestBody::builder(vec![
380                g::Content::builder().role("user").parts(parts).build(),
381            ])
382            .generation_config(config)
383            .build();
384            ImageDialectBody::Gemini(request(
385                format!("/v1beta/models/{}:generateContent", models.generation_model),
386                body,
387            ))
388        }
389    };
390    Ok(Converted {
391        value: PreparedImageRequest { body, context },
392        report,
393    })
394}
395
396fn request<T>(path: String, body: T) -> WireRequest<T> {
397    WireRequest {
398        method: http::Method::POST,
399        path,
400        query: None,
401        headers: http::HeaderMap::new(),
402        body,
403    }
404}