Skip to main content

openai_tools/images/
request.rs

1//! OpenAI Images API Request Module
2//!
3//! This module provides the functionality to interact with the OpenAI Images API.
4//! It allows you to generate, edit, and create variations of images using DALL-E models.
5//!
6//! # Key Features
7//!
8//! - **Generate**: Create images from text prompts
9//! - **Edit**: Modify existing images with new prompts and masks
10//! - **Variations**: Create variations of existing images (DALL-E 2 only)
11//!
12//! # Quick Start
13//!
14//! ```rust,no_run
15//! use openai_tools::images::request::{Images, GenerateOptions};
16//!
17//! #[tokio::main]
18//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
19//!     let images = Images::new()?;
20//!
21//!     // Generate an image
22//!     let response = images.generate("A white cat", GenerateOptions::default()).await?;
23//!     println!("Image URL: {:?}", response.data[0].url);
24//!
25//!     Ok(())
26//! }
27//! ```
28
29use crate::common::auth::AuthProvider;
30use crate::common::client::create_http_client;
31use crate::common::errors::{ErrorResponse, OpenAIToolError, Result};
32use crate::images::response::ImageResponse;
33use request::multipart::{Form, Part};
34use serde::{Deserialize, Serialize};
35use std::path::Path;
36use std::time::Duration;
37
38/// Default API path for Images
39const IMAGES_PATH: &str = "images";
40
41/// Image generation models.
42///
43/// # DALL-E retirement
44///
45/// OpenAI retired `dall-e-2` and `dall-e-3` on `api.openai.com` - requests
46/// naming them fail with "The model 'dall-e-3' does not exist." The variants
47/// are kept because Azure OpenAI deployments can still serve DALL-E, but new
48/// OpenAI code should use one of the GPT Image models.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[non_exhaustive]
51pub enum ImageModel {
52    /// DALL-E 2 model - supports variations, smaller sizes
53    ///
54    /// Retired on OpenAI; may still exist as an Azure deployment.
55    #[serde(rename = "dall-e-2")]
56    DallE2,
57    /// DALL-E 3 model - higher quality, HD support, style options
58    ///
59    /// Retired on OpenAI; may still exist as an Azure deployment.
60    #[serde(rename = "dall-e-3")]
61    DallE3,
62    /// GPT Image model - latest generation (default)
63    #[serde(rename = "gpt-image-1")]
64    #[default]
65    GptImage1,
66    /// GPT Image 1 Mini - cheaper gpt-image-1 for high-volume generation
67    #[serde(rename = "gpt-image-1-mini")]
68    GptImage1Mini,
69    /// GPT Image 1.5 - refreshed gpt-image-1
70    #[serde(rename = "gpt-image-1.5")]
71    GptImage1_5,
72    /// GPT Image 2 - state-of-the-art generation and editing
73    #[serde(rename = "gpt-image-2")]
74    GptImage2,
75    /// GPT Image 2.5 Flare - fast, everyday image generation and editing
76    ///
77    /// Has approximately 50% lower latency than GPT Image 2. It has the same
78    /// pricing as GPT Image 2.5 Sunburst.
79    ///
80    /// Accepts `low`, `medium`, `high`, `auto`, and `max` quality, but rejects
81    /// DALL-E's `standard` and `hd` values. Although `max` is accepted, API
82    /// validation errors only list `low`, `medium`, `high`, and `auto`.
83    /// `response_format` and `style` are unknown parameters and responses are
84    /// always base64. The API accepts `output_format: webp` and
85    /// `background: transparent`.
86    #[serde(rename = "gpt-image-2.5-flare")]
87    GptImage2_5Flare,
88    /// GPT Image 2.5 Sunburst - generation and finely controlled editing
89    ///
90    /// Has the same pricing as GPT Image 2.5 Flare. At `max` quality, one
91    /// generation can take more than 60 seconds; configure a longer timeout
92    /// when using request timeouts.
93    ///
94    /// Accepts `low`, `medium`, `high`, `auto`, and `max` quality, but rejects
95    /// DALL-E's `standard` and `hd` values. Although `max` is accepted, API
96    /// validation errors only list `low`, `medium`, `high`, and `auto`.
97    /// `response_format` and `style` are unknown parameters and responses are
98    /// always base64. The API accepts `output_format: webp` and
99    /// `background: transparent`.
100    #[serde(rename = "gpt-image-2.5-sunburst")]
101    GptImage2_5Sunburst,
102    /// ChatGPT Image Latest - image model currently used in ChatGPT
103    ///
104    /// Requires a verified organization.
105    #[serde(rename = "chatgpt-image-latest")]
106    ChatGptImageLatest,
107}
108
109impl ImageModel {
110    /// Returns the model identifier string.
111    pub fn as_str(&self) -> &'static str {
112        match self {
113            Self::DallE2 => "dall-e-2",
114            Self::DallE3 => "dall-e-3",
115            Self::GptImage1 => "gpt-image-1",
116            Self::GptImage1Mini => "gpt-image-1-mini",
117            Self::GptImage1_5 => "gpt-image-1.5",
118            Self::GptImage2 => "gpt-image-2",
119            Self::GptImage2_5Flare => "gpt-image-2.5-flare",
120            Self::GptImage2_5Sunburst => "gpt-image-2.5-sunburst",
121            Self::ChatGptImageLatest => "chatgpt-image-latest",
122        }
123    }
124}
125
126impl std::fmt::Display for ImageModel {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        write!(f, "{}", self.as_str())
129    }
130}
131
132/// Image sizes for generation.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
134#[non_exhaustive]
135pub enum ImageSize {
136    /// 256x256 pixels (DALL-E 2 only)
137    #[serde(rename = "256x256")]
138    Size256x256,
139    /// 512x512 pixels (DALL-E 2 only)
140    #[serde(rename = "512x512")]
141    Size512x512,
142    /// 1024x1024 pixels (all models)
143    #[serde(rename = "1024x1024")]
144    #[default]
145    Size1024x1024,
146    /// 1792x1024 pixels - landscape (DALL-E 3 only)
147    #[serde(rename = "1792x1024")]
148    Size1792x1024,
149    /// 1024x1792 pixels - portrait (DALL-E 3 only)
150    #[serde(rename = "1024x1792")]
151    Size1024x1792,
152    /// 1024x1536 pixels - portrait (GPT Image models)
153    #[serde(rename = "1024x1536")]
154    Size1024x1536,
155    /// 1536x1024 pixels - landscape (GPT Image models)
156    #[serde(rename = "1536x1024")]
157    Size1536x1024,
158    /// Let the model choose the size (GPT Image models)
159    #[serde(rename = "auto")]
160    Auto,
161}
162
163impl ImageSize {
164    /// Returns the size string.
165    pub fn as_str(&self) -> &'static str {
166        match self {
167            Self::Size256x256 => "256x256",
168            Self::Size512x512 => "512x512",
169            Self::Size1024x1024 => "1024x1024",
170            Self::Size1792x1024 => "1792x1024",
171            Self::Size1024x1792 => "1024x1792",
172            Self::Size1024x1536 => "1024x1536",
173            Self::Size1536x1024 => "1536x1024",
174            Self::Auto => "auto",
175        }
176    }
177}
178
179impl std::fmt::Display for ImageSize {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        write!(f, "{}", self.as_str())
182    }
183}
184
185/// Image quality options.
186///
187/// `Standard` and `Hd` apply to DALL-E 3. The GPT Image models use
188/// `Low`/`Medium`/`High`/`Auto` instead. GPT Image 2.5 Flare and Sunburst also
189/// accept `Max`; the API's validation error omits `max` from its list of
190/// allowed values even though requests using it succeed.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
192#[serde(rename_all = "lowercase")]
193#[non_exhaustive]
194pub enum ImageQuality {
195    /// Standard quality (DALL-E 3)
196    #[default]
197    Standard,
198    /// High definition quality (DALL-E 3)
199    Hd,
200    /// Low quality - cheapest and fastest (GPT Image models)
201    Low,
202    /// Medium quality (GPT Image models)
203    Medium,
204    /// High quality (GPT Image models)
205    High,
206    /// Highest quality (GPT Image 2.5 models only)
207    Max,
208    /// Let the model pick the quality (GPT Image models)
209    Auto,
210}
211
212impl ImageQuality {
213    /// Returns the quality string.
214    pub fn as_str(&self) -> &'static str {
215        match self {
216            Self::Standard => "standard",
217            Self::Hd => "hd",
218            Self::Low => "low",
219            Self::Medium => "medium",
220            Self::High => "high",
221            Self::Max => "max",
222            Self::Auto => "auto",
223        }
224    }
225}
226
227/// Image style options (DALL-E 3 only).
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
229#[serde(rename_all = "lowercase")]
230pub enum ImageStyle {
231    /// Vivid - hyper-real and dramatic
232    #[default]
233    Vivid,
234    /// Natural - more natural, less hyper-real
235    Natural,
236}
237
238impl ImageStyle {
239    /// Returns the style string.
240    pub fn as_str(&self) -> &'static str {
241        match self {
242            Self::Vivid => "vivid",
243            Self::Natural => "natural",
244        }
245    }
246}
247
248/// Response format for images.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
250#[serde(rename_all = "snake_case")]
251pub enum ResponseFormat {
252    /// Return URLs to the generated images (valid for 60 minutes)
253    #[default]
254    Url,
255    /// Return base64-encoded image data
256    B64Json,
257}
258
259impl ResponseFormat {
260    /// Returns the format string.
261    pub fn as_str(&self) -> &'static str {
262        match self {
263            Self::Url => "url",
264            Self::B64Json => "b64_json",
265        }
266    }
267}
268
269/// Options for image generation.
270#[derive(Debug, Clone, Default)]
271pub struct GenerateOptions {
272    /// The model to use; when `None` the API picks its own default
273    pub model: Option<ImageModel>,
274    /// Number of images to generate (1-10, DALL-E 3 only supports 1)
275    pub n: Option<u32>,
276    /// Image quality
277    ///
278    /// DALL-E 3 takes `standard`/`hd`; the GPT Image models take
279    /// `low`/`medium`/`high`/`auto` and reject the DALL-E values. GPT Image 2.5
280    /// models additionally accept `max`.
281    pub quality: Option<ImageQuality>,
282    /// Response format (URL or base64)
283    ///
284    /// **DALL-E only.** The GPT Image models reject this parameter with
285    /// `unknown_parameter` and always return base64 data.
286    pub response_format: Option<ResponseFormat>,
287    /// Image size
288    pub size: Option<ImageSize>,
289    /// Image style
290    ///
291    /// **DALL-E 3 only.** The GPT Image models reject this parameter with
292    /// `unknown_parameter`.
293    pub style: Option<ImageStyle>,
294    /// User identifier for abuse monitoring
295    pub user: Option<String>,
296}
297
298/// Options for image editing.
299#[derive(Debug, Clone, Default)]
300pub struct EditOptions {
301    /// Path to the mask image (transparent areas will be edited)
302    pub mask: Option<String>,
303    /// The model to use (GPT Image models and DALL-E 2 support editing)
304    pub model: Option<ImageModel>,
305    /// Number of images to generate (1-10)
306    pub n: Option<u32>,
307    /// Image size
308    pub size: Option<ImageSize>,
309    /// Response format
310    pub response_format: Option<ResponseFormat>,
311    /// User identifier for abuse monitoring
312    pub user: Option<String>,
313}
314
315/// Options for image variations.
316#[derive(Debug, Clone, Default)]
317pub struct VariationOptions {
318    /// The model to use (only DALL-E 2 supports variations)
319    pub model: Option<ImageModel>,
320    /// Number of variations to generate (1-10)
321    pub n: Option<u32>,
322    /// Response format
323    pub response_format: Option<ResponseFormat>,
324    /// Image size
325    pub size: Option<ImageSize>,
326    /// User identifier for abuse monitoring
327    pub user: Option<String>,
328}
329
330/// Request payload for image generation.
331#[derive(Debug, Clone, Serialize)]
332struct GenerateRequest {
333    prompt: String,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    model: Option<String>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    n: Option<u32>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    quality: Option<String>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    response_format: Option<String>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    size: Option<String>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    style: Option<String>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    user: Option<String>,
348}
349
350/// Client for interacting with the OpenAI Images API.
351///
352/// This struct provides methods to generate, edit, and create variations of images.
353/// Use [`Images::new()`] to create a new instance.
354///
355/// # Example
356///
357/// ```rust,no_run
358/// use openai_tools::images::request::{Images, GenerateOptions, ImageModel, ImageSize};
359///
360/// #[tokio::main]
361/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
362///     let images = Images::new()?;
363///
364///     let options = GenerateOptions {
365///         model: Some(ImageModel::DallE3),
366///         size: Some(ImageSize::Size1024x1024),
367///         ..Default::default()
368///     };
369///
370///     let response = images.generate("A sunset over mountains", options).await?;
371///     println!("Generated image: {:?}", response.data[0].url);
372///
373///     Ok(())
374/// }
375/// ```
376pub struct Images {
377    /// Authentication provider (OpenAI or Azure)
378    auth: AuthProvider,
379    /// Optional request timeout duration
380    timeout: Option<Duration>,
381}
382
383impl Images {
384    /// Creates a new Images client for OpenAI API.
385    ///
386    /// Initializes the client by loading the OpenAI API key from
387    /// the environment variable `OPENAI_API_KEY`. Supports `.env` file loading
388    /// via dotenvy.
389    ///
390    /// # Returns
391    ///
392    /// * `Ok(Images)` - A new Images client ready for use
393    /// * `Err(OpenAIToolError)` - If the API key is not found in the environment
394    ///
395    /// # Example
396    ///
397    /// ```rust,no_run
398    /// use openai_tools::images::request::Images;
399    ///
400    /// let images = Images::new().expect("API key should be set");
401    /// ```
402    pub fn new() -> Result<Self> {
403        let auth = AuthProvider::openai_from_env()?;
404        Ok(Self { auth, timeout: None })
405    }
406
407    /// Creates a new Images client with a custom authentication provider
408    pub fn with_auth(auth: AuthProvider) -> Self {
409        Self { auth, timeout: None }
410    }
411
412    /// Creates a new Images client for Azure OpenAI API
413    pub fn azure() -> Result<Self> {
414        let auth = AuthProvider::azure_from_env()?;
415        Ok(Self { auth, timeout: None })
416    }
417
418    /// Creates a new Images client by auto-detecting the provider
419    pub fn detect_provider() -> Result<Self> {
420        let auth = AuthProvider::from_env()?;
421        Ok(Self { auth, timeout: None })
422    }
423
424    /// Creates a new Images client with URL-based provider detection
425    pub fn with_url<S: Into<String>>(base_url: S, api_key: S) -> Self {
426        let auth = AuthProvider::from_url_with_key(base_url, api_key);
427        Self { auth, timeout: None }
428    }
429
430    /// Creates a new Images client from URL using environment variables
431    pub fn from_url<S: Into<String>>(url: S) -> Result<Self> {
432        let auth = AuthProvider::from_url(url)?;
433        Ok(Self { auth, timeout: None })
434    }
435
436    /// Returns the authentication provider
437    pub fn auth(&self) -> &AuthProvider {
438        &self.auth
439    }
440
441    /// Sets the request timeout duration.
442    ///
443    /// # Arguments
444    ///
445    /// * `timeout` - The maximum time to wait for a response
446    ///
447    /// # Returns
448    ///
449    /// A mutable reference to self for method chaining
450    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
451        self.timeout = Some(timeout);
452        self
453    }
454
455    /// Creates the HTTP client with default headers.
456    fn create_client(&self) -> Result<(request::Client, request::header::HeaderMap)> {
457        let client = create_http_client(self.timeout)?;
458        let mut headers = request::header::HeaderMap::new();
459        self.auth.apply_headers(&mut headers)?;
460        headers.insert("User-Agent", request::header::HeaderValue::from_static("openai-tools-rust"));
461        Ok((client, headers))
462    }
463
464    /// Generates images from a text prompt.
465    ///
466    /// Creates one or more images based on the provided text description.
467    ///
468    /// # Arguments
469    ///
470    /// * `prompt` - Text description of the desired image(s)
471    /// * `options` - Generation options (model, size, quality, etc.)
472    ///
473    /// # Returns
474    ///
475    /// * `Ok(ImageResponse)` - The generated image(s)
476    /// * `Err(OpenAIToolError)` - If the request fails
477    ///
478    /// # Example
479    ///
480    /// ```rust,no_run
481    /// use openai_tools::images::request::{Images, GenerateOptions, ImageQuality, ImageStyle};
482    ///
483    /// #[tokio::main]
484    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
485    ///     let images = Images::new()?;
486    ///
487    ///     let options = GenerateOptions {
488    ///         quality: Some(ImageQuality::Hd),
489    ///         style: Some(ImageStyle::Natural),
490    ///         ..Default::default()
491    ///     };
492    ///
493    ///     let response = images.generate("A serene lake at dawn", options).await?;
494    ///
495    ///     if let Some(url) = &response.data[0].url {
496    ///         println!("Image URL: {}", url);
497    ///     }
498    ///
499    ///     Ok(())
500    /// }
501    /// ```
502    pub async fn generate(&self, prompt: &str, options: GenerateOptions) -> Result<ImageResponse> {
503        let (client, mut headers) = self.create_client()?;
504        headers.insert("Content-Type", request::header::HeaderValue::from_static("application/json"));
505
506        let request_body = GenerateRequest {
507            prompt: prompt.to_string(),
508            model: options.model.map(|m| m.as_str().to_string()),
509            n: options.n,
510            quality: options.quality.map(|q| q.as_str().to_string()),
511            response_format: options.response_format.map(|f| f.as_str().to_string()),
512            size: options.size.map(|s| s.as_str().to_string()),
513            style: options.style.map(|s| s.as_str().to_string()),
514            user: options.user,
515        };
516
517        let body = serde_json::to_string(&request_body).map_err(OpenAIToolError::SerdeJsonError)?;
518
519        let url = format!("{}/generations", self.auth.endpoint(IMAGES_PATH));
520
521        let response = client.post(&url).headers(headers).body(body).send().await.map_err(OpenAIToolError::RequestError)?;
522
523        let status = response.status();
524        let content = response.text().await.map_err(OpenAIToolError::RequestError)?;
525
526        if cfg!(test) {
527            tracing::info!("Response content: {}", content);
528        }
529
530        if !status.is_success() {
531            if let Ok(error_resp) = serde_json::from_str::<ErrorResponse>(&content) {
532                return Err(OpenAIToolError::Error(error_resp.error.message.unwrap_or_default()));
533            }
534            return Err(OpenAIToolError::Error(format!("API error ({}): {}", status, content)));
535        }
536
537        serde_json::from_str::<ImageResponse>(&content).map_err(OpenAIToolError::SerdeJsonError)
538    }
539
540    /// Edits an existing image based on a prompt.
541    ///
542    /// Creates edited versions of an image by replacing areas indicated by
543    /// a transparent mask. Only available with DALL-E 2.
544    ///
545    /// # Arguments
546    ///
547    /// * `image_path` - Path to the image to edit (PNG, max 4MB, square)
548    /// * `prompt` - Text description of the desired edit
549    /// * `options` - Edit options (mask, size, etc.)
550    ///
551    /// # Returns
552    ///
553    /// * `Ok(ImageResponse)` - The edited image(s)
554    /// * `Err(OpenAIToolError)` - If the request fails
555    ///
556    /// # Example
557    ///
558    /// ```rust,no_run
559    /// use openai_tools::images::request::{Images, EditOptions};
560    ///
561    /// #[tokio::main]
562    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
563    ///     let images = Images::new()?;
564    ///
565    ///     let options = EditOptions {
566    ///         mask: Some("mask.png".to_string()),
567    ///         ..Default::default()
568    ///     };
569    ///
570    ///     let response = images.edit("original.png", "Add a red hat", options).await?;
571    ///     println!("Edited image: {:?}", response.data[0].url);
572    ///
573    ///     Ok(())
574    /// }
575    /// ```
576    pub async fn edit(&self, image_path: &str, prompt: &str, options: EditOptions) -> Result<ImageResponse> {
577        let (client, headers) = self.create_client()?;
578
579        // Read the image file
580        let image_content = tokio::fs::read(image_path).await.map_err(|e| OpenAIToolError::Error(format!("Failed to read image: {}", e)))?;
581
582        let image_filename = Path::new(image_path).file_name().and_then(|n| n.to_str()).unwrap_or("image.png").to_string();
583
584        let image_part = Part::bytes(image_content)
585            .file_name(image_filename)
586            .mime_str("image/png")
587            .map_err(|e| OpenAIToolError::Error(format!("Failed to set MIME type: {}", e)))?;
588
589        let mut form = Form::new().part("image", image_part).text("prompt", prompt.to_string());
590
591        // Add mask if provided
592        if let Some(mask_path) = options.mask {
593            let mask_content = tokio::fs::read(&mask_path).await.map_err(|e| OpenAIToolError::Error(format!("Failed to read mask: {}", e)))?;
594
595            let mask_filename = Path::new(&mask_path).file_name().and_then(|n| n.to_str()).unwrap_or("mask.png").to_string();
596
597            let mask_part = Part::bytes(mask_content)
598                .file_name(mask_filename)
599                .mime_str("image/png")
600                .map_err(|e| OpenAIToolError::Error(format!("Failed to set MIME type: {}", e)))?;
601
602            form = form.part("mask", mask_part);
603        }
604
605        // Add optional parameters
606        if let Some(model) = options.model {
607            form = form.text("model", model.as_str().to_string());
608        }
609        if let Some(n) = options.n {
610            form = form.text("n", n.to_string());
611        }
612        if let Some(size) = options.size {
613            form = form.text("size", size.as_str().to_string());
614        }
615        if let Some(response_format) = options.response_format {
616            form = form.text("response_format", response_format.as_str().to_string());
617        }
618        if let Some(user) = options.user {
619            form = form.text("user", user);
620        }
621
622        let url = format!("{}/edits", self.auth.endpoint(IMAGES_PATH));
623
624        let response = client.post(&url).headers(headers).multipart(form).send().await.map_err(OpenAIToolError::RequestError)?;
625
626        let status = response.status();
627        let content = response.text().await.map_err(OpenAIToolError::RequestError)?;
628
629        if cfg!(test) {
630            tracing::info!("Response content: {}", content);
631        }
632
633        if !status.is_success() {
634            if let Ok(error_resp) = serde_json::from_str::<ErrorResponse>(&content) {
635                return Err(OpenAIToolError::Error(error_resp.error.message.unwrap_or_default()));
636            }
637            return Err(OpenAIToolError::Error(format!("API error ({}): {}", status, content)));
638        }
639
640        serde_json::from_str::<ImageResponse>(&content).map_err(OpenAIToolError::SerdeJsonError)
641    }
642
643    /// Creates variations of an existing image.
644    ///
645    /// Only available with DALL-E 2.
646    ///
647    /// # Arguments
648    ///
649    /// * `image_path` - Path to the image to create variations of (PNG, max 4MB, square)
650    /// * `options` - Variation options (n, size, etc.)
651    ///
652    /// # Returns
653    ///
654    /// * `Ok(ImageResponse)` - The image variation(s)
655    /// * `Err(OpenAIToolError)` - If the request fails
656    ///
657    /// # Example
658    ///
659    /// ```rust,no_run
660    /// use openai_tools::images::request::{Images, VariationOptions, ImageModel};
661    ///
662    /// #[tokio::main]
663    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
664    ///     let images = Images::new()?;
665    ///
666    ///     let options = VariationOptions {
667    ///         model: Some(ImageModel::DallE2),
668    ///         n: Some(3),
669    ///         ..Default::default()
670    ///     };
671    ///
672    ///     let response = images.variation("original.png", options).await?;
673    ///
674    ///     for (i, image) in response.data.iter().enumerate() {
675    ///         println!("Variation {}: {:?}", i + 1, image.url);
676    ///     }
677    ///
678    ///     Ok(())
679    /// }
680    /// ```
681    pub async fn variation(&self, image_path: &str, options: VariationOptions) -> Result<ImageResponse> {
682        let (client, headers) = self.create_client()?;
683
684        // Read the image file
685        let image_content = tokio::fs::read(image_path).await.map_err(|e| OpenAIToolError::Error(format!("Failed to read image: {}", e)))?;
686
687        let image_filename = Path::new(image_path).file_name().and_then(|n| n.to_str()).unwrap_or("image.png").to_string();
688
689        let image_part = Part::bytes(image_content)
690            .file_name(image_filename)
691            .mime_str("image/png")
692            .map_err(|e| OpenAIToolError::Error(format!("Failed to set MIME type: {}", e)))?;
693
694        let mut form = Form::new().part("image", image_part);
695
696        // Add optional parameters
697        if let Some(model) = options.model {
698            form = form.text("model", model.as_str().to_string());
699        }
700        if let Some(n) = options.n {
701            form = form.text("n", n.to_string());
702        }
703        if let Some(size) = options.size {
704            form = form.text("size", size.as_str().to_string());
705        }
706        if let Some(response_format) = options.response_format {
707            form = form.text("response_format", response_format.as_str().to_string());
708        }
709        if let Some(user) = options.user {
710            form = form.text("user", user);
711        }
712
713        let url = format!("{}/variations", self.auth.endpoint(IMAGES_PATH));
714
715        let response = client.post(&url).headers(headers).multipart(form).send().await.map_err(OpenAIToolError::RequestError)?;
716
717        let status = response.status();
718        let content = response.text().await.map_err(OpenAIToolError::RequestError)?;
719
720        if cfg!(test) {
721            tracing::info!("Response content: {}", content);
722        }
723
724        if !status.is_success() {
725            if let Ok(error_resp) = serde_json::from_str::<ErrorResponse>(&content) {
726                return Err(OpenAIToolError::Error(error_resp.error.message.unwrap_or_default()));
727            }
728            return Err(OpenAIToolError::Error(format!("API error ({}): {}", status, content)));
729        }
730
731        serde_json::from_str::<ImageResponse>(&content).map_err(OpenAIToolError::SerdeJsonError)
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    // =========================================================================
740    // Image models added in 2026.
741    //
742    // Model IDs, sizes and quality values verified against the OpenAI API
743    // reference (https://developers.openai.com/api/docs/models), August 2026.
744    // =========================================================================
745
746    #[test]
747    fn test_new_image_models_as_str() {
748        assert_eq!(ImageModel::GptImage2.as_str(), "gpt-image-2");
749        assert_eq!(ImageModel::GptImage2_5Flare.as_str(), "gpt-image-2.5-flare");
750        assert_eq!(ImageModel::GptImage2_5Sunburst.as_str(), "gpt-image-2.5-sunburst");
751        assert_eq!(ImageModel::GptImage1Mini.as_str(), "gpt-image-1-mini");
752    }
753
754    #[test]
755    fn test_new_image_models_serialization() {
756        for (model, expected) in [
757            (ImageModel::GptImage2, "gpt-image-2"),
758            (ImageModel::GptImage2_5Flare, "gpt-image-2.5-flare"),
759            (ImageModel::GptImage2_5Sunburst, "gpt-image-2.5-sunburst"),
760            (ImageModel::GptImage1Mini, "gpt-image-1-mini"),
761        ] {
762            let json = serde_json::to_string(&model).unwrap();
763            assert_eq!(json, format!("\"{}\"", expected), "Wrong serialization for {:?}", model);
764            let deserialized: ImageModel = serde_json::from_str(&json).unwrap();
765            assert_eq!(deserialized, model, "Serialization roundtrip failed for {:?}", model);
766        }
767    }
768
769    /// The GPT Image models use portrait/landscape sizes that differ from the
770    /// DALL-E 3 set.
771    #[test]
772    fn test_gpt_image_sizes() {
773        assert_eq!(ImageSize::Size1024x1536.as_str(), "1024x1536");
774        assert_eq!(ImageSize::Size1536x1024.as_str(), "1536x1024");
775
776        for (size, expected) in [(ImageSize::Size1024x1536, "1024x1536"), (ImageSize::Size1536x1024, "1536x1024")] {
777            let json = serde_json::to_string(&size).unwrap();
778            assert_eq!(json, format!("\"{}\"", expected), "Wrong serialization for {:?}", size);
779        }
780    }
781
782    /// GPT Image models take `low`/`medium`/`high`/`auto` instead of the
783    /// DALL-E 3 `standard`/`hd` pair; GPT Image 2.5 also accepts `max`.
784    #[test]
785    fn test_gpt_image_quality_values() {
786        assert_eq!(ImageQuality::Low.as_str(), "low");
787        assert_eq!(ImageQuality::Medium.as_str(), "medium");
788        assert_eq!(ImageQuality::High.as_str(), "high");
789        assert_eq!(ImageQuality::Max.as_str(), "max");
790        assert_eq!(ImageQuality::Auto.as_str(), "auto");
791
792        // The DALL-E 3 values must keep working.
793        assert_eq!(ImageQuality::Standard.as_str(), "standard");
794        assert_eq!(ImageQuality::Hd.as_str(), "hd");
795    }
796
797    #[test]
798    fn test_gpt_image_quality_serialization() {
799        for (quality, expected) in [
800            (ImageQuality::Low, "low"),
801            (ImageQuality::Medium, "medium"),
802            (ImageQuality::High, "high"),
803            (ImageQuality::Max, "max"),
804            (ImageQuality::Auto, "auto"),
805            (ImageQuality::Standard, "standard"),
806            (ImageQuality::Hd, "hd"),
807        ] {
808            let json = serde_json::to_string(&quality).unwrap();
809            assert_eq!(json, format!("\"{}\"", expected), "Wrong serialization for {:?}", quality);
810            let deserialized: ImageQuality = serde_json::from_str(&json).unwrap();
811            assert_eq!(deserialized, quality, "Serialization roundtrip failed for {:?}", quality);
812        }
813    }
814
815    /// Image models present in the live /v1/models listing but previously
816    /// missing from the enum. Verified live against the API (August 2026).
817    #[test]
818    fn test_previously_missing_image_models() {
819        for (model, expected) in [(ImageModel::GptImage1_5, "gpt-image-1.5"), (ImageModel::ChatGptImageLatest, "chatgpt-image-latest")] {
820            assert_eq!(model.as_str(), expected, "Wrong model ID for {:?}", model);
821            let json = serde_json::to_string(&model).unwrap();
822            assert_eq!(json, format!("\"{}\"", expected), "Wrong serialization for {:?}", model);
823            let deserialized: ImageModel = serde_json::from_str(&json).unwrap();
824            assert_eq!(deserialized, model, "Serialization roundtrip failed for {:?}", model);
825        }
826    }
827
828    /// OpenAI retired `dall-e-2`/`dall-e-3` on api.openai.com, so the default
829    /// must name a model that still resolves there. The variants stay because
830    /// Azure OpenAI deployments can still serve DALL-E.
831    #[test]
832    fn test_default_image_model_is_a_live_openai_model() {
833        assert_eq!(ImageModel::default(), ImageModel::GptImage1);
834        assert_ne!(ImageModel::default(), ImageModel::DallE3, "the default must not be a retired model");
835    }
836
837    /// The GPT Image models accept `auto` in place of an explicit size.
838    #[test]
839    fn test_auto_image_size() {
840        assert_eq!(ImageSize::Auto.as_str(), "auto");
841        assert_eq!(serde_json::to_string(&ImageSize::Auto).unwrap(), "\"auto\"");
842    }
843}