1use 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
20const HTTP_TIMEOUT_SECS: u64 = 120;
22
23#[derive(Debug, Error)]
28pub enum ImageGenError {
29 #[error("HTTP error: {0}")]
30 Http(#[from] reqwest::Error),
31
32 #[error("API error: {code} - {message}")]
34 Api { code: String, message: String },
35
36 #[error("Task timed out after {0}s")]
38 Timeout(u64),
39
40 #[error("Task failed: {0}")]
42 TaskFailed(String),
43
44 #[error("Response parse error: {0}")]
46 ParseError(String),
47}
48
49#[derive(Debug, Clone)]
52pub struct ImageGenErrorInfo {
53 pub kind: &'static str,
56 pub code: Option<String>,
58 pub message: String,
60 pub retryable: bool,
63}
64
65impl ImageGenError {
66 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
103fn 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
121pub struct ImageGenRequest {
127 pub prompt: String,
128 pub n: Option<u32>,
129 pub extra_params: Value,
131}
132
133pub struct GeneratedImage {
135 pub url: String,
137 pub size: Option<String>,
139}
140
141#[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 pub async fn generate(&self, req: &ImageGenRequest) -> Result<Vec<GeneratedImage>, ImageGenError> {
166 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
562fn dashscope_error(json: &Value) -> Option<ImageGenError> {
569 let code = json.get("code").and_then(|v| v.as_str())?;
570 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
581fn 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
592fn 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
609fn truncate_str(s: &str, max: usize) -> String {
612 if s.len() <= max {
613 s.to_string()
614 } else {
615 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 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 let s: String = "着".repeat(1000);
665 assert_eq!(s.len(), 3000);
666 let result = truncate_str(&s, 2000);
667 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}