1use crate::chain::{ChainProgressEvent, ChainRequest, ChainResponse, SseChainCompleteEvent};
2use crate::chain_job::{
3 ChainJobDetail, ChainJobListing, ChainJobSummary, CreateChainJobResponse, GcOutcome,
4 RetakeRequest,
5};
6use crate::error::MoldError;
7use crate::types::{
8 AudioData, DeviceState, ExpandRequest, ExpandResponse, GalleryImage, GenerateRequest,
9 GenerateResponse, ImageData, LoraInfo, ModelInfo, ModelInfoExtended, OutputFormat,
10 QueueListingWire, ReferenceUploadCompleteResponse, ReferenceUploadSessionRequest,
11 ReferenceUploadSessionResponse, ServerStatus, SseCompleteEvent, SseErrorEvent,
12 SseProgressEvent, VideoData,
13};
14use anyhow::{Context, Result};
15use base64::Engine as _;
16use reqwest::{Client, StatusCode};
17use std::io::{Seek, SeekFrom};
18use std::path::Path;
19use tokio_util::io::ReaderStream;
20
21const REFERENCE_UPLOAD_HANDLE_HEADER: &str = "x-mold-reference-upload";
22const REFERENCE_UPLOAD_SESSION_HEADER: &str = "x-mold-reference-upload-session";
23
24#[derive(Clone)]
25pub struct MoldClient {
26 base_url: String,
27 client: Client,
28 api_key_configured: bool,
29}
30
31impl MoldClient {
32 pub fn new(base_url: &str) -> Self {
33 let (client, api_key_configured) = build_client(None);
34 Self {
35 base_url: normalize_host(base_url),
36 client,
37 api_key_configured,
38 }
39 }
40
41 pub fn with_api_key(base_url: &str, api_key: String) -> Self {
43 let (client, api_key_configured) = build_client(Some(&api_key));
44 Self {
45 base_url: normalize_host(base_url),
46 client,
47 api_key_configured,
48 }
49 }
50
51 pub fn from_env() -> Self {
52 let base_url =
53 std::env::var("MOLD_HOST").unwrap_or_else(|_| "http://localhost:7680".to_string());
54 let api_key = std::env::var("MOLD_API_KEY").ok().filter(|k| !k.is_empty());
55 let (client, api_key_configured) = build_client(api_key.as_deref());
56 Self {
57 base_url: normalize_host(&base_url),
58 client,
59 api_key_configured,
60 }
61 }
62
63 pub fn has_api_key(&self) -> bool {
65 self.api_key_configured
66 }
67
68 pub async fn create_reference_upload_session(
75 &self,
76 request: &ReferenceUploadSessionRequest,
77 ) -> Result<ReferenceUploadSessionResponse> {
78 let response = self
79 .client
80 .post(format!(
81 "{}/api/generate/reference-upload-sessions",
82 self.base_url
83 ))
84 .json(request)
85 .send()
86 .await?;
87 Ok(error_for_status_with_body(response)
88 .await?
89 .json::<ReferenceUploadSessionResponse>()
90 .await?)
91 }
92
93 pub async fn upload_reference_file(
100 &self,
101 handle: &str,
102 path: &Path,
103 mime_type: &str,
104 ) -> Result<ReferenceUploadCompleteResponse> {
105 let file = tokio::fs::File::open(path)
106 .await
107 .with_context(|| format!("failed to open reference '{}'", path.display()))?;
108 let metadata = file
109 .metadata()
110 .await
111 .with_context(|| format!("failed to inspect reference '{}'", path.display()))?;
112 anyhow::ensure!(
113 metadata.is_file() && metadata.len() > 0,
114 "reference upload source is not a non-empty regular file: {}",
115 path.display()
116 );
117 self.upload_reference_body(
118 handle,
119 mime_type,
120 metadata.len(),
121 reqwest::Body::wrap_stream(ReaderStream::new(file)),
122 )
123 .await
124 }
125
126 pub async fn upload_reference_open_file(
133 &self,
134 handle: &str,
135 mut file: std::fs::File,
136 mime_type: &str,
137 ) -> Result<ReferenceUploadCompleteResponse> {
138 let metadata = file.metadata().context("failed to inspect reference")?;
139 anyhow::ensure!(
140 metadata.is_file() && metadata.len() > 0,
141 "reference upload source is not a non-empty regular file"
142 );
143 file.seek(SeekFrom::Start(0))
144 .context("failed to rewind reference")?;
145 let file = tokio::fs::File::from_std(file);
146 self.upload_reference_body(
147 handle,
148 mime_type,
149 metadata.len(),
150 reqwest::Body::wrap_stream(ReaderStream::new(file)),
151 )
152 .await
153 }
154
155 pub async fn upload_reference_bytes(
161 &self,
162 handle: &str,
163 bytes: Vec<u8>,
164 mime_type: &str,
165 ) -> Result<ReferenceUploadCompleteResponse> {
166 anyhow::ensure!(!bytes.is_empty(), "reference upload source is empty");
167 let length = u64::try_from(bytes.len()).context("reference upload is too large")?;
168 self.upload_reference_body(handle, mime_type, length, reqwest::Body::from(bytes))
169 .await
170 }
171
172 async fn upload_reference_body(
173 &self,
174 handle: &str,
175 mime_type: &str,
176 content_length: u64,
177 body: reqwest::Body,
178 ) -> Result<ReferenceUploadCompleteResponse> {
179 let response = self
180 .client
181 .put(format!("{}/api/generate/reference-upload", self.base_url))
182 .header(REFERENCE_UPLOAD_HANDLE_HEADER, handle)
183 .header(reqwest::header::CONTENT_TYPE, mime_type)
184 .header(reqwest::header::CONTENT_LENGTH, content_length)
185 .body(body)
186 .send()
187 .await?;
188 Ok(error_for_status_with_body(response)
189 .await?
190 .json::<ReferenceUploadCompleteResponse>()
191 .await?)
192 }
193
194 pub async fn cancel_reference_upload_session(&self, handle: &str) -> Result<()> {
196 let response = self
197 .client
198 .delete(format!(
199 "{}/api/generate/reference-upload-sessions",
200 self.base_url
201 ))
202 .header(REFERENCE_UPLOAD_SESSION_HEADER, handle)
203 .send()
204 .await?;
205 error_for_status_with_body(response).await?;
206 Ok(())
207 }
208
209 pub async fn generate_raw(&self, req: &GenerateRequest) -> Result<Vec<u8>> {
213 let bytes = self
214 .client
215 .post(format!("{}/api/generate", self.base_url))
216 .json(req)
217 .send()
218 .await?
219 .error_for_status()?
220 .bytes()
221 .await?
222 .to_vec();
223 Ok(bytes)
224 }
225
226 pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse> {
231 let fallback_seed = req.seed.unwrap_or(0);
232 let width = req.width;
233 let height = req.height;
234 let model = req.model.clone();
235 let format = req.resolved_output_format();
236
237 let start = std::time::Instant::now();
238 let resp = self
239 .client
240 .post(format!("{}/api/generate", self.base_url))
241 .json(&req)
242 .send()
243 .await?
244 .error_for_status()?;
245
246 let seed_used = resp
249 .headers()
250 .get("x-mold-seed-used")
251 .and_then(|v| v.to_str().ok())
252 .and_then(|s| s.parse::<u64>().ok())
253 .unwrap_or(fallback_seed);
254 let gpu = resp
255 .headers()
256 .get("x-mold-gpu")
257 .and_then(|v| v.to_str().ok())
258 .and_then(|s| s.parse::<usize>().ok());
259
260 let audio_meta = parse_audio_headers(resp.headers());
264 let video_meta = parse_video_headers(resp.headers());
266
267 let data = resp.bytes().await?.to_vec();
268 let generation_time_ms = start.elapsed().as_millis() as u64;
269
270 if let Some(meta) = audio_meta {
271 return Ok(GenerateResponse {
272 audio: Some(AudioData {
273 data,
274 format: meta.format.unwrap_or(if format.is_audio() {
278 format
279 } else {
280 OutputFormat::Wav
281 }),
282 sample_rate: meta.sample_rate,
283 channels: meta.channels,
284 duration_ms: meta.duration_ms,
285 thumbnail: Vec::new(),
288 thumbnail_width: meta.thumbnail_width,
289 thumbnail_height: meta.thumbnail_height,
290 }),
291 images: Vec::new(),
292 video: None,
293 generation_time_ms,
294 model,
295 seed_used,
296 gpu,
297 });
298 }
299
300 let video = video_meta.map(|meta| VideoData {
301 data: data.clone(),
302 format,
303 width: meta.width.unwrap_or(width),
304 height: meta.height.unwrap_or(height),
305 frames: meta.frames,
306 fps: meta.fps,
307 pipeline: meta.pipeline,
308 pipeline_provenance_sha256: meta.pipeline_provenance_sha256,
309 source_preprocessing: meta.source_preprocessing,
310 thumbnail: Vec::new(),
311 gif_preview: Vec::new(),
312 has_audio: meta.has_audio,
313 duration_ms: meta.duration_ms,
314 audio_sample_rate: meta.audio_sample_rate,
315 audio_channels: meta.audio_channels,
316 });
317
318 let images = if video.is_some() {
320 Vec::new()
321 } else {
322 vec![ImageData {
323 data,
324 format,
325 width,
326 height,
327 index: 0,
328 }]
329 };
330
331 Ok(GenerateResponse {
332 audio: None,
333 images,
334 generation_time_ms,
335 model,
336 seed_used,
337 video,
338 gpu,
339 })
340 }
341
342 pub async fn list_models(&self) -> Result<Vec<ModelInfo>> {
343 let models = self.list_models_extended().await?;
344 Ok(models.into_iter().map(|m| m.info).collect())
345 }
346
347 pub async fn list_models_extended(&self) -> Result<Vec<ModelInfoExtended>> {
348 let resp = self
349 .client
350 .get(format!("{}/api/models", self.base_url))
351 .send()
352 .await?
353 .error_for_status()?
354 .json::<Vec<ModelInfoExtended>>()
355 .await?;
356 Ok(resp)
357 }
358
359 pub async fn list_loras(&self, model: Option<&str>) -> Result<Vec<LoraInfo>> {
361 match self.list_loras_endpoint(model).await {
362 Ok(loras) => Ok(loras),
363 Err(err) if should_fallback_loras_endpoint(&err) => self
364 .list_loras_from_installed_catalog(model)
365 .await
366 .with_context(|| {
367 format!(
368 "failed to list LoRAs via /api/loras ({err}); fallback to /api/catalog/installed also failed"
369 )
370 }),
371 Err(err) => Err(err),
372 }
373 }
374
375 async fn list_loras_endpoint(&self, model: Option<&str>) -> Result<Vec<LoraInfo>> {
376 let req = self.client.get(format!("{}/api/loras", self.base_url));
377 let req = if let Some(model) = model {
378 req.query(&[("model", model)])
379 } else {
380 req
381 };
382 let resp = req
383 .send()
384 .await?
385 .error_for_status()?
386 .json::<Vec<LoraInfo>>()
387 .await?;
388 Ok(resp)
389 }
390
391 async fn list_loras_from_installed_catalog(
392 &self,
393 model: Option<&str>,
394 ) -> Result<Vec<LoraInfo>> {
395 let family = model.and_then(lora_family_for_model_filter);
396 let mut req = self
397 .client
398 .get(format!("{}/api/catalog/installed", self.base_url))
399 .query(&[("kind", "lora")]);
400 if let Some(family) = family.as_deref() {
401 req = req.query(&[("family", family)]);
402 }
403
404 let resp = req
405 .send()
406 .await?
407 .error_for_status()?
408 .json::<crate::catalog_wire::InstalledCatalogResponse>()
409 .await?;
410 let family = family.as_deref();
411 let mut loras = resp
412 .entries
413 .into_iter()
414 .filter_map(installed_entry_into_lora_info)
415 .filter(|lora| family.is_none_or(|family| lora.family == family))
416 .collect::<Vec<_>>();
417 loras.sort_by(|a, b| {
418 b.added_at
419 .cmp(&a.added_at)
420 .then_with(|| a.name.cmp(&b.name))
421 .then_with(|| a.id.cmp(&b.id))
422 });
423 Ok(loras)
424 }
425
426 pub fn is_connection_error(err: &anyhow::Error) -> bool {
429 if let Some(mold_err) = err.downcast_ref::<MoldError>() {
431 if matches!(mold_err, MoldError::Client(_)) {
432 return true;
433 }
434 }
435 if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
436 return reqwest_err.is_connect();
437 }
438 false
439 }
440
441 pub fn is_model_not_found(err: &anyhow::Error) -> bool {
444 if let Some(mold_err) = err.downcast_ref::<MoldError>() {
446 if matches!(mold_err, MoldError::ModelNotFound(_)) {
447 return true;
448 }
449 }
450 if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
451 return reqwest_err.status() == Some(reqwest::StatusCode::NOT_FOUND);
452 }
453 err.downcast_ref::<ModelNotFoundError>().is_some()
455 }
456
457 pub async fn generate_stream(
464 &self,
465 req: &GenerateRequest,
466 progress_tx: tokio::sync::mpsc::UnboundedSender<SseProgressEvent>,
467 ) -> Result<Option<GenerateResponse>> {
468 let mut resp = self
469 .client
470 .post(format!("{}/api/generate/stream", self.base_url))
471 .json(req)
472 .send()
473 .await?;
474
475 if resp.status() == reqwest::StatusCode::NOT_FOUND {
476 let body = resp.text().await.unwrap_or_default();
477 if body.is_empty() {
478 return Ok(None);
480 }
481 return Err(MoldError::ModelNotFound(body).into());
483 }
484
485 if resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
486 let body = resp.text().await.unwrap_or_default();
487 return Err(MoldError::Validation(api_error_detail(&body)).into());
488 }
489
490 if resp.status().is_client_error() || resp.status().is_server_error() {
491 let status = resp.status();
492 let body = resp.text().await.unwrap_or_default();
493 anyhow::bail!("server error {status}: {body}");
494 }
495
496 let mut buffer = String::new();
498 while let Some(chunk) = resp.chunk().await? {
499 buffer.push_str(&String::from_utf8_lossy(&chunk));
500
501 while let Some(event_text) = next_sse_event(&mut buffer) {
502 let (event_type, data) = parse_sse_event(&event_text);
503 match event_type.as_str() {
504 "progress" => {
505 if let Ok(p) = serde_json::from_str::<SseProgressEvent>(&data) {
506 let _ = progress_tx.send(p);
507 }
508 }
509 "complete" => {
510 let complete: SseCompleteEvent = serde_json::from_str(&data)?;
511 let payload =
512 base64::engine::general_purpose::STANDARD.decode(&complete.image)?;
513 let b64 = base64::engine::general_purpose::STANDARD;
514 let model = if complete.model.is_empty() {
518 req.model.clone()
519 } else {
520 complete.model
521 };
522
523 if let Some(sample_rate) = complete.audio_sample_rate {
529 let thumbnail = complete
530 .audio_thumbnail
531 .as_deref()
532 .and_then(|s| b64.decode(s).ok())
533 .unwrap_or_default();
534 return Ok(Some(GenerateResponse {
535 images: Vec::new(),
536 video: None,
537 audio: Some(AudioData {
538 data: payload,
539 format: complete.format,
540 sample_rate,
541 channels: complete.audio_channels.unwrap_or(1),
542 duration_ms: complete.audio_duration_ms.unwrap_or(0),
543 thumbnail,
544 thumbnail_width: complete.width,
545 thumbnail_height: complete.height,
546 }),
547 generation_time_ms: complete.generation_time_ms,
548 model,
549 seed_used: complete.seed_used,
550 gpu: complete.gpu,
551 }));
552 }
553
554 let (images, video) = if let (Some(frames), Some(fps)) =
556 (complete.video_frames, complete.video_fps)
557 {
558 let thumbnail = complete
559 .video_thumbnail
560 .as_deref()
561 .and_then(|s| b64.decode(s).ok())
562 .unwrap_or_default();
563 let gif_preview = complete
564 .video_gif_preview
565 .as_deref()
566 .and_then(|s| b64.decode(s).ok())
567 .unwrap_or_default();
568 let vd = VideoData {
569 data: payload,
570 format: complete.format,
571 width: complete.width,
572 height: complete.height,
573 frames,
574 fps,
575 pipeline: complete.metadata.as_ref().and_then(|m| m.pipeline),
576 pipeline_provenance_sha256: complete.metadata.as_ref().and_then(
577 |metadata| metadata.pipeline_provenance_sha256.clone(),
578 ),
579 source_preprocessing: complete
580 .metadata
581 .as_ref()
582 .and_then(|metadata| metadata.source_preprocessing.clone()),
583 thumbnail,
584 gif_preview,
585 has_audio: complete.video_has_audio,
586 duration_ms: complete.video_duration_ms,
587 audio_sample_rate: complete.video_audio_sample_rate,
588 audio_channels: complete.video_audio_channels,
589 };
590 (Vec::new(), Some(vd))
591 } else {
592 let img = ImageData {
593 data: payload,
594 format: complete.format,
595 width: complete.width,
596 height: complete.height,
597 index: 0,
598 };
599 (vec![img], None)
600 };
601
602 return Ok(Some(GenerateResponse {
603 audio: None,
604 images,
605 generation_time_ms: complete.generation_time_ms,
606 model,
607 seed_used: complete.seed_used,
608 video,
609 gpu: complete.gpu,
610 }));
611 }
612 "error" => {
613 let error: SseErrorEvent = serde_json::from_str(&data)?;
614 anyhow::bail!("server error: {}", error.message);
615 }
616 _ => {}
617 }
618 }
619 }
620
621 anyhow::bail!("SSE stream ended without complete event")
622 }
623
624 pub async fn generate_chain(&self, req: &ChainRequest) -> Result<ChainResponse> {
632 let resp = self
633 .client
634 .post(format!("{}/api/generate/chain", self.base_url))
635 .json(req)
636 .send()
637 .await?;
638
639 if resp.status() == reqwest::StatusCode::NOT_FOUND {
640 let body = resp.text().await.unwrap_or_default();
641 if body.is_empty() {
642 anyhow::bail!("chain endpoint not found — server predates render-chain v1");
643 }
644 return Err(MoldError::ModelNotFound(body).into());
645 }
646 if resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
647 let body = resp.text().await.unwrap_or_default();
648 return Err(MoldError::Validation(api_error_detail(&body)).into());
649 }
650 if resp.status().is_client_error() || resp.status().is_server_error() {
651 let status = resp.status();
652 let body = resp.text().await.unwrap_or_default();
653 anyhow::bail!("server error {status}: {body}");
654 }
655
656 let chain: ChainResponse = resp.json().await?;
657 Ok(chain)
658 }
659
660 pub async fn generate_chain_stream(
669 &self,
670 req: &ChainRequest,
671 progress_tx: tokio::sync::mpsc::UnboundedSender<ChainProgressEvent>,
672 ) -> Result<Option<ChainResponse>> {
673 let mut resp = self
674 .client
675 .post(format!("{}/api/generate/chain/stream", self.base_url))
676 .json(req)
677 .send()
678 .await?;
679
680 if resp.status() == reqwest::StatusCode::NOT_FOUND {
681 let body = resp.text().await.unwrap_or_default();
682 if body.is_empty() {
683 return Ok(None);
684 }
685 return Err(MoldError::ModelNotFound(body).into());
686 }
687 if resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
688 let body = resp.text().await.unwrap_or_default();
689 return Err(MoldError::Validation(api_error_detail(&body)).into());
690 }
691 if resp.status().is_client_error() || resp.status().is_server_error() {
692 let status = resp.status();
693 let body = resp.text().await.unwrap_or_default();
694 anyhow::bail!("server error {status}: {body}");
695 }
696
697 let b64 = base64::engine::general_purpose::STANDARD;
698 let mut buffer = String::new();
699 while let Some(chunk) = resp.chunk().await? {
700 buffer.push_str(&String::from_utf8_lossy(&chunk));
701
702 while let Some(event_text) = next_sse_event(&mut buffer) {
703 let (event_type, data) = parse_sse_event(&event_text);
704 match event_type.as_str() {
705 "progress" => {
706 if let Ok(p) = serde_json::from_str::<ChainProgressEvent>(&data) {
707 let _ = progress_tx.send(p);
708 }
709 }
710 "complete" => {
711 let complete: SseChainCompleteEvent = serde_json::from_str(&data)?;
712 let payload = b64.decode(&complete.video)?;
713 let thumbnail = complete
714 .thumbnail
715 .as_deref()
716 .and_then(|s| b64.decode(s).ok())
717 .unwrap_or_default();
718 let gif_preview = complete
719 .gif_preview
720 .as_deref()
721 .and_then(|s| b64.decode(s).ok())
722 .unwrap_or_default();
723 let video = VideoData {
724 data: payload,
725 format: complete.format,
726 width: complete.width,
727 height: complete.height,
728 frames: complete.frames,
729 fps: complete.fps,
730 pipeline: complete.metadata.as_ref().and_then(|m| m.pipeline),
731 pipeline_provenance_sha256: complete
732 .metadata
733 .as_ref()
734 .and_then(|metadata| metadata.pipeline_provenance_sha256.clone()),
735 source_preprocessing: complete
736 .metadata
737 .as_ref()
738 .and_then(|metadata| metadata.source_preprocessing.clone()),
739 thumbnail,
740 gif_preview,
741 has_audio: complete.has_audio,
742 duration_ms: complete.duration_ms,
743 audio_sample_rate: complete.audio_sample_rate,
744 audio_channels: complete.audio_channels,
745 };
746 return Ok(Some(ChainResponse {
747 video,
748 stage_count: complete.stage_count,
749 gpu: complete.gpu,
750 script: complete.script,
751 vram_estimate: complete.vram_estimate,
752 }));
753 }
754 "error" => {
755 let error: SseErrorEvent = serde_json::from_str(&data)?;
756 anyhow::bail!("server error: {}", error.message);
757 }
758 _ => {}
759 }
760 }
761 }
762
763 anyhow::bail!("chain SSE stream ended without complete event")
764 }
765
766 pub async fn create_chain_job(&self, req: &ChainRequest) -> Result<CreateChainJobResponse> {
767 let resp = self
768 .client
769 .post(format!("{}/api/chain-jobs", self.base_url))
770 .json(req)
771 .send()
772 .await?;
773 Ok(error_for_status_with_body(resp)
774 .await?
775 .json::<CreateChainJobResponse>()
776 .await?)
777 }
778
779 pub async fn list_chain_jobs(&self) -> Result<ChainJobListing> {
780 let resp = self
781 .client
782 .get(format!("{}/api/chain-jobs", self.base_url))
783 .send()
784 .await?;
785 let mut listing = error_for_status_with_body(resp)
786 .await?
787 .json::<ChainJobListing>()
788 .await?;
789 listing.jobs.retain(|job| !job.ephemeral);
793 Ok(listing)
794 }
795
796 pub async fn get_chain_job(&self, id: &str) -> Result<ChainJobDetail> {
797 let resp = self
798 .client
799 .get(format!(
800 "{}/api/chain-jobs/{}",
801 self.base_url,
802 encode_path_segment(id)
803 ))
804 .send()
805 .await?;
806 Ok(error_for_status_with_body(resp)
807 .await?
808 .json::<ChainJobDetail>()
809 .await?)
810 }
811
812 pub async fn resume_chain_job(&self, id: &str) -> Result<ChainJobSummary> {
813 let resp = self
814 .client
815 .post(format!(
816 "{}/api/chain-jobs/{}/resume",
817 self.base_url,
818 encode_path_segment(id)
819 ))
820 .send()
821 .await?;
822 Ok(error_for_status_with_body(resp)
823 .await?
824 .json::<ChainJobSummary>()
825 .await?)
826 }
827
828 pub async fn retake_chain_job(&self, id: &str, req: &RetakeRequest) -> Result<ChainJobSummary> {
829 let resp = self
830 .client
831 .post(format!(
832 "{}/api/chain-jobs/{}/retake",
833 self.base_url,
834 encode_path_segment(id)
835 ))
836 .json(req)
837 .send()
838 .await?;
839 Ok(error_for_status_with_body(resp)
840 .await?
841 .json::<ChainJobSummary>()
842 .await?)
843 }
844
845 pub async fn cancel_chain_job(&self, id: &str) -> Result<ChainJobSummary> {
846 let resp = self
847 .client
848 .post(format!(
849 "{}/api/chain-jobs/{}/cancel",
850 self.base_url,
851 encode_path_segment(id)
852 ))
853 .send()
854 .await?;
855 Ok(error_for_status_with_body(resp)
856 .await?
857 .json::<ChainJobSummary>()
858 .await?)
859 }
860
861 pub async fn delete_chain_job(&self, id: &str) -> Result<()> {
862 let resp = self
863 .client
864 .delete(format!(
865 "{}/api/chain-jobs/{}",
866 self.base_url,
867 encode_path_segment(id)
868 ))
869 .send()
870 .await?;
871 error_for_status_with_body(resp).await?;
872 Ok(())
873 }
874
875 pub async fn gc_chain_jobs(&self) -> Result<GcOutcome> {
876 let resp = self
877 .client
878 .post(format!("{}/api/chain-jobs/gc", self.base_url))
879 .send()
880 .await?;
881 Ok(error_for_status_with_body(resp)
882 .await?
883 .json::<GcOutcome>()
884 .await?)
885 }
886
887 pub async fn pull_model(&self, model: &str) -> Result<String> {
891 let resp = self
892 .client
893 .post(format!("{}/api/models/pull", self.base_url))
894 .json(&serde_json::json!({ "model": model }))
895 .send()
896 .await?
897 .error_for_status()?
898 .text()
899 .await?;
900 Ok(resp)
901 }
902
903 pub async fn shutdown_server(&self) -> Result<()> {
905 self.client
906 .post(format!("{}/api/shutdown", self.base_url))
907 .send()
908 .await?
909 .error_for_status()?;
910 Ok(())
911 }
912
913 pub async fn pull_model_stream(
918 &self,
919 model: &str,
920 progress_tx: tokio::sync::mpsc::UnboundedSender<SseProgressEvent>,
921 ) -> Result<()> {
922 let mut resp = self
923 .client
924 .post(format!("{}/api/models/pull", self.base_url))
925 .header("Accept", "text/event-stream")
926 .json(&serde_json::json!({ "model": model }))
927 .send()
928 .await?;
929
930 if resp.status().is_client_error() || resp.status().is_server_error() {
931 let status = resp.status();
932 let body = resp.text().await.unwrap_or_default();
933 anyhow::bail!("server error {status}: {body}");
934 }
935
936 let content_type = resp
938 .headers()
939 .get("content-type")
940 .and_then(|v| v.to_str().ok())
941 .unwrap_or("");
942
943 if !content_type.contains("text/event-stream") {
944 drop(progress_tx);
947 let _ = resp.text().await?;
948 return Ok(());
949 }
950
951 let mut buffer = String::new();
953 while let Some(chunk) = resp.chunk().await? {
954 buffer.push_str(&String::from_utf8_lossy(&chunk));
955
956 while let Some(event_text) = next_sse_event(&mut buffer) {
957 let (event_type, data) = parse_sse_event(&event_text);
958 match event_type.as_str() {
959 "progress" => {
960 if let Ok(p) = serde_json::from_str::<SseProgressEvent>(&data) {
961 let is_done = matches!(p, SseProgressEvent::PullComplete { .. });
963 let _ = progress_tx.send(p);
964 if is_done {
965 return Ok(());
966 }
967 }
968 }
969 "error" => {
970 let error: SseErrorEvent = serde_json::from_str(&data)?;
971 anyhow::bail!("server error: {}", error.message);
972 }
973 _ => {}
974 }
975 }
976 }
977
978 Ok(())
979 }
980
981 pub fn host(&self) -> &str {
982 &self.base_url
983 }
984
985 pub async fn unload_model(&self) -> Result<String> {
986 self.unload_model_target(None, None).await
987 }
988
989 pub async fn unload_model_target(
990 &self,
991 model: Option<&str>,
992 gpu: Option<usize>,
993 ) -> Result<String> {
994 let req = serde_json::json!({
995 "model": model,
996 "gpu": gpu,
997 });
998 let builder = self
999 .client
1000 .delete(format!("{}/api/models/unload", self.base_url));
1001 let builder = if model.is_some() || gpu.is_some() {
1002 builder.json(&req)
1003 } else {
1004 builder
1005 };
1006 let resp = builder.send().await?.error_for_status()?.text().await?;
1007 Ok(resp)
1008 }
1009
1010 pub async fn server_status(&self) -> Result<ServerStatus> {
1011 let resp = self
1012 .client
1013 .get(format!("{}/api/status", self.base_url))
1014 .send()
1015 .await?
1016 .error_for_status()?
1017 .json::<ServerStatus>()
1018 .await?;
1019 Ok(resp)
1020 }
1021
1022 pub async fn server_capabilities(&self) -> Result<crate::ServerCapabilities> {
1024 let resp = self
1025 .client
1026 .get(format!("{}/api/capabilities", self.base_url))
1027 .send()
1028 .await?
1029 .error_for_status()?
1030 .json::<crate::ServerCapabilities>()
1031 .await?;
1032 Ok(resp)
1033 }
1034
1035 pub async fn devices(&self) -> Result<DeviceState> {
1037 let resp = self
1038 .client
1039 .get(format!("{}/api/devices", self.base_url))
1040 .send()
1041 .await?
1042 .error_for_status()?
1043 .json::<DeviceState>()
1044 .await?;
1045 Ok(resp)
1046 }
1047
1048 pub async fn capabilities(&self) -> Result<crate::ServerCapabilities> {
1050 self.server_capabilities().await
1051 }
1052
1053 pub async fn set_device_enabled(
1056 &self,
1057 device_id: &str,
1058 enabled: bool,
1059 ) -> Result<crate::DeviceInfo> {
1060 let response = self
1061 .client
1062 .patch(format!(
1063 "{}/api/devices/{}",
1064 self.base_url,
1065 encode_path_segment(device_id)
1066 ))
1067 .json(&crate::DeviceMutationRequest { enabled })
1068 .send()
1069 .await?;
1070 let response = error_for_status_with_body(response)
1071 .await?
1072 .json::<crate::DeviceInfo>()
1073 .await?;
1074 Ok(response)
1075 }
1076
1077 pub async fn list_queue(&self) -> Result<QueueListingWire> {
1085 let resp = self
1086 .client
1087 .get(format!("{}/api/queue", self.base_url))
1088 .send()
1089 .await?
1090 .error_for_status()?
1091 .json::<QueueListingWire>()
1092 .await?;
1093 Ok(resp)
1094 }
1095
1096 pub async fn cancel_queue_job(&self, id: &str) -> Result<()> {
1104 let resp = self
1105 .client
1106 .delete(format!(
1107 "{}/api/queue/{}",
1108 self.base_url,
1109 encode_path_segment(id)
1110 ))
1111 .send()
1112 .await?;
1113 error_for_status_with_body(resp).await?;
1114 Ok(())
1115 }
1116
1117 pub async fn list_gallery(&self) -> Result<Vec<GalleryImage>> {
1119 let resp = self
1120 .client
1121 .get(format!("{}/api/gallery", self.base_url))
1122 .send()
1123 .await?
1124 .error_for_status()?
1125 .json::<Vec<GalleryImage>>()
1126 .await?;
1127 Ok(resp)
1128 }
1129
1130 pub async fn get_gallery_image(&self, filename: &str) -> Result<Vec<u8>> {
1132 let resp = self
1133 .client
1134 .get(format!("{}/api/gallery/image/{filename}", self.base_url))
1135 .send()
1136 .await?
1137 .error_for_status()?
1138 .bytes()
1139 .await?;
1140 Ok(resp.to_vec())
1141 }
1142
1143 pub async fn delete_gallery_image(&self, filename: &str) -> Result<()> {
1145 self.client
1146 .delete(format!("{}/api/gallery/image/{filename}", self.base_url))
1147 .send()
1148 .await?
1149 .error_for_status()?;
1150 Ok(())
1151 }
1152
1153 pub async fn get_gallery_preview(&self, filename: &str) -> Result<Option<Vec<u8>>> {
1159 let resp = self
1160 .client
1161 .get(format!("{}/api/gallery/preview/{filename}", self.base_url))
1162 .send()
1163 .await?;
1164 if resp.status() == reqwest::StatusCode::NOT_FOUND {
1165 return Ok(None);
1166 }
1167 let bytes = resp.error_for_status()?.bytes().await?;
1168 Ok(Some(bytes.to_vec()))
1169 }
1170
1171 pub async fn get_gallery_thumbnail(&self, filename: &str) -> Result<Vec<u8>> {
1173 let resp = self
1174 .client
1175 .get(format!(
1176 "{}/api/gallery/thumbnail/{filename}",
1177 self.base_url
1178 ))
1179 .send()
1180 .await?
1181 .error_for_status()?
1182 .bytes()
1183 .await?;
1184 Ok(resp.to_vec())
1185 }
1186
1187 pub async fn expand_prompt(&self, req: &ExpandRequest) -> Result<ExpandResponse> {
1189 let resp = self
1190 .client
1191 .post(format!("{}/api/expand", self.base_url))
1192 .json(req)
1193 .send()
1194 .await?
1195 .error_for_status()?
1196 .json::<ExpandResponse>()
1197 .await?;
1198 Ok(resp)
1199 }
1200
1201 pub async fn remix_prompt(&self, req: &crate::RemixRequest) -> Result<crate::RemixResponse> {
1204 let resp = self
1205 .client
1206 .post(format!("{}/api/remix", self.base_url))
1207 .json(req)
1208 .send()
1209 .await?
1210 .error_for_status()?
1211 .json::<crate::RemixResponse>()
1212 .await?;
1213 Ok(resp)
1214 }
1215
1216 pub async fn upscale(&self, req: &crate::UpscaleRequest) -> Result<crate::UpscaleResponse> {
1218 let resp = self
1219 .client
1220 .post(format!("{}/api/upscale", self.base_url))
1221 .json(req)
1222 .send()
1223 .await?
1224 .error_for_status()?
1225 .json::<crate::UpscaleResponse>()
1226 .await?;
1227 Ok(resp)
1228 }
1229
1230 pub async fn upscale_stream(
1233 &self,
1234 req: &crate::UpscaleRequest,
1235 progress_tx: tokio::sync::mpsc::UnboundedSender<SseProgressEvent>,
1236 ) -> Result<Option<crate::UpscaleResponse>> {
1237 let mut resp = self
1238 .client
1239 .post(format!("{}/api/upscale/stream", self.base_url))
1240 .json(req)
1241 .send()
1242 .await?;
1243
1244 if resp.status() == reqwest::StatusCode::NOT_FOUND {
1245 let body = resp.text().await.unwrap_or_default();
1246 if body.is_empty() {
1247 return Ok(None); }
1249 return Err(MoldError::ModelNotFound(body).into());
1250 }
1251
1252 if resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
1253 let body = resp.text().await.unwrap_or_default();
1254 return Err(MoldError::Validation(api_error_detail(&body)).into());
1255 }
1256
1257 if resp.status().is_client_error() || resp.status().is_server_error() {
1258 let status = resp.status();
1259 let body = resp.text().await.unwrap_or_default();
1260 anyhow::bail!("server error {status}: {body}");
1261 }
1262
1263 let mut buffer = String::new();
1264 while let Some(chunk) = resp.chunk().await? {
1265 buffer.push_str(&String::from_utf8_lossy(&chunk));
1266
1267 while let Some(event_text) = next_sse_event(&mut buffer) {
1268 let (event_type, data) = parse_sse_event(&event_text);
1269 match event_type.as_str() {
1270 "progress" => {
1271 if let Ok(p) = serde_json::from_str::<SseProgressEvent>(&data) {
1272 let _ = progress_tx.send(p);
1273 }
1274 }
1275 "complete" => {
1276 let complete: crate::SseUpscaleCompleteEvent = serde_json::from_str(&data)?;
1277 let image_data =
1278 base64::engine::general_purpose::STANDARD.decode(&complete.image)?;
1279 return Ok(Some(crate::UpscaleResponse {
1280 image: crate::ImageData {
1281 data: image_data,
1282 format: complete.format,
1283 width: complete.original_width * complete.scale_factor,
1284 height: complete.original_height * complete.scale_factor,
1285 index: 0,
1286 },
1287 upscale_time_ms: complete.upscale_time_ms,
1288 model: complete.model,
1289 scale_factor: complete.scale_factor,
1290 original_width: complete.original_width,
1291 original_height: complete.original_height,
1292 }));
1293 }
1294 "error" => {
1295 let error: crate::SseErrorEvent = serde_json::from_str(&data)?;
1296 anyhow::bail!("server error: {}", error.message);
1297 }
1298 _ => {}
1299 }
1300 }
1301 }
1302
1303 anyhow::bail!("SSE stream ended without complete event")
1304 }
1305}
1306
1307fn installed_entry_into_lora_info(
1309 entry: crate::catalog_wire::InstalledCatalogEntry,
1310) -> Option<LoraInfo> {
1311 if entry.kind != "lora" || !entry.installed {
1312 return None;
1313 }
1314 Some(LoraInfo {
1315 id: entry.id,
1316 name: entry.name,
1317 family: entry.family,
1318 author: entry.author,
1319 path: entry.primary_path?,
1320 trained_words: entry.trained_words,
1321 size_bytes: entry.size_bytes,
1322 thumbnail_url: entry.thumbnail_url,
1323 added_at: entry.added_at,
1324 })
1325}
1326
1327fn should_fallback_loras_endpoint(err: &anyhow::Error) -> bool {
1328 let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() else {
1329 return false;
1330 };
1331 reqwest_err.is_decode()
1332 || reqwest_err.status().is_some_and(|status| {
1333 matches!(
1334 status,
1335 StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED
1336 )
1337 })
1338}
1339
1340async fn error_for_status_with_body(resp: reqwest::Response) -> Result<reqwest::Response> {
1341 if resp.status().is_client_error() || resp.status().is_server_error() {
1342 let status = resp.status();
1343 let body = resp.text().await.unwrap_or_default();
1344 anyhow::bail!("server error {status}: {body}");
1345 }
1346 Ok(resp)
1347}
1348
1349fn api_error_detail(body: &str) -> String {
1350 serde_json::from_str::<serde_json::Value>(body)
1351 .ok()
1352 .and_then(|value| {
1353 value
1354 .get("error")
1355 .or_else(|| value.get("message"))
1356 .and_then(serde_json::Value::as_str)
1357 .map(str::trim)
1358 .filter(|message| !message.is_empty())
1359 .map(ToOwned::to_owned)
1360 })
1361 .unwrap_or_else(|| body.trim().to_string())
1362}
1363
1364fn lora_family_for_model_filter(model: &str) -> Option<String> {
1365 let model = model.trim();
1366 if model.is_empty() {
1367 return None;
1368 }
1369 let canonical = crate::manifest::resolve_model_name(model);
1370 crate::manifest::find_manifest(&canonical)
1371 .or_else(|| crate::manifest::find_manifest(model))
1372 .map(|manifest| catalog_lora_family_filter(&manifest.family))
1373 .or_else(|| {
1374 let config = crate::Config::load_or_default();
1375 config
1376 .models
1377 .get(model)
1378 .or_else(|| config.models.get(&canonical))
1379 .and_then(|model| model.family.as_deref().map(catalog_lora_family_filter))
1380 })
1381}
1382
1383fn catalog_lora_family_filter(family: &str) -> String {
1384 match family {
1385 "qwen-image-edit" | "qwen_image_edit" => "qwen-image".to_string(),
1386 other => other.to_string(),
1387 }
1388}
1389
1390struct VideoMeta {
1392 frames: u32,
1393 fps: u32,
1394 width: Option<u32>,
1395 height: Option<u32>,
1396 pipeline: Option<crate::Ltx2PipelineMode>,
1397 pipeline_provenance_sha256: Option<String>,
1398 source_preprocessing: Option<crate::Ltx2SourcePreprocessing>,
1399 has_audio: bool,
1400 duration_ms: Option<u64>,
1401 audio_sample_rate: Option<u32>,
1402 audio_channels: Option<u32>,
1403}
1404
1405fn parse_video_headers(headers: &reqwest::header::HeaderMap) -> Option<VideoMeta> {
1408 let frames = headers
1409 .get("x-mold-video-frames")
1410 .and_then(|v| v.to_str().ok())
1411 .and_then(|s| s.parse::<u32>().ok())?;
1412 let fps = headers
1413 .get("x-mold-video-fps")
1414 .and_then(|v| v.to_str().ok())
1415 .and_then(|s| s.parse::<u32>().ok())
1416 .unwrap_or(24);
1417 let width = headers
1418 .get("x-mold-video-width")
1419 .and_then(|v| v.to_str().ok())
1420 .and_then(|s| s.parse::<u32>().ok());
1421 let height = headers
1422 .get("x-mold-video-height")
1423 .and_then(|v| v.to_str().ok())
1424 .and_then(|s| s.parse::<u32>().ok());
1425 let pipeline = headers
1426 .get("x-mold-video-pipeline")
1427 .and_then(|v| v.to_str().ok())
1428 .and_then(|value| serde_json::from_value(serde_json::Value::String(value.into())).ok());
1429 let pipeline_provenance_sha256 = headers
1430 .get("x-mold-video-pipeline-provenance-sha256")
1431 .and_then(|value| value.to_str().ok())
1432 .map(str::to_owned);
1433 let source_preprocessing = headers
1434 .get("x-mold-video-source-preprocessing")
1435 .and_then(|value| value.to_str().ok())
1436 .and_then(|json| serde_json::from_str(json).ok());
1437 let has_audio = headers
1438 .get("x-mold-video-has-audio")
1439 .and_then(|v| v.to_str().ok())
1440 .map(|s| s == "1")
1441 .unwrap_or(false);
1442 let duration_ms = headers
1443 .get("x-mold-video-duration-ms")
1444 .and_then(|v| v.to_str().ok())
1445 .and_then(|s| s.parse::<u64>().ok());
1446 let audio_sample_rate = headers
1447 .get("x-mold-video-audio-sample-rate")
1448 .and_then(|v| v.to_str().ok())
1449 .and_then(|s| s.parse::<u32>().ok());
1450 let audio_channels = headers
1451 .get("x-mold-video-audio-channels")
1452 .and_then(|v| v.to_str().ok())
1453 .and_then(|s| s.parse::<u32>().ok());
1454
1455 Some(VideoMeta {
1456 frames,
1457 fps,
1458 width,
1459 height,
1460 pipeline,
1461 pipeline_provenance_sha256,
1462 source_preprocessing,
1463 has_audio,
1464 duration_ms,
1465 audio_sample_rate,
1466 audio_channels,
1467 })
1468}
1469
1470struct AudioMeta {
1471 format: Option<OutputFormat>,
1472 sample_rate: u32,
1473 channels: u32,
1474 duration_ms: u64,
1475 thumbnail_width: u32,
1476 thumbnail_height: u32,
1477}
1478
1479fn parse_audio_headers(headers: &reqwest::header::HeaderMap) -> Option<AudioMeta> {
1487 let read = |name: &str| {
1488 headers
1489 .get(name)
1490 .and_then(|v| v.to_str().ok())
1491 .and_then(|s| s.parse::<u64>().ok())
1492 };
1493 let sample_rate = read("x-mold-audio-sample-rate")? as u32;
1494 Some(AudioMeta {
1495 format: headers
1499 .get("x-mold-audio-format")
1500 .and_then(|v| v.to_str().ok())
1501 .and_then(|s| s.parse::<OutputFormat>().ok()),
1502 sample_rate,
1503 channels: read("x-mold-audio-channels").unwrap_or(1) as u32,
1504 duration_ms: read("x-mold-audio-duration-ms").unwrap_or(0),
1505 thumbnail_width: read("x-mold-audio-thumbnail-width").unwrap_or(0) as u32,
1506 thumbnail_height: read("x-mold-audio-thumbnail-height").unwrap_or(0) as u32,
1507 })
1508}
1509
1510fn next_sse_event(buffer: &mut String) -> Option<String> {
1511 for separator in ["\r\n\r\n", "\n\n"] {
1512 if let Some(pos) = buffer.find(separator) {
1513 let event_text = buffer[..pos].to_string();
1514 *buffer = buffer[pos + separator.len()..].to_string();
1515 return Some(event_text);
1516 }
1517 }
1518 None
1519}
1520
1521fn parse_sse_event(event_text: &str) -> (String, String) {
1522 let mut event_type = String::new();
1523 let mut data_lines = Vec::new();
1524 for line in event_text.lines() {
1525 if line.starts_with(':') {
1526 continue;
1527 }
1528 if let Some(t) = line.strip_prefix("event:") {
1529 event_type = t.trim().to_string();
1530 } else if let Some(d) = line.strip_prefix("data:") {
1531 data_lines.push(d.trim().to_string());
1532 }
1533 }
1534 (event_type, data_lines.join("\n"))
1535}
1536
1537fn build_client(api_key: Option<&str>) -> (Client, bool) {
1539 let mut builder = Client::builder();
1540 let mut api_key_configured = false;
1541 if let Some(key) = api_key {
1542 let mut headers = reqwest::header::HeaderMap::new();
1543 match reqwest::header::HeaderValue::from_str(key) {
1544 Ok(val) if !key.trim().is_empty() => {
1545 headers.insert("x-api-key", val);
1546 api_key_configured = true;
1547 }
1548 _ => {
1549 eprintln!(
1550 "warning: MOLD_API_KEY contains characters invalid for an HTTP header; \
1551 authentication header will not be sent"
1552 );
1553 }
1554 }
1555 builder = builder.default_headers(headers);
1556 }
1557 match builder.build() {
1558 Ok(client) => (client, api_key_configured),
1559 Err(_) => (Client::new(), false),
1560 }
1561}
1562
1563pub fn normalize_host(input: &str) -> String {
1571 let trimmed = input.trim().trim_end_matches('/');
1572 if trimmed.contains("://") {
1573 trimmed.to_string()
1574 } else if trimmed.contains(':') {
1575 format!("http://{trimmed}")
1576 } else {
1577 format!("http://{trimmed}:7680")
1578 }
1579}
1580
1581fn encode_path_segment(raw: &str) -> String {
1582 let mut out = String::with_capacity(raw.len());
1583 for byte in raw.bytes() {
1584 match byte {
1585 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1586 out.push(byte as char)
1587 }
1588 other => out.push_str(&format!("%{other:02X}")),
1589 }
1590 }
1591 out
1592}
1593
1594#[derive(Debug)]
1597pub struct ModelNotFoundError(pub String);
1598
1599impl std::fmt::Display for ModelNotFoundError {
1600 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1601 write!(f, "{}", self.0)
1602 }
1603}
1604
1605impl std::error::Error for ModelNotFoundError {}
1606
1607#[cfg(test)]
1608mod tests {
1609 use super::*;
1610 use crate::test_support::ENV_LOCK;
1611
1612 fn reference_session_request() -> ReferenceUploadSessionRequest {
1613 let request = serde_json::from_value(serde_json::json!({
1614 "prompt": "match the reference",
1615 "model": crate::minimax_h3::REF2VA_COMFY,
1616 "width": crate::minimax_h3::DEFAULT_WIDTH,
1617 "height": crate::minimax_h3::DEFAULT_HEIGHT,
1618 "steps": crate::minimax_h3::DEFAULT_STEPS,
1619 "guidance": 0.0,
1620 "seed": 7,
1621 "batch_size": 1,
1622 "output_format": "mp4",
1623 "strength": 1.0,
1624 "frames": crate::minimax_h3::MIN_FRAMES,
1625 "fps": crate::minimax_h3::FIXED_FPS,
1626 "enable_audio": true,
1627 "references": [{
1628 "kind": "image",
1629 "media": { "authority": "descriptor" },
1630 "provenance": {
1631 "name": "reference.png",
1632 "sha256": "0000000000000000000000000000000000000000000000000000000000000000"
1633 },
1634 "mime_type": "image/png",
1635 "width": 1,
1636 "height": 1
1637 }]
1638 }))
1639 .unwrap();
1640 ReferenceUploadSessionRequest {
1641 request,
1642 upload_references: vec![1],
1643 }
1644 }
1645
1646 #[test]
1647 fn test_new_trims_trailing_slash() {
1648 let client = MoldClient::new("http://localhost:7680/");
1649 assert_eq!(client.host(), "http://localhost:7680");
1650 }
1651
1652 #[test]
1653 fn api_key_state_tracks_only_an_installed_header() {
1654 assert!(!MoldClient::new("http://localhost:7680").has_api_key());
1655 assert!(
1656 MoldClient::with_api_key("http://localhost:7680", "sekrit".to_string()).has_api_key()
1657 );
1658 assert!(!MoldClient::with_api_key("http://localhost:7680", "".to_string()).has_api_key());
1659 assert!(
1660 !MoldClient::with_api_key("http://localhost:7680", "bad\nkey".to_string())
1661 .has_api_key()
1662 );
1663 }
1664
1665 #[test]
1666 fn test_new_no_slash_unchanged() {
1667 let client = MoldClient::new("http://localhost:7680");
1668 assert_eq!(client.host(), "http://localhost:7680");
1669 }
1670
1671 #[test]
1672 fn test_new_multiple_slashes() {
1673 let client = MoldClient::new("http://localhost:7680///");
1674 assert_eq!(client.host(), "http://localhost:7680");
1675 }
1676
1677 #[test]
1678 fn test_from_env_mold_host() {
1679 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1680 unsafe { std::env::remove_var("MOLD_HOST") };
1682 let client = MoldClient::from_env();
1683 assert_eq!(client.host(), "http://localhost:7680");
1684
1685 let unique_url = "http://test-host-env:9999";
1686 unsafe { std::env::set_var("MOLD_HOST", unique_url) };
1687 let client = MoldClient::from_env();
1688 assert_eq!(client.host(), unique_url);
1689 unsafe { std::env::remove_var("MOLD_HOST") };
1690 }
1691
1692 #[test]
1693 fn test_is_connection_error_non_connect() {
1694 let err = anyhow::anyhow!("something went wrong");
1696 assert!(!MoldClient::is_connection_error(&err));
1697 }
1698
1699 #[test]
1700 fn test_is_model_not_found_via_custom_error() {
1701 let err: anyhow::Error =
1702 ModelNotFoundError("model 'test' is not downloaded".to_string()).into();
1703 assert!(MoldClient::is_model_not_found(&err));
1704 }
1705
1706 #[test]
1707 fn test_is_model_not_found_generic_error() {
1708 let err = anyhow::anyhow!("something else");
1709 assert!(!MoldClient::is_model_not_found(&err));
1710 }
1711
1712 #[test]
1713 fn test_normalize_bare_hostname() {
1714 let client = MoldClient::new("hal9000");
1715 assert_eq!(client.host(), "http://hal9000:7680");
1716 }
1717
1718 #[test]
1719 fn test_normalize_hostname_with_port() {
1720 let client = MoldClient::new("hal9000:8080");
1721 assert_eq!(client.host(), "http://hal9000:8080");
1722 }
1723
1724 #[test]
1725 fn test_normalize_full_url_unchanged() {
1726 let client = MoldClient::new("http://hal9000:7680");
1727 assert_eq!(client.host(), "http://hal9000:7680");
1728 }
1729
1730 #[test]
1731 fn test_normalize_https_no_port() {
1732 let client = MoldClient::new("https://hal9000");
1733 assert_eq!(client.host(), "https://hal9000");
1734 }
1735
1736 #[test]
1737 fn test_normalize_http_no_port() {
1738 let client = MoldClient::new("http://hal9000");
1739 assert_eq!(client.host(), "http://hal9000");
1740 }
1741
1742 #[test]
1743 fn test_normalize_localhost() {
1744 let client = MoldClient::new("localhost");
1745 assert_eq!(client.host(), "http://localhost:7680");
1746 }
1747
1748 #[test]
1749 fn test_normalize_whitespace_trimmed() {
1750 let client = MoldClient::new(" hal9000 ");
1751 assert_eq!(client.host(), "http://hal9000:7680");
1752 }
1753
1754 #[test]
1755 fn test_normalize_ip_address() {
1756 let client = MoldClient::new("192.168.1.100");
1757 assert_eq!(client.host(), "http://192.168.1.100:7680");
1758 }
1759
1760 #[test]
1761 fn test_normalize_ip_with_port() {
1762 let client = MoldClient::new("192.168.1.100:9090");
1763 assert_eq!(client.host(), "http://192.168.1.100:9090");
1764 }
1765
1766 #[test]
1767 fn test_is_model_not_found_via_mold_error() {
1768 let err: anyhow::Error =
1769 MoldError::ModelNotFound("model 'test' is not downloaded".to_string()).into();
1770 assert!(MoldClient::is_model_not_found(&err));
1771 }
1772
1773 #[test]
1774 fn test_is_connection_error_via_mold_error() {
1775 let err: anyhow::Error = MoldError::Client("connection refused".to_string()).into();
1776 assert!(MoldClient::is_connection_error(&err));
1777 }
1778
1779 #[tokio::test]
1780 async fn reference_session_preserves_authenticated_http_451_body() {
1781 use wiremock::matchers::{header, method, path};
1782 use wiremock::{Mock, MockServer, ResponseTemplate};
1783
1784 let server = MockServer::start().await;
1785 Mock::given(method("POST"))
1786 .and(path("/api/generate/reference-upload-sessions"))
1787 .and(header("x-api-key", "sekrit"))
1788 .respond_with(ResponseTemplate::new(451).set_body_json(serde_json::json!({
1789 "error": "MiniMax H3 legal activation is unavailable",
1790 "code": crate::MINIMAX_H3_AUTHORIZATION_REQUIRED
1791 })))
1792 .expect(1)
1793 .mount(&server)
1794 .await;
1795
1796 let error = MoldClient::with_api_key(&server.uri(), "sekrit".to_string())
1797 .create_reference_upload_session(&reference_session_request())
1798 .await
1799 .unwrap_err();
1800 let message = error.to_string();
1801 assert!(message.contains("451 Unavailable For Legal Reasons"));
1802 assert!(message.contains(crate::MINIMAX_H3_AUTHORIZATION_REQUIRED));
1803 }
1804
1805 #[tokio::test]
1806 async fn reference_file_streams_with_secret_headers_and_cancels_by_session_header() {
1807 use wiremock::matchers::{body_string, header, method, path};
1808 use wiremock::{Mock, MockServer, ResponseTemplate};
1809
1810 let server = MockServer::start().await;
1811 Mock::given(method("PUT"))
1812 .and(path("/api/generate/reference-upload"))
1813 .and(header("x-api-key", "sekrit"))
1814 .and(header(REFERENCE_UPLOAD_HANDLE_HEADER, "mru_secret"))
1815 .and(header("content-type", "image/png"))
1816 .and(header("content-length", "5"))
1817 .and(body_string("bytes"))
1818 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1819 "instance_id": "server-1",
1820 "reference": 1,
1821 "metadata": {
1822 "kind": "image",
1823 "index": 1,
1824 "name": "reference.png",
1825 "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
1826 "mime_type": "image/png",
1827 "width": 1,
1828 "height": 1
1829 },
1830 "request_scope_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1831 "session_complete": true
1832 })))
1833 .expect(1)
1834 .mount(&server)
1835 .await;
1836 Mock::given(method("PUT"))
1837 .and(path("/api/generate/reference-upload"))
1838 .and(header("x-api-key", "sekrit"))
1839 .and(header(REFERENCE_UPLOAD_HANDLE_HEADER, "mru_open"))
1840 .and(header("content-type", "image/png"))
1841 .and(header("content-length", "15"))
1842 .and(body_string("reference-bytes"))
1843 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1844 "instance_id": "server-1",
1845 "reference": 1,
1846 "metadata": {
1847 "kind": "image",
1848 "index": 1,
1849 "name": "reference.png",
1850 "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
1851 "mime_type": "image/png",
1852 "width": 16,
1853 "height": 16
1854 },
1855 "request_scope_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1856 "session_complete": true
1857 })))
1858 .expect(1)
1859 .mount(&server)
1860 .await;
1861 Mock::given(method("PUT"))
1862 .and(path("/api/generate/reference-upload"))
1863 .and(header("x-api-key", "sekrit"))
1864 .and(header(REFERENCE_UPLOAD_HANDLE_HEADER, "mru_bytes"))
1865 .and(header("content-type", "audio/wav"))
1866 .and(header("content-length", "5"))
1867 .and(body_string("bytes"))
1868 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1869 "instance_id": "server-1",
1870 "reference": 2,
1871 "metadata": {
1872 "kind": "audio",
1873 "index": 2,
1874 "name": "reference.wav",
1875 "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
1876 "mime_type": "audio/wav",
1877 "duration_ms": 2000,
1878 "sample_rate": 32000,
1879 "channels": 2
1880 },
1881 "request_scope_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1882 "session_complete": true
1883 })))
1884 .expect(1)
1885 .mount(&server)
1886 .await;
1887 Mock::given(method("DELETE"))
1888 .and(path("/api/generate/reference-upload-sessions"))
1889 .and(header("x-api-key", "sekrit"))
1890 .and(header(
1891 REFERENCE_UPLOAD_SESSION_HEADER,
1892 "mrs_session_secret",
1893 ))
1894 .respond_with(ResponseTemplate::new(204))
1895 .expect(1)
1896 .mount(&server)
1897 .await;
1898
1899 let dir = tempfile::tempdir().unwrap();
1900 let path = dir.path().join("reference.png");
1901 let open_path = dir.path().join("reference-open.png");
1902 std::fs::write(&path, b"bytes").unwrap();
1903 std::fs::write(&open_path, b"reference-bytes").unwrap();
1904 let client = MoldClient::with_api_key(&server.uri(), "sekrit".to_string());
1905 let completed = client
1906 .upload_reference_file("mru_secret", &path, "image/png")
1907 .await
1908 .unwrap();
1909 assert_eq!(completed.instance_id, "server-1");
1910 assert_eq!(completed.reference, 1);
1911 let completed = client
1912 .upload_reference_open_file(
1913 "mru_open",
1914 std::fs::File::open(&open_path).unwrap(),
1915 "image/png",
1916 )
1917 .await
1918 .unwrap();
1919 assert_eq!(completed.instance_id, "server-1");
1920 assert_eq!(completed.reference, 1);
1921 let completed = client
1922 .upload_reference_bytes("mru_bytes", b"bytes".to_vec(), "audio/wav")
1923 .await
1924 .unwrap();
1925 assert_eq!(completed.instance_id, "server-1");
1926 assert_eq!(completed.reference, 2);
1927 client
1928 .cancel_reference_upload_session("mrs_session_secret")
1929 .await
1930 .unwrap();
1931 }
1932
1933 #[tokio::test]
1934 async fn list_loras_falls_back_to_installed_catalog_for_older_servers() {
1935 use wiremock::matchers::{method, path, query_param};
1936 use wiremock::{Mock, MockServer, ResponseTemplate};
1937
1938 let server = MockServer::start().await;
1939 Mock::given(method("GET"))
1940 .and(path("/api/loras"))
1941 .and(query_param("model", "flux-dev:q8"))
1942 .respond_with(
1943 ResponseTemplate::new(200)
1944 .insert_header("content-type", "text/html")
1945 .set_body_string("<!doctype html><html></html>"),
1946 )
1947 .mount(&server)
1948 .await;
1949 Mock::given(method("GET"))
1950 .and(path("/api/catalog/installed"))
1951 .and(query_param("kind", "lora"))
1952 .and(query_param("family", "flux"))
1953 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1954 "entries": [
1955 {
1956 "id": "cv:827325",
1957 "name": "Flux Skin Texture",
1958 "family": "flux",
1959 "author": null,
1960 "primary_path": "/models/cv-827325/fluxRealSkin-V2.safetensors",
1961 "trained_words": ["realskin"],
1962 "size_bytes": 167938890,
1963 "thumbnail_url": null,
1964 "added_at": 1778268326,
1965 "installed": true,
1966 "kind": "lora"
1967 }
1968 ],
1969 "page": 1,
1970 "page_size": 1,
1971 "total": 1
1972 })))
1973 .mount(&server)
1974 .await;
1975
1976 let client = MoldClient::new(&server.uri());
1977 let loras = client.list_loras(Some("flux-dev:q8")).await.unwrap();
1978
1979 assert_eq!(loras.len(), 1);
1980 assert_eq!(loras[0].id, "cv:827325");
1981 assert_eq!(
1982 loras[0].path,
1983 "/models/cv-827325/fluxRealSkin-V2.safetensors"
1984 );
1985 assert_eq!(loras[0].trained_words, ["realskin"]);
1986 }
1987
1988 #[tokio::test]
1989 async fn devices_fetches_and_parses_the_stable_inventory() {
1990 use wiremock::matchers::{method, path};
1991 use wiremock::{Mock, MockServer, ResponseTemplate};
1992
1993 let server = MockServer::start().await;
1994 Mock::given(method("GET"))
1995 .and(path("/api/devices"))
1996 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1997 "devices": [{
1998 "id": "cuda:0123456789abcdef0123456789abcdef",
1999 "backend": "cuda",
2000 "ordinal": 0,
2001 "device_kind": "full_gpu",
2002 "nvml_uuid": null,
2003 "physical_uuid": null,
2004 "mig_uuid": null,
2005 "mig_parent_uuid": null,
2006 "mig_profile": null,
2007 "name": "test gpu",
2008 "pci_bus_id": null,
2009 "compute_capability": "8.6",
2010 "memory": {
2011 "total_bytes": 24_000_000_000_u64,
2012 "used_bytes": null,
2013 "mold_used_bytes": null,
2014 "other_used_bytes": null
2015 },
2016 "telemetry": {
2017 "utilization_percent": null,
2018 "temperature_c": null,
2019 "power_w": null
2020 },
2021 "desired_enabled": true,
2022 "admin_state": "enabled",
2023 "health": "healthy",
2024 "activity": "idle",
2025 "schedulable": true,
2026 "unschedulable_reason": null,
2027 "loaded_models": [],
2028 "active_work_id": null,
2029 "planned_work_ids": []
2030 }],
2031 "plan_version": 0
2032 })))
2033 .mount(&server)
2034 .await;
2035
2036 let devices = MoldClient::new(&server.uri()).devices().await.unwrap();
2037 assert_eq!(
2038 devices.devices[0].id,
2039 "cuda:0123456789abcdef0123456789abcdef"
2040 );
2041 assert_eq!(devices.devices[0].device_kind, crate::DeviceKind::FullGpu);
2042 assert_eq!(devices.devices[0].memory.used_bytes, None);
2043 }
2044
2045 #[tokio::test]
2046 async fn capabilities_defaults_missing_device_lifecycle_to_unavailable() {
2047 use wiremock::matchers::{method, path};
2048 use wiremock::{Mock, MockServer, ResponseTemplate};
2049
2050 let server = MockServer::start().await;
2051 Mock::given(method("GET"))
2052 .and(path("/api/capabilities"))
2053 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2054 "gallery": { "can_delete": true },
2055 "catalog": { "available": false, "families": [], "sort": [] }
2056 })))
2057 .mount(&server)
2058 .await;
2059
2060 let capabilities = MoldClient::new(&server.uri()).capabilities().await.unwrap();
2061 assert!(!capabilities.devices.lifecycle);
2062 }
2063
2064 #[tokio::test]
2065 async fn set_device_enabled_preserves_the_server_error_body() {
2066 use wiremock::matchers::{body_json, method, path};
2067 use wiremock::{Mock, MockServer, ResponseTemplate};
2068
2069 let server = MockServer::start().await;
2070 Mock::given(method("PATCH"))
2071 .and(path("/api/devices/cuda%3Adevice-1"))
2072 .and(body_json(serde_json::json!({ "enabled": true })))
2073 .respond_with(
2074 ResponseTemplate::new(409)
2075 .set_body_string("device is startup-excluded and requires a restart"),
2076 )
2077 .mount(&server)
2078 .await;
2079
2080 let error = MoldClient::new(&server.uri())
2081 .set_device_enabled("cuda:device-1", true)
2082 .await
2083 .unwrap_err();
2084 let message = error.to_string();
2085 assert!(message.contains("409 Conflict"));
2086 assert!(message.contains("startup-excluded"));
2087 assert!(message.contains("requires a restart"));
2088 }
2089
2090 #[tokio::test]
2091 async fn set_device_enabled_encodes_the_stable_id_and_sends_auth() {
2092 use wiremock::matchers::{body_json, header, method, path};
2093 use wiremock::{Mock, MockServer, ResponseTemplate};
2094
2095 let server = MockServer::start().await;
2096 Mock::given(method("PATCH"))
2097 .and(path("/api/devices/cuda%3Aparent%2Fgpu"))
2098 .and(header("x-api-key", "sekrit"))
2099 .and(body_json(serde_json::json!({ "enabled": false })))
2100 .respond_with(ResponseTemplate::new(202).set_body_json(serde_json::json!({
2101 "id": "cuda:parent/gpu",
2102 "backend": "cuda",
2103 "ordinal": 1,
2104 "device_kind": "full_gpu",
2105 "name": "GPU 1",
2106 "memory": {},
2107 "telemetry": {},
2108 "desired_enabled": false,
2109 "admin_state": "draining",
2110 "health": "healthy",
2111 "activity": "generating",
2112 "schedulable": false,
2113 "loaded_models": [],
2114 "planned_work_ids": []
2115 })))
2116 .mount(&server)
2117 .await;
2118
2119 let device = MoldClient::with_api_key(&server.uri(), "sekrit".to_string())
2120 .set_device_enabled("cuda:parent/gpu", false)
2121 .await
2122 .unwrap();
2123 assert_eq!(device.id, "cuda:parent/gpu");
2124 assert_eq!(device.admin_state, crate::DeviceAdminState::Draining);
2125 assert!(!device.desired_enabled);
2126 }
2127
2128 #[tokio::test]
2131 async fn list_queue_parses_the_wrapped_entries_listing() {
2132 use wiremock::matchers::{method, path};
2133 use wiremock::{Mock, MockServer, ResponseTemplate};
2134
2135 let server = MockServer::start().await;
2136 Mock::given(method("GET"))
2137 .and(path("/api/queue"))
2138 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2139 "entries": [
2140 {
2141 "id": "job-1",
2142 "model": "flux-dev:q8",
2143 "state": "running",
2144 "started_at_unix_ms": 1_711_305_600_000_u64,
2145 "position": 0,
2146 "gpu": 0
2147 },
2148 {
2149 "id": "job-2",
2150 "model": "sdxl:q8",
2151 "state": "queued",
2152 "started_at_unix_ms": 1_711_305_601_000_u64,
2153 "position": 1
2154 }
2155 ]
2156 })))
2157 .mount(&server)
2158 .await;
2159
2160 let client = MoldClient::new(&server.uri());
2161 let listing = client.list_queue().await.unwrap();
2162
2163 assert_eq!(listing.entries.len(), 2);
2164 assert_eq!(listing.entries[0].id, "job-1");
2165 assert_eq!(listing.entries[0].state, "running");
2166 assert_eq!(listing.entries[0].gpu, Some(0));
2167 assert_eq!(listing.entries[1].state, "queued");
2168 assert_eq!(listing.entries[1].gpu, None);
2169 assert_eq!(listing.entries[1].position, 1);
2170 }
2171
2172 #[tokio::test]
2173 async fn list_queue_sends_the_api_key_header() {
2174 use wiremock::matchers::{header, method, path};
2175 use wiremock::{Mock, MockServer, ResponseTemplate};
2176
2177 let server = MockServer::start().await;
2178 Mock::given(method("GET"))
2179 .and(path("/api/queue"))
2180 .and(header("x-api-key", "sekrit"))
2181 .respond_with(
2182 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "entries": [] })),
2183 )
2184 .mount(&server)
2185 .await;
2186
2187 let client = MoldClient::with_api_key(&server.uri(), "sekrit".to_string());
2188 let listing = client.list_queue().await.unwrap();
2189 assert!(listing.entries.is_empty());
2190 }
2191
2192 #[tokio::test]
2193 async fn cancel_queue_job_succeeds_on_no_content() {
2194 use wiremock::matchers::{method, path};
2195 use wiremock::{Mock, MockServer, ResponseTemplate};
2196
2197 let server = MockServer::start().await;
2198 Mock::given(method("DELETE"))
2199 .and(path("/api/queue/job-1"))
2200 .respond_with(ResponseTemplate::new(204))
2201 .mount(&server)
2202 .await;
2203
2204 let client = MoldClient::new(&server.uri());
2205 client.cancel_queue_job("job-1").await.unwrap();
2206 }
2207
2208 #[tokio::test]
2209 async fn cancel_queue_job_surfaces_the_409_body_for_running_jobs() {
2210 use wiremock::matchers::{method, path};
2211 use wiremock::{Mock, MockServer, ResponseTemplate};
2212
2213 let server = MockServer::start().await;
2214 Mock::given(method("DELETE"))
2215 .and(path("/api/queue/job-1"))
2216 .respond_with(ResponseTemplate::new(409).set_body_json(serde_json::json!({
2217 "error": "queue job job-1 is already running; only queued jobs can be cancelled"
2218 })))
2219 .mount(&server)
2220 .await;
2221
2222 let client = MoldClient::new(&server.uri());
2223 let err = client.cancel_queue_job("job-1").await.unwrap_err();
2224 let msg = format!("{err:#}");
2225 assert!(msg.contains("409"), "status missing from error: {msg}");
2226 assert!(
2227 msg.contains("already running"),
2228 "body text missing from error: {msg}"
2229 );
2230 }
2231
2232 #[test]
2233 fn qwen_edit_lora_fallback_uses_qwen_image_catalog_family() {
2234 assert_eq!(
2235 lora_family_for_model_filter("qwen-image-edit-2511:q4"),
2236 Some("qwen-image".to_string())
2237 );
2238 }
2239
2240 #[test]
2241 fn api_error_detail_extracts_actionable_server_json() {
2242 assert_eq!(
2243 api_error_detail(
2244 r#"{"error":"Qwen Image Edit needs a Target image.","code":"VALIDATION_ERROR"}"#
2245 ),
2246 "Qwen Image Edit needs a Target image."
2247 );
2248 assert_eq!(
2249 api_error_detail("plain validation failure"),
2250 "plain validation failure"
2251 );
2252 }
2253
2254 #[test]
2255 fn parse_sse_event_joins_multiline_data() {
2256 let (event_type, data) =
2257 parse_sse_event("event: progress\ndata: {\"a\":1}\ndata: {\"b\":2}");
2258 assert_eq!(event_type, "progress");
2259 assert_eq!(data, "{\"a\":1}\n{\"b\":2}");
2260 }
2261
2262 #[test]
2263 fn next_sse_event_supports_crlf_delimiters() {
2264 let mut buffer = "event: progress\r\ndata: {\"ok\":true}\r\n\r\nrest".to_string();
2265 let event = next_sse_event(&mut buffer).expect("expected one event");
2266 assert!(event.contains("event: progress"));
2267 assert_eq!(buffer, "rest");
2268 }
2269
2270 #[test]
2273 fn parse_audio_headers_returns_none_for_a_still_or_a_clip() {
2274 let mut headers = reqwest::header::HeaderMap::new();
2275 assert!(parse_audio_headers(&headers).is_none());
2276
2277 headers.insert("x-mold-video-frames", "97".parse().unwrap());
2280 headers.insert("x-mold-video-audio-sample-rate", "48000".parse().unwrap());
2281 assert!(parse_audio_headers(&headers).is_none());
2282 }
2283
2284 #[test]
2285 fn parse_audio_headers_reads_the_audio_only_shape() {
2286 let mut headers = reqwest::header::HeaderMap::new();
2287 headers.insert("x-mold-audio-format", "wav".parse().unwrap());
2288 headers.insert("x-mold-audio-sample-rate", "24000".parse().unwrap());
2289 headers.insert("x-mold-audio-channels", "2".parse().unwrap());
2290 headers.insert("x-mold-audio-duration-ms", "5010".parse().unwrap());
2291 headers.insert("x-mold-audio-thumbnail-width", "640".parse().unwrap());
2292 headers.insert("x-mold-audio-thumbnail-height", "360".parse().unwrap());
2293
2294 let meta = parse_audio_headers(&headers).expect("should detect audio");
2295 assert_eq!(meta.format, Some(OutputFormat::Wav));
2296 assert_eq!(meta.sample_rate, 24_000);
2297 assert_eq!(meta.channels, 2);
2298 assert_eq!(meta.duration_ms, 5_010);
2299 assert_eq!(meta.thumbnail_width, 640);
2300 assert_eq!(meta.thumbnail_height, 360);
2301 }
2302
2303 #[test]
2306 fn parse_audio_headers_defaults_the_optional_fields() {
2307 let mut headers = reqwest::header::HeaderMap::new();
2308 headers.insert("x-mold-audio-sample-rate", "48000".parse().unwrap());
2309 let meta = parse_audio_headers(&headers).expect("should detect audio");
2310 assert_eq!(
2311 meta.format, None,
2312 "the caller falls back to an audio format"
2313 );
2314 assert_eq!(meta.sample_rate, 48_000);
2315 assert_eq!(meta.channels, 1);
2316 assert_eq!(meta.duration_ms, 0);
2317 assert_eq!(meta.thumbnail_width, 0);
2318 assert_eq!(meta.thumbnail_height, 0);
2319 }
2320
2321 #[test]
2324 fn parse_video_headers_returns_none_without_frames() {
2325 let headers = reqwest::header::HeaderMap::new();
2326 assert!(parse_video_headers(&headers).is_none());
2327 }
2328
2329 #[test]
2330 fn parse_video_headers_returns_some_with_frames() {
2331 let mut headers = reqwest::header::HeaderMap::new();
2332 headers.insert("x-mold-video-frames", "33".parse().unwrap());
2333 headers.insert("x-mold-video-fps", "12".parse().unwrap());
2334 headers.insert("x-mold-video-width", "832".parse().unwrap());
2335 headers.insert("x-mold-video-height", "480".parse().unwrap());
2336 headers.insert("x-mold-video-pipeline", "two-stage".parse().unwrap());
2337
2338 let meta = parse_video_headers(&headers).expect("should detect video");
2339 assert_eq!(meta.frames, 33);
2340 assert_eq!(meta.fps, 12);
2341 assert_eq!(meta.width, Some(832));
2342 assert_eq!(meta.height, Some(480));
2343 assert_eq!(meta.pipeline, Some(crate::Ltx2PipelineMode::TwoStage));
2344 assert!(!meta.has_audio);
2345 assert!(meta.duration_ms.is_none());
2346 }
2347
2348 #[test]
2349 fn parse_video_headers_with_audio_metadata() {
2350 let mut headers = reqwest::header::HeaderMap::new();
2351 headers.insert("x-mold-video-frames", "17".parse().unwrap());
2352 headers.insert("x-mold-video-fps", "24".parse().unwrap());
2353 headers.insert("x-mold-video-has-audio", "1".parse().unwrap());
2354 headers.insert("x-mold-video-duration-ms", "2750".parse().unwrap());
2355 headers.insert("x-mold-video-audio-sample-rate", "44100".parse().unwrap());
2356 headers.insert("x-mold-video-audio-channels", "2".parse().unwrap());
2357
2358 let meta = parse_video_headers(&headers).expect("should detect video");
2359 assert_eq!(meta.frames, 17);
2360 assert_eq!(meta.fps, 24);
2361 assert!(meta.has_audio);
2362 assert_eq!(meta.duration_ms, Some(2750));
2363 assert_eq!(meta.audio_sample_rate, Some(44100));
2364 assert_eq!(meta.audio_channels, Some(2));
2365 }
2366
2367 #[test]
2368 fn parse_video_headers_fps_defaults_to_24() {
2369 let mut headers = reqwest::header::HeaderMap::new();
2370 headers.insert("x-mold-video-frames", "10".parse().unwrap());
2371 let meta = parse_video_headers(&headers).expect("should detect video");
2374 assert_eq!(meta.fps, 24);
2375 }
2376
2377 #[test]
2378 fn parse_video_headers_has_audio_absent_is_false() {
2379 let mut headers = reqwest::header::HeaderMap::new();
2380 headers.insert("x-mold-video-frames", "10".parse().unwrap());
2381 let meta = parse_video_headers(&headers).expect("should detect video");
2384 assert!(!meta.has_audio);
2385 }
2386}