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