openai-interface 0.7.0

A low-level Rust interface for the OpenAI API
Documentation
//! Creates an edited or extended image given an original image and a
//! prompt.
//!
//! Endpoint: `POST /images/edits` (multipart/form-data request / JSON
//! response).
//!
//! > ![warn] This module is untested!
//! > No OpenAI-compatible provider accessible to this project implements
//! > this endpoint, and no OpenAI API key was available for testing. If you
//! > encounter any issues, please report them on the repository.

use std::path::PathBuf;

use serde::Serialize;
use url::Url;

use crate::{
    errors::OapiError,
    images::{Background, ImageResponseFormat, OutputFormat, enum_to_literal},
    rest::post::{Post, PostNoStream},
};

/// Creates an edited or extended image given an original image and a prompt.
#[derive(Debug, Serialize, Default, Clone)]
pub struct ImageEditRequest {
    /// The image(s) to edit, as file paths.
    ///
    /// For the GPT image models, each image should be a `png`, `webp`, or
    /// `jpg` file less than 50MB. You can provide up to 16 images. For
    /// `dall-e-2`, you can only provide one image, and it should be a square
    /// `png` file less than 4MB.
    ///
    /// Serialized as repeated `image[]` multipart parts when more than one
    /// image is provided, matching the official SDK.
    #[serde(skip_serializing)]
    pub image: Vec<PathBuf>,
    /// A text description of the desired image(s).
    ///
    /// The maximum length is 1000 characters for `dall-e-2`, and 32000
    /// characters for the GPT image models.
    pub prompt: String,
    /// The model to use for image generation. One of `dall-e-2` or a GPT
    /// image model (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`,
    /// `gpt-image-2`, `gpt-image-2-2026-04-21`, or `chatgpt-image-latest`).
    /// Defaults to `gpt-image-1.5`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Allows to set transparency for the background of the generated
    /// image(s). Must be one of `transparent`, `opaque`, or `auto` (default
    /// value).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub background: Option<Background>,
    /// Control how much effort the model will exert to match the style and
    /// features, especially facial features, of input images. This parameter
    /// is only supported for `gpt-image-1` and `gpt-image-1.5` and later
    /// models, unsupported for `gpt-image-1-mini`. Supports `high` and
    /// `low`. Defaults to `low`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub input_fidelity: Option<InputFidelity>,
    /// An additional image (as a file path) whose fully transparent areas
    /// (e.g. where alpha is zero) indicate where `image` should be edited.
    /// If there are multiple images provided, the mask will be applied on
    /// the first image. Must be a valid PNG file, less than 4MB, and have
    /// the same dimensions as `image`.
    #[serde(skip_serializing)]
    pub mask: Option<PathBuf>,
    /// The number of images to generate. Must be between 1 and 10.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub n: Option<u32>,
    /// The compression level (0-100%) for the generated images.
    ///
    /// This parameter is only supported for the GPT image models with the
    /// `webp` or `jpeg` output formats, and defaults to 100.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub output_compression: Option<u32>,
    /// The format in which the generated images are returned.
    ///
    /// This parameter is only supported for the GPT image models. Must be
    /// one of `png`, `jpeg`, or `webp`. The default value is `png`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub output_format: Option<OutputFormat>,
    /// The quality of the image that will be generated for GPT image models.
    /// Defaults to `auto`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub quality: Option<Quality>,
    /// The format in which the generated images are returned. Must be one of
    /// `url` or `b64_json`. This parameter is only supported for `dall-e-2`
    /// (default is `url` for `dall-e-2`), as GPT image models always return
    /// base64-encoded images.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub response_format: Option<ImageResponseFormat>,
    /// The size of the generated images. See the official documentation for
    /// the per-model size options.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size: Option<String>,
    /// A unique identifier representing your end-user, which can help OpenAI
    /// to monitor and detect abuse.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
}

