rig_core/providers/openai/
image_generation.rs1use 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
11pub 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
46pub type ImageGenerationModel<T = reqwest::Client> =
48 GenericImageGenerationModel<OpenAIResponsesExt, T>;
49
50pub type CompletionsImageGenerationModel<T = reqwest::Client> =
52 GenericImageGenerationModel<OpenAICompletionsExt, T>;
53
54fn 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 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 #[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 #[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 #[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 #[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}