1use axum::{
2 extract::{Path, Request, State},
3 http::{header, HeaderMap, HeaderValue, StatusCode},
4 response::{
5 sse::{Event as SseEvent, KeepAlive, Sse},
6 IntoResponse,
7 },
8 routing::{delete, get, patch, post},
9 Json, Router,
10};
11use base64::Engine as _;
12use mold_core::{
13 types::GpuSelection, ActiveGenerationStatus, GenerateRequest, GpuBackend, GpuInfo,
14 GpuWorkerState, ModelInfoExtended, ResourceSnapshot, ServerStatus, SseErrorEvent,
15 SseProgressEvent,
16};
17use serde::{Deserialize, Serialize};
18use std::cmp::Reverse;
19use std::convert::Infallible;
20use std::sync::atomic::Ordering;
21use tokio_stream::StreamExt as _;
22use utoipa::OpenApi;
23
24use crate::model_manager;
25use crate::state::{AppState, GenerationJob, SseMessage, SubmitError};
26
27fn submit_error_to_api(e: SubmitError) -> ApiError {
28 match e {
29 SubmitError::Full { pending, capacity } => {
30 ApiError::queue_full(format!("generation queue is full ({pending}/{capacity})"))
31 }
32 SubmitError::Shutdown => ApiError::internal("generation queue shut down"),
33 }
34}
35
36#[derive(Debug, Serialize)]
39pub struct ApiError {
40 pub error: String,
41 pub code: String,
42 #[serde(skip)]
43 status: StatusCode,
44}
45
46impl ApiError {
47 pub fn validation(msg: impl Into<String>) -> Self {
48 Self {
49 error: msg.into(),
50 code: "VALIDATION_ERROR".to_string(),
51 status: StatusCode::UNPROCESSABLE_ENTITY,
52 }
53 }
54
55 pub fn not_found(msg: impl Into<String>) -> Self {
56 Self {
57 error: msg.into(),
58 code: "MODEL_NOT_FOUND".to_string(),
59 status: StatusCode::NOT_FOUND,
60 }
61 }
62
63 pub fn unknown_model(msg: impl Into<String>) -> Self {
64 Self {
65 error: msg.into(),
66 code: "UNKNOWN_MODEL".to_string(),
67 status: StatusCode::BAD_REQUEST,
68 }
69 }
70
71 pub fn inference(msg: impl Into<String>) -> Self {
72 Self {
73 error: msg.into(),
74 code: "INFERENCE_ERROR".to_string(),
75 status: StatusCode::INTERNAL_SERVER_ERROR,
76 }
77 }
78
79 pub fn internal(msg: impl Into<String>) -> Self {
80 Self {
81 error: msg.into(),
82 code: "INTERNAL_ERROR".to_string(),
83 status: StatusCode::INTERNAL_SERVER_ERROR,
84 }
85 }
86
87 pub fn internal_with_status(msg: impl Into<String>, status: StatusCode) -> Self {
88 Self {
89 error: msg.into(),
90 code: "INTERNAL_ERROR".to_string(),
91 status,
92 }
93 }
94
95 pub fn with_code(msg: impl Into<String>, code: impl Into<String>, status: StatusCode) -> Self {
96 Self {
97 error: msg.into(),
98 code: code.into(),
99 status,
100 }
101 }
102
103 pub fn queue_job_not_found(msg: impl Into<String>) -> Self {
104 Self {
105 error: msg.into(),
106 code: "QUEUE_JOB_NOT_FOUND".to_string(),
107 status: StatusCode::NOT_FOUND,
108 }
109 }
110
111 pub fn queue_job_running(msg: impl Into<String>) -> Self {
112 Self {
113 error: msg.into(),
114 code: "QUEUE_JOB_RUNNING".to_string(),
115 status: StatusCode::CONFLICT,
116 }
117 }
118
119 pub fn cancelled(msg: impl Into<String>) -> Self {
124 Self {
125 error: msg.into(),
126 code: "CANCELLED".to_string(),
127 status: StatusCode::from_u16(499).expect("499 is a valid status code"),
128 }
129 }
130
131 pub fn insufficient_memory(msg: impl Into<String>) -> Self {
132 Self {
133 error: msg.into(),
134 code: "INSUFFICIENT_MEMORY".to_string(),
135 status: StatusCode::SERVICE_UNAVAILABLE,
136 }
137 }
138
139 pub fn forbidden(msg: impl Into<String>) -> Self {
140 Self {
141 error: msg.into(),
142 code: "FORBIDDEN".to_string(),
143 status: StatusCode::FORBIDDEN,
144 }
145 }
146
147 pub fn queue_full(msg: impl Into<String>) -> Self {
148 Self {
149 error: msg.into(),
150 code: "QUEUE_FULL".to_string(),
151 status: StatusCode::SERVICE_UNAVAILABLE,
152 }
153 }
154}
155
156impl IntoResponse for ApiError {
157 fn into_response(self) -> axum::response::Response {
158 let status = self.status;
159 if self.code == "QUEUE_FULL" {
161 let mut headers = HeaderMap::new();
162 headers.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
163 return (status, headers, Json(self)).into_response();
164 }
165 (status, Json(self)).into_response()
166 }
167}
168
169#[cfg(test)]
171use crate::queue::clean_error_message;
172
173#[derive(OpenApi)]
174#[openapi(
175 paths(
176 generate,
177 generate_stream,
178 expand_prompt,
179 list_models,
180 crate::catalog_api::list_loras,
181 load_model,
182 pull_model_endpoint,
183 unload_model,
184 delete_model,
185 server_status,
186 list_queue,
187 patch_queue_job,
188 cancel_queue_job,
189 pause_queue,
190 resume_queue,
191 cancel_all_queue,
192 list_history,
193 delete_history,
194 crate::routes_config::list_config,
195 crate::routes_config::get_config_key,
196 crate::routes_config::put_config_key,
197 crate::routes_config::delete_config_key,
198 crate::routes_config::list_config_profiles,
199 crate::routes_config::put_config_profile,
200 health,
201 capabilities_chain_limits,
202 stream_events,
203 crate::routes_chain::generate_chain,
204 crate::routes_chain::generate_chain_stream,
205 crate::routes_chain_jobs::create_chain_job,
206 crate::routes_chain_jobs::list_chain_jobs,
207 crate::routes_chain_jobs::get_chain_job,
208 crate::routes_chain_jobs::chain_job_events,
209 crate::routes_chain_jobs::resume_chain_job,
210 crate::routes_chain_jobs::retake_chain_job,
211 crate::routes_chain_jobs::cancel_chain_job,
212 crate::routes_chain_jobs::delete_chain_job,
213 crate::routes_chain_jobs::gc_chain_jobs,
214 crate::routes_chain_jobs::chain_job_stage_preview,
215 ),
216 components(schemas(
217 mold_core::GenerateRequest,
218 mold_core::GenerateResponse,
219 mold_core::ExpandRequest,
220 mold_core::ExpandResponse,
221 mold_core::ImageData,
222 mold_core::OutputFormat,
223 mold_core::ModelInfo,
224 mold_core::LoraInfo,
225 mold_core::ServerStatus,
226 mold_core::ActiveGenerationStatus,
227 mold_core::GpuInfo,
228 mold_core::SseProgressEvent,
229 mold_core::SseCompleteEvent,
230 mold_core::SseErrorEvent,
231 mold_core::ChainRequest,
232 mold_core::ChainResponse,
233 mold_core::ChainStage,
234 mold_core::ChainProgressEvent,
235 mold_core::SseChainCompleteEvent,
236 mold_core::chain_job::ChainJobSummary,
237 mold_core::chain_job::ChainJobStageDetail,
238 mold_core::chain_job::ChainJobDetail,
239 mold_core::chain_job::ChainJobListing,
240 mold_core::chain_job::CreateChainJobResponse,
241 mold_core::chain_job::RetakeRequest,
242 mold_core::chain_job::ChainJobEvent,
243 mold_core::chain_job::FinalizeRecord,
244 mold_core::chain_job::RetakeAmendment,
245 mold_core::chain_job::ChainJobState,
246 mold_core::chain_job::StageState,
247 mold_core::chain_job::RetakeMode,
248 mold_core::chain_job::GcOutcome,
249 ModelInfoExtended,
250 LoadModelBody,
251 UnloadRequest,
252 mold_core::ModelRemovalResponse,
253 mold_core::KeptComponent,
254 QueuePatchRequest,
255 QueuePauseResponse,
256 QueueCancelAllResponse,
257 mold_core::HistoryEntry,
258 mold_core::HistoryListing,
259 mold_core::ConfigEntry,
260 mold_core::ConfigListing,
261 mold_core::ConfigProfiles,
262 crate::routes_config::ConfigSetRequest,
263 crate::routes_config::ProfileSetRequest,
264 crate::job_registry::JobEntry,
265 crate::job_registry::QueueListing,
266 crate::chain_limits::ChainLimits,
267 )),
268 tags(
269 (name = "generation", description = "Image generation"),
270 (name = "models", description = "Model management"),
271 (name = "server", description = "Server status and health"),
272 (name = "chain-jobs", description = "Durable chained video jobs"),
273 ),
274 info(
275 title = "mold",
276 description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
277 version = env!("CARGO_PKG_VERSION"),
278 )
279)]
280pub struct ApiDoc;
281
282pub fn create_router(state: AppState) -> Router {
283 Router::new()
286 .route("/api/generate", post(generate))
287 .route("/api/generate/estimate", post(generate_estimate))
288 .route("/api/generate/stream", post(generate_stream))
289 .route(
290 "/api/generate/chain",
291 post(crate::routes_chain::generate_chain),
292 )
293 .route(
294 "/api/generate/chain/stream",
295 post(crate::routes_chain::generate_chain_stream),
296 )
297 .route(
298 "/api/chain-jobs",
299 post(crate::routes_chain_jobs::create_chain_job)
300 .get(crate::routes_chain_jobs::list_chain_jobs),
301 )
302 .route(
303 "/api/chain-jobs/:id",
304 get(crate::routes_chain_jobs::get_chain_job)
305 .delete(crate::routes_chain_jobs::delete_chain_job),
306 )
307 .route(
308 "/api/chain-jobs/:id/events",
309 get(crate::routes_chain_jobs::chain_job_events),
310 )
311 .route(
312 "/api/chain-jobs/:id/resume",
313 post(crate::routes_chain_jobs::resume_chain_job),
314 )
315 .route(
316 "/api/chain-jobs/:id/retake",
317 post(crate::routes_chain_jobs::retake_chain_job),
318 )
319 .route(
320 "/api/chain-jobs/:id/cancel",
321 post(crate::routes_chain_jobs::cancel_chain_job),
322 )
323 .route(
324 "/api/chain-jobs/gc",
325 post(crate::routes_chain_jobs::gc_chain_jobs),
326 )
327 .route(
328 "/api/chain-jobs/:id/stages/:idx/preview",
329 get(crate::routes_chain_jobs::chain_job_stage_preview),
330 )
331 .route("/api/expand", post(expand_prompt))
332 .route("/api/models", get(list_models))
333 .route("/api/models/:model", delete(delete_model))
334 .route("/api/models/:model/components", get(model_components))
335 .route("/api/loras", get(crate::catalog_api::list_loras))
336 .route("/api/models/load", post(load_model))
337 .route("/api/models/pull", post(pull_model_endpoint))
338 .route("/api/models/unload", delete(unload_model))
339 .route("/api/gallery", get(list_gallery))
340 .route(
341 "/api/gallery/image/:filename",
342 get(get_gallery_image).delete(delete_gallery_image),
343 )
344 .route(
345 "/api/gallery/thumbnail/:filename",
346 get(get_gallery_thumbnail),
347 )
348 .route("/api/gallery/preview/:filename", get(get_gallery_preview))
349 .route("/api/downloads", get(list_downloads).post(create_download))
351 .route("/api/downloads/:id", delete(delete_download))
352 .route("/api/downloads/stream", get(stream_downloads))
353 .route(
355 "/api/catalog/families",
356 get(crate::catalog_api::list_families),
357 )
358 .route(
359 "/api/catalog/search",
360 get(crate::catalog_api::live_search_catalog),
361 )
362 .route(
363 "/api/catalog/installed",
364 get(crate::catalog_api::list_installed_catalog),
365 )
366 .route(
367 "/api/catalog/*id",
368 get(crate::catalog_api::get_catalog_entry)
369 .post(crate::catalog_api::post_catalog_dispatch),
370 )
371 .route("/api/upscale", post(upscale))
372 .route("/api/upscale/stream", post(upscale_stream))
373 .route("/api/resources", get(get_resources))
374 .route("/api/resources/stream", get(get_resources_stream))
375 .route("/api/events", get(stream_events))
376 .route("/api/status", get(server_status))
377 .route("/api/queue", get(list_queue).delete(cancel_all_queue))
378 .route("/api/queue/pause", post(pause_queue))
379 .route("/api/queue/resume", post(resume_queue))
380 .route(
381 "/api/queue/:id",
382 patch(patch_queue_job).delete(cancel_queue_job),
383 )
384 .route("/api/history", get(list_history).delete(delete_history))
385 .route("/api/capabilities", get(server_capabilities))
386 .route(
387 "/api/capabilities/chain-limits",
388 get(capabilities_chain_limits),
389 )
390 .route("/api/shutdown", post(shutdown_server))
391 .route("/api/config", get(crate::routes_config::list_config))
393 .route(
394 "/api/config/profiles",
395 get(crate::routes_config::list_config_profiles),
396 )
397 .route(
398 "/api/config/profile",
399 axum::routing::put(crate::routes_config::put_config_profile),
400 )
401 .route(
402 "/api/config/:key",
403 get(crate::routes_config::get_config_key)
404 .put(crate::routes_config::put_config_key)
405 .delete(crate::routes_config::delete_config_key),
406 )
407 .route(
409 "/api/config/model/:name/placement",
410 get(get_model_placement)
411 .put(put_model_placement)
412 .delete(delete_model_placement),
413 )
414 .route("/health", get(health))
415 .with_state(state)
416 .route("/api/openapi.json", get(openapi_json))
417 .route("/api/docs", get(scalar_docs))
418}
419
420fn sse_message_to_event(msg: SseMessage) -> SseEvent {
423 fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
424 match serde_json::to_string(payload) {
425 Ok(data) => SseEvent::default().event(event_name).data(data),
426 Err(err) => SseEvent::default().event("error").data(
427 serde_json::json!({
428 "message": format!("failed to serialize SSE payload: {err}")
429 })
430 .to_string(),
431 ),
432 }
433 }
434
435 match msg {
436 SseMessage::Progress(payload) => serialize_event("progress", &payload),
437 SseMessage::Complete(payload) => serialize_event("complete", &payload),
438 SseMessage::UpscaleComplete(payload) => serialize_event("complete", &payload),
439 SseMessage::Error(payload) => serialize_event("error", &payload),
440 }
441}
442
443#[cfg(test)]
444fn save_image_to_dir(
445 dir: &std::path::Path,
446 img: &mold_core::ImageData,
447 model: &str,
448 batch_size: u32,
449) {
450 if let Err(e) = std::fs::create_dir_all(dir) {
451 tracing::warn!("failed to create output dir {}: {e}", dir.display());
452 return;
453 }
454 let timestamp_ms = std::time::SystemTime::now()
457 .duration_since(std::time::UNIX_EPOCH)
458 .unwrap_or_default()
459 .as_millis() as u64;
460 let ext = img.format.to_string();
461 let filename =
462 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
463 let path = dir.join(&filename);
464 match std::fs::write(&path, &img.data) {
465 Ok(()) => tracing::info!("saved image to {}", path.display()),
466 Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
467 }
468}
469
470async fn prepare_generation(
478 state: &AppState,
479 request: &mut mold_core::GenerateRequest,
480) -> Result<(Option<std::path::PathBuf>, Option<String>, Option<usize>), ApiError> {
481 apply_default_metadata_setting(state, request).await;
486
487 let preferred_gpu = validate_multi_gpu_placement(state, request.placement.as_ref())?;
488
489 maybe_expand_prompt(state, request, preferred_gpu).await?;
491
492 if let Err(e) = model_manager::install_catalog_model(state, &request.model).await {
502 return Err(model_manager::install_error_to_api_error(&e));
503 }
504 let family_hint = model_manager::catalog_family_for(state, &request.model).await;
505
506 let resolved_family = model_manager::family_for_model(state, &request.model).await;
510 request.normalise_output_format(resolved_family.as_deref());
514
515 if let Err(e) = validate_generate_request(request, family_hint.as_deref()) {
516 return Err(ApiError::validation(e));
517 }
518
519 resolve_server_local_media_paths(state, request).await?;
520
521 let _ = model_manager::check_model_available(state, &request.model).await?;
522
523 let (output_dir, dim_warning) = {
524 let config = state.config.read().await;
525 let output_dir = if config.is_output_disabled() {
526 None
527 } else {
528 Some(config.effective_output_dir())
529 };
530 let family = config.resolved_model_config(&request.model).family;
531 let dim_warning = family
532 .as_deref()
533 .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
534 (output_dir, dim_warning)
535 };
536
537 Ok((output_dir, dim_warning, preferred_gpu))
538}
539
540fn record_prompt_history(state: &AppState, prompt: &str, negative: Option<&str>, model: &str) {
546 let Some(db) = state.metadata_db.as_ref().as_ref() else {
547 return;
548 };
549 let history = mold_db::PromptHistory::new(db);
550 if let Ok(rows) = history.recent(1) {
551 if rows.first().is_some_and(|latest| {
552 latest.prompt == prompt
553 && latest.model == model
554 && latest.negative.as_deref() == negative
555 }) {
556 return;
557 }
558 }
559 if let Err(e) = history.push(&mold_db::HistoryEntry {
560 prompt: prompt.to_string(),
561 negative: negative.map(str::to_string),
562 model: model.to_string(),
563 created_at_ms: 0, }) {
565 tracing::warn!("failed to record prompt history: {e:#}");
566 }
567}
568
569pub(crate) async fn resolve_server_local_media_paths(
570 state: &AppState,
571 request: &mut mold_core::GenerateRequest,
572) -> Result<(), ApiError> {
573 if request.audio_file_path.is_none() && request.source_video_path.is_none() {
574 return Ok(());
575 }
576
577 let roots = state.config.read().await.resolved_media_roots();
578 if let Some(path) = request.audio_file_path.as_deref() {
579 let resolved = mold_core::resolve_server_media_path(path, &roots)
580 .map_err(|e| ApiError::validation(format!("audio_file_path: {e}")))?;
581 request.audio_file_path = Some(resolved.to_string_lossy().to_string());
582 }
583 if let Some(path) = request.source_video_path.as_deref() {
584 let resolved = mold_core::resolve_server_media_path(path, &roots)
585 .map_err(|e| ApiError::validation(format!("source_video_path: {e}")))?;
586 request.source_video_path = Some(resolved.to_string_lossy().to_string());
587 }
588
589 Ok(())
590}
591
592fn active_gpu_selection(state: &AppState) -> GpuSelection {
593 let ordinals: Vec<usize> = state
594 .gpu_pool
595 .workers
596 .iter()
597 .map(|w| w.gpu.ordinal)
598 .collect();
599 if ordinals.is_empty() {
600 GpuSelection::All
601 } else {
602 GpuSelection::Specific(ordinals)
603 }
604}
605
606fn validate_multi_gpu_placement(
607 state: &AppState,
608 placement: Option<&mold_core::types::DevicePlacement>,
609) -> Result<Option<usize>, ApiError> {
610 state
611 .gpu_pool
612 .resolve_explicit_placement_gpu(placement)
613 .map_err(ApiError::validation)
614}
615
616fn select_aux_worker(
617 state: &AppState,
618) -> Result<std::sync::Arc<crate::gpu_pool::GpuWorker>, ApiError> {
619 let mut workers: Vec<_> = state
620 .gpu_pool
621 .workers
622 .iter()
623 .filter(|w| !w.is_degraded())
624 .cloned()
625 .collect();
626 workers.sort_by_key(|w| {
627 (
628 w.in_flight.load(Ordering::SeqCst),
629 Reverse(w.gpu.total_vram_bytes),
630 )
631 });
632 workers
633 .into_iter()
634 .next()
635 .ok_or_else(|| ApiError::internal("no GPU worker available for auxiliary workload"))
636}
637
638fn clear_global_upscaler_cache(state: &AppState) {
639 if let Ok(mut cache) = state.upscaler_cache.try_lock() {
640 if cache.is_some() {
641 *cache = None;
642 tracing::info!("upscaler cache cleared");
643 }
644 }
645}
646
647#[utoipa::path(
650 post,
651 path = "/api/generate",
652 tag = "generation",
653 request_body = mold_core::GenerateRequest,
654 responses(
655 (status = 200, description = "Generated image bytes", content_type = "image/png"),
656 (status = 404, description = "Model not downloaded"),
657 (status = 422, description = "Invalid request parameters"),
658 (status = 500, description = "Inference error"),
659 (status = 503, description = "Generation queue full"),
660 )
661)]
662async fn generate(
665 State(state): State<AppState>,
666 Json(mut req): Json<mold_core::GenerateRequest>,
667) -> Result<impl IntoResponse, ApiError> {
668 let typed = (
671 req.prompt.clone(),
672 req.negative_prompt.clone(),
673 req.model.clone(),
674 );
675 let (output_dir, dim_warning, preferred_gpu) = prepare_generation(&state, &mut req).await?;
676 record_prompt_history(&state, &typed.0, typed.1.as_deref(), &typed.2);
677
678 tracing::info!(
679 model = %req.model,
680 prompt = %req.prompt,
681 width = req.width,
682 height = req.height,
683 steps = req.steps,
684 guidance = req.guidance,
685 seed = ?req.seed,
686 format = %req.resolved_output_format(),
687 lora = ?req.lora.as_ref().map(|l| &l.path),
688 lora_scale = ?req.lora.as_ref().map(|l| l.scale),
689 loras = ?req.loras.as_ref().map(|v| v.iter().map(|l| &l.path).collect::<Vec<_>>()),
690 "generate request"
691 );
692
693 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
695 let job_id = uuid::Uuid::new_v4().to_string();
700 let cancel = state
701 .job_registry
702 .register_with_target_gpu(&job_id, &req.model, preferred_gpu);
703 let job = GenerationJob {
704 id: job_id.clone(),
705 request: req,
706 progress_tx: None,
707 result_tx,
708 output_dir,
709 };
710
711 let _position = state
712 .queue
713 .submit(job, state.queue_capacity)
714 .await
715 .inspect_err(|_| state.job_registry.remove(&job_id))
716 .map_err(submit_error_to_api)?;
717
718 let result = tokio::select! {
723 result = result_rx => result
724 .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?,
725 _ = cancel.notified() => {
726 return Err(ApiError::cancelled(format!(
727 "generation job {job_id} was cancelled while queued"
728 )));
729 }
730 };
731
732 match result {
733 Ok(job_result) => {
734 let img = job_result.image;
735 let response = job_result.response;
736 let content_type = HeaderValue::from_static(img.format.content_type());
737 let mut headers = HeaderMap::new();
738 headers.insert(header::CONTENT_TYPE, content_type);
739 headers.insert(
740 "x-mold-seed-used",
741 HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
742 ApiError::internal(format!("failed to serialize seed header: {e}"))
743 })?,
744 );
745 if let Some(ordinal) = response.gpu {
746 headers.insert(
747 "x-mold-gpu",
748 HeaderValue::from_str(&ordinal.to_string()).map_err(|e| {
749 ApiError::internal(format!("failed to serialize gpu header: {e}"))
750 })?,
751 );
752 }
753 if let Some(warning) = dim_warning {
754 match HeaderValue::from_str(&warning.replace('\n', " ")) {
755 Ok(val) => {
756 headers.insert("x-mold-dimension-warning", val);
757 }
758 Err(e) => {
759 tracing::warn!("dimension warning could not be encoded as header: {e}");
760 }
761 }
762 }
763 let output_data = if let Some(ref video) = response.video {
766 let ct = HeaderValue::from_static(video.format.content_type());
767 headers.insert(header::CONTENT_TYPE, ct);
768 if let Ok(v) = HeaderValue::from_str(&video.frames.to_string()) {
769 headers.insert("x-mold-video-frames", v);
770 }
771 if let Ok(v) = HeaderValue::from_str(&video.fps.to_string()) {
772 headers.insert("x-mold-video-fps", v);
773 }
774 if let Ok(v) = HeaderValue::from_str(&video.width.to_string()) {
775 headers.insert("x-mold-video-width", v);
776 }
777 if let Ok(v) = HeaderValue::from_str(&video.height.to_string()) {
778 headers.insert("x-mold-video-height", v);
779 }
780 if video.has_audio {
781 headers.insert("x-mold-video-has-audio", HeaderValue::from_static("1"));
782 }
783 if let Some(dur) = video.duration_ms {
784 if let Ok(v) = HeaderValue::from_str(&dur.to_string()) {
785 headers.insert("x-mold-video-duration-ms", v);
786 }
787 }
788 if let Some(sr) = video.audio_sample_rate {
789 if let Ok(v) = HeaderValue::from_str(&sr.to_string()) {
790 headers.insert("x-mold-video-audio-sample-rate", v);
791 }
792 }
793 if let Some(ch) = video.audio_channels {
794 if let Ok(v) = HeaderValue::from_str(&ch.to_string()) {
795 headers.insert("x-mold-video-audio-channels", v);
796 }
797 }
798 video.data.clone()
799 } else {
800 img.data
801 };
802 Ok((headers, output_data))
803 }
804 Err(err_msg) => {
805 if err_msg.contains("queue is full") {
809 Err(ApiError::queue_full(err_msg))
810 } else {
811 Err(ApiError::inference(err_msg))
812 }
813 }
814 }
815}
816
817fn validate_generate_request(
818 req: &mold_core::GenerateRequest,
819 family_hint: Option<&str>,
820) -> Result<(), String> {
821 mold_core::validate_generate_request_with_family(req, family_hint)
822}
823
824async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
825 if req.embed_metadata.is_some() {
826 return;
827 }
828
829 let config = state.config.read().await;
830 req.embed_metadata = Some(config.effective_embed_metadata(None));
831}
832
833async fn maybe_expand_prompt(
835 state: &AppState,
836 req: &mut mold_core::GenerateRequest,
837 preferred_gpu: Option<usize>,
838) -> Result<(), ApiError> {
839 if req.expand != Some(true) {
840 return Ok(());
841 }
842
843 let config = state.config.read().await;
844 let config_snapshot = config.clone();
845 let expand_settings = config.expand.clone().with_env_overrides();
846
847 let model_family = config
849 .resolved_model_config(&req.model)
850 .family
851 .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
852 .unwrap_or_else(|| {
853 tracing::warn!(
854 model = %req.model,
855 "could not resolve model family for prompt expansion, defaulting to \"flux\""
856 );
857 "flux".to_string()
858 });
859
860 let expand_config = expand_settings.to_expand_config(&model_family, 1);
861 let original_prompt = req.prompt.clone();
862
863 drop(config);
865
866 let expander = create_server_expander(
867 &config_snapshot,
868 &expand_settings,
869 active_gpu_selection(state),
870 preferred_gpu,
871 )?;
872 let result =
873 tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
874 .await
875 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
876 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
877
878 if let Some(expanded) = result.expanded.first() {
879 req.original_prompt = Some(req.prompt.clone());
880 req.prompt = expanded.clone();
881 }
882
883 Ok(())
884}
885
886fn create_server_expander(
888 _config: &mold_core::Config,
889 settings: &mold_core::ExpandSettings,
890 _gpu_selection: GpuSelection,
891 _preferred_gpu: Option<usize>,
892) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
893 if let Some(api_expander) = settings.create_api_expander() {
894 return Ok(Box::new(api_expander));
895 }
896
897 #[cfg(feature = "expand")]
898 {
899 if let Some(local) =
900 mold_inference::expand::LocalExpander::from_config(_config, Some(&settings.model))
901 {
902 return Ok(Box::new(
903 local
904 .with_gpu_selection(_gpu_selection)
905 .with_preferred_gpu(_preferred_gpu),
906 ));
907 }
908 return Err(ApiError::validation(
909 "local expand model not found — run: mold pull qwen3-expand".to_string(),
910 ));
911 }
912
913 #[cfg(not(feature = "expand"))]
914 {
915 Err(ApiError::validation(
916 "local prompt expansion not available — built without expand feature. \
917 Configure an API backend in [expand] settings."
918 .to_string(),
919 ))
920 }
921}
922
923#[utoipa::path(
926 post,
927 path = "/api/expand",
928 tag = "generation",
929 request_body = mold_core::ExpandRequest,
930 responses(
931 (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
932 (status = 422, description = "Invalid request parameters"),
933 (status = 500, description = "Expansion failed"),
934 )
935)]
936async fn expand_prompt(
937 State(state): State<AppState>,
938 Json(req): Json<mold_core::ExpandRequest>,
939) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
940 if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
941 return Err(ApiError::validation(format!(
942 "variations must be between 1 and {}",
943 mold_core::expand::MAX_VARIATIONS,
944 )));
945 }
946
947 let config = state.config.read().await;
948 let expand_settings = config.expand.clone().with_env_overrides();
949 let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
950 let prompt = req.prompt.clone();
951 let config_snapshot = config.clone();
952 drop(config);
953
954 let expander = create_server_expander(
955 &config_snapshot,
956 &expand_settings,
957 active_gpu_selection(&state),
958 None,
959 )?;
960 let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
961 .await
962 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
963 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
964
965 Ok(Json(mold_core::ExpandResponse {
966 original: req.prompt,
967 expanded: result.expanded,
968 }))
969}
970
971async fn upscale(
974 State(state): State<AppState>,
975 Json(req): Json<mold_core::UpscaleRequest>,
976) -> Result<Json<mold_core::UpscaleResponse>, ApiError> {
977 if let Err(msg) = mold_core::validate_upscale_request(&req) {
978 return Err(ApiError::validation(msg));
979 }
980
981 let model_name = mold_core::manifest::resolve_model_name(&req.model);
982
983 let needs_pull = {
985 let config = state.config.read().await;
986 config
987 .models
988 .get(&model_name)
989 .and_then(|c| c.transformer.as_ref())
990 .is_none()
991 };
992 if needs_pull {
993 if mold_core::manifest::find_manifest(&model_name).is_none() {
994 return Err(ApiError::not_found(format!(
995 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
996 model_name
997 )));
998 }
999 model_manager::pull_model(&state, &model_name, None).await?;
1000 }
1001
1002 let config = state.config.read().await;
1003 let weights_path = config
1004 .models
1005 .get(&model_name)
1006 .and_then(|c| c.transformer.as_ref())
1007 .ok_or_else(|| {
1008 ApiError::not_found(format!(
1009 "upscaler model '{}' not configured after pull",
1010 model_name
1011 ))
1012 })?;
1013 let weights_path = std::path::PathBuf::from(weights_path);
1014 let model_name_owned = model_name.clone();
1015 drop(config);
1016
1017 let resp = if state.gpu_pool.worker_count() > 0 {
1018 let worker = select_aux_worker(&state)?;
1019 worker.in_flight.fetch_add(1, Ordering::SeqCst);
1020 let worker_clone = worker.clone();
1021 let result =
1022 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
1023 struct ThreadGpuGuard;
1024 impl Drop for ThreadGpuGuard {
1025 fn drop(&mut self) {
1026 mold_inference::device::clear_thread_gpu_ordinal();
1027 }
1028 }
1029
1030 mold_inference::device::init_thread_gpu_ordinal(worker_clone.gpu.ordinal);
1031 let _thread_gpu = ThreadGpuGuard;
1032 let _load_lock = worker_clone.model_load_lock.lock().unwrap();
1033 let mut engine = mold_inference::create_upscale_engine(
1034 model_name_owned,
1035 weights_path,
1036 mold_inference::LoadStrategy::Eager,
1037 worker_clone.gpu.ordinal,
1038 )?;
1039 engine.upscale(&req)
1040 })
1041 .await
1042 .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")));
1043 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1044 result?.map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?
1045 } else {
1046 let upscaler_cache = state.upscaler_cache.clone();
1047 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
1048 let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
1049
1050 let needs_new = cache
1052 .as_ref()
1053 .is_none_or(|e| e.model_name() != model_name_owned);
1054 if needs_new {
1055 let new_engine = mold_inference::create_upscale_engine(
1056 model_name_owned,
1057 weights_path,
1058 mold_inference::LoadStrategy::Eager,
1059 0,
1060 )?;
1061 *cache = Some(new_engine);
1062 }
1063
1064 cache.as_mut().unwrap().upscale(&req)
1065 })
1066 .await
1067 .map_err(|e| ApiError::internal(format!("upscale task panicked: {e}")))?
1068 .map_err(|e| ApiError::internal(format!("upscale failed: {e}")))?
1069 };
1070
1071 Ok(Json(resp))
1072}
1073
1074async fn upscale_stream(
1077 State(state): State<AppState>,
1078 Json(req): Json<mold_core::UpscaleRequest>,
1079) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
1080 if let Err(msg) = mold_core::validate_upscale_request(&req) {
1081 return Err(ApiError::validation(msg));
1082 }
1083
1084 let model_name = mold_core::manifest::resolve_model_name(&req.model);
1085
1086 let needs_pull = {
1088 let config = state.config.read().await;
1089 config
1090 .models
1091 .get(&model_name)
1092 .and_then(|c| c.transformer.as_ref())
1093 .is_none()
1094 };
1095
1096 if needs_pull && mold_core::manifest::find_manifest(&model_name).is_none() {
1098 return Err(ApiError::not_found(format!(
1099 "unknown upscaler model '{}'. Run 'mold list' to see available models.",
1100 model_name
1101 )));
1102 }
1103
1104 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1105 let model_name_owned = model_name.clone();
1106 let state_clone = state.clone();
1107 let upscaler_cache = state.upscaler_cache.clone();
1108
1109 tokio::spawn(async move {
1110 if needs_pull {
1112 let progress_tx = tx.clone();
1113 let callback =
1114 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
1115 let sse_event = match event {
1116 mold_core::download::DownloadProgressEvent::Status { message } => {
1117 SseProgressEvent::Info { message }
1118 }
1119 mold_core::download::DownloadProgressEvent::FileStart {
1120 filename,
1121 file_index,
1122 total_files,
1123 size_bytes,
1124 batch_bytes_downloaded,
1125 batch_bytes_total,
1126 batch_elapsed_ms,
1127 } => SseProgressEvent::DownloadProgress {
1128 filename,
1129 file_index,
1130 total_files,
1131 bytes_downloaded: 0,
1132 bytes_total: size_bytes,
1133 batch_bytes_downloaded,
1134 batch_bytes_total,
1135 batch_elapsed_ms,
1136 },
1137 mold_core::download::DownloadProgressEvent::FileProgress {
1138 filename,
1139 file_index,
1140 bytes_downloaded,
1141 bytes_total,
1142 batch_bytes_downloaded,
1143 batch_bytes_total,
1144 batch_elapsed_ms,
1145 } => SseProgressEvent::DownloadProgress {
1146 filename,
1147 file_index,
1148 total_files: 0,
1149 bytes_downloaded,
1150 bytes_total,
1151 batch_bytes_downloaded,
1152 batch_bytes_total,
1153 batch_elapsed_ms,
1154 },
1155 mold_core::download::DownloadProgressEvent::FileDone {
1156 filename,
1157 file_index,
1158 total_files,
1159 batch_bytes_downloaded,
1160 batch_bytes_total,
1161 batch_elapsed_ms,
1162 } => SseProgressEvent::DownloadDone {
1163 filename,
1164 file_index,
1165 total_files,
1166 batch_bytes_downloaded,
1167 batch_bytes_total,
1168 batch_elapsed_ms,
1169 },
1170 };
1171 let _ = progress_tx.send(SseMessage::Progress(sse_event));
1172 });
1173
1174 match model_manager::pull_model(&state_clone, &model_name_owned, Some(callback)).await {
1175 Ok(_) => {
1176 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1177 model: model_name_owned.clone(),
1178 }));
1179 }
1180 Err(e) => {
1181 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1182 message: format!("failed to pull upscaler model: {}", e.error),
1183 }));
1184 return;
1185 }
1186 }
1187 }
1188
1189 let weights_path = {
1191 let config = state_clone.config.read().await;
1192 config
1193 .models
1194 .get(&model_name_owned)
1195 .and_then(|c| c.transformer.as_ref())
1196 .map(std::path::PathBuf::from)
1197 };
1198
1199 let Some(weights_path) = weights_path else {
1200 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1201 message: format!(
1202 "upscaler model '{}' not configured after pull",
1203 model_name_owned
1204 ),
1205 }));
1206 return;
1207 };
1208
1209 let result = if state_clone.gpu_pool.worker_count() > 0 {
1210 match select_aux_worker(&state_clone) {
1211 Ok(worker) => {
1212 worker.in_flight.fetch_add(1, Ordering::SeqCst);
1213 let worker_clone = worker.clone();
1214 let tx_for_worker = tx.clone();
1215 let model_name_for_worker = model_name_owned.clone();
1216 let weights_path_for_worker = weights_path.clone();
1217 let req_for_worker = req.clone();
1218 let result = tokio::task::spawn_blocking(move || {
1219 struct ThreadGpuGuard;
1220 impl Drop for ThreadGpuGuard {
1221 fn drop(&mut self) {
1222 mold_inference::device::clear_thread_gpu_ordinal();
1223 }
1224 }
1225
1226 mold_inference::device::init_thread_gpu_ordinal(worker_clone.gpu.ordinal);
1227 let _thread_gpu = ThreadGpuGuard;
1228 let _load_lock = worker_clone.model_load_lock.lock().unwrap();
1229 let _ = tx_for_worker.send(SseMessage::Progress(
1230 mold_core::SseProgressEvent::StageStart {
1231 name: format!(
1232 "Loading upscaler model on GPU {}",
1233 worker_clone.gpu.ordinal
1234 ),
1235 },
1236 ));
1237 let mut engine = match mold_inference::create_upscale_engine(
1238 model_name_for_worker,
1239 weights_path_for_worker,
1240 mold_inference::LoadStrategy::Eager,
1241 worker_clone.gpu.ordinal,
1242 ) {
1243 Ok(engine) => engine,
1244 Err(e) => {
1245 let _ = tx_for_worker.send(SseMessage::Error(
1246 mold_core::SseErrorEvent {
1247 message: format!("failed to load upscaler: {e}"),
1248 },
1249 ));
1250 return;
1251 }
1252 };
1253
1254 let tx_progress = tx_for_worker.clone();
1255 engine.set_on_progress(Box::new(move |event| {
1256 let sse_event: mold_core::SseProgressEvent = event.into();
1257 let _ = tx_progress.send(SseMessage::Progress(sse_event));
1258 }));
1259
1260 match engine.upscale(&req_for_worker) {
1261 Ok(resp) => {
1262 let image_b64 = base64::engine::general_purpose::STANDARD
1263 .encode(&resp.image.data);
1264 let _ = tx_for_worker.send(SseMessage::UpscaleComplete(
1265 mold_core::SseUpscaleCompleteEvent {
1266 image: image_b64,
1267 format: resp.image.format,
1268 model: resp.model,
1269 scale_factor: resp.scale_factor,
1270 original_width: resp.original_width,
1271 original_height: resp.original_height,
1272 upscale_time_ms: resp.upscale_time_ms,
1273 },
1274 ));
1275 }
1276 Err(e) => {
1277 let _ = tx_for_worker.send(SseMessage::Error(
1278 mold_core::SseErrorEvent {
1279 message: format!("upscale failed: {e}"),
1280 },
1281 ));
1282 }
1283 }
1284
1285 engine.clear_on_progress();
1286 })
1287 .await;
1288 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1289 result
1290 }
1291 Err(e) => {
1292 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1293 message: e.error,
1294 }));
1295 return;
1296 }
1297 }
1298 } else {
1299 let model_name_for_cache = model_name_owned.clone();
1300 let weights_path_for_cache = weights_path.clone();
1301 let req_for_cache = req.clone();
1302 tokio::task::spawn_blocking(move || {
1303 let mut cache = upscaler_cache.lock().unwrap();
1304
1305 let needs_new = cache
1306 .as_ref()
1307 .is_none_or(|e| e.model_name() != model_name_for_cache);
1308 if needs_new {
1309 let _ = tx.send(SseMessage::Progress(
1310 mold_core::SseProgressEvent::StageStart {
1311 name: "Loading upscaler model".to_string(),
1312 },
1313 ));
1314 match mold_inference::create_upscale_engine(
1315 model_name_for_cache,
1316 weights_path_for_cache,
1317 mold_inference::LoadStrategy::Eager,
1318 0,
1319 ) {
1320 Ok(new_engine) => {
1321 *cache = Some(new_engine);
1322 }
1323 Err(e) => {
1324 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1325 message: format!("failed to load upscaler: {e}"),
1326 }));
1327 return;
1328 }
1329 }
1330 }
1331
1332 let engine = cache.as_mut().unwrap();
1333 let tx_progress = tx.clone();
1334 engine.set_on_progress(Box::new(move |event| {
1335 let sse_event: mold_core::SseProgressEvent = event.into();
1336 let _ = tx_progress.send(SseMessage::Progress(sse_event));
1337 }));
1338
1339 match engine.upscale(&req_for_cache) {
1340 Ok(resp) => {
1341 let image_b64 =
1342 base64::engine::general_purpose::STANDARD.encode(&resp.image.data);
1343 let _ = tx.send(SseMessage::UpscaleComplete(
1344 mold_core::SseUpscaleCompleteEvent {
1345 image: image_b64,
1346 format: resp.image.format,
1347 model: resp.model,
1348 scale_factor: resp.scale_factor,
1349 original_width: resp.original_width,
1350 original_height: resp.original_height,
1351 upscale_time_ms: resp.upscale_time_ms,
1352 },
1353 ));
1354 }
1355 Err(e) => {
1356 let _ = tx.send(SseMessage::Error(mold_core::SseErrorEvent {
1357 message: format!("upscale failed: {e}"),
1358 }));
1359 }
1360 }
1361
1362 engine.clear_on_progress();
1363 })
1364 .await
1365 };
1366
1367 if let Err(e) = result {
1368 tracing::error!("upscale task panicked: {e}");
1369 }
1370 });
1371
1372 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1373 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1374
1375 Ok(Sse::new(stream).keep_alive(
1376 KeepAlive::new()
1377 .interval(std::time::Duration::from_secs(15))
1378 .text("ping"),
1379 ))
1380}
1381
1382#[utoipa::path(
1385 post,
1386 path = "/api/generate/stream",
1387 tag = "generation",
1388 request_body = mold_core::GenerateRequest,
1389 responses(
1390 (status = 200, description = "SSE event stream with progress and result"),
1391 (status = 404, description = "Model not downloaded"),
1392 (status = 422, description = "Invalid request parameters"),
1393 (status = 500, description = "Inference error"),
1394 (status = 503, description = "Generation queue full"),
1395 )
1396)]
1397async fn generate_stream(
1398 State(state): State<AppState>,
1399 Json(mut req): Json<mold_core::GenerateRequest>,
1400) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
1401 let typed = (
1404 req.prompt.clone(),
1405 req.negative_prompt.clone(),
1406 req.model.clone(),
1407 );
1408 let (output_dir, dim_warning, preferred_gpu) = prepare_generation(&state, &mut req).await?;
1409 record_prompt_history(&state, &typed.0, typed.1.as_deref(), &typed.2);
1410
1411 tracing::info!(
1412 model = %req.model,
1413 prompt = %req.prompt,
1414 "generate/stream request"
1415 );
1416
1417 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1419
1420 if let Some(warning) = dim_warning {
1422 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
1423 message: warning,
1424 }));
1425 }
1426
1427 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
1428 let job_id = uuid::Uuid::new_v4().to_string();
1431 let cancel = state
1432 .job_registry
1433 .register_with_target_gpu(&job_id, &req.model, preferred_gpu);
1434 let job = GenerationJob {
1435 id: job_id.clone(),
1436 request: req,
1437 progress_tx: Some(tx.clone()),
1438 result_tx,
1439 output_dir,
1440 };
1441
1442 let position = state
1443 .queue
1444 .submit(job, state.queue_capacity)
1445 .await
1446 .inspect_err(|_| state.job_registry.remove(&job_id))
1447 .map_err(submit_error_to_api)?;
1448
1449 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
1452 position,
1453 id: job_id,
1454 }));
1455
1456 tokio::spawn(async move {
1463 tokio::select! {
1464 _ = result_rx => {}
1465 _ = cancel.notified() => {
1466 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1467 message: "cancelled".to_string(),
1468 }));
1469 }
1470 }
1471 drop(tx); });
1473
1474 let stream = async_stream::stream! {
1480 let mut rx = rx;
1481 while let Some(msg) = rx.recv().await {
1482 let is_error = matches!(msg, SseMessage::Error(_));
1483 yield Ok::<_, Infallible>(sse_message_to_event(msg));
1484 if is_error {
1485 break;
1486 }
1487 }
1488 };
1489
1490 Ok(Sse::new(stream).keep_alive(
1491 KeepAlive::new()
1492 .interval(std::time::Duration::from_secs(15))
1493 .text("ping"),
1494 ))
1495}
1496
1497#[utoipa::path(
1500 get,
1501 path = "/api/models",
1502 tag = "models",
1503 responses(
1504 (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
1505 )
1506)]
1507async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
1508 Json(model_manager::list_models(&state).await)
1509}
1510
1511async fn generate_estimate(
1512 State(state): State<AppState>,
1513 Json(req): Json<GenerateRequest>,
1514) -> Result<Json<mold_core::GenerationMemoryEstimate>, ApiError> {
1515 Ok(Json(
1516 model_manager::estimate_generation_memory(&state, &req).await?,
1517 ))
1518}
1519
1520async fn model_components(
1521 State(state): State<AppState>,
1522 Path(model): Path<String>,
1523) -> Result<Json<mold_core::ModelComponentsResponse>, ApiError> {
1524 Ok(Json(
1525 model_manager::model_component_status(&state, &model).await?,
1526 ))
1527}
1528
1529#[derive(Debug, Deserialize, utoipa::ToSchema)]
1532pub struct LoadModelBody {
1533 #[schema(example = "flux-schnell:q8")]
1534 pub model: String,
1535 #[serde(default, skip_serializing_if = "Option::is_none")]
1538 pub gpu: Option<usize>,
1539}
1540
1541#[utoipa::path(
1542 post,
1543 path = "/api/models/load",
1544 tag = "models",
1545 request_body = LoadModelBody,
1546 responses(
1547 (status = 200, description = "Model loaded successfully"),
1548 (status = 404, description = "Model not downloaded"),
1549 (status = 400, description = "Unknown model"),
1550 (status = 500, description = "Failed to load model"),
1551 )
1552)]
1553async fn load_model(
1554 State(state): State<AppState>,
1555 Json(body): Json<LoadModelBody>,
1556) -> Result<impl IntoResponse, ApiError> {
1557 if let Err(e) = model_manager::install_catalog_model(&state, &body.model).await {
1558 return Err(model_manager::install_error_to_api_error(&e));
1559 }
1560 let _ = model_manager::check_model_available(&state, &body.model).await?;
1561
1562 if state.gpu_pool.worker_count() > 0 {
1564 let worker = match body.gpu {
1565 Some(ordinal) => state
1566 .gpu_pool
1567 .workers
1568 .iter()
1569 .find(|w| w.gpu.ordinal == ordinal)
1570 .cloned()
1571 .ok_or_else(|| {
1572 ApiError::not_found(format!("no GPU worker with ordinal {ordinal}"))
1573 })?,
1574 None => {
1575 let est = crate::queue::estimate_model_vram(&body.model);
1576 state
1577 .gpu_pool
1578 .select_worker(&body.model, est)
1579 .ok_or_else(|| {
1580 ApiError::internal(format!(
1581 "no GPU available to load model '{}'",
1582 body.model
1583 ))
1584 })?
1585 }
1586 };
1587 let config_snapshot = state.config.read().await.clone();
1588 let model_name = body.model.clone();
1589 let worker_clone = worker.clone();
1590 tokio::task::spawn_blocking(move || {
1591 crate::gpu_worker::load_blocking(&worker_clone, &model_name, &config_snapshot)
1592 })
1593 .await
1594 .map_err(|e| ApiError::internal(format!("model load task failed: {e}")))?
1595 .map_err(|e| ApiError::internal(format!("model load error: {e}")))?;
1596
1597 tracing::info!(
1598 model = %body.model,
1599 gpu = worker.gpu.ordinal,
1600 "model loaded via API"
1601 );
1602 return Ok(StatusCode::OK);
1603 }
1604
1605 model_manager::ensure_model_ready(&state, &body.model, None, None, false).await?;
1608 tracing::info!(model = %body.model, gpu = ?body.gpu, "model loaded via API (legacy)");
1609 Ok(StatusCode::OK)
1610}
1611
1612#[utoipa::path(
1615 post,
1616 path = "/api/models/pull",
1617 tag = "models",
1618 request_body = LoadModelBody,
1619 responses(
1620 (status = 200, description = "Model pulled (SSE stream or plain text)"),
1621 (status = 400, description = "Unknown model"),
1622 (status = 500, description = "Download failed"),
1623 )
1624)]
1625async fn pull_model_endpoint(
1626 State(state): State<AppState>,
1627 headers: HeaderMap,
1628 Json(body): Json<LoadModelBody>,
1629) -> Result<impl IntoResponse, ApiError> {
1630 let wants_sse = headers
1631 .get(header::ACCEPT)
1632 .and_then(|v| v.to_str().ok())
1633 .is_some_and(|v| v.contains("text/event-stream"));
1634
1635 if body.model.starts_with("cv:") || body.model.starts_with("hf:") {
1636 if let Err(e) = model_manager::install_catalog_model(&state, &body.model).await {
1637 return Err(model_manager::install_error_to_api_error(&e));
1638 }
1639 if model_manager::check_model_available(&state, &body.model)
1640 .await
1641 .is_ok()
1642 {
1643 return Ok(PullResponse::Text(format!(
1644 "model '{}' is already present",
1645 body.model
1646 )));
1647 }
1648 let companion_names = {
1649 let intents = state.catalog_intents.read().await;
1650 intents
1651 .get(&body.model)
1652 .map(|intent| {
1653 intent
1654 .companions
1655 .iter()
1656 .map(|companion| companion.name.clone())
1657 .collect::<Vec<_>>()
1658 })
1659 .unwrap_or_default()
1660 };
1661 let models_dir = state.config.read().await.resolved_models_dir();
1662 let companion_jobs = crate::catalog_api::enqueue_missing_companions(
1663 &companion_names,
1664 &models_dir,
1665 &state.downloads,
1666 Some(&body.model),
1667 )
1668 .await;
1669 let primary_job = crate::catalog_api::enqueue_catalog_primary_repair(&state, &body.model)
1670 .await
1671 .map_err(|(status, msg)| ApiError::internal_with_status(msg, status))?;
1672 if !companion_jobs.is_empty() || primary_job.is_some() {
1673 let primary_count = usize::from(primary_job.is_some());
1674 return Ok(PullResponse::Text(format!(
1675 "queued repair for model '{}' ({} primary job(s), {} companion job(s))",
1676 body.model,
1677 primary_count,
1678 companion_jobs.len()
1679 )));
1680 }
1681 model_manager::check_model_available(&state, &body.model).await?;
1682 return Ok(PullResponse::Text(format!(
1683 "model '{}' is already present",
1684 body.model
1685 )));
1686 }
1687
1688 let (job_id, _position) = match state.downloads.enqueue(body.model.clone()).await {
1690 Ok((id, pos, _)) => (id, pos),
1691 Err(crate::downloads::EnqueueError::UnknownModel(_)) => {
1692 return Err(ApiError::unknown_model(format!(
1693 "unknown model '{}'. Run 'mold list' to see available models.",
1694 body.model
1695 )));
1696 }
1697 Err(crate::downloads::EnqueueError::LockPoisoned) => {
1698 return Err(ApiError::internal("download queue state is corrupt"));
1699 }
1700 };
1701
1702 if !wants_sse {
1703 let mut rx = state.downloads.subscribe();
1705 loop {
1706 match rx.recv().await {
1707 Ok(mold_core::types::DownloadEvent::JobDone { id, model }) if id == job_id => {
1708 return Ok(PullResponse::Text(format!(
1709 "model '{model}' pulled successfully"
1710 )));
1711 }
1712 Ok(mold_core::types::DownloadEvent::JobFailed { id, error }) if id == job_id => {
1713 return Err(ApiError::internal(format!(
1714 "failed to pull model '{}': {error}",
1715 body.model
1716 )));
1717 }
1718 Ok(mold_core::types::DownloadEvent::JobCancelled { id }) if id == job_id => {
1719 return Err(ApiError::internal(format!(
1720 "pull of '{}' was cancelled",
1721 body.model
1722 )));
1723 }
1724 Ok(_) => continue,
1725 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1726 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1727 return Err(ApiError::internal("download queue channel closed"));
1728 }
1729 }
1730 }
1731 }
1732
1733 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1736 let mut events = state.downloads.subscribe();
1737 let model_for_cb = body.model.clone();
1738 tokio::spawn(async move {
1739 loop {
1740 match events.recv().await {
1741 Ok(mold_core::types::DownloadEvent::Started {
1742 id,
1743 files_total,
1744 bytes_total,
1745 }) if id == job_id => {
1746 let _ = tx.send(SseMessage::Progress(SseProgressEvent::DownloadProgress {
1747 filename: String::new(),
1748 file_index: 0,
1749 total_files: files_total,
1750 bytes_downloaded: 0,
1751 bytes_total,
1752 batch_bytes_downloaded: 0,
1753 batch_bytes_total: bytes_total,
1754 batch_elapsed_ms: 0,
1755 }));
1756 }
1757 Ok(mold_core::types::DownloadEvent::Progress {
1758 id,
1759 files_done,
1760 bytes_done,
1761 current_file,
1762 }) if id == job_id => {
1763 let _ = tx.send(SseMessage::Progress(SseProgressEvent::DownloadProgress {
1764 filename: current_file.unwrap_or_default(),
1765 file_index: files_done,
1766 total_files: 0,
1767 bytes_downloaded: bytes_done,
1768 bytes_total: 0,
1769 batch_bytes_downloaded: bytes_done,
1770 batch_bytes_total: 0,
1771 batch_elapsed_ms: 0,
1772 }));
1773 }
1774 Ok(mold_core::types::DownloadEvent::JobDone { id, .. }) if id == job_id => {
1775 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
1776 model: model_for_cb.clone(),
1777 }));
1778 break;
1779 }
1780 Ok(mold_core::types::DownloadEvent::JobFailed { id, error }) if id == job_id => {
1781 let _ = tx.send(SseMessage::Error(SseErrorEvent { message: error }));
1782 break;
1783 }
1784 Ok(mold_core::types::DownloadEvent::JobCancelled { id }) if id == job_id => {
1785 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1786 message: "pull cancelled".into(),
1787 }));
1788 break;
1789 }
1790 Ok(_) => continue,
1791 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1792 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1793 }
1794 }
1795 });
1796
1797 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
1798 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
1799
1800 Ok(PullResponse::Sse(
1801 Sse::new(stream)
1802 .keep_alive(
1803 KeepAlive::new()
1804 .interval(std::time::Duration::from_secs(15))
1805 .text("ping"),
1806 )
1807 .into_response(),
1808 ))
1809}
1810
1811enum PullResponse {
1813 Sse(axum::response::Response),
1814 Text(String),
1815}
1816
1817impl IntoResponse for PullResponse {
1818 fn into_response(self) -> axum::response::Response {
1819 match self {
1820 PullResponse::Sse(resp) => resp,
1821 PullResponse::Text(text) => text.into_response(),
1822 }
1823 }
1824}
1825
1826#[derive(Debug, Default, Deserialize, utoipa::ToSchema)]
1831pub struct UnloadRequest {
1832 #[serde(default, skip_serializing_if = "Option::is_none")]
1834 pub model: Option<String>,
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1837 pub gpu: Option<usize>,
1838}
1839
1840#[utoipa::path(
1841 delete,
1842 path = "/api/models/unload",
1843 tag = "models",
1844 request_body(content = Option<UnloadRequest>, content_type = "application/json"),
1845 responses(
1846 (status = 200, description = "Model unloaded or no model was loaded", body = String),
1847 )
1848)]
1849async fn unload_model(
1850 State(state): State<AppState>,
1851 body: Option<Json<UnloadRequest>>,
1852) -> Result<impl IntoResponse, ApiError> {
1853 let req = body.map(|b| b.0).unwrap_or_default();
1854 tracing::debug!(model = ?req.model, gpu = ?req.gpu, "unload request");
1855 clear_global_upscaler_cache(&state);
1856
1857 if state.gpu_pool.worker_count() > 0 {
1859 let targets: Vec<_> = match (req.gpu, req.model.as_deref()) {
1861 (Some(ordinal), _) => state
1862 .gpu_pool
1863 .workers
1864 .iter()
1865 .filter(|w| w.gpu.ordinal == ordinal)
1866 .cloned()
1867 .collect(),
1868 (None, Some(model)) => state
1869 .gpu_pool
1870 .workers
1871 .iter()
1872 .filter(|w| {
1873 let cache = w.model_cache.lock().unwrap();
1874 cache
1875 .get(model)
1876 .map(|e| e.residency == crate::model_cache::ModelResidency::Gpu)
1877 .unwrap_or(false)
1878 })
1879 .cloned()
1880 .collect(),
1881 (None, None) => state.gpu_pool.workers.clone(),
1882 };
1883
1884 if targets.is_empty() {
1885 return Ok((StatusCode::OK, "no model loaded".to_string()));
1886 }
1887
1888 let mut unloaded_pairs: Vec<(usize, String)> = Vec::new();
1889 for worker in targets {
1890 let worker_clone = worker.clone();
1891 let result = tokio::task::spawn_blocking(move || {
1892 crate::gpu_worker::unload_blocking(&worker_clone)
1893 })
1894 .await
1895 .map_err(|e| ApiError::internal(format!("unload task failed: {e}")))?;
1896 if let Some(name) = result {
1897 unloaded_pairs.push((worker.gpu.ordinal, name));
1898 }
1899 }
1900
1901 let msg = if unloaded_pairs.is_empty() {
1902 "no model loaded".to_string()
1903 } else {
1904 let joined: Vec<String> = unloaded_pairs
1905 .iter()
1906 .map(|(o, m)| format!("gpu{o}:{m}"))
1907 .collect();
1908 format!("unloaded {}", joined.join(", "))
1909 };
1910 return Ok((StatusCode::OK, msg));
1911 }
1912
1913 Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1915}
1916
1917async fn model_is_gpu_resident(state: &AppState, canonical: &str) -> bool {
1923 {
1924 let cache = state.model_cache.lock().await;
1925 if cache.active_model() == Some(canonical) {
1926 return true;
1927 }
1928 }
1929 if state
1930 .active_generation
1931 .read()
1932 .unwrap_or_else(|e| e.into_inner())
1933 .as_ref()
1934 .is_some_and(|g| g.model == canonical)
1935 {
1936 return true;
1937 }
1938 for worker in &state.gpu_pool.workers {
1939 if let Ok(active) = worker.active_generation.read() {
1940 if active.as_ref().is_some_and(|g| g.model == canonical) {
1941 return true;
1942 }
1943 }
1944 if let Ok(cache) = worker.model_cache.lock() {
1945 if cache.active_model() == Some(canonical) {
1946 return true;
1947 }
1948 }
1949 }
1950 false
1951}
1952
1953#[utoipa::path(
1962 delete,
1963 path = "/api/models/{model}",
1964 tag = "models",
1965 params(("model" = String, Path, description = "Model name (e.g. flux-schnell:q8)")),
1966 responses(
1967 (status = 200, description = "Model removed", body = mold_core::ModelRemovalResponse),
1968 (status = 404, description = "Model not installed"),
1969 (status = 409, description = "Model is currently loaded — unload it first"),
1970 )
1971)]
1972async fn delete_model(
1973 State(state): State<AppState>,
1974 Path(model): Path<String>,
1975) -> Result<Json<mold_core::ModelRemovalResponse>, ApiError> {
1976 let canonical = mold_core::manifest::resolve_model_name(&model);
1977
1978 if model_is_gpu_resident(&state, &canonical).await
1984 || (model != canonical && model_is_gpu_resident(&state, &model).await)
1985 {
1986 return Err(ApiError::with_code(
1987 format!(
1988 "model '{canonical}' is currently loaded; unload it first (DELETE /api/models/unload)"
1989 ),
1990 "MODEL_LOADED",
1991 StatusCode::CONFLICT,
1992 ));
1993 }
1994
1995 let mut config = state.config.write().await;
1998 let in_config = config.models.contains_key(&canonical);
1999 if !in_config && !config.manifest_model_is_downloaded(&canonical) {
2000 return Err(ApiError::with_code(
2001 format!("model '{canonical}' is not installed"),
2002 "UNKNOWN_MODEL",
2003 StatusCode::NOT_FOUND,
2004 ));
2005 }
2006
2007 tracing::info!(model = %canonical, "model removal requested");
2008 let plan = mold_core::removal::plan_removal(&config, &canonical);
2009 let outcome = mold_core::removal::execute_removal(&config, &plan);
2010 for warning in &outcome.warnings {
2011 tracing::warn!(model = %canonical, "model removal: {warning}");
2012 }
2013
2014 mold_core::download::remove_pulling_marker(&canonical);
2015 if in_config {
2016 config.remove_model(&canonical);
2017 if let Err(e) = config.save() {
2018 tracing::warn!("failed to persist model removal to config.toml: {e}");
2019 }
2020 }
2021 drop(config);
2022
2023 {
2026 let mut cache = state.model_cache.lock().await;
2027 let _ = cache.remove(&canonical);
2028 }
2029 for worker in &state.gpu_pool.workers {
2030 if let Ok(mut cache) = worker.model_cache.lock() {
2031 let _ = cache.remove(&canonical);
2032 }
2033 }
2034
2035 let kept = plan
2036 .shared_files
2037 .iter()
2038 .map(|(path, used_by)| mold_core::KeptComponent {
2039 component: path.clone(),
2040 used_by: used_by.clone(),
2041 })
2042 .collect();
2043
2044 Ok(Json(mold_core::ModelRemovalResponse {
2045 removed: outcome.removed,
2046 kept,
2047 freed_bytes: outcome.freed_bytes,
2048 }))
2049}
2050
2051#[utoipa::path(
2054 get,
2055 path = "/api/status",
2056 tag = "server",
2057 responses(
2058 (status = 200, description = "Server status", body = ServerStatus),
2059 )
2060)]
2061async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
2062 let gpu_statuses = state.gpu_pool.gpu_status();
2064 let has_gpus = !gpu_statuses.is_empty();
2065
2066 let gpu_models_loaded: Vec<String> = gpu_statuses
2068 .iter()
2069 .filter_map(|g| g.loaded_model.clone())
2070 .collect();
2071 let gpu_busy = gpu_statuses
2072 .iter()
2073 .any(|g| g.state == GpuWorkerState::Generating);
2074
2075 let multi_gpu_current_gen = if has_gpus {
2078 state.gpu_pool.workers.iter().find_map(|w| {
2079 let gen = w.active_generation.read().ok()?;
2080 gen.as_ref().map(|g| ActiveGenerationStatus {
2081 model: g.model.clone(),
2082 prompt_sha256: g.prompt_sha256.clone(),
2083 started_at_unix_ms: g.started_at_unix_ms,
2084 elapsed_ms: g.started_at.elapsed().as_millis() as u64,
2085 })
2086 })
2087 } else {
2088 None
2089 };
2090
2091 let (models_loaded, busy, current_generation) = if has_gpus {
2093 (gpu_models_loaded, gpu_busy, multi_gpu_current_gen)
2094 } else {
2095 let snapshot = state.model_cache.lock().await.snapshot();
2096 let models = match (snapshot.model_name, snapshot.is_loaded) {
2097 (Some(model_name), true) => vec![model_name],
2098 _ => vec![],
2099 };
2100 let gen = state
2101 .active_generation
2102 .read()
2103 .unwrap_or_else(|e| e.into_inner())
2104 .as_ref()
2105 .map(|active| ActiveGenerationStatus {
2106 model: active.model.clone(),
2107 prompt_sha256: active.prompt_sha256.clone(),
2108 started_at_unix_ms: active.started_at_unix_ms,
2109 elapsed_ms: active.started_at.elapsed().as_millis() as u64,
2110 });
2111 let is_busy = gen.is_some();
2112 (models, is_busy, gen)
2113 };
2114
2115 Json(ServerStatus {
2116 version: env!("CARGO_PKG_VERSION").to_string(),
2117 git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
2118 None
2119 } else {
2120 Some(mold_core::build_info::GIT_SHA.to_string())
2121 },
2122 build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
2123 None
2124 } else {
2125 Some(mold_core::build_info::BUILD_DATE.to_string())
2126 },
2127 models_loaded,
2128 busy,
2129 current_generation,
2130 gpu_info: query_gpu_info(),
2131 uptime_secs: state.start_time.elapsed().as_secs(),
2132 hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
2133 memory_status: mold_inference::device::memory_status_string(),
2134 gpus: if has_gpus { Some(gpu_statuses) } else { None },
2135 queue_depth: Some(state.queue.pending()),
2136 queue_capacity: Some(state.queue_capacity),
2137 queue_paused: Some(state.queue_pause.is_paused()),
2138 })
2139}
2140
2141#[utoipa::path(
2144 get,
2145 path = "/health",
2146 tag = "server",
2147 responses(
2148 (status = 200, description = "Server is healthy"),
2149 )
2150)]
2151async fn health() -> impl IntoResponse {
2152 StatusCode::OK
2153}
2154
2155#[utoipa::path(
2162 get,
2163 path = "/api/queue",
2164 tag = "queue",
2165 responses(
2166 (status = 200, description = "Queue snapshot", body = crate::job_registry::QueueListing),
2167 )
2168)]
2169async fn list_queue(State(state): State<AppState>) -> Json<crate::job_registry::QueueListing> {
2170 Json(state.job_registry.snapshot())
2171}
2172
2173#[derive(Debug, Deserialize, utoipa::ToSchema)]
2174struct QueuePatchRequest {
2175 target_gpu: Option<usize>,
2176}
2177
2178#[utoipa::path(
2179 patch,
2180 path = "/api/queue/{id}",
2181 tag = "queue",
2182 request_body = QueuePatchRequest,
2183 responses(
2184 (status = 200, description = "Updated queue entry", body = crate::job_registry::JobEntry),
2185 (status = 404, description = "Queue job not found"),
2186 (status = 409, description = "Queue job is already running"),
2187 (status = 422, description = "Invalid GPU target"),
2188 )
2189)]
2190async fn patch_queue_job(
2191 State(state): State<AppState>,
2192 Path(id): Path<String>,
2193 Json(req): Json<QueuePatchRequest>,
2194) -> Result<Json<crate::job_registry::JobEntry>, ApiError> {
2195 if let Some(target) = req.target_gpu {
2196 let available = state
2197 .gpu_pool
2198 .workers
2199 .iter()
2200 .any(|w| w.gpu.ordinal == target);
2201 if !available {
2202 return Err(ApiError::validation(format!(
2203 "gpu:{target} is not available in this server's worker pool"
2204 )));
2205 }
2206 }
2207
2208 state
2209 .job_registry
2210 .set_target_gpu(&id, req.target_gpu)
2211 .map_err(|e| match e {
2212 crate::job_registry::TargetGpuUpdateError::NotFound => {
2213 ApiError::queue_job_not_found(format!("queue job {id} not found"))
2214 }
2215 crate::job_registry::TargetGpuUpdateError::AlreadyRunning => {
2216 ApiError::queue_job_running(format!(
2217 "queue job {id} is already running; lane changes only apply to queued jobs"
2218 ))
2219 }
2220 })?;
2221
2222 let entry = state
2223 .job_registry
2224 .entry(&id)
2225 .ok_or_else(|| ApiError::queue_job_not_found(format!("queue job {id} not found")))?;
2226 Ok(Json(entry))
2227}
2228
2229#[utoipa::path(
2235 delete,
2236 path = "/api/queue/{id}",
2237 tag = "queue",
2238 params(("id" = String, Path, description = "Queue job id")),
2239 responses(
2240 (status = 204, description = "Queued job cancelled"),
2241 (status = 404, description = "Queue job not found"),
2242 (status = 409, description = "Queue job is already running"),
2243 )
2244)]
2245async fn cancel_queue_job(
2246 State(state): State<AppState>,
2247 Path(id): Path<String>,
2248) -> Result<StatusCode, ApiError> {
2249 state.job_registry.cancel_queued(&id).map_err(|e| match e {
2250 crate::job_registry::QueuedJobCancelError::NotFound => {
2251 ApiError::queue_job_not_found(format!("queue job {id} not found"))
2252 }
2253 crate::job_registry::QueuedJobCancelError::AlreadyRunning => ApiError::queue_job_running(
2254 format!("queue job {id} is already running; only queued jobs can be cancelled"),
2255 ),
2256 })?;
2257 Ok(StatusCode::NO_CONTENT)
2258}
2259
2260#[derive(Debug, Serialize, utoipa::ToSchema)]
2263struct QueuePauseResponse {
2264 paused: bool,
2265}
2266
2267#[derive(Debug, Serialize, utoipa::ToSchema)]
2269struct QueueCancelAllResponse {
2270 cancelled: usize,
2271}
2272
2273#[utoipa::path(
2277 post,
2278 path = "/api/queue/pause",
2279 tag = "queue",
2280 responses(
2281 (status = 200, description = "Queue dispatch paused", body = QueuePauseResponse),
2282 )
2283)]
2284async fn pause_queue(State(state): State<AppState>) -> Json<QueuePauseResponse> {
2285 if state.queue_pause.pause() {
2286 state.events.publish(mold_core::ServerEvent::QueuePaused);
2287 }
2288 Json(QueuePauseResponse { paused: true })
2289}
2290
2291#[utoipa::path(
2294 post,
2295 path = "/api/queue/resume",
2296 tag = "queue",
2297 responses(
2298 (status = 200, description = "Queue dispatch resumed", body = QueuePauseResponse),
2299 )
2300)]
2301async fn resume_queue(State(state): State<AppState>) -> Json<QueuePauseResponse> {
2302 if state.queue_pause.resume() {
2303 state.events.publish(mold_core::ServerEvent::QueueResumed);
2304 }
2305 Json(QueuePauseResponse { paused: false })
2306}
2307
2308#[utoipa::path(
2313 delete,
2314 path = "/api/queue",
2315 tag = "queue",
2316 responses(
2317 (status = 200, description = "Queued jobs cancelled", body = QueueCancelAllResponse),
2318 )
2319)]
2320async fn cancel_all_queue(State(state): State<AppState>) -> Json<QueueCancelAllResponse> {
2321 let cancelled = state.job_registry.cancel_all_queued();
2322 Json(QueueCancelAllResponse { cancelled })
2323}
2324
2325const HISTORY_DEFAULT_LIMIT: usize = 50;
2329const HISTORY_MAX_LIMIT: usize = 500;
2331
2332const HISTORY_UNAVAILABLE: &str = "HISTORY_UNAVAILABLE";
2334
2335fn history_db(state: &AppState) -> Result<&mold_db::MetadataDb, ApiError> {
2336 state.metadata_db.as_ref().as_ref().ok_or_else(|| {
2337 ApiError::with_code(
2338 "prompt history is unavailable because the metadata DB is disabled",
2339 HISTORY_UNAVAILABLE,
2340 StatusCode::SERVICE_UNAVAILABLE,
2341 )
2342 })
2343}
2344
2345#[derive(Debug, Deserialize)]
2346struct HistoryListQuery {
2347 query: Option<String>,
2349 limit: Option<usize>,
2351}
2352
2353#[utoipa::path(
2354 get,
2355 path = "/api/history",
2356 tag = "server",
2357 params(
2358 ("query" = Option<String>, Query, description = "Substring filter over prompt text (case-insensitive)"),
2359 ("limit" = Option<usize>, Query, description = "Max rows to return (default 50, max 500)"),
2360 ),
2361 responses(
2362 (status = 200, description = "Prompt history, newest first", body = mold_core::HistoryListing),
2363 (status = 503, description = "Metadata DB disabled"),
2364 )
2365)]
2366async fn list_history(
2367 State(state): State<AppState>,
2368 axum::extract::Query(params): axum::extract::Query<HistoryListQuery>,
2369) -> Result<Json<mold_core::HistoryListing>, ApiError> {
2370 let db = history_db(&state)?;
2371 let limit = params
2372 .limit
2373 .unwrap_or(HISTORY_DEFAULT_LIMIT)
2374 .min(HISTORY_MAX_LIMIT);
2375 let history = mold_db::PromptHistory::new(db);
2376 let rows = match params
2377 .query
2378 .as_deref()
2379 .map(str::trim)
2380 .filter(|q| !q.is_empty())
2381 {
2382 Some(query) => history.search(query, limit),
2383 None => history.recent(limit),
2384 }
2385 .map_err(|e| ApiError::internal(format!("failed to read prompt history: {e:#}")))?;
2386 let entries = rows
2387 .into_iter()
2388 .map(|e| mold_core::HistoryEntry {
2389 prompt: e.prompt,
2390 model: e.model,
2391 used_at: e.created_at_ms,
2392 })
2393 .collect();
2394 Ok(Json(mold_core::HistoryListing { entries }))
2395}
2396
2397#[derive(Debug, Deserialize)]
2398struct HistoryDeleteQuery {
2399 keep: Option<usize>,
2401}
2402
2403#[utoipa::path(
2404 delete,
2405 path = "/api/history",
2406 tag = "server",
2407 params(
2408 ("keep" = Option<usize>, Query, description = "Keep only the most recent N entries instead of clearing everything"),
2409 ),
2410 responses(
2411 (status = 204, description = "Prompt history cleared (or trimmed)"),
2412 (status = 503, description = "Metadata DB disabled"),
2413 )
2414)]
2415async fn delete_history(
2416 State(state): State<AppState>,
2417 axum::extract::Query(params): axum::extract::Query<HistoryDeleteQuery>,
2418) -> Result<StatusCode, ApiError> {
2419 let db = history_db(&state)?;
2420 let history = mold_db::PromptHistory::new(db);
2421 match params.keep {
2422 Some(keep) => history.trim_to(keep),
2423 None => history.clear(),
2424 }
2425 .map_err(|e| ApiError::internal(format!("failed to clear prompt history: {e:#}")))?;
2426 Ok(StatusCode::NO_CONTENT)
2427}
2428
2429async fn server_capabilities() -> Json<mold_core::ServerCapabilities> {
2435 let catalog_available = std::env::var("MOLD_CATALOG_DISABLE")
2436 .map(|v| v != "1" && !v.eq_ignore_ascii_case("true"))
2437 .unwrap_or(true);
2438
2439 Json(mold_core::ServerCapabilities {
2440 gallery: mold_core::GalleryCapabilities { can_delete: true },
2441 catalog: mold_core::CatalogCapabilities {
2442 available: catalog_available,
2443 families: mold_catalog::families::ALL_FAMILIES
2444 .iter()
2445 .map(|f| f.as_str().to_string())
2446 .collect::<Vec<_>>(),
2447 },
2448 events: mold_core::EventsCapabilities { available: true },
2449 queue: mold_core::QueueCapabilities {
2450 can_pause: true,
2451 can_cancel_all: true,
2452 },
2453 })
2454}
2455
2456#[utoipa::path(
2459 get,
2460 path = "/api/capabilities/chain-limits",
2461 tag = "server",
2462 params(
2463 ("model" = String, Query, description = "Model name (e.g. ltx-2-19b-distilled:fp8)")
2464 ),
2465 responses(
2466 (status = 200, description = "Chain limits for the requested model",
2467 body = crate::chain_limits::ChainLimits),
2468 (status = 400, description = "Missing required 'model' query parameter"),
2469 (status = 404, description = "Unknown or unsupported model"),
2470 )
2471)]
2472async fn capabilities_chain_limits(
2473 axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
2474) -> axum::response::Response {
2475 let raw_model = match params.get("model") {
2476 Some(m) => m.clone(),
2477 None => {
2478 return (
2479 StatusCode::BAD_REQUEST,
2480 "missing required 'model' query parameter\n",
2481 )
2482 .into_response();
2483 }
2484 };
2485
2486 let resolved = mold_core::manifest::resolve_model_name(&raw_model);
2487 let Some(manifest) = mold_core::manifest::find_manifest(&resolved) else {
2488 return (StatusCode::NOT_FOUND, "unknown model\n").into_response();
2489 };
2490 let family = manifest.family.clone();
2491
2492 if crate::chain_limits::family_cap(&family).is_none() {
2493 return (StatusCode::NOT_FOUND, "model is not chain-capable\n").into_response();
2494 }
2495
2496 let quant = resolved
2497 .split_once(':')
2498 .map(|(_, tag)| tag.to_string())
2499 .unwrap_or_default();
2500
2501 let limits = crate::chain_limits::compute_limits(&resolved, &family, &quant, 0);
2503 Json(limits).into_response()
2504}
2505
2506#[utoipa::path(
2514 post,
2515 path = "/api/shutdown",
2516 tag = "server",
2517 responses(
2518 (status = 200, description = "Shutdown initiated"),
2519 (status = 403, description = "Forbidden — remote shutdown requires API key auth"),
2520 )
2521)]
2522async fn shutdown_server(State(state): State<AppState>, request: Request) -> impl IntoResponse {
2523 let auth_enabled = request
2526 .extensions()
2527 .get::<crate::auth::AuthState>()
2528 .is_some_and(|s| s.is_some());
2529
2530 if !auth_enabled {
2531 let is_loopback = request
2532 .extensions()
2533 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2534 .map(|ci| ci.0.ip().is_loopback())
2535 .unwrap_or(false);
2536 if !is_loopback {
2537 return (
2538 StatusCode::FORBIDDEN,
2539 "shutdown requires API key auth or localhost access\n",
2540 );
2541 }
2542 }
2543
2544 tracing::info!("shutdown requested via API");
2545 if let Some(tx) = state.shutdown_tx.lock().await.take() {
2546 let _ = tx.send(());
2547 }
2548 (StatusCode::OK, "shutdown initiated\n")
2549}
2550
2551async fn list_gallery(
2561 State(state): State<AppState>,
2562) -> Result<Json<Vec<mold_core::GalleryImage>>, ApiError> {
2563 let config = state.config.read().await;
2564 if config.is_output_disabled() {
2565 return Ok(Json(Vec::new()));
2566 }
2567 let output_dir = config.effective_output_dir();
2568 drop(config);
2569
2570 if !output_dir.is_dir() {
2571 return Ok(Json(Vec::new()));
2572 }
2573
2574 if state.metadata_db.is_some() {
2575 let db_arc = state.metadata_db.clone();
2576 let dir = output_dir.clone();
2577 let listed = tokio::task::spawn_blocking(move || {
2578 db_arc
2579 .as_ref()
2580 .as_ref()
2581 .map(|db| db.list(Some(&dir)))
2582 .transpose()
2583 })
2584 .await
2585 .map_err(|e| ApiError::internal(format!("gallery DB query failed: {e}")))?
2586 .map_err(|e| ApiError::internal(format!("gallery DB query failed: {e:#}")))?;
2587 if let Some(rows) = listed {
2588 if !rows.is_empty() {
2589 let images = rows.iter().map(|r| r.to_gallery_image()).collect();
2590 return Ok(Json(images));
2591 }
2592 }
2593 }
2594
2595 let images = tokio::task::spawn_blocking(move || scan_gallery_dir(&output_dir))
2596 .await
2597 .map_err(|e| ApiError::internal(format!("gallery scan failed: {e}")))?;
2598
2599 Ok(Json(images))
2600}
2601
2602async fn get_gallery_image(
2612 State(state): State<AppState>,
2613 headers: HeaderMap,
2614 axum::extract::Path(filename): axum::extract::Path<String>,
2615) -> Result<axum::response::Response, ApiError> {
2616 let config = state.config.read().await;
2617 if config.is_output_disabled() {
2618 return Err(ApiError::not_found("image output is disabled"));
2619 }
2620 let output_dir = config.effective_output_dir();
2621 drop(config);
2622
2623 let clean_name = std::path::Path::new(&filename)
2625 .file_name()
2626 .map(|f| f.to_string_lossy().to_string())
2627 .unwrap_or_default();
2628
2629 if clean_name.is_empty() || clean_name != filename {
2630 return Err(ApiError::validation("invalid filename"));
2631 }
2632
2633 let path = output_dir.join(&clean_name);
2634 let meta = match tokio::fs::metadata(&path).await {
2635 Ok(m) if m.is_file() => m,
2636 _ => {
2637 return Err(ApiError::not_found(format!(
2638 "image not found: {clean_name}"
2639 )));
2640 }
2641 };
2642 let total_len = meta.len();
2643 let content_type = content_type_for_filename(&clean_name);
2644
2645 let range_header = headers
2646 .get(header::RANGE)
2647 .and_then(|v| v.to_str().ok())
2648 .map(|s| s.to_string());
2649
2650 let file = tokio::fs::File::open(&path)
2651 .await
2652 .map_err(|e| ApiError::internal(format!("failed to open file: {e}")))?;
2653
2654 if let Some(raw) = range_header {
2655 if let Some((start, end)) = parse_byte_range(&raw, total_len) {
2656 return serve_range(file, start, end, total_len, content_type).await;
2657 } else {
2658 return Ok(axum::response::Response::builder()
2660 .status(StatusCode::RANGE_NOT_SATISFIABLE)
2661 .header(header::CONTENT_RANGE, format!("bytes */{total_len}"))
2662 .body(axum::body::Body::empty())
2663 .unwrap());
2664 }
2665 }
2666
2667 let stream = tokio_util::io::ReaderStream::new(file);
2669 let body = axum::body::Body::from_stream(stream);
2670 Ok(axum::response::Response::builder()
2671 .status(StatusCode::OK)
2672 .header(header::CONTENT_TYPE, content_type)
2673 .header(header::ACCEPT_RANGES, "bytes")
2674 .header(header::CONTENT_LENGTH, total_len)
2675 .header(header::CACHE_CONTROL, "public, max-age=3600")
2676 .body(body)
2677 .unwrap())
2678}
2679
2680fn parse_byte_range(header: &str, total_len: u64) -> Option<(u64, u64)> {
2688 let spec = header.strip_prefix("bytes=")?;
2689 if spec.contains(',') {
2690 return None;
2691 }
2692 let (start_s, end_s) = spec.split_once('-')?;
2693 let start_s = start_s.trim();
2694 let end_s = end_s.trim();
2695
2696 if total_len == 0 {
2697 return None;
2698 }
2699
2700 if start_s.is_empty() {
2701 let suffix: u64 = end_s.parse().ok()?;
2703 if suffix == 0 {
2704 return None;
2705 }
2706 let start = total_len.saturating_sub(suffix);
2707 return Some((start, total_len - 1));
2708 }
2709
2710 let start: u64 = start_s.parse().ok()?;
2711 if start >= total_len {
2712 return None;
2713 }
2714 let end: u64 = if end_s.is_empty() {
2715 total_len - 1
2716 } else {
2717 end_s.parse().ok()?
2718 };
2719 let end = end.min(total_len - 1);
2720 if end < start {
2721 return None;
2722 }
2723 Some((start, end))
2724}
2725
2726async fn serve_range(
2730 mut file: tokio::fs::File,
2731 start: u64,
2732 end: u64,
2733 total_len: u64,
2734 content_type: &'static str,
2735) -> Result<axum::response::Response, ApiError> {
2736 use tokio::io::{AsyncReadExt, AsyncSeekExt};
2737 file.seek(std::io::SeekFrom::Start(start))
2738 .await
2739 .map_err(|e| ApiError::internal(format!("seek failed: {e}")))?;
2740 let len = end - start + 1;
2741 let stream = tokio_util::io::ReaderStream::new(file.take(len));
2742 let body = axum::body::Body::from_stream(stream);
2743 Ok(axum::response::Response::builder()
2744 .status(StatusCode::PARTIAL_CONTENT)
2745 .header(header::CONTENT_TYPE, content_type)
2746 .header(header::ACCEPT_RANGES, "bytes")
2747 .header(header::CONTENT_LENGTH, len)
2748 .header(
2749 header::CONTENT_RANGE,
2750 format!("bytes {start}-{end}/{total_len}"),
2751 )
2752 .header(header::CACHE_CONTROL, "public, max-age=300")
2755 .body(body)
2756 .unwrap())
2757}
2758
2759fn content_type_for_filename(name: &str) -> &'static str {
2762 let lower = name.to_ascii_lowercase();
2763 if lower.ends_with(".png") {
2764 "image/png"
2765 } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
2766 "image/jpeg"
2767 } else if lower.ends_with(".gif") {
2768 "image/gif"
2769 } else if lower.ends_with(".webp") {
2770 "image/webp"
2771 } else if lower.ends_with(".apng") {
2772 "image/apng"
2773 } else if lower.ends_with(".mp4") {
2774 "video/mp4"
2775 } else {
2776 "application/octet-stream"
2777 }
2778}
2779
2780async fn delete_gallery_image(
2785 State(state): State<AppState>,
2786 axum::extract::Path(filename): axum::extract::Path<String>,
2787) -> Result<impl IntoResponse, ApiError> {
2788 let config = state.config.read().await;
2789 if config.is_output_disabled() {
2790 return Err(ApiError::not_found("image output is disabled"));
2791 }
2792 let output_dir = config.effective_output_dir();
2793 drop(config);
2794
2795 let clean_name = std::path::Path::new(&filename)
2796 .file_name()
2797 .map(|f| f.to_string_lossy().to_string())
2798 .unwrap_or_default();
2799
2800 if clean_name.is_empty() || clean_name != filename {
2801 return Err(ApiError::validation("invalid filename"));
2802 }
2803
2804 let db = state.metadata_db.clone();
2808 let name = clean_name.clone();
2809 let dir = output_dir.clone();
2810 tokio::task::spawn_blocking(move || -> Result<(), ApiError> {
2811 let path = dir.join(&name);
2812 if path.is_file() {
2813 std::fs::remove_file(&path)
2814 .map_err(|e| ApiError::internal(format!("failed to delete image: {e}")))?;
2815 }
2816
2817 let thumb_dir = server_thumbnail_dir();
2822 let _ = std::fs::remove_file(thumb_dir.join(&name));
2823 let _ = std::fs::remove_file(thumb_dir.join(format!("{name}.png")));
2824 let _ = std::fs::remove_file(
2825 server_preview_gif_dir().join(mold_core::media_paths::preview_gif_filename(&name)),
2826 );
2827
2828 if let Some(db) = db.as_ref().as_ref() {
2832 match db.delete(&dir, &name) {
2833 Ok(true) => {}
2834 Ok(false) => {
2835 tracing::debug!("delete: no metadata row for {}", dir.join(&name).display())
2836 }
2837 Err(e) => tracing::warn!(
2838 "metadata DB delete failed for {}: {e:#}",
2839 dir.join(&name).display()
2840 ),
2841 }
2842 }
2843 Ok(())
2844 })
2845 .await
2846 .map_err(|e| ApiError::internal(format!("gallery delete task failed: {e}")))??;
2847
2848 state
2849 .events
2850 .publish(mold_core::ServerEvent::GalleryRemoved {
2851 filename: clean_name,
2852 });
2853
2854 Ok(StatusCode::NO_CONTENT)
2855}
2856
2857async fn get_gallery_thumbnail(
2860 State(state): State<AppState>,
2861 axum::extract::Path(filename): axum::extract::Path<String>,
2862) -> Result<impl IntoResponse, ApiError> {
2863 let config = state.config.read().await;
2864 if config.is_output_disabled() {
2865 return Err(ApiError::not_found("image output is disabled"));
2866 }
2867 let output_dir = config.effective_output_dir();
2868 drop(config);
2869
2870 let clean_name = std::path::Path::new(&filename)
2871 .file_name()
2872 .map(|f| f.to_string_lossy().to_string())
2873 .unwrap_or_default();
2874
2875 if clean_name.is_empty() || clean_name != filename {
2876 return Err(ApiError::validation("invalid filename"));
2877 }
2878
2879 let source_path = output_dir.join(&clean_name);
2880 if !source_path.is_file() {
2881 return Err(ApiError::not_found(format!(
2882 "image not found: {clean_name}"
2883 )));
2884 }
2885
2886 let thumb_dir = server_thumbnail_dir();
2890 let thumb_path = thumb_dir.join(format!("{clean_name}.png"));
2891 let lower = clean_name.to_ascii_lowercase();
2892 let is_video = lower.ends_with(".mp4");
2893
2894 if !thumb_path.is_file() {
2895 let source = source_path.clone();
2902 let dest = thumb_path.clone();
2903 let gen_result = tokio::task::spawn_blocking(move || {
2904 if is_video {
2905 generate_video_thumbnail(&source, &dest)
2906 } else {
2907 generate_server_thumbnail(&source, &dest)
2908 }
2909 })
2910 .await
2911 .map_err(|e| ApiError::internal(format!("thumbnail generation failed: {e}")))?;
2912
2913 if let Err(err) = gen_result {
2914 tracing::warn!(
2915 file = %clean_name,
2916 error = %err,
2917 "thumbnail decode failed; falling back to source bytes"
2918 );
2919 if is_video {
2923 let mut headers = HeaderMap::new();
2924 headers.insert(
2925 header::CONTENT_TYPE,
2926 HeaderValue::from_static("image/svg+xml"),
2927 );
2928 headers.insert(
2929 header::CACHE_CONTROL,
2930 HeaderValue::from_static("public, max-age=300"),
2931 );
2932 return Ok((headers, VIDEO_PLACEHOLDER_SVG.as_bytes().to_vec()));
2933 }
2934 let raw = tokio::fs::read(&source_path)
2935 .await
2936 .map_err(|e| ApiError::internal(format!("failed to read source: {e}")))?;
2937 let mut headers = HeaderMap::new();
2938 headers.insert(
2939 header::CONTENT_TYPE,
2940 HeaderValue::from_static(content_type_for_filename(&clean_name)),
2941 );
2942 headers.insert(
2943 header::CACHE_CONTROL,
2944 HeaderValue::from_static("public, max-age=300"),
2945 );
2946 return Ok((headers, raw));
2947 }
2948 }
2949
2950 let data = tokio::fs::read(&thumb_path)
2951 .await
2952 .map_err(|e| ApiError::internal(format!("failed to read thumbnail: {e}")))?;
2953
2954 let mut headers = HeaderMap::new();
2955 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
2956 headers.insert(
2957 header::CACHE_CONTROL,
2958 HeaderValue::from_static("public, max-age=3600"),
2959 );
2960
2961 Ok((headers, data))
2962}
2963
2964const VIDEO_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1e293b"/><stop offset="1" stop-color="#0f172a"/></linearGradient></defs><rect width="256" height="256" fill="url(#g)"/><circle cx="128" cy="128" r="52" fill="rgba(255,255,255,0.08)"/><polygon points="112,100 112,156 160,128" fill="rgba(226,232,240,0.85)"/></svg>"##;
2965
2966async fn get_gallery_preview(
2976 State(state): State<AppState>,
2977 axum::extract::Path(filename): axum::extract::Path<String>,
2978) -> Result<axum::response::Response, ApiError> {
2979 let config = state.config.read().await;
2980 if config.is_output_disabled() {
2981 return Err(ApiError::not_found("image output is disabled"));
2982 }
2983 let output_dir = config.effective_output_dir();
2984 drop(config);
2985
2986 let clean_name = std::path::Path::new(&filename)
2989 .file_name()
2990 .map(|f| f.to_string_lossy().to_string())
2991 .unwrap_or_default();
2992 if clean_name.is_empty() || clean_name != filename {
2993 return Err(ApiError::validation("invalid filename"));
2994 }
2995
2996 let source_path = output_dir.join(&clean_name);
3003 if !tokio::fs::metadata(&source_path)
3004 .await
3005 .map(|m| m.is_file())
3006 .unwrap_or(false)
3007 {
3008 return Err(ApiError::not_found(format!(
3009 "image not found: {clean_name}"
3010 )));
3011 }
3012
3013 let preview_path =
3014 server_preview_gif_dir().join(mold_core::media_paths::preview_gif_filename(&clean_name));
3015 let meta = match tokio::fs::metadata(&preview_path).await {
3016 Ok(m) if m.is_file() => m,
3017 _ => {
3018 return Err(ApiError::not_found(format!(
3019 "preview not found: {clean_name}"
3020 )));
3021 }
3022 };
3023 let total_len = meta.len();
3024
3025 let file = tokio::fs::File::open(&preview_path)
3026 .await
3027 .map_err(|e| ApiError::internal(format!("failed to open preview: {e}")))?;
3028 let stream = tokio_util::io::ReaderStream::new(file);
3029 let body = axum::body::Body::from_stream(stream);
3030 Ok(axum::response::Response::builder()
3031 .status(StatusCode::OK)
3032 .header(header::CONTENT_TYPE, "image/gif")
3033 .header(header::CONTENT_LENGTH, total_len)
3034 .header(header::CACHE_CONTROL, "public, max-age=3600")
3035 .body(body)
3036 .unwrap())
3037}
3038
3039fn server_preview_gif_dir() -> std::path::PathBuf {
3044 mold_core::Config::mold_dir()
3045 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
3046 .join("cache")
3047 .join("previews")
3048}
3049
3050fn server_thumbnail_dir() -> std::path::PathBuf {
3052 mold_core::Config::mold_dir()
3053 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
3054 .join("cache")
3055 .join("thumbnails")
3056}
3057
3058fn generate_server_thumbnail(
3062 source: &std::path::Path,
3063 dest: &std::path::Path,
3064) -> anyhow::Result<()> {
3065 let img = image::open(source)?;
3066 let thumb = img.thumbnail(256, 256);
3067 if let Some(parent) = dest.parent() {
3068 std::fs::create_dir_all(parent)?;
3069 }
3070 thumb.save_with_format(dest, image::ImageFormat::Png)?;
3071 Ok(())
3072}
3073
3074fn generate_video_thumbnail(
3082 source: &std::path::Path,
3083 dest: &std::path::Path,
3084) -> anyhow::Result<()> {
3085 if let Some(parent) = dest.parent() {
3086 std::fs::create_dir_all(parent)?;
3087 }
3088 let tmp = dest.with_extension("firstframe.png");
3093 mold_inference::ltx2::media::extract_thumbnail(source, &tmp)?;
3094 let decode_result = (|| -> anyhow::Result<()> {
3095 let img = image::open(&tmp)?;
3096 let thumb = img.thumbnail(256, 256);
3097 thumb.save_with_format(dest, image::ImageFormat::Png)?;
3098 Ok(())
3099 })();
3100 let _ = std::fs::remove_file(&tmp);
3101 decode_result
3102}
3103
3104pub fn spawn_thumbnail_warmup(config: &mold_core::Config) {
3106 if !thumbnail_warmup_enabled() {
3107 tracing::info!("thumbnail warmup disabled; thumbnails will be generated on demand");
3108 return;
3109 }
3110
3111 let output_dir = config.effective_output_dir();
3112 std::thread::spawn(move || {
3113 if !output_dir.is_dir() {
3114 return;
3115 }
3116 let thumb_dir = server_thumbnail_dir();
3117 let walker = walkdir::WalkDir::new(&output_dir).max_depth(1).into_iter();
3118 for entry in walker.filter_map(|e| e.ok()) {
3119 let path = entry.path();
3120 if !path.is_file() {
3121 continue;
3122 }
3123 let ext = path
3124 .extension()
3125 .and_then(|e| e.to_str())
3126 .map(|e| e.to_lowercase());
3127 let is_raster = matches!(
3128 ext.as_deref(),
3129 Some("png" | "jpg" | "jpeg" | "gif" | "apng" | "webp")
3130 );
3131 let is_video = matches!(ext.as_deref(), Some("mp4"));
3132 if !is_raster && !is_video {
3133 continue;
3134 }
3135 let filename = path
3136 .file_name()
3137 .map(|f| f.to_string_lossy().to_string())
3138 .unwrap_or_default();
3139 let thumb_path = thumb_dir.join(format!("{filename}.png"));
3140 if thumb_path.is_file() {
3141 continue;
3142 }
3143 let result = if is_video {
3144 generate_video_thumbnail(path, &thumb_path)
3145 } else {
3146 generate_server_thumbnail(path, &thumb_path)
3147 };
3148 if let Err(e) = result {
3149 tracing::warn!("failed to generate thumbnail for {}: {e}", path.display());
3150 }
3151 }
3152 tracing::info!("thumbnail warmup complete");
3153 });
3154}
3155
3156fn thumbnail_warmup_enabled() -> bool {
3157 std::env::var("MOLD_THUMBNAIL_WARMUP")
3158 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
3159 .unwrap_or(false)
3160}
3161
3162fn scan_gallery_dir(dir: &std::path::Path) -> Vec<mold_core::GalleryImage> {
3181 let mut images: Vec<mold_core::GalleryImage> = mold_db::scan::scan_output_dir(dir)
3182 .filter_map(|item| match item {
3183 mold_db::scan::ScanItem::Valid(file) => Some(file),
3184 _ => None,
3185 })
3186 .map(|file| {
3187 let timestamp = file.timestamp_secs();
3188 let size_bytes = file.size_u64();
3189 let (metadata, synthetic) = mold_db::metadata_io::read_or_synthesize(
3190 &file.path,
3191 file.format,
3192 &file.filename,
3193 timestamp,
3194 );
3195 mold_core::GalleryImage {
3196 filename: file.filename,
3197 metadata,
3198 timestamp,
3199 format: Some(file.format),
3200 size_bytes: Some(size_bytes),
3201 metadata_synthetic: synthetic,
3202 }
3203 })
3204 .collect();
3205
3206 images.sort_by_key(|img| std::cmp::Reverse(img.timestamp));
3207 images
3208}
3209
3210async fn get_model_placement(
3218 State(state): State<AppState>,
3219 axum::extract::Path(name): axum::extract::Path<String>,
3220) -> Result<Json<mold_core::types::DevicePlacement>, ApiError> {
3221 let cfg = state.config.read().await;
3222 match cfg.models.get(&name).and_then(|mc| mc.placement.clone()) {
3223 Some(placement) => Ok(Json(placement)),
3224 None => Err(ApiError::not_found(format!(
3225 "no placement saved for model '{name}'"
3226 ))),
3227 }
3228}
3229
3230async fn put_model_placement(
3231 State(state): State<AppState>,
3232 axum::extract::Path(name): axum::extract::Path<String>,
3233 Json(placement): Json<mold_core::types::DevicePlacement>,
3234) -> Result<Json<serde_json::Value>, ApiError> {
3235 validate_multi_gpu_placement(&state, Some(&placement))?;
3236 {
3237 let mut cfg = state.config.write().await;
3238 cfg.set_model_placement(&name, Some(placement.clone()));
3239 cfg.save().map_err(|e| {
3240 tracing::warn!("failed to persist placement to config.toml: {e}");
3241 ApiError::internal(format!("failed to persist placement to config.toml: {e}"))
3242 })?;
3243 }
3244 Ok(Json(serde_json::json!({
3245 "ok": true,
3246 "model": name,
3247 })))
3248}
3249
3250async fn delete_model_placement(
3251 State(state): State<AppState>,
3252 axum::extract::Path(name): axum::extract::Path<String>,
3253) -> Result<Json<serde_json::Value>, ApiError> {
3254 let mut cfg = state.config.write().await;
3255 cfg.set_model_placement(&name, None);
3256 cfg.save().map_err(|e| {
3257 tracing::warn!("failed to persist placement removal to config.toml: {e}");
3258 ApiError::internal(format!(
3259 "failed to persist placement removal to config.toml: {e}"
3260 ))
3261 })?;
3262 Ok(Json(serde_json::json!({ "ok": true })))
3263}
3264
3265async fn openapi_json() -> impl IntoResponse {
3268 Json(ApiDoc::openapi())
3269}
3270
3271async fn scalar_docs() -> impl IntoResponse {
3274 (
3275 [(header::CONTENT_TYPE, "text/html")],
3276 r#"<!DOCTYPE html>
3277<html>
3278<head>
3279 <title>mold API</title>
3280 <meta charset="utf-8" />
3281 <meta name="viewport" content="width=device-width, initial-scale=1" />
3282</head>
3283<body>
3284 <script id="api-reference" data-url="/api/openapi.json"></script>
3285 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
3286</body>
3287</html>"#,
3288 )
3289}
3290
3291fn query_gpu_info() -> Option<GpuInfo> {
3294 query_cuda_gpu_info().or_else(query_metal_gpu_info)
3295}
3296
3297fn query_cuda_gpu_info() -> Option<GpuInfo> {
3298 let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
3299 "/run/current-system/sw/bin/nvidia-smi"
3300 } else {
3301 "nvidia-smi"
3302 };
3303
3304 let output = std::process::Command::new(nvidia_smi)
3305 .args([
3306 "--query-gpu=name,memory.total,memory.used",
3307 "--format=csv,noheader,nounits",
3308 ])
3309 .output()
3310 .ok()?;
3311
3312 if !output.status.success() {
3313 return None;
3314 }
3315
3316 let text = String::from_utf8(output.stdout).ok()?;
3317 let line = text.lines().next()?;
3318 let parts: Vec<&str> = line.split(',').map(str::trim).collect();
3319 if parts.len() < 3 {
3320 return None;
3321 }
3322
3323 Some(GpuInfo {
3324 name: parts[0].to_string(),
3325 vram_total_mb: parts[1].parse().ok()?,
3326 vram_used_mb: parts[2].parse().ok()?,
3327 backend: Some(GpuBackend::Cuda),
3328 })
3329}
3330
3331fn query_metal_gpu_info() -> Option<GpuInfo> {
3336 let gpu = crate::resources::metal_snapshot().into_iter().next()?;
3337 Some(GpuInfo {
3338 name: gpu.name,
3339 vram_total_mb: gpu.vram_total / 1_000_000,
3342 vram_used_mb: gpu.vram_used / 1_000_000,
3343 backend: Some(GpuBackend::Metal),
3344 })
3345}
3346
3347#[derive(serde::Deserialize, utoipa::ToSchema)]
3350pub struct CreateDownloadBody {
3351 pub model: String,
3352}
3353
3354#[derive(serde::Serialize, utoipa::ToSchema)]
3355pub struct CreateDownloadResponse {
3356 pub id: String,
3357 pub position: usize,
3358}
3359
3360#[utoipa::path(
3361 post,
3362 path = "/api/downloads",
3363 tag = "downloads",
3364 request_body = CreateDownloadBody,
3365 responses(
3366 (status = 200, description = "Enqueued; position 0 = will start immediately", body = CreateDownloadResponse),
3367 (status = 400, description = "Unknown model"),
3368 (status = 409, description = "Already active or queued; body contains existing id", body = CreateDownloadResponse),
3369 )
3370)]
3371pub async fn create_download(
3372 State(state): State<AppState>,
3373 Json(body): Json<CreateDownloadBody>,
3374) -> axum::response::Response {
3375 use crate::downloads::{EnqueueError, EnqueueOutcome};
3376 match state.downloads.enqueue(body.model.clone()).await {
3377 Ok((id, position, EnqueueOutcome::Created)) => (
3378 StatusCode::OK,
3379 Json(CreateDownloadResponse { id, position }),
3380 )
3381 .into_response(),
3382 Ok((id, position, EnqueueOutcome::AlreadyPresent)) => (
3383 StatusCode::CONFLICT,
3384 Json(CreateDownloadResponse { id, position }),
3385 )
3386 .into_response(),
3387 Err(EnqueueError::UnknownModel(_)) => (
3388 StatusCode::BAD_REQUEST,
3389 Json(serde_json::json!({
3390 "error": format!("unknown model '{}'. Run 'mold list' to see available models.", body.model)
3391 })),
3392 )
3393 .into_response(),
3394 Err(EnqueueError::LockPoisoned) => (
3395 StatusCode::INTERNAL_SERVER_ERROR,
3396 Json(serde_json::json!({ "error": "download queue state is corrupt" })),
3397 )
3398 .into_response(),
3399 }
3400}
3401
3402#[utoipa::path(
3403 delete,
3404 path = "/api/downloads/{id}",
3405 tag = "downloads",
3406 params(("id" = String, Path, description = "Job id")),
3407 responses(
3408 (status = 204, description = "Cancelled"),
3409 (status = 404, description = "Unknown id"),
3410 )
3411)]
3412pub async fn delete_download(
3413 State(state): State<AppState>,
3414 axum::extract::Path(id): axum::extract::Path<String>,
3415) -> axum::response::Response {
3416 if state.downloads.cancel(&id).await {
3417 StatusCode::NO_CONTENT.into_response()
3418 } else {
3419 (
3420 StatusCode::NOT_FOUND,
3421 Json(serde_json::json!({ "error": format!("unknown download id '{id}'") })),
3422 )
3423 .into_response()
3424 }
3425}
3426
3427#[utoipa::path(
3428 get,
3429 path = "/api/downloads",
3430 tag = "downloads",
3431 responses((status = 200, description = "Current queue state"))
3432)]
3433pub async fn list_downloads(State(state): State<AppState>) -> axum::response::Response {
3434 Json(state.downloads.listing().await).into_response()
3435}
3436
3437#[utoipa::path(
3438 get,
3439 path = "/api/downloads/stream",
3440 tag = "downloads",
3441 responses((status = 200, description = "SSE stream of DownloadEvent JSON")),
3442)]
3443pub async fn stream_downloads(
3444 State(state): State<AppState>,
3445) -> Sse<
3446 impl futures_core::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>,
3447> {
3448 use axum::response::sse::Event;
3449 use tokio_stream::wrappers::BroadcastStream;
3450 use tokio_stream::StreamExt as _;
3451
3452 let rx = state.downloads.subscribe();
3459 let initial = state.downloads.listing().await;
3460 let snapshot_event = mold_core::types::DownloadEvent::Snapshot { listing: initial };
3461
3462 let stream = async_stream::stream! {
3463 let data = serde_json::to_string(&snapshot_event).unwrap_or_else(|_| "{}".to_string());
3464 yield Ok::<_, std::convert::Infallible>(Event::default().event("download").data(data));
3465
3466 let mut bs = BroadcastStream::new(rx);
3467 while let Some(item) = bs.next().await {
3468 match item {
3469 Ok(event) => {
3470 let data = serde_json::to_string(&event)
3471 .unwrap_or_else(|_| "{}".to_string());
3472 yield Ok(Event::default().event("download").data(data));
3473 }
3474 Err(_lagged) => continue,
3478 }
3479 }
3480 };
3481
3482 Sse::new(stream).keep_alive(
3483 KeepAlive::new()
3484 .interval(std::time::Duration::from_secs(15))
3485 .text("ping"),
3486 )
3487}
3488
3489async fn get_resources(State(state): State<AppState>) -> Result<Json<ResourceSnapshot>, ApiError> {
3495 match state.resources.latest() {
3496 Some(snap) => Ok(Json(snap)),
3497 None => Err(ApiError::internal_with_status(
3498 "resource telemetry not ready",
3499 StatusCode::SERVICE_UNAVAILABLE,
3500 )),
3501 }
3502}
3503
3504async fn get_resources_stream(
3507 State(state): State<AppState>,
3508) -> Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>> {
3509 use tokio_stream::wrappers::BroadcastStream;
3510
3511 let rx = state.resources.subscribe();
3512 let initial = state.resources.latest();
3515
3516 let stream = async_stream::stream! {
3517 if let Some(snap) = initial {
3518 yield Ok::<_, Infallible>(snapshot_to_sse(&snap));
3519 }
3520 let mut bs = BroadcastStream::new(rx);
3521 while let Some(item) = bs.next().await {
3522 match item {
3523 Ok(snap) => yield Ok(snapshot_to_sse(&snap)),
3524 Err(_lagged) => continue,
3527 }
3528 }
3529 };
3530
3531 Sse::new(stream).keep_alive(
3532 KeepAlive::new()
3533 .interval(std::time::Duration::from_secs(15))
3534 .text("ping"),
3535 )
3536}
3537
3538fn snapshot_to_sse(snap: &ResourceSnapshot) -> SseEvent {
3539 match serde_json::to_string(snap) {
3540 Ok(data) => SseEvent::default().event("snapshot").data(data),
3541 Err(e) => SseEvent::default()
3542 .event("error")
3543 .data(format!("{{\"message\":\"serialize failed: {e}\"}}")),
3544 }
3545}
3546
3547#[utoipa::path(
3555 get,
3556 path = "/api/events",
3557 tag = "server",
3558 responses(
3559 (status = 200, description = "SSE stream of server lifecycle events", content_type = "text/event-stream")
3560 )
3561)]
3562async fn stream_events(
3563 State(state): State<AppState>,
3564) -> Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>> {
3565 use tokio_stream::wrappers::BroadcastStream;
3566
3567 let rx = state.events.subscribe();
3568 let stream = async_stream::stream! {
3569 let mut bs = BroadcastStream::new(rx);
3570 while let Some(item) = bs.next().await {
3571 match item {
3572 Ok(ev) => yield Ok::<_, Infallible>(server_event_to_sse(&ev)),
3573 Err(_lagged) => continue,
3576 }
3577 }
3578 };
3579
3580 Sse::new(stream).keep_alive(
3581 KeepAlive::new()
3582 .interval(std::time::Duration::from_secs(15))
3583 .text("ping"),
3584 )
3585}
3586
3587fn server_event_to_sse(ev: &mold_core::ServerEvent) -> SseEvent {
3588 match serde_json::to_string(ev) {
3589 Ok(data) => SseEvent::default().event("event").data(data),
3590 Err(e) => SseEvent::default()
3593 .event("error")
3594 .data(serde_json::json!({ "message": format!("serialize failed: {e}") }).to_string()),
3595 }
3596}
3597
3598#[cfg(test)]
3599mod tests {
3600 use super::*;
3601
3602 fn env_lock() -> &'static std::sync::Mutex<()> {
3603 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3604 &ENV_LOCK
3605 }
3606
3607 #[test]
3608 fn clean_error_message_strips_backtrace() {
3609 let err = anyhow::anyhow!(
3610 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
3611 \x20 0: candle_core::error::Error::bt\n\
3612 \x20 at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
3613 \x20 1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
3614 \x20 at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
3615 );
3616 let msg = clean_error_message(&err);
3617 assert_eq!(
3618 msg,
3619 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
3620 );
3621 }
3622
3623 #[test]
3624 fn clean_error_message_preserves_simple_error() {
3625 let err = anyhow::anyhow!("model not found: flux-dev:q4");
3626 let msg = clean_error_message(&err);
3627 assert_eq!(msg, "model not found: flux-dev:q4");
3628 }
3629
3630 #[test]
3631 fn clean_error_message_preserves_multiline_without_backtrace() {
3632 let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
3633 let msg = clean_error_message(&err);
3634 assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
3635 }
3636
3637 #[test]
3638 fn clean_error_message_strips_high_numbered_frames() {
3639 let err = anyhow::anyhow!(
3640 "some error\n\
3641 \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
3642 \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
3643 );
3644 let msg = clean_error_message(&err);
3645 assert_eq!(msg, "some error");
3646 }
3647
3648 #[test]
3649 fn clean_error_message_empty_fallback() {
3650 let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
3652 let msg = clean_error_message(&err);
3653 assert!(!msg.is_empty());
3655 }
3656
3657 #[test]
3658 fn clean_error_message_renders_full_anyhow_chain() {
3659 let root = std::io::Error::new(std::io::ErrorKind::InvalidData, "bytes past end");
3664 let err: anyhow::Error = anyhow::Error::new(root)
3665 .context("validate single-file checkpoint at /tmp/foo.safetensors");
3666 let msg = clean_error_message(&err);
3667 assert!(
3668 msg.contains("validate single-file checkpoint") && msg.contains("bytes past end"),
3669 "expected both context layers in the rendered chain, got: {msg}",
3670 );
3671 }
3672
3673 #[test]
3674 fn save_image_to_dir_creates_directory_and_writes_file() {
3675 let dir = std::env::temp_dir().join(format!(
3676 "mold-save-test-{}",
3677 std::time::SystemTime::now()
3678 .duration_since(std::time::UNIX_EPOCH)
3679 .unwrap()
3680 .as_nanos()
3681 ));
3682 assert!(!dir.exists());
3683
3684 let img = mold_core::ImageData {
3685 data: vec![0x89, 0x50, 0x4E, 0x47], format: mold_core::OutputFormat::Png,
3687 width: 64,
3688 height: 64,
3689 index: 0,
3690 };
3691
3692 save_image_to_dir(&dir, &img, "test-model:q8", 1);
3693
3694 assert!(dir.exists(), "directory should be created");
3695 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
3696 assert_eq!(files.len(), 1, "should have exactly one file");
3697 let file = files[0].as_ref().unwrap();
3698 let filename = file.file_name().to_str().unwrap().to_string();
3699 assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
3700 assert!(filename.ends_with(".png"), "{filename}");
3701 let contents = std::fs::read(file.path()).unwrap();
3702 assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
3703
3704 std::fs::remove_dir_all(&dir).ok();
3705 }
3706
3707 #[test]
3708 fn save_image_to_dir_batch_includes_index() {
3709 let dir = std::env::temp_dir().join(format!(
3710 "mold-save-batch-{}",
3711 std::time::SystemTime::now()
3712 .duration_since(std::time::UNIX_EPOCH)
3713 .unwrap()
3714 .as_nanos()
3715 ));
3716
3717 let img = mold_core::ImageData {
3718 data: vec![0xFF, 0xD8], format: mold_core::OutputFormat::Jpeg,
3720 width: 64,
3721 height: 64,
3722 index: 2,
3723 };
3724
3725 save_image_to_dir(&dir, &img, "flux-dev", 4);
3726
3727 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
3728 assert_eq!(files.len(), 1);
3729 let filename = files[0]
3730 .as_ref()
3731 .unwrap()
3732 .file_name()
3733 .to_str()
3734 .unwrap()
3735 .to_string();
3736 assert!(
3737 filename.contains("-2.jpeg"),
3738 "batch index in name: {filename}"
3739 );
3740
3741 std::fs::remove_dir_all(&dir).ok();
3742 }
3743
3744 #[test]
3745 fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
3746 let img = mold_core::ImageData {
3748 data: vec![0x00],
3749 format: mold_core::OutputFormat::Png,
3750 width: 1,
3751 height: 1,
3752 index: 0,
3753 };
3754 save_image_to_dir(
3756 std::path::Path::new("/dev/null/impossible"),
3757 &img,
3758 "test",
3759 1,
3760 );
3761 }
3763
3764 #[test]
3765 fn thumbnail_warmup_is_disabled_by_default() {
3766 let _guard = env_lock().lock().unwrap();
3767 unsafe {
3768 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3769 }
3770 assert!(!thumbnail_warmup_enabled());
3771 }
3772
3773 #[test]
3774 fn thumbnail_warmup_accepts_truthy_env_values() {
3775 let _guard = env_lock().lock().unwrap();
3776 unsafe {
3777 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "1");
3778 }
3779 assert!(thumbnail_warmup_enabled());
3780 unsafe {
3781 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "true");
3782 }
3783 assert!(thumbnail_warmup_enabled());
3784 unsafe {
3785 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "YES");
3786 }
3787 assert!(thumbnail_warmup_enabled());
3788 unsafe {
3789 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3790 }
3791 }
3792
3793 #[test]
3794 fn thumbnail_warmup_rejects_falsey_env_values() {
3795 let _guard = env_lock().lock().unwrap();
3796 unsafe {
3797 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "0");
3798 }
3799 assert!(!thumbnail_warmup_enabled());
3800 unsafe {
3801 std::env::set_var("MOLD_THUMBNAIL_WARMUP", "false");
3802 }
3803 assert!(!thumbnail_warmup_enabled());
3804 unsafe {
3805 std::env::remove_var("MOLD_THUMBNAIL_WARMUP");
3806 }
3807 }
3808
3809 #[test]
3810 fn content_type_covers_every_output_format() {
3811 assert_eq!(content_type_for_filename("a.png"), "image/png");
3812 assert_eq!(content_type_for_filename("a.PNG"), "image/png");
3813 assert_eq!(content_type_for_filename("a.jpg"), "image/jpeg");
3814 assert_eq!(content_type_for_filename("a.jpeg"), "image/jpeg");
3815 assert_eq!(content_type_for_filename("a.gif"), "image/gif");
3816 assert_eq!(content_type_for_filename("a.webp"), "image/webp");
3817 assert_eq!(content_type_for_filename("a.apng"), "image/apng");
3818 assert_eq!(content_type_for_filename("a.mp4"), "video/mp4");
3819 assert_eq!(
3820 content_type_for_filename("a.unknown"),
3821 "application/octet-stream"
3822 );
3823 }
3824 struct TempDir(std::path::PathBuf);
3833 impl TempDir {
3834 fn new(tag: &str) -> Self {
3835 let mut p = std::env::temp_dir();
3836 p.push(format!("mold-gallery-test-{tag}-{}", uuid::Uuid::new_v4()));
3837 std::fs::create_dir_all(&p).expect("create tempdir");
3838 Self(p)
3839 }
3840 fn path(&self) -> &std::path::Path {
3841 &self.0
3842 }
3843 }
3844 impl Drop for TempDir {
3845 fn drop(&mut self) {
3846 let _ = std::fs::remove_dir_all(&self.0);
3847 }
3848 }
3849
3850 fn make_png_bytes(width: u32, height: u32) -> Vec<u8> {
3855 let img = image::RgbImage::from_fn(width, height, |x, y| {
3856 let n = (x.wrapping_mul(37) ^ y.wrapping_mul(131)) as u8;
3857 image::Rgb([n, n.wrapping_add(85), n.wrapping_sub(17)])
3858 });
3859 let mut buf = Vec::new();
3860 image::DynamicImage::ImageRgb8(img)
3861 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3862 .expect("encode png");
3863 buf
3864 }
3865
3866 #[test]
3867 fn scan_gallery_dir_filters_invalid_and_keeps_valid() {
3868 let td = TempDir::new("scan");
3869 let dir = td.path();
3870
3871 std::fs::write(dir.join("mold-model-1000.png"), make_png_bytes(32, 32)).unwrap();
3873
3874 let mut junk = vec![0u8; 512];
3876 junk[..4].copy_from_slice(b"JUNK");
3877 std::fs::write(dir.join("mold-broken-2000.png"), &junk).unwrap();
3878
3879 std::fs::write(
3881 dir.join("mold-tiny-3000.png"),
3882 b"\x89PNG\r\n\x1a\n", )
3884 .unwrap();
3885
3886 let mut mp4 = Vec::new();
3888 mp4.extend_from_slice(&[0x00, 0x00, 0x00, 0x20]);
3889 mp4.extend_from_slice(b"ftyp");
3890 mp4.extend_from_slice(b"isom\x00\x00\x02\x00");
3891 mp4.resize(8192, 0);
3894 std::fs::write(dir.join("mold-ltx-4000.mp4"), &mp4).unwrap();
3895
3896 let bad_mp4 = vec![0u8; 8192];
3898 std::fs::write(dir.join("mold-no-ftyp-5000.mp4"), &bad_mp4).unwrap();
3899
3900 std::fs::write(dir.join("random.txt"), b"not an output").unwrap();
3902
3903 let results = scan_gallery_dir(dir);
3904 let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
3905 assert!(
3906 names.contains(&"mold-model-1000.png"),
3907 "valid PNG should survive: {names:?}"
3908 );
3909 assert!(
3910 names.contains(&"mold-ltx-4000.mp4"),
3911 "valid MP4 with ftyp should survive: {names:?}"
3912 );
3913 assert!(
3914 !names.contains(&"mold-broken-2000.png"),
3915 "PNG with no valid header should be filtered: {names:?}"
3916 );
3917 assert!(
3918 !names.contains(&"mold-tiny-3000.png"),
3919 "under-size PNG stub should be filtered: {names:?}"
3920 );
3921 assert!(
3922 !names.contains(&"mold-no-ftyp-5000.mp4"),
3923 "MP4 without ftyp should be filtered: {names:?}"
3924 );
3925 assert_eq!(names.len(), 2, "only the 2 valid fixtures remain");
3926 }
3927
3928 #[test]
3929 fn solid_black_png_is_filtered_at_scan_time() {
3930 let td = TempDir::new("black");
3931 let dir = td.path();
3932
3933 let black = image::RgbImage::from_pixel(256, 256, image::Rgb([0, 0, 0]));
3937 let mut buf = Vec::new();
3938 image::DynamicImage::ImageRgb8(black)
3939 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3940 .unwrap();
3941 std::fs::write(dir.join("mold-noisy-1000.png"), &buf).unwrap();
3942
3943 std::fs::write(dir.join("mold-valid-2000.png"), make_png_bytes(256, 256)).unwrap();
3945
3946 let results = scan_gallery_dir(dir);
3947 let names: Vec<&str> = results.iter().map(|i| i.filename.as_str()).collect();
3948 assert!(
3949 !names.contains(&"mold-noisy-1000.png"),
3950 "solid-black PNG should be filtered: {names:?}"
3951 );
3952 assert!(
3953 names.contains(&"mold-valid-2000.png"),
3954 "noisy PNG should survive: {names:?}"
3955 );
3956 }
3957 #[test]
3958 fn parse_byte_range_handles_common_forms() {
3959 assert_eq!(parse_byte_range("bytes=0-499", 2000), Some((0, 499)));
3961 assert_eq!(parse_byte_range("bytes=100-", 2000), Some((100, 1999)));
3963 assert_eq!(parse_byte_range("bytes=-500", 2000), Some((1500, 1999)));
3965 assert_eq!(parse_byte_range("bytes=0-9999", 2000), Some((0, 1999)));
3967 assert_eq!(parse_byte_range("bytes=0-1999", 2000), Some((0, 1999)));
3969 }
3970
3971 #[test]
3972 fn parse_byte_range_rejects_malformed_and_unsatisfiable() {
3973 assert_eq!(parse_byte_range("bytes=", 1000), None);
3974 assert_eq!(parse_byte_range("bytes=abc-100", 1000), None);
3975 assert_eq!(parse_byte_range("bytes=2000-", 1000), None);
3977 assert_eq!(parse_byte_range("bytes=500-100", 1000), None);
3979 assert_eq!(parse_byte_range("bytes=0-10,20-30", 1000), None);
3981 assert_eq!(parse_byte_range("bytes=-0", 1000), None);
3983 assert_eq!(parse_byte_range("bytes=0-10", 0), None);
3985 assert_eq!(parse_byte_range("items=0-10", 1000), None);
3987 }
3988
3989 #[test]
3990 fn scan_populates_real_dimensions_for_synthesized_metadata() {
3991 let td = TempDir::new("dims");
3995 let dir = td.path();
3996 std::fs::write(dir.join("mold-nometa-1000.png"), make_png_bytes(128, 96)).unwrap();
3997
3998 let results = scan_gallery_dir(dir);
3999 assert_eq!(results.len(), 1);
4000 let entry = &results[0];
4001 assert!(entry.metadata_synthetic);
4002 assert_eq!(entry.metadata.width, 128);
4003 assert_eq!(entry.metadata.height, 96);
4004 }
4005}