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#[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 pub observed_at: std::time::SystemTime,
54 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
249pub 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 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
309pub 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}