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