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 {
325 let ct = HeaderValue::from_static(video.format.content_type());
326 headers.insert(header::CONTENT_TYPE, ct);
327 if let Ok(v) = HeaderValue::from_str(&video.frames.to_string()) {
328 headers.insert("x-mold-video-frames", v);
329 }
330 if let Ok(v) = HeaderValue::from_str(&video.fps.to_string()) {
331 headers.insert("x-mold-video-fps", v);
332 }
333 if let Ok(v) = HeaderValue::from_str(&video.width.to_string()) {
334 headers.insert("x-mold-video-width", v);
335 }
336 if let Ok(v) = HeaderValue::from_str(&video.height.to_string()) {
337 headers.insert("x-mold-video-height", v);
338 }
339 if video.has_audio {
340 headers.insert("x-mold-video-has-audio", HeaderValue::from_static("1"));
341 }
342 if let Some(dur) = video.duration_ms {
343 if let Ok(v) = HeaderValue::from_str(&dur.to_string()) {
344 headers.insert("x-mold-video-duration-ms", v);
345 }
346 }
347 if let Some(sr) = video.audio_sample_rate {
348 if let Ok(v) = HeaderValue::from_str(&sr.to_string()) {
349 headers.insert("x-mold-video-audio-sample-rate", v);
350 }
351 }
352 if let Some(ch) = video.audio_channels {
353 if let Ok(v) = HeaderValue::from_str(&ch.to_string()) {
354 headers.insert("x-mold-video-audio-channels", v);
355 }
356 }
357 video.data.clone()
358 } else {
359 img.data
360 };
361 Ok((headers, output_data))
362 }
363 Err(err_msg) => Err(ApiError::inference(err_msg)),
364 }
365}
366
367fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
368 mold_core::validate_generate_request(req)
369}
370
371async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
372 if req.embed_metadata.is_some() {
373 return;
374 }
375
376 let config = state.config.read().await;
377 req.embed_metadata = Some(config.effective_embed_metadata(None));
378}
379
380async fn maybe_expand_prompt(
382 state: &AppState,
383 req: &mut mold_core::GenerateRequest,
384) -> Result<(), ApiError> {
385 if req.expand != Some(true) {
386 return Ok(());
387 }
388
389 let config = state.config.read().await;
390 let expand_settings = config.expand.clone().with_env_overrides();
391
392 let model_family = config
394 .resolved_model_config(&req.model)
395 .family
396 .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
397 .unwrap_or_else(|| {
398 tracing::warn!(
399 model = %req.model,
400 "could not resolve model family for prompt expansion, defaulting to \"flux\""
401 );
402 "flux".to_string()
403 });
404
405 let expand_config = expand_settings.to_expand_config(&model_family, 1);
406 let original_prompt = req.prompt.clone();
407
408 drop(config);
410
411 let expander = create_server_expander(&expand_settings)?;
412 let result =
413 tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
414 .await
415 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
416 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
417
418 if let Some(expanded) = result.expanded.first() {
419 req.original_prompt = Some(req.prompt.clone());
420 req.prompt = expanded.clone();
421 }
422
423 Ok(())
424}
425
426fn create_server_expander(
428 settings: &mold_core::ExpandSettings,
429) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
430 if let Some(api_expander) = settings.create_api_expander() {
431 return Ok(Box::new(api_expander));
432 }
433
434 #[cfg(feature = "expand")]
435 {
436 let config = mold_core::Config::load_or_default();
437 if let Some(local) =
438 mold_inference::expand::LocalExpander::from_config(&config, Some(&settings.model))
439 {
440 return Ok(Box::new(local));
441 }
442 return Err(ApiError::validation(
443 "local expand model not found — run: mold pull qwen3-expand".to_string(),
444 ));
445 }
446
447 #[cfg(not(feature = "expand"))]
448 {
449 Err(ApiError::validation(
450 "local prompt expansion not available — built without expand feature. \
451 Configure an API backend in [expand] settings."
452 .to_string(),
453 ))
454 }
455}
456
457#[utoipa::path(
460 post,
461 path = "/api/expand",
462 tag = "generation",
463 request_body = mold_core::ExpandRequest,
464 responses(
465 (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
466 (status = 422, description = "Invalid request parameters"),
467 (status = 500, description = "Expansion failed"),
468 )
469)]
470async fn expand_prompt(
471 State(state): State<AppState>,
472 Json(req): Json<mold_core::ExpandRequest>,
473) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
474 if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
475 return Err(ApiError::validation(format!(
476 "variations must be between 1 and {}",
477 mold_core::expand::MAX_VARIATIONS,
478 )));
479 }
480
481 let config = state.config.read().await;
482 let expand_settings = config.expand.clone().with_env_overrides();
483 let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
484 let prompt = req.prompt.clone();
485 drop(config);
486
487 let expander = create_server_expander(&expand_settings)?;
488 let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
489 .await
490 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
491 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
492
493 Ok(Json(mold_core::ExpandResponse {
494 original: req.prompt,
495 expanded: result.expanded,
496 }))
497}
498
499async fn upscale(
502 State(state): State<AppState>,
503 Json(req): Json<mold_core::UpscaleRequest>,
504) -> Result<Json<mold_core::UpscaleResponse>, ApiError> {
505 if let Err(msg) = mold_core::validate_upscale_request(&req) {
506 return Err(ApiError::validation(msg));
507 }
508
509 let model_name = mold_core::manifest::resolve_model_name(&req.model);
510
511 let needs_pull = {
513 let config = state.config.read().await;
514 config
515 .models
516 .get(&model_name)
517 .and_then(|c| c.transformer.as_ref())
518 .is_none()
519 };
520 if needs_pull {
521 if mold_core::manifest::find_manifest(&model_name).is_none() {
522 return Err(ApiError::not_found(format!(
523 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
524 model_name
525 )));
526 }
527 model_manager::pull_model(&state, &model_name, None).await?;
528 }
529
530 let config = state.config.read().await;
531 let weights_path = config
532 .models
533 .get(&model_name)
534 .and_then(|c| c.transformer.as_ref())
535 .ok_or_else(|| {
536 ApiError::not_found(format!(
537 "upscaler model '{}' not configured after pull",
538 model_name
539 ))
540 })?;
541 let weights_path = std::path::PathBuf::from(weights_path);
542 let model_name_owned = model_name.clone();
543 drop(config);
544
545 let upscaler_cache = state.upscaler_cache.clone();
546 let resp =
547 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
548 let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
549
550 let needs_new = cache
552 .as_ref()
553 .is_none_or(|e| e.model_name() != model_name_owned);
554 if needs_new {
555 let new_engine = mold_inference::create_upscale_engine(
556 model_name_owned,
557 weights_path,
558 mold_inference::LoadStrategy::Eager,
559 )?;
560 *cache = Some(new_engine);
561 }
562
563 cache.as_mut().unwrap().upscale(&req)
564 })
565 .await
566 .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")))?
567 .map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?;
568
569 Ok(Json(resp))
570}
571
572async fn upscale_stream(
575 State(state): State<AppState>,
576 Json(req): Json<mold_core::UpscaleRequest>,
577) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
578 if let Err(msg) = mold_core::validate_upscale_request(&req) {
579 return Err(ApiError::validation(msg));
580 }
581
582 let model_name = mold_core::manifest::resolve_model_name(&req.model);
583
584 let needs_pull = {
586 let config = state.config.read().await;
587 config
588 .models
589 .get(&model_name)
590 .and_then(|c| c.transformer.as_ref())
591 .is_none()
592 };
593
594 if needs_pull && mold_core::manifest::find_manifest(&model_name).is_none() {
596 return Err(ApiError::not_found(format!(
597 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
598 model_name
599 )));
600 }
601
602 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
603 let model_name_owned = model_name.clone();
604 let state_clone = state.clone();
605 let upscaler_cache = state.upscaler_cache.clone();
606
607 tokio::spawn(async move {
608 if needs_pull {
610 let progress_tx = tx.clone();
611 let callback =
612 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
613 let sse_event = match event {
614 mold_core::download::DownloadProgressEvent::Status { message } => {
615 SseProgressEvent::Info { message }
616 }
617 mold_core::download::DownloadProgressEvent::FileStart {
618 filename,
619 file_index,
620 total_files,
621 size_bytes,
622 batch_bytes_downloaded,
623 batch_bytes_total,
624 batch_elapsed_ms,
625 } => SseProgressEvent::DownloadProgress {
626 filename,
627 file_index,
628 total_files,
629 bytes_downloaded: 0,
630 bytes_total: size_bytes,
631 batch_bytes_downloaded,
632 batch_bytes_total,
633 batch_elapsed_ms,
634 },
635 mold_core::download::DownloadProgressEvent::FileProgress {
636 filename,
637 file_index,
638 bytes_downloaded,
639 bytes_total,
640 batch_bytes_downloaded,
641 batch_bytes_total,
642 batch_elapsed_ms,
643 } => SseProgressEvent::DownloadProgress {
644 filename,
645 file_index,
646 total_files: 0,
647 bytes_downloaded,
648 bytes_total,
649 batch_bytes_downloaded,
650 batch_bytes_total,
651 batch_elapsed_ms,
652 },
653 mold_core::download::DownloadProgressEvent::FileDone {
654 filename,
655 file_index,
656 total_files,
657 batch_bytes_downloaded,
658 batch_bytes_total,
659 batch_elapsed_ms,
660 } => SseProgressEvent::DownloadDone {
661 filename,
662 file_index,
663 total_files,
664 batch_bytes_downloaded,
665 batch_bytes_total,
666 batch_elapsed_ms,
667 },
668 };
669 let _ = progress_tx.send(SseMessage::Progress(sse_event));
670 });
671
672 match model_manager::pull_model(&state_clone, &model_name_owned, Some(callback)).await {
673 Ok(_) => {
674 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
675 model: model_name_owned.clone(),
676 }));
677 }
678 Err(e) => {
679 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
680 message: format!("failed to pull upscaler model: {}", e.error),
681 }));
682 return;
683 }
684 }
685 }
686
687 let weights_path = {
689 let config = state_clone.config.read().await;
690 config
691 .models
692 .get(&model_name_owned)
693 .and_then(|c| c.transformer.as_ref())
694 .map(std::path::PathBuf::from)
695 };
696
697 let Some(weights_path) = weights_path else {
698 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
699 message: format!(
700 "upscaler model '{}' not configured after pull",
701 model_name_owned
702 ),
703 }));
704 return;
705 };
706
707 let result = tokio::task::spawn_blocking(move || {
708 let mut cache = upscaler_cache.lock().unwrap();
709
710 let needs_new = cache
711 .as_ref()
712 .is_none_or(|e| e.model_name() != model_name_owned);
713 if needs_new {
714 let _ = tx.send(SseMessage::Progress(
715 mold_core::SseProgressEvent::StageStart {
716 name: "Loading upscaler model".to_string(),
717 },
718 ));
719 match mold_inference::create_upscale_engine(
720 model_name_owned,
721 weights_path,
722 mold_inference::LoadStrategy::Eager,
723 ) {
724 Ok(new_engine) => {
725 *cache = Some(new_engine);
726 }
727 Err(e) => {
728 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
729 message: format!("failed to load upscaler: {e}"),
730 }));
731 return;
732 }
733 }
734 }
735
736 let engine = cache.as_mut().unwrap();
737
738 let tx_progress = tx.clone();
740 engine.set_on_progress(Box::new(move |event| {
741 let sse_event: mold_core::SseProgressEvent = event.into();
742 let _ = tx_progress.send(SseMessage::Progress(sse_event));
743 }));
744
745 match engine.upscale(&req) {
746 Ok(resp) => {
747 let image_b64 =
748 base64::engine::general_purpose::STANDARD.encode(&resp.image.data);
749 let _ = tx.send(SseMessage::UpscaleComplete(
750 mold_core::SseUpscaleCompleteEvent {
751 image: image_b64,
752 format: resp.image.format,
753 model: resp.model,
754 scale_factor: resp.scale_factor,
755 original_width: resp.original_width,
756 original_height: resp.original_height,
757 upscale_time_ms: resp.upscale_time_ms,
758 },
759 ));
760 }
761 Err(e) => {
762 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
763 message: format!("upscale failed: {e}"),
764 }));
765 }
766 }
767
768 engine.clear_on_progress();
769 })
770 .await;
771
772 if let Err(e) = result {
773 tracing::error!("upscale task panicked: {e}");
774 }
775 });
776
777 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
778 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
779
780 Ok(Sse::new(stream).keep_alive(
781 KeepAlive::new()
782 .interval(std::time::Duration::from_secs(15))
783 .text("ping"),
784 ))
785}
786
787#[utoipa::path(
790 post,
791 path = "/api/generate/stream",
792 tag = "generation",
793 request_body = mold_core::GenerateRequest,
794 responses(
795 (status = 200, description = "SSE event stream with progress and result"),
796 (status = 404, description = "Model not downloaded"),
797 (status = 422, description = "Invalid request parameters"),
798 (status = 500, description = "Inference error"),
799 )
800)]
801async fn generate_stream(
802 State(state): State<AppState>,
803 Json(mut req): Json<mold_core::GenerateRequest>,
804) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
805 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
806
807 tracing::info!(
808 model = %req.model,
809 prompt = %req.prompt,
810 "generate/stream request"
811 );
812
813 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
815
816 if let Some(warning) = dim_warning {
818 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
819 message: warning,
820 }));
821 }
822
823 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
824 let job = GenerationJob {
825 request: req,
826 progress_tx: Some(tx.clone()),
827 result_tx,
828 output_dir,
829 };
830
831 let position = state.queue.submit(job).await.map_err(ApiError::internal)?;
832
833 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
835
836 tokio::spawn(async move {
840 let _ = result_rx.await;
841 drop(tx); });
843
844 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
846 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
847
848 Ok(Sse::new(stream).keep_alive(
849 KeepAlive::new()
850 .interval(std::time::Duration::from_secs(15))
851 .text("ping"),
852 ))
853}
854
855#[utoipa::path(
858 get,
859 path = "/api/models",
860 tag = "models",
861 responses(
862 (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
863 )
864)]
865async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
866 Json(model_manager::list_models(&state).await)
867}
868
869#[derive(Debug, Deserialize, utoipa::ToSchema)]
872pub struct LoadModelBody {
873 #[schema(example = "flux-schnell:q8")]
874 pub model: String,
875}
876
877#[utoipa::path(
878 post,
879 path = "/api/models/load",
880 tag = "models",
881 request_body = LoadModelBody,
882 responses(
883 (status = 200, description = "Model loaded successfully"),
884 (status = 404, description = "Model not downloaded"),
885 (status = 400, description = "Unknown model"),
886 (status = 500, description = "Failed to load model"),
887 )
888)]
889async fn load_model(
890 State(state): State<AppState>,
891 Json(body): Json<LoadModelBody>,
892) -> Result<impl IntoResponse, ApiError> {
893 model_manager::ensure_model_ready(&state, &body.model, None).await?;
894 tracing::info!(model = %body.model, "model loaded via API");
895 Ok(StatusCode::OK)
896}
897
898#[utoipa::path(
901 post,
902 path = "/api/models/pull",
903 tag = "models",
904 request_body = LoadModelBody,
905 responses(
906 (status = 200, description = "Model pulled (SSE stream or plain text)"),
907 (status = 400, description = "Unknown model"),
908 (status = 500, description = "Download failed"),
909 )
910)]
911async fn pull_model_endpoint(
912 State(state): State<AppState>,
913 headers: HeaderMap,
914 Json(body): Json<LoadModelBody>,
915) -> Result<impl IntoResponse, ApiError> {
916 let wants_sse = headers
917 .get(header::ACCEPT)
918 .and_then(|v| v.to_str().ok())
919 .is_some_and(|v| v.contains("text/event-stream"));
920
921 if !wants_sse {
922 return pull_model_blocking(state, body.model)
924 .await
925 .map(PullResponse::Text);
926 }
927
928 let model = body.model.clone();
930
931 if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
933 .is_none()
934 {
935 return Err(ApiError::unknown_model(format!(
936 "unknown model '{model}'. Run 'mold list' to see available models."
937 )));
938 }
939
940 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
941
942 tokio::spawn(async move {
943 let progress_tx = tx.clone();
944 let model_for_cb = model.clone();
945 let callback =
946 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
947 let sse_event = match event {
948 mold_core::download::DownloadProgressEvent::Status { message } => {
949 SseProgressEvent::Info { message }
950 }
951 mold_core::download::DownloadProgressEvent::FileStart {
952 filename,
953 file_index,
954 total_files,
955 size_bytes,
956 batch_bytes_downloaded,
957 batch_bytes_total,
958 batch_elapsed_ms,
959 } => SseProgressEvent::DownloadProgress {
960 filename,
961 file_index,
962 total_files,
963 bytes_downloaded: 0,
964 bytes_total: size_bytes,
965 batch_bytes_downloaded,
966 batch_bytes_total,
967 batch_elapsed_ms,
968 },
969 mold_core::download::DownloadProgressEvent::FileProgress {
970 filename,
971 file_index,
972 bytes_downloaded,
973 bytes_total,
974 batch_bytes_downloaded,
975 batch_bytes_total,
976 batch_elapsed_ms,
977 } => SseProgressEvent::DownloadProgress {
978 filename,
979 file_index,
980 total_files: 0,
981 bytes_downloaded,
982 bytes_total,
983 batch_bytes_downloaded,
984 batch_bytes_total,
985 batch_elapsed_ms,
986 },
987 mold_core::download::DownloadProgressEvent::FileDone {
988 filename,
989 file_index,
990 total_files,
991 batch_bytes_downloaded,
992 batch_bytes_total,
993 batch_elapsed_ms,
994 } => SseProgressEvent::DownloadDone {
995 filename,
996 file_index,
997 total_files,
998 batch_bytes_downloaded,
999 batch_bytes_total,
1000 batch_elapsed_ms,
1001 },
1002 };
1003 let _ = progress_tx.send(SseMessage::Progress(sse_event));
1004 });
1005
1006 match model_manager::pull_model(&state, &model, Some(callback)).await {
1007 Ok(model_manager::PullStatus::AlreadyAvailable) => {
1008 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1009 model: model_for_cb,
1010 }));
1011 }
1012 Ok(model_manager::PullStatus::Pulled) => {
1013 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1014 model: model_for_cb,
1015 }));
1016 }
1017 Err(e) => {
1018 let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
1019 }
1020 }
1021 });
1022
1023 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1024 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1025
1026 Ok(PullResponse::Sse(
1027 Sse::new(stream)
1028 .keep_alive(
1029 KeepAlive::new()
1030 .interval(std::time::Duration::from_secs(15))
1031 .text("ping"),
1032 )
1033 .into_response(),
1034 ))
1035}
1036
1037async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
1039 match model_manager::pull_model(&state, &model, None).await? {
1040 model_manager::PullStatus::AlreadyAvailable => {
1041 Ok(format!("model '{}' already available", model))
1042 }
1043 model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
1044 }
1045}
1046
1047enum PullResponse {
1049 Sse(axum::response::Response),
1050 Text(String),
1051}
1052
1053impl IntoResponse for PullResponse {
1054 fn into_response(self) -> axum::response::Response {
1055 match self {
1056 PullResponse::Sse(resp) => resp,
1057 PullResponse::Text(text) => text.into_response(),
1058 }
1059 }
1060}
1061
1062#[utoipa::path(
1065 delete,
1066 path = "/api/models/unload",
1067 tag = "models",
1068 responses(
1069 (status = 200, description = "Model unloaded or no model was loaded", body = String),
1070 )
1071)]
1072async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
1073 Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1074}
1075
1076#[utoipa::path(
1079 get,
1080 path = "/api/status",
1081 tag = "server",
1082 responses(
1083 (status = 200, description = "Server status", body = ServerStatus),
1084 )
1085)]
1086async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
1087 let snapshot = state.engine_snapshot.read().await.clone();
1088 let models_loaded = match (snapshot.model_name, snapshot.is_loaded) {
1089 (Some(model_name), true) => vec![model_name],
1090 _ => vec![],
1091 };
1092 let current_generation = state
1093 .active_generation
1094 .read()
1095 .unwrap_or_else(|e| e.into_inner())
1096 .as_ref()
1097 .map(|active| ActiveGenerationStatus {
1098 model: active.model.clone(),
1099 prompt_sha256: active.prompt_sha256.clone(),
1100 started_at_unix_ms: active.started_at_unix_ms,
1101 elapsed_ms: active.started_at.elapsed().as_millis() as u64,
1102 });
1103 let busy = current_generation.is_some();
1104
1105 Json(ServerStatus {
1106 version: env!("CARGO_PKG_VERSION").to_string(),
1107 git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
1108 None
1109 } else {
1110 Some(mold_core::build_info::GIT_SHA.to_string())
1111 },
1112 build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
1113 None
1114 } else {
1115 Some(mold_core::build_info::BUILD_DATE.to_string())
1116 },
1117 models_loaded,
1118 busy,
1119 current_generation,
1120 gpu_info: query_gpu_info(),
1121 uptime_secs: state.start_time.elapsed().as_secs(),
1122 hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
1123 memory_status: mold_inference::device::memory_status_string(),
1124 })
1125}
1126
1127#[utoipa::path(
1130 get,
1131 path = "/health",
1132 tag = "server",
1133 responses(
1134 (status = 200, description = "Server is healthy"),
1135 )
1136)]
1137async fn health() -> impl IntoResponse {
1138 StatusCode::OK
1139}
1140
1141#[utoipa::path(
1149 post,
1150 path = "/api/shutdown",
1151 tag = "server",
1152 responses(
1153 (status = 200, description = "Shutdown initiated"),
1154 (status = 403, description = "Forbidden — remote shutdown requires API key auth"),
1155 )
1156)]
1157async fn shutdown_server(State(state): State<AppState>, request: Request) -> impl IntoResponse {
1158 let auth_enabled = request
1161 .extensions()
1162 .get::<crate::auth::AuthState>()
1163 .is_some_and(|s| s.is_some());
1164
1165 if !auth_enabled {
1166 let is_loopback = request
1167 .extensions()
1168 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1169 .map(|ci| ci.0.ip().is_loopback())
1170 .unwrap_or(false);
1171 if !is_loopback {
1172 return (
1173 StatusCode::FORBIDDEN,
1174 "shutdown requires API key auth or localhost access\n",
1175 );
1176 }
1177 }
1178
1179 tracing::info!("shutdown requested via API");
1180 if let Some(tx) = state.shutdown_tx.lock().await.take() {
1181 let _ = tx.send(());
1182 }
1183 (StatusCode::OK, "shutdown initiated\n")
1184}
1185
1186async fn list_gallery(
1191 State(state): State<AppState>,
1192) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
1193 let config = state.config.read().await;
1194 if config.is_output_disabled() {
1195 return Ok(Json(Vec::new()));
1196 }
1197 let output_dir = config.effective_output_dir();
1198 drop(config);
1199
1200 if !output_dir.is_dir() {
1201 return Ok(Json(Vec::new()));
1202 }
1203
1204 let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
1205 .await
1206 .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
1207
1208 Ok(Json(images))
1209}
1210
1211async fn get_gallery_image(
1213 State(state): State<AppState>,
1214 axum::extract::Path(filename): axum::extract::Path<String>,
1215) -> Result<impl IntoResponse, ApiError> {
1216 let config = state.config.read().await;
1217 if config.is_output_disabled() {
1218 return Err(ApiError::not_found("image output is disabled"));
1219 }
1220 let output_dir = config.effective_output_dir();
1221 drop(config);
1222
1223 let clean_name = std::path::Path::new(&filename)
1225 .file_name()
1226 .map(|f| f.to_string_lossy().to_string())
1227 .unwrap_or_default();
1228
1229 if clean_name.is_empty() || clean_name != filename {
1230 return Err(ApiError::validation("invalid filename"));
1231 }
1232
1233 let path = output_dir.join(&clean_name);
1234 if !path.is_file() {
1235 return Err(ApiError::not_found(format!(
1236 "image not found: {clean_name}"
1237 )));
1238 }
1239
1240 let data = tokio::fs::read(&path)
1241 .await
1242 .map_err(|e| ApiError::internal(format!("failed to read image: {e}")))?;
1243
1244 let content_type = if clean_name.ends_with(".png") {
1245 "image/png"
1246 } else if clean_name.ends_with(".jpg") || clean_name.ends_with(".jpeg") {
1247 "image/jpeg"
1248 } else {
1249 "application/octet-stream"
1250 };
1251
1252 let mut headers = HeaderMap::new();
1253 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
1254
1255 Ok((headers, data))
1256}
1257
1258async fn delete_gallery_image(
1260 State(state): State<AppState>,
1261 axum::extract::Path(filename): axum::extract::Path<String>,
1262) -> Result<impl IntoResponse, ApiError> {
1263 let config = state.config.read().await;
1264 if config.is_output_disabled() {
1265 return Err(ApiError::not_found("image output is disabled"));
1266 }
1267 let output_dir = config.effective_output_dir();
1268 drop(config);
1269
1270 let clean_name = std::path::Path::new(&filename)
1271 .file_name()
1272 .map(|f| f.to_string_lossy().to_string())
1273 .unwrap_or_default();
1274
1275 if clean_name.is_empty() || clean_name != filename {
1276 return Err(ApiError::validation("invalid filename"));
1277 }
1278
1279 let path = output_dir.join(&clean_name);
1280 if path.is_file() {
1281 std::fs::remove_file(&path)
1282 .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
1283 }
1284
1285 let thumb_path = server_thumbnail_dir().join(&clean_name);
1287 let _ = std::fs::remove_file(&thumb_path);
1288
1289 Ok(StatusCode::NO_CONTENT)
1290}
1291
1292async fn get_gallery_thumbnail(
1295 State(state): State<AppState>,
1296 axum::extract::Path(filename): axum::extract::Path<String>,
1297) -> Result<impl IntoResponse, ApiError> {
1298 let config = state.config.read().await;
1299 if config.is_output_disabled() {
1300 return Err(ApiError::not_found("image output is disabled"));
1301 }
1302 let output_dir = config.effective_output_dir();
1303 drop(config);
1304
1305 let clean_name = std::path::Path::new(&filename)
1306 .file_name()
1307 .map(|f| f.to_string_lossy().to_string())
1308 .unwrap_or_default();
1309
1310 if clean_name.is_empty() || clean_name != filename {
1311 return Err(ApiError::validation("invalid filename"));
1312 }
1313
1314 let source_path = output_dir.join(&clean_name);
1315 if !source_path.is_file() {
1316 return Err(ApiError::not_found(format!(
1317 "image not found: {clean_name}"
1318 )));
1319 }
1320
1321 let thumb_dir = server_thumbnail_dir();
1323 let thumb_path = thumb_dir.join(&clean_name);
1324
1325 if !thumb_path.is_file() {
1326 let source = source_path.clone();
1328 let dest = thumb_path.clone();
1329 tokio::task::spawn_blocking(move || generate_server_thumbnail(&source, &dest))
1330 .await
1331 .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?
1332 .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
1333 }
1334
1335 let data = tokio::fs::read(&thumb_path)
1336 .await
1337 .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
1338
1339 let mut headers = HeaderMap::new();
1340 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
1341
1342 Ok((headers, data))
1343}
1344
1345fn server_thumbnail_dir() -> std::path::PathBuf {
1347 mold_core::Config::mold_dir()
1348 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
1349 .join("cache")
1350 .join("thumbnails")
1351}
1352
1353fn generate_server_thumbnail(
1355 source: &std::path::Path,
1356 dest: &std::path::Path,
1357) -> anyhow::Result<()> {
1358 let img = image::open(source)?;
1359 let thumb = img.thumbnail(256, 256);
1360 if let Some(parent) = dest.parent() {
1361 std::fs::create_dir_all(parent)?;
1362 }
1363 thumb.save(dest)?;
1364 Ok(())
1365}
1366
1367pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
1369 if !thumbnail_warmup_enabled() {
1370 tracing::info!("thumbnail warmup disabled; thumbnails will be generated on demand");
1371 return;
1372 }
1373
1374 let output_dir = config.effective_output_dir();
1375 std::thread::spawn(move || {
1376 if !output_dir.is_dir() {
1377 return;
1378 }
1379 let thumb_dir = server_thumbnail_dir();
1380 let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
1381 for entry in walker.filter_map(|e| e.ok()) {
1382 let path = entry.path();
1383 if !path.is_file() {
1384 continue;
1385 }
1386 let ext = path
1387 .extension()
1388 .and_then(|e| e.to_str())
1389 .map(|e| e.to_lowercase());
1390 if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
1391 continue;
1392 }
1393 let filename = path
1394 .file_name()
1395 .map(|f| f.to_string_lossy().to_string())
1396 .unwrap_or_default();
1397 let thumb_path = thumb_dir.join(&filename);
1398 if !thumb_path.is_file() {
1399 if let Err(e) = generate_server_thumbnail(path, &thumb_path) {
1400 tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
1401 }
1402 }
1403 }
1404 tracing::info!("thumbnail warmup complete");
1405 });
1406}
1407
1408fn thumbnail_warmup_enabled() -> bool {
1409 std::env::var("MOLD_THUMBNAIL_WARMUP")
1410 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
1411 .unwrap_or(false)
1412}
1413
1414fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
1416 let mut images = Vec::new();
1417
1418 let walker = walkdir::WalkDir::new(dir).max_depth(1).into_iter();
1419 for entry in walker.filter_map(|e| e.ok()) {
1420 let path = entry.path();
1421 if !path.is_file() {
1422 continue;
1423 }
1424
1425 let ext = path
1426 .extension()
1427 .and_then(|e| e.to_str())
1428 .map(|e| e.to_lowercase());
1429 if !matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg")) {
1430 continue;
1431 }
1432
1433 let timestamp = entry
1434 .metadata()
1435 .ok()
1436 .and_then(|m| m.modified().ok())
1437 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1438 .map(|d| d.as_secs())
1439 .unwrap_or(0);
1440
1441 let filename = path
1442 .file_name()
1443 .map(|f| f.to_string_lossy().to_string())
1444 .unwrap_or_default();
1445
1446 let meta = if ext.as_deref() == Some("png") {
1447 read_png_metadata(path)
1448 } else {
1449 read_jpeg_metadata(path)
1450 };
1451
1452 if let Some(meta) = meta {
1453 images.push(mold_core::GalleryImage {
1454 filename,
1455 metadata: meta,
1456 timestamp,
1457 });
1458 }
1459 }
1460
1461 images.sort_by_key(|img| std::cmp::Reverse(img.timestamp));
1462 images
1463}
1464
1465fn read_png_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1467 let file = std::fs::File::open(path).ok()?;
1468 let decoder = png::Decoder::new(std::io::BufReader::new(file));
1469 let reader = decoder.read_info().ok()?;
1470 let info = reader.info();
1471
1472 for chunk in &info.uncompressed_latin1_text {
1473 if chunk.keyword == "mold:parameters" {
1474 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&chunk.text) {
1475 return Some(meta);
1476 }
1477 }
1478 }
1479 for chunk in &info.utf8_text {
1480 if chunk.keyword == "mold:parameters" {
1481 if let Ok(text) = chunk.get_text() {
1482 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(&text) {
1483 return Some(meta);
1484 }
1485 }
1486 }
1487 }
1488 None
1489}
1490
1491fn read_jpeg_metadata(path: &std::path::Path) -> Option<mold_core::OutputMetadata> {
1493 let data = std::fs::read(path).ok()?;
1494 let mut i = 0;
1495 while i + 1 < data.len() {
1496 if data[i] != 0xFF {
1497 i += 1;
1498 continue;
1499 }
1500 let marker = data[i + 1];
1501 match marker {
1502 0xD8 | 0x01 => {
1504 i += 2;
1505 }
1506 0xD9 => break, 0xD0..=0xD7 => {
1508 i += 2; }
1510 0xFE => {
1512 if i + 3 >= data.len() {
1513 break;
1514 }
1515 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1516 if len < 2 || i + 2 + len > data.len() {
1517 break;
1518 }
1519 let comment = &data[i + 4..i + 2 + len];
1520 if let Ok(text) = std::str::from_utf8(comment) {
1521 if let Some(json) = text.strip_prefix("mold:parameters ") {
1522 if let Ok(meta) = serde_json::from_str::<mold_core::OutputMetadata>(json) {
1523 return Some(meta);
1524 }
1525 }
1526 }
1527 i += 2 + len;
1528 }
1529 _ => {
1531 if i + 3 >= data.len() {
1532 break;
1533 }
1534 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1535 if len < 2 || i + 2 + len > data.len() {
1536 break;
1537 }
1538 i += 2 + len;
1539 }
1540 }
1541 }
1542 None
1543}
1544
1545async fn openapi_json() -> impl IntoResponse {
1548 Json(ApiDoc::openapi())
1549}
1550
1551async fn scalar_docs() -> impl IntoResponse {
1554 (
1555 [(header::CONTENT_TYPE, "text/html")],
1556 r#"<!DOCTYPE html>
1557<html>
1558<head>
1559 <title>mold API</title>
1560 <meta charset="utf-8" />
1561 <meta name="viewport" content="width=device-width, initial-scale=1" />
1562</head>
1563<body>
1564 <script id="api-reference" data-url="/api/openapi.json"></script>
1565 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
1566</body>
1567</html>"#,
1568 )
1569}
1570
1571fn query_gpu_info() -> Option<GpuInfo> {
1574 let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
1575 "/run/current-system/sw/bin/nvidia-smi"
1576 } else {
1577 "nvidia-smi"
1578 };
1579
1580 let output = std::process::Command::new(nvidia_smi)
1581 .args([
1582 "--query-gpu=name,memory.total,memory.used",
1583 "--format=csv,noheader,nounits",
1584 ])
1585 .output()
1586 .ok()?;
1587
1588 if !output.status.success() {
1589 return None;
1590 }
1591
1592 let text = String::from_utf8(output.stdout).ok()?;
1593 let line = text.lines().next()?;
1594 let parts: Vec<&str> = line.split(',').map(str::trim).collect();
1595 if parts.len() < 3 {
1596 return None;
1597 }
1598
1599 Some(GpuInfo {
1600 name: parts[0].to_string(),
1601 vram_total_mb: parts[1].parse().ok()?,
1602 vram_used_mb: parts[2].parse().ok()?,
1603 })
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608 use super::*;
1609
1610 fn env_lock() -> &'static std::sync::Mutex<()> {
1611 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1612 &ENV_LOCK
1613 }
1614
1615 #[test]
1616 fn clean_error_message_strips_backtrace() {
1617 let err = anyhow::anyhow!(
1618 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
1619 \x20 0: candle_core::error::Error::bt\n\
1620 \x20 at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
1621 \x20 1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
1622 \x20 at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
1623 );
1624 let msg = clean_error_message(&err);
1625 assert_eq!(
1626 msg,
1627 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
1628 );
1629 }
1630
1631 #[test]
1632 fn clean_error_message_preserves_simple_error() {
1633 let err = anyhow::anyhow!("model not found: flux-dev:q4");
1634 let msg = clean_error_message(&err);
1635 assert_eq!(msg, "model not found: flux-dev:q4");
1636 }
1637
1638 #[test]
1639 fn clean_error_message_preserves_multiline_without_backtrace() {
1640 let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
1641 let msg = clean_error_message(&err);
1642 assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
1643 }
1644
1645 #[test]
1646 fn clean_error_message_strips_high_numbered_frames() {
1647 let err = anyhow::anyhow!(
1648 "some error\n\
1649 \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
1650 \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
1651 );
1652 let msg = clean_error_message(&err);
1653 assert_eq!(msg, "some error");
1654 }
1655
1656 #[test]
1657 fn clean_error_message_empty_fallback() {
1658 let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
1660 let msg = clean_error_message(&err);
1661 assert!(!msg.is_empty());
1663 }
1664
1665 #[test]
1666 fn save_image_to_dir_creates_directory_and_writes_file() {
1667 let dir = std::env::temp_dir().join(format!(
1668 "mold-save-test-{}",
1669 std::time::SystemTime::now()
1670 .duration_since(std::time::UNIX_EPOCH)
1671 .unwrap()
1672 .as_nanos()
1673 ));
1674 assert!(!dir.exists());
1675
1676 let img = mold_core::ImageData {
1677 data: vec![0x89, 0x50, 0x4E, 0x47], format: mold_core::OutputFormat::Png,
1679 width: 64,
1680 height: 64,
1681 index: 0,
1682 };
1683
1684 save_image_to_dir(&dir, &img, "test-model:q8", 1);
1685
1686 assert!(dir.exists(), "directory should be created");
1687 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1688 assert_eq!(files.len(), 1, "should have exactly one file");
1689 let file = files[0].as_ref().unwrap();
1690 let filename = file.file_name().to_str().unwrap().to_string();
1691 assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
1692 assert!(filename.ends_with(".png"), "{filename}");
1693 let contents = std::fs::read(file.path()).unwrap();
1694 assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
1695
1696 std::fs::remove_dir_all(&dir).ok();
1697 }
1698
1699 #[test]
1700 fn save_image_to_dir_batch_includes_index() {
1701 let dir = std::env::temp_dir().join(format!(
1702 "mold-save-batch-{}",
1703 std::time::SystemTime::now()
1704 .duration_since(std::time::UNIX_EPOCH)
1705 .unwrap()
1706 .as_nanos()
1707 ));
1708
1709 let img = mold_core::ImageData {
1710 data: vec![0xFF, 0xD8], format: mold_core::OutputFormat::Jpeg,
1712 width: 64,
1713 height: 64,
1714 index: 2,
1715 };
1716
1717 save_image_to_dir(&dir, &img, "flux-dev", 4);
1718
1719 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
1720 assert_eq!(files.len(), 1);
1721 let filename = files[0]
1722 .as_ref()
1723 .unwrap()
1724 .file_name()
1725 .to_str()
1726 .unwrap()
1727 .to_string();
1728 assert!(
1729 filename.contains("-2.jpeg"),
1730 "batch index in name: {filename}"
1731 );
1732
1733 std::fs::remove_dir_all(&dir).ok();
1734 }
1735
1736 #[test]
1737 fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
1738 let img = mold_core::ImageData {
1740 data: vec![0x00],
1741 format: mold_core::OutputFormat::Png,
1742 width: 1,
1743 height: 1,
1744 index: 0,
1745 };
1746 save_image_to_dir(
1748 std::path::Path::new("/dev/null/impossible"),
1749 &img,
1750 "test",
1751 1,
1752 );
1753 }
1755
1756 #[test]
1757 fn thumbnail_warmup_is_disabled_by_default() {
1758 let _guard = env_lock().lock().unwrap();
1759 unsafe {
1760 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1761 }
1762 assert!(!thumbnail_warmup_enabled());
1763 }
1764
1765 #[test]
1766 fn thumbnail_warmup_accepts_truthy_env_values() {
1767 let _guard = env_lock().lock().unwrap();
1768 unsafe {
1769 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "1");
1770 }
1771 assert!(thumbnail_warmup_enabled());
1772 unsafe {
1773 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "true");
1774 }
1775 assert!(thumbnail_warmup_enabled());
1776 unsafe {
1777 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "YES");
1778 }
1779 assert!(thumbnail_warmup_enabled());
1780 unsafe {
1781 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1782 }
1783 }
1784
1785 #[test]
1786 fn thumbnail_warmup_rejects_falsey_env_values() {
1787 let _guard = env_lock().lock().unwrap();
1788 unsafe {
1789 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "0");
1790 }
1791 assert!(!thumbnail_warmup_enabled());
1792 unsafe {
1793 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "false");
1794 }
1795 assert!(!thumbnail_warmup_enabled());
1796 unsafe {
1797 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
1798 }
1799 }
1800}