Skip to main content

mold_server/
routes.rs

1use axum::{
2    extract::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 mold_core::{
12    ActiveGenerationStatus, GpuInfo, ModelInfoExtended, OutputFormat, ServerStatus, SseErrorEvent,
13    SseProgressEvent,
14};
15use serde::{Deserialize, Serialize};
16use std::convert::Infallible;
17use tokio_stream::StreamExt as _;
18use utoipa::OpenApi;
19
20use crate::model_manager;
21use crate::state::{AppState, GenerationJob, SseMessage};
22
23// ── ApiError — structured JSON error response ────────────────────────────────
24
25#[derive(Debug, Serialize)]
26pub struct ApiError {
27    pub error: String,
28    pub code: String,
29    #[serde(skip)]
30    status: StatusCode,
31}
32
33impl ApiError {
34    pub fn validation(msg: impl Into<String>) -> Self {
35        Self {
36            error: msg.into(),
37            code: "VALIDATION_ERROR".to_string(),
38            status: StatusCode::UNPROCESSABLE_ENTITY,
39        }
40    }
41
42    pub fn not_found(msg: impl Into<String>) -> Self {
43        Self {
44            error: msg.into(),
45            code: "MODEL_NOT_FOUND".to_string(),
46            status: StatusCode::NOT_FOUND,
47        }
48    }
49
50    pub fn unknown_model(msg: impl Into<String>) -> Self {
51        Self {
52            error: msg.into(),
53            code: "UNKNOWN_MODEL".to_string(),
54            status: StatusCode::BAD_REQUEST,
55        }
56    }
57
58    pub fn inference(msg: impl Into<String>) -> Self {
59        Self {
60            error: msg.into(),
61            code: "INFERENCE_ERROR".to_string(),
62            status: StatusCode::INTERNAL_SERVER_ERROR,
63        }
64    }
65
66    pub fn internal(msg: impl Into<String>) -> Self {
67        Self {
68            error: msg.into(),
69            code: "INTERNAL_ERROR".to_string(),
70            status: StatusCode::INTERNAL_SERVER_ERROR,
71        }
72    }
73
74    pub fn insufficient_memory(msg: impl Into<String>) -> Self {
75        Self {
76            error: msg.into(),
77            code: "INSUFFICIENT_MEMORY".to_string(),
78            status: StatusCode::SERVICE_UNAVAILABLE,
79        }
80    }
81}
82
83impl IntoResponse for ApiError {
84    fn into_response(self) -> axum::response::Response {
85        let status = self.status;
86        (status, Json(self)).into_response()
87    }
88}
89
90// Re-export for tests — the canonical implementation lives in queue.rs.
91#[cfg(test)]
92use crate::queue::clean_error_message;
93
94#[derive(OpenApi)]
95#[openapi(
96    paths(generate, generate_stream, expand_prompt, list_models, load_model, pull_model_endpoint, unload_model, server_status, health),
97    components(schemas(
98        mold_core::GenerateRequest,
99        mold_core::GenerateResponse,
100        mold_core::ExpandRequest,
101        mold_core::ExpandResponse,
102        mold_core::ImageData,
103        mold_core::OutputFormat,
104        mold_core::ModelInfo,
105        mold_core::ServerStatus,
106        mold_core::ActiveGenerationStatus,
107        mold_core::GpuInfo,
108        mold_core::SseProgressEvent,
109        mold_core::SseCompleteEvent,
110        mold_core::SseErrorEvent,
111        ModelInfoExtended,
112        LoadModelBody,
113    )),
114    tags(
115        (name = "generation", description = "Image generation"),
116        (name = "models", description = "Model management"),
117        (name = "server", description = "Server status and health"),
118    ),
119    info(
120        title = "mold",
121        description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
122        version = env!("CARGO_PKG_VERSION"),
123    )
124)]
125pub struct ApiDoc;
126
127pub fn create_router(state: AppState) -> Router {
128    // Stateful routes (need AppState) are added first, then .with_state() converts
129    // Router<AppState> → Router<()>. Stateless routes (OpenAPI, docs) are merged after.
130    Router::new()
131        .route("/api/generate", post(generate))
132        .route("/api/generate/stream", post(generate_stream))
133        .route("/api/expand", post(expand_prompt))
134        .route("/api/models", get(list_models))
135        .route("/api/models/load", post(load_model))
136        .route("/api/models/pull", post(pull_model_endpoint))
137        .route("/api/models/unload", delete(unload_model))
138        .route("/api/gallery", get(list_gallery))
139        .route(
140            "/api/gallery/image/:filename",
141            get(get_gallery_image).delete(delete_gallery_image),
142        )
143        .route(
144            "/api/gallery/thumbnail/:filename",
145            get(get_gallery_thumbnail),
146        )
147        .route("/api/status", get(server_status))
148        .route("/health", get(health))
149        .with_state(state)
150        .route("/api/openapi.json", get(openapi_json))
151        .route("/api/docs", get(scalar_docs))
152}
153
154// ── Model readiness ──────────────────────────────────────────────────────────
155
156fn sse_message_to_event(msg: SseMessage) -> SseEvent {
157    fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
158        match serde_json::to_string(payload) {
159            Ok(data) => SseEvent::default().event(event_name).data(data),
160            Err(err) => SseEvent::default().event("error").data(
161                serde_json::json!({
162                    "message": format!("failed to serialize SSE payload: {err}")
163                })
164                .to_string(),
165            ),
166        }
167    }
168
169    match msg {
170        SseMessage::Progress(payload) => serialize_event("progress", &payload),
171        SseMessage::Complete(payload) => serialize_event("complete", &payload),
172        SseMessage::Error(payload) => serialize_event("error", &payload),
173    }
174}
175
176#[cfg(test)]
177fn save_image_to_dir(
178    dir: &std::path::Path,
179    img: &mold_core::ImageData,
180    model: &str,
181    batch_size: u32,
182) {
183    if let Err(e) = std::fs::create_dir_all(dir) {
184        tracing::warn!("failed to create output dir {}: {e}", dir.display());
185        return;
186    }
187    // Use milliseconds for server-side filenames to avoid overwrites when
188    // concurrent requests finish in the same second.
189    let timestamp_ms = std::time::SystemTime::now()
190        .duration_since(std::time::UNIX_EPOCH)
191        .unwrap_or_default()
192        .as_millis() as u64;
193    let ext = img.format.to_string();
194    let filename =
195        mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
196    let path = dir.join(&filename);
197    match std::fs::write(&path, &img.data) {
198        Ok(()) => tracing::info!("saved image to {}", path.display()),
199        Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
200    }
201}
202
203// ── Shared pre-queue validation ───────────────────────────────────────────────
204
205/// Validate a generate request and resolve server-side defaults.
206///
207/// Performs the identical pre-queue checks used by both `generate` and
208/// `generate_stream`: applies the default metadata setting, validates the
209/// request, checks model availability, and resolves the output directory.
210async fn prepare_generation(
211    state: &AppState,
212    request: &mut mold_core::GenerateRequest,
213) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
214    apply_default_metadata_setting(state, request).await;
215
216    // Expand prompt if requested (before validation, so the expanded prompt gets validated)
217    maybe_expand_prompt(state, request).await?;
218
219    if let Err(e) = validate_generate_request(request) {
220        return Err(ApiError::validation(e));
221    }
222
223    let _ = model_manager::check_model_available(state, &request.model).await?;
224
225    let (output_dir, dim_warning) = {
226        let config = state.config.read().await;
227        let output_dir = if config.is_output_disabled() {
228            None
229        } else {
230            Some(config.effective_output_dir())
231        };
232        let family = config.resolved_model_config(&request.model).family;
233        let dim_warning = family
234            .as_deref()
235            .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
236        (output_dir, dim_warning)
237    };
238
239    Ok((output_dir, dim_warning))
240}
241
242// ── /api/generate ─────────────────────────────────────────────────────────────
243
244#[utoipa::path(
245    post,
246    path = "/api/generate",
247    tag = "generation",
248    request_body = mold_core::GenerateRequest,
249    responses(
250        (status = 200, description = "Generated image bytes", content_type = "image/png"),
251        (status = 404, description = "Model not downloaded"),
252        (status = 422, description = "Invalid request parameters"),
253        (status = 500, description = "Inference error"),
254    )
255)]
256// The server always produces 1 image per request; batch looping (--batch N)
257// is handled client-side by the CLI, which sends N requests with incrementing seeds.
258async fn generate(
259    State(state): State<AppState>,
260    Json(mut req): Json<mold_core::GenerateRequest>,
261) -> Result<impl IntoResponse, ApiError> {
262    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
263
264    tracing::info!(
265        model = %req.model,
266        prompt = %req.prompt,
267        width = req.width,
268        height = req.height,
269        steps = req.steps,
270        guidance = req.guidance,
271        seed = ?req.seed,
272        format = %req.output_format,
273        lora = ?req.lora.as_ref().map(|l| &l.path),
274        lora_scale = ?req.lora.as_ref().map(|l| l.scale),
275        "generate request"
276    );
277
278    // Submit to generation queue
279    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
280    let job = GenerationJob {
281        request: req,
282        progress_tx: None,
283        result_tx,
284        output_dir,
285    };
286
287    let _position = state.queue.submit(job).await.map_err(ApiError::internal)?;
288
289    // Wait for the queue worker to process the job
290    let result = result_rx
291        .await
292        .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
293
294    match result {
295        Ok(job_result) => {
296            let img = job_result.image;
297            let response = job_result.response;
298            let content_type = match img.format {
299                OutputFormat::Png => HeaderValue::from_static("image/png"),
300                OutputFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
301            };
302            let mut headers = HeaderMap::new();
303            headers.insert(header::CONTENT_TYPE, content_type);
304            headers.insert(
305                "x-mold-seed-used",
306                HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
307                    ApiError::internal(format!("failed to serialize seed header: {e}"))
308                })?,
309            );
310            if let Some(warning) = dim_warning {
311                match HeaderValue::from_str(&warning.replace('\n', " ")) {
312                    Ok(val) => {
313                        headers.insert("x-mold-dimension-warning", val);
314                    }
315                    Err(e) => {
316                        tracing::warn!("dimension warning could not be encoded as header: {e}");
317                    }
318                }
319            }
320            Ok((headers, img.data))
321        }
322        Err(err_msg) => Err(ApiError::inference(err_msg)),
323    }
324}
325
326fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
327    mold_core::validate_generate_request(req)
328}
329
330async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
331    if req.embed_metadata.is_some() {
332        return;
333    }
334
335    let config = state.config.read().await;
336    req.embed_metadata = Some(config.effective_embed_metadata(None));
337}
338
339/// Apply prompt expansion if `expand: true` is set on a generate request.
340async fn maybe_expand_prompt(
341    state: &AppState,
342    req: &mut mold_core::GenerateRequest,
343) -> Result<(), ApiError> {
344    if req.expand != Some(true) {
345        return Ok(());
346    }
347
348    let config = state.config.read().await;
349    let expand_settings = config.expand.clone().with_env_overrides();
350
351    // Resolve model family for prompt style
352    let model_family = config
353        .resolved_model_config(&req.model)
354        .family
355        .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
356        .unwrap_or_else(|| {
357            tracing::warn!(
358                model = %req.model,
359                "could not resolve model family for prompt expansion, defaulting to \"flux\""
360            );
361            "flux".to_string()
362        });
363
364    let expand_config = expand_settings.to_expand_config(&model_family, 1);
365    let original_prompt = req.prompt.clone();
366
367    // Drop config lock before blocking
368    drop(config);
369
370    let expander = create_server_expander(&expand_settings)?;
371    let result =
372        tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
373            .await
374            .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
375            .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
376
377    if let Some(expanded) = result.expanded.first() {
378        req.original_prompt = Some(req.prompt.clone());
379        req.prompt = expanded.clone();
380    }
381
382    Ok(())
383}
384
385/// Create the appropriate expander for server-side use.
386fn create_server_expander(
387    settings: &mold_core::ExpandSettings,
388) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
389    if let Some(api_expander) = settings.create_api_expander() {
390        return Ok(Box::new(api_expander));
391    }
392
393    #[cfg(feature = "expand")]
394    {
395        let config = mold_core::Config::load_or_default();
396        if let Some(local) =
397            mold_inference::expand::LocalExpander::from_config(&config, Some(&settings.model))
398        {
399            return Ok(Box::new(local));
400        }
401        return Err(ApiError::validation(
402            "local expand model not found — run: mold pull qwen3-expand".to_string(),
403        ));
404    }
405
406    #[cfg(not(feature = "expand"))]
407    {
408        Err(ApiError::validation(
409            "local prompt expansion not available — built without expand feature. \
410             Configure an API backend in [expand] settings."
411                .to_string(),
412        ))
413    }
414}
415
416// ── /api/expand ──────────────────────────────────────────────────────────────
417
418#[utoipa::path(
419    post,
420    path = "/api/expand",
421    tag = "generation",
422    request_body = mold_core::ExpandRequest,
423    responses(
424        (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
425        (status = 422, description = "Invalid request parameters"),
426        (status = 500, description = "Expansion failed"),
427    )
428)]
429async fn expand_prompt(
430    State(state): State<AppState>,
431    Json(req): Json<mold_core::ExpandRequest>,
432) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
433    if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
434        return Err(ApiError::validation(format!(
435            "variations must be between 1 and {}",
436            mold_core::expand::MAX_VARIATIONS,
437        )));
438    }
439
440    let config = state.config.read().await;
441    let expand_settings = config.expand.clone().with_env_overrides();
442    let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
443    let prompt = req.prompt.clone();
444    drop(config);
445
446    let expander = create_server_expander(&expand_settings)?;
447    let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
448        .await
449        .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
450        .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
451
452    Ok(Json(mold_core::ExpandResponse {
453        original: req.prompt,
454        expanded: result.expanded,
455    }))
456}
457
458// ── /api/generate/stream (SSE) ───────────────────────────────────────────────
459
460#[utoipa::path(
461    post,
462    path = "/api/generate/stream",
463    tag = "generation",
464    request_body = mold_core::GenerateRequest,
465    responses(
466        (status = 200, description = "SSE event stream with progress and result"),
467        (status = 404, description = "Model not downloaded"),
468        (status = 422, description = "Invalid request parameters"),
469        (status = 500, description = "Inference error"),
470    )
471)]
472async fn generate_stream(
473    State(state): State<AppState>,
474    Json(mut req): Json<mold_core::GenerateRequest>,
475) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
476    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
477
478    tracing::info!(
479        model = %req.model,
480        prompt = %req.prompt,
481        "generate/stream request"
482    );
483
484    // Create SSE channel
485    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
486
487    // Send dimension warning before queuing so the client sees it early
488    if let Some(warning) = dim_warning {
489        let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
490            message: warning,
491        }));
492    }
493
494    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
495    let job = GenerationJob {
496        request: req,
497        progress_tx: Some(tx.clone()),
498        result_tx,
499        output_dir,
500    };
501
502    let position = state.queue.submit(job).await.map_err(ApiError::internal)?;
503
504    // Send initial queue position to the client
505    let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
506
507    // Hold `tx` alive in a background task until the job completes, so the SSE
508    // stream never closes prematurely even if the queue worker hasn't received
509    // the job yet.
510    tokio::spawn(async move {
511        let _ = result_rx.await;
512        drop(tx); // closes the SSE stream
513    });
514
515    // Build SSE stream from the channel receiver.
516    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
517        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
518
519    Ok(Sse::new(stream).keep_alive(
520        KeepAlive::new()
521            .interval(std::time::Duration::from_secs(15))
522            .text("ping"),
523    ))
524}
525
526// ── /api/models ───────────────────────────────────────────────────────────────
527
528#[utoipa::path(
529    get,
530    path = "/api/models",
531    tag = "models",
532    responses(
533        (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
534    )
535)]
536async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
537    Json(model_manager::list_models(&state).await)
538}
539
540// ── /api/models/load ──────────────────────────────────────────────────────────
541
542#[derive(Debug, Deserialize, utoipa::ToSchema)]
543pub struct LoadModelBody {
544    #[schema(example = "flux-schnell:q8")]
545    pub model: String,
546}
547
548#[utoipa::path(
549    post,
550    path = "/api/models/load",
551    tag = "models",
552    request_body = LoadModelBody,
553    responses(
554        (status = 200, description = "Model loaded successfully"),
555        (status = 404, description = "Model not downloaded"),
556        (status = 400, description = "Unknown model"),
557        (status = 500, description = "Failed to load model"),
558    )
559)]
560async fn load_model(
561    State(state): State<AppState>,
562    Json(body): Json<LoadModelBody>,
563) -> Result<impl IntoResponse, ApiError> {
564    model_manager::ensure_model_ready(&state, &body.model, None).await?;
565    tracing::info!(model = %body.model, "model loaded via API");
566    Ok(StatusCode::OK)
567}
568
569// ── /api/models/pull ──────────────────────────────────────────────────────────
570
571#[utoipa::path(
572    post,
573    path = "/api/models/pull",
574    tag = "models",
575    request_body = LoadModelBody,
576    responses(
577        (status = 200, description = "Model pulled (SSE stream or plain text)"),
578        (status = 400, description = "Unknown model"),
579        (status = 500, description = "Download failed"),
580    )
581)]
582async fn pull_model_endpoint(
583    State(state): State<AppState>,
584    headers: HeaderMap,
585    Json(body): Json<LoadModelBody>,
586) -> Result<impl IntoResponse, ApiError> {
587    let wants_sse = headers
588        .get(header::ACCEPT)
589        .and_then(|v| v.to_str().ok())
590        .is_some_and(|v| v.contains("text/event-stream"));
591
592    if !wants_sse {
593        // Legacy: blocking pull with plain text response
594        return pull_model_blocking(state, body.model)
595            .await
596            .map(PullResponse::Text);
597    }
598
599    // SSE streaming pull
600    let model = body.model.clone();
601
602    // Validate model exists in manifest before starting SSE
603    if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
604        .is_none()
605    {
606        return Err(ApiError::unknown_model(format!(
607            "unknown model '{model}'. Run 'mold list' to see available models."
608        )));
609    }
610
611    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
612
613    tokio::spawn(async move {
614        let progress_tx = tx.clone();
615        let model_for_cb = model.clone();
616        let callback =
617            std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
618                let sse_event = match event {
619                    mold_core::download::DownloadProgressEvent::FileStart {
620                        filename,
621                        file_index,
622                        total_files,
623                        size_bytes,
624                    } => SseProgressEvent::DownloadProgress {
625                        filename,
626                        file_index,
627                        total_files,
628                        bytes_downloaded: 0,
629                        bytes_total: size_bytes,
630                    },
631                    mold_core::download::DownloadProgressEvent::FileProgress {
632                        filename,
633                        file_index,
634                        bytes_downloaded,
635                        bytes_total,
636                    } => SseProgressEvent::DownloadProgress {
637                        filename,
638                        file_index,
639                        total_files: 0,
640                        bytes_downloaded,
641                        bytes_total,
642                    },
643                    mold_core::download::DownloadProgressEvent::FileDone {
644                        filename,
645                        file_index,
646                        total_files,
647                    } => SseProgressEvent::DownloadDone {
648                        filename,
649                        file_index,
650                        total_files,
651                    },
652                };
653                let _ = progress_tx.send(SseMessage::Progress(sse_event));
654            });
655
656        match model_manager::pull_model(&state, &model, Some(callback)).await {
657            Ok(model_manager::PullStatus::AlreadyAvailable) => {
658                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
659                    model: model_for_cb,
660                }));
661            }
662            Ok(model_manager::PullStatus::Pulled) => {
663                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
664                    model: model_for_cb,
665                }));
666            }
667            Err(e) => {
668                let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
669            }
670        }
671    });
672
673    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
674        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
675
676    Ok(PullResponse::Sse(
677        Sse::new(stream)
678            .keep_alive(
679                KeepAlive::new()
680                    .interval(std::time::Duration::from_secs(15))
681                    .text("ping"),
682            )
683            .into_response(),
684    ))
685}
686
687/// Legacy blocking pull — returns plain text.
688async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
689    match model_manager::pull_model(&state, &model, None).await? {
690        model_manager::PullStatus::AlreadyAvailable => {
691            Ok(format!("model '{}' already available", model))
692        }
693        model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
694    }
695}
696
697/// Response type that can be either SSE stream or plain text.
698enum PullResponse {
699    Sse(axum::response::Response),
700    Text(String),
701}
702
703impl IntoResponse for PullResponse {
704    fn into_response(self) -> axum::response::Response {
705        match self {
706            PullResponse::Sse(resp) => resp,
707            PullResponse::Text(text) => text.into_response(),
708        }
709    }
710}
711
712// ── /api/models/unload ────────────────────────────────────────────────────────
713
714#[utoipa::path(
715    delete,
716    path = "/api/models/unload",
717    tag = "models",
718    responses(
719        (status = 200, description = "Model unloaded or no model was loaded", body = String),
720    )
721)]
722async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
723    Ok((StatusCode::OK, model_manager::unload_model(&state).await))
724}
725
726// ── /api/status ───────────────────────────────────────────────────────────────
727
728#[utoipa::path(
729    get,
730    path = "/api/status",
731    tag = "server",
732    responses(
733        (status = 200, description = "Server status", body = ServerStatus),
734    )
735)]
736async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
737    let snapshot = state.engine_snapshot.read().await.clone();
738    let models_loaded = match (snapshot.model_name, snapshot.is_loaded) {
739        (Some(model_name), true) => vec![model_name],
740        _ => vec![],
741    };
742    let current_generation = state
743        .active_generation
744        .read()
745        .unwrap_or_else(|e| e.into_inner())
746        .as_ref()
747        .map(|active| ActiveGenerationStatus {
748            model: active.model.clone(),
749            prompt_sha256: active.prompt_sha256.clone(),
750            started_at_unix_ms: active.started_at_unix_ms,
751            elapsed_ms: active.started_at.elapsed().as_millis() as u64,
752        });
753    let busy = current_generation.is_some();
754
755    Json(ServerStatus {
756        version: env!("CARGO_PKG_VERSION").to_string(),
757        git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
758            None
759        } else {
760            Some(mold_core::build_info::GIT_SHA.to_string())
761        },
762        build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
763            None
764        } else {
765            Some(mold_core::build_info::BUILD_DATE.to_string())
766        },
767        models_loaded,
768        busy,
769        current_generation,
770        gpu_info: query_gpu_info(),
771        uptime_secs: state.start_time.elapsed().as_secs(),
772    })
773}
774
775// ── /health ───────────────────────────────────────────────────────────────────
776
777#[utoipa::path(
778    get,
779    path = "/health",
780    tag = "server",
781    responses(
782        (status = 200, description = "Server is healthy"),
783    )
784)]
785async fn health() -> impl IntoResponse {
786    StatusCode::OK
787}
788
789// ── /api/gallery ──────────────────────────────────────────────────────────────
790
791/// List gallery images from the server's output directory.
792/// Returns metadata from PNG `mold:parameters` chunks, sorted newest-first.
793async fn list_gallery(
794    State(state): State<AppState>,
795) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
796    let config = state.config.read().await;
797    if config.is_output_disabled() {
798        return Ok(Json(Vec::new()));
799    }
800    let output_dir = config.effective_output_dir();
801    drop(config);
802
803    if !output_dir.is_dir() {
804        return Ok(Json(Vec::new()));
805    }
806
807    let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
808        .await
809        .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
810
811    Ok(Json(images))
812}
813
814/// Serve a gallery image file by filename.
815async fn get_gallery_image(
816    State(state): State<AppState>,
817    axum::extract::Path(filename): axum::extract::Path<String>,
818) -> Result<impl IntoResponse, ApiError> {
819    let config = state.config.read().await;
820    if config.is_output_disabled() {
821        return Err(ApiError::not_found("image output is disabled"));
822    }
823    let output_dir = config.effective_output_dir();
824    drop(config);
825
826    // Sanitize: prevent directory traversal
827    let clean_name = std::path::Path::new(&filename)
828        .file_name()
829        .map(|f| f.to_string_lossy().to_string())
830        .unwrap_or_default();
831
832    if clean_name.is_empty() || clean_name != filename {
833        return Err(ApiError::validation("invalid filename"));
834    }
835
836    let path = output_dir.join(&clean_name);
837    if !path.is_file() {
838        return Err(ApiError::not_found(format!(
839            "image not found: {clean_name}"
840        )));
841    }
842
843    let data = tokio::fs::read(&path)
844        .await
845        .map_err(|e| ApiError::internal(format!("failed to read image: {e}")))?;
846
847    let content_type = if clean_name.ends_with(".png") {
848        "image/png"
849    } else if clean_name.ends_with(".jpg") || clean_name.ends_with(".jpeg") {
850        "image/jpeg"
851    } else {
852        "application/octet-stream"
853    };
854
855    let mut headers = HeaderMap::new();
856    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
857
858    Ok((headers, data))
859}
860
861/// Delete a gallery image and its server-side thumbnail.
862async fn delete_gallery_image(
863    State(state): State<AppState>,
864    axum::extract::Path(filename): axum::extract::Path<String>,
865) -> Result<impl IntoResponse, ApiError> {
866    let config = state.config.read().await;
867    if config.is_output_disabled() {
868        return Err(ApiError::not_found("image output is disabled"));
869    }
870    let output_dir = config.effective_output_dir();
871    drop(config);
872
873    let clean_name = std::path::Path::new(&filename)
874        .file_name()
875        .map(|f| f.to_string_lossy().to_string())
876        .unwrap_or_default();
877
878    if clean_name.is_empty() || clean_name != filename {
879        return Err(ApiError::validation("invalid filename"));
880    }
881
882    let path = output_dir.join(&clean_name);
883    if path.is_file() {
884        std::fs::remove_file(&path)
885            .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
886    }
887
888    // Also remove server-side thumbnail
889    let thumb_path = server_thumbnail_dir().join(&clean_name);
890    let _ = std::fs::remove_file(&thumb_path);
891
892    Ok(StatusCode::NO_CONTENT)
893}
894
895/// Serve a thumbnail for a gallery image. Generated on-demand and cached
896/// at ~/.mold/cache/thumbnails/ on the server side.
897async fn get_gallery_thumbnail(
898    State(state): State<AppState>,
899    axum::extract::Path(filename): axum::extract::Path<String>,
900) -> Result<impl IntoResponse, ApiError> {
901    let config = state.config.read().await;
902    if config.is_output_disabled() {
903        return Err(ApiError::not_found("image output is disabled"));
904    }
905    let output_dir = config.effective_output_dir();
906    drop(config);
907
908    let clean_name = std::path::Path::new(&filename)
909        .file_name()
910        .map(|f| f.to_string_lossy().to_string())
911        .unwrap_or_default();
912
913    if clean_name.is_empty() || clean_name != filename {
914        return Err(ApiError::validation("invalid filename"));
915    }
916
917    let source_path = output_dir.join(&clean_name);
918    if !source_path.is_file() {
919        return Err(ApiError::not_found(format!(
920            "image not found: {clean_name}"
921        )));
922    }
923
924    // Check if server-side thumbnail already exists
925    let thumb_dir = server_thumbnail_dir();
926    let thumb_path = thumb_dir.join(&clean_name);
927
928    if !thumb_path.is_file() {
929        // Generate thumbnail on-demand
930        let source = source_path.clone();
931        let dest = thumb_path.clone();
932        tokio::task::spawn_blocking(move || generate_server_thumbnail(&source, &dest))
933            .await
934            .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?
935            .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
936    }
937
938    let data = tokio::fs::read(&thumb_path)
939        .await
940        .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
941
942    let mut headers = HeaderMap::new();
943    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
944
945    Ok((headers, data))
946}
947
948/// Server-side thumbnail cache directory.
949fn server_thumbnail_dir() -> std::path::PathBuf {
950    mold_core::Config::mold_dir()
951        .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
952        .join("cache")
953        .join("thumbnails")
954}
955
956/// Generate a 256x256 max thumbnail from source image.
957fn generate_server_thumbnail(
958    source: &std::path::Path,
959    dest: &std::path::Path,
960) -> anyhow::Result<()> {
961    let img = image::open(source)?;
962    let thumb = img.thumbnail(256, 256);
963    if let Some(parent) = dest.parent() {
964        std::fs::create_dir_all(parent)?;
965    }
966    thumb.save(dest)?;
967    Ok(())
968}
969
970/// Pre-generate thumbnails for all gallery images on server startup.
971pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
972    let output_dir = config.effective_output_dir();
973    std::thread::spawn(move || {
974        if !output_dir.is_dir() {
975            return;
976        }
977        let thumb_dir = server_thumbnail_dir();
978        let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
979        for entry in walker.filter_map(|e| e.ok()) {
980            let path = entry.path();
981            if !path.is_file() {
982                continue;
983            }
984            let ext = path
985                .extension()
986                .and_then(|e| e.to_str())
987                .map(|e| e.to_lowercase());
988            if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
989                continue;
990            }
991            let filename = path
992                .file_name()
993                .map(|f| f.to_string_lossy().to_string())
994                .unwrap_or_default();
995            let thumb_path = thumb_dir.join(&filename);
996            if !thumb_path.is_file() {
997                if let Err(e) = generate_server_thumbnail(path, &thumb_path) {
998                    tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
999                }
1000            }
1001        }
1002        tracing::info!("thumbnail warmup complete");
1003    });
1004}
1005
1006/// Scan a directory for image files with mold metadata.
1007fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
1008    let mut images = Vec::new();
1009
1010    let walker = walkdir::WalkDir::new(dir).max_depth(1).into_iter();
1011    for entry in walker.filter_map(|e| e.ok()) {
1012        let path = entry.path();
1013        if !path.is_file() {
1014            continue;
1015        }
1016
1017        let ext = path
1018            .extension()
1019            .and_then(|e| e.to_str())
1020            .map(|e| e.to_lowercase());
1021        if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
1022            continue;
1023        }
1024
1025        let timestamp = entry
1026            .metadata()
1027            .ok()
1028            .and_then(|m| m.modified().ok())
1029            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1030            .map(|d| d.as_secs())
1031            .unwrap_or(0);
1032
1033        let filename = path
1034            .file_name()
1035            .map(|f| f.to_string_lossy().to_string())
1036            .unwrap_or_default();
1037
1038        let meta = if ext.as_deref() == Some("png") {
1039            read_png_metadata(path)
1040        } else {
1041            read_jpeg_metadata(path)
1042        };
1043
1044        if let Some(meta) = meta {
1045            images.push(mold_core::GalleryImage {
1046                filename,
1047                metadata: meta,
1048                timestamp,
1049            });
1050        }
1051    }
1052
1053    images.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
1054    images
1055}
1056
1057/// Read OutputMetadata from a PNG file's text chunks.
1058fn read_png_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1059    let file = std::fs::File::open(path).ok()?;
1060    let decoder = png::Decoder::new(std::io::BufReader::new(file));
1061    let reader = decoder.read_info().ok()?;
1062    let info = reader.info();
1063
1064    for chunk in &info.uncompressed_latin1_text {
1065        if chunk.keyword == "mold:parameters" {
1066            if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&chunk.text) {
1067                return Some(meta);
1068            }
1069        }
1070    }
1071    for chunk in &info.utf8_text {
1072        if chunk.keyword == "mold:parameters" {
1073            if let Ok(text) = chunk.get_text() {
1074                if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&text) {
1075                    return Some(meta);
1076                }
1077            }
1078        }
1079    }
1080    None
1081}
1082
1083/// Read OutputMetadata from a JPEG file's COM marker.
1084fn read_jpeg_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1085    let data = std::fs::read(path).ok()?;
1086    let mut i = 0;
1087    while i + 1 < data.len() {
1088        if data[i] != 0xFF {
1089            i += 1;
1090            continue;
1091        }
1092        let marker = data[i + 1];
1093        match marker {
1094            // Standalone markers (no length field): SOI, EOI, RST0-7, TEM
1095            0xD8 | 0x01 => {
1096                i += 2;
1097            }
1098            0xD9 => break, // EOI — end of image
1099            0xD0..=0xD7 => {
1100                i += 2; // RST markers
1101            }
1102            // COM marker — check for mold:parameters
1103            0xFE => {
1104                if i + 3 >= data.len() {
1105                    break;
1106                }
1107                let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1108                if len < 2 || i + 2 + len > data.len() {
1109                    break;
1110                }
1111                let comment = &data[i + 4..i + 2 + len];
1112                if let Ok(text) = std::str::from_utf8(comment) {
1113                    if let Some(json) = text.strip_prefix("mold:parameters ") {
1114                        if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(json) {
1115                            return Some(meta);
1116                        }
1117                    }
1118                }
1119                i += 2 + len;
1120            }
1121            // All other markers have a 2-byte length field
1122            _ => {
1123                if i + 3 >= data.len() {
1124                    break;
1125                }
1126                let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1127                if len < 2 || i + 2 + len > data.len() {
1128                    break;
1129                }
1130                i += 2 + len;
1131            }
1132        }
1133    }
1134    None
1135}
1136
1137// ── /api/openapi.json ─────────────────────────────────────────────────────────
1138
1139async fn openapi_json() -> impl IntoResponse {
1140    Json(ApiDoc::openapi())
1141}
1142
1143// ── /api/docs ─────────────────────────────────────────────────────────────────
1144
1145async fn scalar_docs() -> impl IntoResponse {
1146    (
1147        [(header::CONTENT_TYPE, "text/html")],
1148        r#"<!DOCTYPE html>
1149<html>
1150<head>
1151  <title>mold API</title>
1152  <meta charset="utf-8" />
1153  <meta name="viewport" content="width=device-width, initial-scale=1" />
1154</head>
1155<body>
1156  <script id="api-reference" data-url="/api/openapi.json"></script>
1157  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
1158</body>
1159</html>"#,
1160    )
1161}
1162
1163// ── GPU info ──────────────────────────────────────────────────────────────────
1164
1165fn query_gpu_info() -> Option<GpuInfo> {
1166    let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
1167        "/run/current-system/sw/bin/nvidia-smi"
1168    } else {
1169        "nvidia-smi"
1170    };
1171
1172    let output = std::process::Command::new(nvidia_smi)
1173        .args([
1174            "--query-gpu=name,memory.total,memory.used",
1175            "--format=csv,noheader,nounits",
1176        ])
1177        .output()
1178        .ok()?;
1179
1180    if !output.status.success() {
1181        return None;
1182    }
1183
1184    let text = String::from_utf8(output.stdout).ok()?;
1185    let line = text.lines().next()?;
1186    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
1187    if parts.len() < 3 {
1188        return None;
1189    }
1190
1191    Some(GpuInfo {
1192        name: parts[0].to_string(),
1193        vram_total_mb: parts[1].parse().ok()?,
1194        vram_used_mb: parts[2].parse().ok()?,
1195    })
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200    use super::*;
1201
1202    #[test]
1203    fn clean_error_message_strips_backtrace() {
1204        let err = anyhow::anyhow!(
1205            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
1206             \x20  0: candle_core::error::Error::bt\n\
1207             \x20           at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
1208             \x20  1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
1209             \x20           at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
1210        );
1211        let msg = clean_error_message(&err);
1212        assert_eq!(
1213            msg,
1214            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
1215        );
1216    }
1217
1218    #[test]
1219    fn clean_error_message_preserves_simple_error() {
1220        let err = anyhow::anyhow!("model not found: flux-dev:q4");
1221        let msg = clean_error_message(&err);
1222        assert_eq!(msg, "model not found: flux-dev:q4");
1223    }
1224
1225    #[test]
1226    fn clean_error_message_preserves_multiline_without_backtrace() {
1227        let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
1228        let msg = clean_error_message(&err);
1229        assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
1230    }
1231
1232    #[test]
1233    fn clean_error_message_strips_high_numbered_frames() {
1234        let err = anyhow::anyhow!(
1235            "some error\n\
1236             \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
1237             \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
1238        );
1239        let msg = clean_error_message(&err);
1240        assert_eq!(msg, "some error");
1241    }
1242
1243    #[test]
1244    fn clean_error_message_empty_fallback() {
1245        // An error whose Display starts immediately with a backtrace-like line
1246        let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
1247        let msg = clean_error_message(&err);
1248        // Should fall back to root_cause since all lines look like backtrace
1249        assert!(!msg.is_empty());
1250    }
1251
1252    #[test]
1253    fn save_image_to_dir_creates_directory_and_writes_file() {
1254        let dir = std::env::temp_dir().join(format!(
1255            "mold-save-test-{}",
1256            std::time::SystemTime::now()
1257                .duration_since(std::time::UNIX_EPOCH)
1258                .unwrap()
1259                .as_nanos()
1260        ));
1261        assert!(!dir.exists());
1262
1263        let img = mold_core::ImageData {
1264            data: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
1265            format: mold_core::OutputFormat::Png,
1266            width: 64,
1267            height: 64,
1268            index: 0,
1269        };
1270
1271        save_image_to_dir(&dir, &img, "test-model:q8", 1);
1272
1273        assert!(dir.exists(), "directory should be created");
1274        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1275        assert_eq!(files.len(), 1, "should have exactly one file");
1276        let file = files[0].as_ref().unwrap();
1277        let filename = file.file_name().to_str().unwrap().to_string();
1278        assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
1279        assert!(filename.ends_with(".png"), "{filename}");
1280        let contents = std::fs::read(file.path()).unwrap();
1281        assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
1282
1283        std::fs::remove_dir_all(&dir).ok();
1284    }
1285
1286    #[test]
1287    fn save_image_to_dir_batch_includes_index() {
1288        let dir = std::env::temp_dir().join(format!(
1289            "mold-save-batch-{}",
1290            std::time::SystemTime::now()
1291                .duration_since(std::time::UNIX_EPOCH)
1292                .unwrap()
1293                .as_nanos()
1294        ));
1295
1296        let img = mold_core::ImageData {
1297            data: vec![0xFF, 0xD8], // JPEG magic
1298            format: mold_core::OutputFormat::Jpeg,
1299            width: 64,
1300            height: 64,
1301            index: 2,
1302        };
1303
1304        save_image_to_dir(&dir, &img, "flux-dev", 4);
1305
1306        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1307        assert_eq!(files.len(), 1);
1308        let filename = files[0]
1309            .as_ref()
1310            .unwrap()
1311            .file_name()
1312            .to_str()
1313            .unwrap()
1314            .to_string();
1315        assert!(
1316            filename.contains("-2.jpeg"),
1317            "batch index in name: {filename}"
1318        );
1319
1320        std::fs::remove_dir_all(&dir).ok();
1321    }
1322
1323    #[test]
1324    fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
1325        // Saving to a path that can't be created should not panic
1326        let img = mold_core::ImageData {
1327            data: vec![0x00],
1328            format: mold_core::OutputFormat::Png,
1329            width: 1,
1330            height: 1,
1331            index: 0,
1332        };
1333        // /dev/null/impossible can't be created as a directory
1334        save_image_to_dir(
1335            std::path::Path::new("/dev/null/impossible"),
1336            &img,
1337            "test",
1338            1,
1339        );
1340        // Test passes if no panic occurred
1341    }
1342}