Skip to main content

openai_interface/images/
edit.rs

1//! Creates an edited or extended image given an original image and a
2//! prompt.
3//!
4//! Endpoint: `POST /images/edits` (multipart/form-data request / JSON
5//! response).
6//!
7//! > ![warn] This module is untested!
8//! > No OpenAI-compatible provider accessible to this project implements
9//! > this endpoint, and no OpenAI API key was available for testing. If you
10//! > encounter any issues, please report them on the repository.
11
12use std::path::PathBuf;
13
14use serde::Serialize;
15use url::Url;
16
17use crate::{
18    errors::OapiError,
19    images::{Background, ImageResponseFormat, OutputFormat, enum_to_literal},
20    rest::RequestOptions,
21    rest::post::{Post, PostNoStream},
22};
23
24/// Creates an edited or extended image given an original image and a prompt.
25#[derive(Debug, Serialize, Default, Clone)]
26pub struct ImageEditRequest {
27    /// The image(s) to edit, as file paths.
28    ///
29    /// For the GPT image models, each image should be a `png`, `webp`, or
30    /// `jpg` file less than 50MB. You can provide up to 16 images. For
31    /// `dall-e-2`, you can only provide one image, and it should be a square
32    /// `png` file less than 4MB.
33    ///
34    /// Serialized as repeated `image[]` multipart parts when more than one
35    /// image is provided, matching the official SDK.
36    #[serde(skip_serializing)]
37    pub image: Vec<PathBuf>,
38    /// A text description of the desired image(s).
39    ///
40    /// The maximum length is 1000 characters for `dall-e-2`, and 32000
41    /// characters for the GPT image models.
42    pub prompt: String,
43    /// The model to use for image generation. One of `dall-e-2` or a GPT
44    /// image model (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`,
45    /// `gpt-image-2`, `gpt-image-2-2026-04-21`, or `chatgpt-image-latest`).
46    /// Defaults to `gpt-image-1.5`.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub model: Option<String>,
49    /// Allows to set transparency for the background of the generated
50    /// image(s). Must be one of `transparent`, `opaque`, or `auto` (default
51    /// value).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub background: Option<Background>,
54    /// Control how much effort the model will exert to match the style and
55    /// features, especially facial features, of input images. This parameter
56    /// is only supported for `gpt-image-1` and `gpt-image-1.5` and later
57    /// models, unsupported for `gpt-image-1-mini`. Supports `high` and
58    /// `low`. Defaults to `low`.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub input_fidelity: Option<InputFidelity>,
61    /// An additional image (as a file path) whose fully transparent areas
62    /// (e.g. where alpha is zero) indicate where `image` should be edited.
63    /// If there are multiple images provided, the mask will be applied on
64    /// the first image. Must be a valid PNG file, less than 4MB, and have
65    /// the same dimensions as `image`.
66    #[serde(skip_serializing)]
67    pub mask: Option<PathBuf>,
68    /// The number of images to generate. Must be between 1 and 10.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub n: Option<u32>,
71    /// The compression level (0-100%) for the generated images.
72    ///
73    /// This parameter is only supported for the GPT image models with the
74    /// `webp` or `jpeg` output formats, and defaults to 100.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub output_compression: Option<u32>,
77    /// The format in which the generated images are returned.
78    ///
79    /// This parameter is only supported for the GPT image models. Must be
80    /// one of `png`, `jpeg`, or `webp`. The default value is `png`.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub output_format: Option<OutputFormat>,
83    /// The quality of the image that will be generated for GPT image models.
84    /// Defaults to `auto`.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub quality: Option<Quality>,
87    /// The format in which the generated images are returned. Must be one of
88    /// `url` or `b64_json`. This parameter is only supported for `dall-e-2`
89    /// (default is `url` for `dall-e-2`), as GPT image models always return
90    /// base64-encoded images.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub response_format: Option<ImageResponseFormat>,
93    /// The size of the generated images. See the official documentation for
94    /// the per-model size options.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub size: Option<String>,
97    /// A unique identifier representing your end-user, which can help OpenAI
98    /// to monitor and detect abuse.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub user: Option<String>,
101    /// Additional JSON properties, sent as extra multipart text fields
102    /// (strings verbatim, other JSON values serialized).
103    pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
104}
105
106/// Control how much effort the model will exert to match the style and
107/// features of input images.
108#[derive(Debug, Serialize, Clone, Copy)]
109#[serde(rename_all = "snake_case")]
110pub enum InputFidelity {
111    High,
112    Low,
113}
114
115/// The quality of the image that will be generated for GPT image models.
116#[derive(Debug, Serialize, Clone, Copy)]
117#[serde(rename_all = "snake_case")]
118pub enum Quality {
119    Standard,
120    Low,
121    Medium,
122    High,
123    Auto,
124}
125
126impl Post for ImageEditRequest {
127    #[inline]
128    fn is_streaming(&self) -> bool {
129        false
130    }
131
132    /// Builds the URL for the request.
133    ///
134    /// `base_url` should be like <https://api.openai.com/v1>
135    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
136        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
137        url.path_segments_mut()
138            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
139            .push("images")
140            .push("edits");
141
142        Ok(url.to_string())
143    }
144}
145
146impl PostNoStream for ImageEditRequest {
147    type Response = crate::images::ImagesResponse;
148
149    /// Sends an image edit POST request using multipart/form-data format,
150    /// following the field layout of the official SDK.
151    async fn get_response_string(
152        &self,
153        client: &reqwest::Client,
154        base_url: &str,
155        options: &RequestOptions,
156    ) -> Result<String, OapiError> {
157        if self.image.is_empty() {
158            return Err(OapiError::ResponseError(
159                "At least one image is required".to_string(),
160            ));
161        }
162
163        let mut form = reqwest::multipart::Form::new();
164
165        // The official SDK sends a single image as the `image` part and
166        // multiple images as repeated `image[]` parts.
167        let image_part_name = if self.image.len() == 1 {
168            "image"
169        } else {
170            "image[]"
171        };
172        for path in &self.image {
173            let content = tokio::fs::read(path).await?;
174            let file_name = file_name_of(path)?;
175            let part = reqwest::multipart::Part::bytes(content).file_name(file_name);
176            form = form.part(image_part_name, part);
177        }
178
179        if let Some(mask) = &self.mask {
180            let content = tokio::fs::read(mask).await?;
181            let file_name = file_name_of(mask)?;
182            let part = reqwest::multipart::Part::bytes(content).file_name(file_name);
183            form = form.part("mask", part);
184        }
185
186        form = form.text("prompt", self.prompt.clone());
187
188        if let Some(model) = &self.model {
189            form = form.text("model", model.clone());
190        }
191        if let Some(background) = self.background {
192            form = form.text("background", enum_to_literal(&background)?);
193        }
194        if let Some(input_fidelity) = self.input_fidelity {
195            form = form.text("input_fidelity", enum_to_literal(&input_fidelity)?);
196        }
197        if let Some(n) = self.n {
198            form = form.text("n", n.to_string());
199        }
200        if let Some(output_compression) = self.output_compression {
201            form = form.text("output_compression", output_compression.to_string());
202        }
203        if let Some(output_format) = self.output_format {
204            form = form.text("output_format", enum_to_literal(&output_format)?);
205        }
206        if let Some(quality) = self.quality {
207            form = form.text("quality", enum_to_literal(&quality)?);
208        }
209        if let Some(response_format) = self.response_format {
210            form = form.text("response_format", enum_to_literal(&response_format)?);
211        }
212        if let Some(size) = &self.size {
213            form = form.text("size", size.clone());
214        }
215        if let Some(user) = &self.user {
216            form = form.text("user", user.clone());
217        }
218
219        form = crate::rest::post::append_extra_body_map(form, &self.extra_body_map);
220
221        let url = self.build_url(base_url)?;
222        crate::rest::post::post_multipart_json(client, url, form, options).await
223    }
224}
225
226/// Extracts the file name of a path for the multipart part.
227fn file_name_of(path: &std::path::Path) -> Result<String, OapiError> {
228    path.file_name()
229        .and_then(|name| name.to_str())
230        .map(|name| name.to_string())
231        .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_build_url() {
240        let request = ImageEditRequest::default();
241        let url = request.build_url("https://api.openai.com/v1/").unwrap();
242        assert_eq!(url, "https://api.openai.com/v1/images/edits");
243    }
244
245    /// Enum literals serialize to their official wire values.
246    #[test]
247    fn enum_literals() {
248        assert_eq!(enum_to_literal(&InputFidelity::High).unwrap(), "high");
249        assert_eq!(enum_to_literal(&Quality::Standard).unwrap(), "standard");
250        assert_eq!(
251            enum_to_literal(&ImageResponseFormat::B64Json).unwrap(),
252            "b64_json"
253        );
254    }
255}