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