Skip to main content

rig_core/providers/gemini/
image_generation.rs

1//! Gemini image generation support.
2
3use super::client::{ApiResponse, Client};
4use super::completion::gemini_api_types::{
5    Content, GenerateContentRequest, GenerateContentResponse, GenerationConfig, ImageConfig, Part,
6    PartKind, ResponseModality, Role,
7};
8use crate::http_client::HttpClientExt;
9use crate::image_generation::{ImageGenerationError, ImageGenerationRequest};
10use crate::{http_client, image_generation};
11use base64::Engine;
12use base64::prelude::BASE64_STANDARD;
13use serde_json::Value;
14
15/// `gemini-2.5-flash-image` image generation model, commonly referred to as Nano Banana.
16pub const GEMINI_2_5_FLASH_IMAGE: &str = super::completion::GEMINI_2_5_FLASH_IMAGE;
17
18/// Gemini image generation model.
19#[derive(Clone)]
20pub struct ImageGenerationModel<T = reqwest::Client> {
21    client: Client<T>,
22    /// Name of the model, for example [`GEMINI_2_5_FLASH_IMAGE`].
23    pub model: String,
24}
25
26impl<T> ImageGenerationModel<T> {
27    pub(crate) fn new(client: Client<T>, model: impl Into<String>) -> Self {
28        Self {
29            client,
30            model: model.into(),
31        }
32    }
33}
34
35impl TryFrom<GenerateContentResponse>
36    for image_generation::ImageGenerationResponse<GenerateContentResponse>
37{
38    type Error = ImageGenerationError;
39
40    fn try_from(value: GenerateContentResponse) -> Result<Self, Self::Error> {
41        let image = first_image_bytes(&value)?;
42
43        Ok(image_generation::ImageGenerationResponse {
44            image,
45            response: value,
46        })
47    }
48}
49
50impl<T> image_generation::ImageGenerationModel for ImageGenerationModel<T>
51where
52    T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
53{
54    type Response = GenerateContentResponse;
55
56    type Client = Client<T>;
57
58    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
59        Self::new(client.clone(), model)
60    }
61
62    async fn image_generation(
63        &self,
64        generation_request: ImageGenerationRequest,
65    ) -> Result<image_generation::ImageGenerationResponse<Self::Response>, ImageGenerationError>
66    {
67        let body = serde_json::to_vec(&create_request_body(generation_request)?)?;
68
69        let request = self
70            .client
71            .post(generate_content_path(&self.model))?
72            .body(body)
73            .map_err(|e| ImageGenerationError::HttpError(e.into()))?;
74
75        let response = self.client.send(request).await?;
76
77        let status = response.status();
78        let text = http_client::text(response).await?;
79
80        if !status.is_success() {
81            return Err(ImageGenerationError::from_http_response(status, text));
82        }
83
84        match serde_json::from_str::<ApiResponse<GenerateContentResponse>>(&text)? {
85            ApiResponse::Ok(response) => response.try_into(),
86            ApiResponse::Err(err) => {
87                tracing::warn!(message = %err.message, "provider returned an error response");
88                Err(ImageGenerationError::from_http_response(status, text))
89            }
90        }
91    }
92}
93
94fn generate_content_path(model: &str) -> String {
95    format!("/v1beta/models/{model}:generateContent")
96}
97
98fn create_request_body(
99    generation_request: ImageGenerationRequest,
100) -> Result<Value, ImageGenerationError> {
101    let request = GenerateContentRequest {
102        contents: vec![Content {
103            role: Some(Role::User),
104            parts: vec![Part {
105                thought: None,
106                thought_signature: None,
107                part: PartKind::Text(generation_request.prompt),
108                additional_params: None,
109            }],
110        }],
111        tools: None,
112        tool_config: None,
113        generation_config: Some(GenerationConfig {
114            response_modalities: Some(vec![ResponseModality::Image]),
115            image_config: Some(ImageConfig {
116                aspect_ratio: aspect_ratio(generation_request.width, generation_request.height),
117                image_size: None,
118            }),
119            ..Default::default()
120        }),
121        safety_settings: None,
122        system_instruction: None,
123        additional_params: None,
124    };
125
126    let mut body = serde_json::to_value(request)?;
127
128    if let Some(additional_params) = generation_request.additional_params {
129        merge_json_deep(&mut body, additional_params);
130    }
131
132    Ok(body)
133}
134
135fn merge_json_deep(target: &mut Value, source: Value) {
136    match (target, source) {
137        (Value::Object(target), Value::Object(source)) => {
138            for (key, value) in source {
139                if let Some(existing) = target.get_mut(&key) {
140                    merge_json_deep(existing, value);
141                } else {
142                    target.insert(key, value);
143                }
144            }
145        }
146        (target, source) => *target = source,
147    }
148}
149
150fn aspect_ratio(width: u32, height: u32) -> Option<String> {
151    match (width, height) {
152        (0, _) | (_, 0) => None,
153        (w, h) if w == h => Some("1:1".to_string()),
154        (w, h) if w.saturating_mul(3) == h.saturating_mul(4) => Some("3:4".to_string()),
155        (w, h) if w.saturating_mul(4) == h.saturating_mul(3) => Some("4:3".to_string()),
156        (w, h) if w.saturating_mul(9) == h.saturating_mul(16) => Some("9:16".to_string()),
157        (w, h) if w.saturating_mul(16) == h.saturating_mul(9) => Some("16:9".to_string()),
158        _ => None,
159    }
160}
161
162fn first_image_bytes(response: &GenerateContentResponse) -> Result<Vec<u8>, ImageGenerationError> {
163    for candidate in &response.candidates {
164        let Some(content) = &candidate.content else {
165            continue;
166        };
167
168        for part in &content.parts {
169            if part.thought == Some(true) {
170                continue;
171            }
172
173            if let PartKind::InlineData(inline_data) = &part.part {
174                if !inline_data.mime_type.starts_with("image/") {
175                    continue;
176                }
177
178                return BASE64_STANDARD.decode(&inline_data.data).map_err(|err| {
179                    ImageGenerationError::ResponseError(format!(
180                        "Gemini image data was not valid base64: {err}"
181                    ))
182                });
183            }
184        }
185    }
186
187    Err(ImageGenerationError::ResponseError(
188        "Gemini image generation response did not include image data".into(),
189    ))
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::providers::gemini::completion::gemini_api_types::{
196        Blob, ContentCandidate, FinishReason, UsageMetadata,
197    };
198    use serde_json::json;
199
200    fn image_generation_request(prompt: &str) -> ImageGenerationRequest {
201        ImageGenerationRequest {
202            prompt: prompt.to_string(),
203            width: 1024,
204            height: 1024,
205            additional_params: None,
206        }
207    }
208
209    #[test]
210    fn request_body_uses_gemini_image_generation_shape() {
211        let body = create_request_body(image_generation_request("Generate an image of an axolotl"))
212            .expect("request should serialize");
213
214        assert_eq!(
215            generate_content_path(GEMINI_2_5_FLASH_IMAGE),
216            "/v1beta/models/gemini-2.5-flash-image:generateContent"
217        );
218        assert_eq!(body["contents"][0]["role"], "user");
219        assert_eq!(
220            body["contents"][0]["parts"][0]["text"],
221            "Generate an image of an axolotl"
222        );
223        assert_eq!(
224            body["generationConfig"]["responseModalities"],
225            json!(["IMAGE"])
226        );
227        assert_eq!(
228            body["generationConfig"]["imageConfig"]["aspectRatio"],
229            "1:1"
230        );
231    }
232
233    #[test]
234    fn request_body_allows_additional_params_to_override_image_config() {
235        let mut request = image_generation_request("Generate an image of an axolotl");
236        request.additional_params = Some(json!({
237            "generationConfig": {
238                "imageConfig": {
239                    "aspectRatio": "16:9",
240                    "imageSize": "2K"
241                }
242            }
243        }));
244
245        let body = create_request_body(request).expect("request should serialize");
246
247        assert_eq!(
248            body["generationConfig"]["imageConfig"]["aspectRatio"],
249            "16:9"
250        );
251        assert_eq!(body["generationConfig"]["imageConfig"]["imageSize"], "2K");
252        assert_eq!(
253            body["generationConfig"]["responseModalities"],
254            json!(["IMAGE"])
255        );
256    }
257
258    #[test]
259    fn response_parsing_returns_first_non_thought_inline_image() {
260        let response = GenerateContentResponse {
261            candidates: vec![ContentCandidate {
262                content: Some(Content {
263                    role: Some(Role::Model),
264                    parts: vec![
265                        Part {
266                            thought: Some(false),
267                            thought_signature: None,
268                            part: PartKind::Text("Here you go".to_string()),
269                            additional_params: None,
270                        },
271                        Part {
272                            thought: Some(true),
273                            thought_signature: None,
274                            part: PartKind::InlineData(Blob {
275                                mime_type: "image/png".to_string(),
276                                data: BASE64_STANDARD.encode("thought image"),
277                            }),
278                            additional_params: None,
279                        },
280                        Part {
281                            thought: Some(false),
282                            thought_signature: None,
283                            part: PartKind::InlineData(Blob {
284                                mime_type: "image/png".to_string(),
285                                data: BASE64_STANDARD.encode("final image"),
286                            }),
287                            additional_params: None,
288                        },
289                    ],
290                }),
291                finish_reason: Some(FinishReason::Stop),
292                safety_ratings: None,
293                citation_metadata: None,
294                token_count: None,
295                avg_logprobs: None,
296                logprobs_result: None,
297                index: None,
298                finish_message: None,
299            }],
300            prompt_feedback: None,
301            usage_metadata: Some(UsageMetadata {
302                prompt_token_count: 1,
303                cached_content_token_count: None,
304                candidates_token_count: Some(1),
305                total_token_count: 2,
306                thoughts_token_count: None,
307                prompt_tokens_details: None,
308                cache_tokens_details: None,
309                candidates_tokens_details: None,
310                tool_use_prompt_token_count: None,
311                tool_use_prompt_tokens_details: None,
312                traffic_type: None,
313            }),
314            model_version: Some(GEMINI_2_5_FLASH_IMAGE.to_string()),
315            response_id: "response-id".to_string(),
316        };
317
318        let parsed: image_generation::ImageGenerationResponse<GenerateContentResponse> = response
319            .try_into()
320            .expect("response should contain an image");
321
322        assert_eq!(parsed.image, b"final image");
323    }
324
325    #[test]
326    fn response_parsing_rejects_text_only_response() {
327        let response = GenerateContentResponse {
328            candidates: vec![ContentCandidate {
329                content: Some(Content {
330                    role: Some(Role::Model),
331                    parts: vec![Part {
332                        thought: Some(false),
333                        thought_signature: None,
334                        part: PartKind::Text("No image".to_string()),
335                        additional_params: None,
336                    }],
337                }),
338                finish_reason: Some(FinishReason::Stop),
339                safety_ratings: None,
340                citation_metadata: None,
341                token_count: None,
342                avg_logprobs: None,
343                logprobs_result: None,
344                index: None,
345                finish_message: None,
346            }],
347            prompt_feedback: None,
348            usage_metadata: None,
349            model_version: Some(GEMINI_2_5_FLASH_IMAGE.to_string()),
350            response_id: "response-id".to_string(),
351        };
352
353        let err = image_generation::ImageGenerationResponse::<GenerateContentResponse>::try_from(
354            response,
355        )
356        .expect_err("text-only responses should fail");
357
358        assert!(err.to_string().contains("did not include image data"));
359    }
360
361    #[tokio::test]
362    async fn image_generation_non_success_preserves_status_and_body() {
363        use crate::client::image_generation::ImageGenerationClient;
364        use crate::image_generation::ImageGenerationModel as _;
365        use crate::test_utils::RecordingHttpClient;
366
367        let body = r#"{"error":{"code":503,"message":"boom","status":"UNAVAILABLE"}}"#;
368        let http_client =
369            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
370        let client = Client::builder()
371            .api_key("test-key")
372            .http_client(http_client)
373            .build()
374            .expect("build client");
375        let model = client.image_generation_model(GEMINI_2_5_FLASH_IMAGE);
376
377        let error = model
378            .image_generation(image_generation_request("draw a cat"))
379            .await
380            .expect_err("should fail with non-success status");
381
382        assert!(matches!(error, ImageGenerationError::HttpError(_)));
383        assert_eq!(
384            error.provider_response_status(),
385            Some(http::StatusCode::SERVICE_UNAVAILABLE)
386        );
387        assert_eq!(error.provider_response_body(), Some(body));
388    }
389
390    #[tokio::test]
391    async fn image_generation_2xx_error_envelope_preserves_status_and_body() {
392        use crate::client::image_generation::ImageGenerationClient;
393        use crate::image_generation::ImageGenerationModel as _;
394        use crate::test_utils::RecordingHttpClient;
395
396        // 200 OK whose body deserializes to `ApiResponse::Err` (Gemini `ApiErrorResponse`
397        // requires a top-level `message` field; the absence of `candidates`/`response_id`
398        // prevents it from matching the success `GenerateContentResponse` variant).
399        let body =
400            r#"{"message":"boom","error":{"code":503,"message":"boom","status":"UNAVAILABLE"}}"#;
401        let http_client = RecordingHttpClient::new(body); // 200 OK
402        let client = Client::builder()
403            .api_key("test-key")
404            .http_client(http_client)
405            .build()
406            .expect("build client");
407        let model = client.image_generation_model(GEMINI_2_5_FLASH_IMAGE);
408
409        let error = model
410            .image_generation(image_generation_request("draw a cat"))
411            .await
412            .expect_err("should fail with provider error envelope");
413
414        match &error {
415            ImageGenerationError::ProviderResponse(stored) => {
416                assert_eq!(stored.body, body);
417                assert_eq!(stored.status, Some(http::StatusCode::OK));
418            }
419            other => panic!("expected ProviderResponse, got {other:?}"),
420        }
421    }
422}