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