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
75impl IntoResponse for ApiError {
76    fn into_response(self) -> axum::response::Response {
77        let status = self.status;
78        (status, Json(self)).into_response()
79    }
80}
81
82// Re-export for tests — the canonical implementation lives in queue.rs.
83#[cfg(test)]
84use crate::queue::clean_error_message;
85
86#[derive(OpenApi)]
87#[openapi(
88    paths(generate, generate_stream, expand_prompt, list_models, load_model, pull_model_endpoint, unload_model, server_status, health),
89    components(schemas(
90        mold_core::GenerateRequest,
91        mold_core::GenerateResponse,
92        mold_core::ExpandRequest,
93        mold_core::ExpandResponse,
94        mold_core::ImageData,
95        mold_core::OutputFormat,
96        mold_core::ModelInfo,
97        mold_core::ServerStatus,
98        mold_core::ActiveGenerationStatus,
99        mold_core::GpuInfo,
100        mold_core::SseProgressEvent,
101        mold_core::SseCompleteEvent,
102        mold_core::SseErrorEvent,
103        ModelInfoExtended,
104        LoadModelBody,
105    )),
106    tags(
107        (name = "generation", description = "Image generation"),
108        (name = "models", description = "Model management"),
109        (name = "server", description = "Server status and health"),
110    ),
111    info(
112        title = "mold",
113        description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
114        version = env!("CARGO_PKG_VERSION"),
115    )
116)]
117pub struct ApiDoc;
118
119pub fn create_router(state: AppState) -> Router {
120    // Stateful routes (need AppState) are added first, then .with_state() converts
121    // Router<AppState> → Router<()>. Stateless routes (OpenAPI, docs) are merged after.
122    Router::new()
123        .route("/api/generate", post(generate))
124        .route("/api/generate/stream", post(generate_stream))
125        .route("/api/expand", post(expand_prompt))
126        .route("/api/models", get(list_models))
127        .route("/api/models/load", post(load_model))
128        .route("/api/models/pull", post(pull_model_endpoint))
129        .route("/api/models/unload", delete(unload_model))
130        .route("/api/status", get(server_status))
131        .route("/health", get(health))
132        .with_state(state)
133        .route("/api/openapi.json", get(openapi_json))
134        .route("/api/docs", get(scalar_docs))
135}
136
137// ── Model readiness ──────────────────────────────────────────────────────────
138
139fn sse_message_to_event(msg: SseMessage) -> SseEvent {
140    fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
141        match serde_json::to_string(payload) {
142            Ok(data) => SseEvent::default().event(event_name).data(data),
143            Err(err) => SseEvent::default().event("error").data(
144                serde_json::json!({
145                    "message": format!("failed to serialize SSE payload: {err}")
146                })
147                .to_string(),
148            ),
149        }
150    }
151
152    match msg {
153        SseMessage::Progress(payload) => serialize_event("progress", &payload),
154        SseMessage::Complete(payload) => serialize_event("complete", &payload),
155        SseMessage::Error(payload) => serialize_event("error", &payload),
156    }
157}
158
159#[cfg(test)]
160fn save_image_to_dir(
161    dir: &std::path::Path,
162    img: &mold_core::ImageData,
163    model: &str,
164    batch_size: u32,
165) {
166    if let Err(e) = std::fs::create_dir_all(dir) {
167        tracing::warn!("failed to create output dir {}: {e}", dir.display());
168        return;
169    }
170    // Use milliseconds for server-side filenames to avoid overwrites when
171    // concurrent requests finish in the same second.
172    let timestamp_ms = std::time::SystemTime::now()
173        .duration_since(std::time::UNIX_EPOCH)
174        .unwrap_or_default()
175        .as_millis() as u64;
176    let ext = img.format.to_string();
177    let filename =
178        mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
179    let path = dir.join(&filename);
180    match std::fs::write(&path, &img.data) {
181        Ok(()) => tracing::info!("saved image to {}", path.display()),
182        Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
183    }
184}
185
186// ── Shared pre-queue validation ───────────────────────────────────────────────
187
188/// Validate a generate request and resolve server-side defaults.
189///
190/// Performs the identical pre-queue checks used by both `generate` and
191/// `generate_stream`: applies the default metadata setting, validates the
192/// request, checks model availability, and resolves the output directory.
193async fn prepare_generation(
194    state: &AppState,
195    request: &mut mold_core::GenerateRequest,
196) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
197    apply_default_metadata_setting(state, request).await;
198
199    // Expand prompt if requested (before validation, so the expanded prompt gets validated)
200    maybe_expand_prompt(state, request).await?;
201
202    if let Err(e) = validate_generate_request(request) {
203        return Err(ApiError::validation(e));
204    }
205
206    let _ = model_manager::check_model_available(state, &request.model).await?;
207
208    let (output_dir, dim_warning) = {
209        let config = state.config.read().await;
210        let output_dir = config.resolved_output_dir().map(|p| p.to_path_buf());
211        let family = config.resolved_model_config(&request.model).family;
212        let dim_warning = family
213            .as_deref()
214            .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
215        (output_dir, dim_warning)
216    };
217
218    Ok((output_dir, dim_warning))
219}
220
221// ── /api/generate ─────────────────────────────────────────────────────────────
222
223#[utoipa::path(
224    post,
225    path = "/api/generate",
226    tag = "generation",
227    request_body = mold_core::GenerateRequest,
228    responses(
229        (status = 200, description = "Generated image bytes", content_type = "image/png"),
230        (status = 404, description = "Model not downloaded"),
231        (status = 422, description = "Invalid request parameters"),
232        (status = 500, description = "Inference error"),
233    )
234)]
235// The server always produces 1 image per request; batch looping (--batch N)
236// is handled client-side by the CLI, which sends N requests with incrementing seeds.
237async fn generate(
238    State(state): State<AppState>,
239    Json(mut req): Json<mold_core::GenerateRequest>,
240) -> Result<impl IntoResponse, ApiError> {
241    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
242
243    tracing::info!(
244        model = %req.model,
245        prompt = %req.prompt,
246        width = req.width,
247        height = req.height,
248        steps = req.steps,
249        guidance = req.guidance,
250        seed = ?req.seed,
251        format = %req.output_format,
252        lora = ?req.lora.as_ref().map(|l| &l.path),
253        lora_scale = ?req.lora.as_ref().map(|l| l.scale),
254        "generate request"
255    );
256
257    // Submit to generation queue
258    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
259    let job = GenerationJob {
260        request: req,
261        progress_tx: None,
262        result_tx,
263        output_dir,
264    };
265
266    let _position = state.queue.submit(job).await.map_err(ApiError::internal)?;
267
268    // Wait for the queue worker to process the job
269    let result = result_rx
270        .await
271        .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
272
273    match result {
274        Ok(job_result) => {
275            let img = job_result.image;
276            let response = job_result.response;
277            let content_type = match img.format {
278                OutputFormat::Png => HeaderValue::from_static("image/png"),
279                OutputFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
280            };
281            let mut headers = HeaderMap::new();
282            headers.insert(header::CONTENT_TYPE, content_type);
283            headers.insert(
284                "x-mold-seed-used",
285                HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
286                    ApiError::internal(format!("failed to serialize seed header: {e}"))
287                })?,
288            );
289            if let Some(warning) = dim_warning {
290                match HeaderValue::from_str(&warning.replace('\n', " ")) {
291                    Ok(val) => {
292                        headers.insert("x-mold-dimension-warning", val);
293                    }
294                    Err(e) => {
295                        tracing::warn!("dimension warning could not be encoded as header: {e}");
296                    }
297                }
298            }
299            Ok((headers, img.data))
300        }
301        Err(err_msg) => Err(ApiError::inference(err_msg)),
302    }
303}
304
305fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
306    mold_core::validate_generate_request(req)
307}
308
309async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
310    if req.embed_metadata.is_some() {
311        return;
312    }
313
314    let config = state.config.read().await;
315    req.embed_metadata = Some(config.effective_embed_metadata(None));
316}
317
318/// Apply prompt expansion if `expand: true` is set on a generate request.
319async fn maybe_expand_prompt(
320    state: &AppState,
321    req: &mut mold_core::GenerateRequest,
322) -> Result<(), ApiError> {
323    if req.expand != Some(true) {
324        return Ok(());
325    }
326
327    let config = state.config.read().await;
328    let expand_settings = config.expand.clone().with_env_overrides();
329
330    // Resolve model family for prompt style
331    let model_family = config
332        .resolved_model_config(&req.model)
333        .family
334        .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
335        .unwrap_or_else(|| {
336            tracing::warn!(
337                model = %req.model,
338                "could not resolve model family for prompt expansion, defaulting to \"flux\""
339            );
340            "flux".to_string()
341        });
342
343    let expand_config = expand_settings.to_expand_config(&model_family, 1);
344    let original_prompt = req.prompt.clone();
345
346    // Drop config lock before blocking
347    drop(config);
348
349    let expander = create_server_expander(&expand_settings)?;
350    let result =
351        tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
352            .await
353            .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
354            .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
355
356    if let Some(expanded) = result.expanded.first() {
357        req.original_prompt = Some(req.prompt.clone());
358        req.prompt = expanded.clone();
359    }
360
361    Ok(())
362}
363
364/// Create the appropriate expander for server-side use.
365fn create_server_expander(
366    settings: &mold_core::ExpandSettings,
367) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
368    if let Some(api_expander) = settings.create_api_expander() {
369        return Ok(Box::new(api_expander));
370    }
371
372    #[cfg(feature = "expand")]
373    {
374        let config = mold_core::Config::load_or_default();
375        if let Some(local) =
376            mold_inference::expand::LocalExpander::from_config(&config, Some(&settings.model))
377        {
378            return Ok(Box::new(local));
379        }
380        return Err(ApiError::validation(
381            "local expand model not found — run: mold pull qwen3-expand".to_string(),
382        ));
383    }
384
385    #[cfg(not(feature = "expand"))]
386    {
387        Err(ApiError::validation(
388            "local prompt expansion not available — built without expand feature. \
389             Configure an API backend in [expand] settings."
390                .to_string(),
391        ))
392    }
393}
394
395// ── /api/expand ──────────────────────────────────────────────────────────────
396
397#[utoipa::path(
398    post,
399    path = "/api/expand",
400    tag = "generation",
401    request_body = mold_core::ExpandRequest,
402    responses(
403        (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
404        (status = 422, description = "Invalid request parameters"),
405        (status = 500, description = "Expansion failed"),
406    )
407)]
408async fn expand_prompt(
409    State(state): State<AppState>,
410    Json(req): Json<mold_core::ExpandRequest>,
411) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
412    if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
413        return Err(ApiError::validation(format!(
414            "variations must be between 1 and {}",
415            mold_core::expand::MAX_VARIATIONS,
416        )));
417    }
418
419    let config = state.config.read().await;
420    let expand_settings = config.expand.clone().with_env_overrides();
421    let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
422    let prompt = req.prompt.clone();
423    drop(config);
424
425    let expander = create_server_expander(&expand_settings)?;
426    let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
427        .await
428        .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
429        .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
430
431    Ok(Json(mold_core::ExpandResponse {
432        original: req.prompt,
433        expanded: result.expanded,
434    }))
435}
436
437// ── /api/generate/stream (SSE) ───────────────────────────────────────────────
438
439#[utoipa::path(
440    post,
441    path = "/api/generate/stream",
442    tag = "generation",
443    request_body = mold_core::GenerateRequest,
444    responses(
445        (status = 200, description = "SSE event stream with progress and result"),
446        (status = 404, description = "Model not downloaded"),
447        (status = 422, description = "Invalid request parameters"),
448        (status = 500, description = "Inference error"),
449    )
450)]
451async fn generate_stream(
452    State(state): State<AppState>,
453    Json(mut req): Json<mold_core::GenerateRequest>,
454) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
455    let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
456
457    tracing::info!(
458        model = %req.model,
459        prompt = %req.prompt,
460        "generate/stream request"
461    );
462
463    // Create SSE channel
464    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
465
466    // Send dimension warning before queuing so the client sees it early
467    if let Some(warning) = dim_warning {
468        let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
469            message: warning,
470        }));
471    }
472
473    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
474    let job = GenerationJob {
475        request: req,
476        progress_tx: Some(tx.clone()),
477        result_tx,
478        output_dir,
479    };
480
481    let position = state.queue.submit(job).await.map_err(ApiError::internal)?;
482
483    // Send initial queue position to the client
484    let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
485
486    // Hold `tx` alive in a background task until the job completes, so the SSE
487    // stream never closes prematurely even if the queue worker hasn't received
488    // the job yet.
489    tokio::spawn(async move {
490        let _ = result_rx.await;
491        drop(tx); // closes the SSE stream
492    });
493
494    // Build SSE stream from the channel receiver.
495    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
496        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
497
498    Ok(Sse::new(stream).keep_alive(
499        KeepAlive::new()
500            .interval(std::time::Duration::from_secs(15))
501            .text("ping"),
502    ))
503}
504
505// ── /api/models ───────────────────────────────────────────────────────────────
506
507#[utoipa::path(
508    get,
509    path = "/api/models",
510    tag = "models",
511    responses(
512        (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
513    )
514)]
515async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
516    Json(model_manager::list_models(&state).await)
517}
518
519// ── /api/models/load ──────────────────────────────────────────────────────────
520
521#[derive(Debug, Deserialize, utoipa::ToSchema)]
522pub struct LoadModelBody {
523    #[schema(example = "flux-schnell:q8")]
524    pub model: String,
525}
526
527#[utoipa::path(
528    post,
529    path = "/api/models/load",
530    tag = "models",
531    request_body = LoadModelBody,
532    responses(
533        (status = 200, description = "Model loaded successfully"),
534        (status = 404, description = "Model not downloaded"),
535        (status = 400, description = "Unknown model"),
536        (status = 500, description = "Failed to load model"),
537    )
538)]
539async fn load_model(
540    State(state): State<AppState>,
541    Json(body): Json<LoadModelBody>,
542) -> Result<impl IntoResponse, ApiError> {
543    model_manager::ensure_model_ready(&state, &body.model, None).await?;
544    tracing::info!(model = %body.model, "model loaded via API");
545    Ok(StatusCode::OK)
546}
547
548// ── /api/models/pull ──────────────────────────────────────────────────────────
549
550#[utoipa::path(
551    post,
552    path = "/api/models/pull",
553    tag = "models",
554    request_body = LoadModelBody,
555    responses(
556        (status = 200, description = "Model pulled (SSE stream or plain text)"),
557        (status = 400, description = "Unknown model"),
558        (status = 500, description = "Download failed"),
559    )
560)]
561async fn pull_model_endpoint(
562    State(state): State<AppState>,
563    headers: HeaderMap,
564    Json(body): Json<LoadModelBody>,
565) -> Result<impl IntoResponse, ApiError> {
566    let wants_sse = headers
567        .get(header::ACCEPT)
568        .and_then(|v| v.to_str().ok())
569        .is_some_and(|v| v.contains("text/event-stream"));
570
571    if !wants_sse {
572        // Legacy: blocking pull with plain text response
573        return pull_model_blocking(state, body.model)
574            .await
575            .map(PullResponse::Text);
576    }
577
578    // SSE streaming pull
579    let model = body.model.clone();
580
581    // Validate model exists in manifest before starting SSE
582    if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
583        .is_none()
584    {
585        return Err(ApiError::unknown_model(format!(
586            "unknown model '{model}'. Run 'mold list' to see available models."
587        )));
588    }
589
590    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
591
592    tokio::spawn(async move {
593        let progress_tx = tx.clone();
594        let model_for_cb = model.clone();
595        let callback =
596            std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
597                let sse_event = match event {
598                    mold_core::download::DownloadProgressEvent::FileStart {
599                        filename,
600                        file_index,
601                        total_files,
602                        size_bytes,
603                    } => SseProgressEvent::DownloadProgress {
604                        filename,
605                        file_index,
606                        total_files,
607                        bytes_downloaded: 0,
608                        bytes_total: size_bytes,
609                    },
610                    mold_core::download::DownloadProgressEvent::FileProgress {
611                        filename,
612                        file_index,
613                        bytes_downloaded,
614                        bytes_total,
615                    } => SseProgressEvent::DownloadProgress {
616                        filename,
617                        file_index,
618                        total_files: 0,
619                        bytes_downloaded,
620                        bytes_total,
621                    },
622                    mold_core::download::DownloadProgressEvent::FileDone {
623                        filename,
624                        file_index,
625                        total_files,
626                    } => SseProgressEvent::DownloadDone {
627                        filename,
628                        file_index,
629                        total_files,
630                    },
631                };
632                let _ = progress_tx.send(SseMessage::Progress(sse_event));
633            });
634
635        match model_manager::pull_model(&state, &model, Some(callback)).await {
636            Ok(model_manager::PullStatus::AlreadyAvailable) => {
637                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
638                    model: model_for_cb,
639                }));
640            }
641            Ok(model_manager::PullStatus::Pulled) => {
642                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
643                    model: model_for_cb,
644                }));
645            }
646            Err(e) => {
647                let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
648            }
649        }
650    });
651
652    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
653        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
654
655    Ok(PullResponse::Sse(
656        Sse::new(stream)
657            .keep_alive(
658                KeepAlive::new()
659                    .interval(std::time::Duration::from_secs(15))
660                    .text("ping"),
661            )
662            .into_response(),
663    ))
664}
665
666/// Legacy blocking pull — returns plain text.
667async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
668    match model_manager::pull_model(&state, &model, None).await? {
669        model_manager::PullStatus::AlreadyAvailable => {
670            Ok(format!("model '{}' already available", model))
671        }
672        model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
673    }
674}
675
676/// Response type that can be either SSE stream or plain text.
677enum PullResponse {
678    Sse(axum::response::Response),
679    Text(String),
680}
681
682impl IntoResponse for PullResponse {
683    fn into_response(self) -> axum::response::Response {
684        match self {
685            PullResponse::Sse(resp) => resp,
686            PullResponse::Text(text) => text.into_response(),
687        }
688    }
689}
690
691// ── /api/models/unload ────────────────────────────────────────────────────────
692
693#[utoipa::path(
694    delete,
695    path = "/api/models/unload",
696    tag = "models",
697    responses(
698        (status = 200, description = "Model unloaded or no model was loaded", body = String),
699    )
700)]
701async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
702    Ok((StatusCode::OK, model_manager::unload_model(&state).await))
703}
704
705// ── /api/status ───────────────────────────────────────────────────────────────
706
707#[utoipa::path(
708    get,
709    path = "/api/status",
710    tag = "server",
711    responses(
712        (status = 200, description = "Server status", body = ServerStatus),
713    )
714)]
715async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
716    let snapshot = state.engine_snapshot.read().await.clone();
717    let models_loaded = match (snapshot.model_name, snapshot.is_loaded) {
718        (Some(model_name), true) => vec![model_name],
719        _ => vec![],
720    };
721    let current_generation = state
722        .active_generation
723        .read()
724        .unwrap_or_else(|e| e.into_inner())
725        .as_ref()
726        .map(|active| ActiveGenerationStatus {
727            model: active.model.clone(),
728            prompt_sha256: active.prompt_sha256.clone(),
729            started_at_unix_ms: active.started_at_unix_ms,
730            elapsed_ms: active.started_at.elapsed().as_millis() as u64,
731        });
732    let busy = current_generation.is_some();
733
734    Json(ServerStatus {
735        version: env!("CARGO_PKG_VERSION").to_string(),
736        git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
737            None
738        } else {
739            Some(mold_core::build_info::GIT_SHA.to_string())
740        },
741        build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
742            None
743        } else {
744            Some(mold_core::build_info::BUILD_DATE.to_string())
745        },
746        models_loaded,
747        busy,
748        current_generation,
749        gpu_info: query_gpu_info(),
750        uptime_secs: state.start_time.elapsed().as_secs(),
751    })
752}
753
754// ── /health ───────────────────────────────────────────────────────────────────
755
756#[utoipa::path(
757    get,
758    path = "/health",
759    tag = "server",
760    responses(
761        (status = 200, description = "Server is healthy"),
762    )
763)]
764async fn health() -> impl IntoResponse {
765    StatusCode::OK
766}
767
768// ── /api/openapi.json ─────────────────────────────────────────────────────────
769
770async fn openapi_json() -> impl IntoResponse {
771    Json(ApiDoc::openapi())
772}
773
774// ── /api/docs ─────────────────────────────────────────────────────────────────
775
776async fn scalar_docs() -> impl IntoResponse {
777    (
778        [(header::CONTENT_TYPE, "text/html")],
779        r#"<!DOCTYPE html>
780<html>
781<head>
782  <title>mold API</title>
783  <meta charset="utf-8" />
784  <meta name="viewport" content="width=device-width, initial-scale=1" />
785</head>
786<body>
787  <script id="api-reference" data-url="/api/openapi.json"></script>
788  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
789</body>
790</html>"#,
791    )
792}
793
794// ── GPU info ──────────────────────────────────────────────────────────────────
795
796fn query_gpu_info() -> Option<GpuInfo> {
797    let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
798        "/run/current-system/sw/bin/nvidia-smi"
799    } else {
800        "nvidia-smi"
801    };
802
803    let output = std::process::Command::new(nvidia_smi)
804        .args([
805            "--query-gpu=name,memory.total,memory.used",
806            "--format=csv,noheader,nounits",
807        ])
808        .output()
809        .ok()?;
810
811    if !output.status.success() {
812        return None;
813    }
814
815    let text = String::from_utf8(output.stdout).ok()?;
816    let line = text.lines().next()?;
817    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
818    if parts.len() < 3 {
819        return None;
820    }
821
822    Some(GpuInfo {
823        name: parts[0].to_string(),
824        vram_total_mb: parts[1].parse().ok()?,
825        vram_used_mb: parts[2].parse().ok()?,
826    })
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn clean_error_message_strips_backtrace() {
835        let err = anyhow::anyhow!(
836            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
837             \x20  0: candle_core::error::Error::bt\n\
838             \x20           at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
839             \x20  1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
840             \x20           at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
841        );
842        let msg = clean_error_message(&err);
843        assert_eq!(
844            msg,
845            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
846        );
847    }
848
849    #[test]
850    fn clean_error_message_preserves_simple_error() {
851        let err = anyhow::anyhow!("model not found: flux-dev:q4");
852        let msg = clean_error_message(&err);
853        assert_eq!(msg, "model not found: flux-dev:q4");
854    }
855
856    #[test]
857    fn clean_error_message_preserves_multiline_without_backtrace() {
858        let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
859        let msg = clean_error_message(&err);
860        assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
861    }
862
863    #[test]
864    fn clean_error_message_strips_high_numbered_frames() {
865        let err = anyhow::anyhow!(
866            "some error\n\
867             \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
868             \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
869        );
870        let msg = clean_error_message(&err);
871        assert_eq!(msg, "some error");
872    }
873
874    #[test]
875    fn clean_error_message_empty_fallback() {
876        // An error whose Display starts immediately with a backtrace-like line
877        let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
878        let msg = clean_error_message(&err);
879        // Should fall back to root_cause since all lines look like backtrace
880        assert!(!msg.is_empty());
881    }
882
883    #[test]
884    fn save_image_to_dir_creates_directory_and_writes_file() {
885        let dir = std::env::temp_dir().join(format!(
886            "mold-save-test-{}",
887            std::time::SystemTime::now()
888                .duration_since(std::time::UNIX_EPOCH)
889                .unwrap()
890                .as_nanos()
891        ));
892        assert!(!dir.exists());
893
894        let img = mold_core::ImageData {
895            data: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
896            format: mold_core::OutputFormat::Png,
897            width: 64,
898            height: 64,
899            index: 0,
900        };
901
902        save_image_to_dir(&dir, &img, "test-model:q8", 1);
903
904        assert!(dir.exists(), "directory should be created");
905        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
906        assert_eq!(files.len(), 1, "should have exactly one file");
907        let file = files[0].as_ref().unwrap();
908        let filename = file.file_name().to_str().unwrap().to_string();
909        assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
910        assert!(filename.ends_with(".png"), "{filename}");
911        let contents = std::fs::read(file.path()).unwrap();
912        assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
913
914        std::fs::remove_dir_all(&dir).ok();
915    }
916
917    #[test]
918    fn save_image_to_dir_batch_includes_index() {
919        let dir = std::env::temp_dir().join(format!(
920            "mold-save-batch-{}",
921            std::time::SystemTime::now()
922                .duration_since(std::time::UNIX_EPOCH)
923                .unwrap()
924                .as_nanos()
925        ));
926
927        let img = mold_core::ImageData {
928            data: vec![0xFF, 0xD8], // JPEG magic
929            format: mold_core::OutputFormat::Jpeg,
930            width: 64,
931            height: 64,
932            index: 2,
933        };
934
935        save_image_to_dir(&dir, &img, "flux-dev", 4);
936
937        let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
938        assert_eq!(files.len(), 1);
939        let filename = files[0]
940            .as_ref()
941            .unwrap()
942            .file_name()
943            .to_str()
944            .unwrap()
945            .to_string();
946        assert!(
947            filename.contains("-2.jpeg"),
948            "batch index in name: {filename}"
949        );
950
951        std::fs::remove_dir_all(&dir).ok();
952    }
953
954    #[test]
955    fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
956        // Saving to a path that can't be created should not panic
957        let img = mold_core::ImageData {
958            data: vec![0x00],
959            format: mold_core::OutputFormat::Png,
960            width: 1,
961            height: 1,
962            index: 0,
963        };
964        // /dev/null/impossible can't be created as a directory
965        save_image_to_dir(
966            std::path::Path::new("/dev/null/impossible"),
967            &img,
968            "test",
969            1,
970        );
971        // Test passes if no panic occurred
972    }
973}