heartbit_core/tool/builtins/
image_generate.rs1use 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
13pub struct ImageGenerateTool {
21 client: reqwest::Client,
22}
23
24impl ImageGenerateTool {
25 pub fn new() -> Self {
30 Self::try_new().expect("failed to build reqwest client")
31 }
32
33 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 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 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 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 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 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_from_response(&data, prompt)
199 })
200 }
201}
202
203fn 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 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 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 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 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 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 let output = format!("{IMAGE_MARKER_PREFIX}image/png;abc123]");
476 assert!(output.starts_with("[IMAGE:base64:"));
477 assert!(output.ends_with(']'));
478 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}