Skip to main content

mold_server/
routes.rs

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 base64::Engine as _;
12use mold_core::{
13    GpuInfo, ModelInfoExtended, OutputFormat, ServerStatus, SseCompleteEvent, SseErrorEvent,
14    SseProgressEvent,
15};
16use serde::{Deserialize, Serialize};
17use std::convert::Infallible;
18use tokio_stream::StreamExt as _;
19use utoipa::OpenApi;
20
21use crate::model_manager;
22use crate::state::AppState;
23
24// ── ApiError — structured JSON error response ────────────────────────────────
25
26#[derive(Debug, Serialize)]
27pub struct ApiError {
28    pub error: String,
29    pub code: String,
30    #[serde(skip)]
31    status: StatusCode,
32}
33
34impl ApiError {
35    pub fn validation(msg: impl Into<String>) -> Self {
36        Self {
37            error: msg.into(),
38            code: "VALIDATION_ERROR".to_string(),
39            status: StatusCode::UNPROCESSABLE_ENTITY,
40        }
41    }
42
43    pub fn not_found(msg: impl Into<String>) -> Self {
44        Self {
45            error: msg.into(),
46            code: "MODEL_NOT_FOUND".to_string(),
47            status: StatusCode::NOT_FOUND,
48        }
49    }
50
51    pub fn unknown_model(msg: impl Into<String>) -> Self {
52        Self {
53            error: msg.into(),
54            code: "UNKNOWN_MODEL".to_string(),
55            status: StatusCode::BAD_REQUEST,
56        }
57    }
58
59    pub fn inference(msg: impl Into<String>) -> Self {
60        Self {
61            error: msg.into(),
62            code: "INFERENCE_ERROR".to_string(),
63            status: StatusCode::INTERNAL_SERVER_ERROR,
64        }
65    }
66
67    pub fn internal(msg: impl Into<String>) -> Self {
68        Self {
69            error: msg.into(),
70            code: "INTERNAL_ERROR".to_string(),
71            status: StatusCode::INTERNAL_SERVER_ERROR,
72        }
73    }
74}
75
76impl IntoResponse for ApiError {
77    fn into_response(self) -> axum::response::Response {
78        let status = self.status;
79        (status, Json(self)).into_response()
80    }
81}
82
83/// Extract a clean, user-facing error message from an anyhow error.
84/// Strips backtrace frames and internal source locations that candle
85/// embeds into its Display output via `Error::bt()`.
86fn clean_error_message(e: &anyhow::Error) -> String {
87    let full = format!("{e}");
88    // Candle backtraces start with a frame number like "   0: candle_core::..."
89    // Take only lines before the first backtrace frame.
90    let mut lines: Vec<&str> = Vec::new();
91    for line in full.lines() {
92        let trimmed = line.trim_start();
93        if trimmed.starts_with("0:") || trimmed.starts_with("1:") {
94            // Check if this looks like a backtrace frame (digit + colon + space + path)
95            if trimmed.len() > 3
96                && trimmed
97                    .as_bytes()
98                    .first()
99                    .is_some_and(|b| b.is_ascii_digit())
100            {
101                break;
102            }
103        }
104        // Also catch higher-numbered frames like "  10: ..."
105        if trimmed.len() > 2
106            && trimmed.as_bytes()[0].is_ascii_digit()
107            && trimmed.contains("::")
108            && trimmed.contains("at ")
109        {
110            break;
111        }
112        lines.push(line);
113    }
114    let msg = lines.join("\n").trim().to_string();
115    if msg.is_empty() {
116        // Fallback: just the root cause
117        format!("{}", e.root_cause())
118    } else {
119        msg
120    }
121}
122
123#[derive(OpenApi)]
124#[openapi(
125    paths(generate, generate_stream, list_models, load_model, pull_model_endpoint, unload_model, server_status, health),
126    components(schemas(
127        mold_core::GenerateRequest,
128        mold_core::GenerateResponse,
129        mold_core::ImageData,
130        mold_core::OutputFormat,
131        mold_core::ModelInfo,
132        mold_core::ServerStatus,
133        mold_core::GpuInfo,
134        mold_core::SseProgressEvent,
135        mold_core::SseCompleteEvent,
136        mold_core::SseErrorEvent,
137        ModelInfoExtended,
138        LoadModelBody,
139    )),
140    tags(
141        (name = "generation", description = "Image generation"),
142        (name = "models", description = "Model management"),
143        (name = "server", description = "Server status and health"),
144    ),
145    info(
146        title = "mold",
147        description = "Local AI image generation server — FLUX, SD3.5, SD1.5, SDXL, Z-Image, Flux.2, Qwen-Image",
148        version = env!("CARGO_PKG_VERSION"),
149    )
150)]
151pub struct ApiDoc;
152
153pub fn create_router(state: AppState) -> Router {
154    // Stateful routes (need AppState) are added first, then .with_state() converts
155    // Router<AppState> → Router<()>. Stateless routes (OpenAPI, docs) are merged after.
156    Router::new()
157        .route("/api/generate", post(generate))
158        .route("/api/generate/stream", post(generate_stream))
159        .route("/api/models", get(list_models))
160        .route("/api/models/load", post(load_model))
161        .route("/api/models/pull", post(pull_model_endpoint))
162        .route("/api/models/unload", delete(unload_model))
163        .route("/api/status", get(server_status))
164        .route("/health", get(health))
165        .with_state(state)
166        .route("/api/openapi.json", get(openapi_json))
167        .route("/api/docs", get(scalar_docs))
168}
169
170// ── Model readiness ──────────────────────────────────────────────────────────
171
172/// Internal SSE message type for the streaming channel.
173enum SseMessage {
174    Progress(SseProgressEvent),
175    Complete(SseCompleteEvent),
176    Error(SseErrorEvent),
177}
178
179/// Convert an inference-crate progress event to an SSE wire event.
180fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
181    event.into()
182}
183
184fn sse_message_to_event(msg: SseMessage) -> SseEvent {
185    fn serialize_event<T: Serialize>(event_name: &str, payload: &T) -> SseEvent {
186        match serde_json::to_string(payload) {
187            Ok(data) => SseEvent::default().event(event_name).data(data),
188            Err(err) => SseEvent::default().event("error").data(
189                serde_json::json!({
190                    "message": format!("failed to serialize SSE payload: {err}")
191                })
192                .to_string(),
193            ),
194        }
195    }
196
197    match msg {
198        SseMessage::Progress(payload) => serialize_event("progress", &payload),
199        SseMessage::Complete(payload) => serialize_event("complete", &payload),
200        SseMessage::Error(payload) => serialize_event("error", &payload),
201    }
202}
203
204fn take_generated_image(
205    response: &mut mold_core::GenerateResponse,
206) -> Result<mold_core::ImageData, ApiError> {
207    if response.images.is_empty() {
208        return Err(ApiError::inference(
209            "generation error: engine returned no images",
210        ));
211    }
212    Ok(response.images.remove(0))
213}
214
215// ── /api/generate ─────────────────────────────────────────────────────────────
216
217#[utoipa::path(
218    post,
219    path = "/api/generate",
220    tag = "generation",
221    request_body = mold_core::GenerateRequest,
222    responses(
223        (status = 200, description = "Generated image bytes", content_type = "image/png"),
224        (status = 404, description = "Model not downloaded"),
225        (status = 422, description = "Invalid request parameters"),
226        (status = 500, description = "Inference error"),
227    )
228)]
229// The server always produces 1 image per request; batch looping (--batch N)
230// is handled client-side by the CLI, which sends N requests with incrementing seeds.
231async fn generate(
232    State(state): State<AppState>,
233    Json(mut req): Json<mold_core::GenerateRequest>,
234) -> Result<impl IntoResponse, ApiError> {
235    apply_default_metadata_setting(&state, &mut req).await;
236    tracing::info!(
237        model = %req.model,
238        prompt = %req.prompt,
239        width = req.width,
240        height = req.height,
241        steps = req.steps,
242        guidance = req.guidance,
243        seed = ?req.seed,
244        format = %req.output_format,
245        "generate request"
246    );
247
248    // Validate request before touching the engine
249    if let Err(e) = validate_generate_request(&req) {
250        return Err(ApiError::validation(e));
251    }
252
253    // Ensure model is ready (handles empty state, hot-swap, lazy load)
254    model_manager::ensure_model_ready(&state, &req.model, None).await?;
255
256    // Run inference in a blocking task — panics caught → 500 with body.
257    let engine = state.engine.clone();
258    let result = tokio::task::spawn_blocking(move || {
259        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
260            let mut guard = engine.blocking_lock();
261            let engine = guard.as_mut().ok_or_else(|| {
262                anyhow::anyhow!("no engine available after model readiness check")
263            })?;
264            engine.clear_on_progress();
265            engine.generate(&req)
266        }))
267    })
268    .await
269    .map_err(|e| {
270        tracing::error!("inference task join error: {e:?}");
271        ApiError::inference("inference task failed")
272    })?;
273
274    let mut response = match result {
275        Ok(Ok(resp)) => resp,
276        Ok(Err(e)) => {
277            tracing::error!("generation error: {e:#}");
278            return Err(ApiError::inference(format!(
279                "generation error: {}",
280                clean_error_message(&e)
281            )));
282        }
283        Err(panic_payload) => {
284            let msg = panic_payload
285                .downcast_ref::<String>()
286                .map(|s| s.as_str())
287                .or_else(|| panic_payload.downcast_ref::<&str>().copied())
288                .unwrap_or("unknown panic");
289            tracing::error!("inference panicked: {msg}");
290            return Err(ApiError::inference(format!("inference panicked: {msg}")));
291        }
292    };
293
294    let img = take_generated_image(&mut response)?;
295    let content_type = match img.format {
296        OutputFormat::Png => HeaderValue::from_static("image/png"),
297        OutputFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
298    };
299    let mut headers = HeaderMap::new();
300    headers.insert(header::CONTENT_TYPE, content_type);
301    headers.insert(
302        "x-mold-seed-used",
303        HeaderValue::from_str(&response.seed_used.to_string())
304            .map_err(|e| ApiError::internal(format!("failed to serialize seed header: {e}")))?,
305    );
306    Ok((headers, img.data))
307}
308
309fn validate_generate_request(req: &mold_core::GenerateRequest) -> Result<(), String> {
310    mold_core::validate_generate_request(req)
311}
312
313async fn apply_default_metadata_setting(state: &AppState, req: &mut mold_core::GenerateRequest) {
314    if req.embed_metadata.is_some() {
315        return;
316    }
317
318    let config = state.config.read().await;
319    req.embed_metadata = Some(config.effective_embed_metadata(None));
320}
321
322// ── /api/generate/stream (SSE) ───────────────────────────────────────────────
323
324#[utoipa::path(
325    post,
326    path = "/api/generate/stream",
327    tag = "generation",
328    request_body = mold_core::GenerateRequest,
329    responses(
330        (status = 200, description = "SSE event stream with progress and result"),
331        (status = 404, description = "Model not downloaded"),
332        (status = 422, description = "Invalid request parameters"),
333        (status = 500, description = "Inference error"),
334    )
335)]
336async fn generate_stream(
337    State(state): State<AppState>,
338    Json(mut req): Json<mold_core::GenerateRequest>,
339) -> Result<Sse<impl futures_core::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
340    apply_default_metadata_setting(&state, &mut req).await;
341    tracing::info!(
342        model = %req.model,
343        prompt = %req.prompt,
344        "generate/stream request"
345    );
346
347    // Validate before starting the SSE stream (returns HTTP error, not SSE event)
348    if let Err(e) = validate_generate_request(&req) {
349        return Err(ApiError::validation(e));
350    }
351
352    // Check model availability (404/400 returned as HTTP errors before SSE starts)
353    let _ = model_manager::check_model_available(&state, &req.model).await?;
354
355    // Create SSE channel
356    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
357
358    // Spawn background task for model loading + inference
359    let bg_tx = tx;
360    tokio::spawn(async move {
361        // Load model if needed (with progress events)
362        let progress = std::sync::Arc::new({
363            let bg_tx = bg_tx.clone();
364            move |event| {
365                let _ = bg_tx.send(SseMessage::Progress(progress_to_sse(event)));
366            }
367        });
368
369        if let Err(api_err) =
370            model_manager::ensure_model_ready(&state, &req.model, Some(progress)).await
371        {
372            let _ = bg_tx.send(SseMessage::Error(SseErrorEvent {
373                message: api_err.error,
374            }));
375            return;
376        }
377
378        // Run inference in blocking thread with progress callback
379        let engine = state.engine.clone();
380        let gen_tx = bg_tx.clone();
381        let gen_req = req.clone();
382        let result = tokio::task::spawn_blocking(move || {
383            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
384                let mut guard = engine.blocking_lock();
385                let e = guard.as_mut().ok_or_else(|| {
386                    anyhow::anyhow!("no engine available after model readiness check")
387                })?;
388                // Install progress callback for the generate phase
389                let progress_tx = gen_tx.clone();
390                e.set_on_progress(Box::new(move |event| {
391                    let _ = progress_tx.send(SseMessage::Progress(progress_to_sse(event)));
392                }));
393                let generate_result = e.generate(&gen_req);
394                e.clear_on_progress();
395                generate_result
396            }))
397        })
398        .await;
399
400        match result {
401            Ok(Ok(Ok(mut response))) => {
402                let img = match take_generated_image(&mut response) {
403                    Ok(img) => img,
404                    Err(err) => {
405                        let _ = bg_tx.send(SseMessage::Error(SseErrorEvent { message: err.error }));
406                        return;
407                    }
408                };
409                let _ = bg_tx.send(SseMessage::Complete(SseCompleteEvent {
410                    image: base64::engine::general_purpose::STANDARD.encode(&img.data),
411                    format: img.format,
412                    width: img.width,
413                    height: img.height,
414                    seed_used: response.seed_used,
415                    generation_time_ms: response.generation_time_ms,
416                }));
417            }
418            Ok(Ok(Err(e))) => {
419                tracing::error!("generation error: {e:#}");
420                let _ = bg_tx.send(SseMessage::Error(SseErrorEvent {
421                    message: format!("generation error: {}", clean_error_message(&e)),
422                }));
423            }
424            Ok(Err(panic_payload)) => {
425                let msg = panic_payload
426                    .downcast_ref::<String>()
427                    .map(|s| s.as_str())
428                    .or_else(|| panic_payload.downcast_ref::<&str>().copied())
429                    .unwrap_or("unknown panic");
430                tracing::error!("inference panicked: {msg}");
431                let _ = bg_tx.send(SseMessage::Error(SseErrorEvent {
432                    message: format!("inference panicked: {msg}"),
433                }));
434            }
435            Err(join_err) => {
436                tracing::error!("inference task join error: {join_err:?}");
437                let _ = bg_tx.send(SseMessage::Error(SseErrorEvent {
438                    message: "inference task failed".to_string(),
439                }));
440            }
441        }
442    });
443
444    // Build SSE stream from the channel receiver
445    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
446        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
447
448    Ok(Sse::new(stream).keep_alive(
449        KeepAlive::new()
450            .interval(std::time::Duration::from_secs(15))
451            .text("ping"),
452    ))
453}
454
455// ── /api/models ───────────────────────────────────────────────────────────────
456
457#[utoipa::path(
458    get,
459    path = "/api/models",
460    tag = "models",
461    responses(
462        (status = 200, description = "List of available models", body = Vec<ModelInfoExtended>),
463    )
464)]
465async fn list_models(State(state): State<AppState>) -> Json<Vec<ModelInfoExtended>> {
466    Json(model_manager::list_models(&state).await)
467}
468
469// ── /api/models/load ──────────────────────────────────────────────────────────
470
471#[derive(Debug, Deserialize, utoipa::ToSchema)]
472pub struct LoadModelBody {
473    #[schema(example = "flux-schnell:q8")]
474    pub model: String,
475}
476
477#[utoipa::path(
478    post,
479    path = "/api/models/load",
480    tag = "models",
481    request_body = LoadModelBody,
482    responses(
483        (status = 200, description = "Model loaded successfully"),
484        (status = 404, description = "Model not downloaded"),
485        (status = 400, description = "Unknown model"),
486        (status = 500, description = "Failed to load model"),
487    )
488)]
489async fn load_model(
490    State(state): State<AppState>,
491    Json(body): Json<LoadModelBody>,
492) -> Result<impl IntoResponse, ApiError> {
493    model_manager::ensure_model_ready(&state, &body.model, None).await?;
494    tracing::info!(model = %body.model, "model loaded via API");
495    Ok(StatusCode::OK)
496}
497
498// ── /api/models/pull ──────────────────────────────────────────────────────────
499
500#[utoipa::path(
501    post,
502    path = "/api/models/pull",
503    tag = "models",
504    request_body = LoadModelBody,
505    responses(
506        (status = 200, description = "Model pulled (SSE stream or plain text)"),
507        (status = 400, description = "Unknown model"),
508        (status = 500, description = "Download failed"),
509    )
510)]
511async fn pull_model_endpoint(
512    State(state): State<AppState>,
513    headers: HeaderMap,
514    Json(body): Json<LoadModelBody>,
515) -> Result<impl IntoResponse, ApiError> {
516    let wants_sse = headers
517        .get(header::ACCEPT)
518        .and_then(|v| v.to_str().ok())
519        .is_some_and(|v| v.contains("text/event-stream"));
520
521    if !wants_sse {
522        // Legacy: blocking pull with plain text response
523        return pull_model_blocking(state, body.model)
524            .await
525            .map(PullResponse::Text);
526    }
527
528    // SSE streaming pull
529    let model = body.model.clone();
530
531    // Validate model exists in manifest before starting SSE
532    if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(&model))
533        .is_none()
534    {
535        return Err(ApiError::unknown_model(format!(
536            "unknown model '{model}'. Run 'mold list' to see available models."
537        )));
538    }
539
540    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseMessage>();
541
542    tokio::spawn(async move {
543        let progress_tx = tx.clone();
544        let model_for_cb = model.clone();
545        let callback =
546            std::sync::Arc::new(move |event: mold_core::download::DownloadProgressEvent| {
547                let sse_event = match event {
548                    mold_core::download::DownloadProgressEvent::FileStart {
549                        filename,
550                        file_index,
551                        total_files,
552                        size_bytes,
553                    } => SseProgressEvent::DownloadProgress {
554                        filename,
555                        file_index,
556                        total_files,
557                        bytes_downloaded: 0,
558                        bytes_total: size_bytes,
559                    },
560                    mold_core::download::DownloadProgressEvent::FileProgress {
561                        filename,
562                        file_index,
563                        bytes_downloaded,
564                        bytes_total,
565                    } => SseProgressEvent::DownloadProgress {
566                        filename,
567                        file_index,
568                        total_files: 0,
569                        bytes_downloaded,
570                        bytes_total,
571                    },
572                    mold_core::download::DownloadProgressEvent::FileDone {
573                        filename,
574                        file_index,
575                        total_files,
576                    } => SseProgressEvent::DownloadDone {
577                        filename,
578                        file_index,
579                        total_files,
580                    },
581                };
582                let _ = progress_tx.send(SseMessage::Progress(sse_event));
583            });
584
585        match model_manager::pull_model(&state, &model, Some(callback)).await {
586            Ok(model_manager::PullStatus::AlreadyAvailable) => {
587                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
588                    model: model_for_cb,
589                }));
590            }
591            Ok(model_manager::PullStatus::Pulled) => {
592                let _ = tx.send(SseMessage::Progress(SseProgressEvent::PullComplete {
593                    model: model_for_cb,
594                }));
595            }
596            Err(e) => {
597                let _ = tx.send(SseMessage::Error(SseErrorEvent { message: e.error }));
598            }
599        }
600    });
601
602    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
603        .map(|msg| Ok::<_, Infallible>(sse_message_to_event(msg)));
604
605    Ok(PullResponse::Sse(
606        Sse::new(stream)
607            .keep_alive(
608                KeepAlive::new()
609                    .interval(std::time::Duration::from_secs(15))
610                    .text("ping"),
611            )
612            .into_response(),
613    ))
614}
615
616/// Legacy blocking pull — returns plain text.
617async fn pull_model_blocking(state: AppState, model: String) -> Result<String, ApiError> {
618    match model_manager::pull_model(&state, &model, None).await? {
619        model_manager::PullStatus::AlreadyAvailable => {
620            Ok(format!("model '{}' already available", model))
621        }
622        model_manager::PullStatus::Pulled => Ok(format!("model '{}' pulled successfully", model)),
623    }
624}
625
626/// Response type that can be either SSE stream or plain text.
627enum PullResponse {
628    Sse(axum::response::Response),
629    Text(String),
630}
631
632impl IntoResponse for PullResponse {
633    fn into_response(self) -> axum::response::Response {
634        match self {
635            PullResponse::Sse(resp) => resp,
636            PullResponse::Text(text) => text.into_response(),
637        }
638    }
639}
640
641// ── /api/models/unload ────────────────────────────────────────────────────────
642
643#[utoipa::path(
644    delete,
645    path = "/api/models/unload",
646    tag = "models",
647    responses(
648        (status = 200, description = "Model unloaded or no model was loaded", body = String),
649    )
650)]
651async fn unload_model(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
652    Ok((StatusCode::OK, model_manager::unload_model(&state).await))
653}
654
655// ── /api/status ───────────────────────────────────────────────────────────────
656
657#[utoipa::path(
658    get,
659    path = "/api/status",
660    tag = "server",
661    responses(
662        (status = 200, description = "Server status", body = ServerStatus),
663    )
664)]
665async fn server_status(State(state): State<AppState>) -> Json<ServerStatus> {
666    let engine = state.engine.lock().await;
667    let models_loaded = match engine.as_ref() {
668        Some(e) if e.is_loaded() => vec![e.model_name().to_string()],
669        _ => vec![],
670    };
671    drop(engine);
672
673    Json(ServerStatus {
674        version: env!("CARGO_PKG_VERSION").to_string(),
675        models_loaded,
676        gpu_info: query_gpu_info(),
677        uptime_secs: state.start_time.elapsed().as_secs(),
678    })
679}
680
681// ── /health ───────────────────────────────────────────────────────────────────
682
683#[utoipa::path(
684    get,
685    path = "/health",
686    tag = "server",
687    responses(
688        (status = 200, description = "Server is healthy"),
689    )
690)]
691async fn health() -> impl IntoResponse {
692    StatusCode::OK
693}
694
695// ── /api/openapi.json ─────────────────────────────────────────────────────────
696
697async fn openapi_json() -> impl IntoResponse {
698    Json(ApiDoc::openapi())
699}
700
701// ── /api/docs ─────────────────────────────────────────────────────────────────
702
703async fn scalar_docs() -> impl IntoResponse {
704    (
705        [(header::CONTENT_TYPE, "text/html")],
706        r#"<!DOCTYPE html>
707<html>
708<head>
709  <title>mold API</title>
710  <meta charset="utf-8" />
711  <meta name="viewport" content="width=device-width, initial-scale=1" />
712</head>
713<body>
714  <script id="api-reference" data-url="/api/openapi.json"></script>
715  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
716</body>
717</html>"#,
718    )
719}
720
721// ── GPU info ──────────────────────────────────────────────────────────────────
722
723fn query_gpu_info() -> Option<GpuInfo> {
724    let nvidia_smi = if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
725        "/run/current-system/sw/bin/nvidia-smi"
726    } else {
727        "nvidia-smi"
728    };
729
730    let output = std::process::Command::new(nvidia_smi)
731        .args([
732            "--query-gpu=name,memory.total,memory.used",
733            "--format=csv,noheader,nounits",
734        ])
735        .output()
736        .ok()?;
737
738    if !output.status.success() {
739        return None;
740    }
741
742    let text = String::from_utf8(output.stdout).ok()?;
743    let line = text.lines().next()?;
744    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
745    if parts.len() < 3 {
746        return None;
747    }
748
749    Some(GpuInfo {
750        name: parts[0].to_string(),
751        vram_total_mb: parts[1].parse().ok()?,
752        vram_used_mb: parts[2].parse().ok()?,
753    })
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn clean_error_message_strips_backtrace() {
762        let err = anyhow::anyhow!(
763            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")\n\
764             \x20  0: candle_core::error::Error::bt\n\
765             \x20           at /home/user/.cargo/git/candle/src/error.rs:264:25\n\
766             \x20  1: <core::result::Result<O,E> as candle_core::cuda_backend::error::WrapErr<O>>::w\n\
767             \x20           at /home/user/.cargo/git/candle/src/cuda_backend/error.rs:60:65"
768        );
769        let msg = clean_error_message(&err);
770        assert_eq!(
771            msg,
772            "DriverError(CUDA_ERROR_OUT_OF_MEMORY, \"out of memory\")"
773        );
774    }
775
776    #[test]
777    fn clean_error_message_preserves_simple_error() {
778        let err = anyhow::anyhow!("model not found: flux-dev:q4");
779        let msg = clean_error_message(&err);
780        assert_eq!(msg, "model not found: flux-dev:q4");
781    }
782
783    #[test]
784    fn clean_error_message_preserves_multiline_without_backtrace() {
785        let err = anyhow::anyhow!("validation failed\nprompt is empty\nsteps must be > 0");
786        let msg = clean_error_message(&err);
787        assert_eq!(msg, "validation failed\nprompt is empty\nsteps must be > 0");
788    }
789
790    #[test]
791    fn clean_error_message_strips_high_numbered_frames() {
792        let err = anyhow::anyhow!(
793            "some error\n\
794             \x20 10: tokio::runtime::task::core::Core<T,S>::poll at /home/user/.cargo/tokio/src/core.rs:375\n\
795             \x20 11: std::panicking::catch_unwind at /nix/store/rust/src/panicking.rs:544"
796        );
797        let msg = clean_error_message(&err);
798        assert_eq!(msg, "some error");
799    }
800
801    #[test]
802    fn clean_error_message_empty_fallback() {
803        // An error whose Display starts immediately with a backtrace-like line
804        let err = anyhow::anyhow!("0: candle_core::error::Error::bt at /some/path.rs:10:5");
805        let msg = clean_error_message(&err);
806        // Should fall back to root_cause since all lines look like backtrace
807        assert!(!msg.is_empty());
808    }
809}