1use axum::{
2 extract::State,
3 http::{header, HeaderMap, HeaderValue, StatusCode},
4 response::{
5 sse::{Event as SseEvent, KeepAlive, Sse},
6 IntoResponse,
7 },
8 routing::{delete, get, post},
9 Json, Router,
10};
11use mold_core::{
12 ActiveGenerationStatus, GpuInfo, ModelInfoExtended, OutputFormat, ServerStatus, SseErrorEvent,
13 SseProgressEvent,
14};
15use serde::{Deserialize, Serialize};
16use std::convert::Infallible;
17use tokio_stream::StreamExt as _;
18use utoipa::OpenApi;
19
20use crate::model_manager;
21use crate::state::{AppState, GenerationJob, SseMessage};
22
23#[derive(Debug, Serialize)]
26pub struct ApiError {
27 pub error: String,
28 pub code: String,
29 #[serde(skip)]
30 status: StatusCode,
31}
32
33impl ApiError {
34 pub fn validation(msg: impl Into<String>) -> Self {
35 Self {
36 error: msg.into(),
37 code: "VALIDATION_ERROR".to_string(),
38 status: StatusCode::UNPROCESSABLE_ENTITY,
39 }
40 }
41
42 pub fn not_found(msg: impl Into<String>) -> Self {
43 Self {
44 error: msg.into(),
45 code: "MODEL_NOT_FOUND".to_string(),
46 status: StatusCode::NOT_FOUND,
47 }
48 }
49
50 pub fn unknown_model(msg: impl Into<String>) -> Self {
51 Self {
52 error: msg.into(),
53 code: "UNKNOWN_MODEL".to_string(),
54 status: StatusCode::BAD_REQUEST,
55 }
56 }
57
58 pub fn inference(msg: impl Into<String>) -> Self {
59 Self {
60 error: msg.into(),
61 code: "INFERENCE_ERROR".to_string(),
62 status: StatusCode::INTERNAL_SERVER_ERROR,
63 }
64 }
65
66 pub fn internal(msg: impl Into<String>) -> Self {
67 Self {
68 error: msg.into(),
69 code: "INTERNAL_ERROR".to_string(),
70 status: StatusCode::INTERNAL_SERVER_ERROR,
71 }
72 }
73}
74
75impl IntoResponse for ApiError {
76 fn into_response(self) -> axum::response::Response {
77 let status = self.status;
78 (status, Json(self)).into_response()
79 }
80}
81
82#[cfg(test)]
84use crate::queue::clean_error_message;
85
86#[derive(OpenApi)]
87#[openapi(
88 paths(generate, generate_stream, expand_prompt, list_models, load_model, pull_model_endpoint, unload_model, server_status, health),
89 components(schemas(
90 mold_core::GenerateRequest,
91 mold_core::GenerateResponse,
92 mold_core::ExpandRequest,
93 mold_core::ExpandResponse,
94 mold_core::ImageData,
95 mold_core::OutputFormat,
96 mold_core::ModelInfo,
97 mold_core::ServerStatus,
98 mold_core::ActiveGenerationStatus,
99 mold_core::GpuInfo,
100 mold_core::SseProgressEvent,
101 mold_core::SseCompleteEvent,
102 mold_core::SseErrorEvent,
103 ModelInfoExtended,
104 LoadModelBody,
105 )),
106 tags(
107 (name = "generation", description = "Image generation"),
108 (name = "models", description = "Model management"),
109 (name = "server", description = "Server status and health"),
110 ),
111 info(
112 title = "mold",
113 description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
114 version = env!("CARGO_PKG_VERSION"),
115 )
116)]
117pub struct ApiDoc;
118
119pub fn create_router(state: AppState) -> Router {
120 Router::new()
123 .route("/api/generate", post(generate))
124 .route("/api/generate/stream", post(generate_stream))
125 .route("/api/expand", post(expand_prompt))
126 .route("/api/models", get(list_models))
127 .route("/api/models/load", post(load_model))
128 .route("/api/models/pull", post(pull_model_endpoint))
129 .route("/api/models/unload", delete(unload_model))
130 .route("/api/status", get(server_status))
131 .route("/health", get(health))
132 .with_state(state)
133 .route("/api/openapi.json", get(openapi_json))
134 .route("/api/docs", get(scalar_docs))
135}
136
137fn sse_message_to_event(msg: SseMessage) -> SseEvent {
140 fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
141 match serde_json::to_string(payload) {
142 Ok(data) => SseEvent::default().event(event_name).data(data),
143 Err(err) => SseEvent::default().event("error").data(
144 serde_json::json!({
145 "message": format!("failed to serialize SSE payload: {err}")
146 })
147 .to_string(),
148 ),
149 }
150 }
151
152 match msg {
153 SseMessage::Progress(payload) => serialize_event("progress", &payload),
154 SseMessage::Complete(payload) => serialize_event("complete", &payload),
155 SseMessage::Error(payload) => serialize_event("error", &payload),
156 }
157}
158
159#[cfg(test)]
160fn save_image_to_dir(
161 dir: &std::path::Path,
162 img: &mold_core::ImageData,
163 model: &str,
164 batch_size: u32,
165) {
166 if let Err(e) = std::fs::create_dir_all(dir) {
167 tracing::warn!("failed to create output dir {}: {e}", dir.display());
168 return;
169 }
170 let timestamp_ms = std::time::SystemTime::now()
173 .duration_since(std::time::UNIX_EPOCH)
174 .unwrap_or_default()
175 .as_millis() as u64;
176 let ext = img.format.to_string();
177 let filename =
178 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
179 let path = dir.join(&filename);
180 match std::fs::write(&path, &img.data) {
181 Ok(()) => tracing::info!("saved image to {}", path.display()),
182 Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
183 }
184}
185
186async fn prepare_generation(
194 state: &AppState,
195 request: &mut mold_core::GenerateRequest,
196) -> Result<(Option<std::path::PathBuf>, Option<String>), ApiError> {
197 apply_default_metadata_setting(state, request).await;
198
199 maybe_expand_prompt(state, request).await?;
201
202 if let Err(e) = validate_generate_request(request) {
203 return Err(ApiError::validation(e));
204 }
205
206 let _ = model_manager::check_model_available(state, &request.model).await?;
207
208 let (output_dir, dim_warning) = {
209 let config = state.config.read().await;
210 let output_dir = config.resolved_output_dir().map(|p| p.to_path_buf());
211 let family = config.resolved_model_config(&request.model).family;
212 let dim_warning = family
213 .as_deref()
214 .and_then(|f| mold_core::dimension_warning(request.width, request.height, f));
215 (output_dir, dim_warning)
216 };
217
218 Ok((output_dir, dim_warning))
219}
220
221#[utoipa::path(
224 post,
225 path = "/api/generate",
226 tag = "generation",
227 request_body = mold_core::GenerateRequest,
228 responses(
229 (status = 200, description = "Generated image bytes", content_type = "image/png"),
230 (status = 404, description = "Model not downloaded"),
231 (status = 422, description = "Invalid request parameters"),
232 (status = 500, description = "Inference error"),
233 )
234)]
235async fn generate(
238 State(state): State<AppState>,
239 Json(mut req): Json<mold_core::GenerateRequest>,
240) -> Result<impl IntoResponse, ApiError> {
241 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
242
243 tracing::info!(
244 model = %req.model,
245 prompt = %req.prompt,
246 width = req.width,
247 height = req.height,
248 steps = req.steps,
249 guidance = req.guidance,
250 seed = ?req.seed,
251 format = %req.output_format,
252 lora = ?req.lora.as_ref().map(|l| &l.path),
253 lora_scale = ?req.lora.as_ref().map(|l| l.scale),
254 "generate request"
255 );
256
257 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
259 let job = GenerationJob {
260 request: req,
261 progress_tx: None,
262 result_tx,
263 output_dir,
264 };
265
266 let _position = state.queue.submit(job).await.map_err(ApiError::internal)?;
267
268 let result = result_rx
270 .await
271 .map_err(|_| ApiError::internal("generation queue worker dropped the job"))?;
272
273 match result {
274 Ok(job_result) => {
275 let img = job_result.image;
276 let response = job_result.response;
277 let content_type = match img.format {
278 OutputFormat::Png => HeaderValue::from_static("image/png"),
279 OutputFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
280 };
281 let mut headers = HeaderMap::new();
282 headers.insert(header::CONTENT_TYPE, content_type);
283 headers.insert(
284 "x-mold-seed-used",
285 HeaderValue::from_str(&response.seed_used.to_string()).map_err(|e| {
286 ApiError::internal(format!("failed to serialize seed header: {e}"))
287 })?,
288 );
289 if let Some(warning) = dim_warning {
290 match HeaderValue::from_str(&warning.replace('\n', " ")) {
291 Ok(val) => {
292 headers.insert("x-mold-dimension-warning", val);
293 }
294 Err(e) => {
295 tracing::warn!("dimension warning could not be encoded as header: {e}");
296 }
297 }
298 }
299 Ok((headers, img.data))
300 }
301 Err(err_msg) => Err(ApiError::inference(err_msg)),
302 }
303}
304
305fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
306 mold_core::validate_generate_request(req)
307}
308
309async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
310 if req.embed_metadata.is_some() {
311 return;
312 }
313
314 let config = state.config.read().await;
315 req.embed_metadata = Some(config.effective_embed_metadata(None));
316}
317
318async fn maybe_expand_prompt(
320 state: &AppState,
321 req: &mut mold_core::GenerateRequest,
322) -> Result<(), ApiError> {
323 if req.expand != Some(true) {
324 return Ok(());
325 }
326
327 let config = state.config.read().await;
328 let expand_settings = config.expand.clone().with_env_overrides();
329
330 let model_family = config
332 .resolved_model_config(&req.model)
333 .family
334 .or_else(|| mold_core::manifest::find_manifest(&req.model).map(|m| m.family.clone()))
335 .unwrap_or_else(|| {
336 tracing::warn!(
337 model = %req.model,
338 "could not resolve model family for prompt expansion, defaulting to \"flux\""
339 );
340 "flux".to_string()
341 });
342
343 let expand_config = expand_settings.to_expand_config(&model_family, 1);
344 let original_prompt = req.prompt.clone();
345
346 drop(config);
348
349 let expander = create_server_expander(&expand_settings)?;
350 let result =
351 tokio::task::spawn_blocking(move || expander.expand(&original_prompt, &expand_config))
352 .await
353 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
354 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
355
356 if let Some(expanded) = result.expanded.first() {
357 req.original_prompt = Some(req.prompt.clone());
358 req.prompt = expanded.clone();
359 }
360
361 Ok(())
362}
363
364fn create_server_expander(
366 settings: &mold_core::ExpandSettings,
367) -> Result<Box<dyn mold_core::PromptExpander>, ApiError> {
368 if let Some(api_expander) = settings.create_api_expander() {
369 return Ok(Box::new(api_expander));
370 }
371
372 #[cfg(feature = "expand")]
373 {
374 let config = mold_core::Config::load_or_default();
375 if let Some(local) =
376 mold_inference::expand::LocalExpander::from_config(&config, Some(&settings.model))
377 {
378 return Ok(Box::new(local));
379 }
380 return Err(ApiError::validation(
381 "local expand model not found — run: mold pull qwen3-expand".to_string(),
382 ));
383 }
384
385 #[cfg(not(feature = "expand"))]
386 {
387 Err(ApiError::validation(
388 "local prompt expansion not available — built without expand feature. \
389 Configure an API backend in [expand] settings."
390 .to_string(),
391 ))
392 }
393}
394
395#[utoipa::path(
398 post,
399 path = "/api/expand",
400 tag = "generation",
401 request_body = mold_core::ExpandRequest,
402 responses(
403 (status = 200, description = "Expanded prompt(s)", body = mold_core::ExpandResponse),
404 (status = 422, description = "Invalid request parameters"),
405 (status = 500, description = "Expansion failed"),
406 )
407)]
408async fn expand_prompt(
409 State(state): State<AppState>,
410 Json(req): Json<mold_core::ExpandRequest>,
411) -> Result<Json<mold_core::ExpandResponse>, ApiError> {
412 if req.variations == 0 || req.variations > mold_core::expand::MAX_VARIATIONS {
413 return Err(ApiError::validation(format!(
414 "variations must be between 1 and {}",
415 mold_core::expand::MAX_VARIATIONS,
416 )));
417 }
418
419 let config = state.config.read().await;
420 let expand_settings = config.expand.clone().with_env_overrides();
421 let expand_config = expand_settings.to_expand_config(&req.model_family, req.variations);
422 let prompt = req.prompt.clone();
423 drop(config);
424
425 let expander = create_server_expander(&expand_settings)?;
426 let result = tokio::task::spawn_blocking(move || expander.expand(&prompt, &expand_config))
427 .await
428 .map_err(|e| ApiError::internal(format!("expand task failed: {e}")))?
429 .map_err(|e| ApiError::internal(format!("prompt expansion failed: {e}")))?;
430
431 Ok(Json(mold_core::ExpandResponse {
432 original: req.prompt,
433 expanded: result.expanded,
434 }))
435}
436
437#[utoipa::path(
440 post,
441 path = "/api/generate/stream",
442 tag = "generation",
443 request_body = mold_core::GenerateRequest,
444 responses(
445 (status = 200, description = "SSE event stream with progress and result"),
446 (status = 404, description = "Model not downloaded"),
447 (status = 422, description = "Invalid request parameters"),
448 (status = 500, description = "Inference error"),
449 )
450)]
451async fn generate_stream(
452 State(state): State<AppState>,
453 Json(mut req): Json<mold_core::GenerateRequest>,
454) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
455 let (output_dir, dim_warning) = prepare_generation(&state, &mut req).await?;
456
457 tracing::info!(
458 model = %req.model,
459 prompt = %req.prompt,
460 "generate/stream request"
461 );
462
463 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
465
466 if let Some(warning) = dim_warning {
468 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Info {
469 message: warning,
470 }));
471 }
472
473 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
474 let job = GenerationJob {
475 request: req,
476 progress_tx: Some(tx.clone()),
477 result_tx,
478 output_dir,
479 };
480
481 let position = state.queue.submit(job).await.map_err(ApiError::internal)?;
482
483 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued { position }));
485
486 tokio::spawn(async move {
490 let _ = result_rx.await;
491 drop(tx); });
493
494 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
496 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
497
498 Ok(Sse::new(stream).keep_alive(
499 KeepAlive::new()
500 .interval(std::time::Duration::from_secs(15))
501 .text("ping"),
502 ))
503}
504
505#[utoipa::path(
508 get,
509 path = "/api/models",
510 tag = "models",
511 responses(
512 (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
513 )
514)]
515async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
516 Json(model_manager::list_models(&state).await)
517}
518
519#[derive(Debug, Deserialize, utoipa::ToSchema)]
522pub struct LoadModelBody {
523 #[schema(example = "flux-schnell:q8")]
524 pub model: String,
525}
526
527#[utoipa::path(
528 post,
529 path = "/api/models/load",
530 tag = "models",
531 request_body = LoadModelBody,
532 responses(
533 (status = 200, description = "Model loaded successfully"),
534 (status = 404, description = "Model not downloaded"),
535 (status = 400, description = "Unknown model"),
536 (status = 500, description = "Failed to load model"),
537 )
538)]
539async fn load_model(
540 State(state): State<AppState>,
541 Json(body): Json<LoadModelBody>,
542) -> Result<impl IntoResponse, ApiError> {
543 model_manager::ensure_model_ready(&state, &body.model, None).await?;
544 tracing::info!(model = %body.model, "model loaded via API");
545 Ok(StatusCode::OK)
546}
547
548#[utoipa::path(
551 post,
552 path = "/api/models/pull",
553 tag = "models",
554 request_body = LoadModelBody,
555 responses(
556 (status = 200, description = "Model pulled (SSE stream or plain text)"),
557 (status = 400, description = "Unknown model"),
558 (status = 500, description = "Download failed"),
559 )
560)]
561async fn pull_model_endpoint(
562 State(state): State<AppState>,
563 headers: HeaderMap,
564 Json(body): Json<LoadModelBody>,
565) -> Result<impl IntoResponse, ApiError> {
566 let wants_sse = headers
567 .get(header::ACCEPT)
568 .and_then(|v| v.to_str().ok())
569 .is_some_and(|v| v.contains("text/event-stream"));
570
571 if !wants_sse {
572 return pull_model_blocking(state, body.model)
574 .await
575 .map(PullResponse::Text);
576 }
577
578 let model = body.model.clone();
580
581 if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
583 .is_none()
584 {
585 return Err(ApiError::unknown_model(format!(
586 "unknown model '{model}'. Run 'mold list' to see available models."
587 )));
588 }
589
590 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
591
592 tokio::spawn(async move {
593 let progress_tx = tx.clone();
594 let model_for_cb = model.clone();
595 let callback =
596 std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
597 let sse_event = match event {
598 mold_core::download::DownloadProgressEvent::FileStart {
599 filename,
600 file_index,
601 total_files,
602 size_bytes,
603 } => SseProgressEvent::DownloadProgress {
604 filename,
605 file_index,
606 total_files,
607 bytes_downloaded: 0,
608 bytes_total: size_bytes,
609 },
610 mold_core::download::DownloadProgressEvent::FileProgress {
611 filename,
612 file_index,
613 bytes_downloaded,
614 bytes_total,
615 } => SseProgressEvent::DownloadProgress {
616 filename,
617 file_index,
618 total_files: 0,
619 bytes_downloaded,
620 bytes_total,
621 },
622 mold_core::download::DownloadProgressEvent::FileDone {
623 filename,
624 file_index,
625 total_files,
626 } => SseProgressEvent::DownloadDone {
627 filename,
628 file_index,
629 total_files,
630 },
631 };
632 let _ = progress_tx.send(SseMessage::Progress(sse_event));
633 });
634
635 match model_manager::pull_model(&state, &model, Some(callback)).await {
636 Ok(model_manager::PullStatus::AlreadyAvailable) => {
637 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
638 model: model_for_cb,
639 }));
640 }
641 Ok(model_manager::PullStatus::Pulled) => {
642 let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
643 model: model_for_cb,
644 }));
645 }
646 Err(e) => {
647 let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
648 }
649 }
650 });
651
652 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
653 .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
654
655 Ok(PullResponse::Sse(
656 Sse::new(stream)
657 .keep_alive(
658 KeepAlive::new()
659 .interval(std::time::Duration::from_secs(15))
660 .text("ping"),
661 )
662 .into_response(),
663 ))
664}
665
666async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
668 match model_manager::pull_model(&state, &model, None).await? {
669 model_manager::PullStatus::AlreadyAvailable => {
670 Ok(format!("model '{}' already available", model))
671 }
672 model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
673 }
674}
675
676enum PullResponse {
678 Sse(axum::response::Response),
679 Text(String),
680}
681
682impl IntoResponse for PullResponse {
683 fn into_response(self) -> axum::response::Response {
684 match self {
685 PullResponse::Sse(resp) => resp,
686 PullResponse::Text(text) => text.into_response(),
687 }
688 }
689}
690
691#[utoipa::path(
694 delete,
695 path = "/api/models/unload",
696 tag = "models",
697 responses(
698 (status = 200, description = "Model unloaded or no model was loaded", body = String),
699 )
700)]
701async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
702 Ok((StatusCode::OK, model_manager::unload_model(&state).await))
703}
704
705#[utoipa::path(
708 get,
709 path = "/api/status",
710 tag = "server",
711 responses(
712 (status = 200, description = "Server status", body = ServerStatus),
713 )
714)]
715async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
716 let snapshot = state.engine_snapshot.read().await.clone();
717 let models_loaded = match (snapshot.model_name, snapshot.is_loaded) {
718 (Some(model_name), true) => vec![model_name],
719 _ => vec![],
720 };
721 let current_generation = state
722 .active_generation
723 .read()
724 .unwrap_or_else(|e| e.into_inner())
725 .as_ref()
726 .map(|active| ActiveGenerationStatus {
727 model: active.model.clone(),
728 prompt_sha256: active.prompt_sha256.clone(),
729 started_at_unix_ms: active.started_at_unix_ms,
730 elapsed_ms: active.started_at.elapsed().as_millis() as u64,
731 });
732 let busy = current_generation.is_some();
733
734 Json(ServerStatus {
735 version: env!("CARGO_PKG_VERSION").to_string(),
736 git_sha: if mold_core::build_info::GIT_SHA == "unknown" {
737 None
738 } else {
739 Some(mold_core::build_info::GIT_SHA.to_string())
740 },
741 build_date: if mold_core::build_info::BUILD_DATE == "unknown" {
742 None
743 } else {
744 Some(mold_core::build_info::BUILD_DATE.to_string())
745 },
746 models_loaded,
747 busy,
748 current_generation,
749 gpu_info: query_gpu_info(),
750 uptime_secs: state.start_time.elapsed().as_secs(),
751 })
752}
753
754#[utoipa::path(
757 get,
758 path = "/health",
759 tag = "server",
760 responses(
761 (status = 200, description = "Server is healthy"),
762 )
763)]
764async fn health() -> impl IntoResponse {
765 StatusCode::OK
766}
767
768async fn openapi_json() -> impl IntoResponse {
771 Json(ApiDoc::openapi())
772}
773
774async fn scalar_docs() -> impl IntoResponse {
777 (
778 [(header::CONTENT_TYPE, "text/html")],
779 r#"<!DOCTYPE html>
780<html>
781<head>
782 <title>mold API</title>
783 <meta charset="utf-8" />
784 <meta name="viewport" content="width=device-width, initial-scale=1" />
785</head>
786<body>
787 <script id="api-reference" data-url="/api/openapi.json"></script>
788 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
789</body>
790</html>"#,
791 )
792}
793
794fn query_gpu_info() -> Option<GpuInfo> {
797 let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
798 "/run/current-system/sw/bin/nvidia-smi"
799 } else {
800 "nvidia-smi"
801 };
802
803 let output = std::process::Command::new(nvidia_smi)
804 .args([
805 "--query-gpu=name,memory.total,memory.used",
806 "--format=csv,noheader,nounits",
807 ])
808 .output()
809 .ok()?;
810
811 if !output.status.success() {
812 return None;
813 }
814
815 let text = String::from_utf8(output.stdout).ok()?;
816 let line = text.lines().next()?;
817 let parts: Vec<&str> = line.split(',').map(str::trim).collect();
818 if parts.len() < 3 {
819 return None;
820 }
821
822 Some(GpuInfo {
823 name: parts[0].to_string(),
824 vram_total_mb: parts[1].parse().ok()?,
825 vram_used_mb: parts[2].parse().ok()?,
826 })
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn clean_error_message_strips_backtrace() {
835 let err = anyhow::anyhow!(
836 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
837 \x20 0: candle_core::error::Error::bt\n\
838 \x20 at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
839 \x20 1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
840 \x20 at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
841 );
842 let msg = clean_error_message(&err);
843 assert_eq!(
844 msg,
845 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
846 );
847 }
848
849 #[test]
850 fn clean_error_message_preserves_simple_error() {
851 let err = anyhow::anyhow!("model not found: flux-dev:q4");
852 let msg = clean_error_message(&err);
853 assert_eq!(msg, "model not found: flux-dev:q4");
854 }
855
856 #[test]
857 fn clean_error_message_preserves_multiline_without_backtrace() {
858 let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
859 let msg = clean_error_message(&err);
860 assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
861 }
862
863 #[test]
864 fn clean_error_message_strips_high_numbered_frames() {
865 let err = anyhow::anyhow!(
866 "some error\n\
867 \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
868 \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
869 );
870 let msg = clean_error_message(&err);
871 assert_eq!(msg, "some error");
872 }
873
874 #[test]
875 fn clean_error_message_empty_fallback() {
876 let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
878 let msg = clean_error_message(&err);
879 assert!(!msg.is_empty());
881 }
882
883 #[test]
884 fn save_image_to_dir_creates_directory_and_writes_file() {
885 let dir = std::env::temp_dir().join(format!(
886 "mold-save-test-{}",
887 std::time::SystemTime::now()
888 .duration_since(std::time::UNIX_EPOCH)
889 .unwrap()
890 .as_nanos()
891 ));
892 assert!(!dir.exists());
893
894 let img = mold_core::ImageData {
895 data: vec![0x89, 0x50, 0x4E, 0x47], format: mold_core::OutputFormat::Png,
897 width: 64,
898 height: 64,
899 index: 0,
900 };
901
902 save_image_to_dir(&dir, &img, "test-model:q8", 1);
903
904 assert!(dir.exists(), "directory should be created");
905 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
906 assert_eq!(files.len(), 1, "should have exactly one file");
907 let file = files[0].as_ref().unwrap();
908 let filename = file.file_name().to_str().unwrap().to_string();
909 assert!(filename.starts_with("mold-test-model-q8-"), "{filename}");
910 assert!(filename.ends_with(".png"), "{filename}");
911 let contents = std::fs::read(file.path()).unwrap();
912 assert_eq!(contents, vec![0x89, 0x50, 0x4E, 0x47]);
913
914 std::fs::remove_dir_all(&dir).ok();
915 }
916
917 #[test]
918 fn save_image_to_dir_batch_includes_index() {
919 let dir = std::env::temp_dir().join(format!(
920 "mold-save-batch-{}",
921 std::time::SystemTime::now()
922 .duration_since(std::time::UNIX_EPOCH)
923 .unwrap()
924 .as_nanos()
925 ));
926
927 let img = mold_core::ImageData {
928 data: vec![0xFF, 0xD8], format: mold_core::OutputFormat::Jpeg,
930 width: 64,
931 height: 64,
932 index: 2,
933 };
934
935 save_image_to_dir(&dir, &img, "flux-dev", 4);
936
937 let files: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
938 assert_eq!(files.len(), 1);
939 let filename = files[0]
940 .as_ref()
941 .unwrap()
942 .file_name()
943 .to_str()
944 .unwrap()
945 .to_string();
946 assert!(
947 filename.contains("-2.jpeg"),
948 "batch index in name: {filename}"
949 );
950
951 std::fs::remove_dir_all(&dir).ok();
952 }
953
954 #[test]
955 fn save_image_to_dir_invalid_path_logs_warning_no_panic() {
956 let img = mold_core::ImageData {
958 data: vec![0x00],
959 format: mold_core::OutputFormat::Png,
960 width: 1,
961 height: 1,
962 index: 0,
963 };
964 save_image_to_dir(
966 std::path::Path::new("/dev/null/impossible"),
967 &img,
968 "test",
969 1,
970 );
971 }
973}