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!("Task 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 size: Option<String>,
129    pub n: Option<u32>,
130    /// Extra parameters passed through to the provider (e.g. `watermark`).
131    pub extra_params: Value,
132}
133
134/// A single generated image, as returned by the provider API.
135pub struct GeneratedImage {
136    /// Original image URL returned by the API (valid for ~24h).
137    pub url: String,
138    /// Image resolution string from the API response (e.g. "2048*2048").
139    pub size: Option<String>,
140}
141
142// ============================================================================
143// Client
144// ============================================================================
145
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={:?}, size={:?}, n={:?}, extra_params={}",
177            req.prompt,
178            req.size,
179            req.n,
180            req.extra_params,
181        );
182        match self.provider.protocol {
183            ImageProtocol::Openai => self.generate_openai(req).await,
184            ImageProtocol::Dashscope => match self.provider.mode {
185                ImageCallMode::Sync => self.generate_dashscope_sync(req).await,
186                ImageCallMode::Async => self.generate_dashscope_async(req).await,
187            },
188        }
189    }
190
191    // ----------------------------------------------------------------------
192    // OpenAI-compatible Images API
193    // ----------------------------------------------------------------------
194
195    async fn generate_openai(&self, req: &ImageGenRequest) -> Result<Vec<GeneratedImage>, ImageGenError> {
196        let url = format!("{}/images/generations", self.provider.base_url.trim_end_matches('/'));
197
198        let mut body = json!({
199            "model": self.provider.model_id,
200            "prompt": req.prompt,
201            "response_format": "url",
202        });
203        if let Some(n) = req.n {
204            body["n"] = json!(n);
205        }
206        if let Some(ref size) = req.size {
207            body["size"] = json!(size);
208        }
209        // Merge any extra params (caller-provided overrides)
210        if let Value::Object(ref extra) = req.extra_params {
211            if let Value::Object(body_map) = &mut body {
212                for (k, v) in extra {
213                    body_map.insert(k.clone(), v.clone());
214                }
215            }
216        }
217
218        tracing::debug!("[image_gen] openai POST {} | auth: Bearer {} | body: {}", url, mask_key(&self.provider.api_key), body);
219
220        let resp = self.http.post(&url).bearer_auth(&self.provider.api_key).json(&body).send().await?;
221        let status = resp.status();
222        let text = resp.text().await?;
223
224        tracing::debug!("[image_gen] openai response: status={} | body: {}", status, truncate_str(&text, 2000));
225
226        let json: Value = serde_json::from_str(&text)
227            .map_err(|e| ImageGenError::ParseError(format!("openai response: {e} (body: {text})")))?;
228
229        if !status.is_success() {
230            tracing::warn!(
231                "[image_gen] openai request failed: status={}, url={}, body={}",
232                status, url, truncate_str(&text, 2000)
233            );
234            return Err(openai_error(&json).unwrap_or(ImageGenError::Api {
235                code: status.as_u16().to_string(),
236                message: text,
237            }));
238        }
239
240        let data = json.get("data").and_then(|d| d.as_array()).ok_or_else(|| {
241            ImageGenError::ParseError(format!("openai response missing 'data' array (body: {text})"))
242        })?;
243
244        let images = data
245            .iter()
246            .filter_map(|item| {
247                item.get("url")
248                    .and_then(|u| u.as_str())
249                    .map(|u| GeneratedImage { url: u.to_string(), size: req.size.clone() })
250            })
251            .collect::<Vec<_>>();
252
253        Ok(images)
254    }
255
256    // ----------------------------------------------------------------------
257    // DashScope (Wanxiang) - synchronous call
258    // ----------------------------------------------------------------------
259
260    async fn generate_dashscope_sync(
261        &self,
262        req: &ImageGenRequest,
263    ) -> Result<Vec<GeneratedImage>, ImageGenError> {
264        let url = format!(
265            "{}/services/aigc/multimodal-generation/generation",
266            self.provider.base_url.trim_end_matches('/')
267        );
268        let body = self.build_dashscope_body(req);
269        let json = self.dashscope_post(&url, &body, false).await?;
270        self.parse_dashscope_result(&json)
271    }
272
273    // ----------------------------------------------------------------------
274    // DashScope (Wanxiang) - asynchronous call (submit + poll)
275    // ----------------------------------------------------------------------
276
277    async fn generate_dashscope_async(
278        &self,
279        req: &ImageGenRequest,
280    ) -> Result<Vec<GeneratedImage>, ImageGenError> {
281        let submit_url = format!(
282            "{}/services/aigc/image-generation/generation",
283            self.provider.base_url.trim_end_matches('/')
284        );
285        let body = self.build_dashscope_body(req);
286
287        // Some providers (e.g. Token Plan) reject async calls with an
288        // "AccessDenied: does not support asynchronous calls" error. In that
289        // case, transparently fall back to synchronous mode so callers don't
290        // need to know whether their provider supports async.
291        let submit_resp = match self.dashscope_post(&submit_url, &body, true).await {
292            Ok(resp) => resp,
293            Err(ImageGenError::Api { ref code, ref message })
294                if message.to_lowercase().contains("asynchronous") =>
295            {
296                tracing::warn!(
297                    "[image_gen] provider does not support async calls ({}: {}), falling back to sync mode",
298                    code, message
299                );
300                return self.generate_dashscope_sync(req).await;
301            }
302            Err(e) => return Err(e),
303        };
304
305        let task_id = submit_resp
306            .pointer("/output/task_id")
307            .and_then(|v| v.as_str())
308            .ok_or_else(|| {
309                ImageGenError::ParseError(format!(
310                    "dashscope async response missing task_id (body: {submit_resp})"
311                ))
312            })?
313            .to_string();
314
315        tracing::info!("[image_gen] async task submitted: {}", task_id);
316
317        let poll_url = format!(
318            "{}/tasks/{}",
319            self.provider.base_url.trim_end_matches('/'),
320            task_id
321        );
322
323        let poll_timeout = Duration::from_secs(self.provider.poll_timeout_secs);
324        let result = timeout(poll_timeout, self.poll_task(&poll_url)).await;
325
326        match result {
327            Ok(Ok(json)) => self.parse_dashscope_result(&json),
328            Ok(Err(e)) => Err(e),
329            Err(_) => Err(ImageGenError::Timeout(self.provider.poll_timeout_secs)),
330        }
331    }
332
333    /// Poll the task endpoint until a terminal state is reached.
334    async fn poll_task(&self, poll_url: &str) -> Result<Value, ImageGenError> {
335        let interval = Duration::from_secs(self.provider.poll_interval_secs);
336        loop {
337            sleep(interval).await;
338            tracing::debug!(
339                "[image_gen] dashscope poll GET {} | auth: Bearer {}",
340                poll_url, mask_key(&self.provider.api_key)
341            );
342            let resp = self
343                .http
344                .get(poll_url)
345                .bearer_auth(&self.provider.api_key)
346                .send()
347                .await?;
348            let status = resp.status();
349            let text = resp.text().await?;
350
351            tracing::debug!(
352                "[image_gen] dashscope poll response: status={} | body: {}",
353                status, truncate_str(&text, 2000)
354            );
355
356            let json: Value = serde_json::from_str(&text).map_err(|e| {
357                ImageGenError::ParseError(format!("dashscope poll response: {e} (body: {text})"))
358            })?;
359
360            if !status.is_success() {
361                tracing::warn!(
362                    "[image_gen] dashscope poll failed: status={}, url={}, body={}",
363                    status, poll_url, truncate_str(&text, 2000)
364                );
365                return Err(dashscope_error(&json).unwrap_or(ImageGenError::Api {
366                    code: status.as_u16().to_string(),
367                    message: text,
368                }));
369            }
370
371            let task_status = json.pointer("/output/task_status").and_then(|v| v.as_str());
372            match task_status {
373                Some("SUCCEEDED") => {
374                    tracing::info!("[image_gen] async task succeeded");
375                    return Ok(json);
376                }
377                Some("FAILED") | Some("CANCELED") | Some("UNKNOWN") => {
378                    return Err(ImageGenError::TaskFailed(
379                        task_status.unwrap_or("UNKNOWN").to_string(),
380                    ));
381                }
382                // PENDING / RUNNING -> keep polling
383                _ => {
384                    tracing::debug!("[image_gen] task status: {:?}", task_status);
385                }
386            }
387        }
388    }
389
390    // ----------------------------------------------------------------------
391    // DashScope helpers
392    // ----------------------------------------------------------------------
393
394    /// Build the Wanxiang request body from the unified request.
395    fn build_dashscope_body(&self, req: &ImageGenRequest) -> Value {
396        let mut parameters = json!({});
397        if let Some(n) = req.n {
398            parameters["n"] = json!(n);
399        }
400        if let Some(ref size) = req.size {
401            parameters["size"] = json!(size);
402        }
403        // Merge extra params into parameters (e.g. watermark, thinking_mode)
404        if let Value::Object(ref extra) = req.extra_params {
405            if let Value::Object(p) = &mut parameters {
406                for (k, v) in extra {
407                    p.insert(k.clone(), v.clone());
408                }
409            }
410        }
411
412        json!({
413            "model": self.provider.model_id,
414            "input": {
415                "messages": [
416                    {
417                        "role": "user",
418                        "content": [ { "text": req.prompt } ]
419                    }
420                ]
421            },
422            "parameters": parameters,
423        })
424    }
425
426    /// POST a DashScope request and return the parsed JSON, checking for errors.
427    /// When `async_mode` is true, the `X-DashScope-Async: enable` header is set.
428    async fn dashscope_post(
429        &self,
430        url: &str,
431        body: &Value,
432        async_mode: bool,
433    ) -> Result<Value, ImageGenError> {
434        tracing::debug!(
435            "[image_gen] dashscope POST {} | async={} | auth: Bearer {} | body: {}",
436            url, async_mode, mask_key(&self.provider.api_key), body
437        );
438
439        let mut req_builder = self
440            .http
441            .post(url)
442            .bearer_auth(&self.provider.api_key)
443            .header("Content-Type", "application/json");
444        if async_mode {
445            req_builder = req_builder.header("X-DashScope-Async", "enable");
446        }
447        let resp = req_builder.json(body).send().await?;
448        let status = resp.status();
449        let text = resp.text().await?;
450
451        tracing::debug!(
452            "[image_gen] dashscope response: status={} | url={} | body: {}",
453            status, url, truncate_str(&text, 2000)
454        );
455
456        let json: Value = serde_json::from_str(&text)
457            .map_err(|e| ImageGenError::ParseError(format!("dashscope response: {e} (body: {text})")))?;
458
459        if !status.is_success() {
460            tracing::warn!(
461                "[image_gen] dashscope request failed: status={}, url={}, body={}",
462                status, url, truncate_str(&text, 2000)
463            );
464            return Err(dashscope_error(&json).unwrap_or(ImageGenError::Api {
465                code: status.as_u16().to_string(),
466                message: text,
467            }));
468        }
469        // DashScope may return 200 with an error code in the body
470        if let Some(err) = dashscope_error(&json) {
471            tracing::warn!(
472                "[image_gen] dashscope API error in 200 response: url={}, body={}",
473                url, truncate_str(&text, 2000)
474            );
475            return Err(err);
476        }
477        Ok(json)
478    }
479
480    /// Extract generated images from a DashScope success response (sync result
481    /// or the final polled task result).
482    fn parse_dashscope_result(&self, json: &Value) -> Result<Vec<GeneratedImage>, ImageGenError> {
483        let size = json
484            .pointer("/usage/size")
485            .and_then(|v| v.as_str())
486            .map(|s| s.to_string());
487
488        let choices = json.pointer("/output/choices").and_then(|c| c.as_array()).ok_or_else(|| {
489            ImageGenError::ParseError(format!("dashscope response missing output.choices (body: {json})"))
490        })?;
491
492        let mut images = Vec::new();
493        for choice in choices {
494            let content = choice
495                .pointer("/message/content")
496                .and_then(|c| c.as_array())
497                .ok_or_else(|| {
498                    ImageGenError::ParseError(format!(
499                        "dashscope choice missing message.content (body: {json})"
500                    ))
501                })?;
502            for item in content {
503                if let Some(url) = item.get("image").and_then(|u| u.as_str()) {
504                    images.push(GeneratedImage {
505                        url: url.to_string(),
506                        size: size.clone(),
507                    });
508                }
509            }
510        }
511
512        if images.is_empty() {
513            return Err(ImageGenError::ParseError(format!(
514                "dashscope response contained no images (body: {json})"
515            )));
516        }
517        Ok(images)
518    }
519}
520
521// ============================================================================
522// Error extraction helpers
523// ============================================================================
524
525/// Extract a DashScope error from the response body, if present.
526/// DashScope errors look like `{ "code": "...", "message": "..." }`.
527fn dashscope_error(json: &Value) -> Option<ImageGenError> {
528    let code = json.get("code").and_then(|v| v.as_str())?;
529    // An empty code string means success (DashScope sometimes returns "" on success)
530    if code.is_empty() {
531        return None;
532    }
533    let message = json.get("message").and_then(|v| v.as_str()).unwrap_or("");
534    Some(ImageGenError::Api {
535        code: code.to_string(),
536        message: message.to_string(),
537    })
538}
539
540/// Extract an OpenAI-style error from the response body, if present.
541fn openai_error(json: &Value) -> Option<ImageGenError> {
542    let err = json.get("error")?;
543    let message = err.get("message").and_then(|v| v.as_str()).unwrap_or("");
544    let code = err.get("code").and_then(|v| v.as_str()).unwrap_or("error");
545    Some(ImageGenError::Api {
546        code: code.to_string(),
547        message: message.to_string(),
548    })
549}
550
551// ============================================================================
552// Logging helpers
553// ============================================================================
554
555/// Mask an API key for logging: show only the first 8 and last 4 characters.
556/// Returns "<empty>" / "<unset>" for edge cases so the log is unambiguous.
557fn mask_key(key: &str) -> String {
558    if key.is_empty() {
559        return "<empty>".to_string();
560    }
561    let len = key.len();
562    if len <= 12 {
563        return format!("{}***", &key[..len.min(4)]);
564    }
565    format!("{}...{}", &key[..8], &key[len - 4..])
566}
567
568/// Truncate a string to `max` characters, appending "..." if truncated.
569/// Keeps log output bounded for large response bodies.
570fn truncate_str(s: &str, max: usize) -> String {
571    if s.len() <= max {
572        s.to_string()
573    } else {
574        format!("{}... (truncated, {} bytes total)", &s[..max], s.len())
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn test_mask_key_normal() {
584        let masked = mask_key("sk-sp-abcdef1234567890");
585        assert!(masked.starts_with("sk-sp-ab"));
586        assert!(masked.ends_with("7890"));
587        assert!(!masked.contains("1234567"));
588    }
589
590    #[test]
591    fn test_mask_key_short() {
592        // Keys <= 12 chars show only first 4 + ***
593        let masked = mask_key("sk-sp-abc");
594        assert_eq!(masked, "sk-s***");
595    }
596
597    #[test]
598    fn test_mask_key_empty() {
599        assert_eq!(mask_key(""), "<empty>");
600    }
601
602    #[test]
603    fn test_truncate_str_short() {
604        assert_eq!(truncate_str("hello", 10), "hello");
605    }
606
607    #[test]
608    fn test_truncate_str_long() {
609        let result = truncate_str("abcdefghijklmnopqrstuvwxyz", 10);
610        assert!(result.starts_with("abcdefghij"));
611        assert!(result.contains("truncated"));
612    }
613
614    #[test]
615    fn test_error_info_api_not_retryable() {
616        let e = ImageGenError::Api {
617            code: "AccessDenied".to_string(),
618            message: "current user api does not support asynchronous calls".to_string(),
619        };
620        let info = e.to_error_info();
621        assert_eq!(info.kind, "api_error");
622        assert_eq!(info.code.as_deref(), Some("AccessDenied"));
623        assert!(!info.retryable, "permission errors should not be retryable");
624    }
625
626    #[test]
627    fn test_error_info_api_retryable() {
628        let e = ImageGenError::Api {
629            code: "Throttling".to_string(),
630            message: "Rate limit exceeded, please retry later".to_string(),
631        };
632        let info = e.to_error_info();
633        assert!(info.retryable, "rate limit errors should be retryable");
634    }
635
636    #[test]
637    fn test_error_info_timeout_retryable() {
638        let info = ImageGenError::Timeout(300).to_error_info();
639        assert_eq!(info.kind, "timeout");
640        assert!(info.retryable);
641        assert!(info.message.contains("300"));
642    }
643
644    #[test]
645    fn test_error_info_parse_error_not_retryable() {
646        let info = ImageGenError::ParseError("bad json".to_string()).to_error_info();
647        assert_eq!(info.kind, "parse_error");
648        assert!(!info.retryable);
649    }
650
651    #[test]
652    fn test_error_info_task_failed_retryable() {
653        let info = ImageGenError::TaskFailed("FAILED".to_string()).to_error_info();
654        assert_eq!(info.kind, "task_failed");
655        assert!(info.retryable);
656    }
657}