Skip to main content

mold_server/
routes.rs

1use axum::{
2    extract::{Request, State},
3    http::{header, HeaderMap, HeaderValue, StatusCode},
4    response::{
5        sse::{Event as SseEvent, KeepAlive, Sse},
6        IntoResponse,
7    },
8    routing::{delete, get, post},
9    Json, Router,
10};
11use base64::Engine as _;
12use mold_core::{
13    ActiveGenerationStatus, GpuInfo, ModelInfoExtended, ServerStatus, SseErrorEvent,
14    SseProgressEvent,
15};
16use serde::{Deserialize, Serialize};
17use std::convert::Infallible;
18use tokio_stream::StreamExt as _;
19use utoipa::OpenApi;
20
21use crate::model_manager;
22use crate::state::{AppState, GenerationJob, SseMessage};
23
24// ── ApiError — structured JSON error response ────────────────────────────────
25
26#[derive(Debug, Serialize)]
27pub struct ApiError {
28    pub error: String,
29    pub code: String,
30    #[serde(skip)]
31    status: StatusCode,
32}
33
34impl ApiError {
35    pub fn validation(msg: impl Into<String>) -> Self {
36        Self {
37            error: msg.into(),
38            code: "VALIDATION_ERROR".to_string(),
39            status: StatusCode::UNPROCESSABLE_ENTITY,
40        }
41    }
42
43    pub fn not_found(msg: impl Into<String>) -> Self {
44        Self {
45            error: msg.into(),
46            code: "MODEL_NOT_FOUND".to_string(),
47            status: StatusCode::NOT_FOUND,
48        }
49    }
50
51    pub fn unknown_model(msg: impl Into<String>) -> Self {
52        Self {
53            error: msg.into(),
54            code: "UNKNOWN_MODEL".to_string(),
55            status: StatusCode::BAD_REQUEST,
56        }
57    }
58
59    pub fn inference(msg: impl Into<String>) -> Self {
60        Self {
61            error: msg.into(),
62            code: "INFERENCE_ERROR".to_string(),
63            status: StatusCode::INTERNAL_SERVER_ERROR,
64        }
65    }
66
67    pub fn internal(msg: impl Into<String>) -> Self {
68        Self {
69            error: msg.into(),
70            code: "INTERNAL_ERROR".to_string(),
71            status: StatusCode::INTERNAL_SERVER_ERROR,
72        }
73    }
74
75    pub fn insufficient_memory(msg: impl Into<String>) -> Self {
76        Self {
77            error: msg.into(),
78            code: "INSUFFICIENT_MEMORY".to_string(),
79            status: StatusCode::SERVICE_UNAVAILABLE,
80        }
81    }
82}
83
84impl IntoResponse for ApiError {
85    fn into_response(self) -> axum::response::Response {
86        let status = self.status;
87        (status, Json(self)).into_response()
88    }
89}
90
91// Re-export for tests — the canonical implementation lives in queue.rs.
92#[cfg(test)]
93use crate::queue::clean_error_message;
94
95#[derive(OpenApi)]
96#[openapi(
97    paths(generate, generate_stream, expand_prompt, list_models, load_model, pull_model_endpoint, unload_model, server_status, health),
98    components(schemas(
99        mold_core::GenerateRequest,
100        mold_core::GenerateResponse,
101        mold_core::ExpandRequest,
102        mold_core::ExpandResponse,
103        mold_core::ImageData,
104        mold_core::OutputFormat,
105        mold_core::ModelInfo,
106        mold_core::ServerStatus,
107        mold_core::ActiveGenerationStatus,
108        mold_core::GpuInfo,
109        mold_core::SseProgressEvent,
110        mold_core::SseCompleteEvent,
111        mold_core::SseErrorEvent,
112        ModelInfoExtended,
113        LoadModelBody,
114    )),
115    tags(
116        (name = "generation", description = "Image generation"),
117        (name = "models", description = "Model management"),
118        (name = "server", description = "Server status and health"),
119    ),
120    info(
121        title = "mold",
122        description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
123        version = env!("CARGO_PKG_VERSION"),
124    )
125)]
126pub struct ApiDoc;
127
128pub fn create_router(state: AppState) -> Router {
129    // Stateful routes (need AppState) are added first, then .with_state() converts
130    // Router<AppState> → Router<()>. Stateless routes (OpenAPI, docs) are merged after.
131    Router::new()
132        .route("/api/generate", post(generate))
133        .route("/api/generate/stream", post(generate_stream))
134        .route("/api/expand", post(expand_prompt))
135        .route("/api/models", get(list_models))
136        .route("/api/models/load", post(load_model))
137        .route("/api/models/pull", post(pull_model_endpoint))
138        .route("/api/models/unload", delete(unload_model))
139        .route("/api/gallery", get(list_gallery))
140        .route(
141            "/api/gallery/image/:filename",
142            get(get_gallery_image).delete(delete_gallery_image),
143        )
144        .route(
145            "/api/gallery/thumbnail/:filename",
146            get(get_gallery_thumbnail),
147        )
148        .route("/api/upscale", post(upscale))
149        .route("/api/upscale/stream", post(upscale_stream))
150        .route("/api/status", get(server_status))
151        .route("/api/shutdown", post(shutdown_server))
152        .route("/health", get(health))
153        .with_state(state)
154        .route("/api/openapi.json", get(openapi_json))
155        .route("/api/docs", get(scalar_docs))
156}
157
158// ── Model readiness ──────────────────────────────────────────────────────────
159
160fn sse_message_to_event(msg: SseMessage) -> SseEvent {
161    fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
162        match serde_json::to_string(payload) {
163            Ok(data) => SseEvent::default().event(event_name).data(data),
164            Err(err) => SseEvent::default().event("error").data(
165                serde_json::json!({
166                    "message": format!("failed to serialize SSE payload: {err}")
167                })
168                .to_string(),
169            ),
170        }
171    }
172
173    match msg {
174        SseMessage::Progress(payload) => serialize_event("progress", &payload),
175        SseMessage::Complete(payload) => serialize_event("complete", &payload),
176        SseMessage::UpscaleComplete(payload) => serialize_event("complete", &payload),
177        SseMessage::Error(payload) => serialize_event("error", &payload),
178    }
179}
180
181#[cfg(test)]
182fn save_image_to_dir(
183    dir: &std::path::Path,
184    img: &mold_core::ImageData,
185    model: &str,
186    batch_size: u32,
187) {
188    if let Err(e) = std::fs::create_dir_all(dir) {
189        tracing::warn!("failed to create output dir {}: {e}", dir.display());
190        return;
191    }
192    // Use milliseconds for server-side filenames to avoid overwrites when
193    // concurrent requests finish in the same second.
194    let timestamp_ms = std::time::SystemTime::now()
195        .duration_since(std::time::UNIX_EPOCH)
196        .unwrap_or_default()
197        .as_millis() as u64;
198    let ext = img.format.to_string();
199    let filename =
200        mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
201    let path = dir.join(&filename);
202    match std::fs::write(&path, &img.data) {
203        Ok(()) => tracing::info!("saved image to {}", path.display()),
204        Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
205    }
206}
207
208// ── Shared pre-queue validation ───────────────────────────────────────────────
209
210/// Validate a generate request and resolve server-side defaults.
211///
212/// Performs the identical pre-queue checks used by both `generate` and
213/// `generate_stream`: applies the default metadata setting, validates the
214/// request, checks model availability, and resolves the output directory.
215async fn prepare_generation(
216    state: &AppState,
217    request: &mut mold_core::GenerateRequest,
218) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
219    apply_default_metadata_setting(state, request).await;
220
221    // Expand prompt if requested (before validation, so the expanded prompt gets validated)
222    maybe_expand_prompt(state, request).await?;
223
224    if let Err(e) = validate_generate_request(request) {
225        return Err(ApiError::validation(e));
226    }
227
228    let _ = model_manager::check_model_available(state, &request.model).await?;
229
230    let (output_dir, dim_warning) = {
231        let config = state.config.read().await;
232        let output_dir = if config.is_output_disabled() {
233            None
234        } else {
235            Some(config.effective_output_dir())
236        };
237        let family = config.resolved_model_config(&request.model).family;
238        let dim_warning = family
239            .as_deref()
240            .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
241        (output_dir, dim_warning)
242    };
243
244    Ok((output_dir, dim_warning))
245}
246
247// ── /api/generate ─────────────────────────────────────────────────────────────
248
249#[utoipa::path(
250    post,
251    path = "/api/generate",
252    tag = "generation",
253    request_body = mold_core::GenerateRequest,
254    responses(
255        (status = 200, description = "Generated image bytes", content_type = "image/png"),
256        (status = 404, description = "Model not downloaded"),
257        (status = 422, description = "Invalid request parameters"),
258        (status = 500, description = "Inference error"),
259    )
260)]
261// The server always produces 1 image per request; batch looping (--batch N)
262// is handled client-side by the CLI, which sends N requests with incrementing seeds.
263async fn generate(
264    State(state): State<AppState>,
265    Json(mut req): Json<mold_core::GenerateRequest>,
266) -> Result<impl IntoResponse, ApiError> {
267    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
268
269    tracing::info!(
270        model = %req.model,
271        prompt = %req.prompt,
272        width = req.width,
273        height = req.height,
274        steps = req.steps,
275        guidance = req.guidance,
276        seed = ?req.seed,
277        format = %req.output_format,
278        lora = ?req.lora.as_ref().map(|l| &l.path),
279        lora_scale = ?req.lora.as_ref().map(|l| l.scale),
280        "generate request"
281    );
282
283    // Submit to generation queue
284    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
285    let job = GenerationJob {
286        request: req,
287        progress_tx: None,
288        result_tx,
289        output_dir,
290    };
291
292    let _position = state.queue.submit(job).await.map_err(ApiError::internal)?;
293
294    // Wait for the queue worker to process the job
295    let result = result_rx
296        .await
297        .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
298
299    match result {
300        Ok(job_result) => {
301            let img = job_result.image;
302            let response = job_result.response;
303            let content_type = HeaderValue::from_static(img.format.content_type());
304            let mut headers = HeaderMap::new();
305            headers.insert(header::CONTENT_TYPE, content_type);
306            headers.insert(
307                "x-mold-seed-used",
308                HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
309                    ApiError::internal(format!("failed to serialize seed header: {e}"))
310                })?,
311            );
312            if let Some(warning) = dim_warning {
313                match HeaderValue::from_str(&warning.replace('\n', " ")) {
314                    Ok(val) => {
315                        headers.insert("x-mold-dimension-warning", val);
316                    }
317                    Err(e) => {
318                        tracing::warn!("dimension warning could not be encoded as header: {e}");
319                    }
320                }
321            }
322            // For video responses, return the actual video data (not the thumbnail)
323            // and send video metadata in headers so the client can reconstruct VideoData.
324            let output_data = if let Some(ref video) = response.video {
325                let ct = HeaderValue::from_static(video.format.content_type());
326                headers.insert(header::CONTENT_TYPE, ct);
327                if let Ok(v) = HeaderValue::from_str(&video.frames.to_string()) {
328                    headers.insert("x-mold-video-frames", v);
329                }
330                if let Ok(v) = HeaderValue::from_str(&video.fps.to_string()) {
331                    headers.insert("x-mold-video-fps", v);
332                }
333                if let Ok(v) = HeaderValue::from_str(&video.width.to_string()) {
334                    headers.insert("x-mold-video-width", v);
335                }
336                if let Ok(v) = HeaderValue::from_str(&video.height.to_string()) {
337                    headers.insert("x-mold-video-height", v);
338                }
339                if video.has_audio {
340                    headers.insert("x-mold-video-has-audio", HeaderValue::from_static("1"));
341                }
342                if let Some(dur) = video.duration_ms {
343                    if let Ok(v) = HeaderValue::from_str(&dur.to_string()) {
344                        headers.insert("x-mold-video-duration-ms", v);
345                    }
346                }
347                if let Some(sr) = video.audio_sample_rate {
348                    if let Ok(v) = HeaderValue::from_str(&sr.to_string()) {
349                        headers.insert("x-mold-video-audio-sample-rate", v);
350                    }
351                }
352                if let Some(ch) = video.audio_channels {
353                    if let Ok(v) = HeaderValue::from_str(&ch.to_string()) {
354                        headers.insert("x-mold-video-audio-channels", v);
355                    }
356                }
357                video.data.clone()
358            } else {
359                img.data
360            };
361            Ok((headers, output_data))
362        }
363        Err(err_msg) => Err(ApiError::inference(err_msg)),
364    }
365}
366
367fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
368    mold_core::validate_generate_request(req)
369}
370
371async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
372    if req.embed_metadata.is_some() {
373        return;
374    }
375
376    let config = state.config.read().await;
377    req.embed_metadata = Some(config.effective_embed_metadata(None));
378}
379
380/// Apply prompt expansion if `expand: true` is set on a generate request.
381async fn maybe_expand_prompt(
382    state: &AppState,
383    req: &mut mold_core::GenerateRequest,
384) -> Result<(), ApiError> {
385    if req.expand != Some(true) {
386        return Ok(());
387    }
388
389    let config = state.config.read().await;
390    let expand_settings = config.expand.clone().with_env_overrides();
391
392    // Resolve model family for prompt style
393    let model_family = config
394        .resolved_model_config(&req.model)
395        .family
396        .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
397        .unwrap_or_else(|| {
398            tracing::warn!(
399                model = %req.model,
400                "could not resolve model family for prompt expansion, defaulting to \"flux\""
401            );
402            "flux".to_string()
403        });
404
405    let expand_config = expand_settings.to_expand_config(&model_family, 1);
406    let original_prompt = req.prompt.clone();
407
408    // Drop config lock before blocking
409    drop(config);
410
411    let expander = create_server_expander(&expand_settings)?;
412    let result =
413        tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
414            .await
415            .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
416            .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
417
418    if let Some(expanded) = result.expanded.first() {
419        req.original_prompt = Some(req.prompt.clone());
420        req.prompt = expanded.clone();
421    }
422
423    Ok(())
424}
425
426/// Create the appropriate expander for server-side use.
427fn create_server_expander(
428    settings: &mold_core::ExpandSettings,
429) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
430    if let Some(api_expander) = settings.create_api_expander() {
431        return Ok(Box::new(api_expander));
432    }
433
434    #[cfg(feature = "expand")]
435    {
436        let config = mold_core::Config::load_or_default();
437        if let Some(local) =
438            mold_inference::expand::LocalExpander::from_config(&config, Some(&settings.model))
439        {
440            return Ok(Box::new(local));
441        }
442        return Err(ApiError::validation(
443            "local expand model not found — run: mold pull qwen3-expand".to_string(),
444        ));
445    }
446
447    #[cfg(not(feature = "expand"))]
448    {
449        Err(ApiError::validation(
450            "local prompt expansion not available — built without expand feature. \
451             Configure an API backend in [expand] settings."
452                .to_string(),
453        ))
454    }
455}
456
457// ── /api/expand ──────────────────────────────────────────────────────────────
458
459#[utoipa::path(
460    post,
461    path = "/api/expand",
462    tag = "generation",
463    request_body = mold_core::ExpandRequest,
464    responses(
465        (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
466        (status = 422, description = "Invalid request parameters"),
467        (status = 500, description = "Expansion failed"),
468    )
469)]
470async fn expand_prompt(
471    State(state): State<AppState>,
472    Json(req): Json<mold_core::ExpandRequest>,
473) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
474    if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
475        return Err(ApiError::validation(format!(
476            "variations must be between 1 and {}",
477            mold_core::expand::MAX_VARIATIONS,
478        )));
479    }
480
481    let config = state.config.read().await;
482    let expand_settings = config.expand.clone().with_env_overrides();
483    let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
484    let prompt = req.prompt.clone();
485    drop(config);
486
487    let expander = create_server_expander(&expand_settings)?;
488    let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
489        .await
490        .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
491        .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
492
493    Ok(Json(mold_core::ExpandResponse {
494        original: req.prompt,
495        expanded: result.expanded,
496    }))
497}
498
499// ── /api/upscale ────────────────────────────────────────────────────────────
500
501async fn upscale(
502    State(state): State<AppState>,
503    Json(req): Json<mold_core::UpscaleRequest>,
504) -> Result<Json<mold_core::UpscaleResponse>, ApiError> {
505    if let Err(msg) = mold_core::validate_upscale_request(&req) {
506        return Err(ApiError::validation(msg));
507    }
508
509    let model_name = mold_core::manifest::resolve_model_name(&req.model);
510
511    // Auto-pull upscaler model if not downloaded
512    let needs_pull = {
513        let config = state.config.read().await;
514        config
515            .models
516            .get(&model_name)
517            .and_then(|c| c.transformer.as_ref())
518            .is_none()
519    };
520    if needs_pull {
521        if mold_core::manifest::find_manifest(&model_name).is_none() {
522            return Err(ApiError::not_found(format!(
523                "unknown upscaler model '{}'. Run 'mold list' to see available models.",
524                model_name
525            )));
526        }
527        model_manager::pull_model(&state, &model_name, None).await?;
528    }
529
530    let config = state.config.read().await;
531    let weights_path = config
532        .models
533        .get(&model_name)
534        .and_then(|c| c.transformer.as_ref())
535        .ok_or_else(|| {
536            ApiError::not_found(format!(
537                "upscaler model '{}' not configured after pull",
538                model_name
539            ))
540        })?;
541    let weights_path = std::path::PathBuf::from(weights_path);
542    let model_name_owned = model_name.clone();
543    drop(config);
544
545    let upscaler_cache = state.upscaler_cache.clone();
546    let resp =
547        tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
548            let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
549
550            // Reuse cached engine if same model
551            let needs_new = cache
552                .as_ref()
553                .is_none_or(|e| e.model_name() != model_name_owned);
554            if needs_new {
555                let new_engine = mold_inference::create_upscale_engine(
556                    model_name_owned,
557                    weights_path,
558                    mold_inference::LoadStrategy::Eager,
559                )?;
560                *cache = Some(new_engine);
561            }
562
563            cache.as_mut().unwrap().upscale(&req)
564        })
565        .await
566        .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")))?
567        .map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?;
568
569    Ok(Json(resp))
570}
571
572// ── /api/upscale/stream (SSE) ──────────────────────────────────────────────
573
574async fn upscale_stream(
575    State(state): State<AppState>,
576    Json(req): Json<mold_core::UpscaleRequest>,
577) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
578    if let Err(msg) = mold_core::validate_upscale_request(&req) {
579        return Err(ApiError::validation(msg));
580    }
581
582    let model_name = mold_core::manifest::resolve_model_name(&req.model);
583
584    // Check if model needs pulling before spawning the SSE stream
585    let needs_pull = {
586        let config = state.config.read().await;
587        config
588            .models
589            .get(&model_name)
590            .and_then(|c| c.transformer.as_ref())
591            .is_none()
592    };
593
594    // Validate the model exists in the manifest if we need to pull
595    if needs_pull && mold_core::manifest::find_manifest(&model_name).is_none() {
596        return Err(ApiError::not_found(format!(
597            "unknown upscaler model '{}'. Run 'mold list' to see available models.",
598            model_name
599        )));
600    }
601
602    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
603    let model_name_owned = model_name.clone();
604    let state_clone = state.clone();
605    let upscaler_cache = state.upscaler_cache.clone();
606
607    tokio::spawn(async move {
608        // Auto-pull the upscaler model if not downloaded
609        if needs_pull {
610            let progress_tx = tx.clone();
611            let callback =
612                std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
613                    let sse_event = match event {
614                        mold_core::download::DownloadProgressEvent::Status { message } => {
615                            SseProgressEvent::Info { message }
616                        }
617                        mold_core::download::DownloadProgressEvent::FileStart {
618                            filename,
619                            file_index,
620                            total_files,
621                            size_bytes,
622                            batch_bytes_downloaded,
623                            batch_bytes_total,
624                            batch_elapsed_ms,
625                        } => SseProgressEvent::DownloadProgress {
626                            filename,
627                            file_index,
628                            total_files,
629                            bytes_downloaded: 0,
630                            bytes_total: size_bytes,
631                            batch_bytes_downloaded,
632                            batch_bytes_total,
633                            batch_elapsed_ms,
634                        },
635                        mold_core::download::DownloadProgressEvent::FileProgress {
636                            filename,
637                            file_index,
638                            bytes_downloaded,
639                            bytes_total,
640                            batch_bytes_downloaded,
641                            batch_bytes_total,
642                            batch_elapsed_ms,
643                        } => SseProgressEvent::DownloadProgress {
644                            filename,
645                            file_index,
646                            total_files: 0,
647                            bytes_downloaded,
648                            bytes_total,
649                            batch_bytes_downloaded,
650                            batch_bytes_total,
651                            batch_elapsed_ms,
652                        },
653                        mold_core::download::DownloadProgressEvent::FileDone {
654                            filename,
655                            file_index,
656                            total_files,
657                            batch_bytes_downloaded,
658                            batch_bytes_total,
659                            batch_elapsed_ms,
660                        } => SseProgressEvent::DownloadDone {
661                            filename,
662                            file_index,
663                            total_files,
664                            batch_bytes_downloaded,
665                            batch_bytes_total,
666                            batch_elapsed_ms,
667                        },
668                    };
669                    let _ = progress_tx.send(SseMessage::Progress(sse_event));
670                });
671
672            match model_manager::pull_model(&state_clone, &model_name_owned, Some(callback)).await {
673                Ok(_) => {
674                    let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
675                        model: model_name_owned.clone(),
676                    }));
677                }
678                Err(e) => {
679                    let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
680                        message: format!("failed to pull upscaler model: {}", e.error),
681                    }));
682                    return;
683                }
684            }
685        }
686
687        // Read weights path after potential pull
688        let weights_path = {
689            let config = state_clone.config.read().await;
690            config
691                .models
692                .get(&model_name_owned)
693                .and_then(|c| c.transformer.as_ref())
694                .map(std::path::PathBuf::from)
695        };
696
697        let Some(weights_path) = weights_path else {
698            let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
699                message: format!(
700                    "upscaler model '{}' not configured after pull",
701                    model_name_owned
702                ),
703            }));
704            return;
705        };
706
707        let result = tokio::task::spawn_blocking(move || {
708            let mut cache = upscaler_cache.lock().unwrap();
709
710            let needs_new = cache
711                .as_ref()
712                .is_none_or(|e| e.model_name() != model_name_owned);
713            if needs_new {
714                let _ = tx.send(SseMessage::Progress(
715                    mold_core::SseProgressEvent::StageStart {
716                        name: "Loading upscaler model".to_string(),
717                    },
718                ));
719                match mold_inference::create_upscale_engine(
720                    model_name_owned,
721                    weights_path,
722                    mold_inference::LoadStrategy::Eager,
723                ) {
724                    Ok(new_engine) => {
725                        *cache = Some(new_engine);
726                    }
727                    Err(e) => {
728                        let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
729                            message: format!("failed to load upscaler: {e}"),
730                        }));
731                        return;
732                    }
733                }
734            }
735
736            let engine = cache.as_mut().unwrap();
737
738            // Install progress callback for tile-by-tile progress
739            let tx_progress = tx.clone();
740            engine.set_on_progress(Box::new(move |event| {
741                let sse_event: mold_core::SseProgressEvent = event.into();
742                let _ = tx_progress.send(SseMessage::Progress(sse_event));
743            }));
744
745            match engine.upscale(&req) {
746                Ok(resp) => {
747                    let image_b64 =
748                        base64::engine::general_purpose::STANDARD.encode(&resp.image.data);
749                    let _ = tx.send(SseMessage::UpscaleComplete(
750                        mold_core::SseUpscaleCompleteEvent {
751                            image: image_b64,
752                            format: resp.image.format,
753                            model: resp.model,
754                            scale_factor: resp.scale_factor,
755                            original_width: resp.original_width,
756                            original_height: resp.original_height,
757                            upscale_time_ms: resp.upscale_time_ms,
758                        },
759                    ));
760                }
761                Err(e) => {
762                    let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
763                        message: format!("upscale failed: {e}"),
764                    }));
765                }
766            }
767
768            engine.clear_on_progress();
769        })
770        .await;
771
772        if let Err(e) = result {
773            tracing::error!("upscale task panicked: {e}");
774        }
775    });
776
777    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
778        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
779
780    Ok(Sse::new(stream).keep_alive(
781        KeepAlive::new()
782            .interval(std::time::Duration::from_secs(15))
783            .text("ping"),
784    ))
785}
786
787// ── /api/generate/stream (SSE) ───────────────────────────────────────────────
788
789#[utoipa::path(
790    post,
791    path = "/api/generate/stream",
792    tag = "generation",
793    request_body = mold_core::GenerateRequest,
794    responses(
795        (status = 200, description = "SSE event stream with progress and result"),
796        (status = 404, description = "Model not downloaded"),
797        (status = 422, description = "Invalid request parameters"),
798        (status = 500, description = "Inference error"),
799    )
800)]
801async fn generate_stream(
802    State(state): State<AppState>,
803    Json(mut req): Json<mold_core::GenerateRequest>,
804) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
805    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
806
807    tracing::info!(
808        model = %req.model,
809        prompt = %req.prompt,
810        "generate/stream request"
811    );
812
813    // Create SSE channel
814    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
815
816    // Send dimension warning before queuing so the client sees it early
817    if let Some(warning) = dim_warning {
818        let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
819            message: warning,
820        }));
821    }
822
823    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
824    let job = GenerationJob {
825        request: req,
826        progress_tx: Some(tx.clone()),
827        result_tx,
828        output_dir,
829    };
830
831    let position = state.queue.submit(job).await.map_err(ApiError::internal)?;
832
833    // Send initial queue position to the client
834    let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
835
836    // Hold `tx` alive in a background task until the job completes, so the SSE
837    // stream never closes prematurely even if the queue worker hasn't received
838    // the job yet.
839    tokio::spawn(async move {
840        let _ = result_rx.await;
841        drop(tx); // closes the SSE stream
842    });
843
844    // Build SSE stream from the channel receiver.
845    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
846        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
847
848    Ok(Sse::new(stream).keep_alive(
849        KeepAlive::new()
850            .interval(std::time::Duration::from_secs(15))
851            .text("ping"),
852    ))
853}
854
855// ── /api/models ───────────────────────────────────────────────────────────────
856
857#[utoipa::path(
858    get,
859    path = "/api/models",
860    tag = "models",
861    responses(
862        (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
863    )
864)]
865async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
866    Json(model_manager::list_models(&state).await)
867}
868
869// ── /api/models/load ──────────────────────────────────────────────────────────
870
871#[derive(Debug, Deserialize, utoipa::ToSchema)]
872pub struct LoadModelBody {
873    #[schema(example = "flux-schnell:q8")]
874    pub model: String,
875}
876
877#[utoipa::path(
878    post,
879    path = "/api/models/load",
880    tag = "models",
881    request_body = LoadModelBody,
882    responses(
883        (status = 200, description = "Model loaded successfully"),
884        (status = 404, description = "Model not downloaded"),
885        (status = 400, description = "Unknown model"),
886        (status = 500, description = "Failed to load model"),
887    )
888)]
889async fn load_model(
890    State(state): State<AppState>,
891    Json(body): Json<LoadModelBody>,
892) -> Result<impl IntoResponse, ApiError> {
893    model_manager::ensure_model_ready(&state, &body.model, None).await?;
894    tracing::info!(model = %body.model, "model loaded via API");
895    Ok(StatusCode::OK)
896}
897
898// ── /api/models/pull ──────────────────────────────────────────────────────────
899
900#[utoipa::path(
901    post,
902    path = "/api/models/pull",
903    tag = "models",
904    request_body = LoadModelBody,
905    responses(
906        (status = 200, description = "Model pulled (SSE stream or plain text)"),
907        (status = 400, description = "Unknown model"),
908        (status = 500, description = "Download failed"),
909    )
910)]
911async fn pull_model_endpoint(
912    State(state): State<AppState>,
913    headers: HeaderMap,
914    Json(body): Json<LoadModelBody>,
915) -> Result<impl IntoResponse, ApiError> {
916    let wants_sse = headers
917        .get(header::ACCEPT)
918        .and_then(|v| v.to_str().ok())
919        .is_some_and(|v| v.contains("text/event-stream"));
920
921    if !wants_sse {
922        // Legacy: blocking pull with plain text response
923        return pull_model_blocking(state, body.model)
924            .await
925            .map(PullResponse::Text);
926    }
927
928    // SSE streaming pull
929    let model = body.model.clone();
930
931    // Validate model exists in manifest before starting SSE
932    if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
933        .is_none()
934    {
935        return Err(ApiError::unknown_model(format!(
936            "unknown model '{model}'. Run 'mold list' to see available models."
937        )));
938    }
939
940    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
941
942    tokio::spawn(async move {
943        let progress_tx = tx.clone();
944        let model_for_cb = model.clone();
945        let callback =
946            std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
947                let sse_event = match event {
948                    mold_core::download::DownloadProgressEvent::Status { message } => {
949                        SseProgressEvent::Info { message }
950                    }
951                    mold_core::download::DownloadProgressEvent::FileStart {
952                        filename,
953                        file_index,
954                        total_files,
955                        size_bytes,
956                        batch_bytes_downloaded,
957                        batch_bytes_total,
958                        batch_elapsed_ms,
959                    } => SseProgressEvent::DownloadProgress {
960                        filename,
961                        file_index,
962                        total_files,
963                        bytes_downloaded: 0,
964                        bytes_total: size_bytes,
965                        batch_bytes_downloaded,
966                        batch_bytes_total,
967                        batch_elapsed_ms,
968                    },
969                    mold_core::download::DownloadProgressEvent::FileProgress {
970                        filename,
971                        file_index,
972                        bytes_downloaded,
973                        bytes_total,
974                        batch_bytes_downloaded,
975                        batch_bytes_total,
976                        batch_elapsed_ms,
977                    } => SseProgressEvent::DownloadProgress {
978                        filename,
979                        file_index,
980                        total_files: 0,
981                        bytes_downloaded,
982                        bytes_total,
983                        batch_bytes_downloaded,
984                        batch_bytes_total,
985                        batch_elapsed_ms,
986                    },
987                    mold_core::download::DownloadProgressEvent::FileDone {
988                        filename,
989                        file_index,
990                        total_files,
991                        batch_bytes_downloaded,
992                        batch_bytes_total,
993                        batch_elapsed_ms,
994                    } => SseProgressEvent::DownloadDone {
995                        filename,
996                        file_index,
997                        total_files,
998                        batch_bytes_downloaded,
999                        batch_bytes_total,
1000                        batch_elapsed_ms,
1001                    },
1002                };
1003                let _ = progress_tx.send(SseMessage::Progress(sse_event));
1004            });
1005
1006        match model_manager::pull_model(&state, &model, Some(callback)).await {
1007            Ok(model_manager::PullStatus::AlreadyAvailable) => {
1008                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1009                    model: model_for_cb,
1010                }));
1011            }
1012            Ok(model_manager::PullStatus::Pulled) => {
1013                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1014                    model: model_for_cb,
1015                }));
1016            }
1017            Err(e) => {
1018                let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
1019            }
1020        }
1021    });
1022
1023    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1024        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1025
1026    Ok(PullResponse::Sse(
1027        Sse::new(stream)
1028            .keep_alive(
1029                KeepAlive::new()
1030                    .interval(std::time::Duration::from_secs(15))
1031                    .text("ping"),
1032            )
1033            .into_response(),
1034    ))
1035}
1036
1037/// Legacy blocking pull — returns plain text.
1038async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
1039    match model_manager::pull_model(&state, &model, None).await? {
1040        model_manager::PullStatus::AlreadyAvailable => {
1041            Ok(format!("model '{}' already available", model))
1042        }
1043        model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
1044    }
1045}
1046
1047/// Response type that can be either SSE stream or plain text.
1048enum PullResponse {
1049    Sse(axum::response::Response),
1050    Text(String),
1051}
1052
1053impl IntoResponse for PullResponse {
1054    fn into_response(self) -> axum::response::Response {
1055        match self {
1056            PullResponse::Sse(resp) => resp,
1057            PullResponse::Text(text) => text.into_response(),
1058        }
1059    }
1060}
1061
1062// ── /api/models/unload ────────────────────────────────────────────────────────
1063
1064#[utoipa::path(
1065    delete,
1066    path = "/api/models/unload",
1067    tag = "models",
1068    responses(
1069        (status = 200, description = "Model unloaded or no model was loaded", body = String),
1070    )
1071)]
1072async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
1073    Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1074}
1075
1076// ── /api/status ───────────────────────────────────────────────────────────────
1077
1078#[utoipa::path(
1079    get,
1080    path = "/api/status",
1081    tag = "server",
1082    responses(
1083        (status = 200, description = "Server status", body = ServerStatus),
1084    )
1085)]
1086async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
1087    let snapshot = state.engine_snapshot.read().await.clone();
1088    let models_loaded = match (snapshot.model_name, snapshot.is_loaded) {
1089        (Some(model_name), true) => vec![model_name],
1090        _ => vec![],
1091    };
1092    let current_generation = state
1093        .active_generation
1094        .read()
1095        .unwrap_or_else(|e| e.into_inner())
1096        .as_ref()
1097        .map(|active| ActiveGenerationStatus {
1098            model: active.model.clone(),
1099            prompt_sha256: active.prompt_sha256.clone(),
1100            started_at_unix_ms: active.started_at_unix_ms,
1101            elapsed_ms: active.started_at.elapsed().as_millis() as u64,
1102        });
1103    let busy = current_generation.is_some();
1104
1105    Json(ServerStatus {
1106        version: env!("CARGO_PKG_VERSION").to_string(),
1107        git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
1108            None
1109        } else {
1110            Some(mold_core::build_info::GIT_SHA.to_string())
1111        },
1112        build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
1113            None
1114        } else {
1115            Some(mold_core::build_info::BUILD_DATE.to_string())
1116        },
1117        models_loaded,
1118        busy,
1119        current_generation,
1120        gpu_info: query_gpu_info(),
1121        uptime_secs: state.start_time.elapsed().as_secs(),
1122        hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
1123        memory_status: mold_inference::device::memory_status_string(),
1124    })
1125}
1126
1127// ── /health ───────────────────────────────────────────────────────────────────
1128
1129#[utoipa::path(
1130    get,
1131    path = "/health",
1132    tag = "server",
1133    responses(
1134        (status = 200, description = "Server is healthy"),
1135    )
1136)]
1137async fn health() -> impl IntoResponse {
1138    StatusCode::OK
1139}
1140
1141// ── /api/shutdown ─────────────────────────────────────────────────────────────
1142
1143/// Trigger graceful server shutdown.
1144///
1145/// When API key auth is enabled, the auth middleware protects this endpoint.
1146/// When auth is disabled, only requests from loopback addresses (127.0.0.1, ::1)
1147/// are accepted to prevent remote shutdown.
1148#[utoipa::path(
1149    post,
1150    path = "/api/shutdown",
1151    tag = "server",
1152    responses(
1153        (status = 200, description = "Shutdown initiated"),
1154        (status = 403, description = "Forbidden — remote shutdown requires API key auth"),
1155    )
1156)]
1157async fn shutdown_server(State(state): State<AppState>, request: Request) -> impl IntoResponse {
1158    // When auth is disabled (no AuthState extension or AuthState is None),
1159    // restrict shutdown to loopback addresses only.
1160    let auth_enabled = request
1161        .extensions()
1162        .get::<crate::auth::AuthState>()
1163        .is_some_and(|s| s.is_some());
1164
1165    if !auth_enabled {
1166        let is_loopback = request
1167            .extensions()
1168            .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1169            .map(|ci| ci.0.ip().is_loopback())
1170            .unwrap_or(false);
1171        if !is_loopback {
1172            return (
1173                StatusCode::FORBIDDEN,
1174                "shutdown requires API key auth or localhost access\n",
1175            );
1176        }
1177    }
1178
1179    tracing::info!("shutdown requested via API");
1180    if let Some(tx) = state.shutdown_tx.lock().await.take() {
1181        let _ = tx.send(());
1182    }
1183    (StatusCode::OK, "shutdown initiated\n")
1184}
1185
1186// ── /api/gallery ──────────────────────────────────────────────────────────────
1187
1188/// List gallery images from the server's output directory.
1189/// Returns metadata from PNG `mold:parameters` chunks, sorted newest-first.
1190async fn list_gallery(
1191    State(state): State<AppState>,
1192) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
1193    let config = state.config.read().await;
1194    if config.is_output_disabled() {
1195        return Ok(Json(Vec::new()));
1196    }
1197    let output_dir = config.effective_output_dir();
1198    drop(config);
1199
1200    if !output_dir.is_dir() {
1201        return Ok(Json(Vec::new()));
1202    }
1203
1204    let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
1205        .await
1206        .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
1207
1208    Ok(Json(images))
1209}
1210
1211/// Serve a gallery image file by filename.
1212async fn get_gallery_image(
1213    State(state): State<AppState>,
1214    axum::extract::Path(filename): axum::extract::Path<String>,
1215) -> Result<impl IntoResponse, ApiError> {
1216    let config = state.config.read().await;
1217    if config.is_output_disabled() {
1218        return Err(ApiError::not_found("image output is disabled"));
1219    }
1220    let output_dir = config.effective_output_dir();
1221    drop(config);
1222
1223    // Sanitize: prevent directory traversal
1224    let clean_name = std::path::Path::new(&filename)
1225        .file_name()
1226        .map(|f| f.to_string_lossy().to_string())
1227        .unwrap_or_default();
1228
1229    if clean_name.is_empty() || clean_name != filename {
1230        return Err(ApiError::validation("invalid filename"));
1231    }
1232
1233    let path = output_dir.join(&clean_name);
1234    if !path.is_file() {
1235        return Err(ApiError::not_found(format!(
1236            "image not found: {clean_name}"
1237        )));
1238    }
1239
1240    let data = tokio::fs::read(&path)
1241        .await
1242        .map_err(|e| ApiError::internal(format!("failed to read image: {e}")))?;
1243
1244    let content_type = if clean_name.ends_with(".png") {
1245        "image/png"
1246    } else if clean_name.ends_with(".jpg") || clean_name.ends_with(".jpeg") {
1247        "image/jpeg"
1248    } else {
1249        "application/octet-stream"
1250    };
1251
1252    let mut headers = HeaderMap::new();
1253    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
1254
1255    Ok((headers, data))
1256}
1257
1258/// Delete a gallery image and its server-side thumbnail.
1259async fn delete_gallery_image(
1260    State(state): State<AppState>,
1261    axum::extract::Path(filename): axum::extract::Path<String>,
1262) -> Result<impl IntoResponse, ApiError> {
1263    let config = state.config.read().await;
1264    if config.is_output_disabled() {
1265        return Err(ApiError::not_found("image output is disabled"));
1266    }
1267    let output_dir = config.effective_output_dir();
1268    drop(config);
1269
1270    let clean_name = std::path::Path::new(&filename)
1271        .file_name()
1272        .map(|f| f.to_string_lossy().to_string())
1273        .unwrap_or_default();
1274
1275    if clean_name.is_empty() || clean_name != filename {
1276        return Err(ApiError::validation("invalid filename"));
1277    }
1278
1279    let path = output_dir.join(&clean_name);
1280    if path.is_file() {
1281        std::fs::remove_file(&path)
1282            .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
1283    }
1284
1285    // Also remove server-side thumbnail
1286    let thumb_path = server_thumbnail_dir().join(&clean_name);
1287    let _ = std::fs::remove_file(&thumb_path);
1288
1289    Ok(StatusCode::NO_CONTENT)
1290}
1291
1292/// Serve a thumbnail for a gallery image. Generated on-demand and cached
1293/// at ~/.mold/cache/thumbnails/ on the server side.
1294async fn get_gallery_thumbnail(
1295    State(state): State<AppState>,
1296    axum::extract::Path(filename): axum::extract::Path<String>,
1297) -> Result<impl IntoResponse, ApiError> {
1298    let config = state.config.read().await;
1299    if config.is_output_disabled() {
1300        return Err(ApiError::not_found("image output is disabled"));
1301    }
1302    let output_dir = config.effective_output_dir();
1303    drop(config);
1304
1305    let clean_name = std::path::Path::new(&filename)
1306        .file_name()
1307        .map(|f| f.to_string_lossy().to_string())
1308        .unwrap_or_default();
1309
1310    if clean_name.is_empty() || clean_name != filename {
1311        return Err(ApiError::validation("invalid filename"));
1312    }
1313
1314    let source_path = output_dir.join(&clean_name);
1315    if !source_path.is_file() {
1316        return Err(ApiError::not_found(format!(
1317            "image not found: {clean_name}"
1318        )));
1319    }
1320
1321    // Check if server-side thumbnail already exists
1322    let thumb_dir = server_thumbnail_dir();
1323    let thumb_path = thumb_dir.join(&clean_name);
1324
1325    if !thumb_path.is_file() {
1326        // Generate thumbnail on-demand
1327        let source = source_path.clone();
1328        let dest = thumb_path.clone();
1329        tokio::task::spawn_blocking(move || generate_server_thumbnail(&source, &dest))
1330            .await
1331            .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?
1332            .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
1333    }
1334
1335    let data = tokio::fs::read(&thumb_path)
1336        .await
1337        .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
1338
1339    let mut headers = HeaderMap::new();
1340    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
1341
1342    Ok((headers, data))
1343}
1344
1345/// Server-side thumbnail cache directory.
1346fn server_thumbnail_dir() -> std::path::PathBuf {
1347    mold_core::Config::mold_dir()
1348        .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
1349        .join("cache")
1350        .join("thumbnails")
1351}
1352
1353/// Generate a 256x256 max thumbnail from source image.
1354fn generate_server_thumbnail(
1355    source: &std::path::Path,
1356    dest: &std::path::Path,
1357) -> anyhow::Result<()> {
1358    let img = image::open(source)?;
1359    let thumb = img.thumbnail(256, 256);
1360    if let Some(parent) = dest.parent() {
1361        std::fs::create_dir_all(parent)?;
1362    }
1363    thumb.save(dest)?;
1364    Ok(())
1365}
1366
1367/// Pre-generate thumbnails for all gallery images on server startup.
1368pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
1369    if !thumbnail_warmup_enabled() {
1370        tracing::info!("thumbnail warmup disabled; thumbnails will be generated on demand");
1371        return;
1372    }
1373
1374    let output_dir = config.effective_output_dir();
1375    std::thread::spawn(move || {
1376        if !output_dir.is_dir() {
1377            return;
1378        }
1379        let thumb_dir = server_thumbnail_dir();
1380        let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
1381        for entry in walker.filter_map(|e| e.ok()) {
1382            let path = entry.path();
1383            if !path.is_file() {
1384                continue;
1385            }
1386            let ext = path
1387                .extension()
1388                .and_then(|e| e.to_str())
1389                .map(|e| e.to_lowercase());
1390            if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
1391                continue;
1392            }
1393            let filename = path
1394                .file_name()
1395                .map(|f| f.to_string_lossy().to_string())
1396                .unwrap_or_default();
1397            let thumb_path = thumb_dir.join(&filename);
1398            if !thumb_path.is_file() {
1399                if let Err(e) = generate_server_thumbnail(path, &thumb_path) {
1400                    tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
1401                }
1402            }
1403        }
1404        tracing::info!("thumbnail warmup complete");
1405    });
1406}
1407
1408fn thumbnail_warmup_enabled() -> bool {
1409    std::env::var("MOLD_THUMBNAIL_WARMUP")
1410        .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
1411        .unwrap_or(false)
1412}
1413
1414/// Scan a directory for image files with mold metadata.
1415fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
1416    let mut images = Vec::new();
1417
1418    let walker = walkdir::WalkDir::new(dir).max_depth(1).into_iter();
1419    for entry in walker.filter_map(|e| e.ok()) {
1420        let path = entry.path();
1421        if !path.is_file() {
1422            continue;
1423        }
1424
1425        let ext = path
1426            .extension()
1427            .and_then(|e| e.to_str())
1428            .map(|e| e.to_lowercase());
1429        if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
1430            continue;
1431        }
1432
1433        let timestamp = entry
1434            .metadata()
1435            .ok()
1436            .and_then(|m| m.modified().ok())
1437            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1438            .map(|d| d.as_secs())
1439            .unwrap_or(0);
1440
1441        let filename = path
1442            .file_name()
1443            .map(|f| f.to_string_lossy().to_string())
1444            .unwrap_or_default();
1445
1446        let meta = if ext.as_deref() == Some("png") {
1447            read_png_metadata(path)
1448        } else {
1449            read_jpeg_metadata(path)
1450        };
1451
1452        if let Some(meta) = meta {
1453            images.push(mold_core::GalleryImage {
1454                filename,
1455                metadata: meta,
1456                timestamp,
1457            });
1458        }
1459    }
1460
1461    images.sort_by_key(|img| std::cmp::Reverse(img.timestamp));
1462    images
1463}
1464
1465/// Read OutputMetadata from a PNG file's text chunks.
1466fn read_png_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1467    let file = std::fs::File::open(path).ok()?;
1468    let decoder = png::Decoder::new(std::io::BufReader::new(file));
1469    let reader = decoder.read_info().ok()?;
1470    let info = reader.info();
1471
1472    for chunk in &info.uncompressed_latin1_text {
1473        if chunk.keyword == "mold:parameters" {
1474            if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&chunk.text) {
1475                return Some(meta);
1476            }
1477        }
1478    }
1479    for chunk in &info.utf8_text {
1480        if chunk.keyword == "mold:parameters" {
1481            if let Ok(text) = chunk.get_text() {
1482                if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&text) {
1483                    return Some(meta);
1484                }
1485            }
1486        }
1487    }
1488    None
1489}
1490
1491/// Read OutputMetadata from a JPEG file's COM marker.
1492fn read_jpeg_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1493    let data = std::fs::read(path).ok()?;
1494    let mut i = 0;
1495    while i + 1 < data.len() {
1496        if data[i] != 0xFF {
1497            i += 1;
1498            continue;
1499        }
1500        let marker = data[i + 1];
1501        match marker {
1502            // Standalone markers (no length field): SOI, EOI, RST0-7, TEM
1503            0xD8 | 0x01 => {
1504                i += 2;
1505            }
1506            0xD9 => break, // EOI — end of image
1507            0xD0..=0xD7 => {
1508                i += 2; // RST markers
1509            }
1510            // COM marker — check for mold:parameters
1511            0xFE => {
1512                if i + 3 >= data.len() {
1513                    break;
1514                }
1515                let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1516                if len < 2 || i + 2 + len > data.len() {
1517                    break;
1518                }
1519                let comment = &data[i + 4..i + 2 + len];
1520                if let Ok(text) = std::str::from_utf8(comment) {
1521                    if let Some(json) = text.strip_prefix("mold:parameters ") {
1522                        if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(json) {
1523                            return Some(meta);
1524                        }
1525                    }
1526                }
1527                i += 2 + len;
1528            }
1529            // All other markers have a 2-byte length field
1530            _ => {
1531                if i + 3 >= data.len() {
1532                    break;
1533                }
1534                let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1535                if len < 2 || i + 2 + len > data.len() {
1536                    break;
1537                }
1538                i += 2 + len;
1539            }
1540        }
1541    }
1542    None
1543}
1544
1545// ── /api/openapi.json ─────────────────────────────────────────────────────────
1546
1547async fn openapi_json() -> impl IntoResponse {
1548    Json(ApiDoc::openapi())
1549}
1550
1551// ── /api/docs ─────────────────────────────────────────────────────────────────
1552
1553async fn scalar_docs() -> impl IntoResponse {
1554    (
1555        [(header::CONTENT_TYPE, "text/html")],
1556        r#"<!DOCTYPE html>
1557<html>
1558<head>
1559  <title>mold API</title>
1560  <meta charset="utf-8" />
1561  <meta name="viewport" content="width=device-width, initial-scale=1" />
1562</head>
1563<body>
1564  <script id="api-reference" data-url="/api/openapi.json"></script>
1565  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
1566</body>
1567</html>"#,
1568    )
1569}
1570
1571// ── GPU info ──────────────────────────────────────────────────────────────────
1572
1573fn query_gpu_info() -> Option<GpuInfo> {
1574    let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
1575        "/run/current-system/sw/bin/nvidia-smi"
1576    } else {
1577        "nvidia-smi"
1578    };
1579
1580    let output = std::process::Command::new(nvidia_smi)
1581        .args([
1582            "--query-gpu=name,memory.total,memory.used",
1583            "--format=csv,noheader,nounits",
1584        ])
1585        .output()
1586        .ok()?;
1587
1588    if !output.status.success() {
1589        return None;
1590    }
1591
1592    let text = String::from_utf8(output.stdout).ok()?;
1593    let line = text.lines().next()?;
1594    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
1595    if parts.len() < 3 {
1596        return None;
1597    }
1598
1599    Some(GpuInfo {
1600        name: parts[0].to_string(),
1601        vram_total_mb: parts[1].parse().ok()?,
1602        vram_used_mb: parts[2].parse().ok()?,
1603    })
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608    use super::*;
1609
1610    fn env_lock() -> &'static std::sync::Mutex<()> {
1611        static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1612        &ENV_LOCK
1613    }
1614
1615    #[test]
1616    fn clean_error_message_strips_backtrace() {
1617        let err = anyhow::anyhow!(
1618            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
1619             \x20  0: candle_core::error::Error::bt\n\
1620             \x20           at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
1621             \x20  1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
1622             \x20           at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
1623        );
1624        let msg = clean_error_message(&err);
1625        assert_eq!(
1626            msg,
1627            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
1628        );
1629    }
1630
1631    #[test]
1632    fn clean_error_message_preserves_simple_error() {
1633        let err = anyhow::anyhow!("model not found: flux-dev:q4");
1634        let msg = clean_error_message(&err);
1635        assert_eq!(msg, "model not found: flux-dev:q4");
1636    }
1637
1638    #[test]
1639    fn clean_error_message_preserves_multiline_without_backtrace() {
1640        let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
1641        let msg = clean_error_message(&err);
1642        assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
1643    }
1644
1645    #[test]
1646    fn clean_error_message_strips_high_numbered_frames() {
1647        let err = anyhow::anyhow!(
1648            "some error\n\
1649             \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
1650             \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
1651        );
1652        let msg = clean_error_message(&err);
1653        assert_eq!(msg, "some error");
1654    }
1655
1656    #[test]
1657    fn clean_error_message_empty_fallback() {
1658        // An error whose Display starts immediately with a backtrace-like line
1659        let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
1660        let msg = clean_error_message(&err);
1661        // Should fall back to root_cause since all lines look like backtrace
1662        assert!(!msg.is_empty());
1663    }
1664
1665    #[test]
1666    fn save_image_to_dir_creates_directory_and_writes_file() {
1667        let dir = std::env::temp_dir().join(format!(
1668            "mold-save-test-{}",
1669            std::time::SystemTime::now()
1670                .duration_since(std::time::UNIX_EPOCH)
1671                .unwrap()
1672                .as_nanos()
1673        ));
1674        assert!(!dir.exists());
1675
1676        let img = mold_core::ImageData {
1677            data: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
1678            format: mold_core::OutputFormat::Png,
1679            width: 64,
1680            height: 64,
1681            index: 0,
1682        };
1683
1684        save_image_to_dir(&dir, &img, "test-model:q8", 1);
1685
1686        assert!(dir.exists(), "directory should be created");
1687        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1688        assert_eq!(files.len(), 1, "should have exactly one file");
1689        let file = files[0].as_ref().unwrap();
1690        let filename = file.file_name().to_str().unwrap().to_string();
1691        assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
1692        assert!(filename.ends_with(".png"), "{filename}");
1693        let contents = std::fs::read(file.path()).unwrap();
1694        assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
1695
1696        std::fs::remove_dir_all(&dir).ok();
1697    }
1698
1699    #[test]
1700    fn save_image_to_dir_batch_includes_index() {
1701        let dir = std::env::temp_dir().join(format!(
1702            "mold-save-batch-{}",
1703            std::time::SystemTime::now()
1704                .duration_since(std::time::UNIX_EPOCH)
1705                .unwrap()
1706                .as_nanos()
1707        ));
1708
1709        let img = mold_core::ImageData {
1710            data: vec![0xFF, 0xD8], // JPEG magic
1711            format: mold_core::OutputFormat::Jpeg,
1712            width: 64,
1713            height: 64,
1714            index: 2,
1715        };
1716
1717        save_image_to_dir(&dir, &img, "flux-dev", 4);
1718
1719        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1720        assert_eq!(files.len(), 1);
1721        let filename = files[0]
1722            .as_ref()
1723            .unwrap()
1724            .file_name()
1725            .to_str()
1726            .unwrap()
1727            .to_string();
1728        assert!(
1729            filename.contains("-2.jpeg"),
1730            "batch index in name: {filename}"
1731        );
1732
1733        std::fs::remove_dir_all(&dir).ok();
1734    }
1735
1736    #[test]
1737    fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
1738        // Saving to a path that can't be created should not panic
1739        let img = mold_core::ImageData {
1740            data: vec![0x00],
1741            format: mold_core::OutputFormat::Png,
1742            width: 1,
1743            height: 1,
1744            index: 0,
1745        };
1746        // /dev/null/impossible can't be created as a directory
1747        save_image_to_dir(
1748            std::path::Path::new("/dev/null/impossible"),
1749            &img,
1750            "test",
1751            1,
1752        );
1753        // Test passes if no panic occurred
1754    }
1755
1756    #[test]
1757    fn thumbnail_warmup_is_disabled_by_default() {
1758        let _guard = env_lock().lock().unwrap();
1759        unsafe {
1760            std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1761        }
1762        assert!(!thumbnail_warmup_enabled());
1763    }
1764
1765    #[test]
1766    fn thumbnail_warmup_accepts_truthy_env_values() {
1767        let _guard = env_lock().lock().unwrap();
1768        unsafe {
1769            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "1");
1770        }
1771        assert!(thumbnail_warmup_enabled());
1772        unsafe {
1773            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "true");
1774        }
1775        assert!(thumbnail_warmup_enabled());
1776        unsafe {
1777            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "YES");
1778        }
1779        assert!(thumbnail_warmup_enabled());
1780        unsafe {
1781            std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1782        }
1783    }
1784
1785    #[test]
1786    fn thumbnail_warmup_rejects_falsey_env_values() {
1787        let _guard = env_lock().lock().unwrap();
1788        unsafe {
1789            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "0");
1790        }
1791        assert!(!thumbnail_warmup_enabled());
1792        unsafe {
1793            std::env::set_var("MOLD_THUMBNAIL_WARMUP", "false");
1794        }
1795        assert!(!thumbnail_warmup_enabled());
1796        unsafe {
1797            std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1798        }
1799    }
1800}