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 base64::Engine as _;
12use mold_core::{
13 ActiveGenerationStatus, GpuInfo, ModelInfoExtended, ServerStatus, SseErrorEvent,
14 SseProgressEvent,
15};
16use serde::{Deserialize, Serialize};
17use std::convert::Infallible;
18use tokio_stream::StreamExt as _;
19use utoipa::OpenApi;
20
21use crate::model_manager;
22use crate::state::{AppState, GenerationJob, SseMessage};
23
24#[derive(Debug, Serialize)]
27pub struct ApiError {
28 pub error: String,
29 pub code: String,
30 #[serde(skip)]
31 status: StatusCode,
32}
33
34impl ApiError {
35 pub fn validation(msg: impl Into<String>) -> Self {
36 Self {
37 error: msg.into(),
38 code: "VALIDATION_ERROR".to_string(),
39 status: StatusCode::UNPROCESSABLE_ENTITY,
40 }
41 }
42
43 pub fn not_found(msg: impl Into<String>) -> Self {
44 Self {
45 error: msg.into(),
46 code: "MODEL_NOT_FOUND".to_string(),
47 status: StatusCode::NOT_FOUND,
48 }
49 }
50
51 pub fn unknown_model(msg: impl Into<String>) -> Self {
52 Self {
53 error: msg.into(),
54 code: "UNKNOWN_MODEL".to_string(),
55 status: StatusCode::BAD_REQUEST,
56 }
57 }
58
59 pub fn inference(msg: impl Into<String>) -> Self {
60 Self {
61 error: msg.into(),
62 code: "INFERENCE_ERROR".to_string(),
63 status: StatusCode::INTERNAL_SERVER_ERROR,
64 }
65 }
66
67 pub fn internal(msg: impl Into<String>) -> Self {
68 Self {
69 error: msg.into(),
70 code: "INTERNAL_ERROR".to_string(),
71 status: StatusCode::INTERNAL_SERVER_ERROR,
72 }
73 }
74
75 pub fn insufficient_memory(msg: impl Into<String>) -> Self {
76 Self {
77 error: msg.into(),
78 code: "INSUFFICIENT_MEMORY".to_string(),
79 status: StatusCode::SERVICE_UNAVAILABLE,
80 }
81 }
82}
83
84impl IntoResponse for ApiError {
85 fn into_response(self) -> axum::response::Response {
86 let status = self.status;
87 (status, Json(self)).into_response()
88 }
89}
90
91#[cfg(test)]
93use crate::queue::clean_error_message;
94
95#[derive(OpenApi)]
96#[openapi(
97 paths(generate, generate_stream, expand_prompt, list_models, load_model, pull_model_endpoint, unload_model, server_status, health),
98 components(schemas(
99 mold_core::GenerateRequest,
100 mold_core::GenerateResponse,
101 mold_core::ExpandRequest,
102 mold_core::ExpandResponse,
103 mold_core::ImageData,
104 mold_core::OutputFormat,
105 mold_core::ModelInfo,
106 mold_core::ServerStatus,
107 mold_core::ActiveGenerationStatus,
108 mold_core::GpuInfo,
109 mold_core::SseProgressEvent,
110 mold_core::SseCompleteEvent,
111 mold_core::SseErrorEvent,
112 ModelInfoExtended,
113 LoadModelBody,
114 )),
115 tags(
116 (name = "generation", description = "Image generation"),
117 (name = "models", description = "Model management"),
118 (name = "server", description = "Server status and health"),
119 ),
120 info(
121 title = "mold",
122 description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
123 version = env!("CARGO_PKG_VERSION"),
124 )
125)]
126pub struct ApiDoc;
127
128pub fn create_router(state: AppState) -> Router {
129 Router::new()
132 .route("/api/generate", post(generate))
133 .route("/api/generate/stream", post(generate_stream))
134 .route("/api/expand", post(expand_prompt))
135 .route("/api/models", get(list_models))
136 .route("/api/models/load", post(load_model))
137 .route("/api/models/pull", post(pull_model_endpoint))
138 .route("/api/models/unload", delete(unload_model))
139 .route("/api/gallery", get(list_gallery))
140 .route(
141 "/api/gallery/image/:filename",
142 get(get_gallery_image).delete(delete_gallery_image),
143 )
144 .route(
145 "/api/gallery/thumbnail/:filename",
146 get(get_gallery_thumbnail),
147 )
148 .route("/api/upscale", post(upscale))
149 .route("/api/upscale/stream", post(upscale_stream))
150 .route("/api/status", get(server_status))
151 .route("/api/shutdown", post(shutdown_server))
152 .route("/health", get(health))
153 .with_state(state)
154 .route("/api/openapi.json", get(openapi_json))
155 .route("/api/docs", get(scalar_docs))
156}
157
158fn sse_message_to_event(msg: SseMessage) -> SseEvent {
161 fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
162 match serde_json::to_string(payload) {
163 Ok(data) => SseEvent::default().event(event_name).data(data),
164 Err(err) => SseEvent::default().event("error").data(
165 serde_json::json!({
166 "message": format!("failed to serialize SSE payload: {err}")
167 })
168 .to_string(),
169 ),
170 }
171 }
172
173 match msg {
174 SseMessage::Progress(payload) => serialize_event("progress", &payload),
175 SseMessage::Complete(payload) => serialize_event("complete", &payload),
176 SseMessage::UpscaleComplete(payload) => serialize_event("complete", &payload),
177 SseMessage::Error(payload) => serialize_event("error", &payload),
178 }
179}
180
181#[cfg(test)]
182fn save_image_to_dir(
183 dir: &std::path::Path,
184 img: &mold_core::ImageData,
185 model: &str,
186 batch_size: u32,
187) {
188 if let Err(e) = std::fs::create_dir_all(dir) {
189 tracing::warn!("failed to create output dir {}: {e}", dir.display());
190 return;
191 }
192 let timestamp_ms = std::time::SystemTime::now()
195 .duration_since(std::time::UNIX_EPOCH)
196 .unwrap_or_default()
197 .as_millis() as u64;
198 let ext = img.format.to_string();
199 let filename =
200 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
201 let path = dir.join(&filename);
202 match std::fs::write(&path, &img.data) {
203 Ok(()) => tracing::info!("saved image to {}", path.display()),
204 Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
205 }
206}
207
208async fn prepare_generation(
216 state: &AppState,
217 request: &mut mold_core::GenerateRequest,
218) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
219 apply_default_metadata_setting(state, request).await;
220
221 maybe_expand_prompt(state, request).await?;
223
224 if let Err(e) = validate_generate_request(request) {
225 return Err(ApiError::validation(e));
226 }
227
228 let _ = model_manager::check_model_available(state, &request.model).await?;
229
230 let (output_dir, dim_warning) = {
231 let config = state.config.read().await;
232 let output_dir = if config.is_output_disabled() {
233 None
234 } else {
235 Some(config.effective_output_dir())
236 };
237 let family = config.resolved_model_config(&request.model).family;
238 let dim_warning = family
239 .as_deref()
240 .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
241 (output_dir, dim_warning)
242 };
243
244 Ok((output_dir, dim_warning))
245}
246
247#[utoipa::path(
250 post,
251 path = "/api/generate",
252 tag = "generation",
253 request_body = mold_core::GenerateRequest,
254 responses(
255 (status = 200, description = "Generated image bytes", content_type = "image/png"),
256 (status = 404, description = "Model not downloaded"),
257 (status = 422, description = "Invalid request parameters"),
258 (status = 500, description = "Inference error"),
259 )
260)]
261async fn generate(
264 State(state): State<AppState>,
265 Json(mut req): Json<mold_core::GenerateRequest>,
266) -> Result<impl IntoResponse, ApiError> {
267 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
268
269 tracing::info!(
270 model = %req.model,
271 prompt = %req.prompt,
272 width = req.width,
273 height = req.height,
274 steps = req.steps,
275 guidance = req.guidance,
276 seed = ?req.seed,
277 format = %req.output_format,
278 lora = ?req.lora.as_ref().map(|l| &l.path),
279 lora_scale = ?req.lora.as_ref().map(|l| l.scale),
280 "generate request"
281 );
282
283 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
285 let job = GenerationJob {
286 request: req,
287 progress_tx: None,
288 result_tx,
289 output_dir,
290 };
291
292 let _position = state.queue.submit(job).await.map_err(ApiError::internal)?;
293
294 let result = result_rx
296 .await
297 .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
298
299 match result {
300 Ok(job_result) => {
301 let img = job_result.image;
302 let response = job_result.response;
303 let content_type = HeaderValue::from_static(img.format.content_type());
304 let mut headers = HeaderMap::new();
305 headers.insert(header::CONTENT_TYPE, content_type);
306 headers.insert(
307 "x-mold-seed-used",
308 HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
309 ApiError::internal(format!("failed to serialize seed header: {e}"))
310 })?,
311 );
312 if let Some(warning) = dim_warning {
313 match HeaderValue::from_str(&warning.replace('\n', " ")) {
314 Ok(val) => {
315 headers.insert("x-mold-dimension-warning", val);
316 }
317 Err(e) => {
318 tracing::warn!("dimension warning could not be encoded as header: {e}");
319 }
320 }
321 }
322 let output_data = if let Some(ref video) = response.video {
324 let ct = HeaderValue::from_static(video.format.content_type());
325 headers.insert(header::CONTENT_TYPE, ct);
326 video.data.clone()
327 } else {
328 img.data
329 };
330 Ok((headers, output_data))
331 }
332 Err(err_msg) => Err(ApiError::inference(err_msg)),
333 }
334}
335
336fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
337 mold_core::validate_generate_request(req)
338}
339
340async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
341 if req.embed_metadata.is_some() {
342 return;
343 }
344
345 let config = state.config.read().await;
346 req.embed_metadata = Some(config.effective_embed_metadata(None));
347}
348
349async fn maybe_expand_prompt(
351 state: &AppState,
352 req: &mut mold_core::GenerateRequest,
353) -> Result<(), ApiError> {
354 if req.expand != Some(true) {
355 return Ok(());
356 }
357
358 let config = state.config.read().await;
359 let expand_settings = config.expand.clone().with_env_overrides();
360
361 let model_family = config
363 .resolved_model_config(&req.model)
364 .family
365 .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
366 .unwrap_or_else(|| {
367 tracing::warn!(
368 model = %req.model,
369 "could not resolve model family for prompt expansion, defaulting to \"flux\""
370 );
371 "flux".to_string()
372 });
373
374 let expand_config = expand_settings.to_expand_config(&model_family, 1);
375 let original_prompt = req.prompt.clone();
376
377 drop(config);
379
380 let expander = create_server_expander(&expand_settings)?;
381 let result =
382 tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
383 .await
384 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
385 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
386
387 if let Some(expanded) = result.expanded.first() {
388 req.original_prompt = Some(req.prompt.clone());
389 req.prompt = expanded.clone();
390 }
391
392 Ok(())
393}
394
395fn create_server_expander(
397 settings: &mold_core::ExpandSettings,
398) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
399 if let Some(api_expander) = settings.create_api_expander() {
400 return Ok(Box::new(api_expander));
401 }
402
403 #[cfg(feature = "expand")]
404 {
405 let config = mold_core::Config::load_or_default();
406 if let Some(local) =
407 mold_inference::expand::LocalExpander::from_config(&config, Some(&settings.model))
408 {
409 return Ok(Box::new(local));
410 }
411 return Err(ApiError::validation(
412 "local expand model not found — run: mold pull qwen3-expand".to_string(),
413 ));
414 }
415
416 #[cfg(not(feature = "expand"))]
417 {
418 Err(ApiError::validation(
419 "local prompt expansion not available — built without expand feature. \
420 Configure an API backend in [expand] settings."
421 .to_string(),
422 ))
423 }
424}
425
426#[utoipa::path(
429 post,
430 path = "/api/expand",
431 tag = "generation",
432 request_body = mold_core::ExpandRequest,
433 responses(
434 (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
435 (status = 422, description = "Invalid request parameters"),
436 (status = 500, description = "Expansion failed"),
437 )
438)]
439async fn expand_prompt(
440 State(state): State<AppState>,
441 Json(req): Json<mold_core::ExpandRequest>,
442) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
443 if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
444 return Err(ApiError::validation(format!(
445 "variations must be between 1 and {}",
446 mold_core::expand::MAX_VARIATIONS,
447 )));
448 }
449
450 let config = state.config.read().await;
451 let expand_settings = config.expand.clone().with_env_overrides();
452 let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
453 let prompt = req.prompt.clone();
454 drop(config);
455
456 let expander = create_server_expander(&expand_settings)?;
457 let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
458 .await
459 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
460 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
461
462 Ok(Json(mold_core::ExpandResponse {
463 original: req.prompt,
464 expanded: result.expanded,
465 }))
466}
467
468async fn upscale(
471 State(state): State<AppState>,
472 Json(req): Json<mold_core::UpscaleRequest>,
473) -> Result<Json<mold_core::UpscaleResponse>, ApiError> {
474 if let Err(msg) = mold_core::validate_upscale_request(&req) {
475 return Err(ApiError::validation(msg));
476 }
477
478 let model_name = mold_core::manifest::resolve_model_name(&req.model);
479
480 let needs_pull = {
482 let config = state.config.read().await;
483 config
484 .models
485 .get(&model_name)
486 .and_then(|c| c.transformer.as_ref())
487 .is_none()
488 };
489 if needs_pull {
490 if mold_core::manifest::find_manifest(&model_name).is_none() {
491 return Err(ApiError::not_found(format!(
492 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
493 model_name
494 )));
495 }
496 model_manager::pull_model(&state, &model_name, None).await?;
497 }
498
499 let config = state.config.read().await;
500 let weights_path = config
501 .models
502 .get(&model_name)
503 .and_then(|c| c.transformer.as_ref())
504 .ok_or_else(|| {
505 ApiError::not_found(format!(
506 "upscaler model '{}' not configured after pull",
507 model_name
508 ))
509 })?;
510 let weights_path = std::path::PathBuf::from(weights_path);
511 let model_name_owned = model_name.clone();
512 drop(config);
513
514 let upscaler_cache = state.upscaler_cache.clone();
515 let resp =
516 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
517 let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
518
519 let needs_new = cache
521 .as_ref()
522 .is_none_or(|e| e.model_name() != model_name_owned);
523 if needs_new {
524 let new_engine = mold_inference::create_upscale_engine(
525 model_name_owned,
526 weights_path,
527 mold_inference::LoadStrategy::Eager,
528 )?;
529 *cache = Some(new_engine);
530 }
531
532 cache.as_mut().unwrap().upscale(&req)
533 })
534 .await
535 .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")))?
536 .map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?;
537
538 Ok(Json(resp))
539}
540
541async fn upscale_stream(
544 State(state): State<AppState>,
545 Json(req): Json<mold_core::UpscaleRequest>,
546) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
547 if let Err(msg) = mold_core::validate_upscale_request(&req) {
548 return Err(ApiError::validation(msg));
549 }
550
551 let model_name = mold_core::manifest::resolve_model_name(&req.model);
552
553 let needs_pull = {
555 let config = state.config.read().await;
556 config
557 .models
558 .get(&model_name)
559 .and_then(|c| c.transformer.as_ref())
560 .is_none()
561 };
562
563 if needs_pull && mold_core::manifest::find_manifest(&model_name).is_none() {
565 return Err(ApiError::not_found(format!(
566 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
567 model_name
568 )));
569 }
570
571 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
572 let model_name_owned = model_name.clone();
573 let state_clone = state.clone();
574 let upscaler_cache = state.upscaler_cache.clone();
575
576 tokio::spawn(async move {
577 if needs_pull {
579 let progress_tx = tx.clone();
580 let callback =
581 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
582 let sse_event = match event {
583 mold_core::download::DownloadProgressEvent::Status { message } => {
584 SseProgressEvent::Info { message }
585 }
586 mold_core::download::DownloadProgressEvent::FileStart {
587 filename,
588 file_index,
589 total_files,
590 size_bytes,
591 batch_bytes_downloaded,
592 batch_bytes_total,
593 batch_elapsed_ms,
594 } => SseProgressEvent::DownloadProgress {
595 filename,
596 file_index,
597 total_files,
598 bytes_downloaded: 0,
599 bytes_total: size_bytes,
600 batch_bytes_downloaded,
601 batch_bytes_total,
602 batch_elapsed_ms,
603 },
604 mold_core::download::DownloadProgressEvent::FileProgress {
605 filename,
606 file_index,
607 bytes_downloaded,
608 bytes_total,
609 batch_bytes_downloaded,
610 batch_bytes_total,
611 batch_elapsed_ms,
612 } => SseProgressEvent::DownloadProgress {
613 filename,
614 file_index,
615 total_files: 0,
616 bytes_downloaded,
617 bytes_total,
618 batch_bytes_downloaded,
619 batch_bytes_total,
620 batch_elapsed_ms,
621 },
622 mold_core::download::DownloadProgressEvent::FileDone {
623 filename,
624 file_index,
625 total_files,
626 batch_bytes_downloaded,
627 batch_bytes_total,
628 batch_elapsed_ms,
629 } => SseProgressEvent::DownloadDone {
630 filename,
631 file_index,
632 total_files,
633 batch_bytes_downloaded,
634 batch_bytes_total,
635 batch_elapsed_ms,
636 },
637 };
638 let _ = progress_tx.send(SseMessage::Progress(sse_event));
639 });
640
641 match model_manager::pull_model(&state_clone, &model_name_owned, Some(callback)).await {
642 Ok(_) => {
643 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
644 model: model_name_owned.clone(),
645 }));
646 }
647 Err(e) => {
648 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
649 message: format!("failed to pull upscaler model: {}", e.error),
650 }));
651 return;
652 }
653 }
654 }
655
656 let weights_path = {
658 let config = state_clone.config.read().await;
659 config
660 .models
661 .get(&model_name_owned)
662 .and_then(|c| c.transformer.as_ref())
663 .map(std::path::PathBuf::from)
664 };
665
666 let Some(weights_path) = weights_path else {
667 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
668 message: format!(
669 "upscaler model '{}' not configured after pull",
670 model_name_owned
671 ),
672 }));
673 return;
674 };
675
676 let result = tokio::task::spawn_blocking(move || {
677 let mut cache = upscaler_cache.lock().unwrap();
678
679 let needs_new = cache
680 .as_ref()
681 .is_none_or(|e| e.model_name() != model_name_owned);
682 if needs_new {
683 let _ = tx.send(SseMessage::Progress(
684 mold_core::SseProgressEvent::StageStart {
685 name: "Loading upscaler model".to_string(),
686 },
687 ));
688 match mold_inference::create_upscale_engine(
689 model_name_owned,
690 weights_path,
691 mold_inference::LoadStrategy::Eager,
692 ) {
693 Ok(new_engine) => {
694 *cache = Some(new_engine);
695 }
696 Err(e) => {
697 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
698 message: format!("failed to load upscaler: {e}"),
699 }));
700 return;
701 }
702 }
703 }
704
705 let engine = cache.as_mut().unwrap();
706
707 let tx_progress = tx.clone();
709 engine.set_on_progress(Box::new(move |event| {
710 let sse_event: mold_core::SseProgressEvent = event.into();
711 let _ = tx_progress.send(SseMessage::Progress(sse_event));
712 }));
713
714 match engine.upscale(&req) {
715 Ok(resp) => {
716 let image_b64 =
717 base64::engine::general_purpose::STANDARD.encode(&resp.image.data);
718 let _ = tx.send(SseMessage::UpscaleComplete(
719 mold_core::SseUpscaleCompleteEvent {
720 image: image_b64,
721 format: resp.image.format,
722 model: resp.model,
723 scale_factor: resp.scale_factor,
724 original_width: resp.original_width,
725 original_height: resp.original_height,
726 upscale_time_ms: resp.upscale_time_ms,
727 },
728 ));
729 }
730 Err(e) => {
731 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
732 message: format!("upscale failed: {e}"),
733 }));
734 }
735 }
736
737 engine.clear_on_progress();
738 })
739 .await;
740
741 if let Err(e) = result {
742 tracing::error!("upscale task panicked: {e}");
743 }
744 });
745
746 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
747 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
748
749 Ok(Sse::new(stream).keep_alive(
750 KeepAlive::new()
751 .interval(std::time::Duration::from_secs(15))
752 .text("ping"),
753 ))
754}
755
756#[utoipa::path(
759 post,
760 path = "/api/generate/stream",
761 tag = "generation",
762 request_body = mold_core::GenerateRequest,
763 responses(
764 (status = 200, description = "SSE event stream with progress and result"),
765 (status = 404, description = "Model not downloaded"),
766 (status = 422, description = "Invalid request parameters"),
767 (status = 500, description = "Inference error"),
768 )
769)]
770async fn generate_stream(
771 State(state): State<AppState>,
772 Json(mut req): Json<mold_core::GenerateRequest>,
773) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
774 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
775
776 tracing::info!(
777 model = %req.model,
778 prompt = %req.prompt,
779 "generate/stream request"
780 );
781
782 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
784
785 if let Some(warning) = dim_warning {
787 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
788 message: warning,
789 }));
790 }
791
792 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
793 let job = GenerationJob {
794 request: req,
795 progress_tx: Some(tx.clone()),
796 result_tx,
797 output_dir,
798 };
799
800 let position = state.queue.submit(job).await.map_err(ApiError::internal)?;
801
802 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
804
805 tokio::spawn(async move {
809 let _ = result_rx.await;
810 drop(tx); });
812
813 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
815 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
816
817 Ok(Sse::new(stream).keep_alive(
818 KeepAlive::new()
819 .interval(std::time::Duration::from_secs(15))
820 .text("ping"),
821 ))
822}
823
824#[utoipa::path(
827 get,
828 path = "/api/models",
829 tag = "models",
830 responses(
831 (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
832 )
833)]
834async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
835 Json(model_manager::list_models(&state).await)
836}
837
838#[derive(Debug, Deserialize, utoipa::ToSchema)]
841pub struct LoadModelBody {
842 #[schema(example = "flux-schnell:q8")]
843 pub model: String,
844}
845
846#[utoipa::path(
847 post,
848 path = "/api/models/load",
849 tag = "models",
850 request_body = LoadModelBody,
851 responses(
852 (status = 200, description = "Model loaded successfully"),
853 (status = 404, description = "Model not downloaded"),
854 (status = 400, description = "Unknown model"),
855 (status = 500, description = "Failed to load model"),
856 )
857)]
858async fn load_model(
859 State(state): State<AppState>,
860 Json(body): Json<LoadModelBody>,
861) -> Result<impl IntoResponse, ApiError> {
862 model_manager::ensure_model_ready(&state, &body.model, None).await?;
863 tracing::info!(model = %body.model, "model loaded via API");
864 Ok(StatusCode::OK)
865}
866
867#[utoipa::path(
870 post,
871 path = "/api/models/pull",
872 tag = "models",
873 request_body = LoadModelBody,
874 responses(
875 (status = 200, description = "Model pulled (SSE stream or plain text)"),
876 (status = 400, description = "Unknown model"),
877 (status = 500, description = "Download failed"),
878 )
879)]
880async fn pull_model_endpoint(
881 State(state): State<AppState>,
882 headers: HeaderMap,
883 Json(body): Json<LoadModelBody>,
884) -> Result<impl IntoResponse, ApiError> {
885 let wants_sse = headers
886 .get(header::ACCEPT)
887 .and_then(|v| v.to_str().ok())
888 .is_some_and(|v| v.contains("text/event-stream"));
889
890 if !wants_sse {
891 return pull_model_blocking(state, body.model)
893 .await
894 .map(PullResponse::Text);
895 }
896
897 let model = body.model.clone();
899
900 if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
902 .is_none()
903 {
904 return Err(ApiError::unknown_model(format!(
905 "unknown model '{model}'. Run 'mold list' to see available models."
906 )));
907 }
908
909 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
910
911 tokio::spawn(async move {
912 let progress_tx = tx.clone();
913 let model_for_cb = model.clone();
914 let callback =
915 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
916 let sse_event = match event {
917 mold_core::download::DownloadProgressEvent::Status { message } => {
918 SseProgressEvent::Info { message }
919 }
920 mold_core::download::DownloadProgressEvent::FileStart {
921 filename,
922 file_index,
923 total_files,
924 size_bytes,
925 batch_bytes_downloaded,
926 batch_bytes_total,
927 batch_elapsed_ms,
928 } => SseProgressEvent::DownloadProgress {
929 filename,
930 file_index,
931 total_files,
932 bytes_downloaded: 0,
933 bytes_total: size_bytes,
934 batch_bytes_downloaded,
935 batch_bytes_total,
936 batch_elapsed_ms,
937 },
938 mold_core::download::DownloadProgressEvent::FileProgress {
939 filename,
940 file_index,
941 bytes_downloaded,
942 bytes_total,
943 batch_bytes_downloaded,
944 batch_bytes_total,
945 batch_elapsed_ms,
946 } => SseProgressEvent::DownloadProgress {
947 filename,
948 file_index,
949 total_files: 0,
950 bytes_downloaded,
951 bytes_total,
952 batch_bytes_downloaded,
953 batch_bytes_total,
954 batch_elapsed_ms,
955 },
956 mold_core::download::DownloadProgressEvent::FileDone {
957 filename,
958 file_index,
959 total_files,
960 batch_bytes_downloaded,
961 batch_bytes_total,
962 batch_elapsed_ms,
963 } => SseProgressEvent::DownloadDone {
964 filename,
965 file_index,
966 total_files,
967 batch_bytes_downloaded,
968 batch_bytes_total,
969 batch_elapsed_ms,
970 },
971 };
972 let _ = progress_tx.send(SseMessage::Progress(sse_event));
973 });
974
975 match model_manager::pull_model(&state, &model, Some(callback)).await {
976 Ok(model_manager::PullStatus::AlreadyAvailable) => {
977 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
978 model: model_for_cb,
979 }));
980 }
981 Ok(model_manager::PullStatus::Pulled) => {
982 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
983 model: model_for_cb,
984 }));
985 }
986 Err(e) => {
987 let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
988 }
989 }
990 });
991
992 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
993 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
994
995 Ok(PullResponse::Sse(
996 Sse::new(stream)
997 .keep_alive(
998 KeepAlive::new()
999 .interval(std::time::Duration::from_secs(15))
1000 .text("ping"),
1001 )
1002 .into_response(),
1003 ))
1004}
1005
1006async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
1008 match model_manager::pull_model(&state, &model, None).await? {
1009 model_manager::PullStatus::AlreadyAvailable => {
1010 Ok(format!("model '{}' already available", model))
1011 }
1012 model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
1013 }
1014}
1015
1016enum PullResponse {
1018 Sse(axum::response::Response),
1019 Text(String),
1020}
1021
1022impl IntoResponse for PullResponse {
1023 fn into_response(self) -> axum::response::Response {
1024 match self {
1025 PullResponse::Sse(resp) => resp,
1026 PullResponse::Text(text) => text.into_response(),
1027 }
1028 }
1029}
1030
1031#[utoipa::path(
1034 delete,
1035 path = "/api/models/unload",
1036 tag = "models",
1037 responses(
1038 (status = 200, description = "Model unloaded or no model was loaded", body = String),
1039 )
1040)]
1041async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
1042 Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1043}
1044
1045#[utoipa::path(
1048 get,
1049 path = "/api/status",
1050 tag = "server",
1051 responses(
1052 (status = 200, description = "Server status", body = ServerStatus),
1053 )
1054)]
1055async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
1056 let snapshot = state.engine_snapshot.read().await.clone();
1057 let models_loaded = match (snapshot.model_name, snapshot.is_loaded) {
1058 (Some(model_name), true) => vec![model_name],
1059 _ => vec![],
1060 };
1061 let current_generation = state
1062 .active_generation
1063 .read()
1064 .unwrap_or_else(|e| e.into_inner())
1065 .as_ref()
1066 .map(|active| ActiveGenerationStatus {
1067 model: active.model.clone(),
1068 prompt_sha256: active.prompt_sha256.clone(),
1069 started_at_unix_ms: active.started_at_unix_ms,
1070 elapsed_ms: active.started_at.elapsed().as_millis() as u64,
1071 });
1072 let busy = current_generation.is_some();
1073
1074 Json(ServerStatus {
1075 version: env!("CARGO_PKG_VERSION").to_string(),
1076 git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
1077 None
1078 } else {
1079 Some(mold_core::build_info::GIT_SHA.to_string())
1080 },
1081 build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
1082 None
1083 } else {
1084 Some(mold_core::build_info::BUILD_DATE.to_string())
1085 },
1086 models_loaded,
1087 busy,
1088 current_generation,
1089 gpu_info: query_gpu_info(),
1090 uptime_secs: state.start_time.elapsed().as_secs(),
1091 hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
1092 memory_status: mold_inference::device::memory_status_string(),
1093 })
1094}
1095
1096#[utoipa::path(
1099 get,
1100 path = "/health",
1101 tag = "server",
1102 responses(
1103 (status = 200, description = "Server is healthy"),
1104 )
1105)]
1106async fn health() -> impl IntoResponse {
1107 StatusCode::OK
1108}
1109
1110#[utoipa::path(
1118 post,
1119 path = "/api/shutdown",
1120 tag = "server",
1121 responses(
1122 (status = 200, description = "Shutdown initiated"),
1123 (status = 403, description = "Forbidden — remote shutdown requires API key auth"),
1124 )
1125)]
1126async fn shutdown_server(State(state): State<AppState>, request: Request) -> impl IntoResponse {
1127 let auth_enabled = request
1130 .extensions()
1131 .get::<crate::auth::AuthState>()
1132 .is_some_and(|s| s.is_some());
1133
1134 if !auth_enabled {
1135 let is_loopback = request
1136 .extensions()
1137 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1138 .map(|ci| ci.0.ip().is_loopback())
1139 .unwrap_or(false);
1140 if !is_loopback {
1141 return (
1142 StatusCode::FORBIDDEN,
1143 "shutdown requires API key auth or localhost access\n",
1144 );
1145 }
1146 }
1147
1148 tracing::info!("shutdown requested via API");
1149 if let Some(tx) = state.shutdown_tx.lock().await.take() {
1150 let _ = tx.send(());
1151 }
1152 (StatusCode::OK, "shutdown initiated\n")
1153}
1154
1155async fn list_gallery(
1160 State(state): State<AppState>,
1161) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
1162 let config = state.config.read().await;
1163 if config.is_output_disabled() {
1164 return Ok(Json(Vec::new()));
1165 }
1166 let output_dir = config.effective_output_dir();
1167 drop(config);
1168
1169 if !output_dir.is_dir() {
1170 return Ok(Json(Vec::new()));
1171 }
1172
1173 let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
1174 .await
1175 .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
1176
1177 Ok(Json(images))
1178}
1179
1180async fn get_gallery_image(
1182 State(state): State<AppState>,
1183 axum::extract::Path(filename): axum::extract::Path<String>,
1184) -> Result<impl IntoResponse, ApiError> {
1185 let config = state.config.read().await;
1186 if config.is_output_disabled() {
1187 return Err(ApiError::not_found("image output is disabled"));
1188 }
1189 let output_dir = config.effective_output_dir();
1190 drop(config);
1191
1192 let clean_name = std::path::Path::new(&filename)
1194 .file_name()
1195 .map(|f| f.to_string_lossy().to_string())
1196 .unwrap_or_default();
1197
1198 if clean_name.is_empty() || clean_name != filename {
1199 return Err(ApiError::validation("invalid filename"));
1200 }
1201
1202 let path = output_dir.join(&clean_name);
1203 if !path.is_file() {
1204 return Err(ApiError::not_found(format!(
1205 "image not found: {clean_name}"
1206 )));
1207 }
1208
1209 let data = tokio::fs::read(&path)
1210 .await
1211 .map_err(|e| ApiError::internal(format!("failed to read image: {e}")))?;
1212
1213 let content_type = if clean_name.ends_with(".png") {
1214 "image/png"
1215 } else if clean_name.ends_with(".jpg") || clean_name.ends_with(".jpeg") {
1216 "image/jpeg"
1217 } else {
1218 "application/octet-stream"
1219 };
1220
1221 let mut headers = HeaderMap::new();
1222 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
1223
1224 Ok((headers, data))
1225}
1226
1227async fn delete_gallery_image(
1229 State(state): State<AppState>,
1230 axum::extract::Path(filename): axum::extract::Path<String>,
1231) -> Result<impl IntoResponse, ApiError> {
1232 let config = state.config.read().await;
1233 if config.is_output_disabled() {
1234 return Err(ApiError::not_found("image output is disabled"));
1235 }
1236 let output_dir = config.effective_output_dir();
1237 drop(config);
1238
1239 let clean_name = std::path::Path::new(&filename)
1240 .file_name()
1241 .map(|f| f.to_string_lossy().to_string())
1242 .unwrap_or_default();
1243
1244 if clean_name.is_empty() || clean_name != filename {
1245 return Err(ApiError::validation("invalid filename"));
1246 }
1247
1248 let path = output_dir.join(&clean_name);
1249 if path.is_file() {
1250 std::fs::remove_file(&path)
1251 .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
1252 }
1253
1254 let thumb_path = server_thumbnail_dir().join(&clean_name);
1256 let _ = std::fs::remove_file(&thumb_path);
1257
1258 Ok(StatusCode::NO_CONTENT)
1259}
1260
1261async fn get_gallery_thumbnail(
1264 State(state): State<AppState>,
1265 axum::extract::Path(filename): axum::extract::Path<String>,
1266) -> Result<impl IntoResponse, ApiError> {
1267 let config = state.config.read().await;
1268 if config.is_output_disabled() {
1269 return Err(ApiError::not_found("image output is disabled"));
1270 }
1271 let output_dir = config.effective_output_dir();
1272 drop(config);
1273
1274 let clean_name = std::path::Path::new(&filename)
1275 .file_name()
1276 .map(|f| f.to_string_lossy().to_string())
1277 .unwrap_or_default();
1278
1279 if clean_name.is_empty() || clean_name != filename {
1280 return Err(ApiError::validation("invalid filename"));
1281 }
1282
1283 let source_path = output_dir.join(&clean_name);
1284 if !source_path.is_file() {
1285 return Err(ApiError::not_found(format!(
1286 "image not found: {clean_name}"
1287 )));
1288 }
1289
1290 let thumb_dir = server_thumbnail_dir();
1292 let thumb_path = thumb_dir.join(&clean_name);
1293
1294 if !thumb_path.is_file() {
1295 let source = source_path.clone();
1297 let dest = thumb_path.clone();
1298 tokio::task::spawn_blocking(move || generate_server_thumbnail(&source, &dest))
1299 .await
1300 .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?
1301 .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
1302 }
1303
1304 let data = tokio::fs::read(&thumb_path)
1305 .await
1306 .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
1307
1308 let mut headers = HeaderMap::new();
1309 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
1310
1311 Ok((headers, data))
1312}
1313
1314fn server_thumbnail_dir() -> std::path::PathBuf {
1316 mold_core::Config::mold_dir()
1317 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
1318 .join("cache")
1319 .join("thumbnails")
1320}
1321
1322fn generate_server_thumbnail(
1324 source: &std::path::Path,
1325 dest: &std::path::Path,
1326) -> anyhow::Result<()> {
1327 let img = image::open(source)?;
1328 let thumb = img.thumbnail(256, 256);
1329 if let Some(parent) = dest.parent() {
1330 std::fs::create_dir_all(parent)?;
1331 }
1332 thumb.save(dest)?;
1333 Ok(())
1334}
1335
1336pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
1338 if !thumbnail_warmup_enabled() {
1339 tracing::info!("thumbnail warmup disabled; thumbnails will be generated on demand");
1340 return;
1341 }
1342
1343 let output_dir = config.effective_output_dir();
1344 std::thread::spawn(move || {
1345 if !output_dir.is_dir() {
1346 return;
1347 }
1348 let thumb_dir = server_thumbnail_dir();
1349 let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
1350 for entry in walker.filter_map(|e| e.ok()) {
1351 let path = entry.path();
1352 if !path.is_file() {
1353 continue;
1354 }
1355 let ext = path
1356 .extension()
1357 .and_then(|e| e.to_str())
1358 .map(|e| e.to_lowercase());
1359 if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
1360 continue;
1361 }
1362 let filename = path
1363 .file_name()
1364 .map(|f| f.to_string_lossy().to_string())
1365 .unwrap_or_default();
1366 let thumb_path = thumb_dir.join(&filename);
1367 if !thumb_path.is_file() {
1368 if let Err(e) = generate_server_thumbnail(path, &thumb_path) {
1369 tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
1370 }
1371 }
1372 }
1373 tracing::info!("thumbnail warmup complete");
1374 });
1375}
1376
1377fn thumbnail_warmup_enabled() -> bool {
1378 std::env::var("MOLD_THUMBNAIL_WARMUP")
1379 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
1380 .unwrap_or(false)
1381}
1382
1383fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
1385 let mut images = Vec::new();
1386
1387 let walker = walkdir::WalkDir::new(dir).max_depth(1).into_iter();
1388 for entry in walker.filter_map(|e| e.ok()) {
1389 let path = entry.path();
1390 if !path.is_file() {
1391 continue;
1392 }
1393
1394 let ext = path
1395 .extension()
1396 .and_then(|e| e.to_str())
1397 .map(|e| e.to_lowercase());
1398 if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
1399 continue;
1400 }
1401
1402 let timestamp = entry
1403 .metadata()
1404 .ok()
1405 .and_then(|m| m.modified().ok())
1406 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1407 .map(|d| d.as_secs())
1408 .unwrap_or(0);
1409
1410 let filename = path
1411 .file_name()
1412 .map(|f| f.to_string_lossy().to_string())
1413 .unwrap_or_default();
1414
1415 let meta = if ext.as_deref() == Some("png") {
1416 read_png_metadata(path)
1417 } else {
1418 read_jpeg_metadata(path)
1419 };
1420
1421 if let Some(meta) = meta {
1422 images.push(mold_core::GalleryImage {
1423 filename,
1424 metadata: meta,
1425 timestamp,
1426 });
1427 }
1428 }
1429
1430 images.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
1431 images
1432}
1433
1434fn read_png_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1436 let file = std::fs::File::open(path).ok()?;
1437 let decoder = png::Decoder::new(std::io::BufReader::new(file));
1438 let reader = decoder.read_info().ok()?;
1439 let info = reader.info();
1440
1441 for chunk in &info.uncompressed_latin1_text {
1442 if chunk.keyword == "mold:parameters" {
1443 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&chunk.text) {
1444 return Some(meta);
1445 }
1446 }
1447 }
1448 for chunk in &info.utf8_text {
1449 if chunk.keyword == "mold:parameters" {
1450 if let Ok(text) = chunk.get_text() {
1451 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&text) {
1452 return Some(meta);
1453 }
1454 }
1455 }
1456 }
1457 None
1458}
1459
1460fn read_jpeg_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1462 let data = std::fs::read(path).ok()?;
1463 let mut i = 0;
1464 while i + 1 < data.len() {
1465 if data[i] != 0xFF {
1466 i += 1;
1467 continue;
1468 }
1469 let marker = data[i + 1];
1470 match marker {
1471 0xD8 | 0x01 => {
1473 i += 2;
1474 }
1475 0xD9 => break, 0xD0..=0xD7 => {
1477 i += 2; }
1479 0xFE => {
1481 if i + 3 >= data.len() {
1482 break;
1483 }
1484 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1485 if len < 2 || i + 2 + len > data.len() {
1486 break;
1487 }
1488 let comment = &data[i + 4..i + 2 + len];
1489 if let Ok(text) = std::str::from_utf8(comment) {
1490 if let Some(json) = text.strip_prefix("mold:parameters ") {
1491 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(json) {
1492 return Some(meta);
1493 }
1494 }
1495 }
1496 i += 2 + len;
1497 }
1498 _ => {
1500 if i + 3 >= data.len() {
1501 break;
1502 }
1503 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1504 if len < 2 || i + 2 + len > data.len() {
1505 break;
1506 }
1507 i += 2 + len;
1508 }
1509 }
1510 }
1511 None
1512}
1513
1514async fn openapi_json() -> impl IntoResponse {
1517 Json(ApiDoc::openapi())
1518}
1519
1520async fn scalar_docs() -> impl IntoResponse {
1523 (
1524 [(header::CONTENT_TYPE, "text/html")],
1525 r#"<!DOCTYPE html>
1526<html>
1527<head>
1528 <title>mold API</title>
1529 <meta charset="utf-8" />
1530 <meta name="viewport" content="width=device-width, initial-scale=1" />
1531</head>
1532<body>
1533 <script id="api-reference" data-url="/api/openapi.json"></script>
1534 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
1535</body>
1536</html>"#,
1537 )
1538}
1539
1540fn query_gpu_info() -> Option<GpuInfo> {
1543 let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
1544 "/run/current-system/sw/bin/nvidia-smi"
1545 } else {
1546 "nvidia-smi"
1547 };
1548
1549 let output = std::process::Command::new(nvidia_smi)
1550 .args([
1551 "--query-gpu=name,memory.total,memory.used",
1552 "--format=csv,noheader,nounits",
1553 ])
1554 .output()
1555 .ok()?;
1556
1557 if !output.status.success() {
1558 return None;
1559 }
1560
1561 let text = String::from_utf8(output.stdout).ok()?;
1562 let line = text.lines().next()?;
1563 let parts: Vec<&str> = line.split(',').map(str::trim).collect();
1564 if parts.len() < 3 {
1565 return None;
1566 }
1567
1568 Some(GpuInfo {
1569 name: parts[0].to_string(),
1570 vram_total_mb: parts[1].parse().ok()?,
1571 vram_used_mb: parts[2].parse().ok()?,
1572 })
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578
1579 fn env_lock() -> &'static std::sync::Mutex<()> {
1580 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1581 &ENV_LOCK
1582 }
1583
1584 #[test]
1585 fn clean_error_message_strips_backtrace() {
1586 let err = anyhow::anyhow!(
1587 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
1588 \x20 0: candle_core::error::Error::bt\n\
1589 \x20 at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
1590 \x20 1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
1591 \x20 at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
1592 );
1593 let msg = clean_error_message(&err);
1594 assert_eq!(
1595 msg,
1596 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
1597 );
1598 }
1599
1600 #[test]
1601 fn clean_error_message_preserves_simple_error() {
1602 let err = anyhow::anyhow!("model not found: flux-dev:q4");
1603 let msg = clean_error_message(&err);
1604 assert_eq!(msg, "model not found: flux-dev:q4");
1605 }
1606
1607 #[test]
1608 fn clean_error_message_preserves_multiline_without_backtrace() {
1609 let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
1610 let msg = clean_error_message(&err);
1611 assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
1612 }
1613
1614 #[test]
1615 fn clean_error_message_strips_high_numbered_frames() {
1616 let err = anyhow::anyhow!(
1617 "some error\n\
1618 \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
1619 \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
1620 );
1621 let msg = clean_error_message(&err);
1622 assert_eq!(msg, "some error");
1623 }
1624
1625 #[test]
1626 fn clean_error_message_empty_fallback() {
1627 let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
1629 let msg = clean_error_message(&err);
1630 assert!(!msg.is_empty());
1632 }
1633
1634 #[test]
1635 fn save_image_to_dir_creates_directory_and_writes_file() {
1636 let dir = std::env::temp_dir().join(format!(
1637 "mold-save-test-{}",
1638 std::time::SystemTime::now()
1639 .duration_since(std::time::UNIX_EPOCH)
1640 .unwrap()
1641 .as_nanos()
1642 ));
1643 assert!(!dir.exists());
1644
1645 let img = mold_core::ImageData {
1646 data: vec![0x89, 0x50, 0x4E, 0x47], format: mold_core::OutputFormat::Png,
1648 width: 64,
1649 height: 64,
1650 index: 0,
1651 };
1652
1653 save_image_to_dir(&dir, &img, "test-model:q8", 1);
1654
1655 assert!(dir.exists(), "directory should be created");
1656 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1657 assert_eq!(files.len(), 1, "should have exactly one file");
1658 let file = files[0].as_ref().unwrap();
1659 let filename = file.file_name().to_str().unwrap().to_string();
1660 assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
1661 assert!(filename.ends_with(".png"), "{filename}");
1662 let contents = std::fs::read(file.path()).unwrap();
1663 assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
1664
1665 std::fs::remove_dir_all(&dir).ok();
1666 }
1667
1668 #[test]
1669 fn save_image_to_dir_batch_includes_index() {
1670 let dir = std::env::temp_dir().join(format!(
1671 "mold-save-batch-{}",
1672 std::time::SystemTime::now()
1673 .duration_since(std::time::UNIX_EPOCH)
1674 .unwrap()
1675 .as_nanos()
1676 ));
1677
1678 let img = mold_core::ImageData {
1679 data: vec![0xFF, 0xD8], format: mold_core::OutputFormat::Jpeg,
1681 width: 64,
1682 height: 64,
1683 index: 2,
1684 };
1685
1686 save_image_to_dir(&dir, &img, "flux-dev", 4);
1687
1688 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1689 assert_eq!(files.len(), 1);
1690 let filename = files[0]
1691 .as_ref()
1692 .unwrap()
1693 .file_name()
1694 .to_str()
1695 .unwrap()
1696 .to_string();
1697 assert!(
1698 filename.contains("-2.jpeg"),
1699 "batch index in name: {filename}"
1700 );
1701
1702 std::fs::remove_dir_all(&dir).ok();
1703 }
1704
1705 #[test]
1706 fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
1707 let img = mold_core::ImageData {
1709 data: vec![0x00],
1710 format: mold_core::OutputFormat::Png,
1711 width: 1,
1712 height: 1,
1713 index: 0,
1714 };
1715 save_image_to_dir(
1717 std::path::Path::new("/dev/null/impossible"),
1718 &img,
1719 "test",
1720 1,
1721 );
1722 }
1724
1725 #[test]
1726 fn thumbnail_warmup_is_disabled_by_default() {
1727 let _guard = env_lock().lock().unwrap();
1728 unsafe {
1729 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1730 }
1731 assert!(!thumbnail_warmup_enabled());
1732 }
1733
1734 #[test]
1735 fn thumbnail_warmup_accepts_truthy_env_values() {
1736 let _guard = env_lock().lock().unwrap();
1737 unsafe {
1738 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "1");
1739 }
1740 assert!(thumbnail_warmup_enabled());
1741 unsafe {
1742 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "true");
1743 }
1744 assert!(thumbnail_warmup_enabled());
1745 unsafe {
1746 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "YES");
1747 }
1748 assert!(thumbnail_warmup_enabled());
1749 unsafe {
1750 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1751 }
1752 }
1753
1754 #[test]
1755 fn thumbnail_warmup_rejects_falsey_env_values() {
1756 let _guard = env_lock().lock().unwrap();
1757 unsafe {
1758 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "0");
1759 }
1760 assert!(!thumbnail_warmup_enabled());
1761 unsafe {
1762 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "false");
1763 }
1764 assert!(!thumbnail_warmup_enabled());
1765 unsafe {
1766 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1767 }
1768 }
1769}