/// Control how much effort the model will exert to match the style and
/// features of input images.
#[derive(Debug, Serialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum InputFidelity {
    High,
    Low,
}

/// The quality of the image that will be generated for GPT image models.
#[derive(Debug, Serialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum Quality {
    Standard,
    Low,
    Medium,
    High,
    Auto,
}

impl Post for ImageEditRequest {
    #[inline]
    fn is_streaming(&self) -> bool {
        false
    }

    /// Builds the URL for the request.
    ///
    /// `base_url` should be like <https://api.openai.com/v1>
    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
        url.path_segments_mut()
            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
            .push("images")
            .push("edits");

        Ok(url.to_string())
    }
}

impl PostNoStream for ImageEditRequest {
    type Response = crate::images::ImagesResponse;

    /// Sends an image edit POST request using multipart/form-data format,
    /// following the field layout of the official SDK.
    async fn get_response_string(
        &self,
        client: &reqwest::Client,
        url: &str,
        key: &str,
    ) -> Result<String, OapiError> {
        if self.image.is_empty() {
            return Err(OapiError::ResponseError(
                "At least one image is required".to_string(),
            ));
        }

        let mut form = reqwest::multipart::Form::new();

        // The official SDK sends a single image as the `image` part and
        // multiple images as repeated `image[]` parts.
        let image_part_name = if self.image.len() == 1 {
            "image"
        } else {
            "image[]"
        };
        for path in &self.image {
            let content = tokio::fs::read(path).await?;
            let file_name = file_name_of(path)?;
            let part = reqwest::multipart::Part::bytes(content).file_name(file_name);
            form = form.part(image_part_name, part);
        }

        if let Some(mask) = &self.mask {
            let content = tokio::fs::read(mask).await?;
            let file_name = file_name_of(mask)?;
            let part = reqwest::multipart::Part::bytes(content).file_name(file_name);
            form = form.part("mask", part);
        }

        form = form.text("prompt", self.prompt.clone());

        if let Some(model) = &self.model {
            form = form.text("model", model.clone());
        }
        if let Some(background) = self.background {
            form = form.text("background", enum_to_literal(&background)?);
        }
        if let Some(input_fidelity) = self.input_fidelity {
            form = form.text("input_fidelity", enum_to_literal(&input_fidelity)?);
        }
        if let Some(n) = self.n {
            form = form.text("n", n.to_string());
        }
        if let Some(output_compression) = self.output_compression {
            form = form.text("output_compression", output_compression.to_string());
        }
        if let Some(output_format) = self.output_format {
            form = form.text("output_format", enum_to_literal(&output_format)?);
        }
        if let Some(quality) = self.quality {
            form = form.text("quality", enum_to_literal(&quality)?);
        }
        if let Some(response_format) = self.response_format {
            form = form.text("response_format", enum_to_literal(&response_format)?);
        }
        if let Some(size) = &self.size {
            form = form.text("size", size.clone());
        }
        if let Some(user) = &self.user {
            form = form.text("user", user.clone());
        }

        let response = client
            .post(url)
            .header("Accept", "application/json")
            .bearer_auth(key)
            .multipart(form)
            .send()
            .await?;

        crate::rest::response_text_checked(response).await
    }
}

/// Extracts the file name of a path for the multipart part.
fn file_name_of(path: &std::path::Path) -> Result<String, OapiError> {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| name.to_string())
        .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_url() {
        let request = ImageEditRequest::default();
        let url = request.build_url("https://api.openai.com/v1/").unwrap();
        assert_eq!(url, "https://api.openai.com/v1/images/edits");
    }

    /// Enum literals serialize to their official wire values.
    #[test]
    fn enum_literals() {
        assert_eq!(enum_to_literal(&InputFidelity::High).unwrap(), "high");
        assert_eq!(enum_to_literal(&Quality::Standard).unwrap(), "standard");
        assert_eq!(
            enum_to_literal(&ImageResponseFormat::B64Json).unwrap(),
            "b64_json"
        );
    }
}