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