Skip to main content

mold_server/
routes.rs

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// ── ApiError — structured JSON error response ────────────────────────────────
37
38#[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    /// Job cancelled while queued. 499 is the de-facto "client closed
120    /// request" status (nginx); we reuse it for "request cancelled before
121    /// the server did any work" so clients can distinguish cancellation
122    /// from real inference failures.
123    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        // On queue-full (503), hint clients to retry with a short delay.
160        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// Re-export for tests — the canonical implementation lives in queue.rs.
170#[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    // Stateful routes (need AppState) are added first, then .with_state() converts
284    // Router<AppState> → Router<()>. Stateless routes (OpenAPI, docs) are merged after.
285    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        // ─── Downloads UI (Agent A) ────────────────────────────────────────
350        .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        // ─── Catalog (live HF + Civitai proxy) ──────────────────────────
354        .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        // ─── /api/config — HTTP counterpart of the `mold config` verbs ────
392        .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        // Agent C (model-ui-overhaul §3): placement persistence.
408        .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
420// ── Model readiness ──────────────────────────────────────────────────────────
421
422fn 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    // Use milliseconds for server-side filenames to avoid overwrites when
455    // concurrent requests finish in the same second.
456    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
470// ── Shared pre-queue validation ───────────────────────────────────────────────
471
472/// Validate a generate request and resolve server-side defaults.
473///
474/// Performs the identical pre-queue checks used by both `generate` and
475/// `generate_stream`: applies the default metadata setting, validates the
476/// request, checks model availability, and resolves the output directory.
477async fn prepare_generation(
478    state: &AppState,
479    request: &mut mold_core::GenerateRequest,
480) -> Result<(Option<std::path::PathBuf>, Option<String>, Option<usize>), ApiError> {
481    // NOTE: the capacity check is enforced inside `state.queue.submit(...)` so
482    // that a burst of concurrent callers can't all slip past an open check
483    // (classic TOCTOU).  The submit call in `generate`/`generate_stream` will
484    // return `SubmitError::Full`, which is mapped to `ApiError::queue_full()`.
485    apply_default_metadata_setting(state, request).await;
486
487    let preferred_gpu = validate_multi_gpu_placement(state, request.placement.as_ref())?;
488
489    // Expand prompt if requested (before validation, so the expanded prompt gets validated)
490    maybe_expand_prompt(state, request, preferred_gpu).await?;
491
492    // Catalog (`cv:*` / `hf:*`) IDs aren't in the static manifest, so the
493    // pure-mold-core family lookup returns `None` for them. Run the live
494    // single-id install first so the intent cache has the entry; then
495    // feed its family string through as a hint so audio / keyframes /
496    // pipeline gates work for installed Civitai LTX-2 checkpoints.
497    //
498    // A `Network` error here means Civitai/HF is unreachable — surface it
499    // immediately as 502 rather than letting the user fall through to
500    // a "not installed" 404 they can't act on.
501    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    // Resolve the model family for normalisation. `family_for_model` checks the
507    // static manifest first (covers all built-in models), then falls back to the
508    // catalog DB entry (covers `cv:*` / `hf:*` models installed above).
509    let resolved_family = model_manager::family_for_model(state, &request.model).await;
510    // Fill in a family-aware output format default when the caller omitted the
511    // field. This must happen before validation so the validator sees a concrete
512    // format and can gate on it correctly.
513    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
540/// Record an accepted generation prompt into prompt history (best-effort;
541/// no-op when the metadata DB is disabled). Consecutive identical rows are
542/// collapsed so batch siblings and retries don't spam duplicates. Records
543/// what the user actually typed — callers capture the prompt before
544/// `prepare_generation` runs prompt expansion.
545fn 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, // stamped with now() by push()
564    }) {
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// ── /api/generate ─────────────────────────────────────────────────────────────
648
649#[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)]
662// The server always produces 1 image per request; batch looping (--batch N)
663// is handled client-side by the CLI, which sends N requests with incrementing seeds.
664async fn generate(
665    State(state): State<AppState>,
666    Json(mut req): Json<mold_core::GenerateRequest>,
667) -> Result<impl IntoResponse, ApiError> {
668    // Capture before prepare_generation: prompt expansion mutates req.prompt,
669    // and history should hold what the user typed.
670    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    // Submit to generation queue
694    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
695    // Non-streaming path still gets a registry entry so an HTTP client
696    // hanging on the response is visible via GET /api/queue alongside
697    // SSE clients. Cleanup happens unconditionally on every terminal
698    // path (drop guard in `gpu_worker::process_job`).
699    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    // Wait for the queue worker to process the job — or for
719    // `DELETE /api/queue/:id` to cancel it while it's still queued.
720    // Returning here drops `result_rx`, so the worker's is_closed()
721    // check skips the job when it eventually reaches the head.
722    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            // For video responses, return the actual video data (not the thumbnail)
764            // and send video metadata in headers so the client can reconstruct VideoData.
765            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            // The multi-GPU dispatcher sends a queue-full error through result_tx
806            // when a per-worker channel is saturated; surface that as a proper 503
807            // instead of the generic INFERENCE_ERROR 500.
808            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
833/// Apply prompt expansion if `expand: true` is set on a generate request.
834async 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    // Resolve model family for prompt style
848    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 lock before blocking
864    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
886/// Create the appropriate expander for server-side use.
887fn 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// ── /api/expand ──────────────────────────────────────────────────────────────
924
925#[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
971// ── /api/upscale ────────────────────────────────────────────────────────────
972
973async 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    // Auto-pull upscaler model if not downloaded
984    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            // Reuse cached engine if same model.
1051            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
1074// ── /api/upscale/stream (SSE) ──────────────────────────────────────────────
1075
1076async 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    // Check if model needs pulling before spawning the SSE stream
1087    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    // Validate the model exists in the manifest if we need to pull
1097    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        // Auto-pull the upscaler model if not downloaded
1111        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        // Read weights path after potential pull
1190        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// ── /api/generate/stream (SSE) ───────────────────────────────────────────────
1383
1384#[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    // Capture before prepare_generation: prompt expansion mutates req.prompt,
1402    // and history should hold what the user typed.
1403    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    // Create SSE channel
1418    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
1419
1420    // Send dimension warning before queuing so the client sees it early
1421    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    // Assign a server-side ID and register before submit so the entry is
1429    // visible to /api/queue from the moment we accept the request.
1430    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    // First event the client sees — carries the position AND the server
1450    // ID so the SPA can later reconcile this job against /api/queue.
1451    let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
1452        position,
1453        id: job_id,
1454    }));
1455
1456    // Hold `tx` alive in a background task until the job completes, so the SSE
1457    // stream never closes prematurely even if the queue worker hasn't received
1458    // the job yet. A `DELETE /api/queue/:id` cancel resolves the select's
1459    // second arm: emit a terminal error event, then drop both channel ends —
1460    // dropping `result_rx` closes the job's result channel, which is what
1461    // makes the queue worker/dispatcher skip the job when it dequeues it.
1462    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); // closes the SSE stream
1472    });
1473
1474    // Build SSE stream from the channel receiver. Error events are terminal
1475    // on this endpoint (the worker always follows them by resolving the
1476    // job), so end the stream right after one — a cancelled job's queued
1477    // `GenerationJob` still holds a sender clone, and without the explicit
1478    // break the stream would stay open until the worker drained it.
1479    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// ── /api/models ───────────────────────────────────────────────────────────────
1498
1499#[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// ── /api/models/load ──────────────────────────────────────────────────────────
1530
1531#[derive(Debug, Deserialize, utoipa::ToSchema)]
1532pub struct LoadModelBody {
1533    #[schema(example = "flux-schnell:q8")]
1534    pub model: String,
1535    /// Target GPU ordinal (multi-GPU only). If omitted, the server uses its
1536    /// default placement strategy.
1537    #[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    // Multi-GPU path: route through the pool.
1563    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    // Legacy single-GPU path (no workers discovered). No resolution context
1606    // here — admin load uses size-only peak via the `None` hint.
1607    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// ── /api/models/pull ──────────────────────────────────────────────────────────
1613
1614#[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    // Enqueue via the queue. Treat idempotent AlreadyPresent as success.
1689    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        // Await terminal event for this job, return plain text.
1704        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    // SSE: re-emit queue events shaped like the legacy SseProgressEvent::DownloadProgress
1734    // so the TUI's existing consumer continues to work unchanged.
1735    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
1811/// Response type that can be either SSE stream or plain text.
1812enum 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// ── /api/models/unload ────────────────────────────────────────────────────────
1827
1828/// Optional request body for unload — clients may specify a model or GPU target.
1829/// An empty body (or no body) unloads the active model on the legacy path.
1830#[derive(Debug, Default, Deserialize, utoipa::ToSchema)]
1831pub struct UnloadRequest {
1832    /// Specific model to unload. If omitted, the active model is unloaded.
1833    #[serde(default, skip_serializing_if = "Option::is_none")]
1834    pub model: Option<String>,
1835    /// Target GPU ordinal (multi-GPU only).
1836    #[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    // Multi-GPU path: target specific GPU or model across the pool.
1858    if state.gpu_pool.worker_count() > 0 {
1859        // Select the workers to unload from.
1860        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    // Legacy single-GPU path.
1914    Ok((StatusCode::OK, model_manager::unload_model(&state).await))
1915}
1916
1917// ── DELETE /api/models/:model ─────────────────────────────────────────────────
1918
1919/// True when an engine for `canonical` is currently GPU-resident (or mid-
1920/// generation) anywhere on this server — the single-GPU cache, any pool
1921/// worker's cache, or an active-generation snapshot on either path.
1922async 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/// Remove a downloaded model's files — the HTTP counterpart of `mold rm`.
1954///
1955/// Ref-counts every file path across all installed models and deletes only
1956/// paths exclusively owned by this model; components still referenced by
1957/// another downloaded model (shared T5/CLIP/Qwen encoders, VAEs) are kept
1958/// and reported in `kept` with the surviving referents. hf-hub cache blobs
1959/// hardlinked to the deleted clean paths are removed too, so `freed_bytes`
1960/// reflects real disk savings.
1961#[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    // Refuse while the model is GPU-resident — there is no safe way to pull
1979    // files out from under a loaded engine. Check the raw input too: engines
1980    // register under their own model_name, which for non-manifest models may
1981    // not round-trip through resolve_model_name. (Best-effort check: a load
1982    // that races past it just keeps working off its already-open mmaps.)
1983    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    // Hold the config write lock across plan + delete so a concurrent pull
1996    // or placement write can't interleave with the removal.
1997    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    // Evict any parked (non-GPU-resident) engine so a later request can't
2024    // reactivate an engine whose files are gone.
2025    {
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// ── /api/status ───────────────────────────────────────────────────────────────
2052
2053#[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    // Aggregate GPU status from the pool.
2063    let gpu_statuses = state.gpu_pool.gpu_status();
2064    let has_gpus = !gpu_statuses.is_empty();
2065
2066    // Collect loaded models from GPU workers.
2067    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    // Pull current_generation from the first busy worker (multi-GPU) or
2076    // from the legacy snapshot.
2077    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    // Fall back to legacy single-GPU snapshot for backwards compat.
2092    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// ── /health ───────────────────────────────────────────────────────────────────
2142
2143#[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// ── /api/queue ───────────────────────────────────────────────────────────────
2156
2157/// Snapshot of every job currently queued or running on the server. Clients
2158/// (notably the web SPA) poll this to reconcile their local card list — any
2159/// "running" card whose server id isn't here is a zombie left over from a
2160/// dropped SSE stream and should be dead-lettered.
2161#[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/// Cancel a still-queued generation job. Only queued jobs are cancelable —
2230/// once a GPU worker owns the job there is no safe preemption point, so
2231/// running jobs return 409. The waiting client observes the cancellation as
2232/// a 499 `CANCELLED` error (blocking `POST /api/generate`) or a terminal
2233/// SSE `error` event (`POST /api/generate/stream`).
2234#[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/// Response of `POST /api/queue/pause` and `POST /api/queue/resume` — the
2261/// resulting pause state (`true` after pause, `false` after resume).
2262#[derive(Debug, Serialize, utoipa::ToSchema)]
2263struct QueuePauseResponse {
2264    paused: bool,
2265}
2266
2267/// Response of `DELETE /api/queue` — how many queued jobs were cancelled.
2268#[derive(Debug, Serialize, utoipa::ToSchema)]
2269struct QueueCancelAllResponse {
2270    cancelled: usize,
2271}
2272
2273/// Pause dispatch of new generation jobs. The job currently running on a
2274/// worker finishes; only the *next* job is held. Idempotent — repeat pauses
2275/// return `{"paused": true}` without re-emitting the `queue_paused` event.
2276#[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/// Resume dispatch of new generation jobs. Idempotent — repeat resumes return
2292/// `{"paused": false}` without re-emitting the `queue_resumed` event.
2293#[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/// Cancel every still-queued generation job. Running jobs are left untouched
2309/// (same rule as `DELETE /api/queue/:id`). Returns the number of jobs
2310/// cancelled; each waiting client observes the same cancellation signal as a
2311/// single-job cancel.
2312#[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
2325// ── /api/history ─────────────────────────────────────────────────────────────
2326
2327/// Default number of history rows returned when `limit` is omitted.
2328const HISTORY_DEFAULT_LIMIT: usize = 50;
2329/// Hard cap on `limit` — matches the legacy 500-entry history bound.
2330const HISTORY_MAX_LIMIT: usize = 500;
2331
2332/// 503 error code when the metadata DB is disabled (`MOLD_DB_DISABLE=1`).
2333const 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    /// Case-insensitive substring filter over the prompt text.
2348    query: Option<String>,
2349    /// Max rows to return (default 50, capped at 500).
2350    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    /// When present, trim to the most recent N entries instead of clearing.
2400    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
2429// ── /api/capabilities ────────────────────────────────────────────────────────
2430
2431/// Report the feature toggles a client needs to render correctly (hide the
2432/// delete button when delete isn't allowed, etc.). No auth required — this
2433/// is a read-only introspection endpoint.
2434async 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// ── /api/capabilities/chain-limits ───────────────────────────────────────────
2457
2458#[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    // TODO(sub-project D): pass live free VRAM from AppState.
2502    let limits = crate::chain_limits::compute_limits(&resolved, &family, &quant, 0);
2503    Json(limits).into_response()
2504}
2505
2506// ── /api/shutdown ─────────────────────────────────────────────────────────────
2507
2508/// Trigger graceful server shutdown.
2509///
2510/// When API key auth is enabled, the auth middleware protects this endpoint.
2511/// When auth is disabled, only requests from loopback addresses (127.0.0.1, ::1)
2512/// are accepted to prevent remote shutdown.
2513#[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    // When auth is disabled (no AuthState extension or AuthState is None),
2524    // restrict shutdown to loopback addresses only.
2525    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
2551// ── /api/gallery ──────────────────────────────────────────────────────────────
2552
2553/// List gallery images from the server's output directory.
2554///
2555/// Prefers the SQLite metadata DB when available so listings stay fast on
2556/// large galleries (no per-request directory walk). Falls back to the
2557/// filesystem scan when the DB is disabled, can't be opened, or — as a
2558/// safety net — has no rows for this directory yet (e.g. the reconciliation
2559/// background task has not finished on first startup).
2560async 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
2602/// Serve a gallery file by filename.
2603///
2604/// Supports HTTP `Range` requests so `<video>` elements can scrub MP4
2605/// outputs without downloading the whole clip up front. Partial responses
2606/// stream straight from disk via `tokio_util::io::ReaderStream` — nothing
2607/// buffers the full file in server RAM, which matters once a gallery
2608/// contains multi-GB LTX-2 outputs. Non-range requests still return the
2609/// whole file (streamed) with `Accept-Ranges: bytes` so the client knows
2610/// it can seek on subsequent requests.
2611async 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    // Sanitize: prevent directory traversal
2624    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            // A `Range` header we can't satisfy ⇒ 416 per RFC 9110 §15.6.2.
2659            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    // Full response: stream the file rather than buffer it in RAM.
2668    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
2680/// Parse a `Range: bytes=start-end` header into a concrete (start, end)
2681/// byte range inclusive on both ends. Returns `None` for unsatisfiable or
2682/// malformed ranges — the caller translates that into a 416 response.
2683///
2684/// Only the single-range form is supported (multipart ranges are vanishingly
2685/// rare in practice and substantially more complex to implement correctly;
2686/// browsers for `<video>` always send single ranges).
2687fn 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        // Suffix range: `bytes=-N` means "the last N bytes".
2702        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
2726/// Emit a `206 Partial Content` response streaming `[start, end]` inclusive
2727/// from the already-open file handle. `take(len)` bounds the reader so the
2728/// body terminates exactly at `end + 1` instead of reading the tail.
2729async 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        // Partial content is less cacheable at intermediaries than a plain
2753        // 200; keep a short TTL so the client's own cache still helps.
2754        .header(header::CACHE_CONTROL, "public, max-age=300")
2755        .body(body)
2756        .unwrap())
2757}
2758
2759/// Pick an HTTP Content-Type for a gallery filename. Covers every format
2760/// `OutputFormat` can emit plus a safe default.
2761fn 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
2780/// Delete a gallery image and its server-side thumbnail.
2781///
2782/// Destructive, but always enabled — pair with the `MOLD_API_KEY` middleware
2783/// when the server is exposed beyond localhost.
2784async 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    // File removal + DB delete are blocking syscalls / a synchronous SQLite
2805    // call — run the whole batch on the blocking pool so a slow disk can't
2806    // stall an async worker thread mid-generation.
2807    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        // Also remove server-side thumbnail (both legacy no-suffix and current
2818        // `.png`-suffixed cache layouts) and the animated preview sidecar so
2819        // `/api/gallery/preview/:filename` doesn't keep serving the GIF after
2820        // the source MP4 is gone.
2821        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        // Drop the matching metadata row if the DB is enabled. Errors here are
2829        // logged — they don't roll back the disk delete since the file is the
2830        // source of truth and reconciliation will re-sync on the next restart.
2831        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
2857/// Serve a thumbnail for a gallery image. Generated on-demand and cached
2858/// at ~/.mold/cache/thumbnails/ on the server side.
2859async 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    // Thumbnail cache path: always `.png` regardless of the source extension,
2887    // so mp4 / gif / apng / webp / jpg all coexist cleanly in the same cache
2888    // dir and `image.save()` doesn't pick the wrong format from the path.
2889    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        // Generate thumbnail on-demand. Videos go through openh264 for a real
2896        // first-frame extract; everything else decodes via the `image` crate.
2897        // If either path fails, we fall back to serving the source bytes
2898        // directly — browsers are more lenient about partial / checksum-
2899        // mismatched images than either decoder, and the SPA would rather
2900        // show something than a 500.
2901        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            // For videos, the browser can't render the raw mp4 as an <img>
2920            // either, so serving the source doesn't help — fall back to the
2921            // SVG play-icon placeholder instead.
2922            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
2966/// Serve a cached animated GIF preview for a gallery video output.
2967///
2968/// Looks up `<preview_dir>/<filename>.preview.gif` (default:
2969/// `~/.mold/cache/previews/`). When present, streams the file back as
2970/// `image/gif`; otherwise returns 404. This exists so the TUI's remote
2971/// gallery detail pane can animate video entries the same way it animates
2972/// local ones — previously it fell through to fetching the raw MP4 over
2973/// `/api/gallery/image/:filename`, which `image::open` couldn't decode,
2974/// leaving the panel on `Loading…` forever.
2975async 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    // Sanitize: prevent directory traversal — the filename must be a bare
2987    // basename with no separators.
2988    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    // The preview cache lifecycle is tied to the underlying gallery file:
2997    // if the MP4 has been deleted (via `DELETE /api/gallery/image/:filename`
2998    // or an out-of-band `rm`), the sidecar may still be on disk but is
2999    // orphaned and must not be served.
3000    // Check the source file first and 404 before touching the cache so a
3001    // stale `.preview.gif` never leaks deleted content.
3002    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
3039/// Server-side GIF preview cache directory. Mirrors the layout the TUI
3040/// writes to (`crates/mold-tui/src/thumbnails.rs::preview_dir`) so a
3041/// single preview.gif authored on either side is reachable via this
3042/// endpoint.
3043fn 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
3050/// Server-side thumbnail cache directory.
3051fn 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
3058/// Generate a 256x256 max thumbnail from source image. The result is always
3059/// written as a PNG regardless of the source format, so callers should pass
3060/// a `.png`-suffixed `dest` to keep the on-disk cache unambiguous.
3061fn 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
3074/// Extract the first frame of an MP4 as a PNG thumbnail and downscale to
3075/// 256px max via the `image` crate. Uses the openh264 pipeline that
3076/// `mold_inference::ltx2::media` already ships for video probes.
3077///
3078/// The full-frame PNG is written to a sibling temp path first, then decoded
3079/// and resized — this keeps `mold_inference`'s existing helper surface stable
3080/// while still producing a compact thumbnail.
3081fn 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    // Decode the first frame to a temporary full-resolution PNG, then
3089    // thumbnail-resize via the `image` crate. We stage through a temp file
3090    // rather than through memory to reuse `extract_thumbnail`'s existing
3091    // I/O-based API.
3092    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
3104/// Pre-generate thumbnails for all gallery images on server startup.
3105pub 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
3162/// Scan a directory for gallery outputs (images + videos).
3163///
3164/// Picks up every format `OutputFormat` can emit: png / jpg / jpeg / gif /
3165/// apng / webp / mp4. For files with no embedded `mold:parameters` chunk
3166/// (notably gif / webp / mp4), we synthesize a stub `OutputMetadata` from
3167/// the filename so the UI can still display them alongside annotated items.
3168///
3169/// Invalid files are filtered out at scan time rather than surfaced as
3170/// broken tiles in the UI. "Invalid" here means any of:
3171/// - below a format-specific size floor (tiny stubs left by abandoned
3172///   writes, aborted generations, or test harnesses)
3173/// - no decodable image header (raster formats)
3174/// - no `ftyp` box at the start of the file (mp4)
3175///
3176/// This is a header-only validation, not a full pixel decode, so a file
3177/// that passes the check can still be corrupt mid-stream (e.g. broken
3178/// IDAT CRC). Those fall through to the thumbnail endpoint which serves
3179/// the raw bytes as a last resort.
3180fn 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
3210// ── /api/config/model/:name/placement (Agent C, model-ui-overhaul §3) ────────
3211
3212/// Read the saved per-model placement default so an editor can hydrate its
3213/// controls before letting the user edit-and-save (without which a save
3214/// silently clobbers the persisted placement with defaults). Returns the raw
3215/// persisted value — not the env-overlaid `resolved_placement` — so a `404`
3216/// faithfully means "nothing saved for this model".
3217async 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
3265// ── /api/openapi.json ─────────────────────────────────────────────────────────
3266
3267async fn openapi_json() -> impl IntoResponse {
3268    Json(ApiDoc::openapi())
3269}
3270
3271// ── /api/docs ─────────────────────────────────────────────────────────────────
3272
3273async 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
3291// ── GPU info ──────────────────────────────────────────────────────────────────
3292
3293fn 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
3331/// Metal fallback (macOS only elsewhere returns an empty snapshot): unified
3332/// memory means "VRAM" is the addressable system RAM — same convention as
3333/// `/api/resources`. Gives Macs a non-null `gpu_info` so clients can rank
3334/// hosts by backend/VRAM without a resources stream.
3335fn query_metal_gpu_info() -> Option<GpuInfo> {
3336    let gpu = crate::resources::metal_snapshot().into_iter().next()?;
3337    Some(GpuInfo {
3338        name: gpu.name,
3339        // Resource snapshots are bytes; GpuInfo is MB (1 MB = 1_000_000,
3340        // matching `parse_nvidia_smi_line`).
3341        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// ─── Downloads UI (Agent A) ──────────────────────────────────────────────────
3348
3349#[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    // Subscribe BEFORE snapshotting so any event arriving during the
3453    // snapshot read is queued in the broadcast channel instead of being
3454    // missed. The first frame we yield is `Snapshot { listing }` —
3455    // mirrors `/api/resources/stream`'s initial-snapshot pattern so a
3456    // freshly-mounted SPA paints current state without waiting for the
3457    // next delta.
3458    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                // Slow subscribers see lag silently; the snapshot above
3475                // already carries the full state so we don't need to
3476                // resync on every drop.
3477                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
3489// ── Resource telemetry (Agent B scope) ───────────────────────────────────────
3490
3491/// `GET /api/resources` — one-shot JSON snapshot from the aggregator cache.
3492/// Returns 503 if the aggregator has not yet fired (first 1 s after startup
3493/// and before `spawn_aggregator` has run).
3494async 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
3504/// `GET /api/resources/stream` — SSE stream of `ResourceSnapshot` frames.
3505/// Event name: `snapshot`. Matches the keepalive cadence of `/api/generate/stream`.
3506async 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    // Attach the cached `latest` snapshot as the first frame so clients
3513    // don't wait up to one full tick for their initial value.
3514    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                // Lag is normal for slow clients — skip dropped frames
3525                // silently; the next one will catch them up.
3526                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/// `GET /api/events` — SSE stream of server-wide [`mold_core::ServerEvent`]s:
3548/// job lifecycle (queued/started/ended, mirrored off the job registry) and
3549/// gallery mutations (added/removed). One connection observes the whole
3550/// server, so clients don't need a held stream per job to know when the
3551/// gallery changed. Deltas only — bootstrap current state from
3552/// `GET /api/queue` + `GET /api/gallery` after subscribing. Event name:
3553/// `event`. Feature-detect via `capabilities.events.available`.
3554#[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                // Lagged receivers skip the gap; REST endpoints are the
3574                // recovery path for anything missed.
3575                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        // json! escapes the error text — quotes/newlines in `e` must not
3591        // produce an invalid JSON frame.
3592        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        // An error whose Display starts immediately with a backtrace-like line
3651        let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
3652        let msg = clean_error_message(&err);
3653        // Should fall back to root_cause since all lines look like backtrace
3654        assert!(!msg.is_empty());
3655    }
3656
3657    #[test]
3658    fn clean_error_message_renders_full_anyhow_chain() {
3659        // Wrapped errors must surface the root cause; previously the outer
3660        // `with_context` swallowed everything below it (cv:2739091 truncated
3661        // checkpoint surfaced as "mmap single-file checkpoint at …" with no
3662        // hint that the safetensors data was short).
3663        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], // PNG magic bytes
3686            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], // JPEG magic
3719            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        // Saving to a path that can't be created should not panic
3747        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        // /dev/null/impossible can't be created as a directory
3755        save_image_to_dir(
3756            std::path::Path::new("/dev/null/impossible"),
3757            &img,
3758            "test",
3759            1,
3760        );
3761        // Test passes if no panic occurred
3762    }
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    // ── Gallery validation ───────────────────────────────────────────────
3825    // The guard-rail pure functions live in `mold_db::metadata_io` (with
3826    // their own unit tests); these end-to-end scans pin that the server
3827    // gallery keeps consuming them correctly.
3828
3829    /// Create a scratch directory unique to this test and delete it on drop.
3830    /// Using `std::env::temp_dir()` rather than pulling in a `tempfile`
3831    /// dev-dep for two tests' worth of fixtures.
3832    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    /// Encode a noisy PNG in-memory via the `image` crate. The checkerboard
3851    /// pattern resists zlib compression so the encoded bytes exceed the
3852    /// gallery size floor — a solid-color PNG of the same dimensions would
3853    /// compress to ~80 bytes and be filtered out by the size guard.
3854    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        // A valid PNG large enough to exceed the 256-byte raster size floor.
3872        std::fs::write(dir.join("mold-model-1000.png"), make_png_bytes(32, 32)).unwrap();
3873
3874        // Truncated raster that passes size floor but has no valid header.
3875        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        // Tiny raster under the size floor (sub-IHDR).
3880        std::fs::write(
3881            dir.join("mold-tiny-3000.png"),
3882            b"\x89PNG\r\n\x1a\n", // 8 bytes: signature only
3883        )
3884        .unwrap();
3885
3886        // Valid-enough mp4 (ftyp at offset 4) — should survive.
3887        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        // Pad above the 4096-byte mp4 size floor so it isn't filtered on
3892        // size alone — the scan still checks ftyp either way.
3893        mp4.resize(8192, 0);
3894        std::fs::write(dir.join("mold-ltx-4000.mp4"), &mp4).unwrap();
3895
3896        // Mp4 extension but no ftyp.
3897        let bad_mp4 = vec![0u8; 8192];
3898        std::fs::write(dir.join("mold-no-ftyp-5000.mp4"), &bad_mp4).unwrap();
3899
3900        // Unsupported extension — ignored entirely.
3901        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        // A 256×256 solid-black PNG — definitely below the suspect-size
3934        // threshold (compresses to a few hundred bytes) and every pixel is
3935        // below the channel ceiling.
3936        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        // A normal noisy PNG with the same dimensions — should survive.
3944        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        // `bytes=0-499` — first 500 bytes
3960        assert_eq!(parse_byte_range("bytes=0-499", 2000), Some((0, 499)));
3961        // open-ended `bytes=100-` — from byte 100 to EOF
3962        assert_eq!(parse_byte_range("bytes=100-", 2000), Some((100, 1999)));
3963        // suffix `bytes=-500` — last 500 bytes
3964        assert_eq!(parse_byte_range("bytes=-500", 2000), Some((1500, 1999)));
3965        // end past EOF — clamped to last byte
3966        assert_eq!(parse_byte_range("bytes=0-9999", 2000), Some((0, 1999)));
3967        // whole file
3968        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        // start past EOF
3976        assert_eq!(parse_byte_range("bytes=2000-", 1000), None);
3977        // end before start
3978        assert_eq!(parse_byte_range("bytes=500-100", 1000), None);
3979        // multi-range not supported
3980        assert_eq!(parse_byte_range("bytes=0-10,20-30", 1000), None);
3981        // suffix of 0 bytes is meaningless
3982        assert_eq!(parse_byte_range("bytes=-0", 1000), None);
3983        // empty file can't satisfy any range
3984        assert_eq!(parse_byte_range("bytes=0-10", 0), None);
3985        // wrong unit prefix
3986        assert_eq!(parse_byte_range("items=0-10", 1000), None);
3987    }
3988
3989    #[test]
3990    fn scan_populates_real_dimensions_for_synthesized_metadata() {
3991        // Files without an embedded mold:parameters chunk still get their
3992        // actual width/height filled in from the header decode — useful for
3993        // the SPA's aspect-ratio-preserving layout.
3994        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}