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