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!("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
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 size: Option<String>,
129 pub n: Option<u32>,
130 pub extra_params: Value,
132}
133
134pub struct GeneratedImage {
136 pub url: String,
138 pub size: Option<String>,
140}
141
142pub 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={:?}, 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 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 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 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 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 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 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 _ => {
384 tracing::debug!("[image_gen] task status: {:?}", task_status);
385 }
386 }
387 }
388 }
389
390 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 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 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 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 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
521fn dashscope_error(json: &Value) -> Option<ImageGenError> {
528 let code = json.get("code").and_then(|v| v.as_str())?;
529 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
540fn 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
551fn 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
568fn 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 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}