Skip to main content

mold_core/
client.rs

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    /// Create a client with an explicit API key for authentication.
42    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    /// Whether this client actually installed a non-empty API-key header.
64    pub fn has_api_key(&self) -> bool {
65        self.api_key_configured
66    }
67
68    /// Create a request-bound MiniMax H3 reference upload session.
69    ///
70    /// The server applies authentication and legal activation before it
71    /// allocates staging. Error bodies (including HTTP 451 policy details) are
72    /// retained verbatim for callers instead of being collapsed to a generic
73    /// reqwest status error.
74    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    /// Stream one local reference file through a one-use bearer handle.
94    ///
95    /// The handle stays in a header, while the opened file's exact length is
96    /// sent as `Content-Length`. `ReaderStream` keeps large video references
97    /// out of process-sized buffers; the server independently hashes, probes,
98    /// and checks the declared descriptor before admission.
99    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    /// Stream an already-open reference file through a one-use bearer handle.
127    ///
128    /// Keeping the descriptor probe and upload tied to clones of the same file
129    /// descriptor prevents a path or symlink replacement between inspection and
130    /// upload. The local pathname never enters the request or this method's
131    /// errors.
132    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    /// Upload one bounded in-memory attachment through a one-use bearer handle.
156    ///
157    /// Discord already owns attachment bytes in memory after its download-size
158    /// gate. Taking ownership here guarantees the uploaded body is exactly the
159    /// body that was hashed and probed, without a second URL fetch.
160    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    /// Best-effort cleanup for an unconsumed reference upload session.
195    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    /// Generate an image. Returns raw image bytes (PNG or JPEG).
210    /// The server returns raw bytes, not JSON — callers are responsible for
211    /// writing the bytes to disk or further processing.
212    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    /// Generate an image or video and return the response wrapping the raw bytes.
227    ///
228    /// For video responses the server sends `x-mold-video-*` metadata headers
229    /// alongside the raw video bytes so we can reconstruct [`VideoData`].
230    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        // Read the seed the server actually used from the response header.
247        // Fall back to the request seed for backward compat with older servers.
248        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        // Detect an audio-only response before the video probe: an audio print
261        // has no frames, so a video-shaped probe falls through to the image
262        // branch and hands the caller WAV bytes labelled as an image.
263        let audio_meta = parse_audio_headers(resp.headers());
264        // Detect video response via x-mold-video-frames header
265        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                    // The request's format only stands in for an older server
275                    // that predates the header; an audio-only response is
276                    // never the still-image default the request may carry.
277                    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                    // The waveform tile cannot ride along in a body that is
286                    // already the WAV. Only its recorded size travels.
287                    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        // For video responses, images is empty — the payload lives in `video`.
319        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    /// List installed LoRAs from the server, optionally filtered to a model's family.
360    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    /// Check whether an error is a connection error (e.g. "connection refused").
427    /// Useful for deciding whether to fall back to local inference.
428    pub fn is_connection_error(err: &anyhow::Error) -> bool {
429        // Check for MoldError::Client variant
430        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    /// Check whether an error is a 404 "model not found" from the server.
442    /// Useful for triggering a server-side pull when the model isn't downloaded.
443    pub fn is_model_not_found(err: &anyhow::Error) -> bool {
444        // Check for MoldError::ModelNotFound variant
445        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        // SSE streaming returns ModelNotFoundError instead of reqwest status errors
454        err.downcast_ref::<ModelNotFoundError>().is_some()
455    }
456
457    /// Generate an image via SSE streaming, receiving progress events.
458    ///
459    /// Returns:
460    /// - `Ok(Some(response))` — streaming succeeded
461    /// - `Ok(None)` — server doesn't support SSE (endpoint returned 404 with empty body)
462    /// - `Err(e)` — generation error, model not found, or connection error
463    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                // Axum returns empty 404 for unmatched routes — server doesn't support SSE
479                return Ok(None);
480            }
481            // Non-empty 404 = model not found
482            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        // Parse SSE events from chunked response body
497        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                        // Use server-provided model name (source of truth);
515                        // fall back to request model for backwards compat with
516                        // older servers that don't include it.
517                        let model = if complete.model.is_empty() {
518                            req.model.clone()
519                        } else {
520                            complete.model
521                        };
522
523                        // Detect an audio-only response via `audio_sample_rate`.
524                        // Checked before video: an audio print has no frames,
525                        // so the video probe below would fall through to the
526                        // image branch and hand the caller WAV bytes labelled
527                        // as an image.
528                        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                        // Detect video response via video_frames field
555                        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    /// Submit a chained video generation request (non-streaming).
625    ///
626    /// The server normalises the auto-expand form into stages, runs each
627    /// stage sequentially with motion-tail latent carryover, stitches the
628    /// result into a single video, and returns a [`ChainResponse`]. Large
629    /// chains take minutes — prefer [`Self::generate_chain_stream`] for
630    /// interactive clients that want progress updates.
631    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    /// Submit a chained video generation request with SSE progress streaming.
661    ///
662    /// Returns:
663    /// - `Ok(Some(response))` — streaming succeeded and the `complete` event
664    ///   carried the stitched video.
665    /// - `Ok(None)` — server doesn't have the chain endpoint (empty 404).
666    ///   Callers can fall back to [`Self::generate_chain`] or error.
667    /// - `Err(_)` — validation, model-not-found, or mid-stream server error.
668    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        // Older servers exposed the ephemeral runner records used by
790        // automatic long one-shot videos. They are not authored sequences and
791        // must stay out of `mold jobs list` even across a mixed-version pair.
792        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    /// Ask the server to pull (download) a model. Blocks until the download
888    /// completes on the server side. The server updates its in-memory config
889    /// so subsequent generate/load requests can find the model.
890    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    /// Request graceful server shutdown.
904    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    /// Pull a model via SSE streaming, receiving download progress events.
914    ///
915    /// Sends `Accept: text/event-stream` to request SSE from the server.
916    /// Falls back to blocking pull if the server doesn't support SSE.
917    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        // Check if server returned SSE or plain text
937        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            // Old server — blocking pull, no progress. Just consume the response.
945            // Drop the sender so the receiver's recv() returns None instead of blocking.
946            drop(progress_tx);
947            let _ = resp.text().await?;
948            return Ok(());
949        }
950
951        // Parse SSE events (same pattern as generate_stream)
952        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                            // PullComplete signals end of pull
962                            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    /// Feature-detect optional server contracts before rendering controls.
1023    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    /// Read the server's stable, runtime-visible device inventory.
1036    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    /// Alias used by clients that preflight optional administrative actions.
1049    pub async fn capabilities(&self) -> Result<crate::ServerCapabilities> {
1050        self.server_capabilities().await
1051    }
1052
1053    /// Enable or disable one stable device. The server may return a draining
1054    /// or starting state while the owner transition completes.
1055    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    /// Snapshot the server's generation queue (`GET /api/queue`).
1078    ///
1079    /// The listing covers everything currently queued or running — completed
1080    /// jobs live in the gallery, not here. Entries are wire-shaped twins of
1081    /// the server's `job_registry::JobEntry`; see [`QueueListingWire`] for
1082    /// the forward-compat rules (plain-string `state`, defaulted additive
1083    /// fields), which let this client talk to both older and newer servers.
1084    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    /// Cancel a still-queued job (`DELETE /api/queue/{id}`).
1097    ///
1098    /// The server answers `204` on success, `404` for unknown ids, and `409`
1099    /// when the job is already running on a GPU worker (running jobs are not
1100    /// cancelable — there is no safe preemption point). Non-2xx responses
1101    /// surface as errors carrying the response body text, so the 409 reason
1102    /// reaches the caller verbatim.
1103    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    /// List gallery images from the server's output directory.
1118    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    /// Download a gallery image by filename.
1131    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    /// Delete a gallery image on the server.
1144    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    /// Download a cached animated GIF preview for a video gallery entry.
1154    ///
1155    /// Returns `Ok(None)` when the server responds with 404 (no preview
1156    /// has been generated for this filename yet). Callers are expected to
1157    /// fall back to the full `get_gallery_image` path in that case.
1158    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    /// Download a gallery thumbnail by filename. Smaller/faster than full image.
1172    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    /// Expand a prompt using the server's LLM prompt expansion endpoint.
1188    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    /// Generate subject-preserving prompt alternatives. A distinct endpoint is
1202    /// intentional: older hosts return 404 instead of silently expanding.
1203    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    /// Upscale an image using a super-resolution model on the server.
1217    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    /// Upscale an image via SSE streaming -- progress events are sent to `progress_tx`,
1231    /// returns the final `UpscaleResponse` on success.
1232    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); // server doesn't support SSE upscale
1248            }
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
1307/// Client policy: which installed-catalog entries surface as usable LoRAs.
1308fn 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
1390/// Parsed video metadata from `x-mold-video-*` response headers.
1391struct 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
1405/// Parse video metadata from HTTP response headers.
1406/// Returns `Some` when `x-mold-video-frames` is present, indicating a video response.
1407fn 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
1479/// Parse audio metadata from HTTP response headers.
1480///
1481/// Returns `Some` when `x-mold-audio-sample-rate` is present, indicating an
1482/// audio-only response. Probed before the video headers for the same reason
1483/// the SSE branch probes `audio_sample_rate` first: an audio print has no
1484/// frames, so a video-shaped probe would fall through to the image branch and
1485/// hand the caller WAV bytes labelled as an image.
1486fn 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        // Stated by the server, because a request that omitted
1496        // `output_format` was normalised server-side and is no evidence of
1497        // what came back.
1498        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
1537/// Build a reqwest Client, optionally with a default `X-Api-Key` header.
1538fn 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
1563/// Normalize a host string into a full URL.
1564///
1565/// Accepts:
1566/// - Bare hostname: `hal9000` → `http://hal9000:7680`
1567/// - Host with port: `hal9000:8080` → `http://hal9000:8080`
1568/// - Full URL: `http://hal9000:7680` → unchanged
1569/// - URL without port: `http://hal9000` → unchanged (uses scheme default 80/443)
1570pub 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/// Error indicating a model was not found on the server (404 with body).
1595/// Detected by [`MoldClient::is_model_not_found`].
1596#[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        // Single test to avoid env var races between parallel tests
1681        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        // A generic anyhow error is not a connection error
1695        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    // ── Queue endpoints ──────────────────────────────────────────────────
2129
2130    #[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    // ── Audio header parsing tests ───────────────────────────────────────
2271
2272    #[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        // A clip that happens to carry an audio track is still a video: only
2278        // the audio-only headers may promote a response to `AudioData`.
2279        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    /// An older server that grew the sample-rate header before the rest must
2304    /// still produce a usable response rather than none at all.
2305    #[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    // ── Video header parsing tests ───────────────────────────────────────
2322
2323    #[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        // No fps header — should default to 24
2372
2373        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        // No has-audio header
2382
2383        let meta = parse_video_headers(&headers).expect("should detect video");
2384        assert!(!meta.has_audio);
2385    }
2386}