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#[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#[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 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
155fn 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 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
204async 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 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#[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)]
257async 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 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 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
340async 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 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);
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
386fn 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#[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#[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 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
487
488 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 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
507
508 tokio::spawn(async move {
512 let _ = result_rx.await;
513 drop(tx); });
515
516 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#[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#[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#[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 return pull_model_blocking(state, body.model)
596 .await
597 .map(PullResponse::Text);
598 }
599
600 let model = body.model.clone();
602
603 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
709async 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
719enum 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#[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#[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#[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#[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 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
856async 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
881async 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 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
928async 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 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
962async 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 let thumb_dir = server_thumbnail_dir();
993 let thumb_path = thumb_dir.join(&clean_name);
994
995 if !thumb_path.is_file() {
996 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
1015fn 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
1023fn 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
1037pub 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
1073fn 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
1124fn 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
1150fn 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 0xD8 | 0x01 => {
1163 i += 2;
1164 }
1165 0xD9 => break, 0xD0..=0xD7 => {
1167 i += 2; }
1169 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 _ => {
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
1204async fn openapi_json() -> impl IntoResponse {
1207 Json(ApiDoc::openapi())
1208}
1209
1210async 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
1230fn 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 let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
1314 let msg = clean_error_message(&err);
1315 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], 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], 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 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 save_image_to_dir(
1402 std::path::Path::new("/dev/null/impossible"),
1403 &img,
1404 "test",
1405 1,
1406 );
1407 }
1409}