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::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 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 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 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 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 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 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 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 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 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 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 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 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 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
554fn dashscope_error(json: &Value) -> Option<ImageGenError> {
561 let code = json.get("code").and_then(|v| v.as_str())?;
562 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
573fn 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
584fn 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
601fn truncate_str(s: &str, max: usize) -> String {
604 if s.len() <= max {
605 s.to_string()
606 } else {
607 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 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 let s: String = "着".repeat(1000);
657 assert_eq!(s.len(), 3000);
658 let result = truncate_str(&s, 2000);
659 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}