Skip to main content

rig_core/providers/openai/
image_generation.rs

1use super::{OpenAICompletionsExt, OpenAIResponsesExt};
2use crate::image_generation;
3use crate::image_generation::{ImageGenerationError, ImageGenerationRequest};
4use crate::json_utils::merge_inplace;
5use crate::providers::internal::image_generation::{
6    GenericImageGenerationModel, JsonImageGenerationProvider, decode_base64_image,
7};
8use serde::Deserialize;
9use serde_json::json;
10
11// ================================================================
12// OpenAI Image Generation API
13// ================================================================
14pub const DALL_E_2: &str = "dall-e-2";
15pub const DALL_E_3: &str = "dall-e-3";
16pub const GPT_IMAGE_1: &str = "gpt-image-1";
17pub const GPT_IMAGE_1_5: &str = "gpt-image-1.5";
18pub const GPT_IMAGE_2: &str = "gpt-image-2";
19
20#[derive(Debug, Deserialize)]
21pub struct ImageGenerationData {
22    pub b64_json: String,
23}
24
25#[derive(Debug, Deserialize)]
26pub struct ImageGenerationResponse {
27    pub created: i32,
28    pub data: Vec<ImageGenerationData>,
29}
30
31impl TryFrom<ImageGenerationResponse>
32    for image_generation::ImageGenerationResponse<ImageGenerationResponse>
33{
34    type Error = ImageGenerationError;
35
36    fn try_from(value: ImageGenerationResponse) -> Result<Self, Self::Error> {
37        decode_base64_image(
38            value,
39            |response| response.data.first().map(|image| image.b64_json.as_str()),
40            "missing image data",
41            None,
42        )
43    }
44}
45
46/// OpenAI image generation model.
47pub type ImageGenerationModel<T = reqwest::Client> =
48    GenericImageGenerationModel<OpenAIResponsesExt, T>;
49
50/// OpenAI image generation model for a client using Chat Completions.
51pub type CompletionsImageGenerationModel<T = reqwest::Client> =
52    GenericImageGenerationModel<OpenAICompletionsExt, T>;
53
54/// Build the `/v1/images/generations` body.
55///
56/// `response_format` is deliberately absent: it is no longer part of this
57/// endpoint's request schema, which rejects it before it even looks at the
58/// model — a request naming a model that does not exist still fails on
59/// `400 Unknown parameter: 'response_format'` first. Rig used to add it for
60/// every model outside a hardcoded `gpt-image-1`/`1.5`/`2` allowlist, so every
61/// other image model — `gpt-image-1-mini`, `chatgpt-image-latest`, and any
62/// dated snapshot of an allowlisted model such as `gpt-image-2-2026-04-21` —
63/// could not generate an image at all. The models this endpoint currently
64/// serves answer with `data[].b64_json`, which is what
65/// [`decode_base64_image`] reads.
66///
67/// This is a statement about *this* endpoint. An OpenAI-**compatible** images
68/// endpoint reached through the same client may still take the field, and may
69/// need it to answer with base64 rather than a URL; such a caller passes it
70/// explicitly through `additional_params`, which the merge below now honors.
71fn build_request(
72    model: &str,
73    generation_request: ImageGenerationRequest,
74) -> Result<serde_json::Value, ImageGenerationError> {
75    let mut request = json!({
76        "model": model,
77        "prompt": generation_request.prompt,
78        "size": format!("{}x{}", generation_request.width, generation_request.height),
79    });
80
81    // Last, so a caller can reach the endpoint's other parameters (`quality`,
82    // `background`, `output_format`, `user`, …) and override what is derived
83    // above. xAI's and Gemini's image bodies already honor this field;
84    // dropping it here made `ImageGenerationRequestBuilder::additional_params`
85    // silently inert for OpenAI.
86    //
87    // Azure OpenAI's image body (`providers::azure`) has both defects and in a
88    // worse combination: it hardcodes `response_format` *and* drops
89    // `additional_params`, so an Azure caller cannot even work around the
90    // former. Left alone here because a fix that cannot be recorded against
91    // Azure would be a guess, which is what this change set is trying not to
92    // ship.
93    if let Some(additional_params) = generation_request.additional_params {
94        merge_inplace(&mut request, additional_params);
95    }
96
97    Ok(request)
98}
99
100impl JsonImageGenerationProvider for OpenAIResponsesExt {
101    const IMAGE_GENERATION_PATH: &'static str = "/images/generations";
102    type Response = ImageGenerationResponse;
103
104    fn image_generation_request_body(
105        model: &str,
106        request: ImageGenerationRequest,
107    ) -> Result<serde_json::Value, ImageGenerationError> {
108        build_request(model, request)
109    }
110}
111
112impl JsonImageGenerationProvider for OpenAICompletionsExt {
113    const IMAGE_GENERATION_PATH: &'static str = "/images/generations";
114    type Response = ImageGenerationResponse;
115
116    fn image_generation_request_body(
117        model: &str,
118        request: ImageGenerationRequest,
119    ) -> Result<serde_json::Value, ImageGenerationError> {
120        build_request(model, request)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::client::image_generation::ImageGenerationClient;
128    use crate::image_generation::ImageGenerationModel as _;
129    use crate::providers::openai::Client;
130    use crate::test_utils::RecordingHttpClient;
131
132    fn request() -> ImageGenerationRequest {
133        ImageGenerationRequest {
134            prompt: "draw a cat".to_string(),
135            width: 256,
136            height: 256,
137            additional_params: None,
138        }
139    }
140
141    fn body(model: &str, additional_params: Option<serde_json::Value>) -> serde_json::Value {
142        build_request(
143            model,
144            ImageGenerationRequest {
145                additional_params,
146                ..request()
147            },
148        )
149        .expect("body should build")
150    }
151
152    /// The field is not in the endpoint's request schema, so no model may be
153    /// sent it — including the ones the old hardcoded allowlist happened to
154    /// cover, and the retired `dall-e` names rig still exports.
155    #[test]
156    fn build_request_never_sends_response_format() {
157        for model in [
158            DALL_E_2,
159            DALL_E_3,
160            GPT_IMAGE_1,
161            GPT_IMAGE_1_5,
162            GPT_IMAGE_2,
163            "gpt-image-1-mini",
164            "gpt-image-2-2026-04-21",
165            "chatgpt-image-latest",
166        ] {
167            assert!(
168                body(model, None).get("response_format").is_none(),
169                "{model} must not be sent a field outside the endpoint's schema"
170            );
171        }
172    }
173
174    /// Allowlisted and unlisted models now build the *same* body — the split
175    /// the allowlist created is what made unlisted models unusable.
176    #[test]
177    fn build_request_is_model_independent_apart_from_the_model_field() {
178        let listed = body(GPT_IMAGE_1, None);
179        let unlisted = body("gpt-image-1-mini", None);
180
181        assert_eq!(listed["model"], json!(GPT_IMAGE_1));
182        assert_eq!(unlisted["model"], json!("gpt-image-1-mini"));
183        assert_eq!(
184            listed.as_object().map(|body| body.len()),
185            unlisted.as_object().map(|body| body.len())
186        );
187        assert_eq!(listed["prompt"], unlisted["prompt"]);
188        assert_eq!(listed["size"], unlisted["size"]);
189    }
190
191    #[test]
192    fn build_request_derives_prompt_and_size() {
193        let body = body(GPT_IMAGE_1, None);
194
195        assert_eq!(body["prompt"], json!("draw a cat"));
196        assert_eq!(body["size"], json!("256x256"));
197    }
198
199    #[test]
200    fn build_request_merges_additional_params() {
201        let body = body(
202            GPT_IMAGE_1,
203            Some(json!({ "quality": "low", "background": "opaque" })),
204        );
205
206        assert_eq!(body["quality"], json!("low"));
207        assert_eq!(body["background"], json!("opaque"));
208    }
209
210    /// Merged last, so a caller can override each derived key.
211    #[test]
212    fn build_request_lets_additional_params_override_derived_keys() {
213        let body = body(
214            GPT_IMAGE_1,
215            Some(json!({ "model": "other", "prompt": "other prompt", "size": "1024x1024" })),
216        );
217
218        assert_eq!(body["model"], json!("other"));
219        assert_eq!(body["prompt"], json!("other prompt"));
220        assert_eq!(body["size"], json!("1024x1024"));
221    }
222
223    /// The escape hatch: a compatible endpoint that still wants
224    /// `response_format` can be handed it explicitly.
225    #[test]
226    fn build_request_lets_a_caller_reinstate_response_format() {
227        let body = body(GPT_IMAGE_1, Some(json!({ "response_format": "b64_json" })));
228
229        assert_eq!(body["response_format"], json!("b64_json"));
230    }
231
232    #[test]
233    fn build_request_ignores_non_object_additional_params() {
234        assert_eq!(
235            body(GPT_IMAGE_1, Some(json!("not-an-object"))),
236            body(GPT_IMAGE_1, None)
237        );
238        assert_eq!(
239            body(GPT_IMAGE_1, Some(json!(null))),
240            body(GPT_IMAGE_1, None)
241        );
242    }
243
244    #[tokio::test]
245    async fn image_generation_non_success_response_preserves_status_and_body() {
246        let body = r#"{"error":{"message":"invalid image","type":"invalid_request_error"}}"#;
247        let http_client =
248            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
249        let client = Client::builder()
250            .api_key("test-key")
251            .http_client(http_client)
252            .build()
253            .expect("build client");
254        let model = client.image_generation_model(DALL_E_3);
255
256        let error = model
257            .image_generation(request())
258            .await
259            .expect_err("image generation should fail with non-success status");
260
261        assert!(matches!(error, ImageGenerationError::HttpError(_)));
262        assert_eq!(
263            error.provider_response_status(),
264            Some(http::StatusCode::BAD_REQUEST)
265        );
266        assert_eq!(error.provider_response_body(), Some(body));
267    }
268
269    #[tokio::test]
270    async fn image_generation_preserves_raw_provider_error_json_on_api_error_envelope() {
271        let body = r#"{"message":"quota exceeded","type":"insufficient_quota"}"#;
272        let http_client = RecordingHttpClient::new(body);
273        let client = Client::builder()
274            .api_key("test-key")
275            .http_client(http_client)
276            .build()
277            .expect("build client");
278        let model = client.image_generation_model(DALL_E_3);
279
280        let error = model
281            .image_generation(request())
282            .await
283            .expect_err("image generation should fail with provider error envelope");
284
285        match &error {
286            ImageGenerationError::ProviderResponse(stored) => {
287                assert_eq!(stored.body, body);
288                assert_eq!(stored.status, Some(http::StatusCode::OK));
289                assert_eq!(error.provider_response_body(), Some(body));
290            }
291            other => panic!("expected ProviderResponse, got {other:?}"),
292        }
293    }
294}