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