Skip to main content

heartbit_core/tool/builtins/
image_generate.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use serde_json::json;
5
6use crate::error::Error;
7use crate::llm::types::ToolDefinition;
8use crate::tool::{Tool, ToolOutput};
9
10const DEFAULT_MODEL: &str = "google/gemini-3.1-flash-image-preview";
11const IMAGE_MARKER_PREFIX: &str = "[IMAGE:base64:";
12
13/// Builtin tool that generates images from a text prompt via the OpenRouter image API.
14///
15/// Sends a request to the configured model (default: `google/gemini-3.1-flash-image-preview`)
16/// and returns the result as a base64-encoded inline image block marked with the
17/// `[IMAGE:base64:...]` prefix so downstream handlers can decode it. Requires an
18/// `OPENROUTER_API_KEY` environment variable; construction fails gracefully via
19/// `try_new` if the HTTP client cannot be initialised.
20pub struct ImageGenerateTool {
21    client: reqwest::Client,
22}
23
24impl ImageGenerateTool {
25    /// Create an `ImageGenerateTool`.
26    ///
27    /// Panics if the HTTP client cannot be built. Use [`ImageGenerateTool::try_new`]
28    /// if you need to handle the error.
29    pub fn new() -> Self {
30        Self::try_new().expect("failed to build reqwest client")
31    }
32
33    /// Create an `ImageGenerateTool`, returning `Err` on failure.
34    ///
35    /// Returns `Err` if the underlying HTTP client cannot be constructed
36    /// (e.g., TLS initialisation failure).
37    pub fn try_new() -> Result<Self, crate::error::Error> {
38        let client = crate::http::vendor_client_builder()
39            .timeout(std::time::Duration::from_secs(120))
40            .build()
41            .map_err(|e| {
42                crate::error::Error::Agent(format!("failed to build reqwest client: {e}"))
43            })?;
44        Ok(Self { client })
45    }
46}
47
48impl Default for ImageGenerateTool {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl Tool for ImageGenerateTool {
55    fn definition(&self) -> ToolDefinition {
56        ToolDefinition {
57            name: "image_generate".into(),
58            description: "Generate an image from a text prompt using Gemini via OpenRouter. \
59                          Requires OPENROUTER_API_KEY environment variable. \
60                          Returns base64-encoded image data."
61                .into(),
62            input_schema: json!({
63                "type": "object",
64                "properties": {
65                    "prompt": {
66                        "type": "string",
67                        "description": "Text description of the image to generate"
68                    },
69                    "style": {
70                        "type": "string",
71                        "description": "Optional style modifier (e.g., 'photorealistic', 'illustration', 'minimal')"
72                    },
73                    "model": {
74                        "type": "string",
75                        "description": "OpenRouter model ID (default: google/gemini-3.1-flash-image-preview)"
76                    }
77                },
78                "required": ["prompt"]
79            }),
80        }
81    }
82
83    fn redact_for_history(&self, output: &str) -> String {
84        // Replace the [IMAGE:base64:<huge_data>] marker body with a tiny
85        // placeholder. Keep the bracket structure so any naïve parser
86        // doesn't break, and append a SHA-256 short hash so different
87        // images produce different placeholder strings (debugging).
88        //
89        // Without this, the ~600 KB base64 payload re-enters the
90        // conversation on the next LLM turn and trips Anthropic's
91        // 200k-token context cap. The full marker is preserved on
92        // `AgentOutput.tool_call_results` for the caller (e.g. a
93        // pipeline that needs to attach the image to a tweet).
94        if let Some(start) = output.find(IMAGE_MARKER_PREFIX) {
95            let after_prefix = &output[start + IMAGE_MARKER_PREFIX.len()..];
96            if let Some(end) = after_prefix.find(']') {
97                let body = &after_prefix[..end];
98                use sha2::{Digest, Sha256};
99                let mut hasher = Sha256::new();
100                hasher.update(body.as_bytes());
101                let hex = format!("{:x}", hasher.finalize());
102                let short = &hex[..12];
103                let placeholder = format!("[IMAGE:redacted:{short}]");
104                let mut redacted =
105                    String::with_capacity(output.len() - body.len() + placeholder.len());
106                redacted.push_str(&output[..start]);
107                redacted.push_str(&placeholder);
108                redacted.push_str(&after_prefix[end + 1..]);
109                return redacted;
110            }
111        }
112        // No marker found — return verbatim.
113        output.to_string()
114    }
115
116    fn execute(
117        &self,
118        _ctx: &crate::ExecutionContext,
119        input: serde_json::Value,
120    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
121        Box::pin(async move {
122            let prompt = input
123                .get("prompt")
124                .and_then(|v| v.as_str())
125                .ok_or_else(|| Error::Agent("prompt is required".into()))?;
126
127            let style = input.get("style").and_then(|v| v.as_str());
128            let model = input
129                .get("model")
130                .and_then(|v| v.as_str())
131                .unwrap_or(DEFAULT_MODEL);
132
133            let api_key = std::env::var("OPENROUTER_API_KEY").map_err(|_| {
134                Error::Agent(
135                    "OPENROUTER_API_KEY environment variable not set. \
136                     Image generation requires an OpenRouter API key."
137                        .into(),
138                )
139            })?;
140
141            // Build the generation prompt
142            let full_prompt = match style {
143                Some(s) => format!("Generate an image in {s} style: {prompt}"),
144                None => format!("Generate an image: {prompt}"),
145            };
146
147            let body = json!({
148                "model": model,
149                "messages": [
150                    {
151                        "role": "user",
152                        "content": full_prompt
153                    }
154                ],
155                "modalities": ["image", "text"],
156                "image_config": {
157                    "aspect_ratio": "16:9"
158                }
159            });
160
161            let response = self
162                .client
163                .post("https://openrouter.ai/api/v1/chat/completions")
164                .header("Authorization", format!("Bearer {api_key}"))
165                .header("Content-Type", "application/json")
166                .json(&body)
167                .send()
168                .await
169                .map_err(|e| Error::Agent(format!("OpenRouter API request failed: {e}")))?;
170
171            let status = response.status();
172            if !status.is_success() {
173                // SECURITY (F-NET-1): cap error body.
174                let error_body = crate::http::read_text_capped(response, 4 * 1024)
175                    .await
176                    .unwrap_or_default();
177                return Ok(ToolOutput::error(format!(
178                    "OpenRouter API error (HTTP {}): {error_body}",
179                    status.as_u16()
180                )));
181            }
182
183            // SECURITY (F-NET-1): cap successful body before parsing JSON.
184            // Image responses include base64 — generous 15 MiB cap.
185            let (bytes, was_truncated) = crate::http::read_body_capped(response, 15 * 1024 * 1024)
186                .await
187                .map_err(|e| Error::Agent(format!("Failed to read OpenRouter response: {e}")))?;
188            if was_truncated {
189                return Ok(ToolOutput::error(
190                    "OpenRouter image response exceeded 15 MiB cap",
191                ));
192            }
193            let data: serde_json::Value = serde_json::from_slice(&bytes)
194                .map_err(|e| Error::Agent(format!("Failed to parse OpenRouter response: {e}")))?;
195
196            // Extract image data from response
197            // Gemini returns inline_data with base64 in the content parts
198            extract_image_from_response(&data, prompt)
199        })
200    }
201}
202
203/// Extract base64 image data from an OpenRouter image generation response.
204///
205/// OpenRouter returns images in `choices[0].message.images[].image_url.url`
206/// as data URLs (`data:image/png;base64,...`). Also handles fallback formats
207/// like `content` array with `inline_data` or `image_url` parts.
208fn extract_image_from_response(
209    data: &serde_json::Value,
210    prompt: &str,
211) -> Result<ToolOutput, Error> {
212    let choices = data.get("choices").and_then(|v| v.as_array());
213
214    if let Some(choices) = choices {
215        for choice in choices {
216            let msg = match choice.get("message") {
217                Some(m) => m,
218                None => continue,
219            };
220
221            // Primary: OpenRouter documented format — message.images[].image_url.url
222            if let Some(images) = msg.get("images").and_then(|i| i.as_array()) {
223                for image in images {
224                    if let Some(url) = image
225                        .get("image_url")
226                        .and_then(|iu| iu.get("url"))
227                        .and_then(|u| u.as_str())
228                        && let Some(b64_data) = url.strip_prefix("data:")
229                    {
230                        return Ok(ToolOutput::success(format!(
231                            "{IMAGE_MARKER_PREFIX}{b64_data}]\n\n\
232                             Generated image for: {prompt}"
233                        )));
234                    }
235                }
236            }
237
238            // Fallback: content array with inline_data or image_url parts
239            if let Some(content_parts) = msg.get("content").and_then(|c| c.as_array()) {
240                for part in content_parts {
241                    if let Some(inline) = part.get("inline_data")
242                        && let Some(b64) = inline.get("data").and_then(|d| d.as_str())
243                    {
244                        let mime = inline
245                            .get("mime_type")
246                            .and_then(|m| m.as_str())
247                            .unwrap_or("image/png");
248                        return Ok(ToolOutput::success(format!(
249                            "{IMAGE_MARKER_PREFIX}{mime};{b64}]\n\n\
250                             Generated image for: {prompt}"
251                        )));
252                    }
253                    if let Some(image_url) = part.get("image_url")
254                        && let Some(url) = image_url.get("url").and_then(|u| u.as_str())
255                        && let Some(b64_data) = url.strip_prefix("data:")
256                    {
257                        return Ok(ToolOutput::success(format!(
258                            "{IMAGE_MARKER_PREFIX}{b64_data}]\n\n\
259                             Generated image for: {prompt}"
260                        )));
261                    }
262                }
263            }
264
265            // Fallback: content as string — model returned text instead of image
266            if let Some(text) = msg.get("content").and_then(|c| c.as_str()) {
267                return Ok(ToolOutput::error(format!(
268                    "Model returned text instead of image: {text}"
269                )));
270            }
271        }
272    }
273
274    Ok(ToolOutput::error(format!(
275        "No image data found in response. Raw response: {}",
276        serde_json::to_string(data).unwrap_or_default()
277    )))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn definition_has_correct_name() {
286        let tool = ImageGenerateTool::new();
287        let def = tool.definition();
288        assert_eq!(def.name, "image_generate");
289    }
290
291    #[test]
292    fn definition_has_required_prompt() {
293        let tool = ImageGenerateTool::new();
294        let def = tool.definition();
295        let required = def.input_schema["required"].as_array().unwrap();
296        assert_eq!(required.len(), 1);
297        assert_eq!(required[0], "prompt");
298    }
299
300    #[test]
301    fn definition_has_optional_style_and_model() {
302        let tool = ImageGenerateTool::new();
303        let def = tool.definition();
304        let props = def.input_schema["properties"].as_object().unwrap();
305        assert!(props.contains_key("style"));
306        assert!(props.contains_key("model"));
307        assert!(props.contains_key("prompt"));
308    }
309
310    #[tokio::test]
311    async fn image_generate_requires_api_key() {
312        if std::env::var("OPENROUTER_API_KEY").is_ok() {
313            return;
314        }
315
316        let tool = ImageGenerateTool::new();
317        let result = tool
318            .execute(
319                &crate::ExecutionContext::default(),
320                json!({"prompt": "a cat"}),
321            )
322            .await;
323        assert!(result.is_err());
324        let err = result.unwrap_err().to_string();
325        assert!(err.contains("OPENROUTER_API_KEY"), "got: {err}");
326    }
327
328    #[tokio::test]
329    async fn image_generate_requires_prompt() {
330        if std::env::var("OPENROUTER_API_KEY").is_ok() {
331            return;
332        }
333
334        let tool = ImageGenerateTool::new();
335        let result = tool
336            .execute(&crate::ExecutionContext::default(), json!({}))
337            .await;
338        assert!(result.is_err());
339        let err = result.unwrap_err().to_string();
340        assert!(err.contains("prompt is required"), "got: {err}");
341    }
342
343    #[test]
344    fn extract_openrouter_images_array() {
345        // Primary documented format: message.images[].image_url.url
346        let response = json!({
347            "choices": [{
348                "message": {
349                    "images": [{
350                        "image_url": {
351                            "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="
352                        }
353                    }]
354                }
355            }]
356        });
357
358        let result = extract_image_from_response(&response, "mountains").unwrap();
359        assert!(!result.is_error);
360        assert!(result.content.contains(IMAGE_MARKER_PREFIX));
361        assert!(
362            result
363                .content
364                .contains("image/png;base64,iVBORw0KGgoAAAANSUhEUg==")
365        );
366        assert!(result.content.contains("Generated image for: mountains"));
367    }
368
369    #[test]
370    fn extract_gemini_inline_data() {
371        let response = json!({
372            "choices": [{
373                "message": {
374                    "content": [{
375                        "type": "image",
376                        "inline_data": {
377                            "mime_type": "image/png",
378                            "data": "iVBORw0KGgoAAAANSUhEUg=="
379                        }
380                    }]
381                }
382            }]
383        });
384
385        let result = extract_image_from_response(&response, "test prompt").unwrap();
386        assert!(!result.is_error);
387        assert!(
388            result.content.contains(IMAGE_MARKER_PREFIX),
389            "should contain image marker"
390        );
391        assert!(
392            result.content.contains("image/png"),
393            "should contain mime type"
394        );
395        assert!(
396            result.content.contains("iVBORw0KGgoAAAANSUhEUg=="),
397            "should contain base64 data"
398        );
399        assert!(
400            result.content.contains("Generated image for: test prompt"),
401            "should contain prompt reference"
402        );
403    }
404
405    #[test]
406    fn extract_openrouter_image_url() {
407        let response = json!({
408            "choices": [{
409                "message": {
410                    "content": [{
411                        "type": "image_url",
412                        "image_url": {
413                            "url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="
414                        }
415                    }]
416                }
417            }]
418        });
419
420        let result = extract_image_from_response(&response, "sunset").unwrap();
421        assert!(!result.is_error);
422        assert!(result.content.contains(IMAGE_MARKER_PREFIX));
423        assert!(result.content.contains("image/jpeg"));
424        assert!(result.content.contains("/9j/4AAQSkZJRg=="));
425    }
426
427    #[test]
428    fn extract_no_image_returns_error() {
429        let response = json!({
430            "choices": [{
431                "message": {
432                    "content": "I cannot generate images."
433                }
434            }]
435        });
436
437        let result = extract_image_from_response(&response, "cat").unwrap();
438        assert!(result.is_error);
439        assert!(result.content.contains("text instead of image"));
440    }
441
442    #[test]
443    fn extract_empty_response_returns_error() {
444        let response = json!({});
445        let result = extract_image_from_response(&response, "cat").unwrap();
446        assert!(result.is_error);
447        assert!(result.content.contains("No image data found"));
448    }
449
450    #[test]
451    fn redact_for_history_replaces_marker_body_with_short_hash() {
452        let tool = ImageGenerateTool::new();
453        let raw =
454            "[IMAGE:base64:image/png;base64,iVBORw0KGgo=AAAAAAAAA]\n\nGenerated image for: test";
455        let redacted = tool.redact_for_history(raw);
456        assert!(redacted.starts_with("[IMAGE:redacted:"));
457        assert!(redacted.contains("]\n\nGenerated image for: test"));
458        assert!(!redacted.contains("iVBORw0KGgo"));
459        // Same input → same hash → idempotent.
460        assert_eq!(tool.redact_for_history(raw), redacted);
461    }
462
463    #[test]
464    fn redact_for_history_passes_through_when_no_marker() {
465        let tool = ImageGenerateTool::new();
466        assert_eq!(
467            tool.redact_for_history("plain text without marker"),
468            "plain text without marker"
469        );
470    }
471
472    #[test]
473    fn image_marker_format() {
474        // Verify downstream agents can parse the marker
475        let output = format!("{IMAGE_MARKER_PREFIX}image/png;abc123]");
476        assert!(output.starts_with("[IMAGE:base64:"));
477        assert!(output.ends_with(']'));
478        // Extract mime and data
479        let inner = output
480            .strip_prefix("[IMAGE:base64:")
481            .unwrap()
482            .strip_suffix(']')
483            .unwrap();
484        let (mime, data) = inner.split_once(';').unwrap();
485        assert_eq!(mime, "image/png");
486        assert_eq!(data, "abc123");
487    }
488}