Skip to main content

robit_agent/
image_gen.rs

1//! Image generation client: unified interface for multiple providers.
2//!
3//! Supports two protocols:
4//! - `Openai`: OpenAI-compatible Images API (`POST /images/generations`).
5//! - `Dashscope`: DashScope native protocol (Wanxiang), with sync and async
6//!   call modes. Async mode submits a task then polls until completion.
7//!
8//! The model used is resolved from config (`default_image_model`) and is not
9//! exposed to callers - the client uses `provider.model_id` internally.
10
11use std::time::Duration;
12
13use robit_ai::config::{
14    ImageCallMode, ImageProtocol, ResolvedImageProvider,
15};
16use serde_json::{json, Value};
17use thiserror::Error;
18use tokio::time::{sleep, timeout};
19
20/// HTTP request timeout for all API calls (sync generation + async submit/poll).
21const HTTP_TIMEOUT_SECS: u64 = 120;
22
23// ============================================================================
24// Error type
25// ============================================================================
26
27#[derive(Debug, Error)]
28pub enum ImageGenError {
29    #[error("HTTP error: {0}")]
30    Http(#[from] reqwest::Error),
31
32    /// API returned a non-success status or an error body.
33    #[error("API error: {code} - {message}")]
34    Api { code: String, message: String },
35
36    /// Async task did not complete within the polling timeout.
37    #[error("Task timed out after {0}s")]
38    Timeout(u64),
39
40    /// Async task ended in a non-success terminal state (e.g. FAILED).
41    #[error("Task failed: {0}")]
42    TaskFailed(String),
43
44    /// Unexpected response shape that could not be parsed.
45    #[error("Response parse error: {0}")]
46    ParseError(String),
47}
48
49/// Structured error info extracted from an [`ImageGenError`], for returning
50/// to the LLM in a machine-parseable form so it can decide whether to retry.
51#[derive(Debug, Clone)]
52pub struct ImageGenErrorInfo {
53    /// Error category.
54    /// One of: `api_error`, `http_error`, `timeout`, `task_failed`, `parse_error`.
55    pub kind: &'static str,
56    /// Provider-specific error code (only present for `api_error`).
57    pub code: Option<String>,
58    /// Human-readable error message.
59    pub message: String,
60    /// Whether retrying the same request might succeed (e.g. rate limit,
61    /// transient network error). `false` for permission/parameter errors.
62    pub retryable: bool,
63}
64
65impl ImageGenError {
66    /// Extract structured error info for the LLM.
67    pub fn to_error_info(&self) -> ImageGenErrorInfo {
68        match self {
69            ImageGenError::Api { code, message } => ImageGenErrorInfo {
70                kind: "api_error",
71                code: Some(code.clone()),
72                message: message.clone(),
73                retryable: is_retryable_api_error(code, message),
74            },
75            ImageGenError::Http(e) => ImageGenErrorInfo {
76                kind: "http_error",
77                code: None,
78                message: e.to_string(),
79                retryable: true,
80            },
81            ImageGenError::Timeout(secs) => ImageGenErrorInfo {
82                kind: "timeout",
83                code: None,
84                message: format!("Image generation timed out after {}s", secs),
85                retryable: true,
86            },
87            ImageGenError::TaskFailed(status) => ImageGenErrorInfo {
88                kind: "task_failed",
89                code: None,
90                message: format!("Task ended in non-success state: {}", status),
91                retryable: true,
92            },
93            ImageGenError::ParseError(msg) => ImageGenErrorInfo {
94                kind: "parse_error",
95                code: None,
96                message: msg.clone(),
97                retryable: false,
98            },
99        }
100    }
101}
102
103/// Heuristic: whether an API error is likely transient and worth retrying.
104///
105/// Returns `true` for rate-limit / busy / server-error conditions. Returns
106/// `false` for permission, authentication, and parameter errors (retrying
107/// the same request would just fail the same way).
108fn is_retryable_api_error(code: &str, message: &str) -> bool {
109    let combined = format!("{} {}", code, message).to_lowercase();
110    combined.contains("throttl")
111        || combined.contains("rate limit")
112        || combined.contains("ratelimit")
113        || combined.contains("busy")
114        || combined.contains("please retry")
115        || combined.contains("try again")
116        || combined.contains("service unavailable")
117        || combined.contains("internal error")
118        || combined.contains("timeout")
119}
120
121// ============================================================================
122// Request / response types
123// ============================================================================
124
125/// Parameters for an image generation request.
126pub struct ImageGenRequest {
127    pub prompt: String,
128    pub n: Option<u32>,
129    /// Extra parameters passed through to the provider (e.g. `watermark`).
130    pub extra_params: Value,
131}
132
133/// A single generated image, as returned by the provider API.
134pub struct GeneratedImage {
135    /// Original image URL returned by the API (valid for ~24h).
136    pub url: String,
137    /// Image resolution string from the API response (e.g. "2048*2048").
138    pub size: Option<String>,
139}
140
141// ============================================================================
142// Client
143// ============================================================================
144
145#[derive(Clone)]
146pub struct ImageGenClient {
147    provider: ResolvedImageProvider,
148    http: reqwest::Client,
149}
150
151impl ImageGenClient {
152    pub fn new(provider: ResolvedImageProvider) -> Self {
153        let http = reqwest::Client::builder()
154            .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
155            .build()
156            .expect("reqwest client should build");
157        Self { provider, http }
158    }
159
160    /// Generate images from a text prompt.
161    ///
162    /// Returns one `GeneratedImage` per image the API actually produced.
163    /// The number returned may be less than the requested `n` (e.g. due to
164    /// content filtering); it is always taken from the API response.
165    pub async fn generate(&self, req: &ImageGenRequest) -> Result<Vec<GeneratedImage>, ImageGenError> {
166        tracing::info!(
167            "[image_gen] generate: protocol={:?}, mode={:?}, provider={}, model={}, base_url={}, api_key={}",
168            self.provider.protocol,
169            self.provider.mode,
170            self.provider.provider_name,
171            self.provider.model_id,
172            self.provider.base_url,
173            mask_key(&self.provider.api_key),
174        );
175        tracing::debug!(
176            "[image_gen] request: prompt={:?}, n={:?}, extra_params={}",
177            req.prompt,
178            req.n,
179            req.extra_params,
180        );
181
182        // Overall timeout guard. The reqwest client-level timeout is not
183        // reliable when a server accepts the connection but never responds,
184        // so we wrap the whole call in tokio::time::timeout as a hard
185        // backstop. Without this a hung image request blocks the agent task
186        // indefinitely (and with it the whole chat session, since tool
187        // execution is synchronous).
188        let timeout_secs = match self.provider.protocol {
189            ImageProtocol::Dashscope if self.provider.mode == ImageCallMode::Async => {
190                self.provider.poll_timeout_secs
191            }
192            _ => HTTP_TIMEOUT_SECS,
193        };
194        match timeout(
195            Duration::from_secs(timeout_secs),
196            async {
197                match self.provider.protocol {
198                    ImageProtocol::Openai => self.generate_openai(req).await,
199                    ImageProtocol::Dashscope => match self.provider.mode {
200                        ImageCallMode::Sync => self.generate_dashscope_sync(req).await,
201                        ImageCallMode::Async => self.generate_dashscope_async(req).await,
202                    },
203                }
204            },
205        )
206        .await
207        {
208            Ok(inner) => inner,
209            Err(_) => {
210                tracing::error!(
211                    "[image_gen] generate timed out after {}s (protocol={:?}, mode={:?}, provider={}, model={}). \
212                     The background task result will be delivered to the Agent as an error.",
213                    timeout_secs,
214                    self.provider.protocol,
215                    self.provider.mode,
216                    self.provider.provider_name,
217                    self.provider.model_id
218                );
219                Err(ImageGenError::Timeout(timeout_secs))
220            }
221        }
222    }
223
224    // ----------------------------------------------------------------------
225    // OpenAI-compatible Images API
226    // ----------------------------------------------------------------------
227
228    async fn generate_openai(&self, req: &ImageGenRequest) -> Result<Vec<GeneratedImage>, ImageGenError> {
229        let url = format!("{}/images/generations", self.provider.base_url.trim_end_matches('/'));
230
231        let mut body = json!({
232            "model": self.provider.model_id,
233            "prompt": req.prompt,
234            "response_format": "url",
235        });
236        if let Some(n) = req.n {
237            body["n"] = json!(n);
238        }
239        // Merge any extra params (caller-provided overrides)
240        if let Value::Object(ref extra) = req.extra_params {
241            if let Value::Object(body_map) = &mut body {
242                for (k, v) in extra {
243                    body_map.insert(k.clone(), v.clone());
244                }
245            }
246        }
247
248        tracing::debug!("[image_gen] openai POST {} | auth: Bearer {} | body: {}", url, mask_key(&self.provider.api_key), body);
249
250        let resp = self.http.post(&url).bearer_auth(&self.provider.api_key).json(&body).send().await?;
251        let status = resp.status();
252        let text = resp.text().await?;
253
254        tracing::debug!("[image_gen] openai response: status={} | body: {}", status, truncate_str(&text, 2000));
255
256        let json: Value = serde_json::from_str(&text)
257            .map_err(|e| ImageGenError::ParseError(format!("openai response: {e} (body: {text})")))?;
258
259        if !status.is_success() {
260            tracing::warn!(
261                "[image_gen] openai request failed: status={}, url={}, body={}",
262                status, url, truncate_str(&text, 2000)
263            );
264            return Err(openai_error(&json).unwrap_or(ImageGenError::Api {
265                code: status.as_u16().to_string(),
266                message: text,
267            }));
268        }
269
270        let data = json.get("data").and_then(|d| d.as_array()).ok_or_else(|| {
271            ImageGenError::ParseError(format!("openai response missing 'data' array (body: {text})"))
272        })?;
273
274        let images = data
275            .iter()
276            .filter_map(|item| {
277                item.get("url")
278                    .and_then(|u| u.as_str())
279                    .map(|u| GeneratedImage { url: u.to_string(), size: None })
280            })
281            .collect::<Vec<_>>();
282
283        Ok(images)
284    }
285
286    // ----------------------------------------------------------------------
287    // DashScope (Wanxiang) - synchronous call
288    // ----------------------------------------------------------------------
289
290    async fn generate_dashscope_sync(
291        &self,
292        req: &ImageGenRequest,
293    ) -> Result<Vec<GeneratedImage>, ImageGenError> {
294        let url = format!(
295            "{}/services/aigc/multimodal-generation/generation",
296            self.provider.base_url.trim_end_matches('/')
297        );
298        let body = self.build_dashscope_body(req);
299        let json = self.dashscope_post(&url, &body, false).await?;
300        self.parse_dashscope_result(&json)
301    }
302
303    // ----------------------------------------------------------------------
304    // DashScope (Wanxiang) - asynchronous call (submit + poll)
305    // ----------------------------------------------------------------------
306
307    async fn generate_dashscope_async(
308        &self,
309        req: &ImageGenRequest,
310    ) -> Result<Vec<GeneratedImage>, ImageGenError> {
311        let submit_url = format!(
312            "{}/services/aigc/image-generation/generation",
313            self.provider.base_url.trim_end_matches('/')
314        );
315        let body = self.build_dashscope_body(req);
316
317        // Some providers (e.g. Token Plan) reject async calls with an
318        // "AccessDenied: does not support asynchronous calls" error. In that
319        // case, transparently fall back to synchronous mode so callers don't
320        // need to know whether their provider supports async.
321        let submit_resp = match self.dashscope_post(&submit_url, &body, true).await {
322            Ok(resp) => resp,
323            Err(ImageGenError::Api { ref code, ref message })
324                if message.to_lowercase().contains("asynchronous") =>
325            {
326                tracing::warn!(
327                    "[image_gen] provider does not support async calls ({}: {}), falling back to sync mode",
328                    code, message
329                );
330                return self.generate_dashscope_sync(req).await;
331            }
332            Err(e) => return Err(e),
333        };
334
335        let task_id = submit_resp
336            .pointer("/output/task_id")
337            .and_then(|v| v.as_str())
338            .ok_or_else(|| {
339                ImageGenError::ParseError(format!(
340                    "dashscope async response missing task_id (body: {submit_resp})"
341                ))
342            })?
343            .to_string();
344
345        tracing::info!("[image_gen] async task submitted: {}", task_id);
346
347        let poll_url = format!(
348            "{}/tasks/{}",
349            self.provider.base_url.trim_end_matches('/'),
350            task_id
351        );
352
353        let poll_timeout = Duration::from_secs(self.provider.poll_timeout_secs);
354        let result = timeout(poll_timeout, self.poll_task(&poll_url)).await;
355
356        match result {
357            Ok(Ok(json)) => self.parse_dashscope_result(&json),
358            Ok(Err(e)) => Err(e),
359            Err(_) => Err(ImageGenError::Timeout(self.provider.poll_timeout_secs)),
360        }
361    }
362
363    /// Poll the task endpoint until a terminal state is reached.
364    async fn poll_task(&self, poll_url: &str) -> Result<Value, ImageGenError> {
365        let interval = Duration::from_secs(self.provider.poll_interval_secs);
366        loop {
367            sleep(interval).await;
368            tracing::debug!(
369                "[image_gen] dashscope poll GET {} | auth: Bearer {}",
370                poll_url, mask_key(&self.provider.api_key)
371            );
372            let resp = self
373                .http
374                .get(poll_url)
375                .bearer_auth(&self.provider.api_key)
376                .send()
377                .await?;
378            let status = resp.status();
379            let text = resp.text().await?;
380
381            tracing::debug!(
382                "[image_gen] dashscope poll response: status={} | body: {}",
383                status, truncate_str(&text, 2000)
384            );
385
386            let json: Value = serde_json::from_str(&text).map_err(|e| {
387                ImageGenError::ParseError(format!("dashscope poll response: {e} (body: {text})"))
388            })?;
389
390            if !status.is_success() {
391                tracing::warn!(
392                    "[image_gen] dashscope poll failed: status={}, url={}, body={}",
393                    status, poll_url, truncate_str(&text, 2000)
394                );
395                return Err(dashscope_error(&json).unwrap_or(ImageGenError::Api {
396                    code: status.as_u16().to_string(),
397                    message: text,
398                }));
399            }
400
401            let task_status = json.pointer("/output/task_status").and_then(|v| v.as_str());
402            match task_status {
403                Some("SUCCEEDED") => {
404                    tracing::info!("[image_gen] async task succeeded");
405                    return Ok(json);
406                }
407                Some("FAILED") | Some("CANCELED") | Some("UNKNOWN") => {
408                    return Err(ImageGenError::TaskFailed(
409                        task_status.unwrap_or("UNKNOWN").to_string(),
410                    ));
411                }
412                // PENDING / RUNNING -> keep polling
413                Some(status) => {
414                    tracing::info!("[image_gen] task {} still running (status={})", poll_url, status);
415                }
416                None => {
417                    tracing::warn!(
418                        "[image_gen] poll response missing output.task_status (body: {})",
419                        truncate_str(&text, 500)
420                    );
421                }
422            }
423        }
424    }
425
426    // ----------------------------------------------------------------------
427    // DashScope helpers
428    // ----------------------------------------------------------------------
429
430    /// Build the Wanxiang request body from the unified request.
431    fn build_dashscope_body(&self, req: &ImageGenRequest) -> Value {
432        let mut parameters = json!({});
433        if let Some(n) = req.n {
434            parameters["n"] = json!(n);
435        }
436        // Merge extra params into parameters (e.g. watermark, thinking_mode)
437        if let Value::Object(ref extra) = req.extra_params {
438            if let Value::Object(p) = &mut parameters {
439                for (k, v) in extra {
440                    p.insert(k.clone(), v.clone());
441                }
442            }
443        }
444
445        json!({
446            "model": self.provider.model_id,
447            "input": {
448                "messages": [
449                    {
450                        "role": "user",
451                        "content": [ { "text": req.prompt } ]
452                    }
453                ]
454            },
455            "parameters": parameters,
456        })
457    }
458
459    /// POST a DashScope request and return the parsed JSON, checking for errors.
460    /// When `async_mode` is true, the `X-DashScope-Async: enable` header is set.
461    async fn dashscope_post(
462        &self,
463        url: &str,
464        body: &Value,
465        async_mode: bool,
466    ) -> Result<Value, ImageGenError> {
467        tracing::debug!(
468            "[image_gen] dashscope POST {} | async={} | auth: Bearer {} | body: {}",
469            url, async_mode, mask_key(&self.provider.api_key), body
470        );
471
472        let mut req_builder = self
473            .http
474            .post(url)
475            .bearer_auth(&self.provider.api_key)
476            .header("Content-Type", "application/json");
477        if async_mode {
478            req_builder = req_builder.header("X-DashScope-Async", "enable");
479        }
480        let resp = req_builder.json(body).send().await?;
481        let status = resp.status();
482        let text = resp.text().await?;
483
484        tracing::debug!(
485            "[image_gen] dashscope response: status={} | url={} | body: {}",
486            status, url, truncate_str(&text, 2000)
487        );
488
489        let json: Value = serde_json::from_str(&text)
490            .map_err(|e| ImageGenError::ParseError(format!("dashscope response: {e} (body: {text})")))?;
491
492        if !status.is_success() {
493            tracing::warn!(
494                "[image_gen] dashscope request failed: status={}, url={}, body={}",
495                status, url, truncate_str(&text, 2000)
496            );
497            return Err(dashscope_error(&json).unwrap_or(ImageGenError::Api {
498                code: status.as_u16().to_string(),
499                message: text,
500            }));
501        }
502        // DashScope may return 200 with an error code in the body
503        if let Some(err) = dashscope_error(&json) {
504            tracing::warn!(
505                "[image_gen] dashscope API error in 200 response: url={}, body={}",
506                url, truncate_str(&text, 2000)
507            );
508            return Err(err);
509        }
510        Ok(json)
511    }
512
513    /// Extract generated images from a DashScope success response (sync result
514    /// or the final polled task result).
515    fn parse_dashscope_result(&self, json: &Value) -> Result<Vec<GeneratedImage>, ImageGenError> {
516        let size = json
517            .pointer("/usage/size")
518            .and_then(|v| v.as_str())
519            .map(|s| s.to_string());
520
521        let choices = json.pointer("/output/choices").and_then(|c| c.as_array()).ok_or_else(|| {
522            ImageGenError::ParseError(format!("dashscope response missing output.choices (body: {json})"))
523        })?;
524
525        let mut images = Vec::new();
526        for choice in choices {
527            let content = choice
528                .pointer("/message/content")
529                .and_then(|c| c.as_array())
530                .ok_or_else(|| {
531                    ImageGenError::ParseError(format!(
532                        "dashscope choice missing message.content (body: {json})"
533                    ))
534                })?;
535            for item in content {
536                if let Some(url) = item.get("image").and_then(|u| u.as_str()) {
537                    images.push(GeneratedImage {
538                        url: url.to_string(),
539                        size: size.clone(),
540                    });
541                }
542            }
543        }
544
545        if images.is_empty() {
546            return Err(ImageGenError::ParseError(format!(
547                "dashscope response contained no images (body: {json})"
548            )));
549        }
550        Ok(images)
551    }
552}
553
554// ============================================================================
555// Error extraction helpers
556// ============================================================================
557
558/// Extract a DashScope error from the response body, if present.
559/// DashScope errors look like `{ "code": "...", "message": "..." }`.
560fn dashscope_error(json: &Value) -> Option<ImageGenError> {
561    let code = json.get("code").and_then(|v| v.as_str())?;
562    // An empty code string means success (DashScope sometimes returns "" on success)
563    if code.is_empty() {
564        return None;
565    }
566    let message = json.get("message").and_then(|v| v.as_str()).unwrap_or("");
567    Some(ImageGenError::Api {
568        code: code.to_string(),
569        message: message.to_string(),
570    })
571}
572
573/// Extract an OpenAI-style error from the response body, if present.
574fn openai_error(json: &Value) -> Option<ImageGenError> {
575    let err = json.get("error")?;
576    let message = err.get("message").and_then(|v| v.as_str()).unwrap_or("");
577    let code = err.get("code").and_then(|v| v.as_str()).unwrap_or("error");
578    Some(ImageGenError::Api {
579        code: code.to_string(),
580        message: message.to_string(),
581    })
582}
583
584// ============================================================================
585// Logging helpers
586// ============================================================================
587
588/// Mask an API key for logging: show only the first 8 and last 4 characters.
589/// Returns "<empty>" / "<unset>" for edge cases so the log is unambiguous.
590fn mask_key(key: &str) -> String {
591    if key.is_empty() {
592        return "<empty>".to_string();
593    }
594    let len = key.len();
595    if len <= 12 {
596        return format!("{}***", &key[..len.min(4)]);
597    }
598    format!("{}...{}", &key[..8], &key[len - 4..])
599}
600
601/// Truncate a string to `max` characters, appending "..." if truncated.
602/// Keeps log output bounded for large response bodies.
603fn truncate_str(s: &str, max: usize) -> String {
604    if s.len() <= max {
605        s.to_string()
606    } else {
607        // Cut at the last char boundary at or before `max` so multi-byte
608        // (e.g. CJK) response bodies don't panic on a mid-char slice.
609        let cut = s.floor_char_boundary(max);
610        format!("{}... (truncated, {} bytes total)", &s[..cut], s.len())
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn test_mask_key_normal() {
620        let masked = mask_key("sk-sp-abcdef1234567890");
621        assert!(masked.starts_with("sk-sp-ab"));
622        assert!(masked.ends_with("7890"));
623        assert!(!masked.contains("1234567"));
624    }
625
626    #[test]
627    fn test_mask_key_short() {
628        // Keys <= 12 chars show only first 4 + ***
629        let masked = mask_key("sk-sp-abc");
630        assert_eq!(masked, "sk-s***");
631    }
632
633    #[test]
634    fn test_mask_key_empty() {
635        assert_eq!(mask_key(""), "<empty>");
636    }
637
638    #[test]
639    fn test_truncate_str_short() {
640        assert_eq!(truncate_str("hello", 10), "hello");
641    }
642
643    #[test]
644    fn test_truncate_str_long() {
645        let result = truncate_str("abcdefghijklmnopqrstuvwxyz", 10);
646        assert!(result.starts_with("abcdefghij"));
647        assert!(result.contains("truncated"));
648    }
649
650    #[test]
651    fn test_truncate_str_multibyte_boundary() {
652        // 1000 CJK chars (3 bytes each) = 3000 bytes. Cutting at byte 2000
653        // lands mid-character (2000 is not a multiple of 3) and would panic
654        // on a naive `&s[..2000]` slice (the exact crash from the field: byte
655        // 2000 inside '着').
656        let s: String = "着".repeat(1000);
657        assert_eq!(s.len(), 3000);
658        let result = truncate_str(&s, 2000);
659        // floor_char_boundary(2000) = 1998 = 3 * 666 chars.
660        assert!(result.starts_with(&"着".repeat(666)));
661        assert!(!result.starts_with(&"着".repeat(667)));
662        assert!(result.contains("truncated"));
663        assert!(result.contains("3000 bytes total"));
664    }
665
666    #[test]
667    fn test_error_info_api_not_retryable() {
668        let e = ImageGenError::Api {
669            code: "AccessDenied".to_string(),
670            message: "current user api does not support asynchronous calls".to_string(),
671        };
672        let info = e.to_error_info();
673        assert_eq!(info.kind, "api_error");
674        assert_eq!(info.code.as_deref(), Some("AccessDenied"));
675        assert!(!info.retryable, "permission errors should not be retryable");
676    }
677
678    #[test]
679    fn test_error_info_api_retryable() {
680        let e = ImageGenError::Api {
681            code: "Throttling".to_string(),
682            message: "Rate limit exceeded, please retry later".to_string(),
683        };
684        let info = e.to_error_info();
685        assert!(info.retryable, "rate limit errors should be retryable");
686    }
687
688    #[test]
689    fn test_error_info_timeout_retryable() {
690        let info = ImageGenError::Timeout(300).to_error_info();
691        assert_eq!(info.kind, "timeout");
692        assert!(info.retryable);
693        assert!(info.message.contains("300"));
694    }
695
696    #[test]
697    fn test_error_info_parse_error_not_retryable() {
698        let info = ImageGenError::ParseError("bad json".to_string()).to_error_info();
699        assert_eq!(info.kind, "parse_error");
700        assert!(!info.retryable);
701    }
702
703    #[test]
704    fn test_error_info_task_failed_retryable() {
705        let info = ImageGenError::TaskFailed("FAILED".to_string()).to_error_info();
706        assert_eq!(info.kind, "task_failed");
707        assert!(info.retryable);
708    }
709}