use std::sync::Arc;
use mold_core::{
build_model_catalog, GenerateRequest, ModelDefaults, ModelInfo, ModelInfoExtended, ModelPaths,
};
use mold_inference::device::{activation_bytes, activation_family_for, ActivationFamily};
use crate::model_cache::ModelResidency;
use crate::{routes::ApiError, state::AppState};
pub(crate) type EngineProgressCallback = Arc<dyn Fn(mold_inference::ProgressEvent) + Send + Sync>;
#[derive(Debug, Clone, Copy)]
pub struct ActivationHint {
pub width: u32,
pub height: u32,
pub batch: u32,
pub dtype_bytes: u32,
pub family: ActivationFamily,
}
impl ActivationHint {
pub fn from_request(req: &GenerateRequest, family_slug: &str) -> Self {
let family = activation_family_for(family_slug);
let batch = match family {
ActivationFamily::SdxlUnet | ActivationFamily::Sd3Mmdit if req.guidance > 1.0 => 2,
_ => 1,
};
Self {
width: req.width,
height: req.height,
batch,
dtype_bytes: 2,
family,
}
}
pub fn budget_bytes(&self) -> u64 {
activation_bytes(
self.width,
self.height,
self.batch,
self.dtype_bytes,
self.family,
)
}
}
fn check_model_memory_budget(
model_name: &str,
peak_bytes: u64,
available_bytes: u64,
suggestion: &str,
) -> Result<(), ApiError> {
let hard_limit = available_bytes * 9 / 10; if peak_bytes > hard_limit {
return Err(ApiError::insufficient_memory(format!(
"model '{}' estimated peak ~{:.1} GB exceeds the per-load budget cap ~{:.1} GB \
(90% of {:.1} GB free, with 2 GB activation headroom built into peak estimate; \
encoders are dropped before denoise). {}",
model_name,
peak_bytes as f64 / 1_000_000_000.0,
hard_limit as f64 / 1_000_000_000.0,
available_bytes as f64 / 1_000_000_000.0,
suggestion,
)));
}
let warn_limit = available_bytes * 8 / 10; if peak_bytes > warn_limit {
tracing::warn!(
model = %model_name,
peak_gb = format_args!("{:.1}", peak_bytes as f64 / 1_000_000_000.0),
available_gb = format_args!("{:.1}", available_bytes as f64 / 1_000_000_000.0),
"model is close to memory limit — may trigger page reclamation"
);
}
Ok(())
}
fn rejection_suggestion(hint: Option<ActivationHint>) -> &'static str {
match hint.map(|h| h.family) {
Some(ActivationFamily::LtxVideo) => {
"Try reducing --frames or --width/--height, use a quantized variant \
(e.g. ':q8'), or close other GPU apps."
}
_ => {
"Try a smaller variant (e.g. ':q8' / ':q5'), enable --offload (FLUX), \
or close other GPU apps."
}
}
}
const FLUX2_KLEIN_9B_BF16_MIN_VRAM: u64 = 32_000_000_000;
const FLUX2_KLEIN_9B_TRANSFORMER_FLOOR: u64 = 16_000_000_000;
const FLUX2_KLEIN_9B_TEXT_ENCODER_FLOOR: u64 = 14_000_000_000;
fn transformer_file_size(paths: &ModelPaths) -> u64 {
let file_size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
if !paths.transformer_shards.is_empty() {
paths.transformer_shards.iter().map(|p| file_size(p)).sum()
} else {
file_size(&paths.transformer)
}
}
fn is_gguf_path(path: &std::path::Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("gguf"))
}
fn is_flux2_klein_9b_bf16_load(
model_name: &str,
paths: &ModelPaths,
hint: Option<ActivationHint>,
) -> bool {
let lower = model_name.to_lowercase();
let family_matches = hint
.map(|h| h.family == ActivationFamily::Flux2Dit)
.unwrap_or_else(|| lower.contains("flux2"));
if !family_matches || is_gguf_path(&paths.transformer) {
return false;
}
let name_matches_9b = lower.contains("klein-9b") || lower.contains("9b");
let name_marks_full_precision = lower.contains(":bf16") || lower.contains(":fp16");
let transformer_size = transformer_file_size(paths);
let text_encoder_size: u64 = paths
.text_encoder_files
.iter()
.filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len()))
.sum();
let size_matches_9b = transformer_size >= FLUX2_KLEIN_9B_TRANSFORMER_FLOOR
&& text_encoder_size >= FLUX2_KLEIN_9B_TEXT_ENCODER_FLOOR;
(name_matches_9b && name_marks_full_precision) || size_matches_9b
}
pub(crate) fn preflight_memory_guard_with_available(
model_name: &str,
paths: &ModelPaths,
active_vram_bytes: u64,
available_bytes: u64,
hint: Option<ActivationHint>,
) -> Result<(), ApiError> {
let streaming = hint.is_some_and(|h| h.family.streaming_transformer());
let peak = if streaming {
let gemma_will_be_cpu = ltx2_encoder_phase_will_be_cpu(0);
streaming_transformer_peak(paths, gemma_will_be_cpu)
} else {
mold_inference::device::estimate_peak_memory(
paths,
mold_inference::LoadStrategy::Sequential,
)
};
let activation = hint.map(|h| h.budget_bytes()).unwrap_or(0);
let peak_with_activation = peak.saturating_add(activation);
let effective_available = available_bytes.saturating_add(active_vram_bytes);
let suggestion = rejection_suggestion(hint);
if is_flux2_klein_9b_bf16_load(model_name, paths, hint)
&& effective_available < FLUX2_KLEIN_9B_BF16_MIN_VRAM
{
return Err(ApiError::insufficient_memory(format!(
"model '{}' requires a 32 GB GPU for the Flux.2 Klein-9B BF16 transformer \
({:.1} GB effective budget detected). Use a quantized Klein-9B variant \
(e.g. ':q8' / ':q4') or run on a larger card.",
model_name,
effective_available as f64 / 1_000_000_000.0,
)));
}
check_model_memory_budget(
model_name,
peak_with_activation,
effective_available,
suggestion,
)
}
fn streaming_transformer_peak(paths: &ModelPaths, gemma_on_cpu: bool) -> u64 {
const STREAMING_TRANSFORMER_CAP: u64 = 6_000_000_000; const HEADROOM: u64 = 2_000_000_000;
let file_size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let t5_size = paths.t5_encoder.as_ref().map(|p| file_size(p)).unwrap_or(0);
let clip_size = paths
.clip_encoder
.as_ref()
.map(|p| file_size(p))
.unwrap_or(0);
let clip2_size = paths
.clip_encoder_2
.as_ref()
.map(|p| file_size(p))
.unwrap_or(0);
let text_encoder_size: u64 = paths.text_encoder_files.iter().map(|p| file_size(p)).sum();
let encoder_total = if gemma_on_cpu {
0
} else {
t5_size + clip_size + clip2_size + text_encoder_size
};
let inference_phase = STREAMING_TRANSFORMER_CAP;
std::cmp::max(encoder_total, inference_phase) + HEADROOM
}
fn ltx2_encoder_phase_will_be_cpu(gpu_ordinal: usize) -> bool {
matches!(
mold_inference::device::resolve_ltx2_gemma_placement(gpu_ordinal),
mold_inference::device::LtxGemmaPlacement::Cpu,
)
}
pub(crate) fn preflight_memory_guard(
model_name: &str,
paths: &ModelPaths,
active_vram_bytes: u64,
#[cfg_attr(not(feature = "cuda"), allow(unused_variables))] gpu_ordinal: usize,
hint: Option<ActivationHint>,
) -> Result<(), ApiError> {
#[cfg(feature = "cuda")]
{
if active_vram_bytes > 0 {
if let Some(total) = mold_inference::device::total_vram_bytes(gpu_ordinal) {
return preflight_memory_guard_with_available(model_name, paths, 0, total, hint);
}
}
if let (Some(free), Some(total)) = (
mold_inference::device::free_vram_bytes(gpu_ordinal),
mold_inference::device::total_vram_bytes(gpu_ordinal),
) {
const GHOST_VRAM_THRESHOLD: u64 = 1_500_000_000; if total.saturating_sub(free) > GHOST_VRAM_THRESHOLD {
tracing::info!(
gpu = gpu_ordinal,
free_gb = format_args!("{:.1}", free as f64 / 1e9),
total_gb = format_args!("{:.1}", total as f64 / 1e9),
"no active model on this GPU but VRAM is held — reclaiming primary context",
);
mold_inference::device::reclaim_gpu_memory(gpu_ordinal);
}
let effective_free = mold_inference::device::usable_free_vram_bytes(gpu_ordinal)
.unwrap_or_else(|| {
free.saturating_sub(mold_inference::device::reserved_vram_bytes())
});
return preflight_memory_guard_with_available(
model_name,
paths,
active_vram_bytes,
effective_free,
hint,
);
}
if let Some(free) = mold_inference::device::usable_free_vram_bytes(gpu_ordinal) {
return preflight_memory_guard_with_available(
model_name,
paths,
active_vram_bytes,
free,
hint,
);
}
}
if let Some(available) = mold_inference::device::available_system_memory_bytes() {
if available > 0 {
return preflight_memory_guard_with_available(
model_name,
paths,
active_vram_bytes,
available,
hint,
);
}
}
Ok(())
}
pub(crate) type DownloadProgressCallback =
Arc<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>;
pub(crate) enum PullStatus {
AlreadyAvailable,
Pulled,
}
pub(crate) async fn refresh_config(state: &AppState) -> mold_core::Config {
let fresh = {
let current = state.config.read().await;
current.reload_from_disk_preserving_runtime()
};
let mut config = state.config.write().await;
*config = fresh.clone();
fresh
}
pub(crate) async fn list_models(state: &AppState) -> Vec<ModelInfoExtended> {
let config = refresh_config(state).await;
let models_dir = config.resolved_models_dir();
if state.gpu_pool.worker_count() > 0 {
let loaded_models = loaded_models_across_pool(state);
let primary = loaded_models.first().cloned();
let mut catalog = build_model_catalog(&config, primary.as_deref(), primary.is_some());
for entry in catalog.iter_mut() {
if loaded_models.contains(&entry.info.name) {
entry.info.is_loaded = true;
}
}
catalog.extend(installed_catalog_models(
state,
&config,
&models_dir,
primary.as_deref(),
primary.is_some(),
));
return catalog;
}
let snapshot = state.model_cache.lock().await.snapshot();
let mut catalog =
build_model_catalog(&config, snapshot.model_name.as_deref(), snapshot.is_loaded);
catalog.extend(installed_catalog_models(
state,
&config,
&models_dir,
snapshot.model_name.as_deref(),
snapshot.is_loaded,
));
catalog
}
fn loaded_models_across_pool(state: &AppState) -> Vec<String> {
let mut names = Vec::new();
for worker in &state.gpu_pool.workers {
let active = worker
.active_generation
.read()
.ok()
.and_then(|g| g.as_ref().map(|g| g.model.clone()));
let loaded = active.or_else(|| {
let cache = worker.model_cache.lock().ok()?;
cache.active_model().map(|s| s.to_string())
});
if let Some(name) = loaded {
if !names.contains(&name) {
names.push(name);
}
}
}
names
}
fn looks_like_catalog_id(id: &str) -> bool {
id.starts_with("cv:") || id.starts_with("hf:")
}
pub(crate) async fn catalog_family_for(state: &AppState, model_name: &str) -> Option<String> {
if !looks_like_catalog_id(model_name) {
return None;
}
{
let intents = state.catalog_intents.read().await;
if let Some(intent) = intents.get(model_name) {
return Some(intent.family.clone());
}
}
let config = state.config.read().await;
config.models.get(model_name).and_then(|m| m.family.clone())
}
pub(crate) async fn family_for_model(state: &AppState, model_name: &str) -> Option<String> {
if let Some(manifest) = mold_core::manifest::find_manifest(model_name) {
return Some(manifest.family.clone());
}
catalog_family_for(state, model_name).await
}
pub(crate) fn family_for_model_sync(
model_name: &str,
config: &mold_core::Config,
) -> Option<String> {
if let Some(manifest) = mold_core::manifest::find_manifest(model_name) {
return Some(manifest.family.clone());
}
config.models.get(model_name).and_then(|m| m.family.clone())
}
pub(crate) fn activation_hint_for_request_sync(
config: &mold_core::Config,
req: &GenerateRequest,
) -> Option<ActivationHint> {
let family = family_for_model_sync(&req.model, config)?;
Some(ActivationHint::from_request(req, &family))
}
pub(crate) async fn activation_hint_for_request(
state: &AppState,
req: &GenerateRequest,
) -> Option<ActivationHint> {
let family = family_for_model(state, &req.model).await?;
Some(ActivationHint::from_request(req, &family))
}
fn copy_catalog_companion(cfg: &mut mold_core::ModelConfig, companion: &str, paths: &ModelPaths) {
let to_str = |p: &std::path::PathBuf| p.to_str().map(str::to_owned);
match companion {
"clip-l" => {
cfg.clip_encoder = to_str(&paths.transformer);
cfg.clip_tokenizer = paths.clip_tokenizer.as_ref().and_then(to_str);
}
"clip-g" => {
cfg.clip_encoder_2 = to_str(&paths.transformer);
cfg.clip_tokenizer_2 = paths
.clip_tokenizer
.as_ref()
.and_then(to_str)
.or_else(|| cfg.clip_tokenizer.clone());
}
"sdxl-vae" | "sd-vae-ft-mse" => {}
"flux-vae" if cfg.vae.is_none() => {
cfg.vae = to_str(&paths.transformer);
}
"ltx-video-vae" | "flux2-vae" => {
cfg.vae = to_str(&paths.transformer);
}
"t5-v1_1-xxl" => {
cfg.t5_encoder = to_str(&paths.transformer);
cfg.t5_tokenizer = paths.t5_tokenizer.as_ref().and_then(to_str);
}
"z-image-te" | "flux2-te" | "flux2-te-9b" => {
cfg.text_encoder_files = paths
.text_encoder_files
.iter()
.filter_map(to_str)
.collect::<Vec<_>>()
.into();
cfg.text_tokenizer = paths.text_tokenizer.as_ref().and_then(to_str);
}
"ltx2-te" => {
cfg.text_encoder_files = paths
.text_encoder_files
.iter()
.filter_map(to_str)
.collect::<Vec<_>>()
.into();
}
_ => {}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ResolveError {
#[error(
"catalog model '{model}' is missing required component '{companion}'.\n\
The manifest entry for '{companion}' is not present in your config — \
install the matching base model first."
)]
CompanionConfigMissing {
model: String,
companion: &'static str,
},
#[error(
"catalog model '{model}' is missing required component '{companion}'.\n\
The companion file isn't on disk yet. Run `mold pull {model}` to fetch \
the primary checkpoint AND its companion files."
)]
CompanionFileMissing {
model: String,
companion: &'static str,
},
#[error("probe of FLUX checkpoint {path} for bundled VAE failed: {source}")]
FluxProbeFailed {
path: String,
#[source]
source: anyhow::Error,
},
}
fn known_companion_static(name: &str) -> Option<&'static str> {
Some(match name {
"clip-l" => "clip-l",
"clip-g" => "clip-g",
"sdxl-vae" => "sdxl-vae",
"sd-vae-ft-mse" => "sd-vae-ft-mse",
"flux-vae" => "flux-vae",
"flux2-te" => "flux2-te",
"flux2-te-9b" => "flux2-te-9b",
"flux2-vae" => "flux2-vae",
"z-image-te" => "z-image-te",
"ltx-video-vae" => "ltx-video-vae",
"ltx2-te" => "ltx2-te",
"t5-v1_1-xxl" => "t5-v1_1-xxl",
_ => return None,
})
}
pub(crate) fn resolve_intent_to_paths(
model_name: &str,
intent: &mold_catalog::synthesis::CatalogModelIntent,
config: &mold_core::Config,
) -> Result<mold_core::ModelConfig, ResolveError> {
let primary_str = intent
.primary_recipe_path
.to_str()
.expect("synthesize_intent guarantees UTF-8 paths")
.to_string();
let mut cfg = mold_core::ModelConfig {
family: Some(intent.family.clone()),
..Default::default()
};
cfg.transformer = Some(primary_str.clone());
let is_flux = intent.family == "flux";
let probe_says_bundled = if is_flux && intent.primary_recipe_path.exists() {
Some(
mold_inference::loader::flux_single_file_bundles_vae(&intent.primary_recipe_path)
.map_err(|e| ResolveError::FluxProbeFailed {
path: intent.primary_recipe_path.display().to_string(),
source: e.into(),
})?,
)
} else if is_flux {
None
} else if mold_catalog::synthesis::family_bundles_vae_unconditionally(flux_family_for_slug(
&intent.family,
)) {
Some(true)
} else {
Some(false)
};
if probe_says_bundled == Some(true) {
cfg.vae = Some(primary_str);
}
let mut missing_required: Option<&'static str> = None;
for companion in &intent.companions {
let static_name =
known_companion_static(&companion.name).expect("companion name in registry");
match ModelPaths::resolve(static_name, config) {
Some(paths) => copy_catalog_companion(&mut cfg, static_name, &paths),
None => {
if companion.required {
if missing_required.is_none() {
missing_required = Some(static_name);
}
}
}
}
}
if let Some(missing) = missing_required {
return Err(classify_missing_companion(model_name, missing, config));
}
Ok(cfg)
}
fn flux_family_for_slug(slug: &str) -> mold_catalog::families::Family {
use mold_catalog::families::Family;
match slug {
"flux" => Family::Flux,
"flux2" => Family::Flux2,
"sd15" => Family::Sd15,
"sdxl" => Family::Sdxl,
"z-image" => Family::ZImage,
"ltx-video" => Family::LtxVideo,
"ltx2" => Family::Ltx2,
"qwen-image" => Family::QwenImage,
"wuerstchen" => Family::Wuerstchen,
_ => Family::Flux,
}
}
fn classify_missing_companion(
model_name: &str,
companion: &'static str,
config: &mold_core::Config,
) -> ResolveError {
if !config.models.contains_key(companion) {
ResolveError::CompanionConfigMissing {
model: model_name.to_string(),
companion,
}
} else {
ResolveError::CompanionFileMissing {
model: model_name.to_string(),
companion,
}
}
}
#[cfg(test)]
fn synthesize_catalog_config(
entry: &mold_catalog::entry::CatalogEntry,
models_dir: &std::path::Path,
config: &mold_core::Config,
) -> anyhow::Result<mold_core::ModelConfig> {
let intent = mold_catalog::synthesis::synthesize_intent(entry, models_dir)?;
Ok(resolve_intent_to_paths(entry.id.as_str(), &intent, config)?)
}
pub(crate) async fn install_catalog_model(
state: &AppState,
model_name: &str,
) -> Result<(), mold_core::InstallError> {
if !looks_like_catalog_id(model_name) {
return Ok(());
}
{
let intents = state.catalog_intents.read().await;
if intents.contains_key(model_name) {
return Ok(());
}
}
let civitai_base = state.catalog_live_civitai_base.as_str();
let entry = if let Some(version_id) = model_name.strip_prefix("cv:") {
mold_catalog::live::fetch_civitai_version(
civitai_base,
version_id,
std::env::var("CIVITAI_TOKEN").ok().as_deref(),
)
.await
.map_err(|e| live_error_to_install_error(model_name, &e))?
} else if let Some(repo_id) = model_name.strip_prefix("hf:") {
mold_catalog::live::fetch_hf_repo(
"https://huggingface.co",
repo_id,
std::env::var("HF_TOKEN").ok().as_deref(),
)
.await
.map_err(|e| live_error_to_install_error(model_name, &e))?
} else {
return Ok(());
};
let models_dir = state.config.read().await.resolved_models_dir();
let intent = mold_catalog::synthesis::synthesize_intent(&entry, &models_dir).map_err(|e| {
mold_core::InstallError::RecipeMalformed(format!("synthesize intent for {model_name}: {e}"))
})?;
let mut intents = state.catalog_intents.write().await;
intents.insert(model_name.to_string(), intent);
Ok(())
}
fn live_error_to_install_error(
model_name: &str,
err: &mold_catalog::live::LiveSearchError,
) -> mold_core::InstallError {
use mold_catalog::live::LiveSearchError;
match err {
LiveSearchError::Network(e) => {
mold_core::InstallError::Network(format!("{model_name}: {e}"))
}
LiveSearchError::Decode(e) => mold_core::InstallError::RecipeMalformed(format!(
"{model_name}: decode upstream payload: {e}"
)),
LiveSearchError::Upstream { status, body, .. } if *status == 404 => {
mold_core::InstallError::NotFound(format!(
"{model_name}: upstream returned 404 ({})",
truncate_body(body)
))
}
LiveSearchError::Upstream { status, body, .. } => {
mold_core::InstallError::RecipeMalformed(format!(
"{model_name}: upstream HTTP {status}: {}",
truncate_body(body)
))
}
}
}
fn truncate_body(body: &str) -> String {
let trimmed = body.trim();
if trimmed.len() > 160 {
format!("{}…", &trimmed[..160])
} else {
trimmed.to_string()
}
}
pub(crate) fn install_error_to_api_error(err: &mold_core::InstallError) -> ApiError {
use mold_core::InstallError;
match err {
InstallError::Network(msg) => {
ApiError::internal_with_status(
format!("network unreachable: {msg}"),
axum::http::StatusCode::BAD_GATEWAY,
)
}
InstallError::NotFound(msg) => ApiError::not_found(msg.to_string()),
InstallError::RecipeMalformed(msg) => ApiError::internal(msg.to_string()),
}
}
pub(crate) fn resolve_error_to_api_error(err: &ResolveError) -> ApiError {
ApiError::not_found(err.to_string())
}
fn installed_catalog_models(
_state: &AppState,
config: &mold_core::Config,
models_dir: &std::path::Path,
loaded_model: Option<&str>,
engine_is_loaded: bool,
) -> Vec<ModelInfoExtended> {
let walked = mold_catalog::sidecar::walk_sidecars(models_dir);
let mut out = Vec::new();
for (sidecar_dir, sidecar) in walked {
if sidecar.kind != "checkpoint" {
continue;
}
if mold_catalog::sidecar::primary_path_if_present(&sidecar_dir, &sidecar).is_none() {
continue;
}
if mold_core::manifest::find_manifest_by_hf_repo(&sidecar.source_id).is_some() {
continue;
}
let size_gb = sidecar
.size_bytes
.map(|b| b as f32 / 1_000_000_000.0)
.unwrap_or(0.0);
let (w, h, steps, guidance) = mold_core::manifest::visible_manifests()
.find(|m| m.family == sidecar.family)
.map(|m| {
let cfg = config.resolved_model_config(&m.name);
(
cfg.effective_width(config),
cfg.effective_height(config),
cfg.effective_steps(config),
cfg.effective_guidance(),
)
})
.unwrap_or_else(|| match sidecar.family.as_str() {
"ltx-video" | "ltx2" => (768, 512, 25, 3.5),
"sdxl" => (1024, 1024, 20, 7.5),
"sd15" => (512, 512, 20, 7.5),
"flux" => (1024, 1024, 20, 3.5),
"flux2" => (512, 512, 4, 0.0),
_ => (1024, 1024, 20, 3.5),
});
let description = match &sidecar.author {
Some(a) if !a.is_empty() => format!("{} by {a}", sidecar.name),
_ => sidecar.name.clone(),
};
out.push(ModelInfoExtended {
downloaded: true,
defaults: ModelDefaults {
default_width: w,
default_height: h,
default_steps: steps,
default_guidance: guidance,
description,
},
info: ModelInfo {
name: sidecar.id.clone(),
family: sidecar.family.clone(),
size_gb,
is_loaded: loaded_model.is_some_and(|n| engine_is_loaded && n == sidecar.id),
last_used: None,
hf_repo: String::new(),
},
disk_usage_bytes: sidecar.size_bytes,
remaining_download_bytes: Some(0),
});
}
out
}
pub(crate) async fn check_model_available(
state: &AppState,
model_name: &str,
) -> Result<Option<ModelPaths>, ApiError> {
{
let cache = state.model_cache.lock().await;
if cache.contains(model_name) {
return Ok(None);
}
}
let paths = {
let config = state.config.read().await;
if config.manifest_model_needs_download(model_name) {
None
} else {
ModelPaths::resolve(model_name, &config)
}
};
if let Some(paths) = paths {
return Ok(Some(paths));
}
{
let current = state.config.read().await.clone();
let fresh_config = current.reload_from_disk_preserving_runtime();
let needs_download = fresh_config.manifest_model_needs_download(model_name);
let paths = if needs_download {
None
} else {
ModelPaths::resolve(model_name, &fresh_config)
};
{
let mut config = state.config.write().await;
*config = fresh_config;
}
if let Some(paths) = paths {
return Ok(Some(paths));
}
}
if looks_like_catalog_id(model_name) {
if let Err(install_err) = install_catalog_model(state, model_name).await {
return Err(install_error_to_api_error(&install_err));
}
let intents = state.catalog_intents.read().await;
let intent = intents
.get(model_name)
.ok_or_else(|| {
ApiError::not_found(format!(
"catalog model '{model_name}' is not installed. Download it from \
the catalog first."
))
})?
.clone();
drop(intents);
let resolved = {
let config = state.config.read().await;
resolve_intent_to_paths(model_name, &intent, &config)
};
match resolved {
Ok(model_cfg) => {
{
let mut config = state.config.write().await;
config.models.insert(model_name.to_string(), model_cfg);
}
let config = state.config.read().await;
if let Some(paths) = ModelPaths::resolve(model_name, &config) {
return Ok(Some(paths));
}
return Err(ApiError::not_found(format!(
"catalog model '{model_name}' resolved to a config that ModelPaths \
could not turn into runtime paths — internal mismatch, please file an issue."
)));
}
Err(e) => return Err(resolve_error_to_api_error(&e)),
}
}
if mold_core::manifest::find_manifest(model_name).is_some() {
return Err(ApiError::not_found(format!(
"model '{model_name}' is not downloaded. Run: mold pull {model_name}"
)));
}
Err(ApiError::unknown_model(format!(
"unknown model '{model_name}'. Run 'mold list' to see available models."
)))
}
pub(crate) async fn ensure_model_ready(
state: &AppState,
model_name: &str,
progress: Option<EngineProgressCallback>,
hint: Option<ActivationHint>,
) -> Result<(), ApiError> {
let _guard = state.model_load_lock.lock().await;
{
let mut cache = state.model_cache.lock().await;
let active_vram = cache.active_vram_bytes();
if let Some(entry) = cache.get_mut(model_name) {
if entry.residency == ModelResidency::Gpu {
if let Some(callback) = progress.clone() {
entry.engine.set_on_progress(Box::new(move |event| {
callback(event);
}));
} else {
entry.engine.clear_on_progress();
}
return Ok(());
}
if let Some(paths) = entry.engine.model_paths() {
preflight_memory_guard(model_name, paths, active_vram, 0, hint)?;
}
if let Some(active_name) = cache.unload_active() {
#[cfg(feature = "metrics")]
crate::metrics::clear_model_loaded(&active_name);
tracing::info!(
from = %active_name,
to = %model_name,
"unloaded active model to reload cached model"
);
mold_inference::reclaim_gpu_memory(0);
}
let cached = cache.take(model_name).ok_or_else(|| {
ApiError::internal(format!("cache race: model '{model_name}' vanished"))
})?;
drop(cache);
let mut engine = cached.engine;
if let Some(callback) = progress.clone() {
engine.set_on_progress(Box::new(move |event| {
callback(event);
}));
} else {
engine.clear_on_progress();
}
let model_log = model_name.to_string();
#[cfg(feature = "metrics")]
let load_start = std::time::Instant::now();
let vram_baseline = mold_inference::device::vram_in_use_bytes(0);
let join_result = tokio::task::spawn_blocking(move || {
tracing::info!(model = %model_log, "reloading cached engine...");
if let Err(e) = engine.load() {
tracing::error!("model reload failed: {e:#}");
return Err((
ApiError::internal(format!("model reload error: {e}")),
engine,
));
}
Ok(engine)
})
.await;
match join_result {
Ok(Ok(loaded_engine)) => {
#[cfg(feature = "metrics")]
{
let duration = load_start.elapsed().as_secs_f64();
crate::metrics::record_model_load(model_name, duration);
crate::metrics::set_model_loaded(model_name);
let vram_est = mold_inference::device::vram_in_use_bytes(0);
crate::metrics::record_gpu_memory(vram_est);
}
let vram = mold_inference::device::vram_load_delta(0, vram_baseline);
let evicted = {
let mut cache = state.model_cache.lock().await;
cache.insert(loaded_engine, vram)
};
drop(evicted);
}
Ok(Err((api_err, unloaded_engine))) => {
let evicted = {
let mut cache = state.model_cache.lock().await;
cache.insert(unloaded_engine, 0)
};
drop(evicted);
return Err(api_err);
}
Err(join_err) => {
{
let mut cache = state.model_cache.lock().await;
cache.clear_in_flight(model_name);
}
return Err(ApiError::internal(format!(
"model reload task failed: {join_err}"
)));
}
}
return Ok(());
}
}
match check_model_available(state, model_name).await? {
Some(paths) => create_and_load_engine(state, model_name, paths, progress, hint).await,
None => Ok(()),
}
}
pub(crate) async fn pull_model(
state: &AppState,
model: &str,
progress: Option<DownloadProgressCallback>,
) -> Result<PullStatus, ApiError> {
if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(model)).is_none()
{
return Err(ApiError::unknown_model(format!(
"unknown model '{model}'. Run 'mold list' to see available models."
)));
}
let _guard = state.pull_lock.lock().await;
{
let config = refresh_config(state).await;
if config.manifest_model_is_downloaded(model) {
return Ok(PullStatus::AlreadyAvailable);
}
}
tracing::info!(model = %model, "pulling model via API");
let opts = mold_core::download::PullOptions::default();
let new_config = match progress {
Some(callback) => {
mold_core::download::pull_and_configure_with_callback(model, callback, &opts)
.await
.map(|(config, _)| config)
}
None => mold_core::download::pull_and_configure(model, &opts)
.await
.map(|(config, _)| config),
}
.map_err(|e| {
tracing::error!("pull failed for {}: {e}", model);
ApiError::internal(format!("failed to pull model '{}': {e}", model))
})?;
{
let mut config = state.config.write().await;
*config = new_config;
}
tracing::info!(model = %model, "pull complete");
Ok(PullStatus::Pulled)
}
pub(crate) async fn unload_model(state: &AppState) -> String {
if let Ok(mut upscaler) = state.upscaler_cache.try_lock() {
if upscaler.is_some() {
*upscaler = None;
tracing::info!("upscaler cache cleared");
}
}
let mut cache = state.model_cache.lock().await;
match cache.unload_active() {
Some(name) => {
#[cfg(feature = "metrics")]
{
crate::metrics::clear_model_loaded(&name);
crate::metrics::record_gpu_memory(0);
}
drop(cache);
mold_inference::reclaim_gpu_memory(0);
tracing::info!(model = %name, "model unloaded via API");
format!("unloaded {name}")
}
None => "no model loaded".to_string(),
}
}
async fn create_and_load_engine(
state: &AppState,
model_name: &str,
paths: ModelPaths,
progress: Option<EngineProgressCallback>,
hint: Option<ActivationHint>,
) -> Result<(), ApiError> {
let active_vram = {
let cache = state.model_cache.lock().await;
cache.active_vram_bytes()
};
preflight_memory_guard(model_name, &paths, active_vram, 0, hint)?;
let had_active = {
let mut cache = state.model_cache.lock().await;
let result = cache.unload_active();
if let Some(ref name) = result {
#[cfg(feature = "metrics")]
crate::metrics::clear_model_loaded(name);
tracing::info!(
from = %name,
to = %model_name,
"unloading active model before loading new one"
);
}
result.is_some()
};
if had_active {
mold_inference::reclaim_gpu_memory(0);
}
let config = state.config.read().await;
let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
let mut new_engine = mold_inference::create_engine_with_pool(
model_name.to_string(),
paths,
&config,
mold_inference::LoadStrategy::Eager,
0,
offload,
Some(state.shared_pool.clone()),
)
.map_err(|e| ApiError::internal(format!("failed to create engine for '{model_name}': {e}")))?;
drop(config);
if let Some(callback) = progress {
new_engine.set_on_progress(Box::new(move |event| {
callback(event);
}));
} else {
new_engine.clear_on_progress();
}
let model_log = model_name.to_string();
#[cfg(feature = "metrics")]
let load_start = std::time::Instant::now();
let vram_baseline = mold_inference::device::vram_in_use_bytes(0);
new_engine = tokio::task::spawn_blocking(move || {
tracing::info!(model = %model_log, "loading model...");
new_engine.load().map_err(|e| {
tracing::error!("model load failed: {e:#}");
ApiError::internal(format!("model load error: {e}"))
})?;
Ok::<_, ApiError>(new_engine)
})
.await
.map_err(|e| ApiError::internal(format!("model load task failed: {e}")))??;
#[cfg(feature = "metrics")]
{
let duration = load_start.elapsed().as_secs_f64();
crate::metrics::record_model_load(model_name, duration);
crate::metrics::set_model_loaded(model_name);
}
let vram = mold_inference::device::vram_load_delta(0, vram_baseline);
#[cfg(feature = "metrics")]
crate::metrics::record_gpu_memory(mold_inference::device::vram_in_use_bytes(0));
let evicted = {
let mut cache = state.model_cache.lock().await;
cache.insert(new_engine, vram)
};
drop(evicted);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const GB: u64 = 1_000_000_000;
struct Ltx2GemmaEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
prior_main: Option<std::ffi::OsString>,
prior_legacy: Option<std::ffi::OsString>,
}
impl Drop for Ltx2GemmaEnvGuard {
fn drop(&mut self) {
unsafe {
std::env::remove_var("MOLD_LTX2_GEMMA_DEVICE");
std::env::remove_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
if let Some(v) = self.prior_main.take() {
std::env::set_var("MOLD_LTX2_GEMMA_DEVICE", v);
}
if let Some(v) = self.prior_legacy.take() {
std::env::set_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER", v);
}
}
}
}
fn ltx2_gemma_env_guard(value: &str) -> Ltx2GemmaEnvGuard {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let lock = LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner());
let prior_main = std::env::var_os("MOLD_LTX2_GEMMA_DEVICE");
let prior_legacy = std::env::var_os("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
unsafe {
std::env::remove_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
std::env::set_var("MOLD_LTX2_GEMMA_DEVICE", value);
}
Ltx2GemmaEnvGuard {
_lock: lock,
prior_main,
prior_legacy,
}
}
fn test_paths_with_total_size(total_bytes: u64) -> (tempfile::TempDir, ModelPaths) {
let dir = tempfile::tempdir().expect("tempdir");
let transformer = dir.path().join("transformer.safetensors");
let vae = dir.path().join("vae.safetensors");
let half = total_bytes / 2;
let rest = total_bytes - half;
let f1 = std::fs::File::create(&transformer).expect("create transformer");
f1.set_len(half).expect("set transformer len");
let f2 = std::fs::File::create(&vae).expect("create vae");
f2.set_len(rest).expect("set vae len");
let paths = ModelPaths {
transformer,
transformer_shards: Vec::new(),
vae,
spatial_upscaler: None,
temporal_upscaler: None,
distilled_lora: None,
t5_encoder: None,
clip_encoder: None,
t5_tokenizer: None,
clip_tokenizer: None,
clip_encoder_2: None,
clip_tokenizer_2: None,
text_encoder_files: Vec::new(),
text_tokenizer: None,
decoder: None,
};
(dir, paths)
}
#[test]
fn test_paths_helper_sets_file_sizes() {
let (_dir, paths) = test_paths_with_total_size(10 * GB);
let peak = mold_inference::device::estimate_peak_memory(
&paths,
mold_inference::LoadStrategy::Eager,
);
assert!(
peak >= 10 * GB,
"expected peak >= 10 GB component sum, got {peak}"
);
assert!(PathBuf::from(&paths.transformer).exists());
assert!(PathBuf::from(&paths.vae).exists());
}
#[test]
fn preflight_uses_active_vram_as_reclaimable() {
let (_dir, paths) = test_paths_with_total_size(10 * GB);
let result =
preflight_memory_guard_with_available("swap-test", &paths, 10 * GB, 8 * GB, None);
assert!(
result.is_ok(),
"expected swap to succeed with reclaimable VRAM, got {result:?}"
);
}
#[test]
fn preflight_rejects_when_peak_exceeds_effective_available() {
let (_dir, paths) = test_paths_with_total_size(20 * GB);
let result = preflight_memory_guard_with_available("too-big", &paths, 5 * GB, 8 * GB, None);
assert!(
result.is_err(),
"expected oversized model to be rejected, got Ok"
);
}
#[test]
fn memory_guard_ok_when_plenty_of_memory() {
assert!(check_model_memory_budget("test-model", 5 * GB, 20 * GB, "").is_ok());
}
#[test]
fn memory_guard_rejects_over_90pct() {
let result = check_model_memory_budget("flux-dev:bf16", 19 * GB, 20 * GB, "Try --offload.");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, "INSUFFICIENT_MEMORY");
assert!(err.error.contains("flux-dev:bf16"));
assert!(err.error.contains("budget cap"));
}
#[test]
fn memory_guard_ok_at_90pct_boundary() {
assert!(check_model_memory_budget("test", 18 * GB, 20 * GB, "").is_ok());
}
#[test]
fn memory_guard_ok_in_warn_zone() {
assert!(check_model_memory_budget("test", 17 * GB, 20 * GB, "").is_ok());
}
#[test]
fn memory_guard_ok_below_warn_zone() {
assert!(check_model_memory_budget("test", 15 * GB, 20 * GB, "").is_ok());
}
#[test]
fn memory_guard_rejects_tiny_available() {
let result = check_model_memory_budget("huge-model", 30 * GB, 16 * GB, "");
assert!(result.is_err());
}
#[test]
fn memory_guard_swap_uses_active_vram_as_reclaimable() {
let free_vram = 2 * GB;
let active_vram = 18 * GB;
let effective = free_vram + active_vram;
assert!(check_model_memory_budget("swap-target", 15 * GB, effective, "").is_ok());
}
#[test]
fn memory_guard_swap_still_rejects_when_oversized() {
let free_vram = GB;
let active_vram = 8 * GB;
let effective = free_vram + active_vram;
assert!(check_model_memory_budget("too-large", 15 * GB, effective, "").is_err());
}
fn flux_shaped_paths_with_sizes(
transformer_gb: u64,
vae_gb: u64,
t5_gb: u64,
clip_gb: u64,
) -> (tempfile::TempDir, ModelPaths) {
let dir = tempfile::tempdir().expect("tempdir");
let mk = |name: &str, sz: u64| {
let p = dir.path().join(name);
let f = std::fs::File::create(&p).unwrap();
f.set_len(sz * GB).unwrap();
p
};
let transformer = mk("transformer.safetensors", transformer_gb);
let vae = mk("vae.safetensors", vae_gb);
let t5 = mk("t5.safetensors", t5_gb);
let clip = mk("clip.safetensors", clip_gb);
let paths = ModelPaths {
transformer,
transformer_shards: Vec::new(),
vae,
spatial_upscaler: None,
temporal_upscaler: None,
distilled_lora: None,
t5_encoder: Some(t5),
clip_encoder: Some(clip),
t5_tokenizer: None,
clip_tokenizer: None,
clip_encoder_2: None,
clip_tokenizer_2: None,
text_encoder_files: Vec::new(),
text_tokenizer: None,
decoder: None,
};
(dir, paths)
}
#[test]
fn preflight_passes_for_quantized_flux_on_24gb_card_with_swap() {
let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
let result = preflight_memory_guard_with_available("flux-dev:q8", &paths, 0, 24 * GB, None);
assert!(
result.is_ok(),
"quantized FLUX must fit on a 24 GB card under the Sequential \
peak estimate (drop-and-reload encoders), got {result:?}"
);
}
#[test]
fn eager_strategy_would_have_rejected_quantized_flux_on_24gb() {
let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
let eager_peak = mold_inference::device::estimate_peak_memory(
&paths,
mold_inference::LoadStrategy::Eager,
);
let hard_limit = (24 * GB) * 9 / 10;
assert!(
eager_peak > hard_limit,
"Eager peak ({eager_peak}) should exceed hard limit ({hard_limit}) — \
this is the false-rejection the Sequential switch fixes"
);
}
#[test]
fn preflight_memory_guard_accepts_resolution_for_activation_budget() {
let (_dir, paths) = flux_shaped_paths_with_sizes(23, 1, 9, 1);
let hint_768 = ActivationHint {
width: 768,
height: 768,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::FluxDit,
};
let hint_2048 = ActivationHint {
width: 2048,
height: 2048,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::FluxDit,
};
let card_total = 30 * GB;
let result_768 = preflight_memory_guard_with_available(
"flux-dev",
&paths,
0,
card_total,
Some(hint_768),
);
let result_2048 = preflight_memory_guard_with_available(
"flux-dev",
&paths,
0,
card_total,
Some(hint_2048),
);
assert!(
result_768.is_ok(),
"768² FLUX should fit on 30 GB (small activation budget), got {result_768:?}"
);
assert!(
result_2048.is_err(),
"2048² FLUX must be rejected on 30 GB (large activation budget pushes \
peak past 90 % cap), got {result_2048:?}"
);
}
fn flux2_klein9b_bf16_paths() -> (tempfile::TempDir, ModelPaths) {
let dir = tempfile::tempdir().expect("tempdir");
let mk = |name: &str, sz: u64| {
let p = dir.path().join(name);
let f = std::fs::File::create(&p).unwrap();
f.set_len(sz * GB).unwrap();
p
};
let shard_a = mk("diffusion_pytorch_model-00001-of-00002.safetensors", 10);
let shard_b = mk("diffusion_pytorch_model-00002-of-00002.safetensors", 8);
let vae = mk("flux2-vae.safetensors", 1);
let te_a = mk("text_encoder-00001-of-00004.safetensors", 5);
let te_b = mk("text_encoder-00002-of-00004.safetensors", 5);
let te_c = mk("text_encoder-00003-of-00004.safetensors", 5);
let te_d = mk("text_encoder-00004-of-00004.safetensors", 1);
let paths = ModelPaths {
transformer: shard_a.clone(),
transformer_shards: vec![shard_a, shard_b],
vae,
spatial_upscaler: None,
temporal_upscaler: None,
distilled_lora: None,
t5_encoder: None,
clip_encoder: None,
t5_tokenizer: None,
clip_tokenizer: None,
clip_encoder_2: None,
clip_tokenizer_2: None,
text_encoder_files: vec![te_a, te_b, te_c, te_d],
text_tokenizer: None,
decoder: None,
};
(dir, paths)
}
#[test]
fn preflight_rejects_flux2_klein9b_bf16_on_24gb_card() {
let (_dir, paths) = flux2_klein9b_bf16_paths();
let hint = ActivationHint {
width: 1024,
height: 1024,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::Flux2Dit,
};
let result = preflight_memory_guard_with_available(
"flux2-klein-9b:bf16",
&paths,
0,
24 * GB,
Some(hint),
);
assert!(
result.is_err(),
"Klein-9B BF16 must not be admitted on a 24 GB card; \
the plain file-size sequential estimate undercounts its load-time peak, got {result:?}"
);
}
#[test]
fn preflight_allows_flux2_klein9b_bf16_on_32gb_card() {
let (_dir, paths) = flux2_klein9b_bf16_paths();
let hint = ActivationHint {
width: 1024,
height: 1024,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::Flux2Dit,
};
let result = preflight_memory_guard_with_available(
"flux2-klein-9b:bf16",
&paths,
0,
32 * GB,
Some(hint),
);
assert!(
result.is_ok(),
"Klein-9B BF16 should be allowed once the card budget reaches 32 GB, got {result:?}"
);
}
fn ltx2_shaped_paths_with_sizes(
transformer_gb: u64,
gemma_te_gb: u64,
) -> (tempfile::TempDir, ModelPaths) {
let dir = tempfile::tempdir().expect("tempdir");
let mk = |name: &str, sz: u64| {
let p = dir.path().join(name);
let f = std::fs::File::create(&p).unwrap();
f.set_len(sz * GB).unwrap();
p
};
let transformer = mk("ltx2_full.safetensors", transformer_gb);
let gemma = mk("gemma_te.safetensors", gemma_te_gb);
let paths = ModelPaths {
transformer: transformer.clone(),
transformer_shards: Vec::new(),
vae: transformer,
spatial_upscaler: None,
temporal_upscaler: None,
distilled_lora: None,
t5_encoder: None,
clip_encoder: None,
t5_tokenizer: None,
clip_tokenizer: None,
clip_encoder_2: None,
clip_tokenizer_2: None,
text_encoder_files: vec![gemma],
text_tokenizer: None,
decoder: None,
};
(dir, paths)
}
#[test]
fn preflight_accepts_ltx2_22b_on_24gb_card_via_streaming_peak() {
let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 0);
let hint = ActivationHint {
width: 768,
height: 512,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::Ltx2Video,
};
let result =
preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
assert!(
result.is_ok(),
"22B LTX-2 must fit on a 24 GB card under streaming-aware peak \
(only ~2 blocks co-resident; runtime handles its own memory), \
got {result:?}",
);
}
#[test]
fn preflight_rejects_ltx2_22b_when_hint_marks_non_streaming() {
let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 0);
let hint = ActivationHint {
width: 768,
height: 512,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::FluxDit,
};
let result =
preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
assert!(
result.is_err(),
"without the LTX-2 streaming hint the file-size peak must reject \
a 46 GB transformer on a 24 GB card — this anchors the regression \
that landed before the streaming-aware path",
);
}
#[test]
fn preflight_rejects_ltx2_when_encoder_phase_exceeds_card() {
let _guard = ltx2_gemma_env_guard("gpu");
let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
let hint = ActivationHint {
width: 768,
height: 512,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::Ltx2Video,
};
let result =
preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
assert!(
result.is_err(),
"25 GB Gemma TE alone exceeds 90 %% of 24 GB during the encoder \
phase — preflight must surface this even when the transformer \
is streamed, got {result:?}",
);
}
#[test]
fn preflight_admits_ltx2_22b_with_25gb_gemma_when_resolver_picks_cpu() {
let _guard = ltx2_gemma_env_guard("cpu");
let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
let hint = ActivationHint {
width: 768,
height: 512,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::Ltx2Video,
};
let result =
preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
assert!(
result.is_ok(),
"with MOLD_LTX2_GEMMA_DEVICE=cpu the encoder phase should not \
count against GPU VRAM, so cv:2752735 must admit on 24 GB even \
with a 25 GB Gemma TE, got {result:?}",
);
}
#[test]
fn activation_hint_from_request_classifies_correctly() {
let mut req = GenerateRequest {
prompt: "test".into(),
negative_prompt: None,
model: "flux-dev:bf16".into(),
width: 1024,
height: 1024,
steps: 20,
guidance: 3.5,
seed: None,
batch_size: 1,
output_format: Default::default(),
embed_metadata: None,
scheduler: None,
cfg_plus: None,
source_image: None,
edit_images: None,
strength: 1.0,
mask_image: None,
control_image: None,
control_model: None,
control_scale: 1.0,
expand: None,
original_prompt: None,
lora: None,
frames: None,
fps: None,
upscale_model: None,
gif_preview: false,
enable_audio: None,
audio_file: None,
source_video: None,
keyframes: None,
pipeline: None,
loras: None,
retake_range: None,
spatial_upscale: None,
temporal_upscale: None,
placement: None,
};
let hint_flux = ActivationHint::from_request(&req, "flux");
assert_eq!(hint_flux.family, ActivationFamily::FluxDit);
assert_eq!(hint_flux.batch, 1);
let hint_sdxl = ActivationHint::from_request(&req, "sdxl");
assert_eq!(hint_sdxl.family, ActivationFamily::SdxlUnet);
assert_eq!(hint_sdxl.batch, 2);
req.guidance = 1.0;
let hint_sdxl_lcm = ActivationHint::from_request(&req, "sdxl");
assert_eq!(hint_sdxl_lcm.batch, 1);
let hint_unknown = ActivationHint::from_request(&req, "totally-bogus");
assert_eq!(hint_unknown.family, ActivationFamily::FluxDit);
}
#[test]
fn copy_catalog_companion_flux_vae_populates_when_unset() {
let mut cfg = mold_core::ModelConfig {
family: Some("flux".into()),
transformer: Some("/m/cv-x/flux/civitai/x/unet.safetensors".into()),
vae: None,
..Default::default()
};
let paths = ModelPaths {
transformer: PathBuf::from("/m/flux-vae/ae.safetensors"),
transformer_shards: Vec::new(),
vae: PathBuf::from("/m/flux-vae/ae.safetensors"),
spatial_upscaler: None,
temporal_upscaler: None,
distilled_lora: None,
t5_encoder: None,
clip_encoder: None,
t5_tokenizer: None,
clip_tokenizer: None,
clip_encoder_2: None,
clip_tokenizer_2: None,
text_encoder_files: Vec::new(),
text_tokenizer: None,
decoder: None,
};
copy_catalog_companion(&mut cfg, "flux-vae", &paths);
assert_eq!(
cfg.vae.as_deref(),
Some("/m/flux-vae/ae.safetensors"),
"transformer-only cfg.vae must be populated from the flux-vae companion path"
);
}
#[test]
fn copy_catalog_companion_flux_vae_does_not_override_bundled() {
let mut cfg = mold_core::ModelConfig {
family: Some("flux".into()),
transformer: Some("/m/cv-x/flux/civitai/x/full.safetensors".into()),
vae: Some("/m/cv-x/flux/civitai/x/full.safetensors".into()),
..Default::default()
};
let paths = ModelPaths {
transformer: PathBuf::from("/m/flux-vae/ae.safetensors"),
transformer_shards: Vec::new(),
vae: PathBuf::from("/m/flux-vae/ae.safetensors"),
spatial_upscaler: None,
temporal_upscaler: None,
distilled_lora: None,
t5_encoder: None,
clip_encoder: None,
t5_tokenizer: None,
clip_tokenizer: None,
clip_encoder_2: None,
clip_tokenizer_2: None,
text_encoder_files: Vec::new(),
text_tokenizer: None,
decoder: None,
};
copy_catalog_companion(&mut cfg, "flux-vae", &paths);
assert_eq!(
cfg.vae.as_deref(),
Some("/m/cv-x/flux/civitai/x/full.safetensors"),
"bundled cfg.vae must survive the flux-vae companion pass"
);
}
fn write_safetensors_with_keys(path: &std::path::Path, keys: &[&str]) {
use std::io::Write;
let mut header = serde_json::Map::new();
for key in keys {
header.insert(
(*key).to_string(),
serde_json::json!({
"dtype": "F32",
"shape": [1],
"data_offsets": [0, 4],
}),
);
}
let header_json = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
let mut f = std::fs::File::create(path).expect("create fixture");
f.write_all(&(header_json.len() as u64).to_le_bytes())
.unwrap();
f.write_all(&header_json).unwrap();
f.write_all(&[0u8; 4]).unwrap();
}
fn flux_unet_only_catalog_entry(
version_id: &str,
file_name: &str,
) -> mold_catalog::entry::CatalogEntry {
use mold_catalog::entry::{
CatalogEntry, CatalogId, DownloadRecipe, FamilyRole, FileFormat, LicenseFlags,
Modality, RecipeFile, Source, TokenKind,
};
use mold_catalog::families::Family;
CatalogEntry {
id: CatalogId::from(format!("cv:{version_id}")),
source: Source::Civitai,
source_id: version_id.to_string(),
name: "FLUX Unet-only fine-tune".into(),
author: Some("someone".into()),
family: Family::Flux,
family_role: FamilyRole::Finetune,
sub_family: None,
modality: Modality::Image,
kind: mold_catalog::entry::Kind::Checkpoint,
file_format: FileFormat::Safetensors,
bundling: mold_catalog::entry::Bundling::SingleFile,
size_bytes: Some(12_000_000_000),
download_count: 0,
rating: None,
likes: 0,
nsfw: false,
thumbnail_url: None,
description: None,
license: None,
license_flags: LicenseFlags::default(),
tags: vec![],
companions: vec!["t5-v1_1-xxl".into(), "clip-l".into(), "flux-vae".into()],
download_recipe: DownloadRecipe {
files: vec![RecipeFile {
url: format!("https://civitai.com/api/download/models/{version_id}"),
dest: format!("{{family}}/civitai/{version_id}/{file_name}"),
sha256: Some("DEAD".repeat(16)),
size_bytes: Some(12_000_000_000),
}],
needs_token: Some(TokenKind::Civitai),
},
engine_phase: 1,
created_at: None,
updated_at: None,
added_at: 0,
trained_words: vec![],
}
}
#[test]
fn synthesize_catalog_config_uses_flux_vae_companion_when_bundle_lacks_vae() {
let dir = tempfile::tempdir().unwrap();
let models_dir = dir.path();
let _saved = std::env::var("MOLD_HOME").ok();
unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
let primary_path = models_dir
.join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
write_safetensors_with_keys(
&primary_path,
&[
"double_blocks.0.img_attn.proj.weight",
"single_blocks.0.linear1.weight",
"img_in.weight",
],
);
let vae_path = models_dir.join("flux-vae/ae.safetensors");
std::fs::create_dir_all(vae_path.parent().unwrap()).unwrap();
std::fs::File::create(&vae_path).unwrap();
let mut config = mold_core::Config {
models_dir: models_dir.to_string_lossy().into_owned(),
..Default::default()
};
config.models.insert(
"flux-vae".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(vae_path.to_string_lossy().into_owned()),
vae: Some(vae_path.to_string_lossy().into_owned()),
..Default::default()
},
);
config.models.insert(
"clip-l".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(format!("{}/clip-l/model.safetensors", models_dir.display())),
vae: Some(format!("{}/clip-l/model.safetensors", models_dir.display())),
..Default::default()
},
);
config.models.insert(
"t5-v1_1-xxl".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(format!(
"{}/t5-v1_1-xxl/t5xxl_fp16.safetensors",
models_dir.display()
)),
vae: Some(format!(
"{}/t5-v1_1-xxl/t5xxl_fp16.safetensors",
models_dir.display()
)),
..Default::default()
},
);
let entry =
flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
let synth = synthesize_catalog_config(&entry, models_dir, &config).unwrap();
assert_eq!(
synth.transformer.as_deref(),
primary_path.to_str(),
"transformer must point at the on-disk primary checkpoint"
);
assert_eq!(
synth.vae.as_deref(),
vae_path.to_str(),
"flux-vae companion must populate cfg.vae for transformer-only checkpoints",
);
unsafe {
match _saved {
Some(v) => std::env::set_var("MOLD_HOME", v),
None => std::env::remove_var("MOLD_HOME"),
}
}
}
#[test]
fn synthesize_catalog_config_uses_bundled_vae_when_present() {
let dir = tempfile::tempdir().unwrap();
let models_dir = dir.path();
let _saved = std::env::var("MOLD_HOME").ok();
unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
let primary_path = models_dir.join("cv-101010/flux/civitai/101010/flux_full.safetensors");
std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
write_safetensors_with_keys(
&primary_path,
&[
"model.diffusion_model.double_blocks.0.img_attn.proj.weight",
"first_stage_model.encoder.conv_in.weight",
],
);
let vae_path = models_dir.join("flux-vae/ae.safetensors");
std::fs::create_dir_all(vae_path.parent().unwrap()).unwrap();
std::fs::File::create(&vae_path).unwrap();
let mut config = mold_core::Config {
models_dir: models_dir.to_string_lossy().into_owned(),
..Default::default()
};
config.models.insert(
"flux-vae".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(vae_path.to_string_lossy().into_owned()),
vae: Some(vae_path.to_string_lossy().into_owned()),
..Default::default()
},
);
config.models.insert(
"clip-l".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(format!("{}/clip-l/model.safetensors", models_dir.display())),
vae: Some(format!("{}/clip-l/model.safetensors", models_dir.display())),
..Default::default()
},
);
config.models.insert(
"t5-v1_1-xxl".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(format!(
"{}/t5-v1_1-xxl/t5xxl_fp16.safetensors",
models_dir.display()
)),
vae: Some(format!(
"{}/t5-v1_1-xxl/t5xxl_fp16.safetensors",
models_dir.display()
)),
..Default::default()
},
);
let entry = flux_unet_only_catalog_entry("101010", "flux_full.safetensors");
let synth = synthesize_catalog_config(&entry, models_dir, &config).unwrap();
assert_eq!(synth.transformer.as_deref(), primary_path.to_str());
assert_eq!(
synth.vae.as_deref(),
primary_path.to_str(),
"bundled-VAE checkpoint must keep cfg.vae == primary; flux-vae companion is a no-op",
);
unsafe {
match _saved {
Some(v) => std::env::set_var("MOLD_HOME", v),
None => std::env::remove_var("MOLD_HOME"),
}
}
}
fn stub_flux_companion_paths_in_dir(
config: &mut mold_core::Config,
models_dir: &std::path::Path,
flux_vae_present: bool,
) {
let vae_path = models_dir.join("flux-vae/ae.safetensors");
std::fs::create_dir_all(vae_path.parent().unwrap()).unwrap();
if flux_vae_present {
std::fs::File::create(&vae_path).unwrap();
}
config.models.insert(
"flux-vae".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(vae_path.to_string_lossy().into_owned()),
vae: Some(vae_path.to_string_lossy().into_owned()),
..Default::default()
},
);
let clip_path = models_dir.join("clip-l/model.safetensors");
std::fs::create_dir_all(clip_path.parent().unwrap()).unwrap();
std::fs::File::create(&clip_path).unwrap();
config.models.insert(
"clip-l".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(clip_path.to_string_lossy().into_owned()),
vae: Some(clip_path.to_string_lossy().into_owned()),
clip_tokenizer: Some(format!("{}/clip-l/tokenizer.json", models_dir.display())),
..Default::default()
},
);
let t5_path = models_dir.join("t5-v1_1-xxl/t5xxl_fp16.safetensors");
std::fs::create_dir_all(t5_path.parent().unwrap()).unwrap();
std::fs::File::create(&t5_path).unwrap();
config.models.insert(
"t5-v1_1-xxl".into(),
mold_core::ModelConfig {
family: Some("companion".into()),
transformer: Some(t5_path.to_string_lossy().into_owned()),
vae: Some(t5_path.to_string_lossy().into_owned()),
t5_tokenizer: Some(format!(
"{}/t5-v1_1-xxl/tokenizer.json",
models_dir.display()
)),
..Default::default()
},
);
}
#[test]
fn synthesis_intent_is_consistent_before_and_after_download() {
let dir = tempfile::tempdir().unwrap();
let models_dir = dir.path();
let entry =
flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
let intent_absent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
let primary_path = models_dir
.join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
write_safetensors_with_keys(
&primary_path,
&[
"double_blocks.0.img_attn.proj.weight",
"single_blocks.0.linear1.weight",
"img_in.weight",
],
);
let intent_present =
mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
assert_eq!(
intent_absent, intent_present,
"intent synthesis must be pure — independent of disk state"
);
}
#[test]
fn resolve_intent_picks_flux_vae_companion_when_primary_is_transformer_only() {
let dir = tempfile::tempdir().unwrap();
let models_dir = dir.path();
let _saved = std::env::var("MOLD_HOME").ok();
unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
let primary_path = models_dir
.join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
write_safetensors_with_keys(
&primary_path,
&[
"double_blocks.0.img_attn.proj.weight",
"single_blocks.0.linear1.weight",
"img_in.weight",
],
);
let mut config = mold_core::Config {
models_dir: models_dir.to_string_lossy().into_owned(),
..Default::default()
};
stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
let entry =
flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
let cfg = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap();
let vae_path = models_dir.join("flux-vae/ae.safetensors");
assert_eq!(cfg.vae.as_deref(), vae_path.to_str());
assert_eq!(cfg.transformer.as_deref(), primary_path.to_str());
unsafe {
match _saved {
Some(v) => std::env::set_var("MOLD_HOME", v),
None => std::env::remove_var("MOLD_HOME"),
}
}
}
#[test]
fn resolve_intent_returns_error_naming_missing_required_companion() {
let dir = tempfile::tempdir().unwrap();
let models_dir = dir.path();
let primary_path = models_dir
.join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
write_safetensors_with_keys(
&primary_path,
&["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
);
let _saved = std::env::var("MOLD_HOME").ok();
unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
let config = mold_core::Config {
models_dir: models_dir.to_string_lossy().into_owned(),
..Default::default()
};
let entry =
flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
let err = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("t5-v1_1-xxl") || msg.contains("clip-l") || msg.contains("flux-vae"),
"error must name a specific missing companion, got: {msg}"
);
assert!(matches!(err, ResolveError::CompanionConfigMissing { .. }));
unsafe {
match _saved {
Some(v) => std::env::set_var("MOLD_HOME", v),
None => std::env::remove_var("MOLD_HOME"),
}
}
}
#[test]
fn cv_id_resolves_when_files_arrive_after_initial_request() {
let dir = tempfile::tempdir().unwrap();
let models_dir = dir.path();
let _saved = std::env::var("MOLD_HOME").ok();
unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
let entry =
flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
let mut config = mold_core::Config {
models_dir: models_dir.to_string_lossy().into_owned(),
..Default::default()
};
stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
let cfg_first = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap();
let primary_path = models_dir
.join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
let vae_path = models_dir.join("flux-vae/ae.safetensors");
assert_eq!(cfg_first.transformer.as_deref(), primary_path.to_str());
assert_eq!(cfg_first.vae.as_deref(), vae_path.to_str());
std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
write_safetensors_with_keys(
&primary_path,
&["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
);
let cfg_second = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap();
assert_eq!(cfg_second.transformer.as_deref(), primary_path.to_str());
assert_eq!(cfg_second.vae.as_deref(), vae_path.to_str());
unsafe {
match _saved {
Some(v) => std::env::set_var("MOLD_HOME", v),
None => std::env::remove_var("MOLD_HOME"),
}
}
}
#[test]
fn live_error_to_install_error_maps_404_to_not_found() {
let upstream = mold_catalog::live::LiveSearchError::Upstream {
host: "civitai.com",
status: 404,
body: "{\"error\": \"not found\"}".to_string(),
};
let mapped = live_error_to_install_error("cv:42", &upstream);
assert!(matches!(mapped, mold_core::InstallError::NotFound(_)));
}
#[test]
fn live_error_to_install_error_maps_5xx_to_recipe_malformed() {
let upstream = mold_catalog::live::LiveSearchError::Upstream {
host: "civitai.com",
status: 500,
body: "internal".into(),
};
let mapped = live_error_to_install_error("cv:42", &upstream);
assert!(matches!(
mapped,
mold_core::InstallError::RecipeMalformed(_)
));
}
#[test]
fn install_error_to_api_error_maps_network_to_502() {
let err = mold_core::InstallError::Network("dns: civitai.com".into());
let api = install_error_to_api_error(&err);
assert!(api.error.contains("network unreachable"));
}
#[test]
fn install_error_to_api_error_maps_not_found_to_404() {
let err = mold_core::InstallError::NotFound("cv:99999999".into());
let api = install_error_to_api_error(&err);
assert_eq!(api.code, "MODEL_NOT_FOUND");
}
const BUDGET_FRACTION_NUMERATOR: u64 = 9;
const BUDGET_FRACTION_DENOMINATOR: u64 = 10;
fn expected_budget_cap(available: u64) -> u64 {
available * BUDGET_FRACTION_NUMERATOR / BUDGET_FRACTION_DENOMINATOR
}
#[test]
fn preflight_error_message_states_correct_budget_cap() {
let peak: u64 = 24_400_000_000;
let available: u64 = 25_300_000_000;
let cap = expected_budget_cap(available);
assert!(
peak > cap,
"test invariant: peak ({peak}) must exceed cap ({cap})"
);
let result = check_model_memory_budget(
"qwen-image:q8",
peak,
available,
"Try a smaller variant (e.g. ':q5' / ':q4'), enable --offload (FLUX), or close other GPU apps.",
);
assert!(result.is_err(), "expected rejection, got Ok");
let err = result.unwrap_err();
let msg = &err.error;
let cap_gb = cap as f64 / 1_000_000_000.0;
let cap_str = format!("{cap_gb:.1}");
assert!(
msg.contains("budget cap"),
"error must mention 'budget cap', got: {msg}"
);
assert!(
msg.contains(&cap_str),
"error must contain the cap value ~{cap_str} GB, got: {msg}"
);
let available_gb = available as f64 / 1_000_000_000.0;
let available_str = format!("{available_gb:.1}");
let _ = available_str; }
#[test]
fn preflight_error_message_does_not_imply_peak_less_than_available() {
let scenarios: &[(f64, f64)] = &[
(24.4, 25.3), (19.0, 20.0), (10.0, 10.5), (30.0, 32.0), (9.1, 10.0), ];
for &(peak_gb, available_gb) in scenarios {
let peak = (peak_gb * 1_000_000_000.0) as u64;
let available = (available_gb * 1_000_000_000.0) as u64;
let cap = expected_budget_cap(available);
if peak <= cap {
continue;
}
let result =
check_model_memory_budget("test-model", peak, available, "Try a smaller variant.");
assert!(
result.is_err(),
"expected rejection for peak={peak_gb} available={available_gb}, got Ok"
);
let msg = result.unwrap_err().error;
let cap_gb = cap as f64 / 1_000_000_000.0;
let cap_str = format!("{cap_gb:.1}");
assert!(
msg.contains("budget cap"),
"scenario peak={peak_gb} available={available_gb}: \
message must say 'budget cap', got: {msg}"
);
assert!(
msg.contains(&cap_str),
"scenario peak={peak_gb} available={available_gb}: \
message must include cap={cap_str}, got: {msg}"
);
}
}
fn ltx_video_13b_paths(
transformer_gb: u64,
vae_gb: u64,
t5_gb: u64,
) -> (tempfile::TempDir, ModelPaths) {
let dir = tempfile::tempdir().expect("tempdir");
let mk = |name: &str, sz: u64| {
let p = dir.path().join(name);
let f = std::fs::File::create(&p).unwrap();
f.set_len(sz * GB).unwrap();
p
};
let transformer = mk("ltx-video-0.9.8-13b-dev_fp16.safetensors", transformer_gb);
let vae = mk("ltx-video-vae.safetensors", vae_gb);
let t5 = mk("t5xxl_fp16.safetensors", t5_gb);
let paths = ModelPaths {
transformer,
transformer_shards: Vec::new(),
vae,
spatial_upscaler: None,
temporal_upscaler: None,
distilled_lora: None,
t5_encoder: Some(t5),
clip_encoder: None,
t5_tokenizer: None,
clip_tokenizer: None,
clip_encoder_2: None,
clip_tokenizer_2: None,
text_encoder_files: Vec::new(),
text_tokenizer: None,
decoder: None,
};
(dir, paths)
}
#[test]
fn preflight_rejects_ltx_video_13b_at_768x512x25_on_24gb_card() {
let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
let hint = ActivationHint {
width: 768,
height: 512,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::LtxVideo,
};
let result = preflight_memory_guard_with_available(
"ltx-video-0.9.8-13b-dev:bf16",
&paths,
0,
24 * GB,
Some(hint),
);
assert!(
result.is_err(),
"13B LTX-Video BF16 (26 GB transformer) must be rejected on a 24 GB card — \
the transformer is not streamed and its full weight must be counted, \
got {result:?}",
);
let err = result.unwrap_err();
assert!(
err.error.contains("frames") || err.error.contains("width"),
"rejection message must suggest reducing frames or resolution, got: {}",
err.error,
);
}
#[test]
fn preflight_estimate_for_ltx_video_13b_within_expected_range() {
let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
let expected_gb = 29u64;
let peak = mold_inference::device::estimate_peak_memory(
&paths,
mold_inference::LoadStrategy::Sequential,
);
let peak_gb = peak / GB;
assert!(
peak_gb >= expected_gb.saturating_sub(3),
"peak estimate ({peak_gb} GB) is unexpectedly low — LTX-Video 13B BF16 \
sequential estimate should be ≥ {} GB",
expected_gb.saturating_sub(3),
);
assert!(
peak_gb <= expected_gb + 3,
"peak estimate ({peak_gb} GB) is unexpectedly high for 26+1+10 GB layout \
— should be ≤ {} GB",
expected_gb + 3,
);
}
#[test]
fn activation_family_for_ltx_video_is_non_streaming() {
let family = mold_inference::device::activation_family_for("ltx-video");
assert_eq!(
family,
ActivationFamily::LtxVideo,
"ltx-video slug must map to LtxVideo (non-streaming, full-weight load)"
);
assert!(
!family.streaming_transformer(),
"LtxVideo must NOT be treated as a streaming transformer — \
it loads the entire weight file into VRAM at generate time"
);
assert!(
mold_inference::device::activation_family_for("ltx2").streaming_transformer(),
"ltx2 must still map to the streaming family"
);
}
#[test]
fn preflight_rejection_message_for_ltx_video_suggests_frames_or_resolution() {
let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
let hint = ActivationHint {
width: 768,
height: 512,
batch: 1,
dtype_bytes: 2,
family: ActivationFamily::LtxVideo,
};
let result = preflight_memory_guard_with_available(
"ltx-video-0.9.8-13b-dev:bf16",
&paths,
0,
24 * GB,
Some(hint),
);
let err = result.expect_err("must reject");
assert!(
err.error.contains("frames") || err.error.contains("width"),
"LTX-Video rejection message must suggest reducing frames or \
width/height (not --offload), got: {}",
err.error,
);
assert!(
!err.error.contains("--offload"),
"LTX-Video rejection must not mention --offload (FLUX-only flag), \
got: {}",
err.error,
);
}
}